feat(dashboard): Memory Cinema engine — BFS director + narrator + WebGPU GPGPU storm

- pathfinder.ts: deterministic story-path BFS over real graph (origin→strongest→contradiction→recent), Tier-3 bulletproof base (8 tests)
- director.ts: cinematic camera choreography, reduced-motion jump-cut support
- narrator.ts: Tier-1 backend-LLM → Tier-2 local structured captions cascade
- storm.ts: 150k-particle TSL GPGPU SemanticComputeStorm (orbital/stream/Rössler-chaos modes) verified against installed three/tsl API (select not cond, SpriteNodeMaterial, computeAsync)
- sandbox.ts: isolated WebGPU canvas + selective MRT bloom, dynamically imported (zero main-bundle weight), graceful no-WebGPU fallback

WebGL graph untouched = zero regression. 926 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-21 20:16:21 -05:00
parent 28d2434843
commit 1ca5941491
6 changed files with 947 additions and 0 deletions

View file

@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { planCinemaPath } from '../pathfinder';
import { makeNode, makeEdge, resetNodeCounter } from '../../__tests__/helpers';
describe('planCinemaPath', () => {
beforeEach(() => resetNodeCounter());
it('returns an empty path for no nodes', () => {
const path = planCinemaPath([], [], 'missing');
expect(path.beats).toEqual([]);
expect(path.flowEdges).toEqual([]);
});
it('starts at the requested center when it exists', () => {
const a = makeNode({ id: 'a' });
const b = makeNode({ id: 'b' });
const path = planCinemaPath([a, b], [makeEdge('a', 'b')], 'a');
expect(path.beats[0].nodeId).toBe('a');
expect(path.beats[0].kind).toBe('origin');
expect(path.beats[0].viaEdge).toBeNull();
});
it('falls back to the most-connected node when center is missing', () => {
const hub = makeNode({ id: 'hub' });
const x = makeNode({ id: 'x' });
const y = makeNode({ id: 'y' });
const path = planCinemaPath(
[x, hub, y],
[makeEdge('hub', 'x'), makeEdge('hub', 'y')],
'does-not-exist'
);
expect(path.beats[0].nodeId).toBe('hub');
});
it('visits the strongest-weighted connection first', () => {
const a = makeNode({ id: 'a' });
const weak = makeNode({ id: 'weak' });
const strong = makeNode({ id: 'strong' });
const path = planCinemaPath(
[a, weak, strong],
[makeEdge('a', 'weak', { weight: 0.1 }), makeEdge('a', 'strong', { weight: 0.9 })],
'a'
);
expect(path.beats[1].nodeId).toBe('strong');
expect(path.beats[1].kind).toBe('connection');
});
it('detours through a contradiction edge when reachable', () => {
const a = makeNode({ id: 'a' });
const normal = makeNode({ id: 'normal' });
const conflict = makeNode({ id: 'conflict' });
const path = planCinemaPath(
[a, normal, conflict],
[
makeEdge('a', 'normal', { weight: 0.95, type: 'semantic' }),
makeEdge('a', 'conflict', { weight: 0.2, type: 'contradiction' }),
],
'a'
);
const kinds = path.beats.map((b) => b.kind);
expect(kinds).toContain('contradiction');
// The contradiction beat carries max intensity.
const c = path.beats.find((b) => b.kind === 'contradiction');
expect(c?.intensity).toBe(1);
});
it('never exceeds maxBeats and never repeats a node', () => {
const nodes = Array.from({ length: 20 }, (_, i) => makeNode({ id: `n${i}` }));
const edges = nodes.slice(1).map((n) => makeEdge('n0', n.id, { weight: Math.random() }));
const path = planCinemaPath(nodes, edges, 'n0', 5);
expect(path.beats.length).toBeLessThanOrEqual(5);
const ids = path.beats.map((b) => b.nodeId);
expect(new Set(ids).size).toBe(ids.length);
});
it('is deterministic — same inputs yield the same path', () => {
const nodes = [makeNode({ id: 'a' }), makeNode({ id: 'b' }), makeNode({ id: 'c' })];
const edges = [makeEdge('a', 'b', { weight: 0.8 }), makeEdge('b', 'c', { weight: 0.6 })];
const p1 = planCinemaPath(nodes, edges, 'a');
const p2 = planCinemaPath(nodes, edges, 'a');
expect(p1.beats.map((b) => b.nodeId)).toEqual(p2.beats.map((b) => b.nodeId));
});
it('records flowEdges for each traversed connection', () => {
const a = makeNode({ id: 'a' });
const b = makeNode({ id: 'b' });
const path = planCinemaPath([a, b], [makeEdge('a', 'b', { weight: 0.7 })], 'a');
expect(path.flowEdges.length).toBeGreaterThanOrEqual(1);
expect(path.flowEdges[0].source === 'a' || path.flowEdges[0].target === 'a').toBe(true);
});
});

