mirror of
https://github.com/midou36o/midou36o.github.io
synced 2026-11-14 03:53:17 +05:30
multithread offload + nojs + better jit
This commit is contained in:
1 parent
4132289815
commit
a18ac39163
8 files changed
+946
-230
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user