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) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-22 01:26:39 -05:00
parent 782399adb1
commit 0e5ce76274
2 changed files with 33 additions and 22 deletions

View file

@ -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);

View file

@ -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;