View file

@ -0,0 +1,188 @@
// Memory Cinema — the camera director.
//
// Drives a smooth, cinematic camera flight through a planned CinemaPath. Pure
// choreography: it mutates a THREE.PerspectiveCamera + an OrbitControls-like
// target each frame and emits beat-arrival callbacks the narrator + sandbox
// hook into. It knows nothing about which renderer (WebGL/WebGPU) is on screen,
// so it works identically for the legacy graph and the WebGPU sandbox.
//
// Respects prefers-reduced-motion: when reduced, it JUMP-CUTS between beats
// (instant position, dwell, advance) instead of flying — captions still fire.
import * as THREE from 'three';
import type { CinemaPath, CinemaBeat } from './pathfinder';
export interface DirectorCallbacks {
/** Fired once when the camera arrives at (or cuts to) a beat. */
onBeat?: (beat: CinemaBeat, index: number) => void;
/** Fired when the whole tour finishes. */
onComplete?: () => void;
/** Fired every frame with overall progress 0..1 (for a scrubber/progress bar). */
onProgress?: (t: number) => void;
}
export interface DirectorOptions {
/** Seconds of camera flight between consecutive beats. */
flightSeconds?: number;
/** Seconds the camera dwells on each beat before advancing. */
dwellSeconds?: number;
/** Stand-off distance from the focused node, in world units. */
standoff?: number;
/** Instant cuts instead of flights (prefers-reduced-motion). */
reducedMotion?: boolean;
}
type Phase = 'idle' | 'flying' | 'dwelling' | 'done';
const _tmpDir = new THREE.Vector3();
const _tmpUp = new THREE.Vector3(0, 1, 0);
export class CinemaDirector {
private camera: THREE.PerspectiveCamera;
private target: THREE.Vector3;
private positions: Map<string, THREE.Vector3>;
private path: CinemaPath;
private cb: DirectorCallbacks;
private opts: Required<DirectorOptions>;
private phase: Phase = 'idle';
private beatIndex = 0;
private phaseElapsed = 0;
// Flight interpolation endpoints.
private fromPos = new THREE.Vector3();
private toPos = new THREE.Vector3();
private fromTarget = new THREE.Vector3();
private toTarget = new THREE.Vector3();
constructor(
camera: THREE.PerspectiveCamera,
target: THREE.Vector3,
positions: Map<string, THREE.Vector3>,
path: CinemaPath,
cb: DirectorCallbacks = {},
opts: DirectorOptions = {}
) {
this.camera = camera;
this.target = target;
this.positions = positions;
this.path = path;
this.cb = cb;
this.opts = {
flightSeconds: opts.flightSeconds ?? 2.4,
dwellSeconds: opts.dwellSeconds ?? 3.2,
standoff: opts.standoff ?? 26,
reducedMotion: opts.reducedMotion ?? false,
};
}
get totalBeats(): number {
return this.path.beats.length;
}
get isRunning(): boolean {
return this.phase !== 'idle' && this.phase !== 'done';
}
/** Begin the tour from the first beat. */
start(): void {
if (this.path.beats.length === 0) {
this.phase = 'done';
this.cb.onComplete?.();
return;
}
this.beatIndex = 0;
this.beginFlightTo(0);
}
stop(): void {
this.phase = 'done';
}
/** Compute the camera stand-off position for a beat's node. */
private framePosition(beat: CinemaBeat, out: THREE.Vector3): THREE.Vector3 {
const nodePos = this.positions.get(beat.nodeId);
if (!nodePos) {
// Node has no resolved position yet — keep current framing.
return out.copy(this.camera.position);
}
// Offset back + up from the node along the current view direction so the
// node sits centered with a cinematic slightly-above angle.
_tmpDir.copy(this.camera.position).sub(nodePos);
if (_tmpDir.lengthSq() < 1e-4) _tmpDir.set(0, 0.4, 1);
_tmpDir.normalize();
// Bias the approach vector upward a touch for a filmic tilt.
_tmpDir.addScaledVector(_tmpUp, 0.35).normalize();
return out.copy(nodePos).addScaledVector(_tmpDir, this.opts.standoff);
}
private beginFlightTo(index: number): void {
const beat = this.path.beats[index];
const nodePos = this.positions.get(beat.nodeId);
this.fromPos.copy(this.camera.position);
this.fromTarget.copy(this.target);
this.framePosition(beat, this.toPos);
this.toTarget.copy(nodePos ?? this.target);
this.phaseElapsed = 0;
if (this.opts.reducedMotion) {
// Jump-cut: snap, fire the beat, go straight to dwelling.
this.camera.position.copy(this.toPos);
this.target.copy(this.toTarget);
this.phase = 'dwelling';
this.cb.onBeat?.(beat, index);
} else {
this.phase = 'flying';
}
}
/** Advance the choreography. Call once per animation frame with delta seconds. */
update(deltaSeconds: number): void {
if (this.phase === 'idle' || this.phase === 'done') return;
// Clamp dt so a tab-switch stall doesn't teleport the camera.
const dt = Math.min(deltaSeconds, 0.05);
this.phaseElapsed += dt;
if (this.phase === 'flying') {
const t = Math.min(1, this.phaseElapsed / this.opts.flightSeconds);
const e = easeInOutCubic(t);
this.camera.position.lerpVectors(this.fromPos, this.toPos, e);
this.target.lerpVectors(this.fromTarget, this.toTarget, e);
if (t >= 1) {
this.phase = 'dwelling';
this.phaseElapsed = 0;
this.cb.onBeat?.(this.path.beats[this.beatIndex], this.beatIndex);
}
} else if (this.phase === 'dwelling') {
// Gentle drift during the dwell keeps the shot alive (skipped if reduced).
if (!this.opts.reducedMotion) {
const nodePos = this.positions.get(this.path.beats[this.beatIndex].nodeId);
if (nodePos) this.target.lerp(nodePos, 0.02);
}
if (this.phaseElapsed >= this.opts.dwellSeconds) {
const nextIndex = this.beatIndex + 1;
if (nextIndex >= this.path.beats.length) {
this.phase = 'done';
this.cb.onProgress?.(1);
this.cb.onComplete?.();
return;
}
this.beatIndex = nextIndex;
this.beginFlightTo(nextIndex);
}
}
// Overall progress across the whole tour (beat + intra-beat fraction).
const per = 1 / this.path.beats.length;
const intra =
this.phase === 'flying'
? Math.min(1, this.phaseElapsed / this.opts.flightSeconds) * 0.5
: 0.5 + Math.min(1, this.phaseElapsed / this.opts.dwellSeconds) * 0.5;
this.cb.onProgress?.(Math.min(1, this.beatIndex * per + intra * per));
}
}
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}

