diff --git a/src/app.html b/src/app.html index 6992664..f7a25a2 100755 --- a/src/app.html +++ b/src/app.html @@ -5,23 +5,6 @@ %sveltekit.head% -
%sveltekit.body%
diff --git a/src/lib/background/controller.ts b/src/lib/background/controller.ts index 6011b49..8dcc6f8 100644 --- a/src/lib/background/controller.ts +++ b/src/lib/background/controller.ts @@ -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; 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) => 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; } } diff --git a/src/lib/background/engine.ts b/src/lib/background/engine.ts new file mode 100644 index 0000000..ca58dac --- /dev/null +++ b/src/lib/background/engine.ts @@ -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; +type PointerCommand = Extract; +type Cursor = Extract['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): 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; + } + } +} diff --git a/src/lib/background/messages.ts b/src/lib/background/messages.ts new file mode 100644 index 0000000..8cc4c96 --- /dev/null +++ b/src/lib/background/messages.ts @@ -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 }; diff --git a/src/lib/background/renderer.ts b/src/lib/background/renderer.ts index ccc1ec9..7f59f68 100644 --- a/src/lib/background/renderer.ts +++ b/src/lib/background/renderer.ts @@ -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; diff --git a/src/lib/background/static.ts b/src/lib/background/static.ts new file mode 100644 index 0000000..418c297 --- /dev/null +++ b/src/lib/background/static.ts @@ -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(); + } + }; +} diff --git a/src/lib/background/worker.ts b/src/lib/background/worker.ts new file mode 100644 index 0000000..f09c736 --- /dev/null +++ b/src/lib/background/worker.ts @@ -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) => 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(); +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index aba395d..153fc07 100755 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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; + }); -
- {#if !readingBlog || !mobile.current} +
+ {#if enhanced && (!readingBlog || !mobile.current)} {/if}
@@ -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 {