3D visual effects
Particles, ribbons, mesh particles, skid marks and decals for the 3D renderer, described as data and driven by vfx.* commands
or three components. Code: data model, CPU simulation, trails and presets in crates/sparklight-particles/src/vfx3d/; the render
passes in crates/sparklight-3d/src/particles.rs (+ shaders/particles.wgsl, shaders/particles_sim.wgsl); the scene side
(components, running effects, state hash) in crates/sparklight-scene3d/src/vfx.rs; commands in
crates/sparklight-control/src/commands/vfx.rs. The 2D particle system (EmitterConfig) is unchanged.
Quick start
Section titled “Quick start”# one process per command is enough to look at an effectspark call space3d.load '{"demo": "pyramid"}' # (any 3D scene)spark call vfx.spawn '{"effect": "sparks", "position": [0, 0.2, 0]}'spark call space3d.step '{"frames": 60}'spark call space3d.capture '{"eye": [4, 1.5, 4], "target": [0, 0.4, 0], "path": "sparks.png"}'{"command": "vfx.spawn", "params": {"effect": "tyre_smoke", "entity": "Wheel", "patch": {"emission": {"rate_over_distance": 12}}}}{"command": "space3d.set", "params": {"path": "/Wheel/ParticleEmitter3D/playing", "value": false}}{"command": "vfx.stats"}spark mcp exposes every command as a tool (vfx_spawn, vfx_stop, vfx_list, vfx_stats, vfx_presets, vfx_preset, vfx_define,
vfx_validate, vfx_schema, vfx_quality, vfx_decal).
An effect is JSON
Section titled “An effect is JSON”Vfx3DConfig (vfx.schema gives the JSON Schema; vfx.validate checks a file; every error has a code and a JSON path). Metres, seconds,
radians, Y up; colours are linear RGBA and may exceed 1 (HDR emissive: the bloom pass picks them up).
| Section | Fields |
|---|---|
| top level | version (1), name, max_particles, duration, looping, space (world / local), simulation (cpu / gpu) |
emission |
rate per second, rate_over_distance per metre travelled, bursts: [{time, count, count_max}] |
shape |
point, sphere {radius, surface}, box {half_extents, surface}, cone {radius, angle}, mesh {model} (area-weighted triangles of a loaded model) |
start |
lifetime, speed, size, rotation, angular_velocity (a number, [min, max] or {min, max}), color, direction, spread (cone half-angle), shape_normal (0..1), inherit_velocity |
modules |
gravity, drag, turbulence {strength, frequency, scroll} (curl noise), size_over_life (number or [[t, v], ...]), color_over_life (gradient of {t, color}), collision, sub_emitters |
modules.collision |
mode: plane {normal, offset} or depth (GPU only: the scene’s depth buffer), bounce, friction, life_loss, kill |
modules.sub_emitters |
[{on: birth / death / collision, effect, count, inherit_velocity}]: spawns count particles of another effect at the event (3 levels deep) |
render |
mode (billboard, stretched {speed_scale, min_length}, ribbon {width, points, seconds}, mesh {mesh}), blend (additive, alpha, premultiplied), align (camera, ground), sprite, flipbook {cols, rows, fps, random_start}, soft_distance, camera_fade {start, end} (fade in with the distance from the eye, metres: a sprite that envelops the camera thins away instead of popping; the smoke, dust, exhaust and puff presets use 0.5 to 2.5), lit, sort, essential |
Sprites: builtin:soft|smoke|flame|spark|ring|streak|chip|dust|solid|skid (generated in code, sprites.rs; smoke, flame are 4x4 flipbooks,
dust 2x2) or the key of a texture registered with Renderer3D::register_particle_texture (set flipbook for sheets).
Mesh particles: builtin:cube and builtin:shard (a flat chip); other meshes are not supported yet and draw as cubes.
Errors: E_VFX_JSON, E_VFX_VERSION, E_VFX_FIELD, E_VFX_RANGE, E_VFX_CURVE, E_VFX_UNSUPPORTED (a module the mode cannot do, for example
sub-emitters on the GPU), E_VFX_PRESET, E_VFX_NOT_FOUND, E_VFX_EFFECT (sub-emitter names an unknown effect, or nests too deep).
Built-in effects
Section titled “Built-in effects”sparks, spark_burst, tyre_smoke, dust, exhaust, fire, explosion (fireball + sparks + smoke through sub-emitters), smoke_puff,
debris_chips, rain (+ rain_splash as its sub-emitter). vfx.preset {name} prints the JSON; edit it and vfx.define it, or pass a merge
patch to vfx.spawn / the component. They assume a ground plane at y = 0 (collision) and a world in metres.
In the scene
Section titled “In the scene”| Component | What it does |
|---|---|
ParticleEmitter3D |
effect (built-in or defined name), patch, playing, seed, offset, rotation: the effect follows the object; the emitter velocity (for inherit_velocity, rate_over_distance) comes from the object’s body or from its movement |
TrailRenderer |
trail (skid_mark, scrape, custom), config (patch), emitting, normal, offset: a strip that follows the point, fades with age and breaks while emitting is false |
Decal |
sprite, color, lit, angle_fade: projects onto the geometry inside the object’s box (scale = box size; looks down local -Y) |
vfx.spawn starts the same effects without a component (at a point or following an object). Component emitters, spawned effects and
trails are part of space3d.snapshot.* and the state hash (scenes without effects hash as before). Particles born after the last
capture on a simulation: "gpu" effect are kept for the next draw.
Rendering
Section titled “Rendering”Passes after transparent (docs/render-3d.md has the full table): particles_sim (compute, GPU-simulated effects only), decals
(box projection reading the depth buffer), particles (one draw per batch, blended into hdr, depth tested but not written, so the bloom,
exposure and tone mapping that follow treat sparks as the HDR light they are). Soft particles fade by the scene depth under each fragment.
lit shades billboards as spheres by the sun and the ambient colour of the scene (smoke), meshes by their normals.
Alpha and premultiplied batches of the CPU simulation are sorted back to front; additive ones are not, and GPU effects are never sorted.
Quality tiers and costs
Section titled “Quality tiers and costs”VfxSettings::for_tier (renderer switches) and vfx.quality (how many particles are born; essential effects keep at least 0.75):
| Tier | Emission scale | Soft particles | Lit | Decals, ribbons |
|---|---|---|---|---|
low |
0.35 | off | off | on |
medium |
0.6 | on | off | on |
high, ultra |
1.0 | on | on | on |
Measured at 1280x720 on an Apple Silicon GPU (Metal), with crates/sparklight-3d/tests/particles_gpu.rs (cargo test -p sparklight-3d --test particles_gpu -- --nocapture --test-threads=1). The scene is six effects at once (rain at 6000 per second over the view, tyre smoke, dust, sparks, fire,
exhaust), 200 steps in, 20 frames averaged. GPU milliseconds come from the pass timestamps and are stable; CPU milliseconds were measured while
other agents kept the machine at a load average above 100, so read them as upper bounds (the best run of each is given in the text below).
| Tier | Particles alive | GPU particles pass |
|---|---|---|
| low | 2353 | 0.22 ms |
| medium | 3986 | 0.36 ms |
| high | 6616 | 0.41 ms |
| ultra | 6616 | 0.75 ms (the ultra post stack shares the GPU; the pass is the same) |
The 50k budget (budget_50k_particles_at_720p): 49,584 additive soft particles of 12 cm with curl turbulence and colour over life, 20 frames averaged.
| Simulation | CPU simulation per frame | CPU collect + upload | GPU compute | GPU particles pass |
|---|---|---|---|---|
cpu |
12.6 ms best (50 ms under load) | 2.3 ms collect, 1.2 ms record (3.1 MB uploaded) | none | 0.86 ms |
gpu |
0.05 ms (spawning only) | 0.09 ms record, no upload | 0.14 ms | 0.91 ms |
The same particle count without turbulence simulates on the CPU in 0.42 ms per step (budget_50k_particles_on_the_cpu, which asserts under 33 ms).
So 50k particles cost the GPU about 1 ms either way; what the GPU path saves is the CPU: with curl noise the CPU path spends most of a 60 fps frame on
the simulation, the compute path almost none. Overdraw dominates the pass: it grows with particle size on screen, not with count.
CPU and GPU simulation
Section titled “CPU and GPU simulation”simulation: "cpu" is the reference: exact, deterministic (Vfx3DSystem::state_hash), supports every module, sub-emitters and ribbons.
simulation: "gpu" keeps the particles in a storage buffer: the CPU decides when and how particles are born (the same random draws), a
compute shader integrates gravity, drag, curl turbulence, plane collision and (GPU only) depth-buffer collision, and the vertex shader
evaluates the size and colour curves from 16-sample tables. The two agree to float tolerance (test gpu_simulation_follows_the_cpu_reference).
Not on the GPU: sub-emitters, ribbons, space: "local". A GPU effect cannot be hashed bit for bit across machines.
Known limits
Section titled “Known limits”- Mesh particles are a cube or a flat shard; glTF meshes are not drawn as particles yet.
- Particles are lit by the sun and the ambient colour only (no local lights, no shadows received).
- Reflection probe captures show no effects (the passes are skipped there), and particles write no motion vectors, so TAA softens fast ones a little.
- A merge patch over a tagged field (
shape,render.mode) keeps the old variant’s keys: null them to change the variant ({"shape": {"type": "box", "half_extents": [...], "radius": null}}). - Alpha-blended GPU effects are unsorted. Decals read the depth buffer only: they project onto anything opaque, skinned meshes included, and not onto transparent surfaces.