SIGNAL GARDEN // FIELD NOTES
← Return to the garden
FIELD NOTES // OBS-7 ENGINEERING LOG

How to grow 190,000 points of light.

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.

STACK THREE.JS r170 + GSAP 3.12 JS BUDGET < 1 MB TARGET 60 FPS MODELS NONE
00 // PREMISE

Art direction: deep-space electric

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 Void#06060F
Carrier Violet#7443FF
Chorus Cyan#26D9FF
Sideband Magenta#FF48BF
Solar Gold#FFC759

Type: one display voice, one telemetry voice

Every lost signal deserves a garden.
SPACE GROTESK — VARIABLE, WGHT 300–700 · HEADLINES AT 420–640, TIGHT −0.02EM TRACKING
CH.02 // TRANSMISSION 002 — CHORAL BAND · MORPH 033.4% · FPS 60.0
JETBRAINS MONO — VARIABLE · 10–11PX, +0.14EM TRACKING, UPPERCASE — ALL HUD VALUES ARE LIVE, NOT DECORATION

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.

01 // SIGNATURE TECHNIQUE

Morph targets in the vertex shader

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.

VERTEX SHADER — SEQUENTIAL MORPH CHAINindex.html
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:

VERTEX SHADER — FLARE + IDLE DRIFTindex.html
// 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.

FRAGMENT SHADER — SOFT SPRITE + SPECTRAL RAMPindex.html
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
02 // GEOMETRY

Growing formations from math

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.

FORM A — LOG-SPIRAL GALAXYindex.html
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:

FORM B — FBM-DISPLACED FIBONACCI SPHEREindex.html
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.

Why deterministic randomness? All four formations draw from one seeded LCG (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.
03 // CHOREOGRAPHY

Scroll-scrubbed uniforms

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.

MORPH + CAMERA SCRUBindex.html
// 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.

04 // INTERACTION

The field notices you

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.

POINTER → WORLD → REPULSIONindex.html
// 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;
05 // BUDGET

What keeps it at 60fps

DecisionWhy it matters
Morph on the GPUZero per-frame CPU work on positions. The only things JavaScript animates are a handful of floats — uniforms and six camera numbers.
No postprocessing passThe "bloom" is additive blending + soft sprites + a CSS vignette and grain overlay. Saves a full-screen render target at 2× DPR.
depthWrite: false, depthTest: falseAdditive light is order-independent — skipping the depth round-trip is free fill-rate back.
DPR capped at 2Fill-rate is the bottleneck for big soft points. A 3× retina canvas doubles fragment work for detail nobody sees on a glowing dot.
Adaptive degradeA 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 callThe entire universe is a single THREE.Points with frustumCulled = false — no scene-graph traversal, no culling math, no per-object overhead.
Memory math: 190,464 particles × (4 position attributes × 3 floats + 1 seed × 4 floats) × 4 bytes ≈ 12 MB of VRAM — uploaded once at boot, never touched again. The whole page ships ~840 KB of JavaScript, most of it Three.js itself.
06 // PROTOCOL

Replication outline

  1. Vendor the engine. Download 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.
  2. Write the formation generators. Four functions, each returning Float32Array(N×3): log-spiral, fbm fibonacci shell, framed torus knot, phyllotaxis dome + spire. Season everything with gaussian scatter.
  3. Build one geometry. Form A becomes position; B, C, D become extra attributes, plus a vec4 aSeed of per-particle randomness. One ShaderMaterial, additive blending, no depth.
  4. Write the morph chain. Clamp 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.
  5. Choreograph with ScrollTrigger. Tall sections with sticky copy; scrub uMorph per chapter and one camera timeline across the document. Split headlines into spans for staggered reveals with expo.out.
  6. Instrument it. Boot sequence while buffers build, live FPS/morph/count HUD, adaptive DPR + drawRange degrade, reduced-motion and no-WebGL paths. Ship only after the console is clean at 375px and 1280px.

Libraries: Three.js · GSAP ScrollTrigger · Fonts: Space Grotesk, JetBrains Mono