Skip to content

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.

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} />
FunctionDoes
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.

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.

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.

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.

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.

  • 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: N field. 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.

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 → draw

The 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.