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);
+ }
+};