From 0e5ce762748925f0aa5985f55b48eca2eeb1502e Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Mon, 22 Jun 2026 01:26:39 -0500 Subject: [PATCH] feat(cinema): fill the frame + true full-spectrum color (no more white-out) Storm was a small ring leaving the canvas empty, and the core blew to white. - FILL: sandbox fitRadius margin 0.40 -> 0.82 so the storm fills most of the frame; particles now target their OWN radius across 0.12r..0.92r (filled volumetric ORB, not a thin ring). - COLOR: brightness was x(ignition*2.4+0.6) = up to x19.8, which + additive blending across 150k sprites clipped every channel to white. Clamp the glow low (0.45 floor, ~1.15 ceil) so the RAINBOW shows as pure spectral color; smaller quads (0.18 -> 0.1) keep particles crisp instead of overlapping to mush; gentler bloom (strength 1.1->0.6, threshold 0->0.35) accents cores rather than washing the cloud. 937 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dashboard/src/lib/graph/cinema/sandbox.ts | 13 +++--- apps/dashboard/src/lib/graph/cinema/storm.ts | 42 ++++++++++++------- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/apps/dashboard/src/lib/graph/cinema/sandbox.ts b/apps/dashboard/src/lib/graph/cinema/sandbox.ts index 8c30087..1db0b33 100644 --- a/apps/dashboard/src/lib/graph/cinema/sandbox.ts +++ b/apps/dashboard/src/lib/graph/cinema/sandbox.ts @@ -147,7 +147,9 @@ export class CinemaSandbox { 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); + // Gentler bloom (strength 0.6, threshold 0.35) so it accents the bright + // cores instead of washing the whole colored cloud to white. + const bloomed = this.deps.bloomMod.bloom(emissiveTex, 0.6, 0.65, 0.35); const post = new this.deps.PostProcessing(renderer); (post as unknown as { outputNode: unknown }).outputNode = ( outputTex as { add: (n: unknown) => unknown } @@ -189,13 +191,12 @@ export class CinemaSandbox { this.camera.lookAt(ORIGIN); // Size the containment sphere to the camera's VERTICAL FOV at the origin - // (the limiting dimension on a landscape frame). The 0.40 factor leaves - // generous room for the additive BLOOM HALO — each particle's glow spreads - // well beyond its geometric position, so the visible cloud is much larger - // than the radius; an aggressive margin is what actually stops the clip. + // (the limiting dimension on a landscape frame). 0.82 lets the storm fill + // most of the frame; the storm's internal shell sits well inside this and + // the hard boundary snap keeps the bloom halo from spilling past the edge. const dist = this.camera.position.length(); const vfov = (this.camera.fov * Math.PI) / 180; - const fitRadius = Math.tan(vfov / 2) * dist * 0.4; + const fitRadius = Math.tan(vfov / 2) * dist * 0.82; this.storm.setContainRadius(fitRadius); await this.storm.update(deltaSeconds); diff --git a/apps/dashboard/src/lib/graph/cinema/storm.ts b/apps/dashboard/src/lib/graph/cinema/storm.ts index dfc7a08..cf589e7 100644 --- a/apps/dashboard/src/lib/graph/cinema/storm.ts +++ b/apps/dashboard/src/lib/graph/cinema/storm.ts @@ -189,12 +189,16 @@ export class SemanticComputeStorm { // 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 + // Each particle targets its OWN radius spread across the whole interior + // (0.12r .. 0.92r), so the cloud is a FILLED VOLUMETRIC ORB filling the + // frame — not a thin ring. The per-particle preferred radius is a stable + // function of its phase, gently breathing over time. + const prefFrac = float(0.12).add( + abs(sin(phase.mul(1.7).add(this.uTime.mul(0.12)))).mul(0.8) + ); + const shellR = this.uContainRadius.mul(prefFrac); 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)); + const towardShell = toTarget.normalize().mul(radialErr.mul(0.05)); vel.addAssign(towardShell); // Hard velocity clamp so no single step can shoot a particle far. @@ -250,15 +254,17 @@ export class SemanticComputeStorm { .add(this.uTime.mul(0.08)) .add(this.uHueShift) ); - // hue → RGB (classic fract/abs hexagon palette), high saturation. + // 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); - // 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. + // 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. const modeTint = select( this.uMode.equal(2), vec3(1.0, 0.08, 0.32), // contradiction → crimson @@ -266,15 +272,19 @@ export class SemanticComputeStorm { ); 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)); + // 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); })(); - // 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); + // 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;