This commit is contained in:
midou committed 2026-11-06 16:44:27 +00:00
1 parent a18ac39163
commit da00380eb9
8 files changed
+411 -121

No files matched your search

+47 -1
View File
@@ -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;
+30 -18
View File
@@ -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();
+229
View File
@@ -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();
}
}
+13 -4
View File
@@ -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;
+1 -1
View File
@@ -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 };
+7 -12
View File
@@ -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
View File
@@ -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),
+12 -9
View File
@@ -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);
}
`;