mirror of
https://github.com/midou36o/midou36o.github.io
synced 2026-11-13 22:23:12 +05:30
a
This commit is contained in:
1 parent
a18ac39163
commit
da00380eb9
8 files changed
+411
-121
No files matched your search
@@ -33,6 +33,9 @@
|
||||
|
||||
<style>
|
||||
.scene-background {
|
||||
--scene-surface: #d1d5db;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
inset-inline-start: auto;
|
||||
@@ -45,6 +48,45 @@
|
||||
transition: opacity var(--programmer-restore-duration, 0s) ease;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.scene-background {
|
||||
--scene-surface: #000;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dimming stays inside the background layer, underneath every HTML element */
|
||||
.scene-background :global(.scene-dimmer) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.scene-background :global(.scene-dimmer-region) {
|
||||
--feather: 12px;
|
||||
position: absolute;
|
||||
box-sizing: content-box;
|
||||
padding: var(--feather);
|
||||
transform: translate(calc(-1 * var(--feather)), calc(-1 * var(--feather)));
|
||||
background: var(--scene-surface);
|
||||
opacity: calc(0.94 * var(--text-opacity, 1));
|
||||
mask-image:
|
||||
linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
#000 var(--feather),
|
||||
#000 calc(100% - var(--feather)),
|
||||
transparent
|
||||
),
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
#000 var(--feather),
|
||||
#000 calc(100% - var(--feather)),
|
||||
transparent
|
||||
);
|
||||
mask-composite: intersect;
|
||||
}
|
||||
|
||||
.scene-background::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -134,8 +176,12 @@
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
/* Keep cached frames at full height while the worker catches up with a changing width */
|
||||
flex-shrink: 0;
|
||||
width: auto;
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
touch-action: none;
|
||||
cursor: default;
|
||||
opacity: 0;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type BackgroundSize
|
||||
} from './messages.ts';
|
||||
import type { Obstacle } from './types.ts';
|
||||
import { TextDimmer } from './dimmer.ts';
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
|
||||
|
||||
@@ -23,6 +24,7 @@ export class BackgroundController {
|
||||
private readonly observer: ResizeObserver;
|
||||
private readonly contentObserver: ResizeObserver;
|
||||
private readonly mutations: MutationObserver;
|
||||
private readonly dimmer: TextDimmer;
|
||||
private readonly darkMode = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
private readonly reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
private environment: BackgroundEnvironment = {
|
||||
@@ -34,6 +36,8 @@ export class BackgroundController {
|
||||
private obstacles: Obstacle[] = [];
|
||||
private size: BackgroundSize = { width: 0, height: 0, dpr: 1 };
|
||||
private grid = { width: 0, height: 0 };
|
||||
private presentedGridWidth = 0;
|
||||
private presentedGridHeight = 0;
|
||||
private worker: Worker | null = null;
|
||||
private bitmapContext: ImageBitmapRenderingContext | null = null;
|
||||
private drawingContext: CanvasRenderingContext2D | null = null;
|
||||
@@ -58,11 +62,14 @@ export class BackgroundController {
|
||||
private readonly frame: HTMLElement,
|
||||
private readonly onPerformanceReady: () => void
|
||||
) {
|
||||
this.dimmer = new TextDimmer(frame);
|
||||
this.updateInteractivity();
|
||||
this.observer = new ResizeObserver(this.resize);
|
||||
this.observer.observe(frame);
|
||||
this.contentObserver = new ResizeObserver(this.measureObstacles);
|
||||
this.mutations = new MutationObserver(this.observeContent);
|
||||
this.mutations = new MutationObserver((records) => {
|
||||
if (records.some((record) => !frame.contains(record.target))) this.observeContent();
|
||||
});
|
||||
const options = { signal: this.listeners.signal };
|
||||
canvas.addEventListener('pointermove', this.pointerMove, options);
|
||||
canvas.addEventListener('pointerdown', this.pointerDown, options);
|
||||
@@ -163,11 +170,13 @@ export class BackgroundController {
|
||||
let failed = false;
|
||||
try {
|
||||
if (Math.min(window.devicePixelRatio || 1, 2) !== this.size.dpr) this.resize();
|
||||
if (
|
||||
this.worker === worker &&
|
||||
bitmap.width === this.canvas.width &&
|
||||
bitmap.height === this.canvas.height
|
||||
) {
|
||||
if (this.worker === worker) {
|
||||
// Keep completed frames visible while the worker catches up with a changing size
|
||||
// Higher-resolution pixels retain crisp, non-AA edges on the smaller models.
|
||||
if (this.canvas.width !== bitmap.width) this.canvas.width = bitmap.width;
|
||||
if (this.canvas.height !== bitmap.height) this.canvas.height = bitmap.height;
|
||||
this.presentedGridWidth = message.gridWidth;
|
||||
this.presentedGridHeight = message.gridHeight;
|
||||
if (this.bitmapContext) this.bitmapContext.transferFromImageBitmap(bitmap);
|
||||
else if (this.drawingContext) {
|
||||
this.drawingContext.imageSmoothingEnabled = false;
|
||||
@@ -292,11 +301,9 @@ export class BackgroundController {
|
||||
if (width === this.size.width && height === this.size.height && dpr === this.size.dpr) return;
|
||||
this.endDrag();
|
||||
this.size = { width, height, dpr };
|
||||
this.canvas.width = Math.round(width * dpr);
|
||||
this.canvas.height = Math.round(height * dpr);
|
||||
// Higher-resolution pixels retain crisp, non-AA edges on the smaller models.
|
||||
this.grid = simulationSize(this.size);
|
||||
this.obstacles = this.collectObstacles();
|
||||
this.dimmer.update();
|
||||
this.post({ type: 'resize', size: this.size, obstacles: this.obstacles });
|
||||
this.fallback?.update(this.size, this.obstacles, this.darkMode.matches);
|
||||
};
|
||||
@@ -358,6 +365,7 @@ export class BackgroundController {
|
||||
private measureObstacles = () => {
|
||||
if (this.disposed || !this.grid.width || !this.grid.height) return;
|
||||
const obstacles = this.collectObstacles();
|
||||
this.dimmer.update();
|
||||
if (
|
||||
this.obstacles.length === obstacles.length &&
|
||||
obstacles.every((obstacle, i) => {
|
||||
@@ -412,15 +420,18 @@ export class BackgroundController {
|
||||
|
||||
private pointerPosition(event: PointerEvent): void {
|
||||
const bounds = this.canvas.getBoundingClientRect();
|
||||
const cellScale = Math.min(bounds.width / this.grid.width, bounds.height / this.grid.height);
|
||||
const offsetX = (bounds.width - this.grid.width * cellScale) / 2;
|
||||
const offsetY = (bounds.height - this.grid.height * cellScale) / 2;
|
||||
this.pointerX = clamp((event.clientX - bounds.left - offsetX) / cellScale, 0, this.grid.width);
|
||||
this.pointerY = clamp(
|
||||
(bounds.bottom - event.clientY - offsetY) / cellScale,
|
||||
0,
|
||||
this.grid.height
|
||||
);
|
||||
const width = this.presentedGridWidth || this.grid.width;
|
||||
const height = this.presentedGridHeight || this.grid.height;
|
||||
const fitScale = Math.min(bounds.width / this.canvas.width, bounds.height / this.canvas.height);
|
||||
const cellScale = fitScale * Math.min(this.canvas.width / width, this.canvas.height / height);
|
||||
const offsetX = (bounds.width - width * cellScale) / 2;
|
||||
const offsetY = (bounds.height - height * cellScale) / 2;
|
||||
this.pointerX =
|
||||
(clamp((event.clientX - bounds.left - offsetX) / cellScale, 0, width) * this.grid.width) /
|
||||
width;
|
||||
this.pointerY =
|
||||
(clamp((bounds.bottom - event.clientY - offsetY) / cellScale, 0, height) * this.grid.height) /
|
||||
height;
|
||||
}
|
||||
|
||||
private flushPointerMove = () => {
|
||||
@@ -499,6 +510,7 @@ export class BackgroundController {
|
||||
this.observer.disconnect();
|
||||
this.contentObserver.disconnect();
|
||||
this.mutations.disconnect();
|
||||
this.dimmer.destroy();
|
||||
this.endDrag();
|
||||
this.stopWorker();
|
||||
this.bitmap?.close();
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
type Bounds = { left: number; top: number; right: number; bottom: number; opacity: number };
|
||||
type Region = {
|
||||
element: HTMLDivElement;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
const EMPTY: Bounds = { left: 0, top: 0, right: 0, bottom: 0, opacity: 0 };
|
||||
const HAS_TEXT = /\S/;
|
||||
|
||||
export class TextDimmer {
|
||||
private readonly layer = document.createElement('div');
|
||||
private readonly listeners = new AbortController();
|
||||
private readonly mutations: MutationObserver;
|
||||
private readonly root: HTMLElement | null;
|
||||
private readonly walker: TreeWalker | null;
|
||||
private readonly range = document.createRange();
|
||||
private readonly clips = new Map<Element, Bounds>();
|
||||
private readonly rootBounds: Bounds = { ...EMPTY, opacity: 1 };
|
||||
private readonly rectangles: DOMRect[] = [];
|
||||
private readonly opacities: number[] = [];
|
||||
private readonly regions: Region[] = [];
|
||||
private animation = 0;
|
||||
|
||||
constructor(private readonly frame: HTMLElement) {
|
||||
this.root = frame.parentElement?.querySelector<HTMLElement>('.site-content') ?? null;
|
||||
this.layer.className = 'scene-dimmer';
|
||||
this.layer.ariaHidden = 'true';
|
||||
frame.append(this.layer);
|
||||
this.walker = this.root
|
||||
? document.createTreeWalker(this.root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: this.acceptNode
|
||||
})
|
||||
: null;
|
||||
this.mutations = new MutationObserver(this.trackAnimation);
|
||||
if (this.root) {
|
||||
this.mutations.observe(this.root, {
|
||||
attributes: true,
|
||||
subtree: true,
|
||||
attributeFilter: ['class', 'style', 'hidden', 'data-phase']
|
||||
});
|
||||
}
|
||||
const options = { capture: true, signal: this.listeners.signal };
|
||||
for (const event of [
|
||||
'introstart',
|
||||
'introend',
|
||||
'outrostart',
|
||||
'outroend',
|
||||
'animationstart',
|
||||
'animationend',
|
||||
'animationcancel',
|
||||
'transitionrun',
|
||||
'transitionend',
|
||||
'transitioncancel'
|
||||
]) {
|
||||
this.root?.addEventListener(event, this.trackAnimation, options);
|
||||
}
|
||||
this.trackAnimation();
|
||||
}
|
||||
|
||||
private clip(element: Element): Bounds {
|
||||
const cached = this.clips.get(element);
|
||||
if (cached) return cached;
|
||||
const parent = element.parentElement ? this.clip(element.parentElement) : EMPTY;
|
||||
const style = getComputedStyle(element);
|
||||
const opacity = parent.opacity * Number(style.opacity);
|
||||
let bounds = parent;
|
||||
if (style.display === 'none') bounds = EMPTY;
|
||||
else if (style.overflowX !== 'visible' || style.overflowY !== 'visible') {
|
||||
const rect = element.getBoundingClientRect();
|
||||
bounds = {
|
||||
left:
|
||||
style.overflowX === 'visible'
|
||||
? parent.left
|
||||
: Math.max(parent.left, rect.left + element.clientLeft),
|
||||
right:
|
||||
style.overflowX === 'visible'
|
||||
? parent.right
|
||||
: Math.min(parent.right, rect.left + element.clientLeft + element.clientWidth),
|
||||
top:
|
||||
style.overflowY === 'visible'
|
||||
? parent.top
|
||||
: Math.max(parent.top, rect.top + element.clientTop),
|
||||
bottom:
|
||||
style.overflowY === 'visible'
|
||||
? parent.bottom
|
||||
: Math.min(parent.bottom, rect.top + element.clientTop + element.clientHeight),
|
||||
opacity
|
||||
};
|
||||
} else if (opacity !== parent.opacity) bounds = { ...parent, opacity };
|
||||
this.clips.set(element, bounds);
|
||||
return bounds;
|
||||
}
|
||||
|
||||
private acceptNode = (node: Node): number => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return HAS_TEXT.test((node as Text).data)
|
||||
? NodeFilter.FILTER_ACCEPT
|
||||
: NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
const element = node as Element;
|
||||
if (element.matches('script, style')) return NodeFilter.FILTER_REJECT;
|
||||
const clip = this.clip(element);
|
||||
if (clip.left >= clip.right || clip.top >= clip.bottom) return NodeFilter.FILTER_REJECT;
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (
|
||||
rect.width &&
|
||||
rect.height &&
|
||||
(rect.right <= clip.left ||
|
||||
rect.left >= clip.right ||
|
||||
rect.bottom <= clip.top ||
|
||||
rect.top >= clip.bottom)
|
||||
) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
return NodeFilter.FILTER_SKIP;
|
||||
};
|
||||
|
||||
update(): void {
|
||||
if (!this.root || !this.walker) return;
|
||||
const frame = this.frame.getBoundingClientRect();
|
||||
this.clips.clear();
|
||||
this.rootBounds.left = frame.left;
|
||||
this.rootBounds.top = frame.top;
|
||||
this.rootBounds.right = frame.right;
|
||||
this.rootBounds.bottom = frame.bottom;
|
||||
this.rootBounds.opacity = Number(getComputedStyle(this.root).opacity);
|
||||
this.clips.set(this.root, this.rootBounds);
|
||||
this.rectangles.length = 0;
|
||||
this.opacities.length = 0;
|
||||
this.walker.currentNode = this.root;
|
||||
let node: Node | null;
|
||||
while ((node = this.walker.nextNode())) {
|
||||
const text = node as Text;
|
||||
const clip = this.clip(text.parentElement!);
|
||||
this.range.setStart(text, text.data.search(HAS_TEXT));
|
||||
this.range.setEnd(text, text.data.trimEnd().length);
|
||||
// Text fragments exclude empty layout space, and scroll clipping excludes hidden rows
|
||||
for (const rect of this.range.getClientRects()) {
|
||||
const left = Math.max(rect.left, clip.left),
|
||||
right = Math.min(rect.right, clip.right);
|
||||
const top = Math.max(rect.top, clip.top),
|
||||
bottom = Math.min(rect.bottom, clip.bottom);
|
||||
if (left >= right || top >= bottom) continue;
|
||||
const previous = this.rectangles.at(-1);
|
||||
if (
|
||||
previous &&
|
||||
this.opacities.at(-1) === clip.opacity &&
|
||||
Math.abs(previous.top - top) < 2 &&
|
||||
Math.abs(previous.bottom - bottom) < 2 &&
|
||||
left <= previous.right + 8 &&
|
||||
right >= previous.left - 8
|
||||
) {
|
||||
const x = Math.min(previous.left, left),
|
||||
y = Math.min(previous.top, top);
|
||||
previous.width = Math.max(previous.right, right) - x;
|
||||
previous.height = Math.max(previous.bottom, bottom) - y;
|
||||
previous.x = x;
|
||||
previous.y = y;
|
||||
} else {
|
||||
rect.x = left;
|
||||
rect.y = top;
|
||||
rect.width = right - left;
|
||||
rect.height = bottom - top;
|
||||
this.rectangles.push(rect);
|
||||
this.opacities.push(clip.opacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < this.rectangles.length; i++) {
|
||||
const rect = this.rectangles[i];
|
||||
let region = this.regions[i];
|
||||
if (!region) {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'scene-dimmer-region';
|
||||
this.layer.append(element);
|
||||
region = { element, left: NaN, top: NaN, width: NaN, height: NaN, opacity: NaN };
|
||||
this.regions.push(region);
|
||||
}
|
||||
region.element.hidden = false;
|
||||
const opacity = this.opacities[i];
|
||||
if (region.opacity !== opacity) {
|
||||
// Follow the text's fade directly rather than starting a second, lagging transition
|
||||
region.opacity = opacity;
|
||||
region.element.style.setProperty('--text-opacity', String(opacity));
|
||||
}
|
||||
const left = rect.left - frame.left,
|
||||
top = rect.top - frame.top;
|
||||
if (region.left !== left) region.element.style.left = `${(region.left = left)}px`;
|
||||
if (region.top !== top) region.element.style.top = `${(region.top = top)}px`;
|
||||
if (region.width !== rect.width)
|
||||
region.element.style.width = `${(region.width = rect.width)}px`;
|
||||
if (region.height !== rect.height)
|
||||
region.element.style.height = `${(region.height = rect.height)}px`;
|
||||
}
|
||||
for (let i = this.rectangles.length; i < this.regions.length; i++)
|
||||
this.regions[i].element.hidden = true;
|
||||
}
|
||||
|
||||
private trackAnimation = () => {
|
||||
if (!this.animation) this.animation = requestAnimationFrame(this.animate);
|
||||
};
|
||||
|
||||
private animate = () => {
|
||||
this.animation = 0;
|
||||
this.update();
|
||||
if (
|
||||
this.root
|
||||
?.getAnimations({ subtree: true })
|
||||
.some(
|
||||
(animation) =>
|
||||
animation.playState === 'running' &&
|
||||
animation.effect?.getComputedTiming().iterations !== Infinity
|
||||
)
|
||||
) {
|
||||
this.trackAnimation();
|
||||
}
|
||||
};
|
||||
|
||||
destroy(): void {
|
||||
this.listeners.abort();
|
||||
this.mutations.disconnect();
|
||||
if (this.animation) cancelAnimationFrame(this.animation);
|
||||
this.layer.remove();
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,8 @@ export class BackgroundEngine {
|
||||
this.pendingObstacles = null;
|
||||
} else {
|
||||
this.resize(command.size, command.obstacles);
|
||||
this.present();
|
||||
// Animated resizes join the next physics tick instead of canceling its timer
|
||||
if (this.still) this.present();
|
||||
this.schedule();
|
||||
}
|
||||
break;
|
||||
@@ -102,7 +103,7 @@ export class BackgroundEngine {
|
||||
else if (this.pendingFrame) this.pendingObstacles = command.obstacles;
|
||||
else {
|
||||
this.updateObstacles(command.obstacles);
|
||||
this.present();
|
||||
if (this.still) this.present();
|
||||
this.schedule();
|
||||
}
|
||||
break;
|
||||
@@ -124,7 +125,7 @@ export class BackgroundEngine {
|
||||
this.pendingObstacles = null;
|
||||
this.updateObstacles(obstacles);
|
||||
}
|
||||
this.present();
|
||||
if (this.still) this.present();
|
||||
this.schedule();
|
||||
break;
|
||||
default:
|
||||
@@ -255,7 +256,15 @@ export class BackgroundEngine {
|
||||
const bitmap = this.canvas.transferToImageBitmap();
|
||||
try {
|
||||
this.pendingFrame = true;
|
||||
this.emit({ type: 'frame', bitmap }, [bitmap]);
|
||||
this.emit(
|
||||
{
|
||||
type: 'frame',
|
||||
bitmap,
|
||||
gridWidth: this.fluid.width,
|
||||
gridHeight: this.fluid.height
|
||||
},
|
||||
[bitmap]
|
||||
);
|
||||
this.dirty = false;
|
||||
} catch (error) {
|
||||
this.pendingFrame = false;
|
||||
|
||||
@@ -35,7 +35,7 @@ export type BackgroundCommand =
|
||||
| { type: 'destroy' };
|
||||
|
||||
export type BackgroundEvent =
|
||||
| { type: 'frame'; bitmap: ImageBitmap }
|
||||
| { type: 'frame'; bitmap: ImageBitmap; gridWidth: number; gridHeight: number }
|
||||
| { type: 'ready' }
|
||||
| { type: 'state'; interactive: boolean; cursor: 'default' | 'grab' | 'grabbing' }
|
||||
| { type: 'error'; message: string };
|
||||
@@ -243,9 +243,9 @@ function createCanvasRenderer(
|
||||
const spacing = SETTINGS.spacing * dpr;
|
||||
const startX = (w * 0.5) % spacing;
|
||||
const startY = (h * 0.5) % spacing;
|
||||
const warmRed = darkMode ? 255 : 180;
|
||||
const warmGreen = darkMode ? 199 : 83;
|
||||
const warmBlue = darkMode ? 31 : 9;
|
||||
const warmRed = 255;
|
||||
const warmGreen = 199;
|
||||
const warmBlue = 31;
|
||||
const cellScale = Math.min(w / width, h / height);
|
||||
const offsetX = (w - width * cellScale) * 0.5;
|
||||
const offsetY = (h - height * cellScale) * 0.5;
|
||||
@@ -253,21 +253,16 @@ function createCanvasRenderer(
|
||||
for (let xx = startX; xx < w; xx += spacing) {
|
||||
const x = (xx - offsetX) / cellScale;
|
||||
const y = (h - yy - offsetY) / cellScale;
|
||||
const haze = hazeAt(x, y, scene.lamp, time);
|
||||
const haze = darkMode ? hazeAt(x, y, scene.lamp, time) : 0;
|
||||
if (haze >= 1 / 255) {
|
||||
pixelCircle(
|
||||
xx,
|
||||
yy,
|
||||
spacing * 0.13,
|
||||
`rgba(${warmRed},${darkMode ? 166 : warmGreen},${warmBlue},${haze})`
|
||||
);
|
||||
pixelCircle(xx, yy, spacing * 0.13, `rgba(${warmRed},166,${warmBlue},${haze})`);
|
||||
}
|
||||
const bottomBed = Math.exp(-(1 - yy / h) * 32);
|
||||
const density = Math.max(bilinear(fluid.density, x, y, width, height), bottomBed);
|
||||
const sizeFactor = smoothstep(0.015, 0.92, clamp(density * SETTINGS.growth, 0, 1));
|
||||
const radius = spacing * 0.43 * sizeFactor;
|
||||
if (sizeFactor === 0) continue;
|
||||
const warmth = illumination(x, y, scene.lamp);
|
||||
const warmth = darkMode ? illumination(x, y, scene.lamp) : 0;
|
||||
pixelCircle(
|
||||
xx,
|
||||
yy,
|
||||
@@ -285,7 +280,7 @@ function createCanvasRenderer(
|
||||
const brightness = darkMode ? shade : 1;
|
||||
ctx.globalAlpha = scene.sparkInk[i] || darkMode ? 1 : shade;
|
||||
const heat = Math.round((scene.sparkInk[i] / 255) * 4) / 4;
|
||||
const warmth = scene.warmth[i];
|
||||
const warmth = darkMode ? scene.warmth[i] : 0;
|
||||
ctx.fillStyle = scene.sparkInk[i]
|
||||
? `rgb(255,${Math.round(255 * (0.4 + 0.45 * heat))},${Math.round(255 * (0.06 + 0.45 * heat))})`
|
||||
: `rgb(${Math.round((20 + (warmRed - 20) * warmth) * brightness)},${Math.round((108 + (warmGreen - 108) * warmth) * brightness)},${Math.round((255 + (warmBlue - 255) * warmth) * brightness)})`;
|
||||
|
||||
+72
-76
@@ -155,7 +155,7 @@ export class SolidScene implements SceneState {
|
||||
for (let b = a + 1; b < this.models.length; b++)
|
||||
this.collide(this.models[a], this.models[b], scale);
|
||||
}
|
||||
for (const model of this.models) this.collideObstacles(model, scale);
|
||||
for (const model of this.models) this.repelObstacles(model, scale, step);
|
||||
remaining -= step;
|
||||
} while (remaining > 1e-8);
|
||||
this.lightScene(seconds, scale);
|
||||
@@ -270,7 +270,7 @@ export class SolidScene implements SceneState {
|
||||
this.lamp.x = x / count;
|
||||
this.lamp.y = y / count;
|
||||
this.lamp.z = z / count;
|
||||
this.lamp.radius = Math.min(this.width, this.height) * 0.3;
|
||||
this.lamp.radius = scale * 1.5;
|
||||
const decay = Math.exp(-2.2 * seconds);
|
||||
// Bulb wires stay yellow. Other wireframes cool back to blue after leaving its light.
|
||||
for (const model of this.models) {
|
||||
@@ -344,7 +344,12 @@ export class SolidScene implements SceneState {
|
||||
}
|
||||
}
|
||||
|
||||
private findContact(a: ProjectedHull, b: ProjectedHull, bodyA?: Body, bodyB?: Body): boolean {
|
||||
private findOverlap(
|
||||
a: ProjectedHull,
|
||||
b: ProjectedHull,
|
||||
padding = 0,
|
||||
nearestSide = true
|
||||
): boolean {
|
||||
let depth = Infinity,
|
||||
nx = 0,
|
||||
ny = 0;
|
||||
@@ -377,19 +382,29 @@ export class SolidScene implements SceneState {
|
||||
minB = Math.min(minB, d);
|
||||
maxB = Math.max(maxB, d);
|
||||
}
|
||||
const forward = maxA - minB,
|
||||
backward = maxB - minA,
|
||||
const forward = maxA - minB + padding,
|
||||
backward = maxB - minA + padding,
|
||||
overlap = Math.min(forward, backward);
|
||||
if (overlap <= 0) return false;
|
||||
if (overlap < depth) {
|
||||
depth = overlap;
|
||||
const sign = !bodyB && backward < forward ? -1 : 1;
|
||||
const sign = nearestSide && backward < forward ? -1 : 1;
|
||||
nx = x * sign;
|
||||
ny = y * sign;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bodyA && bodyB && nx * (bodyB.x - bodyA.x) + ny * (bodyB.y - bodyA.y) < 0) {
|
||||
this.contact.nx = nx;
|
||||
this.contact.ny = ny;
|
||||
this.contact.depth = depth;
|
||||
return true;
|
||||
}
|
||||
|
||||
private findContact(a: ProjectedHull, b: ProjectedHull, bodyA: Body, bodyB: Body): boolean {
|
||||
// Mesh-pair normals follow the body centers, not the nearest HTML escape face
|
||||
if (!this.findOverlap(a, b, 0, false)) return false;
|
||||
let { nx, ny } = this.contact;
|
||||
if (nx * (bodyB.x - bodyA.x) + ny * (bodyB.y - bodyA.y) < 0) {
|
||||
nx = -nx;
|
||||
ny = -ny;
|
||||
}
|
||||
@@ -401,11 +416,7 @@ export class SolidScene implements SceneState {
|
||||
na = 0,
|
||||
nb = 0,
|
||||
maxA = -Infinity,
|
||||
minB = Infinity,
|
||||
minTA = Infinity,
|
||||
maxTA = -Infinity,
|
||||
minTB = Infinity,
|
||||
maxTB = -Infinity;
|
||||
minB = Infinity;
|
||||
for (let i = 0; i < a.hullCount; i++) {
|
||||
const k = a.hull[i] * 3;
|
||||
maxA = Math.max(maxA, a.projected[k] * nx + a.projected[k + 1] * ny);
|
||||
@@ -419,11 +430,6 @@ export class SolidScene implements SceneState {
|
||||
if (maxA - a.projected[k] * nx - a.projected[k + 1] * ny < 0.5) {
|
||||
ax += a.projected[k];
|
||||
ay += a.projected[k + 1];
|
||||
if (!bodyB) {
|
||||
const tangent = -a.projected[k] * ny + a.projected[k + 1] * nx;
|
||||
minTA = Math.min(minTA, tangent);
|
||||
maxTA = Math.max(maxTA, tangent);
|
||||
}
|
||||
na++;
|
||||
}
|
||||
}
|
||||
@@ -432,26 +438,13 @@ export class SolidScene implements SceneState {
|
||||
if (b.projected[k] * nx + b.projected[k + 1] * ny - minB < 0.5) {
|
||||
bx += b.projected[k];
|
||||
by += b.projected[k + 1];
|
||||
if (!bodyB) {
|
||||
const tangent = -b.projected[k] * ny + b.projected[k + 1] * nx;
|
||||
minTB = Math.min(minTB, tangent);
|
||||
maxTB = Math.max(maxTB, tangent);
|
||||
}
|
||||
nb++;
|
||||
}
|
||||
}
|
||||
this.contact.nx = nx;
|
||||
this.contact.ny = ny;
|
||||
this.contact.depth = depth;
|
||||
this.contact.x = (ax / na + bx / nb) * 0.5;
|
||||
this.contact.y = (ay / na + by / nb) * 0.5;
|
||||
if (!bodyB) {
|
||||
// Keep a long text face's contact on the mesh, not at the text line's midpoint.
|
||||
const tangent = (Math.max(minTA, minTB) + Math.min(maxTA, maxTB)) * 0.5,
|
||||
normal = (maxA + minB) * 0.5;
|
||||
this.contact.x = nx * normal - ny * tangent;
|
||||
this.contact.y = ny * normal + nx * tangent;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -523,61 +516,64 @@ export class SolidScene implements SceneState {
|
||||
projectMesh(modelB.mesh, b, scale);
|
||||
}
|
||||
|
||||
private collideObstacles(model: Model, scale: number): void {
|
||||
private repelObstacles(model: Model, scale: number, seconds: number): void {
|
||||
if (!this.obstacles.length) return;
|
||||
const a = model.body,
|
||||
inertia = scale * scale * 0.65;
|
||||
const mesh = model.mesh,
|
||||
body = model.body,
|
||||
range = scale * 0.45;
|
||||
let minX = Infinity,
|
||||
maxX = -Infinity,
|
||||
minY = Infinity,
|
||||
maxY = -Infinity;
|
||||
for (let i = 0; i < model.mesh.hullCount; i++) {
|
||||
const k = model.mesh.hull[i] * 3;
|
||||
minX = Math.min(minX, model.mesh.projected[k]);
|
||||
maxX = Math.max(maxX, model.mesh.projected[k]);
|
||||
minY = Math.min(minY, model.mesh.projected[k + 1]);
|
||||
maxY = Math.max(maxY, model.mesh.projected[k + 1]);
|
||||
for (let i = 0; i < mesh.hullCount; i++) {
|
||||
const k = mesh.hull[i] * 3;
|
||||
minX = Math.min(minX, mesh.projected[k]);
|
||||
maxX = Math.max(maxX, mesh.projected[k]);
|
||||
minY = Math.min(minY, mesh.projected[k + 1]);
|
||||
maxY = Math.max(maxY, mesh.projected[k + 1]);
|
||||
}
|
||||
let touching = false;
|
||||
let depth = 0,
|
||||
nx = 0,
|
||||
ny = 0;
|
||||
for (const obstacle of this.obstacles) {
|
||||
if (
|
||||
maxX <= obstacle.minX ||
|
||||
minX >= obstacle.maxX ||
|
||||
maxY <= obstacle.minY ||
|
||||
minY >= obstacle.maxY
|
||||
maxX + range <= obstacle.minX ||
|
||||
minX - range >= obstacle.maxX ||
|
||||
maxY + range <= obstacle.minY ||
|
||||
minY - range >= obstacle.maxY
|
||||
)
|
||||
continue;
|
||||
if (!this.findContact(model.mesh, obstacle)) continue;
|
||||
touching = true;
|
||||
const { nx, ny, x, y } = this.contact,
|
||||
rx = x - a.x,
|
||||
ry = y - a.y,
|
||||
spin = a.bz + a.wz,
|
||||
speed = -(a.vx - spin * ry) * nx - (a.vy + spin * rx) * ny,
|
||||
lever = rx * ny - ry * nx;
|
||||
if (speed >= 0) continue;
|
||||
// Text is an infinite-mass body. Only the moving mesh receives the reaction.
|
||||
const impulse = (-1.85 * speed) / (1 + (lever * lever) / inertia);
|
||||
this.emitSparks(x, y, nx, ny, impulse, scale);
|
||||
a.vx -= nx * impulse;
|
||||
a.vy -= ny * impulse;
|
||||
a.wz -= (lever * impulse) / inertia;
|
||||
const tx = -ny,
|
||||
ty = nx,
|
||||
tangentLever = rx * ty - ry * tx,
|
||||
slip = -(a.vx - (a.bz + a.wz) * ry) * tx - (a.vy + (a.bz + a.wz) * rx) * ty,
|
||||
friction = clamp(
|
||||
-slip / (1 + (tangentLever * tangentLever) / inertia),
|
||||
-impulse * 0.35,
|
||||
impulse * 0.35
|
||||
);
|
||||
a.vx -= tx * friction;
|
||||
a.vy -= ty * friction;
|
||||
a.wz -= (tangentLever * friction) / inertia;
|
||||
a.wx -= ((ny * impulse) / scale) * 0.12;
|
||||
a.wy += ((nx * impulse) / scale) * 0.12;
|
||||
if (!this.findOverlap(mesh, obstacle, range) || this.contact.depth <= depth) continue;
|
||||
// The strongest boundary wins so overlapping text does not multiply the force
|
||||
depth = this.contact.depth;
|
||||
nx = this.contact.nx;
|
||||
ny = this.contact.ny;
|
||||
}
|
||||
if (!depth) return;
|
||||
// A compact, model-relative field slows approaching motion without bounce or torque
|
||||
const pressure = Math.min(depth / range, 1),
|
||||
strength = pressure * pressure * (3 - 2 * pressure),
|
||||
approaching = Math.max(0, body.vx * nx + body.vy * ny),
|
||||
gap = Math.max(range - depth, range * 0.05),
|
||||
braking = approaching * -Math.expm1(-seconds * (12 * strength + approaching / gap)),
|
||||
push = scale * 12 * strength * seconds + braking;
|
||||
body.vx -= nx * push;
|
||||
body.vy -= ny * push;
|
||||
if (depth <= range) return;
|
||||
// Layout changes or a forced drag can start inside the field. Recover quietly, never spark
|
||||
const x = body.x,
|
||||
y = body.y;
|
||||
this.separateObstacles(model, scale);
|
||||
const dx = body.x - x,
|
||||
dy = body.y - y,
|
||||
distance = Math.hypot(dx, dy);
|
||||
if (distance > 0) {
|
||||
const inward = (body.vx * dx + body.vy * dy) / distance;
|
||||
if (inward < 0) {
|
||||
body.vx -= (dx / distance) * inward;
|
||||
body.vy -= (dy / distance) * inward;
|
||||
}
|
||||
}
|
||||
if (touching) this.separateObstacles(model, scale);
|
||||
}
|
||||
|
||||
private separateObstacles(model: Model, scale: number): void {
|
||||
@@ -593,7 +589,7 @@ export class SolidScene implements SceneState {
|
||||
minY = Math.min(minY, mesh.projected[k + 1]);
|
||||
maxY = Math.max(maxY, mesh.projected[k + 1]);
|
||||
}
|
||||
// Ordinary impacts need only their minimum SAT correction, not a sweep of every hull axis.
|
||||
// Small overlaps need only their minimum SAT correction, not a sweep of every hull axis.
|
||||
contacts: for (let pass = 0; pass <= 4; pass++) {
|
||||
let touching = false;
|
||||
for (const obstacle of this.obstacles) {
|
||||
@@ -604,7 +600,7 @@ export class SolidScene implements SceneState {
|
||||
minY >= obstacle.maxY
|
||||
)
|
||||
continue;
|
||||
if (!this.findContact(mesh, obstacle)) continue;
|
||||
if (!this.findOverlap(mesh, obstacle)) continue;
|
||||
if (pass === 4) break contacts;
|
||||
const { nx, ny, depth } = this.contact,
|
||||
dx = -nx * (depth + 0.02),
|
||||
|
||||
@@ -55,11 +55,11 @@ void main() {
|
||||
|
||||
// R: density, G: wire brightness, B: retained bulb warmth, A: spark heat.
|
||||
vec3 blue = vec3(.0784, .4235, 1.0);
|
||||
vec3 yellow = uDarkMode ? vec3(1.0, .7804, .1216) : vec3(180.0, 83.0, 9.0) / 255.0;
|
||||
vec3 color = mix(blue, yellow, lampWarmth(point));
|
||||
vec3 yellow = vec3(1.0, .7804, .1216);
|
||||
vec3 color = uDarkMode ? mix(blue, yellow, lampWarmth(point)) : blue;
|
||||
float alpha = circle;
|
||||
if (solid.g > 0.0) {
|
||||
color = mix(blue, yellow, solid.b) * (uDarkMode ? solid.g : 1.0);
|
||||
color = uDarkMode ? mix(blue, yellow, solid.b) * solid.g : blue;
|
||||
alpha = uDarkMode ? 1.0 : solid.g;
|
||||
}
|
||||
if (solid.a > 0.0) {
|
||||
@@ -67,13 +67,16 @@ void main() {
|
||||
color = vec3(1.0, .4 + .45 * heat, .06 + .45 * heat);
|
||||
alpha = 1.0;
|
||||
}
|
||||
// Faint but visible bulb haze dots. Gaps stay transparent with no background wash.
|
||||
vec2 delta = (point - uLamp.xy) / uLamp.z;
|
||||
float distance2 = dot(delta, delta);
|
||||
float wisps = .82 + .18 * sin(point.x * .17 + uTime * .5) * sin(point.y * .13 - uTime * .3);
|
||||
float haze = (.12 * exp(-distance2 / .5) + .04 * exp(-distance2 / .04)) * wisps * step(r, uSpacing * .13);
|
||||
// Bulb lighting is dark-mode only. Gaps stay transparent with no background wash.
|
||||
float haze = 0.0;
|
||||
if (uDarkMode) {
|
||||
vec2 delta = (point - uLamp.xy) / uLamp.z;
|
||||
float distance2 = dot(delta, delta);
|
||||
float wisps = .82 + .18 * sin(point.x * .17 + uTime * .5) * sin(point.y * .13 - uTime * .3);
|
||||
haze = (.12 * exp(-distance2 / .5) + .04 * exp(-distance2 / .04)) * wisps * step(r, uSpacing * .13);
|
||||
}
|
||||
float hazeBehind = haze * (1.0 - alpha), outAlpha = alpha + hazeBehind;
|
||||
vec3 hazeColor = uDarkMode ? vec3(1.0, .65, .12) : yellow;
|
||||
vec3 hazeColor = vec3(1.0, .65, .12);
|
||||
outColor = vec4((color * alpha + hazeColor * hazeBehind) / max(outAlpha, .00001), outAlpha);
|
||||
}
|
||||
`;
|
||||
Reference in new issue
Block a user