View file

@ -0,0 +1,125 @@
// Memory Cinema — narration tiers 1 & 2.
//
// Tier 1 (premium): a backend LLM endpoint (/api/narrative) authors rich prose
// from the planned path. Used only when the backend advertises it.
// Tier 2 (smart local default): deterministic, structured captions generated
// purely from the real node/edge data — no network, no LLM, instant. This is
// what the static HN demo and any backend-without-LLM setup uses.
//
// Tier 3 (the BFS camera engine in director.ts) always runs underneath; the
// narrator only decides what TEXT accompanies each beat. If everything here
// fails, captions fall back to Tier 2, which cannot fail.
import type { CinemaBeat, CinemaPath } from './pathfinder';
export interface BeatNarration {
nodeId: string;
/** The caption shown + optionally spoken for this beat. */
text: string;
/** Short label for the beat kind, shown as a chip. */
chip: string;
}
export type NarrationSource = 'backend-llm' | 'local-captions';
export interface CinemaNarration {
source: NarrationSource;
beats: BeatNarration[];
}
const KIND_CHIP: Record<CinemaBeat['kind'], string> = {
origin: 'Origin',
connection: 'Connection',
contradiction: 'Tension',
recent: 'Now',
bridge: 'Jump',
};
function snippet(content: string, max = 90): string {
const s = (content ?? '').replace(/\s+/g, ' ').trim();
if (s.length <= max) return s;
return s.slice(0, max - 1).trimEnd() + '…';
}
function typeLabel(nodeType: string): string {
const t = (nodeType ?? 'memory').toLowerCase();
return t.charAt(0).toUpperCase() + t.slice(1);
}
/**
* Tier 2 deterministic structured captions from real data only.
* Never throws; always returns a caption per beat.
*/
export function localCaptions(path: CinemaPath): CinemaNarration {
const beats: BeatNarration[] = path.beats.map((beat, i) => {
const n = beat.node;
const what = snippet(n.label || `(${typeLabel(n.type)} memory)`);
let text: string;
switch (beat.kind) {
case 'origin':
text = `We begin at a ${typeLabel(n.type).toLowerCase()} the graph is centered on — "${what}".`;
break;
case 'contradiction': {
const via = beat.viaEdge?.type ? beat.viaEdge.type.replace(/_/g, ' ') : 'a conflict';
text = `This is held in tension with the last memory through ${via}: "${what}".`;
break;
}
case 'recent':
text = `And where the mind is now — a recent memory: "${what}".`;
break;
case 'bridge':
text = `Crossing to a separate cluster — "${what}".`;
break;
default: {
const w = beat.viaEdge?.weight ?? 0;
const strength = w > 0.66 ? 'strongly' : w > 0.33 ? 'closely' : 'loosely';
text = `${strength} connected from there: a ${typeLabel(n.type).toLowerCase()} — "${what}".`;
}
}
// Tags add texture when present.
if (n.tags && n.tags.length > 0 && i > 0) {
text += ` [${n.tags.slice(0, 3).join(', ')}]`;
}
return { nodeId: beat.nodeId, text, chip: KIND_CHIP[beat.kind] };
});
return { source: 'local-captions', beats };
}
/**
* Resolve the best available narration for a path.
*
* @param fetchBackend optional async fn that returns backend-LLM narration
* beats (Tier 1). If it's absent, rejects, times out, or returns a mismatched
* shape, we silently fall back to Tier 2 local captions. The caller passes
* this only when the backend has advertised /api/narrative support.
*/
export async function resolveNarration(
path: CinemaPath,
fetchBackend?: () => Promise<BeatNarration[] | null>
): Promise<CinemaNarration> {
const fallback = localCaptions(path);
if (!fetchBackend) return fallback;
try {
const withTimeout = Promise.race([
fetchBackend(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 6000)),
]);
const backend = await withTimeout;
if (!Array.isArray(backend) || backend.length === 0) return fallback;
// Align backend beats to the real path by nodeId; fill any gaps from
// local captions so every beat always has text (never a blank shot).
const byNode = new Map(backend.map((b) => [b.nodeId, b]));
const beats: BeatNarration[] = path.beats.map((beat, i) => {
const hit = byNode.get(beat.nodeId);
if (hit && typeof hit.text === 'string' && hit.text.trim()) {
return { nodeId: beat.nodeId, text: hit.text, chip: hit.chip || KIND_CHIP[beat.kind] };
}
return fallback.beats[i];
});
return { source: 'backend-llm', beats };
} catch {
return fallback;
}
}

