Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/renderer/src/app/features/canvas-lab/canvas-lab-page.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@
</div>
</mat-card-content>
</mat-card>
<mat-card class="pane-card">
<mat-card-header>
<mat-card-title>OffscreenCanvas — Worker Starfield</mat-card-title>
<mat-card-subtitle> {{ workerStatus() }} </mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<div class="canvas-frame">
<canvas
#offscreenCanvas
class="lab-canvas"
aria-label="Worker-rendered warp starfield"
></canvas>
</div>
</mat-card-content>
</mat-card>
</div>

<mat-card class="controls-card">
Expand Down
77 changes: 77 additions & 0 deletions apps/renderer/src/app/features/canvas-lab/canvas-lab-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
DestroyRef,
ElementRef,
afterNextRender,
effect,
inject,
signal,
viewChild,
Expand Down Expand Up @@ -57,6 +58,11 @@ export class CanvasLabPage {
viewChild.required<ElementRef<HTMLCanvasElement>>('waveCanvas');
readonly glCanvas =
viewChild.required<ElementRef<HTMLCanvasElement>>('glCanvas');
readonly offscreenCanvas =
viewChild.required<ElementRef<HTMLCanvasElement>>('offscreenCanvas');

readonly offscreenSupported = signal(false);
readonly workerStatus = signal('Probing…');

readonly density = signal(48);
readonly speed = signal(10);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -116,6 +138,7 @@ export class CanvasLabPage {
private initialize(): void {
this.initParticleField();
this.initWebGl();
this.initOffscreenWorker();
this.observeResize();
this.scheduleFrame();

Expand All @@ -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');
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
166 changes: 166 additions & 0 deletions apps/renderer/src/app/features/canvas-lab/render.worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/// <reference lib="webworker" />

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<WorkerMessage>) => {
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);
}
};
Loading