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.
The audio thread is another readback consumer
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)
One event, one grain, two axes
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.