vestige/apps/dashboard/src/lib/graph/cinema/sandbox.ts
Sam Valladares 4163f4fc80 fix(cinema): contain the particle storm on-screen (soft sphere + velocity clamp)
Particles (esp. the unbounded Rössler chaos mode) could fly off-screen. Add a
camera-frame-sized spherical containment field: spring pull-back past the
radius, hard velocity clamp, and a snap-to-shell safety net so no particle can
escape. The sandbox sizes the radius from camera distance + vfov each frame so
the storm reframes as the camera flies. Verified: check + build green.
2026-06-22 00:14:29 -05:00

201 lines
8.1 KiB
TypeScript

// Memory Cinema — the isolated WebGPU sandbox.
//
// Boots a SEPARATE WebGPU canvas + scene on Cinema launch. The legacy WebGL
// graph (nebula, grain, every current user's experience) is never touched —
// zero regression by construction. Inside the sandbox: the SemanticComputeStorm
// + selective MRT emissive bloom, driven by the CinemaDirector's beats.
//
// Everything here is dynamically imported (three/webgpu, three/tsl, storm.ts)
// so the heavy WebGPU bundle stays out of the main app. If WebGPU is
// unavailable, isSupported() returns false and the caller falls back to the
// camera-only flythrough on the existing canvas (captions still play).
import * as THREE from 'three';
import type { SemanticRole, SemanticComputeStorm } from './storm';
export function isWebGPUSupported(): boolean {
return typeof navigator !== 'undefined' && 'gpu' in navigator;
}
interface SandboxDeps {
WebGPURenderer: new (params: object) => {
init: () => Promise<void>;
setSize: (w: number, h: number) => void;
setPixelRatio: (r: number) => void;
renderAsync: (scene: THREE.Scene, camera: THREE.Camera) => Promise<void>;
computeAsync: (node: unknown) => Promise<void>;
domElement: HTMLCanvasElement;
dispose?: () => void;
};
PostProcessing: new (renderer: unknown) => { renderAsync: () => Promise<void>; outputNode: unknown };
StormCtor: typeof SemanticComputeStorm;
tsl: typeof import('three/tsl');
bloomMod: { bloom: (node: unknown, strength?: number, radius?: number, threshold?: number) => unknown };
}
export class CinemaSandbox {
private container: HTMLElement;
private deps!: SandboxDeps;
private renderer!: SandboxDeps['WebGPURenderer']['prototype'];
// Scene/camera are created in boot() from the three/webgpu module so every
// object handed to the WebGPU renderer comes from the SAME Three.js instance
// (avoids the "multiple instances of Three.js" incompatibility — the base
// three import is used only for the shared Vector3 math type the director
// mutates, which is identical across instances).
private scene!: THREE.Scene;
private camera!: THREE.PerspectiveCamera;
private storm!: SemanticComputeStorm;
private post: { renderAsync: () => Promise<void> } | null = null;
private booted = false;
/** Camera target the director drives; mirrored into camera.lookAt each frame. */
readonly target = new THREE.Vector3(0, 0, 0);
constructor(container: HTMLElement) {
this.container = container;
}
get cameraRef(): THREE.PerspectiveCamera {
return this.camera;
}
/**
* Boot the WebGPU pipeline. Throws if WebGPU is unsupported or init fails —
* the caller treats a throw as "fall back to camera-only mode".
*/
async boot(): Promise<void> {
if (this.booted) return;
if (!isWebGPUSupported()) throw new Error('WebGPU not supported');
// Dynamic imports keep three/webgpu out of the main bundle.
const webgpu = (await import('three/webgpu')) as unknown as {
WebGPURenderer: SandboxDeps['WebGPURenderer'];
PostProcessing: SandboxDeps['PostProcessing'];
Scene: new () => THREE.Scene;
PerspectiveCamera: new (fov: number, aspect: number, near: number, far: number) => THREE.PerspectiveCamera;
Color: new (hex: number) => THREE.Color;
};
const tsl = (await import('three/tsl')) as typeof import('three/tsl');
// bloom() lives in the TSL display helpers; import the node module.
const bloomMod = (await import(
'three/examples/jsm/tsl/display/BloomNode.js'
)) as unknown as SandboxDeps['bloomMod'];
const { SemanticComputeStorm } = await import('./storm');
this.deps = {
WebGPURenderer: webgpu.WebGPURenderer,
PostProcessing: webgpu.PostProcessing,
StormCtor: SemanticComputeStorm,
tsl,
bloomMod,
};
// Fail loud if the dynamic import didn't yield the expected constructors,
// instead of a cryptic "undefined is not a constructor" later.
if (!webgpu.WebGPURenderer || !webgpu.Scene || !webgpu.PerspectiveCamera || !webgpu.Color) {
throw new Error('[cinema] three/webgpu is missing expected exports');
}
// Build scene + camera from the SAME (webgpu) module instance the
// renderer + storm use, so all objects are instance-compatible.
const w = Math.max(1, this.container.clientWidth);
const h = Math.max(1, this.container.clientHeight);
this.scene = new webgpu.Scene();
this.scene.background = new webgpu.Color(0x02020a);
this.camera = new webgpu.PerspectiveCamera(60, w / h, 0.1, 2000);
this.camera.position.set(0, 18, 60);
const renderer = new this.deps.WebGPURenderer({ antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(w, h);
// CRITICAL FOOTGUN: WebGPU init is async. Must await before first render
// or the canvas silently draws nothing.
await renderer.init();
this.container.appendChild(renderer.domElement);
this.renderer = renderer;
// The compute storm (150k GPU particles).
this.storm = new this.deps.StormCtor(renderer, this.scene, {});
// Selective MRT bloom: scene pass emits an emissive MRT; bloom only the
// emissive channel so the storm blazes against the void without washing
// the whole frame to grey. Falls back to a plain pass if MRT setup
// throws on a given driver.
try {
const { pass, mrt, output, emissive } = this.deps.tsl as unknown as {
pass: (s: THREE.Scene, c: THREE.Camera) => {
setMRT: (m: unknown) => void;
getTextureNode: (name: string) => unknown;
};
mrt: (cfg: Record<string, unknown>) => unknown;
output: unknown;
emissive: unknown;
};
const scenePass = pass(this.scene, this.camera);
if (typeof scenePass?.setMRT !== 'function' || typeof scenePass?.getTextureNode !== 'function') {
throw new Error('three/tsl pass() API mismatch — setMRT/getTextureNode missing');
}
scenePass.setMRT(mrt({ output, emissive }));
const outputTex = scenePass.getTextureNode('output');
const emissiveTex = scenePass.getTextureNode('emissive');
const bloomed = this.deps.bloomMod.bloom(emissiveTex, 1.1, 0.6, 0.0);
const post = new this.deps.PostProcessing(renderer);
(post as unknown as { outputNode: unknown }).outputNode = (
outputTex as { add: (n: unknown) => unknown }
).add(bloomed);
this.post = post as unknown as { renderAsync: () => Promise<void> };
} catch (e) {
// MRT/bloom unavailable on this driver — render straight, no crash.
console.warn('[cinema] selective bloom unavailable, rendering without MRT:', e);
this.post = null;
}
this.booted = true;
}
/** Retarget the storm + look the camera at the beat (called by the director). */
transitionTo(role: SemanticRole, worldPos: THREE.Vector3): void {
if (!this.booted) return;
this.storm.transitionTo(role, worldPos);
}
/** Render one frame. Camera is driven externally (director mutates position/target).
* A single frame's failure must not crash the tour — it's caught and surfaced
* via a thrown error the caller already handles (drops to camera-only). */
async render(deltaSeconds: number): Promise<void> {
if (!this.booted) return;
this.camera.lookAt(this.target);
// Keep the storm inside the frame: derive the largest world radius that
// fully fits the camera's vertical FOV at the current distance to target,
// minus a margin so the glow halo stays on-screen too. The storm clamps
// itself to this each frame, so it reframes as the camera flies.
const dist = this.camera.position.distanceTo(this.target);
const vfov = (this.camera.fov * Math.PI) / 180;
const fitRadius = Math.tan(vfov / 2) * dist * 0.62; // 0.62 = on-screen margin
this.storm.setContainRadius(fitRadius);
await this.storm.update(deltaSeconds);
if (this.post) await this.post.renderAsync();
else await this.renderer.renderAsync(this.scene, this.camera);
}
resize(): void {
if (!this.booted) return;
const w = Math.max(1, this.container.clientWidth);
const h = Math.max(1, this.container.clientHeight);
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
}
dispose(): void {
if (!this.booted) return;
this.storm?.dispose();
this.renderer?.dispose?.();
if (this.renderer?.domElement?.parentNode) {
this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
}
this.booted = false;
}
}