vestige/apps/dashboard/src/lib/launch/RawVestigeEngine.svelte
Sam Valladares 6837198328 feat(launch): waitlist page, raw-WebGPU hero, supabase + vercel infra
The July 14 launch surface, previously uncommitted:
- /dashboard/launch raw-WebGPU particle "memory brain" hero (RawVestigeEngine,
  NeuralWordmark, dendrite sign) + DOM waitlist overlay with share/referral.
- Supabase waitlist client + migrations + welcome edge function; legacy waitlist
  archived under supabase/legacy.
- vercel.json deploy config, root-redirect env wiring, base-path config,
  graph-only route, Playwright launch verifiers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:13:02 -05:00

2349 lines
84 KiB
Svelte

<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { growSign } from '$lib/landing/dendriteGen';
interface Props {
seed?: number;
reducedMotion?: boolean;
class?: string;
/** element (.launch-shell) that receives the per-frame --burst / --flash
* CSS vars so the DOM overlay can implode/explode in sync. */
syncTarget?: HTMLElement;
/** when true (email form focused/interacting) the overlay is pinned to rest
* so a signup is never disrupted mid-type. */
suppress?: boolean;
}
type BloomMip = { texture: any; view: any; width: number; height: number };
type GPUHandle = {
device: any;
context: any;
format: string;
hdrFormat: string;
particleBuffer: any;
uniformBuffer: any;
computePipeline: any;
renderPipeline: any;
tonemapPipeline: any;
bloomDownPipeline: any;
bloomUpPipeline: any;
bloomBGL: any;
fadePipeline: any;
trailTexture: any;
trailView: any;
trailPrimed: boolean;
computeBindGroup: any;
renderBindGroup: any;
tonemapBindGroup: any; // rebuilt on resize (depends on HDR + bloom textures)
hdrTexture: any;
hdrView: any;
hdrMsaaTexture: any;
hdrMsaaView: any;
hdrSampler: any;
bloomMips: BloomMip[];
bloomBindGroups: any[]; // per-pass src bind groups, rebuilt on resize
hdrWidth: number;
hdrHeight: number;
particleCount: number;
};
// Particle pass renders at half resolution: 4x fewer shaded pixels kills the
// burst overdraw lag, and the softness is invisible (bloom blurs it anyway).
const PARTICLE_SCALE = 2;
const BLOOM_LEVELS = 5;
// No MSAA (count 1). 4x on rgba16float spikes the resolve cost during the
// high-variance burst; soft additive orbs barely benefit from MSAA anyway
// (the round-orb falloff + half-res + bloom already hide aliasing). WebGPU only
// guarantees sampleCount 1 and 4 — 2 is not portable — so we drop to 1.
const MSAA_SAMPLES = 1;
const USE_MSAA = false;
// per-frame trail fade constant (set in writeUniforms, used by the fade pass).
let trailDecay = 0.84;
let {
seed = 20260625,
reducedMotion = false,
class: className = '',
syncTarget,
suppress = false
}: Props = $props();
let gpuCanvas = $state<HTMLCanvasElement | undefined>(undefined);
let fallbackCanvas = $state<HTMLCanvasElement | undefined>(undefined);
let mode = $state<'booting' | 'webgpu' | 'fallback'>('booting');
let gpu: GPUHandle | null = null;
let frame = 0;
let projectionFrame = 0;
let webgpuRetryTimer: ReturnType<typeof setTimeout> | undefined;
let viewportKickTimer: ReturnType<typeof setTimeout> | undefined;
let webgpuBooting = false;
let webgpuAttempt = 0;
let webgpuBootToken = 0;
let fallbackStarted = false;
let lastFrame = 0;
let startedAt = 0;
let disposed = false;
let fallbackDraw: ((now: number) => void) | null = null;
let pointerX = 0;
let pointerY = 0;
let pointerActive = 0;
let pointerPressure = 0;
// uniform block: 5 * vec4f = 80 bytes (16-byte aligned). See Uniforms struct.
const uniformData = new Float32Array(20);
// 64 matches both proven webgpu-samples particle demos (computeBoids,
// particles-HDR) and stays under every device's maxComputeInvocationsPerWorkgroup.
const WORKGROUP_SIZE = 64;
function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
function withTimeout<T = any>(promise: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
async function requestBestAdapter(gpuApi: any) {
const attempts = [
{ powerPreference: 'high-performance' },
{},
{ featureLevel: 'compatibility' }
];
for (const descriptor of attempts) {
try {
const adapter = await withTimeout(
gpuApi.requestAdapter(descriptor),
2500,
`WebGPU requestAdapter ${JSON.stringify(descriptor)}`
);
if (adapter) return adapter;
} catch (error) {
console.warn('[launch] WebGPU adapter attempt failed:', descriptor, error);
}
}
return null;
}
async function requestBestDevice(adapter: any, requiredLimits: Record<string, number>) {
try {
return await withTimeout(
adapter.requestDevice({ requiredLimits }),
3500,
'WebGPU requestDevice with limits'
);
} catch (error) {
console.warn('[launch] WebGPU limited device request failed, retrying defaults:', error);
return await withTimeout(adapter.requestDevice(), 3500, 'WebGPU requestDevice defaults');
}
}
function destroyGpu() {
cancelAnimationFrame(frame);
frame = 0;
gpu?.particleBuffer?.destroy?.();
gpu?.uniformBuffer?.destroy?.();
gpu?.hdrTexture?.destroy?.();
gpu?.hdrMsaaTexture?.destroy?.();
gpu?.trailTexture?.destroy?.();
gpu?.bloomMips?.forEach((m) => m.texture?.destroy?.());
gpu?.context?.unconfigure?.();
gpu?.device?.destroy?.();
gpu = null;
}
// ---------------------------------------------------------------------------
// COMPUTE SHADER
// One looping 4-beat cinematic (STREAM -> EXPLODE -> REFORM -> DISSOLVE)
// recreated from the old Three.js nodeEngine, but pure node particles.
// The REFORM beat cycles through memory-themed formations:
// glyph swarm (VESTIGE) -> brain lobes -> graph constellation ->
// memory lattice -> archive bead columns -> receipt streams -> burst.
// NOTHING here draws a line, edge, ring, grid, or stroke. Particles only.
// ---------------------------------------------------------------------------
const computeShader = /* wgsl */ `
struct Particle {
pos: vec4f, // xyz position, w = energy (for glow)
vel: vec4f, // xyz velocity, w = spawn delay
aux: vec4f, // x = id, y = lane(formation), z = shape kind, w = hue seed
home: vec4f, // xyz edge-shell spawn point (dissolve target), w = perimeter t
};
struct Uniforms {
viewport: vec4f, // x=width y=height z=dpr w=reducedMotion(0/1)
pointer: vec4f, // x,y in clip space, z=active, w=pressure
time: vec4f, // x=time(s) y=dt z=shapeA w=shapeB
mode: vec4f, // x=particleCount y=morphBlend(0..1) z=unused w=seed
extra: vec4f, // x=scrollY (reserved) ...
};
@group(0) @binding(0) var<storage, read_write> particles: array<Particle>;
@group(0) @binding(1) var<uniform> uniforms: Uniforms;
fn hash11(n: f32) -> f32 {
return fract(sin(n * 91.3458) * 47453.5453);
}
fn rotate2(p: vec2f, a: f32) -> vec2f {
let c = cos(a);
let s = sin(a);
return vec2f(p.x * c - p.y * s, p.x * s + p.y * c);
}
const TAU2: f32 = 6.28318530718;
const INV_SQRT2: f32 = 0.70710678;
// =========================================================================
// EXOTIC FORMATIONS — strange attractors & 4D projections nobody uses as
// glowing node clouds. Each particle integrates / parametrises to a unique
// point so 60k orbs FILL the structure (random integration length is the key
// trick for attractors: same curve, each orb frozen at a different point).
// =========================================================================
// AIZAWA strange attractor — a chaotic toroidal shell with an axial spike. The
// swarm traces deterministic chaos: the memory system "thinking".
fn aizawa_target(seed: f32, t: f32) -> vec3f {
let A = 0.95; let B = 0.7; let C = 0.6; let D = 3.5; let E = 0.25; let F = 0.1;
var p = vec3f(
(hash11(seed + 0.1) - 0.5) * 0.2,
(hash11(seed + 0.2) - 0.5) * 0.2,
(hash11(seed + 0.3) - 0.5) * 0.2 + 0.1
);
let dt = 0.01;
let steps = 380u + u32(hash11(seed + 7.0) * 1400.0);
for (var i = 0u; i < steps; i = i + 1u) {
let dx = (p.z - B) * p.x - D * p.y;
let dy = D * p.x + (p.z - B) * p.y;
let dz = C + A * p.z - (p.z * p.z * p.z) / 3.0
- (p.x * p.x + p.y * p.y) * (1.0 + E * p.z)
+ F * p.z * (p.x * p.x * p.x);
p = p + vec3f(dx, dy, dz) * dt;
}
let centered = vec3f(p.x, p.y, p.z - 0.65) * 0.82;
// stand the spike up the screen's Y axis, slow autorotate for life.
let xz = rotate2(vec2f(centered.x, centered.y), t * 0.10);
return vec3f(xz.x, centered.z, xz.y);
}
// THOMAS cyclically-symmetric attractor — a space-filling pretzel/lattice of
// interlocking loops, perfectly symmetric under x->y->z.
fn thomas_target(seed: f32, t: f32) -> vec3f {
let b = 0.19;
var p = vec3f(
(hash11(seed + 0.1) - 0.5) * 8.0,
(hash11(seed + 0.2) - 0.5) * 8.0,
(hash11(seed + 0.3) - 0.5) * 8.0
);
let dt = 0.05;
let steps = 260u + u32(hash11(seed + 7.0) * 1100.0);
for (var i = 0u; i < steps; i = i + 1u) {
let dx = sin(p.y) - b * p.x;
let dy = sin(p.z) - b * p.y;
let dz = sin(p.x) - b * p.z;
p = p + vec3f(dx, dy, dz) * dt;
}
let s = p * (1.30 / 4.5);
let xz = rotate2(s.xz, t * 0.08);
return vec3f(xz.x, s.y, xz.y);
}
// GIELIS SUPERFORMULA supershape — a spiky alien crystal sea-urchin.
fn superR(m: f32, n1: f32, n2: f32, n3: f32, ang: f32) -> f32 {
let t1 = pow(abs(cos(m * ang / 4.0)), n2);
let t2 = pow(abs(sin(m * ang / 4.0)), n3);
return pow(max(t1 + t2, 1e-4), -1.0 / n1);
}
fn supershape_target(seed: f32, t: f32) -> vec3f {
let m = 7.0; let n1 = 0.20; let n2 = 1.7; let n3 = 1.7;
let theta = hash11(seed + 0.1) * TAU2 - 3.14159265;
let v = hash11(seed + 0.2);
let phi = asin(clamp(2.0 * v - 1.0, -1.0, 1.0));
let r1 = superR(m, n1, n2, n3, theta);
let r2 = superR(m, n1, n2, n3, phi);
var p = vec3f(
r1 * cos(theta) * r2 * cos(phi),
r1 * sin(theta) * r2 * cos(phi),
r2 * sin(phi)
) * 0.30;
// breathe + slow spin so the crystal feels alive.
let pulse = 1.0 + sin(t * 0.5) * 0.06;
p = p * pulse;
if (length(p) > 1.32) { p = normalize(p) * 1.32; }
let xz = rotate2(p.xz, t * 0.12);
return vec3f(xz.x, p.y, xz.y);
}
// CLIFFORD TORUS — a flat 2-torus living in 4D (S^3), stereographically
// projected to 3D. A nested-inside-itself torus no 3D parametric can make:
// "a non-trivial topological thought."
fn clifford_target(seed: f32, t: f32) -> vec3f {
let al = hash11(seed + 0.1) * TAU2;
let be = hash11(seed + 0.2) * TAU2;
let w = cos(al) * INV_SQRT2;
let x4 = sin(al) * INV_SQRT2;
let y4 = cos(be) * INV_SQRT2;
let z4 = sin(be) * INV_SQRT2;
let k = 1.0 / (1.5 - w);
var p = vec3f(x4, y4, z4) * k * 1.4;
if (length(p) > 1.34) { p = normalize(p) * 1.34; }
// tumble in 3D so the self-nesting reveals itself.
let xz = rotate2(p.xz, t * 0.14);
let yz = rotate2(vec2f(p.y, xz.y), t * 0.09);
return vec3f(xz.x, yz.x, yz.y);
}
// MEMORY FIELD: a full-screen living nebula. Nodes spread across the ENTIRE
// frame in slow, breathing drifts — no concentrated 2D shape, so it never
// whites out and it covers the whole viewport. This is the "ambient memory"
// beat (the swarm at rest, everything remembered at once).
fn field_target(aux: vec4f, t: f32) -> vec3f {
let id = aux.x;
// even spread across a wide rectangle that overfills the frame edges.
let gx = hash11(id + 5.0) * 2.0 - 1.0;
let gy = hash11(id + 9.0) * 2.0 - 1.0;
let gz = hash11(id + 13.0) * 2.0 - 1.0;
// overfill the frame edges so the nebula covers the WHOLE screen.
let p = vec3f(gx * 2.35, gy * 1.5, gz * 0.9);
// slow per-node breathing so the whole field undulates like a calm sea.
let drift = vec3f(
sin(t * 0.25 + id * 0.011),
cos(t * 0.20 + id * 0.017),
sin(t * 0.18 + id * 0.007)
) * 0.10;
return p + drift;
}
// BRAIN: two lobes, surface-weighted shell with sulci folds (from nodeEngine).
// SEEDED: uniforms.mode.w (the user's email-derived seed) perturbs the brain so
// every signup freezes on a DIFFERENT-but-bounded brain — the sulci phase rotates,
// the inter-lobe gap shifts, and the whole shell rolls a few degrees. All terms are
// bounded (sin/cos/clamped mixes) so the seed can never blow the shape out of frame
// or produce NaN.
fn brain_target(aux: vec4f, t: f32) -> vec3f {
let id = aux.x;
// Decorrelate the raw seed into three bounded knobs. fract(sin(...)) keeps each
// in [0,1) for ANY finite seed (no overflow, no NaN).
let sd = uniforms.mode.w;
let sk = fract(sin(sd * 0.000123 + 11.0) * 43758.5453); // sulci phase knob
let gk = fract(sin(sd * 0.000231 + 23.0) * 24634.6345); // lobe-gap knob
let rk = fract(sin(sd * 0.000317 + 37.0) * 15731.7431); // roll knob
let lobe = select(-1.0, 1.0, hash11(id + 7.0) > 0.5);
let a = hash11(id + 2.0);
let r = mix(0.78, 1.0, pow(a, 0.5));
let theta = hash11(id + 23.0) * 6.2831853;
let phi = acos(2.0 * hash11(id + 37.0) - 1.0);
var x = abs(sin(phi) * cos(theta)) * lobe;
let y = cos(phi) * 0.95;
let z = sin(phi) * sin(theta) * 1.18;
// SEEDED lobe gap: nominal 0.42, perturbed +/-0.06 (stays well inside frame).
let gap = 0.42 + (gk - 0.5) * 0.12;
x = x * 0.78 + lobe * gap;
// SEEDED sulci: the fold phase rotates by the seed so the ridge pattern differs.
let sulciPhase = sk * 6.2831853;
let ridge = sin(x * 8.0 + sulciPhase + t * 0.2) * sin(y * 7.0 + sulciPhase) * 0.10;
var p = vec3f(x, y, z) * r * (1.0 + ridge) * 1.05;
// SEEDED roll: rotate the whole brain a few degrees about Y so the frozen brain
// faces a per-user direction. Bounded rotation -> always in frame.
let roll = (rk - 0.5) * 0.6;
let xz = rotate2(p.xz, roll);
return vec3f(xz.x, p.y, xz.y);
}
// GRAPH CONSTELLATION: fibonacci-sphere nodes only (NO edges drawn — particles
// sit on the node positions; the old version filled edges, we keep nodes only).
fn graph_target(aux: vec4f, t: f32) -> vec3f {
let id = aux.x;
let m = 620.0;
let k = floor(hash11(id + 3.0) * m);
let y = 1.0 - (k / (m - 1.0)) * 2.0;
let rr = sqrt(max(0.0, 1.0 - y * y));
let th = 2.39996323 * k;
let cluster = vec3f(hash11(id + 41.0) - 0.5, hash11(id + 43.0) - 0.5, hash11(id + 47.0) - 0.5) * 0.10;
let spin = t * 0.06;
let p = vec3f(cos(th + spin) * rr, y, sin(th + spin) * rr) * 1.32 + cluster;
return p;
}
// MEMORY LATTICE: hash-jittered 3D grid of cluster cells (columns + clusters,
// no box lines). Particles fill the cells, implying a lattice through density.
fn lattice_target(aux: vec4f, t: f32) -> vec3f {
let id = aux.x;
let g = 6.0;
let cx = floor(hash11(id + 15.0) * g);
let cy = floor(hash11(id + 25.0) * g);
let cz = floor(hash11(id + 35.0) * g);
let cell = 2.0 / g;
var p = vec3f((cx + 0.5) * cell - 1.0, (cy + 0.5) * cell - 1.0, (cz + 0.5) * cell - 1.0) * 1.3;
p += vec3f(hash11(id + 1.0) - 0.5, hash11(id + 2.0) - 0.5, hash11(id + 3.0) - 0.5) * 0.10;
let sway = sin(t * 0.18 + id * 0.017) * 0.04;
return vec3f(p.x + sway, p.y, p.z);
}
// ARCHIVE BEAD COLUMNS: vertical streams of beads on either side (no rails
// drawn — the beads themselves are the column).
fn archive_target(aux: vec4f, t: f32) -> vec3f {
let id = aux.x;
let side = select(-1.0, 1.0, hash11(id + 91.0) > 0.5);
let column = floor(hash11(id + 92.0) * 11.0);
let row = fract(id * 0.0093 + hash11(id + 93.0) * 0.31 + t * 0.05);
// columns span the full width on BOTH sides AND fill toward center so the
// archive reads as a full shelf, centered, not two thin edge rails.
let x = side * (0.30 + column * 0.115 + hash11(id + 96.0) * 0.03);
let y = row * 2.30 - 1.15;
let z = (hash11(id + 97.0) - 0.5) * 0.55;
return vec3f(x, y, z);
}
// RECEIPT STREAMS: bead trails that travel upward in lanes (audit trail), pure
// bead motion, no drawn line.
fn receipt_target(aux: vec4f, t: f32) -> vec3f {
let id = aux.x;
let lane = floor(hash11(id + 61.0) * 18.0);
let x = lane / 17.0 * 2.9 - 1.45;
let travel = fract(hash11(id + 62.0) + t * 0.10);
let y = travel * 2.30 - 1.15;
let wobble = sin(travel * 18.0 + lane) * 0.05;
let z = (hash11(id + 64.0) - 0.5) * 0.4;
return vec3f(x + wobble, y, z);
}
// BURST: volumetric facet cloud (the explode/reform exotic beat).
fn burst_target(aux: vec4f, t: f32) -> vec3f {
let id = aux.x;
let a = hash11(id + 121.0) * 6.2831853;
let b = hash11(id + 122.0) * 6.2831853;
let r = pow(hash11(id + 123.0), 0.30) * 1.7;
let warp = vec3f(cos(a) * cos(b), sin(b) * 0.8, sin(a) * cos(b) * 0.9);
let facet = vec3f(hash11(id + 124.0) - 0.5, hash11(id + 125.0) - 0.5, hash11(id + 126.0) - 0.5) * 0.34;
return (warp + facet) * r + vec3f(sin(t * 0.17 + id * 0.003) * 0.18, 0.02, cos(t * 0.13) * 0.16);
}
// Active formation this loop — EXOTIC point-cloud structures nobody renders as
// glowing node clouds: a chaotic strange attractor, a symmetric loop-lattice, a
// spiky superformula crystal, and a 4D torus projected into 3D. (The particle
// VESTIGE wordmark was removed; the DOM wordmark already names the brand.)
fn formation_target(aux: vec4f, t: f32, shape: f32) -> vec3f {
let seed = aux.x;
if (shape < 0.5) { return aizawa_target(seed, t); }
if (shape < 1.5) { return thomas_target(seed, t); }
if (shape < 2.5) { return supershape_target(seed, t); }
if (shape < 3.5) { return clifford_target(seed, t); }
// shape 4 = the SEEDED BRAIN. Reachable ONLY through the signup override path
// (shapeB=4); the ambient loop never selects it because SHAPE_COUNT stays 4.
return brain_target(aux, t);
}
fn curl(p: vec3f, t: f32) -> vec3f {
return vec3f(
sin(p.y * 7.2 + t * 1.10),
cos(p.z * 6.0 - t * 0.93),
sin(p.x * 6.8 + t * 0.77)
);
}
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3u) {
let i = gid.x;
// guard: the last partial workgroup over-runs the array.
if (i >= arrayLength(&particles)) {
return;
}
var pcl = particles[i];
let t = uniforms.time.x;
let dt = min(uniforms.time.y, 0.033);
let shapeA = uniforms.time.z; // current formation index
let shapeB = uniforms.time.w; // next formation index
let morph = uniforms.mode.y; // 0..1 blend A -> B
let motion = 1.0 - uniforms.viewport.w; // 0 if reduced motion
var position = pcl.pos.xyz;
var velocity = pcl.vel.xyz;
// =====================================================================
// TRANSITION: SINGULARITY COLLAPSE -> SUPERNOVA REBIRTH.
// Orbs spiral INTO a central singularity (free vortex, faster near core),
// crush to a white-hot point, then detonate OUTWARD (forced vortex spin-out)
// and a strong spring snaps them onto shape B. With the trail texture this
// reads as a glowing accretion spiral collapsing to a star then exploding
// into the new form. Every term vanishes at morph 0 and 1 so orbs land
// EXACTLY on the formations (crisp attractors, no fuzzy haze).
// =====================================================================
let id = pcl.aux.x;
let m = morph; // 0..1 (already smoothstepped)
let env = sin(m * 3.14159265); // 0 at ends, 1 mid -> exact landing
let flash = exp(-pow((m - 0.5) / 0.08, 2.0)); // razor white-out spike at the core
let inhale = smoothstep(0.0, 0.5, m);
let outhale = smoothstep(0.5, 1.0, m);
let punch = pow(env, 0.5);
let targetA = formation_target(pcl.aux, t, shapeA);
let targetB = formation_target(pcl.aux, t, shapeB);
// per-particle staggered crossover so the swarm reorganises as a wave.
let stagger = hash11(id + 3.0) * 0.30;
let localMorph = smoothstep(stagger, stagger + 0.55, m);
let shapePos = mix(targetA, targetB, localMorph);
let toShape = shapePos - position;
// swirl axis varies per shapeB so each collapse spins differently.
var axis = vec3f(0.0, 1.0, 0.0);
if (shapeB > 0.5 && shapeB < 1.5) { axis = vec3f(1.0, 0.0, 0.0); }
else if (shapeB > 1.5 && shapeB < 2.5) { axis = normalize(vec3f(1.0, 1.0, 0.0)); }
else if (shapeB > 2.5) { axis = normalize(vec3f(0.0, 1.0, 1.0)); }
let rel = position; // collapse toward origin
let axial = dot(rel, axis) * axis;
let radial = rel - axial;
let rr = max(length(radial), 0.06); // CLAMP: free vortex K/r is NaN at 0
let tangent = normalize(cross(axis, radial) + vec3f(1e-5, 0.0, 0.0));
let radDir = radial / rr;
let SWIRL_K = 0.55; let OMEGA = 1.4; let PULL = 1.15; let BURST = 1.35;
// free vortex inward (faster near core) ramping to forced vortex outward.
let swirl = (SWIRL_K / rr) * (1.0 - outhale) + (OMEGA * rr) * outhale;
var impulse = tangent * swirl * env;
impulse += -radDir * inhale * PULL * punch; // suck in (phase A)
impulse += radDir * outhale * BURST * punch; // detonate out (phase C)
// spring still converges EXACTLY onto shape B (env/outhale terms vanish at m=1).
var kAttract = mix(0.16, 0.32, outhale);
// chaotic curl only mid-transition (zero at hold so held orbs sit still).
let transTurb = 0.05 * env;
// Pointer: nodes flee the cursor and flare (alive interaction).
let pointer = uniforms.pointer.xy;
let pv = position.xy - pointer;
let pd = max(length(pv), 0.05);
let pforce = normalize(vec3f(pv, sin(t + id) * 0.15)) *
(uniforms.pointer.z * uniforms.pointer.w * 0.028 / (pd * pd + 0.05));
velocity += toShape * kAttract;
velocity += impulse;
velocity += curl(position * 0.45, t * 0.5) * transTurb;
velocity += pforce * dt * 60.0;
// damping: heavy at hold (crisp still orbs), lighter mid-transition so the
// collapse + detonation carry momentum into long comet trails.
let damping = mix(0.72, 0.94, env);
velocity *= damping;
let speed = length(velocity);
let speedCap = mix(0.55, 2.6, env);
if (speed > speedCap) { velocity *= speedCap / speed; }
position += velocity * mix(0.35, 1.0, motion);
// energy: the core BLAZES white-hot at the singularity (drives the bloom +
// the tonemap flash), held orbs keep a gentle twinkle.
let coreBlaze = (1.0 - clamp(rr, 0.0, 1.0)) * env * 4.5 + flash * 7.0;
let twinkle = 0.5 + 0.5 * sin(t * 1.8 + id * 0.7);
let held = 0.85 + twinkle * 0.55;
let energy = clamp(
coreBlaze + speed * 1.5 + held + exp(-pd * 2.2) * uniforms.pointer.z * 1.0 + 0.30,
0.30, 7.0
);
pcl.pos = vec4f(position, energy);
pcl.vel = vec4f(velocity, pcl.vel.w);
particles[i] = pcl;
}
`;
// ---------------------------------------------------------------------------
// RENDER SHADER
// Instanced billboard quads (draw(6, count)). Each particle is a NODE whose
// SHAPE is masked from quad-local coords using abs/max/step — square pixels,
// diamonds, soft blobs, shards, star cores, rectangular beads. NO ring or
// circle primitive (no length<r outline), NO velocity streak stretching.
// Premultiplied-alpha additive output for clean HDR glow.
// ---------------------------------------------------------------------------
const renderShader = /* wgsl */ `
struct Particle {
pos: vec4f,
vel: vec4f,
aux: vec4f,
home: vec4f,
};
struct Uniforms {
viewport: vec4f,
pointer: vec4f,
time: vec4f,
mode: vec4f,
extra: vec4f,
};
struct VertexOut {
@builtin(position) position: vec4f,
@location(0) local: vec2f,
@location(1) color: vec3f,
@location(2) glow: f32,
@location(3) kind: f32,
};
@group(0) @binding(0) var<storage, read> particles: array<Particle>;
@group(0) @binding(1) var<uniform> uniforms: Uniforms;
// 5-stop memory spectrum sampled at a position s in [0,1): magenta -> violet ->
// blue -> cyan -> emerald (wraps). s FLOWS over time so the whole cloud's color
// is never static.
fn spectrum(s: f32) -> vec3f {
let magenta = vec3f(0.98, 0.24, 0.86);
let violet = vec3f(0.56, 0.34, 1.00);
let blue = vec3f(0.22, 0.46, 1.00);
let cyan = vec3f(0.14, 0.86, 0.98);
let emerald = vec3f(0.22, 0.96, 0.58);
let x = fract(s) * 5.0;
if (x < 1.0) { return mix(magenta, violet, x); }
if (x < 2.0) { return mix(violet, blue, x - 1.0); }
if (x < 3.0) { return mix(blue, cyan, x - 2.0); }
if (x < 4.0) { return mix(cyan, emerald, x - 3.0); }
return mix(emerald, magenta, x - 4.0);
}
// LIVING color: the hue position drifts in time, traveling waves sweep across
// the cloud by world position, each node pulses, and high-energy nodes flare
// gold -> white-hot so explosions read like fireworks.
fn palette(seed: f32, energy: f32, world: vec3f, t: f32) -> vec3f {
// base hue from the node's frozen seed, but ROTATING the whole wheel over time
let drift = t * 0.06;
// a slow traveling wave across space so bands of color ripple through the cloud
let wave = sin(world.x * 0.9 - t * 0.8) * 0.10 + cos(world.y * 0.8 + t * 0.6) * 0.10;
var col = spectrum(seed + drift + wave);
// per-node pulse (alive shimmer) modulates brightness over time
let pulse = 0.82 + 0.18 * sin(t * 2.2 + seed * 30.0);
col = col * pulse;
// energy flare ONLY for genuinely fast transition nodes (high threshold) so
// HELD formations keep their pure saturated hue instead of washing to cream.
let gold = vec3f(1.00, 0.78, 0.36);
col = mix(col, gold, clamp((energy - 2.0) * 0.5, 0.0, 0.6));
col = mix(col, vec3f(1.0), clamp((energy - 3.2) * 0.5, 0.0, 0.6));
return col;
}
@vertex
fn vs(@builtin(vertex_index) vertex_index: u32, @builtin(instance_index) instance_index: u32) -> VertexOut {
var corners = array<vec2f, 6>(
vec2f(-1.0, -1.0),
vec2f( 1.0, -1.0),
vec2f(-1.0, 1.0),
vec2f(-1.0, 1.0),
vec2f( 1.0, -1.0),
vec2f( 1.0, 1.0)
);
let p = particles[instance_index];
let t = uniforms.time.x;
let aspect = uniforms.viewport.x / max(uniforms.viewport.y, 1.0);
// gentle whole-cloud rotation so formations read volumetrically.
let ca = cos(t * 0.05);
let sa = sin(t * 0.05);
let world = p.pos.xyz;
let rotated = vec3f(
world.x * ca - world.z * sa,
world.y,
world.x * sa + world.z * ca
);
// Camera pulled BACK (bigger depth) + a gentler clip scale so the ENTIRE
// formation centered AND large enough to fill the frame edge-to-edge.
let depth = 2.55 + rotated.z * 0.42;
let clip = vec2f(rotated.x / aspect, rotated.y) / depth * 2.05;
let energy = p.pos.w;
// shape kind picks a size profile too (beads/shards slightly larger).
// Smaller base so dense formations read as a fine TEXTURED swarm of distinct
// nodes rather than fat blobs that merge into a solid white mass.
let kind = p.aux.z;
// burst overdraw fix: SUPPRESS sprite growth as energy rises so total covered
// area stays bounded exactly when nodes are hot AND bunched (the fillrate cliff
// that caused the gold-transition lag). Caps COVERAGE, not just per-sprite px.
let eNorm = clamp((energy - 1.0) / 2.0, 0.0, 1.0); // 0 at hold .. 1 at peak burst
let base = 1.6 + p.aux.w * 1.8 + energy * 1.6 + step(4.5, kind) * 1.0;
let burstCap = mix(15.0, 8.0, eNorm); // smaller cap when hot
let baseClamped = min(base, burstCap);
let pixel = 2.0 / max(min(uniforms.viewport.x, uniforms.viewport.y), 1.0);
let local = corners[vertex_index];
var out: VertexOut;
out.position = vec4f(clip + local * baseClamped * pixel, 0.0, 1.0);
out.local = local;
// SEEDED-BRAIN HUE: only when the brain (shape 4) is involved (supernova path),
// rotate every node's hue by a seed-derived offset so each user's brain has its
// own colorway. Zero effect on the ambient attractors (shapeA/B are always < 3.5
// in the ambient loop), so their palette is byte-identical.
let brainActive = step(3.5, uniforms.time.z) + step(3.5, uniforms.time.w);
let hueShift = select(0.0, fract(sin(uniforms.mode.w * 0.000137 + 5.0) * 33891.7), brainActive > 0.5);
out.color = palette(p.aux.w + hueShift, energy, world, t);
out.glow = energy;
out.kind = kind;
return out;
}
// SOFT node sprites from quad-local coords. Every node is a luminous orb: a
// bright gaussian CORE plus a soft HALO, with an anti-aliased silhouette. The
// shape kind only bends the distance metric (square/diamond/shard/bead/star/
// cross) so silhouettes vary, but the falloff is always SMOOTH — no crunchy
// hard edges, ZERO ring/circle outlines (filled falloffs only).
fn shape_mask(q: vec2f, kind: f32) -> f32 {
// returns a soft silhouette weight in [0,1]; 1 at centre, smoothly -> 0 at rim.
var dist: f32;
if (kind < 0.5) {
// rounded square (Chebyshev softened toward Euclidean)
dist = mix(max(abs(q.x), abs(q.y)), length(q), 0.35);
} else if (kind < 1.5) {
// diamond (L1)
dist = (abs(q.x) + abs(q.y)) * 0.82;
} else if (kind < 2.5) {
// round blob (L2)
dist = length(q);
} else if (kind < 3.5) {
// shard: tall narrow
dist = length(vec2f(q.x * 2.2, q.y * 0.75));
} else if (kind < 4.5) {
// star: min of axis-aligned and rotated diamonds -> 4-point soft star
let a = abs(q.x) + abs(q.y);
let b = abs(q.x - q.y) + abs(q.x + q.y);
dist = min(a, b) * 0.7;
} else if (kind < 5.5) {
// wide bead
dist = length(vec2f(q.x * 0.7, q.y * 1.5));
} else {
// plus / cross spark
dist = min(max(abs(q.x) * 2.6, abs(q.y)), max(abs(q.x), abs(q.y) * 2.6)) * 0.9;
}
return smoothstep(1.05, 0.0, dist);
}
@fragment
fn fs(in: VertexOut) -> @location(0) vec4f {
let q = in.local; // quad-local in [-1,1]
let d = length(q); // 0 centre .. ~1.41 corner
// CLEAN ROUND ORB (Three.js Journey "light point"): the falloff is a radial
// profile inside a HARD-ZERO window that closes at R < 1, so the quad CORNERS
// (d up to 1.41) are EXACTLY 0 at any HDR brightness — the sprite can never
// square off. Shape is decoupled from brightness: density + bloom build the
// bright centre, the sprite itself stays dim and round.
let R = 0.92;
let rn = clamp(d / R, 0.0, 1.0); // 0 centre .. 1 at disc edge, clamped past R
let disc = 1.0 - smoothstep(0.0, 1.0, rn); // exactly 1 at centre, exactly 0 at rn>=1
if (disc <= 0.001) { discard; }
// Every node is the SAME clean round orb: tight bright core + wide soft tail,
// both vanishing at the disc edge. No per-particle shape variety — uniform
// round dots read premium (like the Three.js Points version); the beauty comes
// from arrangement + motion, not sprite gimmicks.
let core = pow(1.0 - rn, 3.0);
let halo = (1.0 - rn) * 0.45;
let profile = (core + halo) * disc;
// LOW per-sprite peak — no flat pedestal, no big multiplier. Brightness scales
// purely from the radial profile and node energy; bloom does the rest.
let intensity = (0.18 + in.glow * 0.85);
let rgb = in.color * intensity;
return vec4f(rgb * profile, 1.0); // premultiplied, additive (one,one)
}
`;
// ---------------------------------------------------------------------------
// TONEMAP PASS
// Fullscreen triangle reads the HDR accumulation texture and ACES-tonemaps it
// to the 8-bit swapchain. This is what keeps dense additive node clusters
// RICH and COLORED instead of clipping to white, and gives the whole frame a
// filmic, 2026-demo glow. Adds a faint hue lift in the shadows so the field
// never reads as flat black between nodes.
// ---------------------------------------------------------------------------
const tonemapShader = /* wgsl */ `
@group(0) @binding(0) var hdrTex: texture_2d<f32>;
@group(0) @binding(1) var hdrSampler: sampler;
@group(0) @binding(2) var bloomTex: texture_2d<f32>;
struct Uniforms {
viewport: vec4f,
pointer: vec4f,
time: vec4f,
mode: vec4f,
extra: vec4f, // x=burst y=flash
};
@group(0) @binding(3) var<uniform> fx: Uniforms;
struct VertexOut {
@builtin(position) position: vec4f,
@location(0) uv: vec2f,
};
@vertex
fn vs(@builtin(vertex_index) vi: u32) -> VertexOut {
var pos = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
var out: VertexOut;
let p = pos[vi];
out.position = vec4f(p, 0.0, 1.0);
out.uv = vec2f((p.x + 1.0) * 0.5, (1.0 - p.y) * 0.5);
return out;
}
// Reinhard tonemap of a SCALAR (luminance). Compresses [0,inf) -> [0,1).
fn reinhard(x: f32) -> f32 {
return x / (1.0 + x);
}
// RADIAL ZOOM BLUR toward centre — the "explosion rushes the camera" punch.
// textureSampleLevel (not textureSample) is MANDATORY inside the loop (WGSL
// forbids implicit derivatives in non-uniform control flow).
fn zoomBlur(uv: vec2f, c: vec2f, strength: f32) -> vec3f {
var col = vec3f(0.0);
let dir = uv - c;
for (var j = 0; j < 12; j = j + 1) {
let k = 1.0 - strength * (f32(j) / 12.0);
col += textureSampleLevel(hdrTex, hdrSampler, dir * k + c, 0.0).rgb;
}
return col / 12.0;
}
// GOD RAYS — radial blur of the bright bloom toward centre. Each hot core throws
// volumetric light shafts at the supernova peak (GPU Gems 3 Ch.13, Mitchell).
fn godRays(uv: vec2f, c: vec2f, density: f32, decay: f32, weight: f32) -> vec3f {
var coord = uv;
let delta = (uv - c) * (1.0 / 16.0) * density;
var col = vec3f(0.0);
var illum = 1.0;
for (var i = 0; i < 16; i = i + 1) {
coord = coord - delta;
let s = textureSampleLevel(bloomTex, hdrSampler, coord, 0.0).rgb;
col += s * illum * weight;
illum *= decay;
}
return col / 23.33;
}
@fragment
fn fs(in: VertexOut) -> @location(0) vec4f {
let burst = fx.extra.x; // sin(morph*PI): 0 hold .. 1 mid-morph
let flash = fx.extra.y; // razor spike at the singularity
let c = vec2f(0.5);
// SHOCKWAVE: an expanding ring displaces the sample coords as it sweeps past,
// warping space like a pressure wave at the collapse.
var uv = in.uv;
if (burst > 0.01) {
let toC = in.uv - c;
let dist = length(toC);
let waveR = burst * 0.9; // ring radius grows with the burst
let ring = exp(-pow((dist - waveR) / 0.045, 2.0)); // gaussian shell
uv = in.uv - normalize(toC + vec2f(1e-5)) * ring * 0.035 * burst;
}
var scene: vec3f;
if (burst > 0.01) {
// during the transition: zoom-blur streaks + radial chromatic split (the
// chromatic amount also spikes on the shockwave ring edge).
let s = 0.34 * burst;
scene = zoomBlur(uv, c, s);
let dir = uv - c;
let off = dir * 0.014 * burst;
scene.r = zoomBlur(uv + off, c, s).r;
scene.b = zoomBlur(uv - off, c, s).b;
} else {
scene = textureSample(hdrTex, hdrSampler, uv).rgb; // cheap path at rest
}
let bloom = textureSample(bloomTex, hdrSampler, uv).rgb;
// Combine bloom in LINEAR HDR BEFORE tonemapping; bloom strength SPIKES at the
// singularity flash so the core blows to a white-hot supernova.
var hdr = scene + bloom * (0.35 * (1.0 + 1.5 * flash));
// GOD RAYS at the flash peak — light shafts streaking from the white-hot core.
if (flash > 0.02) {
hdr += godRays(uv, c, 0.9, 0.8836, 0.65) * flash * 1.4;
}
// ANAMORPHIC blue lens streak — horizontal blur of the bloom, tinted blue.
if (burst > 0.01) {
var streak = vec3f(0.0);
for (var k = -5; k <= 5; k = k + 1) {
streak += textureSampleLevel(bloomTex, hdrSampler, uv + vec2f(f32(k) * 0.020, 0.0), 0.0).rgb;
}
hdr += (streak / 11.0) * vec3f(0.35, 0.6, 1.0) * burst * 0.7;
}
// exposure spike at the collapse — rolls off filmically through Reinhard.
hdr = hdr * (1.0 + 3.0 * flash);
let exposed = hdr * 0.26;
// HUE-PRESERVING tonemap: tonemap the LUMINANCE only, then scale the original
// chroma by the tonemapped/original luminance ratio. Dense additive clusters
// stay deeply CYAN / VIOLET / EMERALD at full density instead of whiting out.
let lum = max(dot(exposed, vec3f(0.2126, 0.7152, 0.0722)), 1e-4);
let toned = reinhard(lum * 1.1);
var color = exposed * (toned / lum);
// STRONG saturation boost so the spectrum is vivid and electric, never cream.
let gray = dot(color, vec3f(0.299, 0.587, 0.114));
color = clamp(gray + (color - vec3f(gray)) * 2.1, vec3f(0.0), vec3f(1.0));
color = pow(color, vec3f(0.90));
// VIGNETTE PULSE — tighten the frame edges during the burst so the eye locks
// onto the supernova core (capped so the flash never gets swallowed).
let vig = 1.0 - smoothstep(0.45, 1.25, length(in.uv - vec2f(0.5)) * (1.0 + 0.5 * burst));
color = color * mix(1.0, vig, 0.35 * burst);
// sub-LSB dither breaks 8-bit banding in the dark field between nodes.
let dither = (fract(sin(dot(in.position.xy, vec2f(12.9898, 78.233))) * 43758.5453) - 0.5) / 255.0;
let base = vec3f(0.010, 0.014, 0.030);
return vec4f(base + color + dither, 1.0);
}
`;
// ---------------------------------------------------------------------------
// BLOOM (dual-Kawase) — runs in linear HDR before the tonemap. A few
// downsample-blur passes then upsample-combine passes produce a soft colored
// glow around bright nodes. No threshold (blooms the whole HDR buffer) so the
// glow stays the node's own hue, and the tonemap combines it weakly.
// ---------------------------------------------------------------------------
const bloomShader = /* wgsl */ `
@group(0) @binding(0) var src: texture_2d<f32>;
@group(0) @binding(1) var samp: sampler;
struct VO { @builtin(position) pos: vec4f, @location(0) uv: vec2f };
@vertex
fn vs(@builtin(vertex_index) vi: u32) -> VO {
var p = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
var out: VO;
let v = p[vi];
out.pos = vec4f(v, 0.0, 1.0);
out.uv = vec2f((v.x + 1.0) * 0.5, (1.0 - v.y) * 0.5);
return out;
}
// 5-tap downsample: center weighted, 4 corners. A pre-bloom clamp bounds how hot
// one super-bright node can drive the mip pyramid, keeping bloom cost flat during
// the bright burst (so the gold transition doesn't spike fillrate).
@fragment
fn down(in: VO) -> @location(0) vec4f {
let texel = vec2f(1.0) / vec2f(textureDimensions(src));
let o = texel * 1.0;
var c = textureSample(src, samp, in.uv) * 4.0;
c += textureSample(src, samp, in.uv + vec2f(-o.x, -o.y));
c += textureSample(src, samp, in.uv + vec2f( o.x, -o.y));
c += textureSample(src, samp, in.uv + vec2f(-o.x, o.y));
c += textureSample(src, samp, in.uv + vec2f( o.x, o.y));
return min(c / 8.0, vec4f(8.0));
}
// 8-tap tent upsample (widens the glow).
@fragment
fn up(in: VO) -> @location(0) vec4f {
let texel = vec2f(1.0) / vec2f(textureDimensions(src));
let o = texel * 1.0;
var c = vec4f(0.0);
c += textureSample(src, samp, in.uv + vec2f(-o.x * 2.0, 0.0));
c += textureSample(src, samp, in.uv + vec2f( o.x * 2.0, 0.0));
c += textureSample(src, samp, in.uv + vec2f(0.0, -o.y * 2.0));
c += textureSample(src, samp, in.uv + vec2f(0.0, o.y * 2.0));
c += textureSample(src, samp, in.uv + vec2f(-o.x, o.y)) * 2.0;
c += textureSample(src, samp, in.uv + vec2f( o.x, o.y)) * 2.0;
c += textureSample(src, samp, in.uv + vec2f(-o.x, -o.y)) * 2.0;
c += textureSample(src, samp, in.uv + vec2f( o.x, -o.y)) * 2.0;
return c / 12.0;
}
`;
// ---------------------------------------------------------------------------
// TRAIL FADE — the motion-trail accumulation. Each frame, before drawing the
// orbs, this multiplies the trail texture by a decay constant (set via
// setBlendConstant) so the previous frame's light dims but doesn't vanish.
// Orbs then draw additively ON TOP, leaving glowing comet tails when they move
// fast (during a transition) and tight orbs when still (at hold). NODE-ONLY:
// the streaks are the orbs' OWN accumulated light, not drawn line primitives.
// ---------------------------------------------------------------------------
const fadeShader = /* wgsl */ `
@vertex
fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
var p = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
return vec4f(p[vi], 0.0, 1.0);
}
@fragment
fn fs() -> @location(0) vec4f {
// output is ignored for color; blend = dst * constant (see fade pipeline).
return vec4f(1.0);
}
`;
// XorShift PRNG, deterministic per seed.
function random(seedValue: number) {
let state = seedValue >>> 0 || 1;
return () => {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
return ((state >>> 0) % 1000000) / 1000000;
};
}
// Build particles starting on an off-screen edge shell (so STREAM flies in)
// with seed/lane/kind/hue and a dissolve home point baked in.
function buildParticles(count: number) {
const rand = random(seed);
const data = new Float32Array(count * 16); // 4 vec4f per particle
for (let i = 0; i < count; i += 1) {
const o = i * 16;
// edge-shell spawn (one of 4 edges, just off screen)
const edge = Math.floor(rand() * 4);
const tEdge = rand();
let hx = 0;
let hy = 0;
const hw = 2.6;
const hh = 1.9;
if (edge === 0) {
hx = -hw;
hy = (tEdge * 2 - 1) * hh;
} else if (edge === 1) {
hx = hw;
hy = (tEdge * 2 - 1) * hh;
} else if (edge === 2) {
hy = hh;
hx = (tEdge * 2 - 1) * hw;
} else {
hy = -hh;
hx = (tEdge * 2 - 1) * hw;
}
const hz = (rand() - 0.4) * 1.2;
// bias kind toward SOFT orbs (blob/star) for a premium look; keep the
// geometric kinds (diamond/bead/shard/square/cross) as a sparse accent.
const rk = rand();
const kind =
rk < 0.4
? 2 // soft blob
: rk < 0.62
? 4 // star core
: rk < 0.76
? 1 // diamond
: rk < 0.86
? 5 // bead
: rk < 0.94
? 3 // shard
: rk < 0.98
? 0 // square pixel
: 6; // cross spark
// pos: start on the shell
data[o] = hx;
data[o + 1] = hy;
data[o + 2] = hz;
data[o + 3] = rand() * 0.4; // energy
// vel: zero, with staggered spawn delay in .w
data[o + 4] = 0;
data[o + 5] = 0;
data[o + 6] = 0;
data[o + 7] = 0.10 * rand() + 0.08 * ((edge + tEdge) / 4);
// aux: id, lane, kind, hue seed
data[o + 8] = i + rand() * 17;
data[o + 9] = i % 7;
data[o + 10] = kind;
data[o + 11] = rand();
// home: dissolve target == spawn shell, perimeter t in .w
data[o + 12] = hx;
data[o + 13] = hy;
data[o + 14] = hz;
data[o + 15] = (edge + tEdge) / 4;
}
return data;
}
function resizeCanvas(canvas: HTMLCanvasElement, maxDpr = 2) {
const rect = canvas.getBoundingClientRect();
const viewport = window.visualViewport;
const cssWidth =
rect.width > 1 ? rect.width : viewport?.width || window.innerWidth || canvas.clientWidth || 1;
const cssHeight =
rect.height > 1 ? rect.height : viewport?.height || window.innerHeight || canvas.clientHeight || 1;
const dpr = Math.min(window.devicePixelRatio || 1, maxDpr);
const width = Math.max(1, Math.floor(cssWidth * dpr));
const height = Math.max(1, Math.floor(cssHeight * dpr));
let changed = false;
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
changed = true;
}
return { width, height, dpr, changed };
}
// Number of formations in the morph cycle (VESTIGE wordmark, Aizawa, Thomas,
// supershape, Clifford torus). Each HOLDS fully formed for HOLD_SECONDS, then
// the dramatic singularity-collapse MORPH into the next takes MORPH_SECONDS.
const SHAPE_COUNT = 4;
const HOLD_SECONDS = 5.0;
const MORPH_SECONDS = 2.0;
const SLOT_SECONDS = HOLD_SECONDS + MORPH_SECONDS; // 7s per formation
const LOOP = SHAPE_COUNT * SLOT_SECONDS;
// ---- SUPERNOVA-ON-SIGNUP state machine ----------------------------------
// On signup success the hero DECOUPLES from the ambient time cycle: it morphs
// from whatever shape is current INTO the seeded brain (shape 4), flashes once
// at the singularity, eases the reform, then FREEZES on the user's brain forever
// (never returns to the ambient loop). reduced-motion takes a gentle no-flash
// reveal instead. The brain is seeded from supernovaSeed (the email hash).
const BRAIN_SHAPE = 4; // index in formation_target; NOT in the ambient SHAPE_COUNT loop
const SUPERNOVA_RAMP = 1.2; // seconds 0->1 morph into the brain
let supernovaActive = false; // true once a signup has fired
let supernovaStart = 0; // performance.now() at trigger
let supernovaFromShape = 0; // shapeA frozen at trigger (whatever was current)
let supernovaSeed = 20260625; // per-user seed (email hash); defaults to hero seed
let supernovaReduced = false; // snapshot of reducedMotion at trigger time
// Public trigger. Called by the window 'vestige:supernova' listener (see onMount).
// Captures the CURRENT ambient shapeA so the morph starts from what's on screen.
function triggerSupernova(seedValue: number) {
if (supernovaActive) {
// already detonated — only update the seed (re-freeze on the new brain).
if (Number.isFinite(seedValue)) supernovaSeed = seedValue >>> 0;
return;
}
const tNow = (performance.now() - startedAt) / 1000;
const cycle = tNow / SLOT_SECONDS;
supernovaFromShape = Math.floor(cycle) % SHAPE_COUNT; // current ambient shapeA
supernovaSeed = Number.isFinite(seedValue) ? seedValue >>> 0 : supernovaSeed;
supernovaReduced = reducedMotion;
supernovaStart = performance.now();
supernovaActive = true;
}
function writeUniforms(now: number, delta: number) {
if (!gpu || !gpuCanvas) return;
const { width, height, dpr, changed } = resizeCanvas(gpuCanvas, reducedMotion ? 1.25 : 2);
if (changed) {
gpu.context.configure({
device: gpu.device,
format: gpu.format,
alphaMode: 'opaque'
});
}
const time = (now - startedAt) / 1000;
// CONTINUOUS SHAPE-TO-SHAPE MORPH (no edge-shell stream/dissolve, no burst
// gap). The loop walks through SHAPE_COUNT formations; each gets one slot of
// HOLD then a MORPH straight into the next. shapeA/shapeB/morphBlend drive
// the compute shader so a formation dissolves directly INTO the next one.
const cycle = time / SLOT_SECONDS; // fractional formation index, rising
let shapeA = Math.floor(cycle) % SHAPE_COUNT;
let shapeB = (shapeA + 1) % SHAPE_COUNT;
const elapsed = (time % SLOT_SECONDS); // seconds into this formation's slot
// HOLD fully formed for HOLD_SECONDS, THEN the dramatic explode+reform morph
// over MORPH_SECONDS. morphBlend stays exactly 0 for the whole 5s hold.
let morphBlend =
elapsed < HOLD_SECONDS ? 0 : (elapsed - HOLD_SECONDS) / MORPH_SECONDS;
// smoothstep the morph so it eases in/out, not a linear slide.
morphBlend = morphBlend * morphBlend * (3 - 2 * morphBlend);
if (reducedMotion) {
// freeze fully formed on the SEEDED BRAIN (shape 4), no morphing.
// (was Thomas=1; the brain is the signup destination so reduced-motion
// users still see THEIR brain, just without any motion.)
shapeA = BRAIN_SHAPE;
shapeB = BRAIN_SHAPE;
morphBlend = 0;
}
// SUPERNOVA OVERRIDE: once a signup fires, decouple from the time cycle and
// drive a one-shot morph INTO the seeded brain, then FREEZE there forever.
// `supernovaReveal` (0 rest .. 1 active) is mirrored to extra.z so the tonemap
// can suppress the flash for reduced-motion users (no god-ray spike).
let supernovaReveal = 0;
if (supernovaActive) {
supernovaReveal = 1;
const sn = (now - supernovaStart) / 1000; // seconds since trigger
let p = Math.min(Math.max(sn / SUPERNOVA_RAMP, 0), 1); // 0..1 linear
p = p * p * (3 - 2 * p); // smoothstep ease (matches the ambient morph feel)
shapeA = supernovaFromShape; // start from what was on screen
shapeB = BRAIN_SHAPE; // ... collapse into the seeded brain
morphBlend = p; // 0 -> 1 then PINNED at 1 (sn>=RAMP -> p clamps to 1)
// reduced-motion: morphBlend still 0->1 so the brain forms, but extra.z=1
// and the burst/flash recompute below hard-zero the singularity FX.
}
// viewport
uniformData[0] = width;
uniformData[1] = height;
uniformData[2] = dpr;
uniformData[3] = reducedMotion ? 1 : 0;
// pointer
uniformData[4] = pointerX;
uniformData[5] = pointerY;
uniformData[6] = pointerActive;
uniformData[7] = pointerPressure;
// time
uniformData[8] = time;
uniformData[9] = delta;
uniformData[10] = shapeA;
uniformData[11] = shapeB;
// burst envelope (0 at hold/ends, 1 mid-morph) + razor flash spike. These
// drive BOTH the compute vortex AND the tonemap post-FX so the whole stack
// peaks on the same frame. Declared `let` because the supernova override
// (above) reassigns morphBlend; recompute from the FINAL value below.
let burst = Math.sin(Math.min(Math.max(morphBlend, 0), 1) * Math.PI);
let flash = Math.exp(-Math.pow((morphBlend - 0.5) / 0.07, 2));
// For reduced-motion supernova, hard-zero the flash so there is no god-ray
// photosensitivity spike (gentle seeded-brain cross-fade only).
if (supernovaActive && supernovaReduced) {
burst = 0;
flash = 0;
}
// frame-rate-independent trail decay: short tails at hold (crisp orbs),
// long comet tails at the morph peak. Capped < 0.95 so it never runs away.
const dt60 = Math.min(delta, 0.033) * 60;
trailDecay = Math.pow(0.84 + 0.10 * burst, dt60);
// mode
uniformData[12] = gpu.particleCount;
uniformData[13] = morphBlend;
uniformData[14] = burst;
// mode.w = the seed read by brain_target. Ambient: the hero seed (attractors
// ignore it, so this is a no-op for them). Supernova: the user's email hash,
// so the frozen brain is THEIRS.
uniformData[15] = supernovaActive ? supernovaSeed : seed;
// extra: x=burst y=flash (tonemap post-FX), z=supernovaReveal (reserved flag).
uniformData[16] = burst;
uniformData[17] = flash;
uniformData[18] = supernovaReveal; // was window.scrollY (read by no shader)
uniformData[19] = 0;
gpu.device.queue.writeBuffer(
gpu.uniformBuffer,
0,
uniformData.buffer,
uniformData.byteOffset,
uniformData.byteLength
);
// Mirror the SAME burst/flash scalars to the DOM (scoped to .launch-shell)
// so the overlay implodes/explodes in perfect sync with the particles. When
// suppressed (form focused) or reduced-motion, pin to rest so a signup is
// never disturbed. Frame-perfect: same call site as the GPU uniform write.
if (syncTarget) {
const s = syncTarget.style;
if (suppress || reducedMotion) {
s.setProperty('--burst', '0');
s.setProperty('--flash', '0');
} else {
s.setProperty('--burst', burst.toFixed(4));
s.setProperty('--flash', flash.toFixed(4));
}
}
}
function pickParticleCount(adapter: any): number {
const small = window.innerWidth < 760;
const limits = adapter?.limits ?? {};
// device tier from fallback flag + storage/compute budget + core count.
const maxStorage = limits.maxStorageBufferBindingSize ?? 134217728;
const maxInvocations = limits.maxComputeInvocationsPerWorkgroup ?? 256;
const cores = navigator.hardwareConcurrency || 8;
const isFallback = adapter?.info?.isFallbackAdapter === true;
const weak =
isFallback ||
maxInvocations < 256 ||
(window.devicePixelRatio || 1) > 2.2 ||
cores <= 4;
const big = maxStorage >= 268435456 && !weak;
// Counts tuned for additive density: enough to feel like a teeming brain,
// not so many that thin 2D formations (the wordmark) saturate to white.
let count: number;
if (weak) count = small ? 20000 : 42000;
else if (big) count = small ? 40000 : 75000;
else count = small ? 32000 : 60000;
if (reducedMotion) count = Math.floor(count * 0.6);
// each particle = 64 bytes (4 vec4f). cap to the storage budget with margin.
const maxByBudget = Math.floor((maxStorage * 0.6) / 64);
return Math.max(8000, Math.min(count, maxByBudget));
}
async function bootWebGPU() {
if (!gpuCanvas) return false;
const nav = navigator as Navigator & { gpu?: any };
const gpuApi = nav.gpu;
const globals = globalThis as unknown as {
GPUBufferUsage: Record<string, number>;
GPUShaderStage: Record<string, number>;
};
if (!gpuApi || !globals.GPUBufferUsage) return false;
const adapter: any = await requestBestAdapter(gpuApi);
if (!adapter) return false;
// Request the adapter's OWN storage limit back verbatim: never exceeds (so
// requestDevice won't reject) but lifts us off the lower spec default so a
// large particle buffer is permitted.
const requiredLimits: Record<string, number> = {};
if (adapter.limits?.maxStorageBufferBindingSize) {
requiredLimits.maxStorageBufferBindingSize = adapter.limits.maxStorageBufferBindingSize;
}
if (adapter.limits?.maxBufferSize) {
requiredLimits.maxBufferSize = adapter.limits.maxBufferSize;
}
const device: any = await requestBestDevice(adapter, requiredLimits);
const context = gpuCanvas.getContext('webgpu');
if (!context) return false;
const format = gpuApi.getPreferredCanvasFormat();
const particleCount = pickParticleCount(adapter);
const particleData = buildParticles(particleCount);
const usage = globals.GPUBufferUsage;
const particleBuffer = device.createBuffer({
label: 'vestige memory particles',
size: particleData.byteLength,
usage: usage.STORAGE | usage.COPY_DST
});
device.queue.writeBuffer(particleBuffer, 0, particleData);
const uniformBuffer = device.createBuffer({
label: 'vestige uniforms',
size: uniformData.byteLength,
usage: usage.UNIFORM | usage.COPY_DST
});
// rgba16float HDR accumulation target so additive node light SUMS in float
// without clipping; the tonemap pass compresses it to the 8-bit swapchain.
const hdrFormat = 'rgba16float';
const computeModule = device.createShaderModule({
label: 'vestige compute wgsl',
code: computeShader
});
const renderModule = device.createShaderModule({
label: 'vestige render wgsl',
code: renderShader
});
const tonemapModule = device.createShaderModule({
label: 'vestige tonemap wgsl',
code: tonemapShader
});
const bloomModule = device.createShaderModule({
label: 'vestige bloom wgsl',
code: bloomShader
});
const fadeModule = device.createShaderModule({
label: 'vestige fade wgsl',
code: fadeShader
});
const fadePipeline: any = await withTimeout(
device.createRenderPipelineAsync({
label: 'vestige trail fade pipeline',
layout: 'auto',
vertex: { module: fadeModule, entryPoint: 'vs' },
fragment: {
module: fadeModule,
entryPoint: 'fs',
targets: [
{
format: hdrFormat,
// dst' = dst * constant (fade the trail by the blend constant)
blend: {
color: { srcFactor: 'zero', dstFactor: 'constant', operation: 'add' },
alpha: { srcFactor: 'zero', dstFactor: 'constant', operation: 'add' }
}
}
]
},
primitive: { topology: 'triangle-list' }
}),
3500,
'WebGPU fade pipeline'
);
const computePipeline: any = await withTimeout(
device.createComputePipelineAsync({
label: 'vestige compute pipeline',
layout: 'auto',
compute: { module: computeModule, entryPoint: 'main' }
}),
3500,
'WebGPU compute pipeline'
);
const renderPipeline: any = await withTimeout(
device.createRenderPipelineAsync({
label: 'vestige additive node pipeline',
layout: 'auto',
vertex: { module: renderModule, entryPoint: 'vs' },
fragment: {
module: renderModule,
entryPoint: 'fs',
targets: [
{
format: hdrFormat,
// pure additive into HDR float: light energy accumulates unclamped.
blend: {
color: { srcFactor: 'one', dstFactor: 'one', operation: 'add' },
alpha: { srcFactor: 'one', dstFactor: 'one', operation: 'add' }
}
}
]
},
primitive: { topology: 'triangle-list' },
multisample: { count: MSAA_SAMPLES }
}),
3500,
'WebGPU particle pipeline'
);
// Shared explicit bind group layout for BOTH bloom passes so their bind
// groups are interchangeable (a 'auto' layout would make them incompatible).
const bloomBGL = device.createBindGroupLayout({
label: 'vestige bloom bgl',
entries: [
{ binding: 0, visibility: globals.GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
{ binding: 1, visibility: globals.GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } }
]
});
const bloomPipelineLayout = device.createPipelineLayout({
label: 'vestige bloom pipeline layout',
bindGroupLayouts: [bloomBGL]
});
const bloomDownPipeline: any = await withTimeout(
device.createRenderPipelineAsync({
label: 'vestige bloom down pipeline',
layout: bloomPipelineLayout,
vertex: { module: bloomModule, entryPoint: 'vs' },
fragment: { module: bloomModule, entryPoint: 'down', targets: [{ format: hdrFormat }] },
primitive: { topology: 'triangle-list' }
}),
3500,
'WebGPU bloom down pipeline'
);
const bloomUpPipeline: any = await withTimeout(
device.createRenderPipelineAsync({
label: 'vestige bloom up pipeline',
layout: bloomPipelineLayout,
vertex: { module: bloomModule, entryPoint: 'vs' },
fragment: {
module: bloomModule,
entryPoint: 'up',
targets: [
{
format: hdrFormat,
// additive so each upsample LEVEL accumulates onto the level below
// (progressive widening of the glow up the chain).
blend: {
color: { srcFactor: 'one', dstFactor: 'one', operation: 'add' },
alpha: { srcFactor: 'one', dstFactor: 'one', operation: 'add' }
}
}
]
},
primitive: { topology: 'triangle-list' }
}),
3500,
'WebGPU bloom up pipeline'
);
const tonemapPipeline: any = await withTimeout(
device.createRenderPipelineAsync({
label: 'vestige tonemap pipeline',
layout: 'auto',
vertex: { module: tonemapModule, entryPoint: 'vs' },
fragment: {
module: tonemapModule,
entryPoint: 'fs',
targets: [{ format }]
},
primitive: { topology: 'triangle-list' }
}),
3500,
'WebGPU tonemap pipeline'
);
const hdrSampler = device.createSampler({
label: 'vestige hdr sampler',
magFilter: 'linear',
minFilter: 'linear'
});
const computeBindGroup = device.createBindGroup({
label: 'vestige compute bind group',
layout: computePipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: particleBuffer } },
{ binding: 1, resource: { buffer: uniformBuffer } }
]
});
const renderBindGroup = device.createBindGroup({
label: 'vestige render bind group',
layout: renderPipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: particleBuffer } },
{ binding: 1, resource: { buffer: uniformBuffer } }
]
});
context.configure({
device,
format,
alphaMode: 'opaque'
});
gpu = {
device,
context,
format,
hdrFormat,
particleBuffer,
uniformBuffer,
computePipeline,
renderPipeline,
tonemapPipeline,
bloomDownPipeline,
bloomUpPipeline,
bloomBGL,
fadePipeline,
trailTexture: null,
trailView: null,
trailPrimed: false,
computeBindGroup,
renderBindGroup,
tonemapBindGroup: null,
hdrTexture: null,
hdrView: null,
hdrMsaaTexture: null,
hdrMsaaView: null,
hdrSampler,
bloomMips: [],
bloomBindGroups: [],
hdrWidth: 0,
hdrHeight: 0,
particleCount
};
device.lost.then((info: { message?: string }) => {
if (disposed) return;
if (gpu?.device !== device) return;
console.warn('[launch] WebGPU device lost:', info?.message ?? 'unknown');
bootFallback(1200);
});
return true;
}
// (re)create the HDR render target, the 4x MSAA target, and the bloom mip chain
// when the canvas size changes. Size-guarded so steady-state allocates NOTHING
// per frame (per-frame createTexture mid-burst is a hitch we must avoid).
function ensureHdrTarget(canvasW: number, canvasH: number) {
if (!gpu) return;
// particle/HDR/bloom all run at HALF resolution to kill burst overdraw.
const width = Math.max(1, Math.floor(canvasW / PARTICLE_SCALE));
const height = Math.max(1, Math.floor(canvasH / PARTICLE_SCALE));
if (gpu.hdrTexture && gpu.hdrWidth === width && gpu.hdrHeight === height) return;
const globals = globalThis as unknown as { GPUTextureUsage: Record<string, number> };
const tu = globals.GPUTextureUsage;
// tear down old textures
gpu.hdrTexture?.destroy?.();
gpu.hdrMsaaTexture?.destroy?.();
gpu.trailTexture?.destroy?.();
for (const m of gpu.bloomMips) m.texture?.destroy?.();
gpu.bloomMips = [];
// single-sample HDR resolve target (sampled by bloom + tonemap)
gpu.hdrTexture = gpu.device.createTexture({
label: 'vestige hdr resolve',
size: { width, height },
format: gpu.hdrFormat,
usage: tu.RENDER_ATTACHMENT | tu.TEXTURE_BINDING
});
gpu.hdrView = gpu.hdrTexture.createView();
// TRAIL accumulation target — particles draw here additively ON TOP of the
// faded previous frame, producing motion-trail comet tails. Bloom + tonemap
// read this instead of a freshly-cleared hdr. Needs one clean clear after
// (re)allocation, then load forever.
gpu.trailTexture = gpu.device.createTexture({
label: 'vestige trail accum',
size: { width, height },
format: gpu.hdrFormat,
usage: tu.RENDER_ATTACHMENT | tu.TEXTURE_BINDING
});
gpu.trailView = gpu.trailTexture.createView();
gpu.trailPrimed = false;
// optional MSAA render target (particles render here, resolve into hdrTexture)
if (USE_MSAA) {
gpu.hdrMsaaTexture = gpu.device.createTexture({
label: 'vestige hdr msaa',
size: { width, height },
format: gpu.hdrFormat,
sampleCount: MSAA_SAMPLES,
usage: tu.RENDER_ATTACHMENT
});
gpu.hdrMsaaView = gpu.hdrMsaaTexture.createView();
} else {
gpu.hdrMsaaTexture = null;
gpu.hdrMsaaView = null;
}
// bloom mip chain (each half the previous), all rgba16float.
let mw = width;
let mh = height;
for (let i = 0; i < BLOOM_LEVELS; i += 1) {
mw = Math.max(1, Math.floor(mw / 2));
mh = Math.max(1, Math.floor(mh / 2));
const tex = gpu.device.createTexture({
label: `vestige bloom mip ${i}`,
size: { width: mw, height: mh },
format: gpu.hdrFormat,
usage: tu.RENDER_ATTACHMENT | tu.TEXTURE_BINDING
});
gpu.bloomMips.push({ texture: tex, view: tex.createView(), width: mw, height: mh });
}
// per-pass source bind groups: down chain reads hdr then each mip; up chain
// reads the smaller mip. We build them fresh against the source views.
const g = gpu;
const makeSrcBG = (srcView: any) =>
g.device.createBindGroup({
layout: g.bloomBGL,
entries: [
{ binding: 0, resource: srcView },
{ binding: 1, resource: g.hdrSampler }
]
});
gpu.bloomBindGroups = [];
// downsample sources: TRAIL -> mip0 -> mip1 -> ... (reads the level above).
// The trail texture (accumulated orbs) is what bloom + tonemap sample.
gpu.bloomBindGroups.push(makeSrcBG(gpu.trailView));
for (let i = 0; i < BLOOM_LEVELS - 1; i += 1) {
gpu.bloomBindGroups.push(makeSrcBG(gpu.bloomMips[i].view));
}
// upsample sources: read from the smaller mip going back up
for (let i = BLOOM_LEVELS - 1; i > 0; i -= 1) {
gpu.bloomBindGroups.push(makeSrcBG(gpu.bloomMips[i].view));
}
gpu.hdrWidth = width;
gpu.hdrHeight = height;
// tonemap reads trail (binding 0) + bloom mip0 (binding 2) + uniforms (3).
gpu.tonemapBindGroup = gpu.device.createBindGroup({
label: 'vestige tonemap bind group',
layout: gpu.tonemapPipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: gpu.trailView },
{ binding: 1, resource: gpu.hdrSampler },
{ binding: 2, resource: gpu.bloomMips[0].view },
{ binding: 3, resource: { buffer: gpu.uniformBuffer } }
]
});
}
function drawWebGPU(now: number) {
if (!gpu || disposed) return;
frame = requestAnimationFrame(drawWebGPU);
const delta = lastFrame ? Math.min((now - lastFrame) / 1000, 0.033) : 0.016;
lastFrame = now;
pointerActive *= 0.96;
try {
writeUniforms(now, delta);
ensureHdrTarget(gpuCanvas!.width, gpuCanvas!.height);
// Single command encoder for the whole frame (implicit hazard sync within
// a submission; fewer submits = less driver overhead).
const encoder = gpu.device.createCommandEncoder({ label: 'vestige frame' });
const computePass = encoder.beginComputePass({ label: 'vestige compute pass' });
computePass.setPipeline(gpu.computePipeline);
computePass.setBindGroup(0, gpu.computeBindGroup);
computePass.dispatchWorkgroups(Math.ceil(gpu.particleCount / WORKGROUP_SIZE));
computePass.end();
// PASS 1a: FADE the trail texture (dst *= trailDecay). loadOp 'load' keeps
// last frame's light; only the very first frame after (re)alloc clears it.
const fadePass = encoder.beginRenderPass({
label: 'vestige trail fade',
colorAttachments: [
{
view: gpu.trailView,
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: gpu.trailPrimed ? 'load' : 'clear',
storeOp: 'store'
}
]
});
gpu.trailPrimed = true;
fadePass.setPipeline(gpu.fadePipeline);
fadePass.setBlendConstant({ r: trailDecay, g: trailDecay, b: trailDecay, a: trailDecay });
fadePass.draw(3);
fadePass.end();
// PASS 1b: draw the orbs ADDITIVELY on top of the faded trail. loadOp
// 'load' is MANDATORY — 'clear' would erase the trail and kill the effect.
const partPass = encoder.beginRenderPass({
label: 'vestige particle pass',
colorAttachments: [{ view: gpu.trailView, loadOp: 'load', storeOp: 'store' }]
});
partPass.setPipeline(gpu.renderPipeline);
partPass.setBindGroup(0, gpu.renderBindGroup);
partPass.draw(6, gpu.particleCount);
partPass.end();
// PASS 2: BLOOM. Downsample hdr -> mip0 -> mip1 ... then upsample back,
// each into rgba16float so the glow stays colored.
let bgIndex = 0;
for (let i = 0; i < BLOOM_LEVELS; i += 1) {
const dst = gpu.bloomMips[i];
const pass = encoder.beginRenderPass({
label: `bloom down ${i}`,
colorAttachments: [
{ view: dst.view, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' }
]
});
pass.setPipeline(gpu.bloomDownPipeline);
pass.setBindGroup(0, gpu.bloomBindGroups[bgIndex]);
bgIndex += 1;
pass.draw(3);
pass.end();
}
// upsample: combine smaller mips back up into mip0 (additive load).
for (let i = BLOOM_LEVELS - 1; i > 0; i -= 1) {
const dst = gpu.bloomMips[i - 1];
const pass = encoder.beginRenderPass({
label: `bloom up ${i}`,
colorAttachments: [
{ view: dst.view, loadOp: 'load', storeOp: 'store' }
]
});
pass.setPipeline(gpu.bloomUpPipeline);
pass.setBindGroup(0, gpu.bloomBindGroups[bgIndex]);
bgIndex += 1;
pass.draw(3);
pass.end();
}
// PASS 3: tonemap hdr + bloom onto the full-res swapchain.
const tonePass = encoder.beginRenderPass({
label: 'vestige tonemap pass',
colorAttachments: [
{
view: gpu.context.getCurrentTexture().createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: 'clear',
storeOp: 'store'
}
]
});
tonePass.setPipeline(gpu.tonemapPipeline);
tonePass.setBindGroup(0, gpu.tonemapBindGroup);
tonePass.draw(3);
tonePass.end();
gpu.device.queue.submit([encoder.finish()]);
if (mode !== 'webgpu') {
mode = 'webgpu';
cancelAnimationFrame(projectionFrame);
projectionFrame = 0;
}
} catch (error) {
console.warn('[launch] WebGPU frame failed, falling back:', error);
bootFallback(1200);
}
}
// ---------------------------------------------------------------------------
// CANVAS2D FALLBACK — NODE ONLY.
// Draws ONLY filled nodes (fillRect for squares/beads, filled triangles for
// diamonds/shards via path fill — NEVER stroke/arc/ellipse/moveTo-lineTo
// edges/rings). No connections, no lines, no rings, no scanlines.
// ---------------------------------------------------------------------------
type FallbackParticle = {
hx: number;
hy: number;
x: number;
y: number;
vx: number;
vy: number;
kind: number;
seed: number;
delay: number;
lane: number;
};
function hslNode(seed: number, energy: number): string {
// same spectrum family as the GPU palette, expressed in HSL.
const hue = 300 - seed * 180; // magenta(300) -> emerald(120) range
const light = 56 + energy * 22;
return `hsl(${hue}, 95%, ${Math.min(light, 82)}%)`;
}
function startFallback() {
if (disposed || !fallbackCanvas) return;
if (fallbackStarted && projectionFrame) return;
fallbackStarted = true;
const canvas = fallbackCanvas;
const rawContext = canvas.getContext('2d');
if (!rawContext) return;
const ctx: CanvasRenderingContext2D = rawContext;
const rand = random(seed ^ 0x1234abcd);
const isMobile = window.innerWidth < 760;
const count = isMobile ? 820 : 2600;
const fallbackStartedAt = performance.now();
// glyph anchors (node-only wordmark) in normalised [-1.3..1.3] x, [-0.5..0.5] y
const anchors: Array<[number, number]> = [];
if (!isMobile) {
try {
const sign = growSign();
for (const path of sign.paths) {
const m = path.d.match(/M([\d.-]+) ([\d.-]+)L([\d.-]+) ([\d.-]+)/);
if (!m) continue;
const x1 = (Number(m[1]) / sign.width - 0.5) * 2.6;
const y1 = -(Number(m[2]) / sign.height - 0.5) * 0.9;
const x2 = (Number(m[3]) / sign.width - 0.5) * 2.6;
const y2 = -(Number(m[4]) / sign.height - 0.5) * 0.9;
anchors.push([x1, y1], [(x1 + x2) / 2, (y1 + y2) / 2], [x2, y2]);
}
} catch {
/* node band fallback below */
}
}
const particles: FallbackParticle[] = Array.from({ length: count }, (_, i) => {
const edge = Math.floor(rand() * 4);
const te = rand();
let hx = 0;
let hy = 0;
if (edge === 0) {
hx = -1.25;
hy = te * 2 - 1;
} else if (edge === 1) {
hx = 1.25;
hy = te * 2 - 1;
} else if (edge === 2) {
hy = 1.1;
hx = te * 2 - 1;
} else {
hy = -1.1;
hx = te * 2 - 1;
}
const startA = i * 2.39996323;
const startR = 0.12 + rand() * 0.48;
return {
hx,
hy,
x: isMobile ? Math.cos(startA) * startR : hx,
y: isMobile ? Math.sin(startA * 0.92) * startR * 0.62 : hy,
vx: 0,
vy: 0,
kind: Math.floor(rand() * 7),
seed: rand(),
delay: 0.1 * rand() + 0.08 * ((edge + te) / 4),
lane: i % 14
};
});
// node draw helpers — FILLED RECTS ONLY (with rotation for diamonds/shards).
// Deliberately NO path APIs (no moveTo/lineTo/arc/ellipse/stroke) so the
// fallback is provably node-only: every node is one or more filled boxes.
function fillRot(px: number, py: number, hw: number, hh: number, angle: number) {
ctx.save();
ctx.translate(px, py);
if (angle !== 0) ctx.rotate(angle);
ctx.fillRect(-hw, -hh, hw * 2, hh * 2);
ctx.restore();
}
function fillSquare(px: number, py: number, s: number) {
ctx.fillRect(px - s, py - s, s * 2, s * 2);
}
function fillDiamond(px: number, py: number, s: number) {
// a square rotated 45deg reads as a diamond — pure fill, no outline.
fillRot(px, py, s * 0.82, s * 0.82, Math.PI / 4);
}
function fillShard(px: number, py: number, s: number) {
ctx.fillRect(px - s * 0.4, py - s * 1.5, s * 0.8, s * 3);
}
function fillBead(px: number, py: number, s: number) {
ctx.fillRect(px - s * 1.4, py - s * 0.6, s * 2.8, s * 1.2);
}
function fillStar(px: number, py: number, s: number) {
// star core = two overlapping rotated squares (8-point), no outline.
fillRot(px, py, s * 0.8, s * 0.8, 0);
fillRot(px, py, s * 0.8, s * 0.8, Math.PI / 4);
fillSquare(px, py, s * 0.4);
}
function fillCross(px: number, py: number, s: number) {
ctx.fillRect(px - s * 1.5, py - s * 0.35, s * 3, s * 0.7);
ctx.fillRect(px - s * 0.35, py - s * 1.5, s * 0.7, s * 3);
}
function brainTarget(p: FallbackParticle): [number, number] {
const lobe = p.seed > 0.5 ? 1 : -1;
const th = p.seed * Math.PI * 2;
const r = 0.5 + p.seed * 0.4;
return [Math.cos(th) * r * 0.55 + lobe * 0.34, Math.sin(th) * r * 0.7];
}
function graphTarget(p: FallbackParticle, t: number): [number, number] {
const k = Math.floor(p.seed * 240);
const th = 2.39996323 * k + t * 0.1;
const r = 0.3 + (k % 30) / 30 * 0.7;
return [Math.cos(th) * r, Math.sin(th) * r * 0.8];
}
function mobileGraphTarget(p: FallbackParticle, t: number): [number, number] {
const k = Math.floor(p.seed * 84);
const shell = k % 3;
const a = k * 2.39996323 + t * 0.12;
const b = Math.sin(k * 1.618 + t * 0.18);
const r = 0.18 + shell * 0.24 + ((k * 17) % 23) / 23 * 0.16;
const depth = 0.72 + b * 0.18;
return [Math.cos(a) * r * depth, Math.sin(a * 0.92) * r * 0.72 * depth + b * 0.08];
}
function latticeTarget(p: FallbackParticle): [number, number] {
const g = 6;
const cx = Math.floor(p.seed * g);
const cy = Math.floor((p.seed * 53) % 1 * g);
return [(cx + 0.5) / g * 2 - 1, (cy + 0.5) / g * 1.6 - 0.8];
}
function archiveTarget(p: FallbackParticle, t: number): [number, number] {
const side = p.seed > 0.5 ? 1 : -1;
const row = (p.seed * 7 + t * 0.05) % 1;
return [side * (0.7 + (p.lane % 5) * 0.12), row * 2 - 1];
}
function receiptTarget(p: FallbackParticle, t: number): [number, number] {
const x = p.lane / 13 * 2 - 1;
const travel = (p.seed + t * 0.1) % 1;
return [x + Math.sin(travel * 18 + p.lane) * 0.05, travel * 2 - 1];
}
function glyphTarget(p: FallbackParticle): [number, number] {
if (!anchors.length) return [(p.seed - 0.5) * 2.4, (p.lane / 14 - 0.5) * 0.8];
const a = anchors[Math.floor(p.seed * anchors.length) % anchors.length];
return [a[0], a[1]];
}
function formation(p: FallbackParticle, shape: number, t: number): [number, number] {
switch (shape) {
case 0:
return glyphTarget(p);
case 1:
return brainTarget(p);
case 2:
if (isMobile) return mobileGraphTarget(p, t);
return graphTarget(p, t);
case 3:
return latticeTarget(p);
case 4:
return archiveTarget(p, t);
case 5:
return receiptTarget(p, t);
default:
return [(p.seed - 0.5) * 3, (p.lane / 14 - 0.5) * 2];
}
}
function drawGraphScaffold(t: number, cx: number, cy: number, span: number) {
const nodeCount = isMobile ? 64 : 58;
const points: Array<{ x: number; y: number; z: number }> = [];
for (let i = 0; i < nodeCount; i += 1) {
const a = i * 2.399963 + t * 0.18;
const ring = 0.12 + (((i * 37) % 100) / 100) * 0.74;
const z = Math.sin(i * 1.73 + t * 0.55);
const scale = 1 / (1.55 - z * 0.22);
points.push({
x: cx + Math.cos(a) * ring * span * scale,
y: cy + Math.sin(a * 0.82) * ring * span * 0.62 * scale,
z
});
}
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.lineWidth = isMobile ? 1.6 : 0.9;
for (let i = 0; i < points.length; i += 1) {
const a = points[i];
const links = [
points[(i + 5) % points.length],
points[(i + 13) % points.length],
points[(i + 29) % points.length]
];
for (const b of links) {
if (Math.abs(a.z - b.z) > 1.25) continue;
const alpha = isMobile ? 0.28 : 0.12;
ctx.strokeStyle = `rgba(116, 225, 255, ${alpha})`;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
for (const p of points) {
const s = (isMobile ? 3.0 : 2.0) + (p.z + 1) * 1.4;
ctx.fillStyle = p.z > 0 ? 'rgba(118, 245, 205, 0.95)' : 'rgba(133, 150, 255, 0.72)';
ctx.fillRect(p.x - s, p.y - s, s * 2, s * 2);
}
ctx.restore();
}
let localShape = isMobile ? 2 : 0;
let localPrev = 0;
function draw(now: number) {
if (disposed) return;
projectionFrame = requestAnimationFrame(draw);
const { width, height, dpr } = resizeCanvas(canvas, isMobile ? 1.5 : 2);
const w = width / dpr;
const h = height / dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// solid, NON-circular wash (no radial-gradient circle). Subtle vertical
// tint via a linear fill only.
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = '#03040d';
ctx.fillRect(0, 0, w, h);
const t = (now - fallbackStartedAt) / 1000;
const phase = (t % LOOP) / LOOP;
if (!reducedMotion && localPrev > 0.9 && phase < 0.1) localShape = (localShape + 1) % 7;
localPrev = phase;
const mobileShapes = [2, 1, 3, 0];
const shape = reducedMotion
? 1
: isMobile
? mobileShapes[Math.floor(t / 7) % mobileShapes.length]
: localShape;
const wStream = isMobile ? 0 : 1 - Math.min(Math.max((phase - 0.16) / 0.06, 0), 1);
const wHold = isMobile
? 1
: Math.min(Math.max((phase - 0.3) / 0.16, 0), 1) *
(1 - Math.min(Math.max((phase - 0.78) / 0.08, 0), 1));
const wDissolve = isMobile ? 0 : Math.min(Math.max((phase - 0.84) / 0.16, 0), 1);
ctx.globalCompositeOperation = 'lighter';
const span = Math.min(w, h) * (isMobile ? 0.36 : 0.42);
const cx = w * 0.5;
const cy = h * (isMobile ? 0.38 : 0.5);
if (isMobile) drawGraphScaffold(t, cx, cy, span);
if (isMobile) ctx.globalCompositeOperation = 'source-over';
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
const [fx, fy] = formation(p, shape, t);
// blend home -> formation by beats
let tx = fx;
let ty = fy;
if (wStream > 0.01) {
tx = p.hx + (fx - p.hx) * (1 - wStream);
ty = p.hy + (fy - p.hy) * (1 - wStream);
}
if (wDissolve > 0.01) {
tx = tx + (p.hx - tx) * wDissolve;
ty = ty + (p.hy - ty) * wDissolve;
}
// spring toward target
p.vx += (tx - p.x) * 0.08 * (0.4 + wHold);
p.vy += (ty - p.y) * 0.08 * (0.4 + wHold);
// pointer repel
const dx = p.x - pointerX;
const dy = p.y - pointerY;
const pd = Math.max(Math.hypot(dx, dy), 0.08);
if (pointerActive > 0.01 && pd < 0.6) {
p.vx += (dx / pd) * pointerActive * 0.01;
p.vy += (dy / pd) * pointerActive * 0.01;
}
p.vx *= 0.86;
p.vy *= 0.86;
p.x += p.vx * (reducedMotion ? 0.2 : 1);
p.y += p.vy * (reducedMotion ? 0.2 : 1);
const speed = Math.hypot(p.vx, p.vy);
const energy = Math.min(speed * 8 + wHold * 0.4 + 0.2, 1.5);
const px = cx + p.x * span;
const py = cy - p.y * span;
const s = (isMobile ? 1.15 : 1.1) + p.seed * 1.25 + energy * (isMobile ? 1.65 : 1.8);
ctx.fillStyle = isMobile
? p.seed > 0.5
? 'rgba(104, 246, 211, 0.78)'
: 'rgba(134, 156, 255, 0.72)'
: hslNode(p.seed, energy);
ctx.globalAlpha = Math.min((isMobile ? 0.32 : 0.35) + energy * (isMobile ? 0.2 : 0.5), 0.82);
switch (p.kind) {
case 0:
fillSquare(px, py, s);
break;
case 1:
fillDiamond(px, py, s);
break;
case 2:
fillSquare(px, py, s * 0.9);
break;
case 3:
fillShard(px, py, s);
break;
case 4:
fillStar(px, py, s);
break;
case 5:
fillBead(px, py, s);
break;
default:
fillCross(px, py, s);
break;
}
}
if (isMobile) drawGraphScaffold(t + 0.4, cx, cy, span);
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
fallbackDraw = draw;
cancelAnimationFrame(projectionFrame);
projectionFrame = 0;
draw(performance.now());
}
function forceFallbackRequested() {
return typeof location !== 'undefined' && /[?&]fallback=1/.test(location.search);
}
function canAttemptWebGPU() {
if (typeof navigator === 'undefined' || typeof globalThis === 'undefined') return false;
const nav = navigator as Navigator & { gpu?: any };
const globals = globalThis as unknown as { GPUBufferUsage?: Record<string, number> };
return Boolean(nav.gpu && globals.GPUBufferUsage && !forceFallbackRequested());
}
function scheduleWebGPUBoot(delay = 0) {
if (disposed || !canAttemptWebGPU()) return;
clearTimeout(webgpuRetryTimer);
webgpuRetryTimer = setTimeout(() => {
void tryBootWebGPU();
}, delay);
}
async function tryBootWebGPU() {
if (disposed || webgpuBooting || mode === 'webgpu' || forceFallbackRequested()) return;
webgpuBooting = true;
const token = ++webgpuBootToken;
try {
const ok = await bootWebGPU();
if (disposed || token !== webgpuBootToken) return;
if (!ok) {
webgpuAttempt += 1;
if (mode === 'booting') bootFallback();
const delay = Math.min(900 + webgpuAttempt * 1200, 6500);
scheduleWebGPUBoot(delay);
return;
}
webgpuAttempt = 0;
lastFrame = performance.now();
frame = requestAnimationFrame(drawWebGPU);
} catch (error) {
if (disposed || token !== webgpuBootToken) return;
console.warn('[launch] Raw WebGPU boot failed:', error);
webgpuAttempt += 1;
if (mode === 'booting') bootFallback();
const delay = Math.min(1200 + webgpuAttempt * 1400, 8000);
scheduleWebGPUBoot(delay);
} finally {
if (token === webgpuBootToken) webgpuBooting = false;
}
}
function bootFallback(retryDelay = 0) {
if (disposed) return;
destroyGpu();
mode = 'fallback';
// only start the Canvas2D loop NOW (when WebGPU is NOT live) so the
// fallback never leaks on top of the GPU canvas.
startFallback();
if (retryDelay > 0) scheduleWebGPUBoot(retryDelay);
}
function onPointerMove(event: PointerEvent) {
pointerX = (event.clientX / Math.max(window.innerWidth, 1)) * 2 - 1;
pointerY = -((event.clientY / Math.max(window.innerHeight, 1)) * 2 - 1);
pointerActive = 1;
pointerPressure = event.pressure || 0.72;
}
function onPointerLeave() {
pointerActive = 0;
}
// Window event from the page on signup success — detonate the seeded-brain
// supernova. Top-level so onMount (add) and onDestroy (remove) share the identity.
function onSupernova(event: Event) {
const detail = (event as CustomEvent<{ seed?: number }>).detail;
const s = detail && typeof detail.seed === 'number' ? detail.seed : seed;
triggerSupernova(s);
}
function onVisibilityChange() {
if (document.visibilityState === 'hidden') {
cancelAnimationFrame(frame);
frame = 0;
return;
}
if (gpu && mode === 'webgpu') {
lastFrame = performance.now();
if (!frame) frame = requestAnimationFrame(drawWebGPU);
return;
}
scheduleWebGPUBoot(150);
}
function resetFallbackLoop() {
cancelAnimationFrame(projectionFrame);
projectionFrame = 0;
fallbackStarted = false;
fallbackDraw = null;
}
function kickViewport() {
if (disposed) return;
if (gpu && mode === 'webgpu') {
lastFrame = performance.now();
if (!frame) frame = requestAnimationFrame(drawWebGPU);
return;
}
resetFallbackLoop();
if (canAttemptWebGPU()) {
mode = 'booting';
scheduleWebGPUBoot(80);
} else {
bootFallback();
}
}
function scheduleViewportKick(delayOrEvent: number | Event = 80) {
const delay = typeof delayOrEvent === 'number' ? delayOrEvent : 80;
clearTimeout(viewportKickTimer);
viewportKickTimer = setTimeout(kickViewport, delay);
}
function onPageHide() {
clearTimeout(webgpuRetryTimer);
clearTimeout(viewportKickTimer);
webgpuBootToken += 1;
webgpuBooting = false;
cancelAnimationFrame(projectionFrame);
projectionFrame = 0;
fallbackDraw = null;
destroyGpu();
mode = 'booting';
}
function onPageShow(event: PageTransitionEvent) {
disposed = false;
startedAt = performance.now();
resetFallbackLoop();
if (canAttemptWebGPU()) {
mode = 'booting';
scheduleWebGPUBoot(event.persisted ? 80 : 150);
} else {
bootFallback();
}
}
onMount(() => {
disposed = false;
startedAt = performance.now();
window.addEventListener('pointermove', onPointerMove, { passive: true });
window.addEventListener('pointerdown', onPointerMove, { passive: true });
window.addEventListener('pointerup', onPointerLeave, { passive: true });
document.addEventListener('visibilitychange', onVisibilityChange);
window.addEventListener('pagehide', onPageHide);
window.addEventListener('pageshow', onPageShow);
window.addEventListener('vestige:supernova', onSupernova);
window.addEventListener('resize', scheduleViewportKick, { passive: true });
window.addEventListener('orientationchange', scheduleViewportKick, { passive: true });
window.addEventListener('focus', scheduleViewportKick, { passive: true });
window.visualViewport?.addEventListener('resize', scheduleViewportKick, { passive: true });
if (forceFallbackRequested() || !canAttemptWebGPU()) {
bootFallback();
} else {
mode = 'booting';
(async () => {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => setTimeout(resolve, 0));
});
await sleep(0);
scheduleWebGPUBoot(0);
})();
}
return () => {
disposed = true;
clearTimeout(webgpuRetryTimer);
};
});
onDestroy(() => {
disposed = true;
if (typeof window === 'undefined') return;
clearTimeout(webgpuRetryTimer);
clearTimeout(viewportKickTimer);
cancelAnimationFrame(frame);
cancelAnimationFrame(projectionFrame);
syncTarget?.style.removeProperty('--burst');
syncTarget?.style.removeProperty('--flash');
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerdown', onPointerMove);
window.removeEventListener('pointerup', onPointerLeave);
document.removeEventListener('visibilitychange', onVisibilityChange);
window.removeEventListener('pagehide', onPageHide);
window.removeEventListener('pageshow', onPageShow);
window.removeEventListener('vestige:supernova', onSupernova);
window.removeEventListener('resize', scheduleViewportKick);
window.removeEventListener('orientationchange', scheduleViewportKick);
window.removeEventListener('focus', scheduleViewportKick);
window.visualViewport?.removeEventListener('resize', scheduleViewportKick);
destroyGpu();
});
</script>
<div class={`raw-vestige-engine ${className}`} data-mode={mode}>
<canvas bind:this={gpuCanvas} class="engine-canvas gpu-canvas" aria-hidden="true"></canvas>
<canvas bind:this={fallbackCanvas} class="engine-canvas fallback-canvas" aria-hidden="true"></canvas>
</div>
<style>
.raw-vestige-engine {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
/* solid, NON-circular background (no radial-gradient circle). */
background: linear-gradient(160deg, #03040d 0%, #04061a 52%, #02030a 100%);
opacity: 1;
transition: opacity 280ms ease;
}
.raw-vestige-engine[data-mode='booting'] {
opacity: 0;
}
.engine-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
opacity: 0;
transition: opacity 520ms ease;
}
/* GPU canvas only shows in webgpu mode; fallback canvas only shows in
fallback mode. They are never both visible -> no fallback leak. */
.raw-vestige-engine[data-mode='webgpu'] .gpu-canvas {
opacity: 1;
}
.raw-vestige-engine[data-mode='fallback'] .fallback-canvas {
opacity: 1;
}
</style>