mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-02 22:01:01 +02:00
SpriteNodeMaterial.setupPositionView already rebuilds the billboard quad from positionGeometry — the prior .add(positionLocal) double-counted it (harmless at 0.1 size). Bare center is required for the upcoming velocity-stretch streak (scaleNode/rotationNode will drive the quad). Verified renders identically. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
873 lines
43 KiB
TypeScript
873 lines
43 KiB
TypeScript
// Memory Cinema — the Semantic Compute Storm (WebGPU / TSL GPGPU).
|
||
//
|
||
// 150k particles whose physics run ENTIRELY on the GPU via Three Shading
|
||
// Language compute nodes. The storm shifts behaviour with the narrative beat:
|
||
// - origin/anchor → stable orbital swarm around the focused node
|
||
// - connection → fluid streaming toward the target with wave motion
|
||
// - contradiction → explosive Rössler strange-attractor chaos (crimson)
|
||
// Emissive colour is routed so only the storm blazes through the selective
|
||
// MRT bloom pass against a clean void.
|
||
//
|
||
// IMPORTANT — verified against the INSTALLED three@0.172 three/tsl build:
|
||
// * use select() (NOT cond — does not exist in this build)
|
||
// * use TSL sin()/cos() (NOT Math.sin inside Fn)
|
||
// * SpriteNodeMaterial (NOT SpritePointsMaterial)
|
||
// * renderer.computeAsync() for the dispatch
|
||
// The whole module is dynamically imported only when Cinema launches, so the
|
||
// heavy three/webgpu + three/tsl bundles never load for normal dashboard use.
|
||
//
|
||
// This file is intentionally framework-agnostic and uses `any` for the WebGPU
|
||
// renderer type: three/webgpu's WebGPURenderer is a runtime-only dynamic import
|
||
// (kept out of the main bundle), so a compile-time type isn't available here.
|
||
|
||
import * as THREE from 'three';
|
||
// StorageBufferAttribute + SpriteNodeMaterial live in the three/webgpu entry,
|
||
// not the base three module. This file is dynamically imported only at Cinema
|
||
// launch, so pulling from three/webgpu here does NOT add WebGPU to the main
|
||
// bundle.
|
||
import { StorageBufferAttribute, SpriteNodeMaterial } from 'three/webgpu';
|
||
import {
|
||
Fn,
|
||
storage,
|
||
instanceIndex,
|
||
vec3,
|
||
uniform,
|
||
select,
|
||
float,
|
||
sin,
|
||
cos,
|
||
length,
|
||
clamp,
|
||
min,
|
||
mix,
|
||
fract,
|
||
abs,
|
||
floor,
|
||
smoothstep,
|
||
oneMinus,
|
||
cross,
|
||
sqrt,
|
||
pow,
|
||
mx_noise_vec3,
|
||
vec2,
|
||
atan,
|
||
} from 'three/tsl';
|
||
// note: .max()/.div()/.sub()/.cos()/.sin()/.log()/.lessThanEqual() etc. are
|
||
// fluent methods on TSL nodes — no import needed.
|
||
|
||
export type SemanticRole = 'anchor' | 'connection' | 'contradiction';
|
||
|
||
const ROLE_MODE: Record<SemanticRole, number> = {
|
||
anchor: 0,
|
||
connection: 1,
|
||
contradiction: 2,
|
||
};
|
||
|
||
export interface StormOptions {
|
||
count?: number;
|
||
/** World-space radius of the initial particle cloud. */
|
||
spawnRadius?: number;
|
||
}
|
||
|
||
/**
|
||
* GPU compute particle storm. Construct with a WebGPURenderer + Scene, call
|
||
* update(dt) each frame, and transitionTo(role, worldPos) on each narrative
|
||
* beat. dispose() releases all GPU resources.
|
||
*/
|
||
/** The TSL compute node Fn(...)().compute(count) produces. three@0.172 does not
|
||
* export a public type for it; it is opaque and only handed to computeAsync(). */
|
||
type ComputeDispatch = ReturnType<ReturnType<ReturnType<typeof Fn>>['compute']>;
|
||
|
||
export class SemanticComputeStorm {
|
||
readonly count: number;
|
||
private scene: THREE.Scene;
|
||
// WebGPURenderer — runtime-only type (dynamic import); see file header.
|
||
private renderer: { computeAsync: (node: ComputeDispatch) => Promise<void> };
|
||
|
||
private bufferPos: StorageBufferAttribute | null;
|
||
private bufferVel: StorageBufferAttribute | null;
|
||
private bufferPhase: StorageBufferAttribute | null;
|
||
|
||
// Definite-assigned in buildCompute() (called from the constructor).
|
||
private computeNode!: ComputeDispatch;
|
||
private mesh: THREE.InstancedMesh | null = null;
|
||
private material: THREE.Material | null = null;
|
||
|
||
// Serialize GPU compute dispatches: never queue a new compute pass before the
|
||
// previous one resolves, or the WebGPU dispatch queue backs up and stalls.
|
||
private computeInFlight: Promise<void> | null = null;
|
||
|
||
// Uniforms driven from the camera/beat loop. uIgnition starts non-zero so
|
||
// 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.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
|
||
// 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);
|
||
// 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);
|
||
// WORLD STATE MACHINE — each narrative beat (1..7) is a UNIQUE visual world:
|
||
// 0 nebula mist · 1 orbital anchor · 2 strange attractor · 3 detonation void
|
||
// 4 crystal lattice · 5 fluid galaxy · 6 phyllotaxis bloom
|
||
// Beats map 1:1 to worlds (beatIndex % 7). The compute kernel builds all 7
|
||
// home targets + forces and select()s the live one — particles are never
|
||
// swapped, only the forces acting on them, which IS the journey.
|
||
private uWorld = uniform(0);
|
||
private uPrevWorld = uniform(0);
|
||
// Crossfade prev→current world over ~1s after each beat (eased in update()).
|
||
// 1 = fully previous world, 0 = fully current.
|
||
private uBlend = uniform(0);
|
||
private readonly worldCount = 7;
|
||
// COLOR BLAST — a LONG-LIVED chroma envelope, decoupled from the fast physics
|
||
// burst so the detonation color OUTLIVES the shockwave (owner: "color too
|
||
// brief"). uBlast is the 0..1 magnitude (slow ~2.8s decay); uBlastTime counts
|
||
// seconds since the last detonation and drives the outward spectral wave.
|
||
private uBlast = uniform(0);
|
||
private uBlastTime = uniform(0);
|
||
// ENDLESS DREAM MODE — after the scripted 7-beat tour, the storm keeps
|
||
// generating crazier figures forever instead of sitting idle. uMorphSeed
|
||
// randomizes each procedural figure (worlds 7..11); uChaos ramps 0→1 over the
|
||
// dream so every figure is wilder than the last.
|
||
private uMorphSeed = uniform(0);
|
||
private uChaos = uniform(0);
|
||
// JARRING CLASH PAIR — which opposing inner/outer duotone is live (0..4). Set
|
||
// per beat so every figure is a fresh ice-vs-fire / acid-vs-blood collision.
|
||
private uClash = uniform(0);
|
||
// JS-side dream state (not uniforms): which figure is live + how many fired.
|
||
private dreamCount = 0;
|
||
|
||
constructor(
|
||
renderer: { computeAsync: (node: ComputeDispatch) => Promise<void> },
|
||
scene: THREE.Scene,
|
||
opts: StormOptions = {}
|
||
) {
|
||
this.renderer = renderer;
|
||
this.scene = scene;
|
||
this.count = opts.count ?? 150_000;
|
||
// 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++) {
|
||
// 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);
|
||
const bufferVel = new StorageBufferAttribute(velocities, 3);
|
||
const bufferPhase = new StorageBufferAttribute(phases, 1);
|
||
this.bufferPos = bufferPos;
|
||
this.bufferVel = bufferVel;
|
||
this.bufferPhase = bufferPhase;
|
||
|
||
this.buildCompute(bufferPos, bufferVel, bufferPhase);
|
||
this.buildRender(bufferPos, bufferPhase);
|
||
}
|
||
|
||
private buildCompute(
|
||
bufferPos: StorageBufferAttribute,
|
||
bufferVel: StorageBufferAttribute,
|
||
bufferPhase: StorageBufferAttribute
|
||
): void {
|
||
const posStore = storage(bufferPos, 'vec3', this.count);
|
||
const velStore = storage(bufferVel, 'vec3', this.count);
|
||
const phaseStore = storage(bufferPhase, 'float', this.count);
|
||
|
||
this.computeNode = Fn(() => {
|
||
const pos = posStore.element(instanceIndex);
|
||
const vel = velStore.element(instanceIndex);
|
||
const phase = phaseStore.element(instanceIndex);
|
||
|
||
// ── DETERMINISTIC PER-PARTICLE BASIS (phase → stable spherical coords) ──
|
||
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 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;
|
||
// Outer-shell bias (0.62..1.0) keeps the core hollow → reads as color,
|
||
// not a white-blooming dense center. (The dialed-in anti-white-out.)
|
||
const shellT = fract(phase.mul(3.7));
|
||
const homeFrac = float(0.62).add(shellT.mul(shellT).mul(0.38));
|
||
const fi = float(instanceIndex); // particle index as float (phyllotaxis)
|
||
|
||
// ── CURL NOISE (divergence-free flow → worlds 0 nebula, 5 fluid) ──
|
||
// Never clumps, never stops; the signature "living smoke" motion.
|
||
const curl = Fn(([p]: [ReturnType<typeof vec3>]) => {
|
||
const e = float(0.6);
|
||
const dx = mx_noise_vec3(p.add(vec3(e, 0, 0))).sub(mx_noise_vec3(p.sub(vec3(e, 0, 0))));
|
||
const dy = mx_noise_vec3(p.add(vec3(0, e, 0))).sub(mx_noise_vec3(p.sub(vec3(0, e, 0))));
|
||
const dz = mx_noise_vec3(p.add(vec3(0, 0, e))).sub(mx_noise_vec3(p.sub(vec3(0, 0, e))));
|
||
return vec3(dy.z.sub(dz.y), dz.x.sub(dx.z), dx.y.sub(dy.x)).normalize();
|
||
});
|
||
|
||
// ── 7 WORLD HOME TARGETS (all centered on origin → centroid can't drift) ──
|
||
const sphereShell = vec3(sin(phi).mul(cos(theta)), cos(phi), sin(phi).mul(sin(theta)));
|
||
const wNebula = sphereShell.mul(R.mul(homeFrac)); // world 0 (and 3 base)
|
||
const wAnchor = sphereShell.mul(R.mul(float(0.5).add(shellT.mul(0.3)))); // world 1
|
||
// world 2 attractor: home is "ahead" along the Thomas flow from current pos.
|
||
const bT = float(0.19);
|
||
const thomas = vec3(
|
||
sin(pos.y).sub(pos.x.mul(bT)),
|
||
sin(pos.z).sub(pos.y.mul(bT)),
|
||
sin(pos.x).sub(pos.z.mul(bT))
|
||
);
|
||
const wAttractor = pos.add(thomas.mul(R.mul(0.12)));
|
||
const wVoid = wNebula; // world 3 = sphere; the burst dominates this beat
|
||
const wCrystal = vec3( // world 4 cube lattice
|
||
u.sub(0.5).mul(2).mul(R.mul(0.8)),
|
||
v.sub(0.5).mul(2).mul(R.mul(0.8)),
|
||
w2.sub(0.5).mul(2).mul(R.mul(0.8))
|
||
);
|
||
const armAng = u.mul(6.28318).mul(3).add(w2.mul(0.6)); // world 5 galaxy spiral
|
||
const gr = R.mul(0.2).add(R.mul(0.8).mul(w2));
|
||
const wGalaxy = vec3(
|
||
gr.mul(cos(armAng)),
|
||
R.mul(0.06).mul(sin(phase.mul(20))),
|
||
gr.mul(sin(armAng))
|
||
);
|
||
const golden = float(2.39996323); // world 6 phyllotaxis (Vogel sunflower)
|
||
const pAng = fi.mul(golden);
|
||
const pRad = sqrt(fi).mul(R.mul(0.0042)); // ~R at 150k particles
|
||
const wPhyllo = vec3(pAng.cos().mul(pRad), R.mul(0.04).mul(sin(phase.mul(9))), pAng.sin().mul(pRad));
|
||
|
||
// ══════════════════════════════════════════════════════════════════
|
||
// ENDLESS DREAM FIGURES (worlds 7..11) — the generative mode that
|
||
// kicks in after the scripted 7-beat tour. These are PROCEDURAL and
|
||
// RANDOMIZED: uMorphSeed (set per auto-beat) + uChaos (ramps up over
|
||
// time → each figure crazier than the last) modulate the parameters,
|
||
// so the same world index never looks the same twice.
|
||
// ══════════════════════════════════════════════════════════════════
|
||
const seed = this.uMorphSeed;
|
||
const chaos = this.uChaos;
|
||
// seeded per-figure scalars (deterministic hash of the seed)
|
||
const s1 = fract(seed.mul(0.731).add(0.13));
|
||
const s2 = fract(seed.mul(1.323).add(0.51));
|
||
const s3 = fract(seed.mul(2.117).add(0.27));
|
||
|
||
// ── (u,v) MANIFOLD GRID ── THE spaghetti→skin fix. The hash-scatter
|
||
// basis (u,v,w2) is white noise → reads as gas/strings. A deterministic
|
||
// tensor grid over instanceIndex makes neighbors share rows/cols, so the
|
||
// procedural forms below render as a continuous SCULPTED SKIN, not lines.
|
||
// 387² = 149769 ≈ 150k. Pure arithmetic on fi — no buffers, no indexing.
|
||
const GW = float(387);
|
||
const ug = fract(fi.div(GW)); // grid u 0..1 (across a row)
|
||
const vg = floor(fi.div(GW)).div(GW); // grid v 0..1 (down columns)
|
||
|
||
// ── COMPLEX-MATH HELPERS (sinh/cosh/tanh are NOT in three@0.172 — expand
|
||
// via the confirmed .exp()). Used by the Calabi–Yau + Boy's surface forms. ──
|
||
type FNode = ReturnType<typeof float>;
|
||
type VNode = ReturnType<typeof vec2>;
|
||
const sinhT = (x: FNode) => x.exp().sub(x.mul(-1).exp()).mul(0.5);
|
||
const coshT = (x: FNode) => x.exp().add(x.mul(-1).exp()).mul(0.5);
|
||
const cMul = (a: VNode, b: VNode) =>
|
||
vec2(a.x.mul(b.x).sub(a.y.mul(b.y)), a.x.mul(b.y).add(a.y.mul(b.x)));
|
||
const cExp = (a: VNode) => {
|
||
const e = a.x.exp();
|
||
return vec2(e.mul(cos(a.y)), e.mul(sin(a.y)));
|
||
};
|
||
const cLog = (a: VNode) =>
|
||
vec2(a.x.mul(a.x).add(a.y.mul(a.y)).max(1e-12).log().mul(0.5), atan(a.y, a.x));
|
||
const cPow = (a: VNode, p: FNode) => cExp(cMul(vec2(p, float(0)), cLog(a)));
|
||
const cCosh = (z: VNode) => vec2(coshT(z.x).mul(cos(z.y)), sinhT(z.x).mul(sin(z.y)));
|
||
const cSinh = (z: VNode) => vec2(sinhT(z.x).mul(cos(z.y)), coshT(z.x).mul(sin(z.y)));
|
||
const cInv = (a: VNode) => {
|
||
const dd = a.x.mul(a.x).add(a.y.mul(a.y)).max(1e-6);
|
||
return vec2(a.x.div(dd), a.y.mul(-1).div(dd));
|
||
};
|
||
|
||
// world 7 · SUPERSHAPE (3D superformula — petals/stars/blobs, never same)
|
||
const m1 = float(2).add(floor(s1.mul(14))); // symmetry 2..15
|
||
const sfAng = theta;
|
||
const sfR1 = pow(abs(cos(m1.mul(sfAng).div(4))), float(2).add(s2.mul(8)))
|
||
.add(pow(abs(sin(m1.mul(sfAng).div(4))), float(2).add(s3.mul(8))))
|
||
.add(0.0001)
|
||
.pow(float(-0.5));
|
||
const sfR2 = pow(abs(cos(m1.mul(phi).div(4))), float(3))
|
||
.add(pow(abs(sin(m1.mul(phi).div(4))), float(3)))
|
||
.add(0.0001)
|
||
.pow(float(-0.5));
|
||
const sfRad = R.mul(0.85).mul(clamp(sfR1.mul(sfR2).mul(0.5), 0.1, 1.4));
|
||
const wSuper = vec3(
|
||
sin(phi).mul(cos(theta)).mul(sfRad),
|
||
cos(phi).mul(sfRad),
|
||
sin(phi).mul(sin(theta)).mul(sfRad)
|
||
);
|
||
|
||
// ══════ IMPOSSIBLE-GEOMETRY FORM PACK (worlds 8..11) ══════
|
||
// Brand-new signature skins nobody ships as a living particle figure.
|
||
|
||
// world 8 · CALABI–YAU quintic cross-section (6D string-theory manifold,
|
||
// Hanson 4D→3D projection). 25 interlocking petals; α rotates it THROUGH
|
||
// the 4th dimension so petals pass through each other. The trophy.
|
||
const nCY = float(5);
|
||
const patch = floor(fract(seed.mul(0.013).add(fi.mul(0.00667))).mul(25));
|
||
const k1 = floor(patch.div(5)); // 0..4
|
||
const k2 = patch.sub(k1.mul(5)); // 0..4
|
||
const cyx = ug.mul(2).sub(1); // x ∈ [-1,1]
|
||
const cyy = vg.mul(1.5708); // y ∈ [0, π/2]
|
||
const zc = vec2(cyx, cyy);
|
||
const e1 = cExp(vec2(float(0), k1.mul(6.28318).div(nCY)));
|
||
const e2 = cExp(vec2(float(0), k2.mul(6.28318).div(nCY)));
|
||
const z1 = cMul(e1, cPow(cCosh(zc), float(0.4))); // 2/n = 0.4
|
||
const z2 = cMul(e2, cPow(cSinh(zc), float(0.4)));
|
||
const alpha = this.uTime.mul(0.25).add(seed).add(chaos.mul(1.5));
|
||
const wKnot = vec3(
|
||
z1.x,
|
||
z2.x,
|
||
cos(alpha).mul(z1.y).add(sin(alpha).mul(z2.y))
|
||
).mul(R.mul(0.55)); // ±1.6 → ~0.88R, centroid (0,0,0)
|
||
|
||
// world 9 · BOY'S SURFACE (Bryant–Kusner minimal immersion of RP²) — a
|
||
// CLOSED non-orientable surface with one triple point, no spikes. Pure
|
||
// rational complex arithmetic over the unit disk → a perfect 2-manifold.
|
||
const br = sqrt(ug); // sqrt → uniform area on the disk
|
||
const bth = vg.mul(6.28318);
|
||
const zb = vec2(br.mul(cos(bth)), br.mul(sin(bth)));
|
||
const zb2 = cMul(zb, zb);
|
||
const zb3 = cMul(zb2, zb);
|
||
const zi2 = cInv(zb2);
|
||
const zi3 = cInv(zb3);
|
||
const denom = vec2(zb3.x.sub(zi3.x).add(2.2360679), zb3.y.sub(zi3.y)); // +√5
|
||
const aZ = cInv(denom);
|
||
const V0 = cMul(vec2(float(0), float(1)), vec2(zb2.x.sub(zi2.x), zb2.y.sub(zi2.y)));
|
||
const V1 = vec2(zb2.x.add(zi2.x), zb2.y.add(zi2.y));
|
||
const V2 = cMul(vec2(float(0), float(0.6667)), vec2(zb3.x.add(zi3.x), zb3.y.add(zi3.y)));
|
||
const Mx = cMul(aZ, V0).x;
|
||
const My = cMul(aZ, V1).x;
|
||
const Mz = cMul(aZ, V2).x.add(0.5);
|
||
const m2 = Mx.mul(Mx).add(My.mul(My)).add(Mz.mul(Mz)).max(1e-4); // sphere inversion
|
||
const wLissa = vec3(Mx.div(m2), My.div(m2), Mz.div(m2).sub(0.86)) // sub centroid z
|
||
.mul(R.mul(0.5));
|
||
|
||
// world 10 · AIZAWA attractor SHELL (capped spiral torus mapped over u,v;
|
||
// the Aizawa vector field added in the motion modifiers makes it breathe).
|
||
const az = vg.mul(2).sub(1); // -1..1 vertical
|
||
const ar = sqrt(float(1).sub(az.mul(az)).max(0)).mul(0.9).add(0.25); // radial profile
|
||
const aang = ug.mul(6.28318).add(az.mul(6).mul(chaos.add(0.4))); // spiral twist
|
||
const wHelix = vec3(
|
||
ar.mul(cos(aang)),
|
||
az.mul(1.4), // centered by construction
|
||
ar.mul(sin(aang))
|
||
).mul(R.mul(0.5));
|
||
|
||
// world 11 · GYROID↔SCHWARZ-D Bonnet morph (triply-periodic minimal
|
||
// surface — alien coral/bone lattice). The Bonnet angle θ continuously
|
||
// BENDS the gyroid into Schwarz-D. A woven solid skin, never seen living.
|
||
const period = float(2.2).add(chaos.mul(2.0));
|
||
const gx = ug.mul(6.28318).mul(period);
|
||
const gy = vg.mul(6.28318).mul(period);
|
||
const gz = this.uTime.mul(0.3).add(seed.mul(6.28318));
|
||
const gyroid = sin(gx).mul(cos(gy)).add(sin(gy).mul(cos(gz))).add(sin(gz).mul(cos(gx)));
|
||
const schwD = cos(gx).mul(cos(gy)).mul(cos(gz)).sub(sin(gx).mul(sin(gy)).mul(sin(gz)));
|
||
const bonnet = this.uTime.mul(0.15);
|
||
const fTPMS = cos(bonnet).mul(gyroid).add(sin(bonnet).mul(schwD));
|
||
const tpmsBase = vec3(
|
||
sin(vg.mul(3.14159)).mul(cos(ug.mul(6.28318))),
|
||
cos(vg.mul(3.14159)),
|
||
sin(vg.mul(3.14159)).mul(sin(ug.mul(6.28318)))
|
||
);
|
||
const wFoam = tpmsBase.mul(R.mul(0.5).add(fTPMS.mul(R.mul(0.12)))); // skin ± displacement
|
||
|
||
// select() chain — no dynamic indexing in this TSL build.
|
||
const homeFor = (idx: ReturnType<typeof float>) =>
|
||
select(idx.equal(0), wNebula,
|
||
select(idx.equal(1), wAnchor,
|
||
select(idx.equal(2), wAttractor,
|
||
select(idx.equal(3), wVoid,
|
||
select(idx.equal(4), wCrystal,
|
||
select(idx.equal(5), wGalaxy,
|
||
select(idx.equal(6), wPhyllo,
|
||
select(idx.equal(7), wSuper,
|
||
select(idx.equal(8), wKnot,
|
||
select(idx.equal(9), wLissa,
|
||
select(idx.equal(10), wHelix, wFoam)))))))))));
|
||
const homeCur = homeFor(float(this.uWorld));
|
||
const homePrev = homeFor(float(this.uPrevWorld));
|
||
// uBlend eases prev→cur (smoothstep) so the world morph is silky.
|
||
const blendE = smoothstep(float(0), float(1), oneMinus(this.uBlend));
|
||
const outerHome = mix(homePrev, homeCur, blendE);
|
||
|
||
// ══════════════════════════════════════════════════════════════════
|
||
// 3D-WITHIN-3D — a NESTED INNER FIGURE at the core.
|
||
// ~33% of particles (a deterministic slice of the index) form a
|
||
// SECOND, smaller shape inside the outer shell — a different world,
|
||
// counter-rotating, at ~38% scale. This fills the formerly-blank-bright
|
||
// center with intentional structure (a figure inside a figure) and adds
|
||
// depth nobody ships with particles. The inner world is offset from the
|
||
// outer so the two layers never collapse into the same shape.
|
||
// ══════════════════════════════════════════════════════════════════
|
||
const isInner = fract(fi.mul(0.001).add(0.5)).greaterThan(0.66); // ~34% inner
|
||
// Inner world = outer + 5, wrapped into 0..11 (a clearly different shape).
|
||
// Done with select() (no .mod()) so it's valid in this TSL build.
|
||
const innerSum = float(this.uWorld).add(5);
|
||
const innerWorldIdx = select(innerSum.greaterThan(11), innerSum.sub(12), innerSum);
|
||
const innerRaw = homeFor(innerWorldIdx);
|
||
// Counter-rotate the inner figure about Y so it spins against the shell,
|
||
// and scale it down to sit inside. cos/sin build a Y-rotation matrix.
|
||
const ia = this.uTime.mul(0.4);
|
||
const ic = cos(ia);
|
||
const is = sin(ia);
|
||
const innerRot = vec3(
|
||
innerRaw.x.mul(ic).sub(innerRaw.z.mul(is)),
|
||
innerRaw.y,
|
||
innerRaw.x.mul(is).add(innerRaw.z.mul(ic))
|
||
);
|
||
const innerHome = innerRot.mul(0.52); // nested core at ~52% scale (spread → less white)
|
||
// Each particle is permanently outer OR inner (no flicker): pick its home.
|
||
const home = mix(outerHome, innerHome, isInner.select(float(1), float(0)));
|
||
|
||
// ── DETONATION: per-particle staggered radial blast so it blooms as a
|
||
// shockwave, not all-at-once. uBurst spikes on each beat, decays fast.
|
||
const outDir = pos.normalize();
|
||
const stagger = oneMinus(fract(phase.mul(7.3)).mul(0.4));
|
||
vel.addAssign(outDir.mul(this.uBurst.mul(0.95).mul(stagger)));
|
||
|
||
// ── REFORM SPRING toward the (blended) world home ──
|
||
vel.addAssign(home.sub(pos).mul(0.045));
|
||
|
||
// ── PER-WORLD MOTION MODIFIERS (added to the spring) ──
|
||
// worlds 0 & 5: curl turbulence (living mist / liquid arms)
|
||
const curlV = curl(pos.mul(0.045).add(vec3(0, this.uTime.mul(0.2), 0)));
|
||
const curlAmt = select(this.uWorld.equal(0), float(0.05),
|
||
select(this.uWorld.equal(5), float(0.06), float(0.0)));
|
||
vel.addAssign(curlV.mul(curlAmt));
|
||
// world 1: orbital spin around Y (cross product → orbit, not collapse)
|
||
vel.addAssign(cross(vec3(0, 1, 0), pos).mul(0.0009).mul(select(this.uWorld.equal(1), float(1), float(0))));
|
||
// world 2: integrate the Thomas attractor (chaos lattice)
|
||
vel.addAssign(thomas.mul(0.012).mul(select(this.uWorld.equal(2), float(1), float(0))));
|
||
// world 10: integrate the AIZAWA vector field so the shell breathes/spirals
|
||
// along the real attractor flow (not a static torus).
|
||
const azx = pos.x.div(R.mul(0.5));
|
||
const azy = pos.y.div(R.mul(0.5));
|
||
const azz = pos.z.div(R.mul(0.5));
|
||
const aizawa = vec3(
|
||
azz.sub(0.7).mul(azx).sub(azy.mul(3.5)),
|
||
azx.mul(3.5).add(azz.sub(0.7).mul(azy)),
|
||
float(0.6).add(azz.mul(0.95)).sub(azz.mul(azz).mul(azz).div(3)).sub(azx.mul(azx).add(azy.mul(azy)))
|
||
);
|
||
vel.addAssign(aizawa.mul(0.008).mul(select(this.uWorld.equal(10), float(1), float(0))));
|
||
// world 5: tangential swirl for liquid galaxy arms
|
||
vel.addAssign(cross(vec3(0, 1, 0), pos).mul(0.0016).mul(select(this.uWorld.equal(5), float(1), float(0))));
|
||
|
||
// Subtle living shimmer (mean-zero, no net drift).
|
||
const shimmer = home.normalize().mul(sin(this.uTime.mul(1.3).add(phase.mul(6.1))).mul(0.015));
|
||
vel.addAssign(shimmer);
|
||
|
||
// Hard velocity clamp — nothing can ever fly off or blow up.
|
||
const speed = length(vel);
|
||
const maxSpeed = float(1.3);
|
||
vel.assign(vel.mul(min(maxSpeed, speed).div(speed.max(0.0001))));
|
||
|
||
pos.addAssign(vel);
|
||
vel.mulAssign(0.9); // strong damping → crisp crystallization, no overshoot
|
||
|
||
// ── PIXELATION: voxel snap as particles crystallize (low burst). World 4
|
||
// (crystal lattice) pushes it hardest for the holographic shard look.
|
||
const crystalBoost = select(this.uWorld.equal(4), float(1.6), float(1.0));
|
||
const cell = mix(float(0.55), float(6.0), clamp(this.uBurst, 0, 1));
|
||
const quantized = floor(pos.div(cell)).add(0.5).mul(cell);
|
||
const pixelAmt = clamp(oneMinus(this.uBurst.mul(1.4)), 0, 0.9).mul(crystalBoost).min(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 = pos.normalize().mul(hardR);
|
||
pos.assign(mix(pos, snapped, finalDist.greaterThan(hardR).select(float(1), float(0))));
|
||
})().compute(this.count);
|
||
}
|
||
|
||
private buildRender(bufferPos: StorageBufferAttribute, bufferPhase: StorageBufferAttribute): void {
|
||
// SpriteNodeMaterial: emissive routed to bloom; additive against the void.
|
||
const mat = new SpriteNodeMaterial({
|
||
transparent: true,
|
||
blending: THREE.AdditiveBlending,
|
||
depthWrite: false,
|
||
}) as SpriteNodeMaterial & {
|
||
positionNode: unknown;
|
||
colorNode: unknown;
|
||
emissiveNode: unknown;
|
||
};
|
||
|
||
// CANONICAL SPRITE CENTER: positionNode is the sprite's CENTER only.
|
||
// SpriteNodeMaterial.setupPositionView already builds the billboard quad
|
||
// from positionGeometry.xy (scaled by scaleNode, rotated by rotationNode),
|
||
// so the previous `.add(positionLocal)` double-counted the quad (harmless at
|
||
// the 0.1 size, sub-pixel). Using the bare center is required for the
|
||
// velocity-stretch streak (scaleNode/rotationNode now drive the quad shape).
|
||
const phaseStore = storage(bufferPhase, 'float', this.count);
|
||
const instancePos = storage(bufferPos, 'vec3', this.count).element(instanceIndex);
|
||
mat.positionNode = instancePos;
|
||
|
||
// ── 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.
|
||
|
||
// ── IQ COSINE PALETTE ── one scalar t → smooth, vivid, loopable color.
|
||
// color(t) = a + b·cos(2π·(c·t + d)). The workhorse for per-world palettes
|
||
// and the spectral dispersion wave.
|
||
const palette = Fn(
|
||
([t, a, b, c, d]: [
|
||
ReturnType<typeof float>,
|
||
ReturnType<typeof vec3>,
|
||
ReturnType<typeof vec3>,
|
||
ReturnType<typeof vec3>,
|
||
ReturnType<typeof vec3>,
|
||
]) => a.add(b.mul(cos(c.mul(t).add(d).mul(6.28318))))
|
||
);
|
||
|
||
// ── BLACKBODY K→RGB ── real plasma-cooling color (Tanner-Helland approx).
|
||
// Drives the detonation: blue-white core (hot) cooling to red embers as the
|
||
// blast decays. if/else collapsed to select() for this TSL build.
|
||
const blackbody = Fn(([kelvin]: [ReturnType<typeof float>]) => {
|
||
const k = kelvin.div(100.0);
|
||
const rHot = pow(k.sub(60.0).max(0.0001), float(-0.1332047592)).mul(329.698727446);
|
||
const r = k.lessThanEqual(66.0).select(float(255.0), rHot);
|
||
const gCool = k.max(0.0001).log().mul(99.4708025861).sub(161.1195681661);
|
||
const gHot = pow(k.sub(60.0).max(0.0001), float(-0.0755148492)).mul(288.1221695283);
|
||
const g = k.lessThanEqual(66.0).select(gCool, gHot);
|
||
const bMid = k.sub(10.0).max(0.0001).log().mul(138.5177312231).sub(305.0447927307);
|
||
const b = k.greaterThanEqual(66.0).select(
|
||
float(255.0),
|
||
k.lessThanEqual(19.0).select(float(0.0), bMid)
|
||
);
|
||
return clamp(vec3(r, g, b).div(255.0), 0, 1);
|
||
});
|
||
|
||
const rainbowColor = Fn(() => {
|
||
const pos = instancePos;
|
||
const ph = phaseStore.element(instanceIndex);
|
||
const radius = length(pos.sub(vec3(this.uTarget)));
|
||
|
||
// Recompute the inner/outer layer split (same formula as the compute kernel).
|
||
const fiC = float(instanceIndex);
|
||
const isInnerC = fract(fiC.mul(0.001).add(0.5)).greaterThan(0.66);
|
||
|
||
// A flowing texture coordinate per particle — drives gradients WITHIN each
|
||
// layer's duotone so it shimmers, but stays inside that layer's color world.
|
||
const spatialBand = pos.x.mul(0.03).add(pos.y.mul(0.021)).add(pos.z.mul(0.027));
|
||
const flow = fract(
|
||
ph.mul(0.41).add(radius.mul(0.06)).add(spatialBand).add(this.uTime.mul(0.10)).add(this.uHueShift)
|
||
);
|
||
|
||
// ══════════════════════════════════════════════════════════════════
|
||
// JARRING DUOTONE CLASH — the share hook.
|
||
// The outer shell and the inner nested figure are painted from OPPOSING
|
||
// color universes (ice vs fire, acid vs blood, gold vs violet…). Not a
|
||
// hue shift in one rainbow — two palettes that FIGHT. uClash (set per
|
||
// beat) picks which clashing pair is live, so it's a fresh jarring combo
|
||
// every beat. Each layer is a 2-color gradient (cold→cold, hot→hot) so
|
||
// the layer reads as ONE color world, and the two worlds collide at the
|
||
// boundary. THIS is what makes someone stop scrolling and share.
|
||
// ══════════════════════════════════════════════════════════════════
|
||
// Five hand-picked clash pairs: [outerA, outerB, innerA, innerB].
|
||
const cl = this.uClash; // 0..4, set per beat
|
||
// outer gradient endpoints
|
||
const outA = select(cl.equal(0), vec3(0.0, 0.85, 1.0), // ICE: electric cyan
|
||
select(cl.equal(1), vec3(0.55, 1.0, 0.0), // ACID lime
|
||
select(cl.equal(2), vec3(1.0, 0.82, 0.0), // GOLD
|
||
select(cl.equal(3), vec3(0.0, 1.0, 0.6), // MINT/emerald
|
||
vec3(0.1, 0.5, 1.0))))); // ELECTRIC blue
|
||
const outB = select(cl.equal(0), vec3(0.3, 0.2, 1.0), // ICE→deep indigo
|
||
select(cl.equal(1), vec3(0.0, 0.7, 0.5), // ACID→teal
|
||
select(cl.equal(2), vec3(1.0, 0.4, 0.0), // GOLD→amber
|
||
select(cl.equal(3), vec3(0.0, 0.6, 1.0), // MINT→cyan
|
||
vec3(0.5, 0.0, 1.0))))); // ELECTRIC→violet
|
||
// inner gradient endpoints — the OPPOSING world
|
||
const inA = select(cl.equal(0), vec3(1.0, 0.25, 0.0), // FIRE: molten orange
|
||
select(cl.equal(1), vec3(1.0, 0.0, 0.55), // BLOOD: hot pink
|
||
select(cl.equal(2), vec3(0.6, 0.0, 1.0), // VIOLET
|
||
select(cl.equal(3), vec3(1.0, 0.1, 0.3), // CRIMSON
|
||
vec3(1.0, 0.7, 0.0))))); // GOLD
|
||
const inB = select(cl.equal(0), vec3(1.0, 0.0, 0.3), // FIRE→crimson
|
||
select(cl.equal(1), vec3(1.0, 0.45, 0.0), // BLOOD→orange
|
||
select(cl.equal(2), vec3(1.0, 0.0, 0.7), // VIOLET→magenta
|
||
select(cl.equal(3), vec3(1.0, 0.5, 0.0), // CRIMSON→amber
|
||
vec3(1.0, 0.2, 0.4))))); // GOLD→rose
|
||
|
||
// Each layer = a 2-stop gradient driven by `flow` (stays in its world).
|
||
const grad = smoothstep(float(0.0), float(1.0), flow);
|
||
const outerColor = mix(outA, outB, grad);
|
||
const innerColor = mix(inA, inB, grad);
|
||
// Hard pick by layer → the clash is absolute at the boundary.
|
||
const rainbow = mix(outerColor, innerColor, isInnerC.select(float(1), float(0)));
|
||
|
||
// Beat mode tint kept very light so it never muddies the clash.
|
||
const modeTint = select(
|
||
this.uMode.equal(2),
|
||
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))
|
||
);
|
||
return mix(rainbow, modeTint, this.uModeTintAmt.mul(0.4));
|
||
});
|
||
|
||
// ── 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 — the outer-shell glow that keeps the
|
||
// center from blooming white (preserved white-out protection).
|
||
const edge = rNorm.mul(rNorm);
|
||
// FACING-RATIO FRESNEL — the "make it solid" amplifier. Approximate each
|
||
// particle's surface normal as its outward radial direction; view ≈ +Z.
|
||
// pow(1−|n·v|, 4) blazes the turning-away SILHOUETTE and quiets the front,
|
||
// which flips "glowing fog" into a lit, sculpted SKIN.
|
||
const nrm = pos.normalize();
|
||
const fres = pow(oneMinus(abs(nrm.z)), float(4.0));
|
||
const outerRim = float(0.12).add(edge.mul(0.6)).add(fres.mul(0.5));
|
||
// The NESTED inner figure lives at small radius where `edge` is ~0 → it
|
||
// would be invisible. Give inner particles their OWN brightness: a higher
|
||
// floor + the same Fresnel silhouette so the inner figure reads as its own
|
||
// glowing sculpted object floating inside the shell.
|
||
const fiC = float(instanceIndex);
|
||
const isInnerC = fract(fiC.mul(0.001).add(0.5)).greaterThan(0.66);
|
||
// Inner glow kept VERY low so the nested figure's CLASH COLOR survives as
|
||
// color, not white. Dense small-radius overlap blows to white fast and
|
||
// kills the contrast — so the inner sits dim and saturated, carried by its
|
||
// Fresnel silhouette. This is what makes the jarring inner/outer clash read.
|
||
const innerRim = float(0.07).add(fres.mul(0.3));
|
||
return isInnerC.select(innerRim, outerRim);
|
||
});
|
||
|
||
// ── THE COLOR BLAST ── the signature detonation chroma. Keyed on the LONG
|
||
// uBlast envelope (~2.8s) so the color OUTLIVES the physics burst (owner's
|
||
// "color too brief" fix). Two layers: a blackbody plasma core that cools as
|
||
// the blast ages, and an outward-traveling SPECTRAL DISPERSION WAVE — rainbow
|
||
// shockwave rings expanding through the radius over uBlastTime, like a prism
|
||
// shattering. The unexpected color blast nobody else ships.
|
||
const blastColor = Fn(() => {
|
||
const pos = instancePos;
|
||
const b = clamp(this.uBlast, 0, 1);
|
||
const bt = this.uBlastTime;
|
||
const rNorm = clamp(length(pos).div(this.uContainRadius.max(0.0001)), 0, 1);
|
||
// Blackbody embers: a WARM core (capped ~5200K so it's hot-orange, NOT
|
||
// blinding blue-white — the white-out the owner saw was a 13000K plasma
|
||
// flash). Gentle gain so it tints, never dominates.
|
||
const kelvin = mix(float(1600.0), float(5200.0), b);
|
||
const gain = clamp(b.mul(1.1).add(0.4), 0, 1.3);
|
||
const fire = blackbody(kelvin).mul(gain);
|
||
// THE STAR OF THE BLAST — an outward SPECTRAL DISPERSION shockwave:
|
||
// concentric rainbow rings travel out through the radius over time (red
|
||
// lags, blue leads — real prism order). This is the color, not the fire.
|
||
const specT = fract(rNorm.mul(1.6).sub(bt.mul(1.5)));
|
||
const spectrum = palette(specT, vec3(0.55), vec3(0.55), vec3(3.0), vec3(0.0, 0.33, 0.67));
|
||
// Spectrum DOMINATES (0.78); a touch of warm fire underneath for energy.
|
||
// The owner wants a COLOR blast, so the rainbow wins over the plasma.
|
||
return mix(fire, spectrum, float(0.78));
|
||
});
|
||
|
||
// colorNode: world color × rim × act dim, then the blast overrides toward
|
||
// detonation chroma at the peak and lingers (the long uBlast tail) before
|
||
// melting back into the next world's palette.
|
||
mat.colorNode = Fn(() => {
|
||
const glow = clamp(this.uIgnition.mul(0.05).add(0.5), 0, 1.0);
|
||
const world = rainbowColor().mul(glow).mul(rimFactor()).mul(this.uActDim);
|
||
// Blast is CAPPED at 0.6 so the inner/outer CLASH duotone always shows
|
||
// through even during a detonation — the clash is the star, the blast is
|
||
// an accent (was fully overriding, which washed the contrast to rainbow).
|
||
const blastMix = smoothstep(float(0.0), float(0.85), clamp(this.uBlast, 0, 1)).mul(0.6);
|
||
return mix(world, blastColor().mul(rimFactor()).mul(this.uActDim), blastMix);
|
||
})();
|
||
|
||
// emissiveNode: what the selective bloom reads — THE glow channel. Rim-gated
|
||
// so ONLY the outer shell blooms (calm dark center, no white blob). The blast
|
||
// gain is held below the color path (×0.85) so the bloom never clips white.
|
||
mat.emissiveNode = Fn(() => {
|
||
const emGain = clamp(this.uIgnition.mul(0.04).add(0.6), 0, 1.1);
|
||
const world = rainbowColor().mul(emGain).mul(rimFactor()).mul(this.uActDim);
|
||
// Same cap as colorNode so the bloom feeds on the CLASH colors, not a
|
||
// rainbow override.
|
||
const blastMix = smoothstep(float(0.0), float(0.85), clamp(this.uBlast, 0, 1)).mul(0.6);
|
||
return mix(world, blastColor().mul(0.85).mul(rimFactor()).mul(this.uActDim), blastMix);
|
||
})();
|
||
|
||
// 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;
|
||
this.mesh = mesh;
|
||
this.scene.add(this.mesh);
|
||
}
|
||
|
||
/** Advance the GPU physics one frame. Compute dispatches are serialized so
|
||
* a slow GPU never lets passes pile up and stall the queue. */
|
||
async update(deltaSeconds: number): Promise<void> {
|
||
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.06) % 1;
|
||
// World crossfade: ease uBlend 1→0 over ~1s after each beat so the cloud
|
||
// melts from the previous world's home/forces into the new one's.
|
||
this.uBlend.value = Math.max(0, this.uBlend.value - dt * 1.0);
|
||
// Ignition decays toward 0 between beats (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.
|
||
this.uBurst.value = Math.max(0, this.uBurst.value - dt * 0.85);
|
||
// COLOR BLAST: slow decay (~2.8s) so the detonation chroma LASTS, and the
|
||
// wave clock counts up so the spectral shockwave travels outward over time.
|
||
this.uBlast.value = Math.max(0, this.uBlast.value - dt * 0.35);
|
||
this.uBlastTime.value += dt;
|
||
|
||
// Wait for any in-flight compute to finish before queuing the next.
|
||
if (this.computeInFlight) await this.computeInFlight;
|
||
this.computeInFlight = this.renderer.computeAsync(this.computeNode).finally(() => {
|
||
this.computeInFlight = null;
|
||
});
|
||
await this.computeInFlight;
|
||
}
|
||
|
||
/** 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;
|
||
|
||
// WORLD ADVANCE: beats map 1:1 to the 7 worlds. Record the outgoing world
|
||
// and reset the crossfade so the cloud melts prev→new over ~1s.
|
||
this.uPrevWorld.value = this.uWorld.value;
|
||
this.uWorld.value = beatIndex % this.worldCount;
|
||
this.uBlend.value = 1; // 1 = fully previous; update() eases it to 0
|
||
// Cycle the jarring inner/outer clash pair so each beat collides a fresh
|
||
// pair of opposing color worlds (ice↔fire, acid↔blood, …).
|
||
this.uClash.value = beatIndex % 5;
|
||
|
||
// Per-beat warm-up dim: beats 0/1 stay calm (dialed-in safety), then hands
|
||
// off to the act-based brightness. Acts II/III blaze.
|
||
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, gentle for the rest of Act I,
|
||
// strong (but no longer white-blowing) for Acts II/III — lowered from 8.0 to
|
||
// 4.5 so the inner/outer CLASH colors survive the detonation instead of
|
||
// washing to white.
|
||
this.uIgnition.value = beatIndex <= 1 ? 0.4 : act === 'I' ? 1.6 : 4.5;
|
||
// PHYSICS BURST (fast) — contradiction + the DETONATION world (3) hit hardest.
|
||
const isDetonation = this.uWorld.value === 3;
|
||
this.uBurst.value = mode === 2 || isDetonation ? 1.0 : 0.8;
|
||
// COLOR BLAST (LONG) — fire the chroma envelope + reset its outward-wave
|
||
// clock. Beats 0/1 keep it very low so the calm opener never flashes.
|
||
this.uBlast.value = beatIndex <= 1 ? 0.25 : 1.0;
|
||
this.uBlastTime.value = 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;
|
||
}
|
||
|
||
/**
|
||
* ENDLESS DREAM BEAT — fired on a timer AFTER the scripted tour ends, so the
|
||
* storm never sits idle. Jumps to a RANDOM procedural figure (worlds 7..11),
|
||
* reseeds it (so it's never the same shape twice), ramps uChaos up so each one
|
||
* is wilder than the last, and detonates a full color blast. This is the
|
||
* "random figure generator that makes even crazier beats."
|
||
*/
|
||
dreamBeat(): void {
|
||
this.dreamCount += 1;
|
||
// Pick a random wild figure (worlds 7..11 are the procedural generators).
|
||
const world = 7 + Math.floor(Math.random() * 5);
|
||
this.uPrevWorld.value = this.uWorld.value;
|
||
this.uWorld.value = world;
|
||
this.uBlend.value = 1;
|
||
// Fresh random seed → the superformula/knot/lissajous/helix/foam params all
|
||
// change, so the same world index never looks the same twice.
|
||
this.uMorphSeed.value = Math.random() * 1000;
|
||
// Random opposing clash pair each dream figure → never the same collision.
|
||
this.uClash.value = Math.floor(Math.random() * 5);
|
||
// Chaos ramps up and saturates — figures get progressively crazier, then
|
||
// hold at max wildness. Eases in over the first ~8 dream beats.
|
||
this.uChaos.value = Math.min(1, 0.25 + this.dreamCount * 0.1);
|
||
// Detonation + long color blast every dream beat — but ignition kept
|
||
// MODERATE (not the tour's 8.0) so the random dense figures don't wash to
|
||
// white at the blast peak. The rim-gated spectral blast carries the color.
|
||
this.uActDim.value = 0.85;
|
||
this.uIgnition.value = 3.0;
|
||
this.uBurst.value = 1.0;
|
||
this.uBlast.value = 1.0;
|
||
this.uBlastTime.value = 0;
|
||
// Vary the mode tint randomly too so the palette keeps surprising.
|
||
const modes = [1, 2, 3];
|
||
this.uMode.value = modes[Math.floor(Math.random() * modes.length)];
|
||
this.uModeTintAmt.value = 0.3 + Math.random() * 0.5;
|
||
}
|
||
|
||
/** Size the containment sphere (world units) so the storm always stays in
|
||
* frame. The sandbox derives this from the camera distance + fov. */
|
||
setContainRadius(radius: number): void {
|
||
this.uContainRadius.value = Math.max(8, radius);
|
||
}
|
||
|
||
dispose(): void {
|
||
if (this.mesh) {
|
||
this.scene.remove(this.mesh);
|
||
this.mesh.geometry?.dispose();
|
||
this.mesh.dispose?.();
|
||
this.mesh = null;
|
||
}
|
||
this.material?.dispose();
|
||
this.material = null;
|
||
// StorageBufferAttribute extends BufferAttribute, which has no dispose():
|
||
// its GPU buffer is released by the renderer when the owning geometry is
|
||
// disposed (done above). Drop our references so the ~2.1MB of backing
|
||
// Float32Arrays can be garbage-collected.
|
||
this.bufferPos = null;
|
||
this.bufferVel = null;
|
||
this.bufferPhase = null;
|
||
}
|
||
}
|