feat(cinema): full-spectrum rim-glow storm — kill the white-out, morphing forms

Memory Cinema storm color/shape overhaul (the crown-jewel pillar):
- Fix the white-out root cause: emissiveNode was NEVER set, so the selective
  MRT bloom had no color to bloom and washed the frame white. Route the shared
  iridescent rainbow to BOTH colorNode and emissiveNode.
- Rim glow (fresnel-style): bright glowing edges, dim readable center — the
  shareable luminous-shell / hollow-torus look.
- Morphing geometry: the home target cycles sphere → torus → galaxy spiral →
  cube lattice → wave sheet, drifting continuously and snapping per beat.
- Hyper-saturated full-spectrum palette (per-particle phase + radial shells +
  spatial bands + time) so the whole rainbow is present at once.
- Spread the initial spawn across a wide hollow shell (was a tiny dense ball
  that boot-flashed white).
- Act/beat-aware brightness: beats 0/1 fade in soft, Act I held calm, Acts
  II/III blaze at full. No white-out regressions.

Gate: svelte-check 0/0, 937/937 tests pass (cinema auteur/pathfinder green),
verified live in browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-22 03:59:38 -05:00
parent 1fbbecb0b3
commit 618ec6aee3
3 changed files with 222 additions and 58 deletions

View file

@ -161,7 +161,9 @@
const wp = currentPositions?.get(beat.nodeId);
if (wp) {
const mode: StormMode = shot?.stormMode ?? 'connection';
sandbox.transitionTo(stormRole(mode), wp);
// Pass act + 0-based beat index so the storm holds Act I dimmer AND
// fades in extra-soft on beats 0/1 (which otherwise wash to white).
sandbox.transitionTo(stormRole(mode), wp, shot?.act ?? 'I', index);
}
}
}

View file