View file

@ -0,0 +1,163 @@
// Memory Cinema — Tier 3: the bulletproof pathfinder.
//
// Plans a cinematic tour through the REAL memory graph using nothing but the
// nodes + edges the backend already returns. This is the deterministic engine
// that ALWAYS drives the camera, regardless of which narration tier (backend
// LLM / local captions / none) is active. No WebGPU, no network, no LLM — if
// everything else fails, this still produces a coherent, watchable flythrough.
//
// The path is intentionally a STORY, not a raw BFS dump:
// 1. start at the center (the memory the graph is focused on)
// 2. visit its strongest-weighted connections (what it's most tied to)
// 3. detour to a contradiction edge if one exists (tension = interesting)
// 4. end on a recently-created node (where the mind is now)
// Falling back to plain weighted BFS when those signals are absent.
import type { GraphNode, GraphEdge } from '$types';
export interface CinemaBeat {
/** Node this beat centers the camera on. */
nodeId: string;
/** The node payload, for the narrator + visuals. */
node: GraphNode;
/** Edge traversed to arrive here (null for the opening beat). */
viaEdge: GraphEdge | null;
/** Why this beat exists — drives the deterministic caption + visual emphasis. */
kind: 'origin' | 'connection' | 'contradiction' | 'recent' | 'bridge';
/** 0..1 emphasis used by the sandbox to spike emissive/bloom on arrival. */
intensity: number;
}
export interface CinemaPath {
beats: CinemaBeat[];
centerId: string;
/** Edges that should visibly "flow" during the tour, in beat order. */
flowEdges: GraphEdge[];
}
interface Adjacency {
[nodeId: string]: { edge: GraphEdge; otherId: string }[];
}
function buildAdjacency(edges: GraphEdge[]): Adjacency {
const adj: Adjacency = {};
for (const edge of edges) {
(adj[edge.source] ??= []).push({ edge, otherId: edge.target });
(adj[edge.target] ??= []).push({ edge, otherId: edge.source });
}
// Strongest connections first so the tour visits the most meaningful ties.
for (const id of Object.keys(adj)) {
adj[id].sort((a, b) => (b.edge.weight ?? 0) - (a.edge.weight ?? 0));
}
return adj;
}
function isContradictionEdge(edge: GraphEdge): boolean {
const t = (edge.type ?? '').toLowerCase();
return t.includes('contradict') || t.includes('conflict') || t.includes('supersede');
}
function recencyOf(node: GraphNode): number {
// Larger = more recent. Tolerates missing/invalid timestamps.
const t = Date.parse(node.updatedAt || node.createdAt || '');
return Number.isFinite(t) ? t : 0;
}
/**
* Plan a cinematic path over the real graph.
*
* @param maxBeats hard cap on tour length (keeps the flythrough watchable).
* Deterministic: same inputs always yield the same path (no randomness), so the
* recorded launch GIF is reproducible.
*/
export function planCinemaPath(
nodes: GraphNode[],
edges: GraphEdge[],
centerId: string,
maxBeats = 7
): CinemaPath {
const byId = new Map(nodes.map((n) => [n.id, n]));
const empty: CinemaPath = { beats: [], centerId, flowEdges: [] };
if (nodes.length === 0) return empty;
// Resolve a real starting node: prefer centerId, else the explicit center
// flag, else the most-connected node, else the first node.
const adj = buildAdjacency(edges);
let startId = byId.has(centerId) ? centerId : '';
if (!startId) startId = nodes.find((n) => (n as { isCenter?: boolean }).isCenter)?.id ?? '';
if (!startId) {
startId = nodes
.map((n) => ({ id: n.id, deg: adj[n.id]?.length ?? 0 }))
.sort((a, b) => b.deg - a.deg)[0].id;
}
const start = byId.get(startId);
if (!start) return empty;
const visited = new Set<string>([startId]);
const beats: CinemaBeat[] = [
{ nodeId: startId, node: start, viaEdge: null, kind: 'origin', intensity: 1 },
];
const flowEdges: GraphEdge[] = [];
// Greedy weighted walk: from the current frontier, step to the strongest
// unvisited neighbour, with a one-time detour to a contradiction if reachable.
let current = startId;
let contradictionUsed = false;
while (beats.length < maxBeats) {
const neighbours = adj[current] ?? [];
// Prefer an unused contradiction edge once — tension makes a better story.
let next: { edge: GraphEdge; otherId: string } | undefined;
if (!contradictionUsed) {
next = neighbours.find((n) => !visited.has(n.otherId) && isContradictionEdge(n.edge));
if (next) contradictionUsed = true;
}
// Otherwise the strongest unvisited tie.
if (!next) next = neighbours.find((n) => !visited.has(n.otherId));
// Dead end: hop to the most recent unvisited node anywhere (a "bridge"
// cut) so the tour can keep going instead of stalling.
if (!next) {
const remaining = nodes
.filter((n) => !visited.has(n.id))
.sort((a, b) => recencyOf(b) - recencyOf(a));
if (remaining.length === 0) break;
const node = remaining[0];
visited.add(node.id);
beats.push({ nodeId: node.id, node, viaEdge: null, kind: 'bridge', intensity: 0.6 });
current = node.id;
continue;
}
const node = byId.get(next.otherId);
if (!node) {
visited.add(next.otherId);
continue;
}
visited.add(node.id);
flowEdges.push(next.edge);
beats.push({
nodeId: node.id,
node,
viaEdge: next.edge,
kind: isContradictionEdge(next.edge) ? 'contradiction' : 'connection',
intensity: isContradictionEdge(next.edge) ? 1 : Math.min(1, 0.55 + (next.edge.weight ?? 0) * 0.45),
});
current = node.id;
}
// Closing beat: end on the single most-recent node not already the finale,
// so the tour lands on "where the memory is now". Only if it adds variety.
if (beats.length < maxBeats) {
const last = beats[beats.length - 1].nodeId;
const recent = nodes
.filter((n) => n.id !== last)
.sort((a, b) => recencyOf(b) - recencyOf(a))[0];
if (recent && !beats.some((b) => b.nodeId === recent.id)) {
beats.push({ nodeId: recent.id, node: recent, viaEdge: null, kind: 'recent', intensity: 0.8 });
}
}
return { beats, centerId: startId, flowEdges };
}

