Signal Garden is one continuous GPU particle field that morphs between four sculptural formations as you scroll. No models, no textures, no postprocessing library — one geometry, four target attributes, and a vertex shader doing all the work. These notes explain how to replicate it.
The fiction does the art direction for you: an observatory that renders orphaned radio transmissions as light. Everything follows from that — the void is near-black indigo, never pure black, so the additive particles have somewhere warm to sink into. Particles run a spectral ramp from violet through cyan to magenta, with roughly 2.6% of them flagged solar gold — rare enough to read as an event, not a palette.
The HUD earns its keep by being real: the FPS readout is a measured rolling average, MORPH% is the actual shader uniform, the carrier count is the true draw range. Fake telemetry reads as decoration within seconds; live telemetry reads as an instrument.
The core trick: never move particles on the CPU. The geometry carries four complete formations at once — position holds Form A, and three extra BufferAttributes hold Forms B, C, D. A single uniform, uMorph ∈ [0,3], tells every vertex where it sits between formations. Moving 190k particles costs the GPU nothing extra; it was already running the vertex shader anyway.
attribute vec3 aTargetB; // chorus shell attribute vec3 aTargetC; // torus knot attribute vec3 aTargetD; // phyllotaxis bloom attribute vec4 aSeed; // x stagger · y hue · z twinkle · w size uniform float uMorph; // 0→3 : A→B→C→D, scrubbed by scroll float stag(float t, float s){ // per-particle staggered progress — transitions ripple, never lerp in lockstep float d = 0.45; float x = clamp((t - s * d) / (1.0 - d), 0.0, 1.0); return x * x * (3.0 - 2.0 * x); // smoothstep, done by hand } void main(){ float m = clamp(uMorph, 0.0, 3.0); float t1 = stag(clamp(m, 0.0, 1.0), aSeed.x); float t2 = stag(clamp(m - 1.0, 0.0, 1.0), aSeed.x); float t3 = stag(clamp(m - 2.0, 0.0, 1.0), aSeed.x); vec3 pos = position; // position IS Form A (the spiral) pos = mix(pos, aTargetB, t1); // chain the mixes — only the active pos = mix(pos, aTargetC, t2); // segment has a t in (0,1) pos = mix(pos, aTargetD, t3);
Two details make it feel alive rather than mechanical. First, the stagger: each particle offsets its own progress by aSeed.x, so a transition washes across the field over ~45% of the scrub instead of snapping in unison. Second, the mid-transition flare — at the halfway point of any segment, t·(1−t) peaks, and particles get pushed outward along a per-particle noise direction. The formation visibly breaks apart before it reassembles:
// mid-transition flare: particles blow outward, then re-condense float flare = t1*(1.0-t1) + t2*(1.0-t2) + t3*(1.0-t3); vec3 ndir = normalize(vec3( sin(aSeed.y * 6.2832 + pos.y * 0.35), cos(aSeed.z * 6.2832 + pos.z * 0.35), sin(aSeed.w * 6.2832 + pos.x * 0.35)) + vec3(1e-4)); pos += ndir * flare * 3.6; // idle drift — a cheap curl-flavoured trig field, always breathing float dt = uTime * 0.32; pos.x += 0.22 * sin(pos.y * 0.45 + dt + aSeed.x * 6.2832); pos.y += 0.22 * sin(pos.z * 0.45 + dt * 1.21 + aSeed.y * 6.2832); pos.z += 0.22 * cos(pos.x * 0.45 + dt * 0.83 + aSeed.z * 6.2832);
The fragment shader is what keeps 190k additive points from looking like static: a feathered disc with a hot core (a soft falloff term plus a tight gaussian), discarded outside the radius. Square points are the fastest way to make a particle system look cheap.
vec2 p = gl_PointCoord - vec2(0.5); float d = length(p); if(d > 0.5) discard; // feathered disc + hot core — big soft sprites, never squares float a = pow(max(0.0, 1.0 - d * 2.0), 1.9) * 0.30 + exp(-d * d * 42.0) * 0.42; vec3 c = ramp(fract(vHue + uChapter * 0.13)); // violet→cyan→magenta c = mix(c, vec3(1.0, 0.78, 0.35), vGold * 0.85); // solar-gold rarities gl_FragColor = vec4(c, a * vTw); // AdditiveBlending, no depthWrite
Each formation is a plain Float32Array(N × 3), generated in a few dozen lines of JavaScript during the boot sequence. No meshes are sampled, nothing is loaded. The trick to organic-looking clouds is gaussian scatter everywhere — a cheap Box–Muller-ish gauss() softens every hard mathematical edge.
function formSpiral(n){ const a = new Float32Array(n * 3); const ARMS = 3, R = 24; for(let i = 0; i < n; i++){ const arm = i % ARMS; const r = Math.pow(rnd(), 0.62) * R; // bias density to the core const spread = 0.85 * Math.exp(-r * 0.055); // arms tighten outward const ang = (arm / ARMS) * Math.PI * 2 + r * 0.29 + gauss() * spread * 2.0; const thick = Math.exp(-r * 0.085); // bulge is tall, rim is thin a[i*3] = Math.cos(ang) * r + gauss() * 0.6; a[i*3+1] = gauss() * (0.7 + 3.1 * thick); a[i*3+2] = Math.sin(ang) * r + gauss() * 0.6; } return a; }
The Chorus shell distributes points evenly with a fibonacci sphere (golden-angle increments never cluster), then displaces the radius with four octaves of value-noise fbm — the same fbm you would write in GLSL, just run once on the CPU where it is free:
const GA = Math.PI * (3 - Math.sqrt(5)); // golden angle for(let i = 0; i < n; i++){ const t = (i + 0.5) / n; const y = 1 - 2 * t; // even latitude bands const rad = Math.sqrt(Math.max(0, 1 - y * y)); const th = GA * i; const dx = Math.cos(th) * rad, dz = Math.sin(th) * rad; // the shell breathes: base radius 13, fbm swells it ±4.6 const r = 13 + fbm(dx * 1.9 + 5.2, y * 1.9, dz * 1.9) * 4.6 + gauss() * 0.22; a[i*3] = dx * r; a[i*3+1] = y * r; a[i*3+2] = dz * r; }
The torus knot samples the classic (2,3) curve, then builds a local frame (tangent → normal → binormal) at each sample so particles can scatter through the tube's cross-section with a gaussian radius — a volumetric rope, not a wire. The bloom is 3D phyllotaxis: radius grows with √u, angle steps by 137.507°, a cosine term cups the dome, and the last 12% of particles spiral up a slender central spire. Four formations, four completely different silhouettes, ~120 lines total.
rnd()), not Math.random(). Every visitor sees the same garden, every reload is identical, and tuning a formation is reproducible instead of a slot machine.GSAP's ScrollTrigger never touches the DOM here — it scrubs shader uniforms and a camera proxy object. Each chapter section is 185vh tall with a 100vh sticky copy block inside, so the page provides reading room while the scrub windows stay simple: as the next chapter's section top crosses from 92% to 12% of the viewport, uMorph eases one integer higher. While you read, the value holds — formations rest between transitions.
// uMorph: 0→1→2→3 as each next chapter approaches; holds while you read chapters.slice(1).forEach((sec, i) => { gsap.to(uniforms.uMorph, { value: i + 1, ease: 'none', scrollTrigger: { trigger: sec, start: 'top 92%', end: 'top 12%', scrub: 0.65 }, }); }); // camera path — one timeline scrubbed across the whole document const cams = [ { x: 0, y: 10, z: 44 }, // hero — 3/4 view of the galaxy { x: 7, y: 17, z: 33 }, // ch.01 — over the arms { x: -2, y: 1, z: 27 }, // ch.02 — head-on into the chorus { x: 20, y: 7, z: 20 }, // ch.03 — orbit the knot { x: 3, y: 21, z: 23 }, // ch.04 — look down on the bloom { x: 0, y: 5, z: 42 }, // colophon — pull away ]; const camTl = gsap.timeline({ scrollTrigger: { trigger: '#main', start: 'top top', end: 'bottom bottom', scrub: 0.8 }, });
The camera proxy is just {x, y, z, tx, ty, tz}; the render loop copies it into the real camera and adds mouse parallax on top, so choreography and interactivity never fight over the same object. scrub: 0.65–0.8 adds a lag that makes the whole journey feel damped and heavy — instant scrub reads as jittery at these particle densities.
Under prefers-reduced-motion every ScrollTrigger is skipped: the field holds Form A statically, time is frozen, and chapter copy crossfades via an IntersectionObserver instead of translating.
The cursor is projected into world space every frame — a ray through the mouse NDC intersected with a plane through the origin, facing the camera. That world point feeds a uPointer uniform, and the vertex shader applies a gaussian-falloff repulsion around it. The strength uniform eases up while the mouse moves and decays when it rests, so the field parts around motion and heals behind it.
// CPU, per frame: mouse ray ∩ camera-facing plane through the origin raycaster.setFromCamera(pointerNDC, camera); plane.setFromNormalAndCoplanarPoint( camera.getWorldDirection(plane.normal).negate(), points.position); if(raycaster.ray.intersectPlane(plane, planeHit)) pointerWorld.lerp(planeHit, 0.18); pointerK += (pointerKTarget - pointerK) * 0.06; // ease in… pointerKTarget *= 0.965; // …decay out // GPU, per particle: gaussian shove away from the cursor vec3 toP = pos - uPointer; float pr = length(toP); pos += (toP / max(pr, 0.001)) * exp(-pr * pr * 0.018) * uPointerK * 3.4;
| Decision | Why it matters |
|---|---|
| Morph on the GPU | Zero per-frame CPU work on positions. The only things JavaScript animates are a handful of floats — uniforms and six camera numbers. |
| No postprocessing pass | The "bloom" is additive blending + soft sprites + a CSS vignette and grain overlay. Saves a full-screen render target at 2× DPR. |
depthWrite: false, depthTest: false | Additive light is order-independent — skipping the depth round-trip is free fill-rate back. |
| DPR capped at 2 | Fill-rate is the bottleneck for big soft points. A 3× retina canvas doubles fragment work for detail nobody sees on a glowing dot. |
| Adaptive degrade | A rolling FPS average (the same one shown in the HUD) drops DPR to 1 under 45fps and halves drawRange under 30 — mobile gets 72k carriers up front. |
| One draw call | The entire universe is a single THREE.Points with frustumCulled = false — no scene-graph traversal, no culling math, no per-object overhead. |
three.module.min.js (r170) and GSAP + ScrollTrigger into /libs — the runtime never hotlinks a CDN. Fonts (Space Grotesk, JetBrains Mono) come from Google Fonts as variable families.Float32Array(N×3): log-spiral, fbm fibonacci shell, framed torus knot, phyllotaxis dome + spire. Season everything with gaussian scatter.position; B, C, D become extra attributes, plus a vec4 aSeed of per-particle randomness. One ShaderMaterial, additive blending, no depth.uMorph into three 0–1 segments, stagger each by aSeed.x, chain three mix() calls, add the t(1−t) flare and the trig drift field.uMorph per chapter and one camera timeline across the document. Split headlines into spans for staggered reveals with expo.out.Libraries: Three.js · GSAP ScrollTrigger · Fonts: Space Grotesk, JetBrains Mono