Particle effects (.vfx.json)
An effect is a file of one to eight emitters.
An effect is a file of one to eight emitters. Each emitter is a fixed stack of known modules with typed parameters, curves and bindings: data, not a node graph. Agents and people edit the file like any other asset; there is no new agent tool. The eight presets (smoke, fire, ...) are built-in effects (builtin:smoke ...) and keep their exact look.
The format is declared in packages/contract/src/vfx/schema.ts (Zod) and checked by validateVfx (TypeScript) and argus_particles::fx::compile_json (Rust), which agree on code, pointer and message for every file in fixtures/vfx/ (cases.json). The first problem found is the one reported.
Using one
In a scene, declare the file as an asset and name it from a particles component:
"assets": { "campfire": { "kind": "effect", "path": "effects/campfire.vfx.json" } },
"entities": [{ "id": "fire1", ..., "components": [
{ "type": "particles", "effect": "campfire", "params": { "intensity": 2, "tint": "#ff8040" } }
] }]
argus validate compiles each effect asset and looks for the textures it names, so a broken file fails the check with its own code and pointer instead of loading nothing at run time, and it warns (particles-param-unknown, as the run does) about a component param the effect does not declare or a value that does not fit its type.
effect is a key of scene.assets whose kind is effect, a project-relative *.vfx.json path (read from the project root, then from the scene's folder, as a road's source is), builtin:<preset> (the eight simple presets), or builtin:fx/<preset> (the preset library, with its params in params). Exactly one of preset and effect is required (particles-source); with an effect, rate and color are not used, and a user-shader material is refused for now. A name that resolves to nothing is vfx-effect-missing. The instance follows its entity (position and rotation), and its speed input and perMetre spawns come from that motion. On a child entity (a wheel under a kart, a torch in a hand) that is the world position and rotation through the parent chain, every tick; a parent's scale moves the child but does not scale the emitter, and under a mirrored or sheared parent the emitter's orientation is unspecified. Particles already spawned stay in world space. A preset emitter is placed once, at its entity's world position when it is attached, and does not follow.
From a behaviour (ctx.particles):
ctx.particles.play("builtin:sparks", [0, 1, 0]) // a one-shot instance, reaped when it has finished
ctx.particles.play("campfire", at, { intensity: 3 }) // an effect asset, with params
ctx.particles.setParam("intensity", 0.5) // this entity's particles component
ctx.particles.setParam("tint", "#40a0ff", otherEntity) // another entity's
ctx.particles.burst(30) // 30 extra particles on every emitter, now
ctx.particles.burst(30, undefined, "flash") // ... or only the emitter named "flash"
setParam and burst return false when there is no such instance (or param, or emitter); an unknown param name or a value of the wrong type also warns once (particles-param-unknown). play of an effect the scene did not load warns particles-effect-unknown. A setParam applies on the next tick; params given to play or in a particles component are set before the instance's first tick, so a burst at time 0 already has them (its tint, a count bound to a param).
The file
{
"name": "campfire", // optional
"params": { // optional, at most 16, names 1-32 letters/digits/_
"intensity": { "type": "number", "default": 1, "min": 0, "max": 4 },
"pull": { "type": "vec3", "default": [0, 2, 0] },
"tint": { "type": "colour", "default": "#ff8040" }
},
"emitters": [ { ... } ] // 1 to 8
}
Every object is strict: an unknown key is an error that names the key you probably meant. Angles are degrees, lengths metres, times seconds, colours linear unless written "#rrggbb[aa]" (sRGB). A shape, a module and a renderer are objects named by type: { "type": "cone", "angle": 15, "radius": 0.3 }, { "type": "gravity", "value": [0, -9.8, 0] }, { "type": "sprite", "shape": "puff" }. The tables below list each one's fields after its type.
A whole file, the campfire above: a cone of additive puffs whose rate and tint come from its params, pulled up and shrinking as they fade.
<!-- vfx-md:campfire -->
{
"name": "campfire",
"params": {
"intensity": { "type": "number", "default": 1, "min": 0, "max": 4 },
"pull": { "type": "vec3", "default": [0, 2, 0] },
"tint": { "type": "colour", "default": "#ff8040" }
},
"emitters": [ {
"name": "flames",
"capacity": 512,
"spawn": { "rate": { "param": "intensity", "scale": 40 } },
"shape": { "type": "cone", "angle": 15, "radius": 0.3 },
"init": { "life": [0.6, 1.2], "speed": [1, 2], "size": [0.2, 0.4], "colour": { "param": "tint" } },
"update": [
{ "type": "attractor", "position": { "param": "pull" }, "strength": 2 },
{ "type": "sizeOverLife", "curve": { "preset": "easeOut", "from": 1, "to": 0 } },
{ "type": "colourOverLife", "gradient": [ { "t": 0, "colour": "#ffffff", "alpha": 1 }, { "t": 1, "colour": "#ff2000", "alpha": 0 } ] }
],
"render": { "type": "sprite", "shape": "puff", "blend": "additive" }
} ]
}
Values and bindings
A number field takes a literal, or a binding { "param": "intensity", "scale": 2, "offset": 0 } that reads a number param (result value * scale + offset), or { "input": "speed" | "age" }: speed is how fast the instance itself moves (m/s), age the seconds since it started. A vector field takes [x, y, z] (each a number or binding) or { "param": name } of a vec3 param. A colour field takes [r, g, b], [r, g, b, a], "#rrggbb" or { "param": name } of a colour param. Binding the wrong type of param is vfx-param-invalid.
An init scalar (life, speed, size, rotation, angularSpeed, flipbookStart, drag) is a value, a [min, max] range drawn uniformly per particle, or { "curve": ..., "span": seconds } sampled at the emitter's age over span (default: the emitter's spawn.duration; required if it has none) and held after.
Curves and gradients
A curve is up to 8 keys [[t, value], ...] with t strictly increasing in 0..1 (held flat outside the ends), or { "preset": "linear" | "easeIn" | "easeOut" | "smooth", "from": 0, "to": 1 }. A gradient is up to 8 keys { "t", "colour", "alpha" }. Both are baked at load to 32 samples (t = i/31, f64 then f32) in one arena shared by every effect; the CPU and GPU read the same samples with the same linear interpolation, so they agree.
Emitter
| field | meaning |
|---|---|
name | for burst(..., emitter); default e<index> |
capacity | 1-65536 live particles (the pool size; a spawn into a pool slot still alive is dropped) |
space | local (default): the shape and emission axis follow the instance's rotation; world: they stay world-axis. Particles always simulate in world space |
spawn | rate /s, burst [{time, count, cycles, interval}] (at most 8; cycles 0 repeats forever), perMetre (per metre the instance moves, spread along the path), duration, loop (default true), delay |
shape | point (default), sphere {radius, surface}, box {size} (full extents), cone {angle, radius} (base disc; its angle is the emission spread), ring {radius} |
init | life, speed, size (required); direction {axis, spread, radial}, rotation (radians, default random), angularSpeed, colour (a tint or [a, b] mixed per particle), flipbookStart, drag (a multiplier on the drag module), inherit (below) |
update | modules, each type at most once (below) |
render | sprite {texture, shape, band, rim {colour, width}, flipbook {cols, rows, fps, blend}, facing, normal, stretch {bySpeed, max}, blend, soft {distance}, lit, emissive, maxScreen} or ribbon {...} (below); shape and the rest: Shapes |
delay waits before the first spawn; duration is the spawning window of one cycle; loop: false spawns for one duration and stops (live particles finish). An effect is finished (a play instance is reaped) when no emitter can spawn again and nothing is alive.
Update modules
Listed in any order, applied in this fixed order (the GPU runs them in code order), each at most once:
gravity {value}(m/s^2)attractor {position, strength, falloff}(position in the instance's frame; pullstrength / (1 + falloff r^2))vortex {axis, strength}(tangential acceleration around the axis through the instance origin)curlNoise {strength, frequency, speed}(divergence-free noise, hash based, no transcendentals)wind {scale}: velocity relaxes toward the scene's wind (meta.environment.air) atscaleper second; the app samples the air once per instance per tick at its origindrag {linear}:v *= 1 / (1 + linear * dragMultiplier * dt)speedLimit {max}collide {with, response, bounce, friction, radius}(V2, see Collision below)sizeOverLife {curve, scale}: multiplies the initial sizecolourOverLife {gradient, alpha}: multiplies the tint;alphascales opacityrotationOverLife {curve, scale}: extra rad/sstretchOverLife {curve, scale}: multiplies the streak length
Acceleration modules sum, then v += a dt, then wind, drag, limit, then collision, then p += v dt (a particle that collides is placed by the collision instead). A particle born in a tick is not moved in it.
Inherited velocity. Particles simulate in world space, so an emitter that moves leaves them behind: a flame on a car at 36 m/s that lives 0.3 s trails about 11 m back. init.inherit (0 to 1, default 0) adds that share of the instance's own velocity this tick (how far it moved over the tick) to each particle at birth, on both backends: 1 keeps a jet or a puff moving with what carries it, 0 (or omitted) leaves it where it was born, as before, byte for byte. A move faster than 150 m/s in one tick is a jump (a respawn, a teleport by position) and passes nothing on that tick. A value outside [0, 1] is vfx-inherit-invalid.
Collision
{ "type": "collide", "with": ["ground", "water", "depth"], "response": "bounce", "bounce": 0.3, "friction": 0.2, "radius": 1 }
with is any subset of ground (the terrain, World::ground_sample), water (the scene's water surface: sea, lakes, rivers) and depth (default ["ground"]). response is kill (the particle ends), bounce (the normal velocity is reflected and scaled by bounce 0..1; friction 0..1 takes that share of the tangential velocity) or stick (it stops on the surface). radius multiplies the particle's initial size for the contact offset: the particle rests that far above the surface. The step p -> p + v dt is tested after speedLimit and before the over-life modules; with several surfaces the one met first (nearest the start of the step) wins. A particle that starts more than its offset plus 0.1 m below a surface is underground (a cave under the terrain, a spawn inside it) and is left alone; a shallower one is pushed out.
The patch. The engine hands each instance one coarse height patch per tick: 8 x 8 samples of the ground and of the water over a square centred on the instance, sampled when the tick starts. A particle reads it bilinearly. The half-side is the emitter's reach (its shape plus speed x life, horizontally) clamped to 2-32 m (so 0.57 to 9 m between samples). A particle outside the patch, over a sample with no surface (no terrain, no water, a hole, a non-finite height), or while the instance has no patch yet, does not collide; a particle that slides out of the patch (down a long slope, or an instance moving faster than its particles) falls on through. The patch is centred on where the instance is when the tick starts, so a fast instance lags it by a tick.
Backends. On the CPU depth counts as ground plus water; the CPU does not see the depth buffer. On the HD GPU, depth collides with the frame's opaque depth buffer (the normal rebuilt from depth, a surface up to 1 m in front of the particle's offset), and ground and water read the same patch as the CPU (particle_collide.wgsl is the twin of fx/collide.rs). GPU collisions are visual only: nothing is read back into the simulation, gameplay or a replay. CPU/GPU parity covers ground and water (tests in crates/particles/tests/gpu.rs), not depth. The depth is the current frame's, taken after the opaque pass and before the particles are lit.
The feature. Collision is behind the cargo feature vfx-collide, off by default (cargo build -p argus --features vfx-collide): an engine without it carries none of the collision code or shader text and runs an effect with a collide module as if the module were not there. The loader says so once per effect (particles-collide-unavailable, docs/RUNTIME-CODES.md). The file always parses and validates (vfx-collide-invalid for a bad module). An effect without collide is bit-identical either way.
Events and child emitters
An emitter can start particles in another emitter when one of its particles dies or collides:
"emitters": [
{ "name": "drops", ..., "update": [{ "type": "collide", "with": ["ground"], "response": "kill" }],
"events": [ { "on": "collision", "emit": "splash", "count": 4, "inherit": 0.3 },
{ "on": "death", "emit": "mist" } ] },
{ "name": "splash", "role": "child", "capacity": 4096, ... }, // spawns only from events: no `spawn`
{ "name": "mist", "role": "child", "capacity": 1024, ... } ]
on:death(a particle's life ends, akillcollision included) orcollision(it needs the emitter'scollidemodule).countchildren per particle (1-16, default 1);inherit(0-1, default 0) of the parent's velocity is added to the child's own. The child is tinted by the parent's tint.- One level only: a child has no
events, every child is the target of exactly one event, and each emitter has at most 4 events (vfx-event-invalidotherwise). - Slots and timing. The children of the parent particle in slot
igo to child slotsi * count + k, with birth serialparent serial * count + k, so the child'scapacitymust be at least the parent's timescount. A child starts at the start of the tick after its parent's event, at the parent's position plus the child's own shape, and is not moved in that tick. A slot whose earlier child is still alive keeps it (the new one is dropped). - Determinism. The CPU writes each parent slot's record in slot order and the children read the previous tick's records, so runs repeat byte for byte. The GPU writes the same records, one per pool slot in two halves (this tick's and the previous tick's), each thread only its own: no atomics, and the children match the CPU (
tests/gpu.rs). GPU collision events stay visual, like GPU collisions. - An instance is not finished while a child can still start. On the GPU the CPU cannot see the records, so a child pool counts as alive until its parent has no particles and the child's longest life has passed.
Decals on hit
An event can leave a decal where the particle hit or died, next to (or instead of) an emit:
"events": [ { "on": "collision", "decal": { "texture": "wet", "size": [0.4, 0.4], "life": 6, "fadeOut": 2, "max": 64 } },
{ "on": "death", "emit": "mist" } ]
- An event has
emit,decalor both (vfx-event-invalidif neither).textureis a scene asset id of kindtextureor a path from the effect file, like a sprite's (vfx-texture-invalid);sizeis[width, length]in metres, both above 0;lifeseconds above 0;fadeOut(default 0) from 0 tolife: the decal's opacity falls to 0 over the lastfadeOutseconds;max(1-256, default 64) decals live at once for this event of this effect instance, the oldest replaced first. An emitter has at most one decal per event kind. Out-of-range numbers arevfx-value-range. - The decal is projected onto the surface at the hit point (the particle's position), turned to the hit's normal for a
collisionand flat (straight down) for adeath. Its box is as deep as half its larger side (at least 0.5 m). - HD only. SD and headless worlds make no hit decals (and do not read the texture): the standard renderer has no decal pass. Every decal is visual only: it never reaches the sim, gameplay, physics or a replay, and the two runs of a replay may differ in their decals.
- Two frames late on the GPU. The GPU sim appends a hit record for each decal event to a small ring (at most 64 a frame; the rest are dropped and counted:
ParticleSystem::hits_dropped), copied to one of three mappable buffers and read asynchronously, never waited for. The app turns the hits into decals about two frames after the particle hit. The ring's one atomic counter only orders decals (the sim itself uses no atomics). A hit whose instance was despawned in the meantime, or whose texture did not load (particles-texture-missing, once), makes no decal; a decal already made stays until itslifeends. An emitter simulated on the CPU in HD reports its hits directly, without a readback and without the delay. - Pay for use. Only an effect with a
decalevent sets the row's decal flags; without one there is no ring, no staging buffer, no readback and no decal-pass work, and frames are bit-identical. The decal list is the app's whole list (Decals::set); live hit decals are capped at 1024 in all.
Ribbons
render with "type": "ribbon" draws a trail instead of sprites: one strip through the emitter's live particles.
"render": { "type": "ribbon", "texture": "trail", "width": 0.3, "widthOverLife": [[0, 1], [1, 0]],
"uv": "stretch", "tileLength": 1.0, "facing": "camera" }
| field | meaning |
|---|---|
texture, blend, soft, lit, emissive | as for sprites |
width | metres, times the particle's size (default 0.2; must be above 0) |
widthOverLife | a curve over the particle's life, baked to the shared LUT like sizeOverLife (default constant 1) |
uv | stretch (default): the texture's u runs 0 to 1 along each live run; tile: one repeat per tileLength metres of path |
tileLength | metres per repeat with tile (default 1; must be above 0) |
facing | camera (the strip's flat side turns to the camera), up (lies flat, in the XZ plane), custom (turns to the emitter's normal) |
There is no flipbook, stretch or velocity facing on a ribbon (schema-shape), and a role: "child" emitter cannot be a ribbon (vfx-event-invalid: child slots are not in birth order).
- Linking. Slot
sholds serials % capacity, so the strip walks the pool from the slot the next spawn will take (the oldest particle) round to the newest with no sort. Two live neighbours join when their serials differ by one. A dead particle in the middle, or a spawn that was dropped because its slot was still alive, splits the strip into runs; each run has its own 0 to 1stretchcoordinate and startstileat 0. A run of one particle draws nothing (a ribbon needs two). - Width and colour. Width is
size x width x widthOverLife(age / life), where size is the particle's size with itssizeOverLife; colour is the tint timescolourOverLife. Each point has its own, so a trail tapers and fades along its length. - Draw. One draw per ribbon emitter: the prepare pass (HD) lights each point and gives it its link and u, the draw expands each linked pair into a quad whose edges are shared with the next quad, so the strip has no seams. Blend modes go through the same OIT as sprites. The standard renderer draws the same quads unlit (premultiplied counts as alpha there). With no ribbon emitter there is no ribbon pass, buffer or pipeline and the frame is unchanged.
- Where the strip is built. On the GPU backend,
prepare_ribbon(particle_prepare.wgsl): one workgroup per ribbon emitter walks the pool in two passes with a 256-way chunked scan for the distance along each run (no atomics). A CPU pool builds the same strip infx/ribbon.rs(ParticleSystem::ribbon_points,Renderer::set_ribbons); the two agree to float rounding (tests/gpu.rs). - Trails.
spawn.perMetregives an even trail behind a moving instance (even to within one tick's step);rategives a time-even one. Per-particle trails (every spark leaving its own) need per-particle history and are not in V2. - Cost. A ribbon point is a 64-byte pool record like a sprite plus one instance per segment; the row is 432 bytes (416 before
maxScreen, 400 before ribbons).
Presets
The engine ships a small library of ready effects, the Squall presets. A game names one and sets only the params it wants; the rest of the file is the preset's:
<!-- vfx-md:land-puff -->
// effects/land-puff.vfx.json
{ "name": "land-puff", "preset": "builtin:fx/puff-burst", "params": { "tint": "#D8B48A", "strength": 1.2 } }
params here are values, not definitions: each replaces that param's default. A file that names a preset takes only name, preset, params and render; an unknown preset, a param the preset does not have or any other key is vfx-preset-invalid (a value of the wrong type is refused as that param's default would be, at /params/<name>/default). render is an optional override of the preset's sprites, written over every emitter's render block (a ribbon emitter takes soft, emissive and texture only). It takes only these keys; any other is vfx-preset-invalid at /render/<key> (the values are checked as in any render block, e.g. vfx-max-screen-invalid):
| key | meaning |
|---|---|
maxScreen | the largest a sprite draws, a share of the viewport height (see render.maxScreen) |
soft | { "distance": m }, the depth fade against opaque geometry |
emissive | the glow, replacing the preset's |
texture | draw this texture instead of the preset's built-in shape (drops its shape, band and rim) |
shape | draw this built-in shape instead (not with texture) |
<!-- vfx-md:land-puff-capped -->
{ "name": "land-puff", "preset": "builtin:fx/puff-burst",
"params": { "tint": "#D8B48A" },
"render": { "maxScreen": 0.2, "soft": { "distance": 0.5 }, "emissive": 0.2 } }
There is no opacity or fade override; to change blend, size or colour over life, copy the preset's file into the game. A script can also play a preset directly: ctx.particles.play("builtin:fx/confetti", at, { amount: 0.5 }).
| preset | kind | params |
|---|---|---|
builtin:fx/sparks | continuous: hot streaks (blades, additive), a few stars and an optional ground ring, e.g. drift sparks per tier | intensity (0-1.5), size (a tier's scale), glow (0-1: the ground ring), tint; at most 60 live per instance at 30 m/s |
builtin:fx/flame | continuous: rimmed puffs fading from core to tint with a hot core streak, e.g. a boost or an exhaust | intensity (0-1.5), size, tint, core; at most 40 live |
builtin:fx/dust | continuous, per wheel or foot: puffs per metre moved and per second of slip | intensity (0-1, per metre), slip (0-1, per second), size, tint, shade; at most 40 live per instance at 30 m/s |
builtin:fx/puff-burst | one-shot: a ring of puffs and a few chips, e.g. a landing | strength (0-2: count and size), tint, chips |
builtin:fx/hit | one-shot: a star flash, a ring pop, stars and flakes flung out, e.g. an item hit | strength (0-2: size and count), tint, accent; at most 80 live |
builtin:fx/confetti | one-shot: 4 x 80 flakes in four colours that flutter down over 2.5 s | amount (0-1), colour1-colour4 |
Every preset draws built-in shapes, so it needs no texture. The presets are compiled once and are deterministic like any effect file. builtin:fx/speed-lines (thin streaks on a ring, flying back past the anchor; intensity 0-2, tint) is world-space: anchor it ahead of the camera and above the road. A camera-space emitter that keeps such lines on screen is not in V1. The engine's files are in crates/particles/src/fx/presets/.
Recipe: tyre smoke and skid marks from wheel slip
A kart or car smokes its tyres and leaves marks only while they slide. Nothing here is new engine work: an effect on each rear wheel entity, whose spawns per metre are bound to a slip param, and a behaviour on the vehicle that sets slip from each wheel's slip angle (ctx.body.wheels, docs/VEHICLES.md).
<!-- vfx-md:tyre-slip -->
{
"name": "tyre-slip",
"params": {
"slip": { "type": "number", "default": 0, "min": 0, "max": 1 },
"tint": { "type": "colour", "default": "#d8d4cc" }
},
"emitters": [ {
"name": "smoke",
"capacity": 256,
"space": "world",
"spawn": { "perMetre": { "param": "slip", "scale": 14 } },
"shape": { "type": "sphere", "radius": 0.12 },
"init": { "life": [1.0, 1.5], "speed": [0.2, 0.5], "direction": { "axis": [0, 1, 0], "spread": 60 },
"size": [0.22, 0.34], "colour": { "param": "tint" } },
"update": [
{ "type": "gravity", "value": [0, 0.6, 0] },
{ "type": "wind", "scale": 0.5 },
{ "type": "drag", "linear": 1.0 },
{ "type": "curlNoise", "strength": 0.6, "frequency": 0.8 },
{ "type": "sizeOverLife", "curve": [[0, 1], [1, 2.8]] },
{ "type": "colourOverLife", "gradient": [ { "t": 0, "colour": "#ffffff", "alpha": 0.4 }, { "t": 0.6, "colour": "#ffffff", "alpha": 0.32 }, { "t": 1, "colour": "#ffffff", "alpha": 0 } ] }
],
"render": { "type": "sprite", "lit": true, "soft": { "distance": 0.3 } }
}, {
"name": "marks",
"capacity": 64,
"space": "world",
"spawn": { "perMetre": { "param": "slip", "scale": 4 } },
"init": { "life": 0.05, "speed": 0, "size": 0.01, "colour": [0, 0, 0, 0] },
"render": { "type": "sprite" },
"events": [ { "on": "death", "decal": { "texture": "skid.png", "size": [0.16, 0.4], "life": 8, "fadeOut": 3, "max": 256 } } ]
} ]
}
Put it at scenes/effects/tyre-slip.vfx.json with a skid.png beside it (a small dark strip; its alpha is the mark), declare it in the scene and name it from each rear wheel:
"assets": { "tyre-slip": { "kind": "effect", "path": "effects/tyre-slip.vfx.json" } },
{ "id": "wheel-rl", "parent": "kart", ..., "components": [{ "type": "wheel" }, { "type": "particles", "effect": "tyre-slip" }] }
Then, on the vehicle's body:
<!-- vfx-md:tyre-slip-ts -->
// scenes/behaviors/tyre-slip.ts
import type { Behavior } from "@argus/api"
/** The tyre's `peakSlipAngle` (radians, docs/VEHICLES.md): below it the tyre grips and leaves nothing. 0.12 is the kart preset's. */
const PEAK = 0.12
/** The wheels that carry the effect. */
const SMOKING = ["wheel-rl", "wheel-rr"]
export default {
update(ctx) {
for (const w of ctx.body?.wheels ?? []) {
if (!SMOKING.includes(w.entity.id)) continue
// 0 up to the peak slip angle, 1 at twice it; nothing in the air.
const over = (Math.abs(w.slip.lat) - PEAK) / PEAK
ctx.particles.setParam("slip", w.grounded ? Math.min(Math.max(over, 0), 1) : 0, w.entity)
}
},
} satisfies Behavior
- What the mapping assumes. The slip angle (
slip.lat) is the angle between where the tyre points and where it moves; past the tyre'speakSlipAngleit no longer gains grip and slides. SetPEAKto your vehicle's (tyres.peakSlipAngle: 0.12 on the kart preset, 0.14 to 0.16 on the car presets), and change the/ PEAKto make the smoke come on sooner or later.slip.long(wheelspin and locked brakes) is 0 on aclamptyre such as the kart's; on acurvetyre addMath.abs(w.slip.long)against itspeakSlipRatiothe same way. - The marks are HD only, as every hit decal is: SD and headless runs draw the smoke and no marks. Each mark is a decal under a short-lived invisible particle, left where it dies (
max256 per wheel, the oldest replaced, fading over the last 3 of their 8 s). - The look. Each puff is born about the size of a kart tyre at 40% opacity and grows 2.8 times over a 1 to 1.5 s life, rising and spreading slowly (a little lift, curl noise, the scene's wind), and fades from 60% of its life. The sprite has no
shape: the built-in soft sprite, not a hard-edged one. 14 puffs per metre overlap into one trail;perMetrespreads them evenly along the path, so it is as dense at 30 m/s as at 10. For a car, scalesizeand the decal'ssizeup with the tyre. "space": "world"keeps the puffs upright while the wheel entity spins.
Shapes
A sprite can draw a built-in hard-edged shape instead of a texture: "shape": "disc" | "ring" | "star" | "blade" | "puff" | "flake". It is a flat fill in the particle's colour, a band (the fill times this factor on the lower right, default 0.78, so the light comes from the upper left) and an ink rim (colour, default #2A2320, not tinted by the particle; width, a share of the radius, default 0.12, 0 for none). A shape replaces the texture: texture or flipbook with a shape, band or rim without one, or an unknown shape is vfx-shape-invalid. Ribbons take no shape.
Both renderers draw the shapes the same way: each is a distance field evaluated per pixel, so it stays crisp at any size and needs no image. The standard renderer reads the shape from a small uniform per group (crates/render/src/shaders/particles.wgsl); HD reads it from the emitter's GPU row (the kind in flag bits 18-20, the band and rim width as unorm16 and the rim as RGBA8, FxEmitterRow::set_sprite_shape) in its sprite draw (hd/transparency/shaders/particle_draw.wgsl, a copy of the same shape_distance, which a test holds equal). On HD a shape's solid pixels (opaque, inside the shape, not additive) draw in a cutout pass with a depth write before the blended pass, so an opaque card hides the one behind it as on the standard renderer; its fading or anti-aliased pixels and additive shapes blend as before. The pass runs only in a frame with shaped sprites. An emitter without a shape draws exactly as before on both. On HD the shapes are drawn for effect emitters on the GPU pool, which is where HD runs them; an emitter that simulates on the CPU there (one with a user-shader material) cannot have a shape anyway.
Water effects
The spray of the bodies in the water is three built-in effects, played by the app for each body that touches the water (World::step_water_effects, once a tick, only in a world with a renderer: a scene with no water, or nothing in it, has no instances, and nothing here feeds physics or gameplay). They are the V1 built-ins' kind (minified JSON compiled once, argus_particles::fx::builtin_water), with no textures.
| effect | for | emitters | params (set every tick) |
|---|---|---|---|
builtin:water-bow | a hull with a bow throwing spray (WaterInteractor::bow_spray) | port, starboard (streaks off the stem), mist | speed m/s through the water, intensity particles a second |
builtin:water-jet | a wet jet or propeller (plume) | plume (rooster tail), mist | speed, intensity |
builtin:water-splash | a slam or hard entry (splash) | crown droplets, mist, ripple (a child on the droplets' water collide hit) | strength m/s of the hit, footprint m (body radius); a burst of 16 to 64 on each slam |
intensity is the old sprite count per sample (10 sheets, 12 plume drops, times the spray share) per track sample, a sample each 0.6 m, so it grows with speed. An instance is made when its body first has something to throw, moves with it, goes quiet (rate 0) when the body stops throwing or leaves the water, and is released 2.5 s after it last worked. The ripple child and the droplets' landing need the vfx-collide build; without it the droplets fade out where their life ends and no ripples start.
A game replaces one by declaring an effect asset with the id water-bow, water-jet or water-splash (scene.assets). The replacement should declare the same param names; params it lacks are skipped silently, and the splash's crown and mist emitter names are the ones that receive the burst. The built-ins cannot be named from a particles component. The wake texture, the foam trails and the sea-crest spray are not effects.
Determinism and backends
The effect path uses a hash RNG (draw k of birth serial n: hash(seed ^ hash(n*16 + k))), a fixed draw table, slot-indexed pools and no per-tick random numbers, so a run repeats byte for byte and the CPU interpreter (crates/particles/src/fx) and the HD GPU simulation run the same per-slot algorithm (row: FxEmitterRow, 416 bytes, in crates/render/src/particles.rs). Presets on the CPU backend are the legacy emitters, bit-identical to before; tests/fx.rs compares their built-in effects to the legacy emitter (exact without scatter, statistical with it, and field by field for the GPU row).
GPU and draw (HD)
One compute shader (hd/transparency/shaders/particle_sim.wgsl) simulates every GPU pool, one thread per slot: the row's module_mask switches gravity, attractor, vortex, curl noise, wind, drag, speed limit and the age curves on (the order of integrate in fx/emitter.rs), a spawn is written by serial, and the row's RESET flag kills the pool first. Curves and gradients are one append-only LUT buffer shared by all effects. A legacy preset emitter is converted to the same row in the renderer (FxEmitterRow::from_legacy, the RAMP bit: size and colour ramp start to end), so HD has one simulation, one light pass and one draw. Every tick carries a row for every emitter (an idle one with no spawns), because the renderer lights and draws from the latest tick's rows alone.
Parity (crates/particles/tests/gpu.rs, --features hd, any adapter): for the same seed and ticks the CPU pool and the GPU pool read back agree. serial, tint, frame0 exactly; life, size0, rnd, drag_mul within 1e-6 relative, spin 1e-5, age 1e-5; position, velocity and rotation within 2e-3 (5e-3 with curl noise, for 99% of the slots). The GPU has its own sin, cos, pow and sqrt, and adds in a different order, so exact equality is not promised; llvmpipe measures 6e-7 to 2e-6.
The draw reads one 64-byte record per particle:
- Flipbook: the sheet's cell for
frame(row 0 on top); withblendthe next cell is mixed in by the fraction. Cells are not padded, so a filtered sheet bleeds a texel at cell edges. - Facing:
cameraas before;velocityturns the quad's up to the velocity on screen (stretched bystretch, a streak);uplies flat in the XZ plane;customuses the emitter'snormal. - Blend:
alpha;additive(emission, never lit,emissivescales it by 1 + emissive);premultiplied(the sheet's colour already carries its alpha: it is not multiplied by it again). - Light:
litsprites are lit once at their centre like presets; unlit ones are flat at the engine's unlit unit;emissiveadds that unit times its value to a non-additive sprite. - Soft:
soft.distancemetres per emitter (0 hard); omitted, the HD global soft fade applies. The standard renderer (and so the web runtime) fades a sprite the same way, against the scene depth behind it, with 0.3 m when the key is omitted (HD's default) under a perspective camera; an orthographic (2D) camera applies only an explicitsoft, since 2D layers sit closer together than that. A frame with a soft sprite ends its scene pass before the sprites and goes on with the depth read-only (an MSAA frame resolves its depth first), so it costs one more pass. A caller-owned depth that cannot be sampled draws the sprites hard. Ribbons are not faded on the standard renderer yet. - Max screen size:
maxScreen(sprites only) is the largest a sprite draws, as a share of the viewport height: a sprite whose longer side (afterstretch) would cover more is drawn that size, about its centre, on both renderers (0.25: never taller than a quarter of the screen, however near the camera). It changes only the drawing, never the simulation. Omitted: no limit, as before. A value outside (0, 4], or the key on a ribbon, isvfx-max-screen-invalid. Use it on bursts played near the camera (a hit on the player's own kart) so they never hide what they hit.
The standard (SD) renderer draws an effect's flipbook, its facing (velocity, up, custom) and its soft fade as HD does, from each particle's frame, velocity and depth. Not drawn yet: for CPU-simulated effect particles in HD (an emitter with a user-shader material) an effect is a plain camera-facing sprite with its size, colour, rotation and streak stretch; their flipbook, velocity facing and soft fade need a wider particle record and are left for V2.
Waterfall mist
The spray and mist at the foot of a waterfall (waterways component on a terrain, HD) are the built-in effect builtin:falls-mist, played by the app (World::step_falls_fx, crates/app/src/falls_fx.rs; only with the hd and terrain features and an HD renderer). Each fall gets a row of instances across its width (one per 10 m, at most 6, at most 8 falls), a little downstream of the plunge and turned to its direction (forward is -Z). They are made when the river set or the component's mist changes, and otherwise left alone: a steady scene pays one compare a tick, and a scene without falls (or without HD) has no instance. A change of the quality's water.mistPercent (Auto settling, a preset switch) only updates the intensity of the live instances, so the cloud does not regrow from empty; a mistPercent or mist of 0 has no instances at all. Nothing feeds physics or gameplay.
The mist runs on the simulation tick (step_falls_fx): a held or paused world shows no new mist, and a settings change applies on the next tick. The falls-mist override asset, like the water-bow, water-jet and water-splash ones, is loaded once and not hot-reloaded: change the file and reload the scene. A falls-mist asset that fails to load logs a warning once and the built-in effect is used.
| emitter | what |
|---|---|
spray | round droplets thrown up and out at a share of the impact speed, with gravity and drag |
mist | long-lived soft puffs that rise, drift downstream, swell and sway (curl noise) |
foot | the same, low and bigger: the churned cloud the curtain falls into |
| param | meaning (set by the app from the fall) |
|---|---|
intensity | rate scale of the row's instance: mistPercent/100 x min(mist, 1) x (0.4 + 0.6 power) split over the row |
strength | opacity: mist x (0.5 + 0.5 power) (0..2) |
puff | puff size, m (from the foam radius and width, 2 to 14) |
drop | droplet size scale (from the drop and the width, 0.5 to 2.5) |
lift | rise speed scale (drop / 20, 0.4 to 2) |
impact | the water's speed at the pool, m/s |
power is the fall's share of 500 kW (0.1 to 1). A game replaces the effect by declaring an effect asset with the id falls-mist (scene.assets); the replacement should declare the same param names (a missing one is skipped). The built-in cannot be named from a particles component.
Limits
1-8 emitters, 16 params, 8 bursts, 8 curve/gradient keys, 65536 particles per emitter and 131072 in all per effect (each takes 64 bytes while the instance lives), a file of at most 1 MiB, 12 modules, and a ribbon strip of at most its emitter's capacity points. A param value must be finite as a 32-bit float; a non-finite or negative step does nothing. Not in the format yet: per-particle trails, mesh particles, distortion, LOD and budgets, the editor preview, a user-shader material with an effect.
Rule codes
vfx-value-range, vfx-param-invalid, vfx-shape-invalid, vfx-max-screen-invalid, vfx-inherit-invalid, vfx-preset-invalid, vfx-collide-invalid, vfx-event-invalid, vfx-texture-invalid (the file), vfx-effect-missing and particles-source (the scene), plus schema-shape for a key or type the format does not have. Runtime: particles-param-unknown, particles-effect-unknown, particles-texture-missing, particles-collide-unavailable (docs/RUNTIME-CODES.md).
Export. An exported game carries each effect asset under its content hash, and with it every texture the effect names by a path from its file (a sprite's, a ribbon's, a decal's): each is packed once under its own hash beside the effect, and the packed effect names it so. A texture that is a scene asset id travels as that asset. An effect naming a texture that is not there refuses the export (vfx-texture-missing, with the effect's field), rather than shipping a game that draws the built-in sprite.