Skip to content

Particle binaries

A particle asset-generation run authors the VFX a game leans on — explosions, muzzle flashes, impacts, engine exhaust, splashes, victory bursts, fire and smoke — through a particle binary on its PATH, the only channel for shaping the effect. The paradigm is the one thing to fix up front: the model does not place individual particles. It authors a system — emitters, forces, and per-particle curves — that a live simulation plays, exactly the way a real particle editor (Unreal’s Niagara, Unity’s VFX Graph) plays a system: the authored system definition is the asset, and whatever plays it — the binary’s preview, the review UI, or a consuming game — simulates it live. Being a stochastic simulation, an effect varies slightly from one play to the next, which is exactly right for VFX: a reviewer judges the character of the effect, not a frozen frame sequence.

There are two binaries, both built on the shared particle-core library:

  • particle-2d — for a 2D effect (asset_kind = "particle-2d"): a planar particle system for UI, 2D-game, and screen-space VFX.
  • particle-3d — for a 3D effect (asset_kind = "particle-3d"): a volumetric particle system, the kind the 3D games consume.

The binaries are built from crates/particle-2d and crates/particle-3d on the shared crates/particle-core library — the system model, the particle simulator, and the F-curve reuse from model-core — and each is baked into its own run-container image — one image per asset_kind — so a run carries only the tool it uses. The 3D binary renders billboards through model-core’s wgpu renderer; the 2D binary composites in a 2D raster path. Nothing is regenerated after the run: the binary emits the authored system.json, and the validator parses it and confirms it is well-formed.

The two binaries share the whole op vocabulary and the simulator. They differ only in dimensionality (2D omits z; forces are planar), the preview renderer (a 2D raster path vs. wgpu 3D billboards from an orbit camera), and the runtime binding (a 2D canvas binding vs. the three binding). So this page documents one shared vocabulary and calls out the 2D/3D differences inline, exactly as mesh-binaries covers mc/sn/dc from one shared vocabulary.

A particle case authors one effect — the way a single sprite is one image — so a game needing many effects uses many cases or variants. There is no required-animation-style contract table: a particle effect carries no game-facing rig interface, and is judged subjectively against its brief.

The operations are ordinary CLI subcommands

Section titled “The operations are ordinary CLI subcommands”

A case seeds no operations schema. The vocabulary is the binary’s own --help, exactly as with the drawing, voxel, and meshing tools, and the brief tells the model to read it:

particle-3d --help # every operation
particle-3d add-emitter --help # one operation's exact flags

Each operation is a subcommand with flags — there is no JSON. For example:

particle-3d add-emitter --name blast --shape sphere --x 0 --y 1 --z 0 --radius 0.2 \
--burst 400 --at 0 --lifetime 700 --speed 9 --dir-y 1 --cone-angle 180 --seed 7
particle-3d set-forces --emitter blast --gravity -6 --drag 1.4 --radial 12
particle-3d set-particle --emitter blast --size-curve ease-out \
--color-gradient "#ffffff@0,#ffb23a@0.3,#c02010@0.7,#333333@1" --opacity-curve ease-in
particle-3d add-subemitter --parent blast --on death --emitter embers

The operations, categorized:

  • add-emitter — an emission source. Its --shape is one of point / disc / sphere / cone / box / edge positioned at --x --y (3D adds --z), with a shape extent (--radius / --size ...). It emits either continuously (--rate <particles/s>) or as a timed burst (--burst <count> --at <ms>) — one-shot effects use bursts; continuous effects use a rate — and gives each particle a --lifetime <ms> (±spread), a --speed <v> (±spread), and a launch direction --dir-x --dir-y (3D adds --dir-z) with an optional --cone-angle <deg> spread. An optional --seed <n> pins the emitter’s random draws for a repeatable look; without it, each play varies.
  • set-forces — the forces integrated into the motion each step, global or scoped to one --emitter <n>: --gravity <v> (with an optional --dir), --drag <k>, --radial <v> (an explosion push out from a center point), --vortex <v>, --turbulence <amp,scale> (curl-noise), and --wind <v,dir>. In 2D the direction components are planar; in 3D they carry the full z axis.
  • set-particle — the per-particle appearance over a particle’s normalized life, scoped to an --emitter: --size-curve <curve> and --opacity-curve <curve> are F-curves (the same model-core curves, constant / linear / bezier plus the ease-in / ease-out / ease-in-out presets), and --color-gradient <#c@t,...> is a set of keyed opaque #rrggbb color stops over life (fire runs white→orange→red→smoke). Optional --rotation and --stretch shape spin and velocity-stretch, and an optional --sprite <ref> textures the particles with a produced sprite or atlas — a cross-asset reference into another produced asset.
  • add-subemitter — a secondary system spawned from a parent’s particles, --parent <emitter> --on death|step --emitter <child>: on a particle’s death (a shell that bursts into embers) or along its step path (a spark that trails smoke). The child is itself an authored emitter.
  • set-timeline--loop true|false: a one-shot effect (an explosion that decays to empty) vs. a looping one (fire or smoke settling into a steady state). The effect’s duration and the playback fps come from the case’s [particle] table, not a flag.
  • render — the separate, on-request preview (below).
  • init — seed an empty log; a run starts pre-seeded.

