Resonant Fields ← back to the garden

field notes

An instrument,
disguised as a website

How fourteen glowing organisms, one AnalyserNode, and a fragment shader become a playable sound garden. Everything below is the real code that runs the field — no samples, no stock, no generated images.

01Concept & creative direction

Most websites narrate. This one listens. The brief was the single non-scroll-narrative site of the fifteen: one continuous world where the visitor is a performer, not a reader. The fiction is a nocturnal garden — bioluminescent organisms in an indigo field, each tuned to a note of a pentatonic scale across two octaves from A2.

Three rules shaped every decision. Nothing shrill: sine and triangle voices, lowpassed everywhere, soft 65 ms attacks, six-second tails. Sound is visible: every note becomes light — a bloom, a spore burst, and a ripple in the ground whose wavelength tightens with pitch. The garden is alive without you: organisms breathe, dust drifts, and after twenty untouched seconds it starts playing itself.

02Palette & type

indigo#11131F
deep#0A0C16
aqua#6EE7FF
violet#A78BFA
bloom#FF9EDB

Pitch maps onto the accent gradient: low notes glow aqua, the middle register turns violet, the top blooms pink. The same ramp lives in three places — the organism textures, the ripple shader, and the custom cursor — so color always means frequency.

Unbounded · variable 200–900 · display

Schibsted Grotesk · body

03The voice — a synthesis graph with no samples

Each organism triggers a freshly built voice: a sine fundamental, a triangle detuned +7 cents for slow beating, a quiet sub-octave for warmth, and a late-swelling octave partial — the “bioluminescent glow” you hear arrive a third of a second after the attack. All of it breathes through one lowpass whose cutoff blooms open and settles.

// src/audio.js — the heart of every note
const oscA = ctx.createOscillator(); oscA.type = 'sine';     oscA.frequency.value = f
const oscB = ctx.createOscillator(); oscB.type = 'triangle'; oscB.frequency.value = f
oscB.detune.value = 7                       // slow beating against the sine
const sub  = ctx.createOscillator(); sub.frequency.value  = f / 2
const shim = ctx.createOscillator(); shim.frequency.value = f * 2

gShim.gain.setValueAtTime(0, t)
gShim.gain.linearRampToValueAtTime(0.085, t + 0.35)  // the late glow
gShim.gain.setTargetAtTime(0, t + 0.9, 1.2)

// filter bloom: opens fast, settles warm
filter.frequency.setValueAtTime(f * 1.4, t)
filter.frequency.linearRampToValueAtTime(Math.min(f * 5.5, 5200), t + 0.09)
filter.frequency.setTargetAtTime(f * 2.2, t + 0.09, 0.9)

// amplitude: soft 65ms attack, ~6s exponential tail
amp.gain.setValueAtTime(0, t)
amp.gain.linearRampToValueAtTime(vel * 0.6, t + 0.065)
amp.gain.setTargetAtTime(0, t + 0.12, 1.5)

The reverb is a ping-pong feedback network rather than a convolver — two cross-fed delay lines (311 ms and 427 ms, deliberately non-integer ratios) with a lowpass inside the loop, so every echo returns softer and darker. Warmth by construction: the tail physically cannot build up brightness.

// src/audio.js — ping-pong feedback loop: A -> B -> A, lowpassed both ways
input.connect(pre)                            // 28ms predelay
pre.connect(dA)
dA.connect(lpA); lpA.connect(fbA); fbA.connect(dB)   // A -> B
dB.connect(lpB); lpB.connect(fbB); fbB.connect(dA)   // B -> A (the loop)
dA.connect(panA); dB.connect(panB)            // taps pan hard left / right
panA.connect(wet); panB.connect(wet)

04FFT → shader — how the ground hears

The master chain ends in an AnalyserNode (fftSize 256). Every frame, its 128 frequency bins are copied into a 128×1 DataTexture and its time-domain buffer is folded into a smoothed RMS level. The ground plane is a single quad — all the detail is fragment work.

// src/audio.js — per-frame tap
pump() {
  this.analyser.getByteFrequencyData(this.fft)     // 128 bins -> texture
  this.analyser.getByteTimeDomainData(this.wave)   // -> RMS level
  let sum = 0
  for (let i = 0; i < this.wave.length; i++) {
    const v = (this.wave[i] - 128) / 128
    sum += v * v
  }
  const rms = Math.sqrt(sum / this.wave.length)
  this.level += (rms - this.level) * 0.25          // smoothed
  return { fft: this.fft, level: this.level }
}

