vestige/apps/dashboard/src/lib/graph/cinema/storm.ts
Sam Valladares d9fb791af6 refactor(cinema): canonical sprite center (positionNode = instancePos)
SpriteNodeMaterial.setupPositionView already rebuilds the billboard quad from
positionGeometry — the prior .add(positionLocal) double-counted it (harmless at
0.1 size). Bare center is required for the upcoming velocity-stretch streak
(scaleNode/rotationNode will drive the quad). Verified renders identically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 13:53:52 -05:00

873 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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,
length,
clamp,
min,
mix,
fract,
abs,
floor,
smoothstep,
oneMinus,
cross,
sqrt,
pow,
mx_noise_vec3,
vec2,
atan,
} from 'three/tsl';
// note: .max()/.div()/.sub()/.cos()/.sin()/.log()/.lessThanEqual() etc. are
// fluent methods on TSL nodes — no import needed.
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.
*/
/** The TSL compute node Fn(...)().compute(count) produces. three@0.172 does not
* export a public type for it; it is opaque and only handed to computeAsync(). */
type ComputeDispatch = ReturnType<ReturnType<ReturnType<typeof Fn>>['compute']>;
export class SemanticComputeStorm {
readonly count: number;
private scene: THREE.Scene;
// WebGPURenderer — runtime-only type (dynamic import); see file header.
private renderer: { computeAsync: (node: ComputeDispatch) => Promise<void> };
private bufferPos: StorageBufferAttribute | null;
private bufferVel: StorageBufferAttribute | null;
private bufferPhase: StorageBufferAttribute | null;
// Definite-assigned in buildCompute() (called from the constructor).
private computeNode!: ComputeDispatch;
private mesh: THREE.InstancedMesh | null = null;
private material: THREE.Material | null = null;
// Serialize GPU compute dispatches: never queue a new compute pass before the
// previous one resolves, or the WebGPU dispatch queue backs up and stalls.
private computeInFlight: Promise<void> | null = null;
// Uniforms driven from the camera/beat loop. uIgnition starts non-zero so
// the storm is visible on the very first frame (before any beat fires).
private uTarget = uniform(new THREE.Vector3(0, 0, 0));
private uTime = uniform(0);
private uIgnition = uniform(0.2);
private uMode = uniform(0);
// World-space radius the storm is contained within. Particles past this get
// a spring force back so the storm NEVER flies off-screen. Sized to the
// camera framing by the sandbox via setContainRadius().
private uContainRadius = uniform(48);
// Global hue rotation (advances over time) + how strongly the beat's mode
// tint overrides the rainbow (0 = full rainbow, 1 = full mode color).
private uHueShift = uniform(0);
private uModeTintAmt = uniform(0.25);
// Detonation cycle: spikes to 1 on each beat (explosion), decays to 0
// (crystallize/reform). Drives the explode→pixelate→reform look.
private uBurst = uniform(0);
// ACT DIMMER — a master brightness scalar set per beat from the narrative
// act. Act I opens too hot (the cloud is still in its dense initial spawn and
// the first ignition flash stacks on top), so we hold Act I dimmer and let
// Acts II/III blaze at full. 1.0 = full brightness. Starts very low so the
// pre-first-beat / beat-0 boot frames fade in soft instead of flashing white.
private uActDim = uniform(0.12);
// WORLD STATE MACHINE — each narrative beat (1..7) is a UNIQUE visual world:
// 0 nebula mist · 1 orbital anchor · 2 strange attractor · 3 detonation void
// 4 crystal lattice · 5 fluid galaxy · 6 phyllotaxis bloom
// Beats map 1:1 to worlds (beatIndex % 7). The compute kernel builds all 7
// home targets + forces and select()s the live one — particles are never
// swapped, only the forces acting on them, which IS the journey.
private uWorld = uniform(0);
private uPrevWorld = uniform(0);
// Crossfade prev→current world over ~1s after each beat (eased in update()).
// 1 = fully previous world, 0 = fully current.
private uBlend = uniform(0);
private readonly worldCount = 7;
// COLOR BLAST — a LONG-LIVED chroma envelope, decoupled from the fast physics
// burst so the detonation color OUTLIVES the shockwave (owner: "color too
// brief"). uBlast is the 0..1 magnitude (slow ~2.8s decay); uBlastTime counts
// seconds since the last detonation and drives the outward spectral wave.
private uBlast = uniform(0);
private uBlastTime = uniform(0);
// ENDLESS DREAM MODE — after the scripted 7-beat tour, the storm keeps
// generating crazier figures forever instead of sitting idle. uMorphSeed
// randomizes each procedural figure (worlds 7..11); uChaos ramps 0→1 over the
// dream so every figure is wilder than the last.
private uMorphSeed = uniform(0);
private uChaos = uniform(0);
// JARRING CLASH PAIR — which opposing inner/outer duotone is live (0..4). Set
// per beat so every figure is a fresh ice-vs-fire / acid-vs-blood collision.
private uClash = uniform(0);
// JS-side dream state (not uniforms): which figure is live + how many fired.
private dreamCount = 0;
constructor(
renderer: { computeAsync: (node: ComputeDispatch) => Promise<void> },
scene: THREE.Scene,
opts: StormOptions = {}
) {
this.renderer = renderer;
this.scene = scene;
this.count = opts.count ?? 150_000;
// Spawn particles ALREADY SPREAD across a wide spherical SHELL (not a tiny
// dense ball at the origin). The old ±8 cube packed all 150k into a tiny
// volume, so the very first frame (Beat 0, before the cloud expands to its
// rim-falloff homes) was a solid white blob — additive overlap dominates at
// high density regardless of per-particle dimming. Booting on a broad shell
// means the storm reads as a calm colored cloud from frame one.
const spawn = opts.spawnRadius ?? 34;
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++) {
// Uniform direction on a sphere, radius biased to the outer shell so the
// boot cloud is hollow-cored like the rim look (never a dense center).
const u1 = Math.random();
const u2 = Math.random();
const theta = u1 * Math.PI * 2;
const z = u2 * 2 - 1;
const r = Math.sqrt(Math.max(0, 1 - z * z));
const rad = spawn * (0.55 + Math.random() * 0.45); // shell 0.55..1.0
positions[i * 3] = Math.cos(theta) * r * rad;
positions[i * 3 + 1] = z * rad;
positions[i * 3 + 2] = Math.sin(theta) * r * rad;
phases[i] = Math.random() * Math.PI * 2;
}
const bufferPos = new StorageBufferAttribute(positions, 3);
const bufferVel = new StorageBufferAttribute(velocities, 3);
const bufferPhase = new StorageBufferAttribute(phases, 1);
this.bufferPos = bufferPos;
this.bufferVel = bufferVel;
this.bufferPhase = bufferPhase;
this.buildCompute(bufferPos, bufferVel, bufferPhase);
this.buildRender(bufferPos, bufferPhase);
}
private buildCompute(
bufferPos: StorageBufferAttribute,
bufferVel: StorageBufferAttribute,
bufferPhase: StorageBufferAttribute
): void {
const posStore = storage(bufferPos, 'vec3', this.count);
const velStore = storage(bufferVel, 'vec3', this.count);
const phaseStore = storage(bufferPhase, 'float', this.count);
this.computeNode = Fn(() => {
const pos = posStore.element(instanceIndex);
const vel = velStore.element(instanceIndex);
const phase = phaseStore.element(instanceIndex);
// ── DETERMINISTIC PER-PARTICLE BASIS (phase → stable spherical coords) ──
const a1 = phase.mul(12.9898).sin().mul(43758.5453);
const a2 = phase.mul(78.233).sin().mul(12543.531);
const a3 = phase.mul(39.346).sin().mul(24634.633);
const u = fract(a1); // 0..1
const v = fract(a2); // 0..1
const w2 = fract(a3); // 0..1
const theta = u.mul(6.28318); // azimuth 0..2π
const phi = v.mul(3.14159); // polar 0..π
const R = this.uContainRadius;
// Outer-shell bias (0.62..1.0) keeps the core hollow → reads as color,
// not a white-blooming dense center. (The dialed-in anti-white-out.)
const shellT = fract(phase.mul(3.7));
const homeFrac = float(0.62).add(shellT.mul(shellT).mul(0.38));
const fi = float(instanceIndex); // particle index as float (phyllotaxis)
// ── CURL NOISE (divergence-free flow → worlds 0 nebula, 5 fluid) ──
// Never clumps, never stops; the signature "living smoke" motion.
const curl = Fn(([p]: [ReturnType<typeof vec3>]) => {
const e = float(0.6);
const dx = mx_noise_vec3(p.add(vec3(e, 0, 0))).sub(mx_noise_vec3(p.sub(vec3(e, 0, 0))));
const dy = mx_noise_vec3(p.add(vec3(0, e, 0))).sub(mx_noise_vec3(p.sub(vec3(0, e, 0))));
const dz = mx_noise_vec3(p.add(vec3(0, 0, e))).sub(mx_noise_vec3(p.sub(vec3(0, 0, e))));
return vec3(dy.z.sub(dz.y), dz.x.sub(dx.z), dx.y.sub(dy.x)).normalize();
});
// ── 7 WORLD HOME TARGETS (all centered on origin → centroid can't drift) ──
const sphereShell = vec3(sin(phi).mul(cos(theta)), cos(phi), sin(phi).mul(sin(theta)));
const wNebula = sphereShell.mul(R.mul(homeFrac)); // world 0 (and 3 base)
const wAnchor = sphereShell.mul(R.mul(float(0.5).add(shellT.mul(0.3)))); // world 1
// world 2 attractor: home is "ahead" along the Thomas flow from current pos.
const bT = float(0.19);
const thomas = vec3(
sin(pos.y).sub(pos.x.mul(bT)),
sin(pos.z).sub(pos.y.mul(bT)),
sin(pos.x).sub(pos.z.mul(bT))
);
const wAttractor = pos.add(thomas.mul(R.mul(0.12)));
const wVoid = wNebula; // world 3 = sphere; the burst dominates this beat
const wCrystal = vec3( // world 4 cube lattice
u.sub(0.5).mul(2).mul(R.mul(0.8)),
v.sub(0.5).mul(2).mul(R.mul(0.8)),
w2.sub(0.5).mul(2).mul(R.mul(0.8))
);
const armAng = u.mul(6.28318).mul(3).add(w2.mul(0.6)); // world 5 galaxy spiral
const gr = R.mul(0.2).add(R.mul(0.8).mul(w2));
const wGalaxy = vec3(
gr.mul(cos(armAng)),
R.mul(0.06).mul(sin(phase.mul(20))),
gr.mul(sin(armAng))
);
const golden = float(2.39996323); // world 6 phyllotaxis (Vogel sunflower)
const pAng = fi.mul(golden);
const pRad = sqrt(fi).mul(R.mul(0.0042)); // ~R at 150k particles
const wPhyllo = vec3(pAng.cos().mul(pRad), R.mul(0.04).mul(sin(phase.mul(9))), pAng.sin().mul(pRad));
// ══════════════════════════════════════════════════════════════════
// ENDLESS DREAM FIGURES (worlds 7..11) — the generative mode that
// kicks in after the scripted 7-beat tour. These are PROCEDURAL and
// RANDOMIZED: uMorphSeed (set per auto-beat) + uChaos (ramps up over
// time → each figure crazier than the last) modulate the parameters,
// so the same world index never looks the same twice.
// ══════════════════════════════════════════════════════════════════
const seed = this.uMorphSeed;
const chaos = this.uChaos;
// seeded per-figure scalars (deterministic hash of the seed)
const s1 = fract(seed.mul(0.731).add(0.13));
const s2 = fract(seed.mul(1.323).add(0.51));
const s3 = fract(seed.mul(2.117).add(0.27));
// ── (u,v) MANIFOLD GRID ── THE spaghetti→skin fix. The hash-scatter
// basis (u,v,w2) is white noise → reads as gas/strings. A deterministic
// tensor grid over instanceIndex makes neighbors share rows/cols, so the
// procedural forms below render as a continuous SCULPTED SKIN, not lines.
// 387² = 149769 ≈ 150k. Pure arithmetic on fi — no buffers, no indexing.
const GW = float(387);
const ug = fract(fi.div(GW)); // grid u 0..1 (across a row)
const vg = floor(fi.div(GW)).div(GW); // grid v 0..1 (down columns)
// ── COMPLEX-MATH HELPERS (sinh/cosh/tanh are NOT in three@0.172 — expand
// via the confirmed .exp()). Used by the CalabiYau + Boy's surface forms. ──
type FNode = ReturnType<typeof float>;
type VNode = ReturnType<typeof vec2>;
const sinhT = (x: FNode) => x.exp().sub(x.mul(-1).exp()).mul(0.5);
const coshT = (x: FNode) => x.exp().add(x.mul(-1).exp()).mul(0.5);
const cMul = (a: VNode, b: VNode) =>
vec2(a.x.mul(b.x).sub(a.y.mul(b.y)), a.x.mul(b.y).add(a.y.mul(b.x)));
const cExp = (a: VNode) => {
const e = a.x.exp();
return vec2(e.mul(cos(a.y)), e.mul(sin(a.y)));
};
const cLog = (a: VNode) =>
vec2(a.x.mul(a.x).add(a.y.mul(a.y)).max(1e-12).log().mul(0.5), atan(a.y, a.x));
const cPow = (a: VNode, p: FNode) => cExp(cMul(vec2(p, float(0)), cLog(a)));
const cCosh = (z: VNode) => vec2(coshT(z.x).mul(cos(z.y)), sinhT(z.x).mul(sin(z.y)));
const cSinh = (z: VNode) => vec2(sinhT(z.x).mul(cos(z.y)), coshT(z.x).mul(sin(z.y)));
const cInv = (a: VNode) => {
const dd = a.x.mul(a.x).add(a.y.mul(a.y)).max(1e-6);
return vec2(a.x.div(dd), a.y.mul(-1).div(dd));
};
// world 7 · SUPERSHAPE (3D superformula — petals/stars/blobs, never same)
const m1 = float(2).add(floor(s1.mul(14))); // symmetry 2..15
const sfAng = theta;
const sfR1 = pow(abs(cos(m1.mul(sfAng).div(4))), float(2).add(s2.mul(8)))
.add(pow(abs(sin(m1.mul(sfAng).div(4))), float(2).add(s3.mul(8))))
.add(0.0001)
.pow(float(-0.5));
const sfR2 = pow(abs(cos(m1.mul(phi).div(4))), float(3))
.add(pow(abs(sin(m1.mul(phi).div(4))), float(3)))
.add(0.0001)
.pow(float(-0.5));
const sfRad = R.mul(0.85).mul(clamp(sfR1.mul(sfR2).mul(0.5), 0.1, 1.4));
const wSuper = vec3(
sin(phi).mul(cos(theta)).mul(sfRad),
cos(phi).mul(sfRad),
sin(phi).mul(sin(theta)).mul(sfRad)
);
// ══════ IMPOSSIBLE-GEOMETRY FORM PACK (worlds 8..11) ══════
// Brand-new signature skins nobody ships as a living particle figure.
// world 8 · CALABIYAU quintic cross-section (6D string-theory manifold,
// Hanson 4D→3D projection). 25 interlocking petals; α rotates it THROUGH
// the 4th dimension so petals pass through each other. The trophy.
const nCY = float(5);
const patch = floor(fract(seed.mul(0.013).add(fi.mul(0.00667))).mul(25));
const k1 = floor(patch.div(5)); // 0..4
const k2 = patch.sub(k1.mul(5)); // 0..4
const cyx = ug.mul(2).sub(1); // x ∈ [-1,1]
const cyy = vg.mul(1.5708); // y ∈ [0, π/2]
const zc = vec2(cyx, cyy);
const e1 = cExp(vec2(float(0), k1.mul(6.28318).div(nCY)));
const e2 = cExp(vec2(float(0), k2.mul(6.28318).div(nCY)));
const z1 = cMul(e1, cPow(cCosh(zc), float(0.4))); // 2/n = 0.4
const z2 = cMul(e2, cPow(cSinh(zc), float(0.4)));
const alpha = this.uTime.mul(0.25).add(seed).add(chaos.mul(1.5));
const wKnot = vec3(
z1.x,
z2.x,
cos(alpha).mul(z1.y).add(sin(alpha).mul(z2.y))
).mul(R.mul(0.55)); // ±1.6 → ~0.88R, centroid (0,0,0)
// world 9 · BOY'S SURFACE (BryantKusner minimal immersion of RP²) — a
// CLOSED non-orientable surface with one triple point, no spikes. Pure
// rational complex arithmetic over the unit disk → a perfect 2-manifold.
const br = sqrt(ug); // sqrt → uniform area on the disk
const bth = vg.mul(6.28318);
const zb = vec2(br.mul(cos(bth)), br.mul(sin(bth)));
const zb2 = cMul(zb, zb);
const zb3 = cMul(zb2, zb);
const zi2 = cInv(zb2);
const zi3 = cInv(zb3);
const denom = vec2(zb3.x.sub(zi3.x).add(2.2360679), zb3.y.sub(zi3.y)); // +√5
const aZ = cInv(denom);
const V0 = cMul(vec2(float(0), float(1)), vec2(zb2.x.sub(zi2.x), zb2.y.sub(zi2.y)));
const V1 = vec2(zb2.x.add(zi2.x), zb2.y.add(zi2.y));
const V2 = cMul(vec2(float(0), float(0.6667)), vec2(zb3.x.add(zi3.x), zb3.y.add(zi3.y)));
const Mx = cMul(aZ, V0).x;
const My = cMul(aZ, V1).x;
const Mz = cMul(aZ, V2).x.add(0.5);
const m2 = Mx.mul(Mx).add(My.mul(My)).add(Mz.mul(Mz)).max(1e-4); // sphere inversion
const wLissa = vec3(Mx.div(m2), My.div(m2), Mz.div(m2).sub(0.86)) // sub centroid z
.mul(R.mul(0.5));
// world 10 · AIZAWA attractor SHELL (capped spiral torus mapped over u,v;
// the Aizawa vector field added in the motion modifiers makes it breathe).
const az = vg.mul(2).sub(1); // -1..1 vertical
const ar = sqrt(float(1).sub(az.mul(az)).max(0)).mul(0.9).add(0.25); // radial profile
const aang = ug.mul(6.28318).add(az.mul(6).mul(chaos.add(0.4))); // spiral twist
const wHelix = vec3(
ar.mul(cos(aang)),
az.mul(1.4), // centered by construction
ar.mul(sin(aang))
).mul(R.mul(0.5));
// world 11 · GYROID↔SCHWARZ-D Bonnet morph (triply-periodic minimal
// surface — alien coral/bone lattice). The Bonnet angle θ continuously
// BENDS the gyroid into Schwarz-D. A woven solid skin, never seen living.
const period = float(2.2).add(chaos.mul(2.0));
const gx = ug.mul(6.28318).mul(period);
const gy = vg.mul(6.28318).mul(period);
const gz = this.uTime.mul(0.3).add(seed.mul(6.28318));
const gyroid = sin(gx).mul(cos(gy)).add(sin(gy).mul(cos(gz))).add(sin(gz).mul(cos(gx)));
const schwD = cos(gx).mul(cos(gy)).mul(cos(gz)).sub(sin(gx).mul(sin(gy)).mul(sin(gz)));
const bonnet = this.uTime.mul(0.15);
const fTPMS = cos(bonnet).mul(gyroid).add(sin(bonnet).mul(schwD));
const tpmsBase = vec3(
sin(vg.mul(3.14159)).mul(cos(ug.mul(6.28318))),
cos(vg.mul(3.14159)),
sin(vg.mul(3.14159)).mul(sin(ug.mul(6.28318)))
);
const wFoam = tpmsBase.mul(R.mul(0.5).add(fTPMS.mul(R.mul(0.12)))); // skin ± displacement
// select() chain — no dynamic indexing in this TSL build.
const homeFor = (idx: ReturnType<typeof float>) =>
select(idx.equal(0), wNebula,
select(idx.equal(1), wAnchor,
select(idx.equal(2), wAttractor,
select(idx.equal(3), wVoid,
select(idx.equal(4), wCrystal,
select(idx.equal(5), wGalaxy,
select(idx.equal(6), wPhyllo,
select(idx.equal(7), wSuper,
select(idx.equal(8), wKnot,
select(idx.equal(9), wLissa,
select(idx.equal(10), wHelix, wFoam)))))))))));
const homeCur = homeFor(float(this.uWorld));
const homePrev = homeFor(float(this.uPrevWorld));
// uBlend eases prev→cur (smoothstep) so the world morph is silky.
const blendE = smoothstep(float(0), float(1), oneMinus(this.uBlend));
const outerHome = mix(homePrev, homeCur, blendE);
// ══════════════════════════════════════════════════════════════════
// 3D-WITHIN-3D — a NESTED INNER FIGURE at the core.
// ~33% of particles (a deterministic slice of the index) form a
// SECOND, smaller shape inside the outer shell — a different world,
// counter-rotating, at ~38% scale. This fills the formerly-blank-bright
// center with intentional structure (a figure inside a figure) and adds
// depth nobody ships with particles. The inner world is offset from the
// outer so the two layers never collapse into the same shape.
// ══════════════════════════════════════════════════════════════════
const isInner = fract(fi.mul(0.001).add(0.5)).greaterThan(0.66); // ~34% inner
// Inner world = outer + 5, wrapped into 0..11 (a clearly different shape).
// Done with select() (no .mod()) so it's valid in this TSL build.
const innerSum = float(this.uWorld).add(5);
const innerWorldIdx = select(innerSum.greaterThan(11), innerSum.sub(12), innerSum);
const innerRaw = homeFor(innerWorldIdx);
// Counter-rotate the inner figure about Y so it spins against the shell,
// and scale it down to sit inside. cos/sin build a Y-rotation matrix.
const ia = this.uTime.mul(0.4);
const ic = cos(ia);
const is = sin(ia);
const innerRot = vec3(
innerRaw.x.mul(ic).sub(innerRaw.z.mul(is)),
innerRaw.y,
innerRaw.x.mul(is).add(innerRaw.z.mul(ic))
);
const innerHome = innerRot.mul(0.52); // nested core at ~52% scale (spread → less white)
// Each particle is permanently outer OR inner (no flicker): pick its home.
const home = mix(outerHome, innerHome, isInner.select(float(1), float(0)));
// ── DETONATION: per-particle staggered radial blast so it blooms as a
// shockwave, not all-at-once. uBurst spikes on each beat, decays fast.
const outDir = pos.normalize();
const stagger = oneMinus(fract(phase.mul(7.3)).mul(0.4));
vel.addAssign(outDir.mul(this.uBurst.mul(0.95).mul(stagger)));
// ── REFORM SPRING toward the (blended) world home ──
vel.addAssign(home.sub(pos).mul(0.045));
// ── PER-WORLD MOTION MODIFIERS (added to the spring) ──
// worlds 0 & 5: curl turbulence (living mist / liquid arms)
const curlV = curl(pos.mul(0.045).add(vec3(0, this.uTime.mul(0.2), 0)));
const curlAmt = select(this.uWorld.equal(0), float(0.05),
select(this.uWorld.equal(5), float(0.06), float(0.0)));
vel.addAssign(curlV.mul(curlAmt));
// world 1: orbital spin around Y (cross product → orbit, not collapse)
vel.addAssign(cross(vec3(0, 1, 0), pos).mul(0.0009).mul(select(this.uWorld.equal(1), float(1), float(0))));
// world 2: integrate the Thomas attractor (chaos lattice)
vel.addAssign(thomas.mul(0.012).mul(select(this.uWorld.equal(2), float(1), float(0))));
// world 10: integrate the AIZAWA vector field so the shell breathes/spirals
// along the real attractor flow (not a static torus).
const azx = pos.x.div(R.mul(0.5));
const azy = pos.y.div(R.mul(0.5));
const azz = pos.z.div(R.mul(0.5));
const aizawa = vec3(
azz.sub(0.7).mul(azx).sub(azy.mul(3.5)),
azx.mul(3.5).add(azz.sub(0.7).mul(azy)),
float(0.6).add(azz.mul(0.95)).sub(azz.mul(azz).mul(azz).div(3)).sub(azx.mul(azx).add(azy.mul(azy)))
);
vel.addAssign(aizawa.mul(0.008).mul(select(this.uWorld.equal(10), float(1), float(0))));
// world 5: tangential swirl for liquid galaxy arms
vel.addAssign(cross(vec3(0, 1, 0), pos).mul(0.0016).mul(select(this.uWorld.equal(5), float(1), float(0))));
// Subtle living shimmer (mean-zero, no net drift).
const shimmer = home.normalize().mul(sin(this.uTime.mul(1.3).add(phase.mul(6.1))).mul(0.015));
vel.addAssign(shimmer);
// Hard velocity clamp — nothing can ever fly off or blow up.
const speed = length(vel);
const maxSpeed = float(1.3);
vel.assign(vel.mul(min(maxSpeed, speed).div(speed.max(0.0001))));
pos.addAssign(vel);
vel.mulAssign(0.9); // strong damping → crisp crystallization, no overshoot
// ── PIXELATION: voxel snap as particles crystallize (low burst). World 4
// (crystal lattice) pushes it hardest for the holographic shard look.
const crystalBoost = select(this.uWorld.equal(4), float(1.6), float(1.0));
const cell = mix(float(0.55), float(6.0), clamp(this.uBurst, 0, 1));
const quantized = floor(pos.div(cell)).add(0.5).mul(cell);
const pixelAmt = clamp(oneMinus(this.uBurst.mul(1.4)), 0, 0.9).mul(crystalBoost).min(0.9);
pos.assign(mix(pos, quantized, pixelAmt));
// Final hard safety net: clamp anything past the contain radius back
// onto the boundary shell — guarantees nothing is ever off-screen.
const finalDist = length(pos);
const hardR = this.uContainRadius;
const snapped = pos.normalize().mul(hardR);
pos.assign(mix(pos, snapped, finalDist.greaterThan(hardR).select(float(1), float(0))));
})().compute(this.count);
}
private buildRender(bufferPos: StorageBufferAttribute, bufferPhase: StorageBufferAttribute): 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;
emissiveNode: unknown;
};
// CANONICAL SPRITE CENTER: positionNode is the sprite's CENTER only.
// SpriteNodeMaterial.setupPositionView already builds the billboard quad
// from positionGeometry.xy (scaled by scaleNode, rotated by rotationNode),
// so the previous `.add(positionLocal)` double-counted the quad (harmless at
// the 0.1 size, sub-pixel). Using the bare center is required for the
// velocity-stretch streak (scaleNode/rotationNode now drive the quad shape).
const phaseStore = storage(bufferPhase, 'float', this.count);
const instancePos = storage(bufferPos, 'vec3', this.count).element(instanceIndex);
mat.positionNode = instancePos;
// ── SHARED RAINBOW COLOR ──
// One Fn produces the pure iridescent color for a particle; we feed it to
// BOTH colorNode (the lit/additive surface color) AND emissiveNode (the
// channel the selective MRT bloom reads). The original code only set
// colorNode, so the bloom had NO color to bloom — it washed the frame to
// white. Routing the SAME rainbow to emissive makes the bloom glow in full
// spectral color, which is the whole point.
// ── IQ COSINE PALETTE ── one scalar t → smooth, vivid, loopable color.
// color(t) = a + b·cos(2π·(c·t + d)). The workhorse for per-world palettes
// and the spectral dispersion wave.
const palette = Fn(
([t, a, b, c, d]: [
ReturnType<typeof float>,
ReturnType<typeof vec3>,
ReturnType<typeof vec3>,
ReturnType<typeof vec3>,
ReturnType<typeof vec3>,
]) => a.add(b.mul(cos(c.mul(t).add(d).mul(6.28318))))
);
// ── BLACKBODY K→RGB ── real plasma-cooling color (Tanner-Helland approx).
// Drives the detonation: blue-white core (hot) cooling to red embers as the
// blast decays. if/else collapsed to select() for this TSL build.
const blackbody = Fn(([kelvin]: [ReturnType<typeof float>]) => {
const k = kelvin.div(100.0);
const rHot = pow(k.sub(60.0).max(0.0001), float(-0.1332047592)).mul(329.698727446);
const r = k.lessThanEqual(66.0).select(float(255.0), rHot);
const gCool = k.max(0.0001).log().mul(99.4708025861).sub(161.1195681661);
const gHot = pow(k.sub(60.0).max(0.0001), float(-0.0755148492)).mul(288.1221695283);
const g = k.lessThanEqual(66.0).select(gCool, gHot);
const bMid = k.sub(10.0).max(0.0001).log().mul(138.5177312231).sub(305.0447927307);
const b = k.greaterThanEqual(66.0).select(
float(255.0),
k.lessThanEqual(19.0).select(float(0.0), bMid)
);
return clamp(vec3(r, g, b).div(255.0), 0, 1);
});
const rainbowColor = Fn(() => {
const pos = instancePos;
const ph = phaseStore.element(instanceIndex);
const radius = length(pos.sub(vec3(this.uTarget)));
// Recompute the inner/outer layer split (same formula as the compute kernel).
const fiC = float(instanceIndex);
const isInnerC = fract(fiC.mul(0.001).add(0.5)).greaterThan(0.66);
// A flowing texture coordinate per particle — drives gradients WITHIN each
// layer's duotone so it shimmers, but stays inside that layer's color world.
const spatialBand = pos.x.mul(0.03).add(pos.y.mul(0.021)).add(pos.z.mul(0.027));
const flow = fract(
ph.mul(0.41).add(radius.mul(0.06)).add(spatialBand).add(this.uTime.mul(0.10)).add(this.uHueShift)
);
// ══════════════════════════════════════════════════════════════════
// JARRING DUOTONE CLASH — the share hook.
// The outer shell and the inner nested figure are painted from OPPOSING
// color universes (ice vs fire, acid vs blood, gold vs violet…). Not a
// hue shift in one rainbow — two palettes that FIGHT. uClash (set per
// beat) picks which clashing pair is live, so it's a fresh jarring combo
// every beat. Each layer is a 2-color gradient (cold→cold, hot→hot) so
// the layer reads as ONE color world, and the two worlds collide at the
// boundary. THIS is what makes someone stop scrolling and share.
// ══════════════════════════════════════════════════════════════════
// Five hand-picked clash pairs: [outerA, outerB, innerA, innerB].
const cl = this.uClash; // 0..4, set per beat
// outer gradient endpoints
const outA = select(cl.equal(0), vec3(0.0, 0.85, 1.0), // ICE: electric cyan
select(cl.equal(1), vec3(0.55, 1.0, 0.0), // ACID lime
select(cl.equal(2), vec3(1.0, 0.82, 0.0), // GOLD
select(cl.equal(3), vec3(0.0, 1.0, 0.6), // MINT/emerald
vec3(0.1, 0.5, 1.0))))); // ELECTRIC blue
const outB = select(cl.equal(0), vec3(0.3, 0.2, 1.0), // ICE→deep indigo
select(cl.equal(1), vec3(0.0, 0.7, 0.5), // ACID→teal
select(cl.equal(2), vec3(1.0, 0.4, 0.0), // GOLD→amber
select(cl.equal(3), vec3(0.0, 0.6, 1.0), // MINT→cyan
vec3(0.5, 0.0, 1.0))))); // ELECTRIC→violet
// inner gradient endpoints — the OPPOSING world
const inA = select(cl.equal(0), vec3(1.0, 0.25, 0.0), // FIRE: molten orange
select(cl.equal(1), vec3(1.0, 0.0, 0.55), // BLOOD: hot pink
select(cl.equal(2), vec3(0.6, 0.0, 1.0), // VIOLET
select(cl.equal(3), vec3(1.0, 0.1, 0.3), // CRIMSON
vec3(1.0, 0.7, 0.0))))); // GOLD
const inB = select(cl.equal(0), vec3(1.0, 0.0, 0.3), // FIRE→crimson
select(cl.equal(1), vec3(1.0, 0.45, 0.0), // BLOOD→orange
select(cl.equal(2), vec3(1.0, 0.0, 0.7), // VIOLET→magenta
select(cl.equal(3), vec3(1.0, 0.5, 0.0), // CRIMSON→amber
vec3(1.0, 0.2, 0.4))))); // GOLD→rose
// Each layer = a 2-stop gradient driven by `flow` (stays in its world).
const grad = smoothstep(float(0.0), float(1.0), flow);
const outerColor = mix(outA, outB, grad);
const innerColor = mix(inA, inB, grad);
// Hard pick by layer → the clash is absolute at the boundary.
const rainbow = mix(outerColor, innerColor, isInnerC.select(float(1), float(0)));
// Beat mode tint kept very light so it never muddies the clash.
const modeTint = select(
this.uMode.equal(2),
vec3(1.0, 0.08, 0.32),
select(this.uMode.equal(3), vec3(1.0, 0.78, 0.1), vec3(0.1, 0.9, 1.0))
);
return mix(rainbow, modeTint, this.uModeTintAmt.mul(0.4));
});
// ── RIM GLOW ── THE look: bright glowing EDGES, dim center.
// The dense middle of each form (particles near the center axis, all
// stacking toward the camera) is what blooms to white. So we DIM the core
// and BLAZE the rim: brightness rises with a particle's radial distance
// from the form's center. Near center → ~0.12 (deep, calm), at the outer
// shell → ~1.0 (full blaze). The result is the glowing-shell / hollow-eye
// torus look — luminous silhouette, serene dark center.
const rimFactor = Fn(() => {
const pos = instancePos;
// Normalized radial position 0 (center) .. 1 (contain radius).
const rNorm = clamp(length(pos).div(this.uContainRadius.max(0.0001)), 0, 1);
// Smooth ramp: dark core, bright rim — the outer-shell glow that keeps the
// center from blooming white (preserved white-out protection).
const edge = rNorm.mul(rNorm);
// FACING-RATIO FRESNEL — the "make it solid" amplifier. Approximate each
// particle's surface normal as its outward radial direction; view ≈ +Z.
// pow(1|n·v|, 4) blazes the turning-away SILHOUETTE and quiets the front,
// which flips "glowing fog" into a lit, sculpted SKIN.
const nrm = pos.normalize();
const fres = pow(oneMinus(abs(nrm.z)), float(4.0));
const outerRim = float(0.12).add(edge.mul(0.6)).add(fres.mul(0.5));
// The NESTED inner figure lives at small radius where `edge` is ~0 → it
// would be invisible. Give inner particles their OWN brightness: a higher
// floor + the same Fresnel silhouette so the inner figure reads as its own
// glowing sculpted object floating inside the shell.
const fiC = float(instanceIndex);
const isInnerC = fract(fiC.mul(0.001).add(0.5)).greaterThan(0.66);
// Inner glow kept VERY low so the nested figure's CLASH COLOR survives as
// color, not white. Dense small-radius overlap blows to white fast and
// kills the contrast — so the inner sits dim and saturated, carried by its
// Fresnel silhouette. This is what makes the jarring inner/outer clash read.
const innerRim = float(0.07).add(fres.mul(0.3));
return isInnerC.select(innerRim, outerRim);
});
// ── THE COLOR BLAST ── the signature detonation chroma. Keyed on the LONG
// uBlast envelope (~2.8s) so the color OUTLIVES the physics burst (owner's
// "color too brief" fix). Two layers: a blackbody plasma core that cools as
// the blast ages, and an outward-traveling SPECTRAL DISPERSION WAVE — rainbow
// shockwave rings expanding through the radius over uBlastTime, like a prism
// shattering. The unexpected color blast nobody else ships.
const blastColor = Fn(() => {
const pos = instancePos;
const b = clamp(this.uBlast, 0, 1);
const bt = this.uBlastTime;
const rNorm = clamp(length(pos).div(this.uContainRadius.max(0.0001)), 0, 1);
// Blackbody embers: a WARM core (capped ~5200K so it's hot-orange, NOT
// blinding blue-white — the white-out the owner saw was a 13000K plasma
// flash). Gentle gain so it tints, never dominates.
const kelvin = mix(float(1600.0), float(5200.0), b);
const gain = clamp(b.mul(1.1).add(0.4), 0, 1.3);
const fire = blackbody(kelvin).mul(gain);
// THE STAR OF THE BLAST — an outward SPECTRAL DISPERSION shockwave:
// concentric rainbow rings travel out through the radius over time (red
// lags, blue leads — real prism order). This is the color, not the fire.
const specT = fract(rNorm.mul(1.6).sub(bt.mul(1.5)));
const spectrum = palette(specT, vec3(0.55), vec3(0.55), vec3(3.0), vec3(0.0, 0.33, 0.67));
// Spectrum DOMINATES (0.78); a touch of warm fire underneath for energy.
// The owner wants a COLOR blast, so the rainbow wins over the plasma.
return mix(fire, spectrum, float(0.78));
});
// colorNode: world color × rim × act dim, then the blast overrides toward
// detonation chroma at the peak and lingers (the long uBlast tail) before
// melting back into the next world's palette.
mat.colorNode = Fn(() => {
const glow = clamp(this.uIgnition.mul(0.05).add(0.5), 0, 1.0);
const world = rainbowColor().mul(glow).mul(rimFactor()).mul(this.uActDim);
// Blast is CAPPED at 0.6 so the inner/outer CLASH duotone always shows
// through even during a detonation — the clash is the star, the blast is
// an accent (was fully overriding, which washed the contrast to rainbow).
const blastMix = smoothstep(float(0.0), float(0.85), clamp(this.uBlast, 0, 1)).mul(0.6);
return mix(world, blastColor().mul(rimFactor()).mul(this.uActDim), blastMix);
})();
// emissiveNode: what the selective bloom reads — THE glow channel. Rim-gated
// so ONLY the outer shell blooms (calm dark center, no white blob). The blast
// gain is held below the color path (×0.85) so the bloom never clips white.
mat.emissiveNode = Fn(() => {
const emGain = clamp(this.uIgnition.mul(0.04).add(0.6), 0, 1.1);
const world = rainbowColor().mul(emGain).mul(rimFactor()).mul(this.uActDim);
// Same cap as colorNode so the bloom feeds on the CLASH colors, not a
// rainbow override.
const blastMix = smoothstep(float(0.0), float(0.85), clamp(this.uBlast, 0, 1)).mul(0.6);
return mix(world, blastColor().mul(0.85).mul(rimFactor()).mul(this.uActDim), blastMix);
})();
// One instanced sprite per particle. Small quads (0.1) keep individual
// particles as crisp colored points of light rather than overlapping into
// white mush across the now-larger volume.
const geometry = new THREE.PlaneGeometry(0.1, 0.1);
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. Compute dispatches are serialized so
* a slow GPU never lets passes pile up and stall the queue. */
async update(deltaSeconds: number): Promise<void> {
const dt = Math.max(0, Math.min(deltaSeconds, 0.05));
this.uTime.value += dt;
// Slowly rotate the whole rainbow so the cloud is always shimmering.
this.uHueShift.value = (this.uHueShift.value + dt * 0.06) % 1;
// World crossfade: ease uBlend 1→0 over ~1s after each beat so the cloud
// melts from the previous world's home/forces into the new one's.
this.uBlend.value = Math.max(0, this.uBlend.value - dt * 1.0);
// Ignition decays toward 0 between beats (spikes back up on transitionTo()).
this.uIgnition.value = Math.max(0, this.uIgnition.value - dt * 2.0);
// Burst decays fast so the explosion crystallizes back within ~1.2s.
this.uBurst.value = Math.max(0, this.uBurst.value - dt * 0.85);
// COLOR BLAST: slow decay (~2.8s) so the detonation chroma LASTS, and the
// wave clock counts up so the spectral shockwave travels outward over time.
this.uBlast.value = Math.max(0, this.uBlast.value - dt * 0.35);
this.uBlastTime.value += dt;
// Wait for any in-flight compute to finish before queuing the next.
if (this.computeInFlight) await this.computeInFlight;
this.computeInFlight = this.renderer.computeAsync(this.computeNode).finally(() => {
this.computeInFlight = null;
});
await this.computeInFlight;
}
/** Fired on each narrative beat: retarget the storm + spike ignition.
* `act` blazes Acts II/III at full; `beatIndex` (0-based) holds the very first
* beats EXTRA dim — beats 0 and 1 fire while the cloud is still bunched from
* the initial reform and would otherwise wash to white. They ramp up to full
* over the opening, so the storm fades IN beautifully instead of flashing. */
transitionTo(
role: SemanticRole,
worldPos: THREE.Vector3,
act: 'I' | 'II' | 'III' = 'II',
beatIndex = 99
): void {
this.uTarget.value.copy(worldPos);
const mode = ROLE_MODE[role] ?? 1;
this.uMode.value = mode;
// WORLD ADVANCE: beats map 1:1 to the 7 worlds. Record the outgoing world
// and reset the crossfade so the cloud melts prev→new over ~1s.
this.uPrevWorld.value = this.uWorld.value;
this.uWorld.value = beatIndex % this.worldCount;
this.uBlend.value = 1; // 1 = fully previous; update() eases it to 0
// Cycle the jarring inner/outer clash pair so each beat collides a fresh
// pair of opposing color worlds (ice↔fire, acid↔blood, …).
this.uClash.value = beatIndex % 5;
// Per-beat warm-up dim: beats 0/1 stay calm (dialed-in safety), then hands
// off to the act-based brightness. Acts II/III blaze.
const warmup = beatIndex === 0 ? 0.12 : beatIndex === 1 ? 0.2 : null;
const actDim = act === 'I' ? 0.26 : 1.0;
this.uActDim.value = warmup ?? actDim;
// Ignition flash: nearly none on beats 0/1, gentle for the rest of Act I,
// strong (but no longer white-blowing) for Acts II/III — lowered from 8.0 to
// 4.5 so the inner/outer CLASH colors survive the detonation instead of
// washing to white.
this.uIgnition.value = beatIndex <= 1 ? 0.4 : act === 'I' ? 1.6 : 4.5;
// PHYSICS BURST (fast) — contradiction + the DETONATION world (3) hit hardest.
const isDetonation = this.uWorld.value === 3;
this.uBurst.value = mode === 2 || isDetonation ? 1.0 : 0.8;
// COLOR BLAST (LONG) — fire the chroma envelope + reset its outward-wave
// clock. Beats 0/1 keep it very low so the calm opener never flashes.
this.uBlast.value = beatIndex <= 1 ? 0.25 : 1.0;
this.uBlastTime.value = 0;
// Dramatic beats (contradiction=2, surprise=3) push their mode color over
// the rainbow so they read clearly; calm beats stay mostly iridescent.
this.uModeTintAmt.value = mode >= 2 ? 0.7 : 0.22;
}
/**
* ENDLESS DREAM BEAT — fired on a timer AFTER the scripted tour ends, so the
* storm never sits idle. Jumps to a RANDOM procedural figure (worlds 7..11),
* reseeds it (so it's never the same shape twice), ramps uChaos up so each one
* is wilder than the last, and detonates a full color blast. This is the
* "random figure generator that makes even crazier beats."
*/
dreamBeat(): void {
this.dreamCount += 1;
// Pick a random wild figure (worlds 7..11 are the procedural generators).
const world = 7 + Math.floor(Math.random() * 5);
this.uPrevWorld.value = this.uWorld.value;
this.uWorld.value = world;
this.uBlend.value = 1;
// Fresh random seed → the superformula/knot/lissajous/helix/foam params all
// change, so the same world index never looks the same twice.
this.uMorphSeed.value = Math.random() * 1000;
// Random opposing clash pair each dream figure → never the same collision.
this.uClash.value = Math.floor(Math.random() * 5);
// Chaos ramps up and saturates — figures get progressively crazier, then
// hold at max wildness. Eases in over the first ~8 dream beats.
this.uChaos.value = Math.min(1, 0.25 + this.dreamCount * 0.1);
// Detonation + long color blast every dream beat — but ignition kept
// MODERATE (not the tour's 8.0) so the random dense figures don't wash to
// white at the blast peak. The rim-gated spectral blast carries the color.
this.uActDim.value = 0.85;
this.uIgnition.value = 3.0;
this.uBurst.value = 1.0;
this.uBlast.value = 1.0;
this.uBlastTime.value = 0;
// Vary the mode tint randomly too so the palette keeps surprising.
const modes = [1, 2, 3];
this.uMode.value = modes[Math.floor(Math.random() * modes.length)];
this.uModeTintAmt.value = 0.3 + Math.random() * 0.5;
}
/** Size the containment sphere (world units) so the storm always stays in
* frame. The sandbox derives this from the camera distance + fov. */
setContainRadius(radius: number): void {
this.uContainRadius.value = Math.max(8, radius);
}
dispose(): void {
if (this.mesh) {
this.scene.remove(this.mesh);
this.mesh.geometry?.dispose();
this.mesh.dispose?.();
this.mesh = null;
}
this.material?.dispose();
this.material = null;
// StorageBufferAttribute extends BufferAttribute, which has no dispose():
// its GPU buffer is released by the renderer when the owning geometry is
// disposed (done above). Drop our references so the ~2.1MB of backing
// Float32Arrays can be garbage-collected.
this.bufferPos = null;
this.bufferVel = null;
this.bufferPhase = null;
}
}