Skip to content

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.

Terminal window
# one process per command is enough to look at an effect
spark 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).

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

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.

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.

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.

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.

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.

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