multithread offload + nojs + better jit

This commit is contained in:
midou committed 2026-11-06 11:08:07 +00:00
1 parent 4132289815
commit a18ac39163
8 files changed
+946 -230

No files matched your search

-17
View File
@@ -5,23 +5,6 @@
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width initial-scale=1" />
%sveltekit.head%
<noscript>
<style>
.scene-background {
display: none !important;
}
.site-shell.blog-reading {
--scene-rail-width: 0px;
}
.site-shell .site-content {
animation: none !important;
transition: none !important;
}
.programmer-reveal {
pointer-events: none;
}
</style>
</noscript>
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
+352 -200
View File
@@ -1,67 +1,64 @@
import { FluidField } from './fluid.ts';
import { createRenderer } from './renderer.ts';
import { SolidScene } from './scene.ts';
import {
simulationSize,
type BackgroundCommand,
type BackgroundEnvironment,
type BackgroundEvent,
type BackgroundSize
} from './messages.ts';
import type { Obstacle } from './types.ts';
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
const PROTECTED_CONTENT = 'a, button, h1, h2, h3, h4, h5, h6, p, pre, blockquote, table, img';
const FRAME_CPU_BUDGET_MS = 50;
const PERFORMANCE_WARMUP_MS = 1000;
const PERFORMANCE_WINDOW_MS = 2000;
const PERFORMANCE_MIN_FRAMES = 8;
type StaticBackground = {
readonly canvas: HTMLCanvasElement;
update(size: BackgroundSize, obstacles: Obstacle[], dark: boolean): void;
destroy(): void;
};
type PointerCommand = Extract<BackgroundCommand, { type: 'pointer' }>;
export class BackgroundController {
private readonly fluid = new FluidField();
private readonly scene = new SolidScene();
private readonly renderer;
private readonly softwareFallback: boolean;
private performanceFallback = false;
private sampledFrames = 0;
private sampledCpuTime = 0;
private sampleWindowStart = 0;
private initialCheckComplete = false;
private get still(): boolean {
return this.softwareFallback || this.performanceFallback || this.reducedMotion.matches;
}
private readonly listeners = new AbortController();
private readonly observer: ResizeObserver;
private readonly contentObserver: ResizeObserver;
private readonly mutations: MutationObserver;
private protectedElements: Element[] = [];
private readonly darkMode = window.matchMedia('(prefers-color-scheme: dark)');
private readonly reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
private staticComposition = false;
private obstacles: Obstacle[] | null = null;
private animation = 0;
private lastTime = 0;
private time = 0;
private cssWidth = 0;
private cssHeight = 0;
private dpr = 1;
private environment: BackgroundEnvironment = {
dark: this.darkMode.matches,
reduced: this.reducedMotion.matches,
visible: !document.hidden
};
private protectedElements: Element[] = [];
private obstacles: Obstacle[] = [];
private size: BackgroundSize = { width: 0, height: 0, dpr: 1 };
private grid = { width: 0, height: 0 };
private worker: Worker | null = null;
private bitmapContext: ImageBitmapRenderingContext | null = null;
private drawingContext: CanvasRenderingContext2D | null = null;
private bitmap: ImageBitmap | null = null;
private fallback: StaticBackground | null = null;
private fallbackRequested = false;
private fallbackTask: number | null = null;
private fallbackTaskIsIdle = false;
private workerInteractive = false;
private interactive = false;
private cursor: 'default' | 'grab' | 'grabbing' = 'default';
private pointerId: number | null = null;
private pointerX = 0;
private pointerY = 0;
private previousX = 0;
private previousY = 0;
private hasPrevious = false;
private pendingMove: PointerCommand | null = null;
private moveAnimation = 0;
private initialCheckComplete = false;
private disposed = false;
constructor(
private readonly canvas: HTMLCanvasElement,
private canvas: HTMLCanvasElement,
private readonly frame: HTMLElement,
private readonly onPerformanceReady: () => void
) {
const result = createRenderer(canvas);
this.renderer = result.renderer;
this.softwareFallback = !result.animated;
canvas = this.canvas = result.canvas;
if (this.softwareFallback) {
canvas.style.pointerEvents = 'none';
canvas.style.touchAction = 'auto';
}
this.updateInteractivity();
this.observer = new ResizeObserver(this.resize);
this.observer.observe(frame);
this.contentObserver = new ResizeObserver(this.measureObstacles);
@@ -74,9 +71,9 @@ export class BackgroundController {
canvas.addEventListener('lostpointercapture', this.endDrag, options);
canvas.addEventListener('pointerleave', this.pointerLeave, options);
window.addEventListener('resize', this.resize, options);
document.addEventListener('visibilitychange', this.syncPlayback, options);
this.reducedMotion.addEventListener('change', this.syncPlayback, options);
this.darkMode.addEventListener('change', this.draw, options);
document.addEventListener('visibilitychange', this.syncEnvironment, options);
this.reducedMotion.addEventListener('change', this.syncEnvironment, options);
this.darkMode.addEventListener('change', this.syncEnvironment, options);
const content = frame.parentElement;
if (content) {
this.mutations.observe(content, { childList: true, subtree: true, characterData: true });
@@ -92,7 +89,198 @@ export class BackgroundController {
if (!this.disposed) this.measureObstacles();
});
this.resize();
this.syncPlayback();
this.startWorker();
}
private startWorker(): void {
if (typeof Worker === 'undefined' || typeof OffscreenCanvas === 'undefined') {
this.useStaticBackground();
return;
}
try {
this.bitmapContext = this.canvas.getContext('bitmaprenderer');
} catch {
this.bitmapContext = null;
}
if (!this.bitmapContext) {
try {
this.drawingContext = this.canvas.getContext('2d');
} catch {
this.drawingContext = null;
}
}
if (!this.bitmapContext && !this.drawingContext) {
this.useStaticBackground();
return;
}
try {
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
this.worker = worker;
worker.onmessage = (event: MessageEvent<BackgroundEvent>) => this.receive(worker, event.data);
worker.onerror = (event) => {
event.preventDefault();
if (this.worker === worker) this.useStaticBackground();
};
worker.onmessageerror = () => {
if (this.worker === worker) this.useStaticBackground();
};
this.post({
type: 'initialize',
size: this.size,
environment: this.environment,
obstacles: this.obstacles
});
} catch {
this.useStaticBackground();
}
}
private post(command: BackgroundCommand): void {
if (this.disposed || !this.worker) return;
try {
this.worker.postMessage(command);
} catch {
this.useStaticBackground();
}
}
private receive(worker: Worker, message: BackgroundEvent): void {
if (this.disposed || worker !== this.worker) {
this.discardBitmap(message);
return;
}
if (!message || typeof message !== 'object') {
this.useStaticBackground();
return;
}
switch (message.type) {
case 'frame': {
if (typeof ImageBitmap === 'undefined' || !(message.bitmap instanceof ImageBitmap)) {
this.useStaticBackground();
return;
}
const bitmap = (this.bitmap = message.bitmap);
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.bitmapContext) this.bitmapContext.transferFromImageBitmap(bitmap);
else if (this.drawingContext) {
this.drawingContext.imageSmoothingEnabled = false;
this.drawingContext.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.drawingContext.drawImage(bitmap, 0, 0);
}
}
} catch {
failed = true;
} finally {
bitmap.close();
this.bitmap = null;
if (this.worker === worker) this.post({ type: 'frame-consumed' });
}
if (failed) this.useStaticBackground();
return;
}
case 'ready':
this.finishInitialCheck();
return;
case 'state':
if (
typeof message.interactive !== 'boolean' ||
(message.cursor !== 'default' &&
message.cursor !== 'grab' &&
message.cursor !== 'grabbing')
) {
this.useStaticBackground();
return;
}
this.workerInteractive = message.interactive;
this.cursor = message.cursor;
this.updateInteractivity();
return;
case 'error':
this.useStaticBackground();
return;
default:
this.discardBitmap(message);
this.useStaticBackground();
}
}
private discardBitmap(message: unknown): void {
if (!message || typeof message !== 'object' || !('bitmap' in message)) return;
if (typeof ImageBitmap !== 'undefined' && message.bitmap instanceof ImageBitmap) {
message.bitmap.close();
}
}
private stopWorker(): void {
const worker = this.worker;
this.worker = null;
if (!worker) return;
worker.onmessage = null;
worker.onerror = null;
worker.onmessageerror = null;
try {
worker.postMessage({ type: 'destroy' } satisfies BackgroundCommand);
} catch {
// Termination also releases resources if the worker is already unavailable.
}
worker.terminate();
}
private scheduleStaticSetup(setup: () => void): void {
const run = () => {
this.fallbackTask = null;
if (!this.disposed) setup();
};
this.fallbackTaskIsIdle = typeof window.requestIdleCallback === 'function';
this.fallbackTask = this.fallbackTaskIsIdle
? window.requestIdleCallback(run, { timeout: 1000 })
: window.setTimeout(run, 0);
}
private useStaticBackground(): void {
if (this.disposed || this.fallbackRequested) return;
this.fallbackRequested = true;
this.endDrag();
this.stopWorker();
this.workerInteractive = false;
this.bitmap?.close();
this.bitmap = null;
this.bitmapContext = null;
this.drawingContext = null;
const canvas = this.canvas.cloneNode(false) as HTMLCanvasElement;
this.canvas.replaceWith(canvas);
this.canvas = canvas;
this.updateInteractivity();
this.scheduleStaticSetup(() => {
void import('./static.ts')
.then(({ createStaticBackground }) => {
if (this.disposed) return;
this.scheduleStaticSetup(() => {
let background: StaticBackground | null = null;
try {
background = createStaticBackground(this.canvas);
this.canvas = background.canvas;
this.updateInteractivity();
background.update(this.size, this.obstacles, this.darkMode.matches);
this.fallback = background;
this.finishInitialCheck();
} catch (error) {
background?.destroy();
console.warn('Could not initialize the static background', error);
}
});
})
.catch((error: unknown) => {
if (!this.disposed) console.warn('Could not load the static background', error);
});
});
}
private resize = () => {
@@ -101,39 +289,16 @@ export class BackgroundController {
const width = Math.max(1, Math.round(bounds.width));
const height = Math.max(1, Math.round(bounds.height));
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (width === this.cssWidth && height === this.cssHeight && dpr === this.dpr) return;
this.resetPerformanceSampling();
this.cssWidth = width;
this.cssHeight = height;
this.dpr = dpr;
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);
this.endDrag();
// Higher-resolution pixels retain crisp, non-AA edges on the smaller models.
const simulationScale = 320 / Math.max(width, height);
const simulationWidth = Math.max(3, Math.round(width * simulationScale));
const simulationHeight = Math.max(3, Math.round(height * simulationScale));
const resetStatic = this.still && !this.staticComposition;
if (this.still) this.time = 0;
this.staticComposition = this.still;
if (
resetStatic ||
simulationWidth !== this.fluid.width ||
simulationHeight !== this.fluid.height
) {
this.fluid.resize(simulationWidth, simulationHeight, this.time);
this.scene.resize(simulationWidth, simulationHeight, this.time);
}
this.obstacles = null;
this.renderer.resize(simulationWidth, simulationHeight);
this.measureObstacles();
};
private draw = () => {
if (!this.disposed) {
this.renderer.render(this.fluid, this.scene, this.time, this.dpr, this.darkMode.matches);
}
this.grid = simulationSize(this.size);
this.obstacles = this.collectObstacles();
this.post({ type: 'resize', size: this.size, obstacles: this.obstacles });
this.fallback?.update(this.size, this.obstacles, this.darkMode.matches);
};
private observeContent = () => {
@@ -148,15 +313,15 @@ export class BackgroundController {
}
this.measureObstacles();
};
private measureObstacles = () => {
if (this.disposed || !this.fluid.width || !this.fluid.height) return;
const content = this.frame.parentElement;
if (!content) return;
private collectObstacles(): Obstacle[] {
if (!this.grid.width || !this.grid.height || !this.frame.parentElement) return [];
const frame = this.frame.getBoundingClientRect();
const obstacles: Obstacle[] = [];
const cellScale = Math.min(frame.width / this.fluid.width, frame.height / this.fluid.height);
const offsetX = (frame.width - this.fluid.width * cellScale) / 2;
const offsetY = (frame.height - this.fluid.height * cellScale) / 2;
const cellScale = Math.min(frame.width / this.grid.width, frame.height / this.grid.height);
if (!cellScale) return obstacles;
const offsetX = (frame.width - this.grid.width * cellScale) / 2;
const offsetY = (frame.height - this.grid.height * cellScale) / 2;
const clearance = 8;
for (const element of this.protectedElements) {
if (element.parentElement?.closest(PROTECTED_CONTENT)) continue;
@@ -177,21 +342,26 @@ export class BackgroundController {
obstacles.push({
minX: Math.max(0, (rect.left - frame.left - clearance - offsetX) / cellScale),
maxX: Math.min(
this.fluid.width,
this.grid.width,
(rect.right - frame.left + clearance - offsetX) / cellScale
),
minY: Math.max(0, (frame.bottom - rect.bottom - clearance - offsetY) / cellScale),
maxY: Math.min(
this.fluid.height,
this.grid.height,
(frame.bottom - rect.top + clearance - offsetY) / cellScale
)
});
}
return obstacles;
}
private measureObstacles = () => {
if (this.disposed || !this.grid.width || !this.grid.height) return;
const obstacles = this.collectObstacles();
if (
this.still &&
this.obstacles?.length === obstacles.length &&
this.obstacles.length === obstacles.length &&
obstacles.every((obstacle, i) => {
const previous = this.obstacles![i];
const previous = this.obstacles[i];
return (
obstacle.minX === previous.minX &&
obstacle.maxX === previous.maxX &&
@@ -202,163 +372,145 @@ export class BackgroundController {
)
return;
this.obstacles = obstacles;
this.scene.setObstacles(obstacles);
if (this.still) this.scene.renderPreset();
else this.scene.update(0, this.time);
this.draw();
this.post({ type: 'obstacles', obstacles });
this.fallback?.update(this.size, obstacles, this.darkMode.matches);
};
private resetPerformanceSampling(): void {
this.sampledFrames = 0;
this.sampledCpuTime = 0;
this.sampleWindowStart = performance.now() + PERFORMANCE_WARMUP_MS;
}
private finishInitialCheck(): void {
if (this.initialCheckComplete) return;
if (this.disposed || this.initialCheckComplete) return;
this.initialCheckComplete = true;
this.onPerformanceReady();
}
private syncPlayback = () => {
cancelAnimationFrame(this.animation);
this.animation = 0;
this.lastTime = 0;
this.resetPerformanceSampling();
private updateInteractivity(): void {
const interactive =
!this.disposed &&
this.worker !== null &&
this.workerInteractive &&
!this.reducedMotion.matches &&
!document.hidden;
if (!interactive && this.interactive) this.endDrag();
this.interactive = interactive;
this.canvas.style.pointerEvents = interactive ? '' : 'none';
this.canvas.style.touchAction = interactive ? 'none' : 'auto';
this.canvas.style.cursor = interactive ? this.cursor : 'default';
}
private syncEnvironment = () => {
if (this.disposed) return;
if (this.still) {
this.endDrag();
if (!this.staticComposition) {
this.staticComposition = true;
this.time = 0;
this.fluid.resize(this.fluid.width, this.fluid.height, this.time);
this.scene.renderPreset();
this.draw();
}
this.finishInitialCheck();
return;
}
this.staticComposition = false;
if (document.hidden) {
this.endDrag();
return;
}
this.animation = requestAnimationFrame(this.tick);
const environment: BackgroundEnvironment = {
dark: this.darkMode.matches,
reduced: this.reducedMotion.matches,
visible: !document.hidden
};
const darkChanged = environment.dark !== this.environment.dark;
this.environment = environment;
this.updateInteractivity();
this.post({ type: 'environment', environment });
if (darkChanged) this.fallback?.update(this.size, this.obstacles, environment.dark);
};
private tick = (now: number) => {
if (this.disposed) return;
if (document.hidden || this.still) {
this.syncPlayback();
return;
}
const started = performance.now();
if (Math.min(window.devicePixelRatio || 1, 2) !== this.dpr) this.resize();
const elapsed = this.lastTime ? Math.min((now - this.lastTime) / 16.6667, 1.7) : 1;
this.lastTime = now;
this.time += elapsed / 60;
this.scene.update(elapsed, this.time);
this.fluid.step(elapsed, this.time, this.scene);
this.draw();
const cpuTime = performance.now() - started;
if (started >= this.sampleWindowStart) {
this.sampledCpuTime += cpuTime;
this.sampledFrames++;
if (
started - this.sampleWindowStart >= PERFORMANCE_WINDOW_MS &&
this.sampledFrames >= PERFORMANCE_MIN_FRAMES
) {
if (this.sampledCpuTime / this.sampledFrames > FRAME_CPU_BUDGET_MS) {
this.performanceFallback = true;
this.canvas.style.pointerEvents = 'none';
this.canvas.style.touchAction = 'auto';
this.syncPlayback();
return;
}
this.sampledFrames = 0;
this.sampledCpuTime = 0;
this.sampleWindowStart = started;
this.finishInitialCheck();
}
}
this.animation = requestAnimationFrame(this.tick);
};
private pointerPosition(event: PointerEvent) {
private pointerPosition(event: PointerEvent): void {
const bounds = this.canvas.getBoundingClientRect();
const cellScale = Math.min(bounds.width / this.fluid.width, bounds.height / this.fluid.height);
const offsetX = (bounds.width - this.fluid.width * cellScale) / 2;
const offsetY = (bounds.height - this.fluid.height * cellScale) / 2;
this.pointerX = clamp((event.clientX - bounds.left - offsetX) / cellScale, 0, this.fluid.width);
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.fluid.height
this.grid.height
);
}
private interact(event: PointerEvent) {
const x = clamp(this.pointerX, 1, this.fluid.width - 2);
const y = clamp(this.pointerY, 1, this.fluid.height - 2);
if (this.hasPrevious) {
this.fluid.interact(x, y, x - this.previousX, y - this.previousY, event.buttons !== 0);
}
this.previousX = x;
this.previousY = y;
this.hasPrevious = true;
}
private flushPointerMove = () => {
if (this.moveAnimation) cancelAnimationFrame(this.moveAnimation);
this.moveAnimation = 0;
const command = this.pendingMove;
this.pendingMove = null;
if (command) this.post(command);
};
private pointerMove = (event: PointerEvent) => {
if (this.still) return;
if (this.scene.drag.body) {
if (event.pointerId === this.pointerId) {
this.pointerPosition(event);
this.scene.moveDrag(this.pointerX, this.pointerY);
}
if (!this.interactive || (this.pointerId !== null && event.pointerId !== this.pointerId))
return;
}
this.pointerPosition(event);
this.canvas.style.cursor = this.scene.pickBody(this.pointerX, this.pointerY)
? 'grab'
: 'default';
this.interact(event);
this.pendingMove = {
type: 'pointer',
action: 'move',
x: this.pointerX,
y: this.pointerY,
pressed: event.buttons !== 0
};
if (!this.moveAnimation) this.moveAnimation = requestAnimationFrame(this.flushPointerMove);
};
private pointerDown = (event: PointerEvent) => {
if (this.still || !event.isPrimary || event.button !== 0 || this.pointerId !== null) return;
if (!this.interactive || !event.isPrimary || event.button !== 0 || this.pointerId !== null)
return;
this.flushPointerMove();
this.pointerPosition(event);
this.pointerId = event.pointerId;
this.canvas.setPointerCapture(event.pointerId);
this.hasPrevious = false;
if (this.scene.beginDrag(this.pointerX, this.pointerY)) {
this.canvas.style.cursor = 'grabbing';
} else {
this.interact(event);
}
this.post({
type: 'pointer',
action: 'down',
x: this.pointerX,
y: this.pointerY,
pressed: event.buttons !== 0
});
};
private endDrag = (event?: PointerEvent) => {
if (event && this.pointerId !== null && event.pointerId !== this.pointerId) return;
this.flushPointerMove();
const id = this.pointerId;
this.pointerId = null;
this.scene.endDrag();
this.hasPrevious = false;
if (id !== null) {
this.post({
type: 'pointer',
action: 'up',
x: this.pointerX,
y: this.pointerY,
pressed: false
});
}
this.cursor = 'default';
this.canvas.style.cursor = 'default';
if (id !== null && this.canvas.hasPointerCapture(id)) this.canvas.releasePointerCapture(id);
};
private pointerLeave = () => {
if (!this.scene.drag.body) this.hasPrevious = false;
private pointerLeave = (event: PointerEvent) => {
if (!this.interactive) return;
this.flushPointerMove();
this.post({
type: 'pointer',
action: 'leave',
x: this.pointerX,
y: this.pointerY,
pressed: event.buttons !== 0
});
};
destroy() {
destroy(): void {
if (this.disposed) return;
this.disposed = true;
cancelAnimationFrame(this.animation);
this.listeners.abort();
this.observer.disconnect();
this.contentObserver.disconnect();
this.mutations.disconnect();
this.endDrag();
this.renderer.destroy();
this.stopWorker();
this.bitmap?.close();
this.bitmap = null;
if (this.fallbackTask !== null) {
if (this.fallbackTaskIsIdle) window.cancelIdleCallback(this.fallbackTask);
else window.clearTimeout(this.fallbackTask);
this.fallbackTask = null;
}
this.fallback?.destroy();
this.fallback = null;
this.bitmapContext = null;
this.drawingContext = null;
}
}
+421
View File
@@ -0,0 +1,421 @@
import { FluidField } from './fluid.ts';
import {
simulationSize,
type BackgroundCommand,
type BackgroundEnvironment,
type BackgroundEvent,
type BackgroundSize
} from './messages.ts';
import { createRenderer } from './renderer.ts';
import { SolidScene } from './scene.ts';
import type { Obstacle, Renderer } from './types.ts';
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
const FRAME_INTERVAL_MS = 16.6667;
const FRAME_CPU_BUDGET_MS = 50;
const PERFORMANCE_WARMUP_MS = 1000;
const PERFORMANCE_WINDOW_MS = 2000;
const PERFORMANCE_MIN_FRAMES = 8;
type Emit = (event: BackgroundEvent, transfer?: Transferable[]) => void;
type ResizeCommand = Extract<BackgroundCommand, { type: 'resize' }>;
type PointerCommand = Extract<BackgroundCommand, { type: 'pointer' }>;
type Cursor = Extract<BackgroundEvent, { type: 'state' }>['cursor'];
type WorkerTimers = {
setTimeout(callback: () => void, delay: number): number;
clearTimeout(timer: number): void;
};
const timers = globalThis as unknown as WorkerTimers;
export class BackgroundEngine {
private readonly fluid = new FluidField();
private readonly scene = new SolidScene();
private canvas: OffscreenCanvas | null = null;
private renderer: Renderer | null = null;
private size: BackgroundSize = { width: 0, height: 0, dpr: 1 };
private environment: BackgroundEnvironment = { dark: false, reduced: false, visible: false };
private softwareFallback = false;
private performanceFallback = false;
private staticComposition = false;
private obstacles: Obstacle[] | null = null;
private initialized = false;
private disposed = false;
private timer: number | null = null;
private lastTime = 0;
private time = 0;
private sampledFrames = 0;
private sampledCpuTime = 0;
private sampleWindowStart = 0;
private initialCheckComplete = false;
private pendingFrame = false;
private pendingResize: ResizeCommand | null = null;
private pendingObstacles: Obstacle[] | null = null;
private dirty = false;
private pointerDown = false;
private previousX = 0;
private previousY = 0;
private hasPrevious = false;
private cursor: Cursor = 'default';
private sentInteractive: boolean | null = null;
private sentCursor: Cursor = 'default';
constructor(private readonly emit: Emit) {}
private get still(): boolean {
return this.softwareFallback || this.performanceFallback || this.environment.reduced;
}
private get interactive(): boolean {
return this.initialized && !this.disposed && !this.still && this.environment.visible;
}
handle(command: BackgroundCommand): void {
if (this.disposed) return;
try {
if (command.type === 'destroy') {
this.dispose();
return;
}
if (command.type === 'initialize') {
if (this.initialized) throw new Error('Background worker is already initialized');
this.initialize(command);
return;
}
if (!this.initialized) throw new Error('Background worker has not been initialized');
switch (command.type) {
case 'resize':
this.endDrag();
if (this.pendingFrame) {
// Only the latest size and its content geometry matter after presentation.
this.pendingResize = command;
this.pendingObstacles = null;
} else {
this.resize(command.size, command.obstacles);
this.present();
this.schedule();
}
break;
case 'obstacles':
if (this.pendingResize) this.pendingResize.obstacles = command.obstacles;
else if (this.pendingFrame) this.pendingObstacles = command.obstacles;
else {
this.updateObstacles(command.obstacles);
this.present();
this.schedule();
}
break;
case 'environment':
this.updateEnvironment(command.environment);
break;
case 'pointer':
this.pointer(command);
break;
case 'frame-consumed':
if (!this.pendingFrame) break;
this.pendingFrame = false;
if (this.pendingResize) {
const resize = this.pendingResize;
this.pendingResize = null;
this.resize(resize.size, resize.obstacles);
} else if (this.pendingObstacles) {
const obstacles = this.pendingObstacles;
this.pendingObstacles = null;
this.updateObstacles(obstacles);
}
this.present();
this.schedule();
break;
default:
throw new Error('Unknown background worker command');
}
} catch (error) {
this.fail(error);
}
}
private initialize(command: Extract<BackgroundCommand, { type: 'initialize' }>): void {
this.environment = command.environment;
const result = createRenderer(new OffscreenCanvas(1, 1));
this.canvas = result.canvas;
this.renderer = result.renderer;
this.softwareFallback = !result.animated;
this.initialized = true;
this.staticComposition = this.still;
this.resize(command.size, command.obstacles);
this.syncPlayback();
}
private resize(size: BackgroundSize, obstacles: Obstacle[]): void {
if (!this.canvas || !this.renderer) return;
const changed =
size.width !== this.size.width ||
size.height !== this.size.height ||
size.dpr !== this.size.dpr;
const dimensions = simulationSize(size);
const resized =
dimensions.width !== this.fluid.width || dimensions.height !== this.fluid.height;
if (changed) {
this.resetPerformanceSampling();
this.size = size;
this.canvas.width = Math.max(1, Math.round(size.width * size.dpr));
this.canvas.height = Math.max(1, Math.round(size.height * size.dpr));
// Higher-resolution pixels retain crisp, non-AA edges on the smaller models.
if (resized) {
this.fluid.resize(dimensions.width, dimensions.height, this.time);
// SolidScene rescales existing positions and velocities without resetting poses.
this.scene.resize(dimensions.width, dimensions.height, this.time);
}
this.renderer.resize(dimensions.width, dimensions.height);
this.dirty = true;
}
const contentChanged = this.setObstacles(obstacles);
if (resized || contentChanged) {
if (this.still) this.scene.renderPreset();
else this.scene.update(0, this.time);
this.dirty = true;
}
}
private setObstacles(obstacles: Obstacle[]): boolean {
if (
this.obstacles?.length === obstacles.length &&
obstacles.every((obstacle, i) => {
const previous = this.obstacles![i];
return (
obstacle.minX === previous.minX &&
obstacle.maxX === previous.maxX &&
obstacle.minY === previous.minY &&
obstacle.maxY === previous.maxY
);
})
)
return false;
this.obstacles = obstacles;
this.scene.setObstacles(obstacles);
return true;
}
private updateObstacles(obstacles: Obstacle[]): void {
if (!this.setObstacles(obstacles)) return;
if (this.still) this.scene.renderPreset();
else this.scene.update(0, this.time);
this.dirty = true;
}
private updateEnvironment(environment: BackgroundEnvironment): void {
const playbackChanged =
environment.reduced !== this.environment.reduced ||
environment.visible !== this.environment.visible;
if (environment.dark !== this.environment.dark) this.dirty = true;
this.environment = environment;
if (playbackChanged) this.syncPlayback();
else {
this.present();
this.schedule();
}
}
private syncPlayback(): void {
this.cancelTimer();
this.lastTime = 0;
this.resetPerformanceSampling();
if (this.still) {
this.endDrag();
if (!this.staticComposition) {
this.staticComposition = true;
this.time = 0;
this.fluid.resize(this.fluid.width, this.fluid.height, this.time);
this.scene.renderPreset();
this.dirty = true;
}
} else {
this.staticComposition = false;
if (!this.environment.visible) this.endDrag();
}
this.publishState();
this.present();
this.schedule();
}
private present(): void {
if (
this.disposed ||
!this.dirty ||
this.pendingFrame ||
!this.environment.visible ||
!this.canvas ||
!this.renderer
)
return;
this.cancelTimer();
// Transferring a bitmap clears the canvas, so every presentation renders anew.
this.renderer.render(this.fluid, this.scene, this.time, this.size.dpr, this.environment.dark);
const bitmap = this.canvas.transferToImageBitmap();
try {
this.pendingFrame = true;
this.emit({ type: 'frame', bitmap }, [bitmap]);
this.dirty = false;
} catch (error) {
this.pendingFrame = false;
bitmap.close();
throw error;
}
if (this.still) this.finishInitialCheck();
}
private schedule(): void {
if (!this.interactive || this.pendingFrame || this.timer !== null) return;
const delay = this.lastTime
? Math.max(0, FRAME_INTERVAL_MS - (performance.now() - this.lastTime))
: 0;
this.timer = timers.setTimeout(this.tick, delay);
}
private cancelTimer(): void {
if (this.timer === null) return;
timers.clearTimeout(this.timer);
this.timer = null;
}
private tick = (): void => {
this.timer = null;
if (!this.interactive || this.pendingFrame) return;
try {
const started = performance.now();
const elapsed = this.lastTime
? Math.min((started - this.lastTime) / FRAME_INTERVAL_MS, 1.7)
: 1;
this.lastTime = started;
this.time += elapsed / 60;
this.scene.update(elapsed, this.time);
this.fluid.step(elapsed, this.time, this.scene);
this.dirty = true;
this.present();
const cpuTime = performance.now() - started;
if (started >= this.sampleWindowStart) {
this.sampledCpuTime += cpuTime;
this.sampledFrames++;
if (
started - this.sampleWindowStart >= PERFORMANCE_WINDOW_MS &&
this.sampledFrames >= PERFORMANCE_MIN_FRAMES
) {
if (this.sampledCpuTime / this.sampledFrames > FRAME_CPU_BUDGET_MS) {
this.performanceFallback = true;
this.syncPlayback();
return;
}
this.sampledFrames = 0;
this.sampledCpuTime = 0;
this.sampleWindowStart = started;
this.finishInitialCheck();
}
}
this.schedule();
} catch (error) {
this.fail(error);
}
};
private resetPerformanceSampling(): void {
this.sampledFrames = 0;
this.sampledCpuTime = 0;
this.sampleWindowStart = performance.now() + PERFORMANCE_WARMUP_MS;
}
private finishInitialCheck(): void {
if (this.initialCheckComplete || this.dirty) return;
this.initialCheckComplete = true;
this.emit({ type: 'ready' });
}
private pointer(command: PointerCommand): void {
if (!this.interactive || this.pendingResize) return;
const x = clamp(command.x, 0, this.fluid.width);
const y = clamp(command.y, 0, this.fluid.height);
switch (command.action) {
case 'move':
if (this.scene.drag.body) {
this.scene.moveDrag(x, y);
return;
}
this.cursor = this.scene.pickBody(x, y) ? 'grab' : 'default';
this.publishState();
this.interact(x, y, command.pressed);
break;
case 'down':
if (this.pointerDown) return;
this.pointerDown = true;
this.hasPrevious = false;
if (this.scene.beginDrag(x, y)) this.cursor = 'grabbing';
else {
this.cursor = 'default';
this.interact(x, y, command.pressed);
}
this.publishState();
break;
case 'up':
this.endDrag();
break;
case 'leave':
if (!this.scene.drag.body) this.hasPrevious = false;
break;
}
}
private interact(pointerX: number, pointerY: number, pressed: boolean): void {
const x = clamp(pointerX, 1, this.fluid.width - 2);
const y = clamp(pointerY, 1, this.fluid.height - 2);
if (this.hasPrevious)
this.fluid.interact(x, y, x - this.previousX, y - this.previousY, pressed);
this.previousX = x;
this.previousY = y;
this.hasPrevious = true;
}
private endDrag(): void {
this.pointerDown = false;
this.scene.endDrag();
this.hasPrevious = false;
this.cursor = 'default';
this.publishState();
}
private publishState(): void {
const interactive = this.interactive;
const cursor = interactive ? this.cursor : 'default';
if (interactive === this.sentInteractive && cursor === this.sentCursor) return;
this.sentInteractive = interactive;
this.sentCursor = cursor;
this.emit({ type: 'state', interactive, cursor });
}
fail(error: unknown): void {
if (this.disposed) return;
try {
this.dispose();
} finally {
this.emit({
type: 'error',
message: error instanceof Error ? error.message : 'Background worker failed'
});
}
}
private dispose(): void {
this.disposed = true;
this.cancelTimer();
this.pendingFrame = false;
this.pendingResize = null;
this.pendingObstacles = null;
this.dirty = false;
this.endDrag();
try {
this.renderer?.destroy();
} finally {
this.renderer = null;
this.canvas = null;
}
}
}
+41
View File
@@ -0,0 +1,41 @@
import type { Obstacle } from './types.ts';
export type BackgroundSize = { width: number; height: number; dpr: number };
export type BackgroundEnvironment = { dark: boolean; reduced: boolean; visible: boolean };
export function simulationSize({ width, height }: BackgroundSize): {
width: number;
height: number;
} {
const scale = 320 / Math.max(width, height);
return {
width: Math.max(3, Math.round(width * scale)),
height: Math.max(3, Math.round(height * scale))
};
}
export type BackgroundCommand =
| {
type: 'initialize';
size: BackgroundSize;
environment: BackgroundEnvironment;
obstacles: Obstacle[];
}
| { type: 'resize'; size: BackgroundSize; obstacles: Obstacle[] }
| { type: 'obstacles'; obstacles: Obstacle[] }
| { type: 'environment'; environment: BackgroundEnvironment }
| {
type: 'pointer';
action: 'move' | 'down' | 'up' | 'leave';
x: number;
y: number;
pressed: boolean;
}
| { type: 'frame-consumed' }
| { type: 'destroy' };
export type BackgroundEvent =
| { type: 'frame'; bitmap: ImageBitmap }
| { type: 'ready' }
| { type: 'state'; interactive: boolean; cursor: 'default' | 'grab' | 'grabbing' }
| { type: 'error'; message: string };
+29 -7
View File
@@ -1,10 +1,23 @@
import { FRAGMENT_SHADER, VERTEX_SHADER } from './shaders.ts';
import { SETTINGS, type Lamp, type Renderer } from './types.ts';
type BackgroundCanvas = HTMLCanvasElement | OffscreenCanvas;
export function createRenderer(canvas: OffscreenCanvas): {
canvas: OffscreenCanvas;
renderer: Renderer;
animated: boolean;
};
export function createRenderer(canvas: HTMLCanvasElement): {
canvas: HTMLCanvasElement;
renderer: Renderer;
animated: boolean;
};
export function createRenderer(canvas: BackgroundCanvas): {
canvas: BackgroundCanvas;
renderer: Renderer;
animated: boolean;
} {
try {
const gl = canvas.getContext('webgl2', {
@@ -14,16 +27,22 @@ export function createRenderer(canvas: HTMLCanvasElement): {
depth: false,
stencil: false,
preserveDrawingBuffer: true
});
}) as WebGL2RenderingContext | null;
if (gl) return { canvas, renderer: createWebGLRenderer(canvas, gl), animated: true };
} catch {
// A failed WebGL canvas needs replacing before it can acquire a 2D context
const replacement = canvas.cloneNode(false) as HTMLCanvasElement;
canvas.replaceWith(replacement);
canvas = replacement;
if (typeof OffscreenCanvas !== 'undefined' && canvas instanceof OffscreenCanvas) {
canvas = new OffscreenCanvas(canvas.width, canvas.height);
} else {
const source = canvas as HTMLCanvasElement;
const replacement = source.cloneNode(false) as HTMLCanvasElement;
source.replaceWith(replacement);
canvas = replacement;
}
}
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d') as
CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
if (!ctx) throw new Error('Neither WebGL2 nor Canvas 2D is available for the background');
return { canvas, renderer: createCanvasRenderer(canvas, ctx), animated: false };
}
@@ -44,7 +63,7 @@ function compileShader(gl: WebGL2RenderingContext, type: number, source: string)
}
}
function createWebGLRenderer(canvas: HTMLCanvasElement, gl: WebGL2RenderingContext): Renderer {
function createWebGLRenderer(canvas: BackgroundCanvas, gl: WebGL2RenderingContext): Renderer {
let vertex: WebGLShader | null = null;
let fragment: WebGLShader | null = null;
let program: WebGLProgram | null = null;
@@ -190,7 +209,10 @@ function hazeAt(x: number, y: number, lamp: Lamp, time: number): number {
return (0.12 * Math.exp(-distance2 / 0.5) + 0.04 * Math.exp(-distance2 / 0.04)) * wisps;
}
function createCanvasRenderer(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): Renderer {
function createCanvasRenderer(
canvas: BackgroundCanvas,
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D
): Renderer {
let width = 0;
let height = 0;
let destroyed = false;
+39
View File
@@ -0,0 +1,39 @@
import { FluidField } from './fluid.ts';
import { simulationSize, type BackgroundSize } from './messages.ts';
import { createRenderer } from './renderer.ts';
import { SolidScene } from './scene.ts';
import type { Obstacle } from './types.ts';
export function createStaticBackground(canvas: HTMLCanvasElement) {
const result = createRenderer(canvas);
const fluid = new FluidField();
const scene = new SolidScene();
let pixelWidth = 0;
let pixelHeight = 0;
return {
canvas: result.canvas,
update(size: BackgroundSize, obstacles: Obstacle[], dark: boolean): void {
const nextWidth = Math.round(size.width * size.dpr);
const nextHeight = Math.round(size.height * size.dpr);
if (pixelWidth !== nextWidth || pixelHeight !== nextHeight) {
pixelWidth = nextWidth;
pixelHeight = nextHeight;
result.canvas.width = pixelWidth;
result.canvas.height = pixelHeight;
}
const grid = simulationSize(size);
if (grid.width !== fluid.width || grid.height !== fluid.height) {
fluid.resize(grid.width, grid.height, 0);
scene.resize(grid.width, grid.height, 0);
}
scene.setObstacles(obstacles);
scene.renderPreset();
result.renderer.resize(grid.width, grid.height);
result.renderer.render(fluid, scene, 0, size.dpr, dark);
},
destroy(): void {
result.renderer.destroy();
}
};
}
+39
View File
@@ -0,0 +1,39 @@
import { BackgroundEngine } from './engine.ts';
import type { BackgroundCommand, BackgroundEvent } from './messages.ts';
type BackgroundWorkerScope = {
postMessage(event: BackgroundEvent, transfer: Transferable[]): void;
onmessage: ((event: MessageEvent<BackgroundCommand>) => void) | null;
onmessageerror: (() => void) | null;
close(): void;
};
// Keep the normal DOM library for the app instead of also loading conflicting worker globals.
const scope = globalThis as unknown as BackgroundWorkerScope;
const emit = (event: BackgroundEvent, transfer: Transferable[] = []) =>
scope.postMessage(event, transfer);
let engine: BackgroundEngine | null = null;
scope.onmessage = (event) => {
try {
engine ??= new BackgroundEngine(emit);
engine.handle(event.data);
if (event.data?.type === 'destroy') scope.close();
} catch (error) {
if (engine) engine.fail(error);
else {
emit({
type: 'error',
message: error instanceof Error ? error.message : 'Background worker failed'
});
}
scope.close();
}
};
scope.onmessageerror = () => {
const error = new Error('Unable to read a background worker command');
if (engine) engine.fail(error);
else emit({ type: 'error', message: error.message });
scope.close();
};
+25 -6
View File
@@ -2,7 +2,7 @@
import 'uno.css';
import '#lib/style.css';
import { page } from '$app/state';
import type { Snippet } from 'svelte';
import { onMount, type Snippet } from 'svelte';
import { MediaQuery } from 'svelte/reactivity';
import SceneBackground from '#lib/background/SceneBackground.svelte';
import '@unocss/reset/tailwind.css';
@@ -12,10 +12,19 @@
let { children }: { children: Snippet } = $props();
let readingBlog = $derived(page.url.pathname.startsWith('/blog/'));
const mobile = new MediaQuery('(max-width: 720px)', false);
let enhanced = $state(false);
onMount(() => {
enhanced = true;
});
</script>
<main class="site-shell dark:bg-black bg-gray-300 overflow-hidden" class:blog-reading={readingBlog}>
{#if !readingBlog || !mobile.current}
<main
class="site-shell dark:bg-black bg-gray-300 overflow-hidden"
class:blog-reading={readingBlog}
class:enhanced
>
{#if enhanced && (!readingBlog || !mobile.current)}
<SceneBackground reading={readingBlog} />
{/if}
<div class="site-content">
@@ -30,6 +39,10 @@
min-height: 100vh;
}
.site-shell:not(.enhanced) :global(.programmer-reveal) {
pointer-events: none;
}
.site-shell:has(
:global(.programmer-reveal:is([data-phase='swapping'], [data-phase='isolated']))
) {
@@ -58,11 +71,11 @@
}
@media (min-width: 721px) and (prefers-reduced-motion: no-preference) {
.site-content {
.enhanced .site-content {
transition: padding-inline-end 650ms cubic-bezier(0.22, 1, 0.36, 1);
}
.blog-reading .site-content {
.enhanced.blog-reading .site-content {
animation: reading-enter 850ms ease both;
}
@@ -79,8 +92,14 @@
}
}
@media (min-width: 721px) {
.enhanced.blog-reading {
--scene-rail-width: max(12rem, 23vw, calc(100vw - 72rem));
}
}
.blog-reading {
--scene-rail-width: max(12rem, 23vw, calc(100vw - 72rem));
--scene-rail-width: 0px;
}
.blog-reading .site-content {