@ -166,10 +166,16 @@ export class CinemaSandbox {
/** Retarget the storm's MODE/ignition for a beat. The storm is permanently
* centered at the WORLD ORIGIN (see render) so it is always dead-center in
* frame worldPos here only conveys which node, not where the storm sits. */
transitionTo(role: SemanticRole, _worldPos: THREE.Vector3): void {
* frame worldPos here only conveys which node, not where the storm sits.
* `act` lets the storm hold Act I dimmer (it opens too hot otherwise). */
transitionTo(
role: SemanticRole,
_worldPos: THREE.Vector3,
act: 'I' | 'II' | 'III' = 'II',
beatIndex = 99
): void {
if (!this.booted) return;
this.storm.transitionTo(role, ORIGIN);
this.storm.transitionTo(role, ORIGIN, act, beatIndex);
}
/** Render one frame. The storm is pinned to the origin and the camera always

View file

@ -45,6 +45,7 @@ import {
floor,
positionLocal,
} from 'three/tsl';
// note: .max()/.div()/.sub() etc. are fluent methods on TSL nodes — no import needed.
export type SemanticRole = 'anchor' | 'connection' | 'contradiction';
@ -92,7 +93,7 @@ export class SemanticComputeStorm {
// 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.6);
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
@ -105,6 +106,19 @@ export class SemanticComputeStorm {
// 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);
// MORPH TARGET — which sculpted form the cloud reforms into. Advances slowly
// over time and snaps to the next form on each beat, so the storm is forever
// shape-shifting: sphere → torus → galaxy spiral → cube lattice → wave sheet →
// (loops). The integer part selects the current form, the fractional part
// cross-fades into the next, so morphs are fluid, never a hard pop.
private uShape = uniform(0);
private readonly shapeCount = 5;
constructor(
renderer: { computeAsync: (node: ComputeDispatch) => Promise<void> },
@ -114,17 +128,29 @@ export class SemanticComputeStorm {
this.renderer = renderer;
this.scene = scene;
this.count = opts.count ?? 150_000;
// Spawn inside the contained zone so particles don't start outside the
// shell and get yanked inward asymmetrically (which read as off-center).
const spawn = opts.spawnRadius ?? 8;
// 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++) {
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;
// 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);
@ -152,26 +178,92 @@ export class SemanticComputeStorm {
const vel = velStore.element(instanceIndex);
const phase = phaseStore.element(instanceIndex);
// ── EACH PARTICLE'S "HOME" — a deterministic point on a volumetric
// spherical shell around the ORIGIN, derived purely from its phase.
// The cloud reforms to these homes between beats, so the centroid is
// ANCHORED to origin and CANNOT drift (the bug that pushed it off-frame).
// No swirl/orbital/attractor terms — those drew the ugly ribbons.
// ── EACH PARTICLE'S "HOME" — a deterministic point on a SCULPTED FORM
// around the ORIGIN, derived purely from its phase. The cloud reforms to
// these homes between beats, so the centroid is ANCHORED to origin and
// CANNOT drift. But instead of always a sphere, the home MORPHS through a
// gallery of trippy forms (uShape) — the storm is forever shape-shifting.
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 theta = u.mul(6.28318); // azimuth
const phi = v.mul(3.14159); // polar
// Per-particle home radius fills the interior (0.30r..0.95r) for a
// dense volumetric orb rather than a hollow shell.
const homeFrac = float(0.3).add(fract(phase.mul(3.7)).mul(0.65));
const homeR = this.uContainRadius.mul(homeFrac);
const home = vec3(
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;
// Per-particle radial fill biased to the OUTER shell (0.62..1.0) so
// particles spread across the surface of each form and read as distinct
// COLOR, instead of piling into a dense central core that additive-blooms
// to white. Squaring pushes even more mass outward. This is what finally
// kills the white center while keeping the volumetric feel.
const shellT = fract(phase.mul(3.7));
const homeFrac = float(0.62).add(shellT.mul(shellT).mul(0.38));
// ── FORM 0 · SPHERE (volumetric orb) ──
const sphere = vec3(
sin(phi).mul(cos(theta)),
cos(phi),
sin(phi).mul(sin(theta))
).mul(homeR); // centered on origin (uTarget is always origin in sandbox)
).mul(R.mul(homeFrac));
// ── FORM 1 · TORUS (donut, ring radius 0.7R, tube 0.28R) ──
const tubeR = R.mul(0.28).mul(float(0.5).add(w2.mul(0.5)));
const ringR = R.mul(0.7);
const torus = vec3(
ringR.add(tubeR.mul(cos(phi.mul(2)))).mul(cos(theta)),
tubeR.mul(sin(phi.mul(2))),
ringR.add(tubeR.mul(cos(phi.mul(2)))).mul(sin(theta))
);
// ── FORM 2 · GALAXY SPIRAL (flat logarithmic spiral disc, 2 arms) ──
const arm = u.mul(6.28318).mul(3).add(w2.mul(0.6)); // winding
const gr = R.mul(0.2).add(R.mul(0.8).mul(w2));
const galaxy = vec3(
gr.mul(cos(arm)),
R.mul(0.06).mul(sin(phase.mul(20))), // thin disc with slight z jitter
gr.mul(sin(arm))
);
// ── FORM 3 · CUBE LATTICE (particles snapped onto a glowing box grid) ──
const cube = vec3(
u.sub(0.5).mul(2).mul(R.mul(0.85)),
v.sub(0.5).mul(2).mul(R.mul(0.85)),
w2.sub(0.5).mul(2).mul(R.mul(0.85))
);
// ── FORM 4 · WAVE SHEET (rippling plane, sinusoidal height field) ──
const sx = u.sub(0.5).mul(2).mul(R.mul(0.95));
const sz = v.sub(0.5).mul(2).mul(R.mul(0.95));
const wave = vec3(
sx,
sin(sx.mul(0.35).add(this.uTime.mul(1.2)))
.add(cos(sz.mul(0.35).sub(this.uTime)))
.mul(R.mul(0.22)),
sz
);
// ── MORPH BLEND ── integer part of uShape picks the current form, the
// fractional part cross-fades into the next, so the cloud fluidly melts
// from one sculpture to the next. select()-chain because the build has no
// dynamic array indexing in TSL.
const sIdx = floor(this.uShape);
const sFrac = fract(this.uShape);
const formA = select(
sIdx.equal(0), sphere,
select(sIdx.equal(1), torus,
select(sIdx.equal(2), galaxy,
select(sIdx.equal(3), cube, wave)))
);
const formB = select(
sIdx.equal(0), torus,
select(sIdx.equal(1), galaxy,
select(sIdx.equal(2), cube,
select(sIdx.equal(3), wave, sphere)))
);
// smoothstep-ish ease on the cross-fade for a silky morph.
const ease = sFrac.mul(sFrac).mul(float(3).sub(sFrac.mul(2)));
const home = mix(formA, formB, ease); // centered on origin
// ── DETONATION: on each beat uBurst≈1 → blow particles radially OUT
// from origin (the explosion in photo 2). Strength scales with the
@ -221,7 +313,11 @@ export class SemanticComputeStorm {
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
}) as SpriteNodeMaterial & { positionNode: unknown; colorNode: unknown };
}) as SpriteNodeMaterial & {
positionNode: unknown;
colorNode: unknown;
emissiveNode: unknown;
};
// CRITICAL: particle world position = per-instance GPU compute output
// (storage buffer, indexed by instanceIndex) PLUS the sprite's local quad
@ -233,47 +329,80 @@ export class SemanticComputeStorm {
const instancePos = storage(bufferPos, 'vec3', this.count).element(instanceIndex);
mat.positionNode = instancePos.add(positionLocal);
mat.colorNode = Fn(() => {
// ── 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.
const rainbowColor = Fn(() => {
const pos = instancePos;
const ph = phaseStore.element(instanceIndex);
const radius = length(pos.sub(vec3(this.uTarget)));
// ── INSANE IRIDESCENT RAINBOW ──
// Hue drifts across the spectrum by per-particle phase + radius shell +
// time, plus a global beat-driven hue shift (uHueShift). Each particle
// is a different color and the whole cloud slowly rotates through the
// rainbow — a living aurora, not a flat tint.
// Hue from many decorrelated terms so the whole spectrum is present at
// once and forever swirling: per-particle phase, concentric radial
// shells, a spatial XYZ band (gives morphing forms internal rainbow
// striping), time, and a global beat hue-shift.
const spatialBand = pos.x.mul(0.03).add(pos.y.mul(0.021)).add(pos.z.mul(0.027));
const hue = fract(
ph.mul(0.16)
.add(radius.mul(0.045))
.add(this.uTime.mul(0.08))
ph.mul(0.41)
.add(radius.mul(0.06))
.add(spatialBand)
.add(this.uTime.mul(0.10))
.add(this.uHueShift)
);
// hue → RGB (fract/abs hexagon palette). Pull the valleys UP slightly
// then re-saturate so the rainbow is vivid and FULLY saturated (not
// washed) — pure spectral color, never white.
const r = clamp(abs(hue.mul(6).sub(3)).sub(1), 0, 1);
const g = clamp(float(2).sub(abs(hue.mul(6).sub(2))), 0, 1);
const b = clamp(float(2).sub(abs(hue.mul(6).sub(4))), 0, 1);
const rainbow = vec3(r, g, b);
// hue → RGB at FULL saturation (HSV S=1,V=1) hexagon ramps. Pure jewel
// tone per particle, never desaturated toward luma.
const r0 = clamp(abs(hue.mul(6).sub(3)).sub(1), 0, 1);
const g0 = clamp(float(2).sub(abs(hue.mul(6).sub(2))), 0, 1);
const b0 = clamp(float(2).sub(abs(hue.mul(6).sub(4))), 0, 1);
const rainbow = vec3(r0, g0, b0);
// The beat's mode tint (crimson at a contradiction, gold at surprise,
// cyan default) is blended in by uModeTintAmt so dramatic beats read
// their color while keeping the iridescent shimmer underneath.
// Beat mode tint (crimson contradiction / gold surprise / cyan default)
// blended by uModeTintAmt so dramatic beats read their color.
const modeTint = select(
this.uMode.equal(2),
vec3(1.0, 0.08, 0.32), // contradiction → crimson
select(this.uMode.equal(3), vec3(1.0, 0.78, 0.1), vec3(0.1, 0.9, 1.0)) // surprise → gold, else cyan
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))
);
const tinted = mix(rainbow, modeTint, this.uModeTintAmt);
return mix(rainbow, modeTint, this.uModeTintAmt);
});
// Brightness is CLAMPED low so the rainbow shows as COLOR, not white.
// Additive blending across 150k overlapping sprites compounds fast — a
// high multiplier blows the core to pure white (the bug you saw). Keep
// the glow gentle (0.45 floor, +ignition up to ~1.1) and let the
// selective bloom pass do the blooming, not raw over-bright color.
const glow = clamp(this.uIgnition.mul(0.18).add(0.45), 0, 1.15);
return tinted.mul(glow);
// ── 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. pow-like curve via rNorm² pushes
// the brightness toward the outer shell so the edge reads as a crisp
// glowing rind and the interior falls away into shadow.
const edge = rNorm.mul(rNorm);
return float(0.12).add(edge.mul(0.95)); // 0.12 core → ~1.07 rim
});
// colorNode: surface color × rim falloff × act dimmer. Moderate base so
// additive overlap blends hues (kaleidoscope) rather than summing to white.
mat.colorNode = Fn(() => {
const glow = clamp(this.uIgnition.mul(0.05).add(0.5), 0, 1.0);
return rainbowColor().mul(glow).mul(rimFactor()).mul(this.uActDim);
})();
// emissiveNode: what the selective bloom reads — THE glow channel. The rim
// factor means ONLY the outer shell feeds the bloom hard, so the storm
// haloes as a luminous spectral RING/SHELL with a calm dark center, instead
// of a solid white blob. Modest base gain keeps overlapping hues blending
// to new colors, never clipping past white.
mat.emissiveNode = Fn(() => {
const emGain = clamp(this.uIgnition.mul(0.04).add(0.6), 0, 1.1);
return rainbowColor().mul(emGain).mul(rimFactor()).mul(this.uActDim);
})();
// One instanced sprite per particle. Small quads (0.1) keep individual
@ -293,7 +422,12 @@ export class SemanticComputeStorm {
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.05) % 1;
this.uHueShift.value = (this.uHueShift.value + dt * 0.06) % 1;
// Drift the morph target forward continuously so the cloud is ALWAYS
// melting toward the next sculpted form (even mid-beat). Beats snap it to
// the next whole form for a dramatic transform; this slow drift keeps it
// alive between beats. Wraps around the gallery.
this.uShape.value = (this.uShape.value + dt * 0.09) % this.shapeCount;
// Ignition decays toward 0 between beats (the colorNode floor keeps the
// storm glowing); spikes back up on transitionTo().
this.uIgnition.value = Math.max(0, this.uIgnition.value - dt * 2.0);
@ -309,15 +443,37 @@ export class SemanticComputeStorm {
await this.computeInFlight;
}
/** Fired on each narrative beat: retarget the storm + spike ignition. */
transitionTo(role: SemanticRole, worldPos: THREE.Vector3): void {
/** 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;
this.uIgnition.value = 8.0;
// Per-beat warm-up dim: beat 0 ≈0.12, beat 1 ≈0.22, beat 2 ≈0.45, then it
// hands off to the act-based brightness. This specifically tames beats 0/1
// (the only ones still washing out) without dimming the rest of Act I.
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 (no punch while bunched), gentle
// for the rest of Act I, full blaze for Acts II/III.
this.uIgnition.value = beatIndex <= 1 ? 0.4 : act === 'I' ? 1.6 : 8.0;
// DETONATE: every beat explodes the orb, then it crystallizes/pixelates
// back. Contradictions detonate hardest.
this.uBurst.value = mode === 2 ? 1.0 : 0.8;
// MORPH: snap the cloud onward to the NEXT sculpted form on this beat, so
// every narrative beat transforms the geometry (sphere→torus→galaxy→cube→
// wave→…). Round up to the next whole index, then wrap.
this.uShape.value = (Math.floor(this.uShape.value) + 1) % this.shapeCount;
// 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;