From 71a32472fb048206d09a1bacbc9fe3ba6e23da50 Mon Sep 17 00:00:00 2001 From: Simon Hagger Date: Mon, 24 Aug 2026 09:34:48 +0100 Subject: [PATCH] feat(renderer): render Canvas Lab starfield in Web Worker via OffscreenCanvas - Third pane transfers control to OffscreenCanvas and hands it to a module worker running its own requestAnimationFrame warp starfield - effect() forwards density/speed/accent/pause signal changes to the worker; ResizeObserver posts DPR-sized resize messages - Graceful fallbacks when OffscreenCanvas or Workers are unavailable; terminate() on destroy --- .../features/canvas-lab/canvas-lab-page.html | 15 ++ .../features/canvas-lab/canvas-lab-page.ts | 77 ++++++++ .../app/features/canvas-lab/render.worker.ts | 166 ++++++++++++++++++ 3 files changed, 258 insertions(+) create mode 100644 apps/renderer/src/app/features/canvas-lab/render.worker.ts diff --git a/apps/renderer/src/app/features/canvas-lab/canvas-lab-page.html b/apps/renderer/src/app/features/canvas-lab/canvas-lab-page.html index d6df56c..8d54139 100644 --- a/apps/renderer/src/app/features/canvas-lab/canvas-lab-page.html +++ b/apps/renderer/src/app/features/canvas-lab/canvas-lab-page.html @@ -50,6 +50,21 @@ + + + OffscreenCanvas — Worker Starfield + {{ workerStatus() }} + + +
+ +
+
+
diff --git a/apps/renderer/src/app/features/canvas-lab/canvas-lab-page.ts b/apps/renderer/src/app/features/canvas-lab/canvas-lab-page.ts index 6a8a39b..e22116e 100644 --- a/apps/renderer/src/app/features/canvas-lab/canvas-lab-page.ts +++ b/apps/renderer/src/app/features/canvas-lab/canvas-lab-page.ts @@ -4,6 +4,7 @@ import { DestroyRef, ElementRef, afterNextRender, + effect, inject, signal, viewChild, @@ -57,6 +58,11 @@ export class CanvasLabPage { viewChild.required>('waveCanvas'); readonly glCanvas = viewChild.required>('glCanvas'); + readonly offscreenCanvas = + viewChild.required>('offscreenCanvas'); + + readonly offscreenSupported = signal(false); + readonly workerStatus = signal('Probing…'); readonly density = signal(48); readonly speed = signal(10); @@ -85,10 +91,26 @@ export class CanvasLabPage { private lastFpsTimestamp = performance.now(); private resizeObserver: ResizeObserver | null = null; private startTime = performance.now(); + private renderWorker: Worker | null = null; + private workerCanvas: OffscreenCanvas | null = null; constructor() { afterNextRender(() => this.initialize()); this.destroyRef.onDestroy(() => this.teardown()); + + effect(() => { + const worker = this.renderWorker; + if (!worker) { + return; + } + worker.postMessage({ + type: 'params', + density: this.density(), + speed: this.speed(), + accent: this.accent(), + running: this.running(), + }); + }); } setDensity(value: number): void { @@ -116,6 +138,7 @@ export class CanvasLabPage { private initialize(): void { this.initParticleField(); this.initWebGl(); + this.initOffscreenWorker(); this.observeResize(); this.scheduleFrame(); @@ -124,6 +147,47 @@ export class CanvasLabPage { }); } + private initOffscreenWorker(): void { + const canvas = this.offscreenCanvas().nativeElement; + if (typeof canvas.transferControlToOffscreen !== 'function') { + this.workerStatus.set('OffscreenCanvas not supported here.'); + return; + } + if (typeof Worker === 'undefined') { + this.workerStatus.set('Workers unavailable in this environment.'); + return; + } + + try { + this.workerCanvas = canvas.transferControlToOffscreen(); + this.renderWorker = new Worker( + new URL('./render.worker.ts', import.meta.url), + { type: 'module' }, + ); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + this.renderWorker.postMessage( + { + type: 'init', + canvas: this.workerCanvas, + width: Math.max(1, Math.floor(canvas.clientWidth * dpr)), + height: Math.max(1, Math.floor(canvas.clientHeight * dpr)), + density: this.density(), + speed: this.speed(), + accent: this.accent(), + running: this.running(), + }, + [this.workerCanvas], + ); + this.offscreenSupported.set(true); + this.workerStatus.set('Rendering in Web Worker via OffscreenCanvas.'); + } catch { + this.workerStatus.set('Failed to start worker rendering.'); + this.renderWorker?.terminate(); + this.renderWorker = null; + this.workerCanvas = null; + } + } + private initParticleField(): void { const canvas = this.waveCanvas().nativeElement; const ctx = canvas.getContext('2d'); @@ -245,9 +309,19 @@ export class CanvasLabPage { this.gl.drawingBufferHeight, ); } + const offscreenHost = this.offscreenCanvas().nativeElement; + if (this.renderWorker && this.workerCanvas) { + const dpr = Math.min(window.devicePixelRatio || 1, 2); + this.renderWorker.postMessage({ + type: 'resize', + width: Math.max(1, Math.floor(offscreenHost.clientWidth * dpr)), + height: Math.max(1, Math.floor(offscreenHost.clientHeight * dpr)), + }); + } }); this.resizeObserver.observe(this.waveCanvas().nativeElement); this.resizeObserver.observe(this.glCanvas().nativeElement); + this.resizeObserver.observe(this.offscreenCanvas().nativeElement); } private resizeCanvas(canvas: HTMLCanvasElement): void { @@ -358,6 +432,9 @@ export class CanvasLabPage { if (this.gl) { this.gl.getExtension('WEBGL_lose_context')?.loseContext(); } + this.renderWorker?.terminate(); + this.renderWorker = null; + this.workerCanvas = null; this.gl = null; this.glProgram = null; this.glUniforms = null; diff --git a/apps/renderer/src/app/features/canvas-lab/render.worker.ts b/apps/renderer/src/app/features/canvas-lab/render.worker.ts new file mode 100644 index 0000000..6ede98c --- /dev/null +++ b/apps/renderer/src/app/features/canvas-lab/render.worker.ts @@ -0,0 +1,166 @@ +/// + +interface InitMessage { + type: 'init'; + canvas: OffscreenCanvas; + width: number; + height: number; + density: number; + speed: number; + accent: string; + running: boolean; +} + +interface ParamsMessage { + type: 'params'; + density?: number; + speed?: number; + accent?: string; + running?: boolean; +} + +interface ResizeMessage { + type: 'resize'; + width: number; + height: number; +} + +type WorkerMessage = InitMessage | ParamsMessage | ResizeMessage; + +interface Star { + x: number; + y: number; + z: number; +} + +let ctx: OffscreenCanvasRenderingContext2D | null = null; +let canvas: OffscreenCanvas | null = null; +const stars: Star[] = []; +let density = 48; +let speed = 10; +let accent = '#38bdf8'; +let accentRgb: [number, number, number] = [0.22, 0.74, 0.97]; +let running = true; +let rafHandle = 0; +let lastTimestamp = 0; + +function hexToRgb(hex: string): [number, number, number] { + const value = Number.parseInt(hex.replace('#', ''), 16); + return [ + ((value >> 16) & 255) / 255, + ((value >> 8) & 255) / 255, + (value & 255) / 255, + ]; +} + +function seedStars(width: number, height: number): void { + stars.length = 0; + const count = density * 6; + for (let i = 0; i < count; i++) { + stars.push({ + x: (Math.random() - 0.5) * width, + y: (Math.random() - 0.5) * height, + z: Math.random(), + }); + } +} + +function drawStarfield(timestamp: number): void { + if (!ctx || !canvas || !running) { + rafHandle = requestAnimationFrame(drawStarfield); + return; + } + + const dt = Math.min((timestamp - lastTimestamp) / 1000, 0.1); + lastTimestamp = timestamp; + + const { width, height } = canvas; + ctx.fillStyle = 'rgba(8, 11, 18, 0.35)'; + ctx.fillRect(0, 0, width, height); + + const cx = width / 2; + const cy = height / 2; + const warp = speed * 60 * dt; + + for (const star of stars) { + const prevZ = star.z; + star.z -= (warp / Math.max(1, width)) * 2.2; + if (star.z <= 0.02) { + star.x = (Math.random() - 0.5) * width; + star.y = (Math.random() - 0.5) * height; + star.z = 1; + continue; + } + + const px = cx + (star.x / prevZ) * 0.08; + const py = cy + (star.y / prevZ) * 0.08; + const x = cx + (star.x / star.z) * 0.08; + const y = cy + (star.y / star.z) * 0.08; + + if (x < 0 || y < 0 || x > width || y > height) { + continue; + } + + const alpha = 1 - star.z; + const [r, g, b] = accentRgb; + ctx.strokeStyle = `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${alpha.toFixed(3)})`; + ctx.lineWidth = Math.max(1, (1 - star.z) * 2.5); + + ctx.beginPath(); + ctx.moveTo(px, py); + ctx.lineTo(x, y); + ctx.stroke(); + } + + rafHandle = requestAnimationFrame(drawStarfield); +} + +self.onmessage = (event: MessageEvent) => { + const message = event.data; + + if (message.type === 'init') { + canvas = message.canvas; + ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + canvas.width = Math.max(1, Math.floor(message.width)); + canvas.height = Math.max(1, Math.floor(message.height)); + density = message.density; + speed = message.speed; + accent = message.accent; + accentRgb = hexToRgb(accent); + running = message.running; + seedStars(canvas.width, canvas.height); + lastTimestamp = performance.now(); + rafHandle = requestAnimationFrame(drawStarfield); + return; + } + + if (message.type === 'params') { + if (message.density !== undefined && message.density !== density) { + density = message.density; + if (canvas) { + seedStars(canvas.width, canvas.height); + } + } + if (message.speed !== undefined) { + speed = message.speed; + } + if (message.accent !== undefined && message.accent !== accent) { + accent = message.accent; + accentRgb = hexToRgb(accent); + } + if (message.running !== undefined) { + running = message.running; + lastTimestamp = performance.now(); + } + return; + } + + if (message.type === 'resize' && canvas) { + canvas.width = Math.max(1, Math.floor(message.width)); + canvas.height = Math.max(1, Math.floor(message.height)); + seedStars(canvas.width, canvas.height); + } +};