The coordinate convention matches the voxel and mesh tools: x across, y up, z in depth, forward = +z. particle-2d simply omits the z component everywhere — positions, directions, and forces are planar.

There is no bake and no determinism requirement. Where the meshing tools composite a field once and extract a fixed mesh, a particle effect is a live simulation of the authored system — the same model a real particle editor uses. The authored system.json is the whole asset; whatever plays it simulates it in real time from the emitters, forces, and curves it declares. A stochastic simulation naturally varies from play to play, and that is correct for an explosion or a plume — there is nothing to freeze and nothing to reproduce frame-for-frame.

This keeps the actions-are-the-output property cleanly: the authored system is the recorded operations, resolved into their emitters and forces, so there is no separate baked artifact a model could produce outside the tool. The only output is the system the model built through the binary; every consumer simulates it.

Because every consumer simulates the system live, every frame, forever, the number of particles alive at once is a cost the reviewer’s browser and the consuming game pay continuously — and it is a cost the authoring flags hide. Nothing in --rate 20000 --lifetime 1600 announces that it means thirty-two thousand live particles, and the binary’s own preview cannot show the difference, because it draws at most 8,000 billboards a frame however many the system holds. An effect can look right in the run and stutter in the review UI.

So the binaries enforce a hard ceiling of 10,000 live particles for the whole system, at authoring time. Every operation is projected forward to the peak live count the system would settle at, and one that would push it over the ceiling is rejected — nothing is recorded, and the tool reports the projection, the emitters spending it, and the flags to turn down:

particle-3d: this system would hold about 35200 particles alive at once, over the
10000-particle budget an effect has to fit in (every consumer simulates the system
live, every frame). ...
What the system spends its particles on:
flood ~32000 live
fall ~3200 live

The projection mirrors the simulator’s own rules, as an upper bound:

  • a rate emitter holds roughly rate x lifetime particles alive (--rate 2000 --lifetime 1500 is ~3000 live), counting a --lifetime-spread at its maximum;
  • a burst holds its --burst count — re-fired every cycle on a looping timeline, so a lifetime longer than the loop window overlaps into itself;
  • a sub-emitter child is projected from the traffic its parent hands it (one child burst per parent death, or a trail along every live parent particle), generation by generation, so a chain that multiplies is caught where it multiplies rather than at render.

This is a ceiling on count, not on density: a fuller-looking effect comes from particle size, opacity, and color, which cost nothing per frame. Ten thousand particles is already denser than any preview a model can see. The simulator and the browser runtime enforce the same ceiling as a hard backstop — a system that reaches it another way stops spawning rather than growing without bound.

How a call records; rendering is on request

Section titled “How a call records; rendering is on request”

Each operation only appends itself to the run’s operation log — that is all an authoring call does. Simulating an effect over its whole duration and rendering it is far more expensive than recording an intent, so — like the voxel and meshing tools — these binaries do not re-simulate after every call. Rendering is a separate, on-request step, the render command. The orchestrator seeds a particle-3d.config.json (or particle-2d.config.json) next to the workspace giving the [particle] field dimensions, the duration and playback fps, and the log / preview / system.json paths, so neither an operation nor render needs any of those flags.

