mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-02 22:01:01 +02:00
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:
parent
28d2434843
commit
1ca5941491
6 changed files with 947 additions and 0 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
188
apps/dashboard/src/lib/graph/cinema/director.ts
Normal file
188
apps/dashboard/src/lib/graph/cinema/director.ts
Normal 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;
|
||||
}
|
||||
125
apps/dashboard/src/lib/graph/cinema/narrator.ts
Normal file
125
apps/dashboard/src/lib/graph/cinema/narrator.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
163
apps/dashboard/src/lib/graph/cinema/pathfinder.ts
Normal file
163
apps/dashboard/src/lib/graph/cinema/pathfinder.ts
Normal 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 };
|
||||
}
|
||||
171
apps/dashboard/src/lib/graph/cinema/sandbox.ts
Normal file
171
apps/dashboard/src/lib/graph/cinema/sandbox.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
209
apps/dashboard/src/lib/graph/cinema/storm.ts
Normal file
209
apps/dashboard/src/lib/graph/cinema/storm.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue