From 65c801bc2f68497638a6736c64ff6007805e1fdd Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Mon, 22 Jun 2026 01:15:12 -0500 Subject: [PATCH] feat(cinema): radial containment spring + INSANE iridescent rainbow storm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the runaway ring (screenshot showed an expanding ellipse clipping the frame): orbital mode added tangential velocity with nothing pulling particles to a target radius, so they spiraled outward. Now a two-sided RADIAL SPRING pulls every particle toward an in-frame shell (containRadius*0.62) with a per-particle band so the cloud is a contained breathing sphere, not an ever-growing ring. Tighter velocity clamp + boundary snap as belt-and-suspenders. Color: replaced the flat 3-color tint with a living iridescent RAINBOW — hue drifts by per-particle phase + radius + time + a global rotating hue shift (fract/abs hexagon palette). Dramatic beats blend their mode color over the rainbow (crimson at contradictions, gold at surprises) via uModeTintAmt; calm beats stay mostly rainbow. 937 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/dashboard/src/lib/graph/cinema/storm.ts | 105 +++++++++++++------ 1 file changed, 72 insertions(+), 33 deletions(-) diff --git a/apps/dashboard/src/lib/graph/cinema/storm.ts b/apps/dashboard/src/lib/graph/cinema/storm.ts index bab0807..08bae39 100644 --- a/apps/dashboard/src/lib/graph/cinema/storm.ts +++ b/apps/dashboard/src/lib/graph/cinema/storm.ts @@ -40,6 +40,8 @@ import { clamp, min, mix, + fract, + abs, positionLocal, } from 'three/tsl'; @@ -95,6 +97,10 @@ export class SemanticComputeStorm { // 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); constructor( renderer: { computeAsync: (node: ComputeDispatch) => Promise }, @@ -123,7 +129,7 @@ export class SemanticComputeStorm { this.bufferPhase = bufferPhase; this.buildCompute(bufferPos, bufferVel, bufferPhase); - this.buildRender(bufferPos); + this.buildRender(bufferPos, bufferPhase); } private buildCompute( @@ -143,9 +149,10 @@ export class SemanticComputeStorm { const toTarget = vec3(this.uTarget).sub(pos); // Mode 0 — Anchor: orbital swirl (tangential velocity around target). + // Gentler than before so particles revolve rather than fling outward. const orbital = vec3(toTarget.z, float(0), toTarget.x.negate()) .normalize() - .mul(0.05); + .mul(0.03); // Mode 1 — Connection: stream toward target + per-particle wave. const wave = vec3( @@ -173,38 +180,41 @@ export class SemanticComputeStorm { // Ignition shockwave yanks particles toward the new node on each beat. vel.addAssign(toTarget.normalize().mul(this.uIgnition.mul(0.02))); - // CONTAINMENT: soft spherical boundary around the focused target so the - // storm NEVER escapes the camera frame. Past uContainRadius a spring - // force pulls each particle back toward the target; the force ramps in - // smoothly (smoothstep-like) so the boundary reads as a glowing - // membrane, not a hard wall. The chaos attractor (Rössler) is - // unbounded by nature — this is what keeps mode 2 on-screen. - const distFromTarget = length(toTarget.negate()); // |pos - target| - const overflow = distFromTarget.sub(this.uContainRadius).max(0); - const pullBack = clamp(overflow.mul(0.012), 0, 0.6); - vel.addAssign(toTarget.normalize().mul(pullBack)); + // RADIAL CONTAINMENT SPRING — the real fix for the runaway ring. + // Every particle is pulled toward a TARGET SHELL radius (a fraction of + // the contain radius), so the cloud forms a contained, breathing sphere + // instead of spiraling outward into an ever-bigger ring that clips the + // frame. The spring is two-sided: pulls IN when too far, pushes OUT when + // collapsed to the center, giving the storm volume without escape. + const distFromTarget = length(toTarget.negate()); + const shellR = this.uContainRadius.mul(0.62); // comfortable in-frame shell + const radialErr = distFromTarget.sub(shellR); // + = outside, - = inside + // Per-particle phase varies each particle's preferred shell a touch so + // they spread across a thick band, not a razor-thin ring. + const band = sin(phase.mul(3.0).add(this.uTime.mul(0.3))).mul(shellR.mul(0.18)); + const towardShell = toTarget.normalize().mul(radialErr.sub(band).mul(0.06)); + vel.addAssign(towardShell); - // Hard velocity clamp so no single step can shoot a particle across - // the frame even at peak ignition / chaos divergence. + // Hard velocity clamp so no single step can shoot a particle far. const speed = length(vel); - const maxSpeed = float(1.2); + const maxSpeed = float(0.9); vel.assign(vel.mul(min(maxSpeed, speed).div(speed.max(0.0001)))); pos.addAssign(vel); - vel.mulAssign(0.95); + vel.mulAssign(0.94); - // Final safety net: if a particle still ends up beyond 1.35x the - // radius (extreme edge case), snap it onto the boundary shell so it - // can never be lost off-screen. + // Final hard safety net: clamp any particle that still ends up past the + // contain radius back onto the boundary shell — guarantees nothing can + // ever be off-screen, even mid chaos divergence. const finalToTarget = vec3(this.uTarget).sub(pos); const finalDist = length(finalToTarget.negate()); - const hardR = this.uContainRadius.mul(1.35); + const hardR = this.uContainRadius; const snapped = vec3(this.uTarget).sub(finalToTarget.normalize().mul(hardR)); pos.assign(mix(pos, snapped, finalDist.greaterThan(hardR).select(float(1), float(0)))); })().compute(this.count); } - private buildRender(bufferPos: StorageBufferAttribute): void { + private buildRender(bufferPos: StorageBufferAttribute, bufferPhase: StorageBufferAttribute): void { // SpriteNodeMaterial: emissive routed to bloom; additive against the void. const mat = new SpriteNodeMaterial({ transparent: true, @@ -218,22 +228,45 @@ export class SemanticComputeStorm { // translated to its computed position. Assigning the bare storage element // to positionNode (without positionLocal) collapses every quad to a point // at its instance origin — the bug the audit caught. + const phaseStore = storage(bufferPhase, 'float', this.count); const instancePos = storage(bufferPos, 'vec3', this.count).element(instanceIndex); mat.positionNode = instancePos.add(positionLocal); 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) + 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. + const hue = fract( + ph.mul(0.16) + .add(radius.mul(0.045)) + .add(this.uTime.mul(0.08)) + .add(this.uHueShift) ); - // Brighten on ignition so the beat blazes through the bloom pass. - // The +0.55 floor keeps particles visibly glowing between beats so the - // storm never fades to black once ignition decays. - return base.mul(this.uIgnition.mul(3.0).add(0.55)); + // hue → RGB (classic fract/abs hexagon palette), high saturation. + 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); + + // The beat's mode tint (crimson at a contradiction, cyan anchor, etc.) + // is blended IN by uModeTintAmt so dramatic beats still read their color + // while keeping the iridescent shimmer underneath. + 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 + ); + const tinted = mix(rainbow, modeTint, this.uModeTintAmt); + + // Brighten on ignition so beats blaze through the bloom pass; the +0.6 + // floor keeps the rainbow glowing between beats. + return tinted.mul(this.uIgnition.mul(2.4).add(0.6)); })(); // One instanced sprite per particle; positions come from the GPU storage @@ -252,6 +285,8 @@ export class SemanticComputeStorm { async update(deltaSeconds: number): Promise { 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; // 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); @@ -267,8 +302,12 @@ export class SemanticComputeStorm { /** 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; + const mode = ROLE_MODE[role] ?? 1; + this.uMode.value = mode; this.uIgnition.value = 8.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; } /** Size the containment sphere (world units) so the storm always stays in