// src/field.js — upload
this.fftTex = new THREE.DataTexture(this.fftData, 128, 1,
  THREE.RedFormat, THREE.UnsignedByteType)
if (fft) this.fftData.set(fft)
this.fftTex.needsUpdate = true

In the shader, distance from the field’s heart indexes the spectrum — bass wraps the organisms, treble halos the horizon — and the RMS level feeds the mycelium filaments, so the whole floor brightens when the garden sings.

Ripples that actually interfere

Every note pushes a ripple into a 16-slot uniform ring buffer: position, birth time, and pitch. Each ripple is a travelling gaussian packet of signed sine — and the packets are summed per color channel. That sign is the whole trick: where crests align the sum spikes and the field flares white-hot; where phases oppose, the waves cancel back to dark indigo. Two-source interference, the physics-textbook figure, drawn by your chord.

// src/shaders/ground.frag.glsl — the signature loop
vec3 waveField = vec3(0.0);
float waveSum = 0.0;
for (int i = 0; i < MAX_RIPPLES; i++) {
  vec4 r = uRipples[i];                    // x, z, birth, pitch01
  float age = uTime - r.z;
  if (r.z < 0.0 || age < 0.0 || age > 7.0) continue;
  float d = distance(P, r.xy);
  float x = d - age * 3.1;                 // wavefront radius
  float packet = exp(-x * x / (0.5 + age * 0.55));
  float k = 3.0 + r.w * 5.5;               // higher pitch -> tighter wavelength
  float w = sin(x * k) * packet * exp(-age * 0.6) * uRippleAmp[i];
  waveField += pitchColor(r.w) * w;        // SIGNED sum -> real interference
  waveSum += w;
}
col += max(waveField, vec3(0.0)) * 1.15;
col += vec3(1.0) * pow(max(waveSum, 0.0), 3.0) * 0.32;  // aligned crests flare

Live 2D model of the same math — click to drop ripples, drop two close together and watch the bands:

05The garden that plays itself

After twenty untouched seconds an idle composer wakes. It is a weighted random walk over scale degrees — tuned until it stopped sounding random and started sounding shy:

Idle composer probability weights
eventweightwhy
step ±1 degree48%melody is mostly stepwise
skip ±2 degrees26%gentle contour changes
repeat the note8%insistence reads as intent
leap ±4 degrees10%rare surprise, never twice
drift toward home8%keeps phrases centered low
dyad a third above12%harmony as seasoning
long rest ×2.49%music needs breath
low anchor noteevery 8–13 eventsgrounds the phrase on the root

The loop button records something different from audio: timing. It captures { at, index, velocity } tuples and replays them through the same trigger path as your finger — so a loop re-blooms the organisms, re-ripples the ground, and even retunes itself if you switch the scale mid-loop. Loops are patterns of gesture, not recordings of sound.

06Asset pipeline

This site ships zero AI-generated images — the brief demanded it, and the aesthetic is better for it. Every organism texture is drawn at runtime on a 256px canvas: five to eight gradient petals around a hot core, seeded by a deterministic PRNG (mulberry32) so organism №7 always grows the same petals. Rings, beams, spores, and dust are canvas gradients too. The only binary assets are three self-hosted woff2 files (Unbounded variable + Schibsted Grotesk) and the OG image — which is simply a screenshot of the live field mid-bloom, triggered through the same QA hooks used to test the audio.

07Iteration diary

Pass 1 — structure

Built the full skeleton: engine, field, composer, veil, HUD, drawer, guide. A 31-check scripted probe confirmed the audio graph headless (the harness can’t hear): AudioContext unlocks on the veil gesture, notes drive the analyser to RMS 0.26, the drone sustains, loops replay audibly, the idle composer sings. Fixed a hard horizon seam by fading the ground plane’s alpha into the fog so the field dissolves into the night sky, pulled clipped edge organisms back into frame, and made this page’s demo re-seed itself.

Pass 2 — craft

Read every capture like a critic. The ground’s twinkle cells were lighting up as hard rectangles — replaced with round gaussian motes. Rendered the OG image from a live mid-bloom frame, parked the custom cursor out of captures, fixed the keyboard hint’s “0” rendering as “O” in Unbounded, and tightened the organism spread against the camera drift. Report clean at all three widths, both routes.

Pass 3 — complexify & refine

Added the detail that makes the garden feel inhabited: gesture threads — when it dreams or replays your loop, a faint glowing arc travels from the previous organism to the next, drawing the melody’s constellation. Human touches don’t get threads; your hand already draws its own line. Made the hint yield to the dream caption, re-verified the full probe + harness suite, and re-rendered the OG frame.