diff --git a/apps/dashboard/src/lib/graph/cinema/__tests__/pathfinder.test.ts b/apps/dashboard/src/lib/graph/cinema/__tests__/pathfinder.test.ts new file mode 100644 index 0000000..40aeef6 --- /dev/null +++ b/apps/dashboard/src/lib/graph/cinema/__tests__/pathfinder.test.ts @@ -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); + }); +}); diff --git a/apps/dashboard/src/lib/graph/cinema/director.ts b/apps/dashboard/src/lib/graph/cinema/director.ts new file mode 100644 index 0000000..75ba56f --- /dev/null +++ b/apps/dashboard/src/lib/graph/cinema/director.ts @@ -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; + private path: CinemaPath; + private cb: DirectorCallbacks; + private opts: Required; + + 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, + 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; +} diff --git a/apps/dashboard/src/lib/graph/cinema/narrator.ts b/apps/dashboard/src/lib/graph/cinema/narrator.ts new file mode 100644 index 0000000..9325cb7 --- /dev/null +++ b/apps/dashboard/src/lib/graph/cinema/narrator.ts @@ -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 = { + 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 +): Promise { + const fallback = localCaptions(path); + if (!fetchBackend) return fallback; + + try { + const withTimeout = Promise.race([ + fetchBackend(), + new Promise((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; + } +} diff --git a/apps/dashboard/src/lib/graph/cinema/pathfinder.ts b/apps/dashboard/src/lib/graph/cinema/pathfinder.ts new file mode 100644 index 0000000..76f27d5 --- /dev/null +++ b/apps/dashboard/src/lib/graph/cinema/pathfinder.ts @@ -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([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 }; +} diff --git a/apps/dashboard/src/lib/graph/cinema/sandbox.ts b/apps/dashboard/src/lib/graph/cinema/sandbox.ts new file mode 100644 index 0000000..2243898 --- /dev/null +++ b/apps/dashboard/src/lib/graph/cinema/sandbox.ts @@ -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; + 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']; + private scene = new 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; + 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 { + 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) => 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 }; + } 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 { + 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; + } +} diff --git a/apps/dashboard/src/lib/graph/cinema/storm.ts b/apps/dashboard/src/lib/graph/cinema/storm.ts new file mode 100644 index 0000000..e7e6ca9 --- /dev/null +++ b/apps/dashboard/src/lib/graph/cinema/storm.ts @@ -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 = { + 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 }; + + 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 }, + 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 { + 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(); + } +}