← Writing · CELLAGA

One Grain Per Cell Event

GPU delta pass, clz32 extraction, AudioWorklet grains — one birth/death → one grain.

Every cell birth and death becomes exactly one audio grain — panned by x, pitched by y. A lone glider is an audible object crossing stereo at four events per period; colliding soup is granular static; ship damage is a distinct grain type from the ship’s screen position.

Engine prerequisites

State: one RGBA8 texture, one texel per cell (R alive, G team, B age, A player tag). Generations: ping-pong FBOs — translate, inject, step. Ship health = pattern integrity: 48×48 ROI → 1×1 every 30 ticks, async PBO readback with fence

  • non-blocking poll, one in flight. CPU oracle gates the shader against known sequences. Grid 2048×512.

Ping-pong means prev and curr are both resident → birth/death mask is a per-texel compare. Integrity path already gets bytes off the GPU without a sync stall.

FIG. 01

The audio thread is another readback consumer

Diagram tracing one birth event from ping-pong state textures through a GPU delta pass, PBO pool, clz32 scan, and AudioWorklet grain scheduler
The audio thread is another readback consumer. One exemplar birth event traced from a texel in the current state texture to a scheduled grain; stages right of the state textures are the design path (dashed).

Delta pass

One pass after step. Each fragment reads 64 consecutive cells from both textures, packs two bitmasks into an RGBA32UI texel: births (curr && !prev) in R/G, deaths (!curr && prev) in B/A. Output 32×512 × 16 B = 256 KB/gen, 15.4 MB/s at 60 Hz vs 4 MB/frame (240 MB/s) naive full-grid — 16× reduction, exact.

Four-channel packing forced by ES 3.0: readPixels on unsigned-integer buffers only in RGBA_INTEGER/UNSIGNED_INT, so RG32UI would still read 16 B/texel. Packing four meaningful channels halves texel count instead.

Delta between consecutive post-inject states → enemy stamps register as birth bursts (spawn-in as audible crackle). Readback: three-PBO pool tagged by generation, non-blocking poll; gen N events on audio side within two frames of N stepped.

Bitmask → event list

Scan as Uint32Array; skip zero words with one compare — cost tracks activity, not grid size. Set bits via Math.clz32 → exact (x, y). Pack into Uint32: x:12 | y:10 | type:2. Type 2 (player-cell death) = death ∩ player ROI bbox.

Frames above MAX_EVENTS_PER_FRAME = 4096 reservoir-sample down. Batch to worklet as one postMessage per gen with transferable ArrayBuffer.

Grains

Precomputed Hann-windowed tables — sine ping ~8 ms (births), band-passed noise ~6 ms (deaths), ~20 ms detuned pair (player deaths) — added into the worklet output at a resampled rate.

  • x → equal-power pan (cosine/sine; power position-independent)
  • y → pitch: births quantize into three octaves of pentatonic (no semitone-adjacent pairs → no minor-second clashes under arbitrary simultaneous events); deaths map exponentially, unquantized. Scale table swappable via setScale
  • Batch gain ∝ 1/√N — uncorrelated grains sum in power; tanh soft limiter backstop (≤ −1 dBFS on 30%-density soup)
FIG. 02

One event, one grain, two axes

Diagram of a grid patch with a birth and a death mapping to stereo pan by column and pitch by row, with birth pitches quantized to a pentatonic scale
One event, one grain, two axes. A birth and a death from the same grid patch project to pan by column and pitch by row; only the birth snaps to the scale, and both onsets land at jittered offsets inside the tick.

Events arrive quantized to the sim tick. Schedule directly → impulse train at 60 Hz. Onset jittered uniformly across the following tick (~16.7 ms) — whitens the train, keeps every event inside its generation. Player-initiated sounds (firing) trigger on input, not sim confirm.

Acceptance gate

Delta masks must match CPU oracle bit-exact on gated sequences, including a glider on the wrap seam (texelFetch + mod), and a still-life field must produce all-zero masks for 100 consecutive generations. Downstream: lone glider event counts match oracle for 32 gens; pan moves monotonically L→R within one 64-cell bucket; still-life leaves master bus ≤ −60 dBFS RMS after 1 s.