View file

@ -0,0 +1,171 @@
// 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'];
private scene = new 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;
this.camera = new THREE.PerspectiveCamera(
60,
container.clientWidth / Math.max(1, container.clientHeight),
0.1,
2000
);
this.camera.position.set(0, 18, 60);
this.scene.background = new THREE.Color(0x02020a);
}
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'];
};
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,
};
const renderer = new this.deps.WebGPURenderer({ antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(this.container.clientWidth, this.container.clientHeight);
// 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);
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). */
async render(deltaSeconds: number): Promise<void> {
if (!this.booted) return;
this.camera.lookAt(this.target);
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 = this.container.clientWidth;
const h = this.container.clientHeight;
this.camera.aspect = w / Math.max(1, 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;
}
}

View file

@ -0,0 +1,209 @@
// Memory Cinema — the Semantic Compute Storm (WebGPU / TSL GPGPU).
//
// 150k particles whose physics run ENTIRELY on the GPU via Three Shading
// Language compute nodes. The storm shifts behaviour with the narrative beat:
// - origin/anchor → stable orbital swarm around the focused node
// - connection → fluid streaming toward the target with wave motion
// - contradiction → explosive Rössler strange-attractor chaos (crimson)
// Emissive colour is routed so only the storm blazes through the selective
// MRT bloom pass against a clean void.
//
// IMPORTANT — verified against the INSTALLED three@0.172 three/tsl build:
// * use select() (NOT cond — does not exist in this build)
// * use TSL sin()/cos() (NOT Math.sin inside Fn)
// * SpriteNodeMaterial (NOT SpritePointsMaterial)
// * renderer.computeAsync() for the dispatch
// The whole module is dynamically imported only when Cinema launches, so the
// heavy three/webgpu + three/tsl bundles never load for normal dashboard use.
//
// This file is intentionally framework-agnostic and uses `any` for the WebGPU
// renderer type: three/webgpu's WebGPURenderer is a runtime-only dynamic import
// (kept out of the main bundle), so a compile-time type isn't available here.
import * as THREE from 'three';
// StorageBufferAttribute + SpriteNodeMaterial live in the three/webgpu entry,
// not the base three module. This file is dynamically imported only at Cinema
// launch, so pulling from three/webgpu here does NOT add WebGPU to the main
// bundle.
import { StorageBufferAttribute, SpriteNodeMaterial } from 'three/webgpu';
import {
Fn,
storage,
instanceIndex,
vec3,
uniform,
select,
float,
sin,
cos,
} from 'three/tsl';
export type SemanticRole = 'anchor' | 'connection' | 'contradiction';
const ROLE_MODE: Record<SemanticRole, number> = {
anchor: 0,
connection: 1,
contradiction: 2,
};
export interface StormOptions {
count?: number;
/** World-space radius of the initial particle cloud. */
spawnRadius?: number;
}
/**
* GPU compute particle storm. Construct with a WebGPURenderer + Scene, call
* update(dt) each frame, and transitionTo(role, worldPos) on each narrative
* beat. dispose() releases all GPU resources.
*/
export class SemanticComputeStorm {
readonly count: number;
private scene: THREE.Scene;
// WebGPURenderer — runtime-only type (dynamic import); see file header.
private renderer: { computeAsync: (node: unknown) => Promise<void> };
private bufferPos: StorageBufferAttribute;
private bufferVel: StorageBufferAttribute;
private bufferPhase: StorageBufferAttribute;
private computeNode: unknown;
private mesh!: THREE.Object3D;
private material!: THREE.Material;
// Uniforms driven from the camera/beat loop.
private uTarget = uniform(new THREE.Vector3(0, 0, 0));
private uTime = uniform(0);
private uIgnition = uniform(0);
private uMode = uniform(0);
constructor(
renderer: { computeAsync: (node: unknown) => Promise<void> },
scene: THREE.Scene,
opts: StormOptions = {}
) {
this.renderer = renderer;
this.scene = scene;
this.count = opts.count ?? 150_000;
const spawn = opts.spawnRadius ?? 15;
const positions = new Float32Array(this.count * 3);
const velocities = new Float32Array(this.count * 3);
const phases = new Float32Array(this.count);
for (let i = 0; i < this.count; i++) {
positions[i * 3] = (Math.random() - 0.5) * spawn;
positions[i * 3 + 1] = (Math.random() - 0.5) * spawn;
positions[i * 3 + 2] = (Math.random() - 0.5) * spawn;
phases[i] = Math.random() * Math.PI * 2;
}
this.bufferPos = new StorageBufferAttribute(positions, 3);
this.bufferVel = new StorageBufferAttribute(velocities, 3);
this.bufferPhase = new StorageBufferAttribute(phases, 1);
this.buildCompute();
this.buildRender();
}
private buildCompute(): void {
const posStore = storage(this.bufferPos, 'vec3', this.count);
const velStore = storage(this.bufferVel, 'vec3', this.count);
const phaseStore = storage(this.bufferPhase, 'float', this.count);
this.computeNode = Fn(() => {
const pos = posStore.element(instanceIndex);
const vel = velStore.element(instanceIndex);
const phase = phaseStore.element(instanceIndex);
const toTarget = vec3(this.uTarget).sub(pos);
// Mode 0 — Anchor: orbital swirl (tangential velocity around target).
const orbital = vec3(toTarget.z, float(0), toTarget.x.negate())
.normalize()
.mul(0.05);
// Mode 1 — Connection: stream toward target + per-particle wave.
const wave = vec3(
sin(this.uTime.add(phase)).mul(0.02),
cos(this.uTime.add(phase)).mul(0.02),
sin(this.uTime.mul(1.5).add(phase)).mul(0.02)
);
const stream = toTarget.normalize().mul(0.08).add(wave);
// Mode 2 — Contradiction: Rössler strange-attractor chaos.
const dt = float(0.01);
const dx = vel.y.negate().sub(vel.z).mul(dt);
const dy = vel.x.add(vel.y.mul(0.2)).mul(dt);
const dz = float(0.2).add(vel.z.mul(vel.x.sub(5.7))).mul(dt);
const chaos = vec3(dx, dy, dz).mul(2.0);
// Runtime mode selection (select(), not cond()).
const active = select(
this.uMode.equal(0),
orbital,
select(this.uMode.equal(1), stream, chaos)
);
vel.addAssign(active);
// Ignition shockwave yanks particles toward the new node on each beat.
vel.addAssign(toTarget.normalize().mul(this.uIgnition.mul(0.02)));
pos.addAssign(vel);
vel.mulAssign(0.95);
})().compute(this.count);
}
private buildRender(): void {
// SpriteNodeMaterial: emissive routed to bloom; additive against the void.
const mat = new SpriteNodeMaterial({
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
}) as SpriteNodeMaterial & { positionNode: unknown; colorNode: unknown };
mat.positionNode = storage(this.bufferPos, 'vec3', this.count).element(instanceIndex);
mat.colorNode = Fn(() => {
const anchor = vec3(0.0, 1.0, 0.85); // luminescent cyan
const link = vec3(0.2, 0.4, 1.0); // electric royal blue
const contradiction = vec3(1.0, 0.1, 0.3); // crimson neon
const base = select(
this.uMode.equal(0),
anchor,
select(this.uMode.equal(1), link, contradiction)
);
// Brighten on ignition so the beat blazes through the bloom pass.
return base.mul(this.uIgnition.mul(3.0).add(0.4));
})();
// One instanced sprite per particle; positions come from the GPU storage
// buffer via positionNode, so the geometry is a single unit quad and the
// instance count is the particle count.
const geometry = new THREE.PlaneGeometry(0.18, 0.18);
const mesh = new THREE.InstancedMesh(geometry, mat as unknown as THREE.Material, this.count);
mesh.frustumCulled = false;
this.material = mat;
this.mesh = mesh;
this.scene.add(this.mesh);
}
/** Advance the GPU physics one frame. */
async update(deltaSeconds: number): Promise<void> {
this.uTime.value += deltaSeconds;
if (this.uIgnition.value > 0) {
this.uIgnition.value = Math.max(0, this.uIgnition.value - deltaSeconds * 2.0);
}
await this.renderer.computeAsync(this.computeNode);
}
/** Fired on each narrative beat: retarget the storm + spike ignition. */
transitionTo(role: SemanticRole, worldPos: THREE.Vector3): void {
this.uTarget.value.copy(worldPos);
this.uMode.value = ROLE_MODE[role] ?? 1;
this.uIgnition.value = 8.0;
}
dispose(): void {
this.scene.remove(this.mesh);
(this.mesh as THREE.InstancedMesh).geometry?.dispose();
this.material?.dispose();
}
}