The render command simulates the authored system over the effect’s duration and, from the resulting motion, renders the preview frames plus a GIF (reusing the existing animation GIF export) and emits system.json. A model runs it to see its progress and, before it finishes, to emit the system.json the run’s result is built from — an unrendered effect leaves an empty system, which the validator records as empty. The preview is a representative capture of one live play, not a canonical bake — another play of the same system reads the same but is not pixel-identical.

particle-3d init # write an empty log (a run starts pre-seeded); renders nothing
particle-3d render # simulate the system; render preview frames + GIF; emit system.json
particle-3d render --frame 12 # capture a single preview frame to a still

The preview render produces is the played effect. For particle-3d it is a real 3D render: the binary billboards each live particle and rasterizes it with wgpu targeting Mesa lavapipe — software Vulkan, running on the CPU, headless (there is no GPU in the run container) — through an orbit camera, so the previews are apples-to-apples with the other voxel-family binaries. For particle-2d the frames come from the 2D raster path, compositing the particles in the planar [particle] field. Either way the per-frame images are packed into a GIF — one-shot for a decaying effect, looping for a steady-state one — via the same animation GIF export the sprite-sheet sequences use. That GIF (and its frames) is what the model reads to judge its effect and a still the result page can show; the interactive, live-playing view is the frontend’s live simulation of the emitted system.json (see Runtime consumption), not something the binary produces.

When a run is being watched — driven by a driver or the Tauri app rather than a plain tcab run — the model’s authoring is streamed to the viewer in real time, mechanically identical to the voxel and meshing tools: the orchestrator adds a live block (a host.docker.internal endpoint and an opaque per-run token) to the seeded config, and when the model runs render the binary connects back to the run host and streams a one-line JSON header ({ token, frame, operationCount, operation, length, ... }) followed by the freshly rendered preview frame’s raw bytes and then the current system.json payload. That payload lets the viewer simulate the effect live as it is authored — looping or replaying it exactly as the finished-run view does — rather than showing only the flat preview frame; a frame-only viewer simply ignores it. Streaming is best-effort and non-essential — absent for an unwatched run, never fails an operation, and never recorded; the recorded operation log and the emitted system.json remain the run’s authoritative output.

render emits a single artifact:

  • system.json — the authored particle-system definition: its emitters, forces, per-particle curves, sub-emitters, duration, and loop flag. It is compact metadata — the rig.json analogue, but where a rig is posed, a system is simulated. Every consumer — the review UI, an embedding game — plays the effect by running the simulation live from this definition.

The preview GIF a reviewer plays and the live-simulated effect are what is scored — the character of the effect, the way a sprite-sheet’s sequences are what a reviewer plays. The validator parses system.json, confirms it is well-formed and readable, and checks that it is non-empty — that the system actually emits particles rather than declaring emitters that produce nothing.

A shared TypeScript package @test-cabinet/particle-runtime (mirroring @test-cabinet/voxel-runtime) turns a particle run’s system.json into a playable effect by simulating it live — the difference from the voxel runtime being that a rig is posed from decoded geometry, while a particle system is simulated from its definition:

  • the pure core runs the simulation from system.json — stepping the emitters and forces and evaluating the per-particle curves each frame;
  • a three binding renders the 3D effect as billboards — instanced quads or THREE.Points — from the simulated state; and
  • a 2D canvas binding composites the same simulated state in a 2D context.

The in-repo web viewer plays the effect livelooping, or one-shot with replay — the way the voxel viewer poses a rig, a running particle editor rather than a frozen clip. A consuming game embeds this runtime to play the effect, or re-authors system.json into its own particle system (Niagara, VFX Graph) — the same portable definition either way.

The two binaries share everything above; their specifics differ only where dimensionality forces it:

  • particle-2d — its [particle] table gives width / height, a 2D field like [canvas]; forces are planar; the preview is a 2D raster animation; and the runtime is the canvas binding. Good for UI, 2D-game, and screen-space VFX. Unlike a model coding an effect inline, this produces a reusable system asset a game plays through the runtime, distinct from an effect authored in a game’s own code.
  • particle-3d — its [particle] table gives width / height / depth, a volume like [voxel]; it carries the full 3D forces (gravity, vortex, curl-noise turbulence, radial push); the preview is the wgpu orbit billboard render; and the runtime is the three binding. This is what the 3D games consume.