From bc81da46eb73003fbdc0b759830cf6b7b12dde69 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Mon, 22 Jun 2026 01:37:10 -0500 Subject: [PATCH] feat(cinema): explode -> pixelate -> reform storm (kill the swirls) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per direction: keep the mind-blowing explosion + pixelation moments, ditch the thin ribbon swirls. Complete physics rewrite: - removed orbital/stream/Rössler modes (the swirls + the off-center drift source) - each particle has a deterministic HOME on a volumetric shell around ORIGIN (centroid anchored — can never drift off-frame again) - uBurst detonation cycle: every beat blows particles radially out (explosion), then a home-spring crystallizes them back (reform); contradictions detonate hardest - PIXELATION: positions snap to a 3D grid that's fine when reformed, dissolved during the burst — the crystalline voxel look - hard velocity + radius clamps so it can never fly off or blow up 937 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/dashboard/src/lib/graph/cinema/storm.ts | 117 ++++++++++--------- 1 file changed, 59 insertions(+), 58 deletions(-) diff --git a/apps/dashboard/src/lib/graph/cinema/storm.ts b/apps/dashboard/src/lib/graph/cinema/storm.ts index cf589e7..5242988 100644 --- a/apps/dashboard/src/lib/graph/cinema/storm.ts +++ b/apps/dashboard/src/lib/graph/cinema/storm.ts @@ -42,6 +42,7 @@ import { mix, fract, abs, + floor, positionLocal, } from 'three/tsl'; @@ -101,6 +102,9 @@ export class SemanticComputeStorm { // tint overrides the rainbow (0 = full rainbow, 1 = full mode color). private uHueShift = uniform(0); private uModeTintAmt = uniform(0.25); + // Detonation cycle: spikes to 1 on each beat (explosion), decays to 0 + // (crystallize/reform). Drives the explode→pixelate→reform look. + private uBurst = uniform(0); constructor( renderer: { computeAsync: (node: ComputeDispatch) => Promise }, @@ -148,74 +152,65 @@ export class SemanticComputeStorm { const vel = velStore.element(instanceIndex); const phase = phaseStore.element(instanceIndex); - const toTarget = vec3(this.uTarget).sub(pos); + // ── 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. + const a1 = phase.mul(12.9898).sin().mul(43758.5453); + const a2 = phase.mul(78.233).sin().mul(12543.531); + 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( + sin(phi).mul(cos(theta)), + cos(phi), + sin(phi).mul(sin(theta)) + ).mul(homeR); // centered on origin (uTarget is always origin in sandbox) - // 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.03); + // ── DETONATION: on each beat uBurst≈1 → blow particles radially OUT + // from origin (the explosion in photo 2). Strength scales with the + // particle's own radius so the burst is a full-volume shockwave. + const outDir = pos.normalize(); + vel.addAssign(outDir.mul(this.uBurst.mul(0.9))); - // Mode 1 — Connection: stream toward target + per-particle wave. - const wave = vec3( - sin(this.uTime.add(phase)).mul(0.02), - cos(this.uTime.add(phase)).mul(0.02), - sin(this.uTime.mul(1.5).add(phase)).mul(0.02) - ); - const stream = toTarget.normalize().mul(0.08).add(wave); + // ── REFORM: a spring pulling each particle back to its home. As uBurst + // decays the spring wins, crystallizing the explosion back into the orb. + const toHome = home.sub(pos); + vel.addAssign(toHome.mul(0.045)); - // Mode 2 — Contradiction: Rössler strange-attractor chaos. - const dt = float(0.01); - const dx = vel.y.negate().sub(vel.z).mul(dt); - const dy = vel.x.add(vel.y.mul(0.2)).mul(dt); - const dz = float(0.2).add(vel.z.mul(vel.x.sub(5.7))).mul(dt); - const chaos = vec3(dx, dy, dz).mul(2.0); + // Subtle living shimmer so the reformed orb breathes (mean-zero, no net + // drift — uses the particle's own home direction, not a global bias). + const shimmer = home.normalize().mul(sin(this.uTime.mul(1.3).add(phase.mul(6.1))).mul(0.015)); + vel.addAssign(shimmer); - // Runtime mode selection (select(), not cond()). - const active = select( - this.uMode.equal(0), - orbital, - select(this.uMode.equal(1), stream, chaos) - ); - - vel.addAssign(active); - // Ignition shockwave yanks particles toward the new node on each beat. - vel.addAssign(toTarget.normalize().mul(this.uIgnition.mul(0.02))); - - // 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()); - // 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 - const towardShell = toTarget.normalize().mul(radialErr.mul(0.05)); - vel.addAssign(towardShell); - - // Hard velocity clamp so no single step can shoot a particle far. + // Hard velocity clamp — nothing can ever fly off or blow up. const speed = length(vel); - const maxSpeed = float(0.9); + const maxSpeed = float(1.3); vel.assign(vel.mul(min(maxSpeed, speed).div(speed.max(0.0001)))); pos.addAssign(vel); - vel.mulAssign(0.94); + vel.mulAssign(0.9); // strong damping → crisp crystallization, no overshoot - // 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()); + // ── PIXELATION: as particles crystallize (low burst) snap positions to + // a 3D GRID so the cloud resolves into discrete colored voxels — the + // crystalline look of photo 3. The grid is finest when fully reformed + // (burst≈0) and dissolves during the explosion (burst≈1). + const cell = mix(float(0.55), float(6.0), clamp(this.uBurst, 0, 1)); // small cell = fine pixels + const quantized = floor(pos.div(cell)).add(0.5).mul(cell); + const pixelAmt = clamp(float(1).sub(this.uBurst.mul(1.4)), 0, 0.9); + pos.assign(mix(pos, quantized, pixelAmt)); + + // Final hard safety net: clamp anything past the contain radius back + // onto the boundary shell — guarantees nothing is ever off-screen. + const finalDist = length(pos); const hardR = this.uContainRadius; - const snapped = vec3(this.uTarget).sub(finalToTarget.normalize().mul(hardR)); + const snapped = pos.normalize().mul(hardR); pos.assign(mix(pos, snapped, finalDist.greaterThan(hardR).select(float(1), float(0)))); })().compute(this.count); } @@ -302,6 +297,9 @@ export class SemanticComputeStorm { // 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); + // Burst decays fast so the explosion crystallizes back within ~1.2s, + // leaving the rest of the beat as a calm pixelated orb. + this.uBurst.value = Math.max(0, this.uBurst.value - dt * 0.85); // Wait for any in-flight compute to finish before queuing the next. if (this.computeInFlight) await this.computeInFlight; @@ -317,6 +315,9 @@ export class SemanticComputeStorm { const mode = ROLE_MODE[role] ?? 1; this.uMode.value = mode; this.uIgnition.value = 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; // 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;