// 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; setSize: (w: number, h: number) => void; setPixelRatio: (r: number) => void; renderAsync: (scene: THREE.Scene, camera: THREE.Camera) => Promise; computeAsync: (node: unknown) => Promise; domElement: HTMLCanvasElement; dispose?: () => void; }; PostProcessing: new (renderer: unknown) => { renderAsync: () => Promise; 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 } | 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 { 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) => 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 }; } 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 { 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; } }