diff --git a/apps/dashboard/src/lib/components/MemoryCinema.svelte b/apps/dashboard/src/lib/components/MemoryCinema.svelte index 68bb823..b48f0bd 100644 --- a/apps/dashboard/src/lib/components/MemoryCinema.svelte +++ b/apps/dashboard/src/lib/components/MemoryCinema.svelte @@ -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); } } } diff --git a/apps/dashboard/src/lib/graph/cinema/sandbox.ts b/apps/dashboard/src/lib/graph/cinema/sandbox.ts index 1db0b33..77be1dc 100644 --- a/apps/dashboard/src/lib/graph/cinema/sandbox.ts +++ b/apps/dashboard/src/lib/graph/cinema/sandbox.ts @@ -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 diff --git a/apps/dashboard/src/lib/graph/cinema/storm.ts b/apps/dashboard/src/lib/graph/cinema/storm.ts index 5242988..6bb3191 100644 --- a/apps/dashboard/src/lib/graph/cinema/storm.ts +++ b/apps/dashboard/src/lib/graph/cinema/storm.ts @@ -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 }, @@ -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;