Particles
Fabric has one primary particle system — one buffer set, one simulation
dispatch, one instanced draw — owned by <Grid>. Any further particle geometry
is an additive extra layer rather than a second system.
The pattern
Section titled “The pattern”A particle-geometry component (Grid, Wave, and any future Star or
Whale) returns nothing. On GPU ready it creates and owns a layer, writes its
xyzw positions into that layer’s buffer, and disposes it on unmount.
They differ only in how positions are computed — a lattice, EEG samples, a star
field. <Grid> is simply the first one.
<Grid {...activeGrid} /><Wave samples={eegSamples} gain={flux.gain} />The API
Section titled “The API”| Function | Does |
|---|---|
createParticleLayer(g, capacity, style?) | Creates the buffer and bind group, pushes to g.particleLayers |
writeParticleLayer(g, layer, positions) | Grows the buffer if needed, writes, sets numParticles, marks dirty |
disposeParticleLayer(g, layer) | Removes from the array and destroys the buffer |
g.particleLayers is empty by default, so a scene with no extra layers pays
nothing.
Why additive, not a refactor
Section titled “Why additive, not a refactor”Fabric is one primary particle system plus a set of lazy, self-contained subsystems — SDF, mesh, text, spatial grid, axes, gizmo, blit, background mips — each added on first use, each leaving the core buffers alone. Layers follow that grain.
A general geometries[] refactor was rejected: it would churn
rebuildBindGroups (shared by three callers), every reader of the singular
numParticles and buffer fields, and the shared render uniform buffer that the
SDF, axes, gizmo and mesh draws also read.
Grid stays primary — it carries the compute simulation and PLY loading. Making Grid a layer too is possible and not required.
Why a layer’s bind group never goes stale
Section titled “Why a layer’s bind group never goes stale”A layer binds only its own buffer plus the render uniform buffer, which is
created once at boot and never recreated. So when sdfEngine recreates the
object or grid buffers — forcing a rebuild of the primary bind group — the
layers’ bind groups are unaffected.
The particle shader reads only positions, the uniform, and PLY colours when that flag is set. The remaining bindings are the SDF’s and go unread, so a layer passes its own buffer as filler.
Simulated and unsimulated layers
Section titled “Simulated and unsimulated layers”The compute simulation reads original and writes current; the draw reads
current.
An unsimulated layer has no compute pass, so it authors current directly
each frame — current is its geometry. A simulated layer would need an
original buffer and a compute bind group; that is not built.
Per-layer style
Section titled “Per-layer style”A layer can own its colour, focus colour, point size and highlight focus,
independent of Grid. Pass a style to createParticleLayer and the layer gets
its own uniform buffer, written each frame as a global snapshot plus those
overrides. Pass none and it shares Grid’s.
Zero cost when no layer has a style — the primary path is unchanged.
Data comes in as a prop
Section titled “Data comes in as a prop”A geometry component takes its data as a prop. The layout file owns the subscription.
// The layout file owns the stream and the controls:const eegFrame = s.bind(streams.FlatEcho())const eegSamples = c(eegFrame() && eegFrame().rawDataArray())
<Wave samples={eegSamples} gain={flux.gain} color={flux.waveColor} />This keeps the library free of transport and debug-panel imports, which is what makes it reusable and clean to extract.
See Adding a particle layer for the
untrack rule that applies when creating one.
Adding a layer component
Section titled “Adding a layer component”- Keep it small. It is statically imported, so it lands in the eager chunk.
- Add no WGSL — reuse the shared particle pipeline.
- Do not add a
type: Nfield. The build scans for that to decide which SDF primitives survive, and it would pin one that isn’t used. - Do not add region markers unless you mean them.
Inside the subsystem
Section titled “Inside the subsystem”The particle system is created at boot rather than on first use, and it is the only part of fabric that is.
originalPositionsBuffer → (compute simulation) → currentPositionsBuffer → drawThe simulation applies curl noise, reading the original positions and writing the current ones. It only runs when scene speed is above zero — at zero there is no compute pass, so nothing reaches the current buffer, and geometry loaded into the original buffer will not appear.
Boot creates the buffers and then leaves the particle count at zero. So the buffers and the shared bind group exist whether or not a particle component mounts, which is why the particle shaders sit in the eager chunk and are not dead code in a scene with no particles.
initParticles destroys the buffers, recreates them, sets the count and
rebuilds bind groups. Two callers doing that to the same shared buffer
conflict — which is the reason extra geometries are additive layers. A new
particle component does not need initParticles at all.
loadGeometry is the contract for an external point cloud: it writes the
original buffer and lets the simulation animate it. Writing the current
buffer fights the simulation — except in a layer with no simulation, which owns
its current buffer outright.