Talos docs

Particle effects (.vfx.json)

Since 0.7

An effect is a file of one to eight emitters.

Plain text

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

fieldmeaning
namefor burst(..., emitter); default e<index>
capacity1-65536 live particles (the pool size; a spawn into a pool slot still alive is dropped)
spacelocal (default): the shape and emission axis follow the instance's rotation; world: they stay world-axis. Particles always simulate in world space
spawnrate /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
shapepoint (default), sphere {radius, surface}, box {size} (full extents), cone {angle, radius} (base disc; its angle is the emission spread), ring {radius}
initlife, 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)
updatemodules, each type at most once (below)
rendersprite {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:

  1. gravity {value} (m/s^2)
  2. attractor {position, strength, falloff} (position in the instance's frame; pull strength / (1 + falloff r^2))
  3. vortex {axis, strength} (tangential acceleration around the axis through the instance origin)
  4. curlNoise {strength, frequency, speed} (divergence-free noise, hash based, no transcendentals)
  5. wind {scale}: velocity relaxes toward the scene's wind (meta.environment.air) at scale per second; the app samples the air once per instance per tick at its origin
  6. drag {linear}: v *= 1 / (1 + linear * dragMultiplier * dt)
  7. speedLimit {max}
  8. collide {with, response, bounce, friction, radius} (V2, see Collision below)
  9. sizeOverLife {curve, scale}: multiplies the initial size
  10. colourOverLife {gradient, alpha}: multiplies the tint; alpha scales opacity
  11. rotationOverLife {curve, scale}: extra rad/s
  12. stretchOverLife {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, a kill collision included) or collision (it needs the emitter's collide module). count children 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-invalid otherwise).
  • Slots and timing. The children of the parent particle in slot i go to child slots i * count + k, with birth serial parent serial * count + k, so the child's capacity must be at least the parent's times count. 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, decal or both (vfx-event-invalid if neither). texture is a scene asset id of kind texture or a path from the effect file, like a sprite's (vfx-texture-invalid); size is [width, length] in metres, both above 0; life seconds above 0; fadeOut (default 0) from 0 to life: the decal's opacity falls to 0 over the last fadeOut seconds; 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 are vfx-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 collision and flat (straight down) for a death. 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 its life ends. 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 decal event 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" }
fieldmeaning
texture, blend, soft, lit, emissiveas for sprites
widthmetres, times the particle's size (default 0.2; must be above 0)
widthOverLifea curve over the particle's life, baked to the shared LUT like sizeOverLife (default constant 1)
uvstretch (default): the texture's u runs 0 to 1 along each live run; tile: one repeat per tileLength metres of path
tileLengthmetres per repeat with tile (default 1; must be above 0)
facingcamera (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 s holds serial s % 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 1 stretch coordinate and starts tile at 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 its sizeOverLife; colour is the tint times colourOverLife. 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 in fx/ribbon.rs (ParticleSystem::ribbon_points, Renderer::set_ribbons); the two agree to float rounding (tests/gpu.rs).
  • Trails. spawn.perMetre gives an even trail behind a moving instance (even to within one tick's step); rate gives 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):

keymeaning
maxScreenthe largest a sprite draws, a share of the viewport height (see render.maxScreen)
soft{ "distance": m }, the depth fade against opaque geometry
emissivethe glow, replacing the preset's
texturedraw this texture instead of the preset's built-in shape (drops its shape, band and rim)
shapedraw 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 }).

presetkindparams
builtin:fx/sparkscontinuous: hot streaks (blades, additive), a few stars and an optional ground ring, e.g. drift sparks per tierintensity (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/flamecontinuous: rimmed puffs fading from core to tint with a hot core streak, e.g. a boost or an exhaustintensity (0-1.5), size, tint, core; at most 40 live
builtin:fx/dustcontinuous, per wheel or foot: puffs per metre moved and per second of slipintensity (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-burstone-shot: a ring of puffs and a few chips, e.g. a landingstrength (0-2: count and size), tint, chips
builtin:fx/hitone-shot: a star flash, a ring pop, stars and flakes flung out, e.g. an item hitstrength (0-2: size and count), tint, accent; at most 80 live
builtin:fx/confettione-shot: 4 x 80 flakes in four colours that flutter down over 2.5 samount (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's peakSlipAngle it no longer gains grip and slides. Set PEAK to your vehicle's (tyres.peakSlipAngle: 0.12 on the kart preset, 0.14 to 0.16 on the car presets), and change the / PEAK to make the smoke come on sooner or later. slip.long (wheelspin and locked brakes) is 0 on a clamp tyre such as the kart's; on a curve tyre add Math.abs(w.slip.long) against its peakSlipRatio the 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 (max 256 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; perMetre spreads them evenly along the path, so it is as dense at 30 m/s as at 10. For a car, scale size and the decal's size up 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.

effectforemittersparams (set every tick)
builtin:water-bowa hull with a bow throwing spray (WaterInteractor::bow_spray)port, starboard (streaks off the stem), mistspeed m/s through the water, intensity particles a second
builtin:water-jeta wet jet or propeller (plume)plume (rooster tail), mistspeed, intensity
builtin:water-splasha 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); with blend the next cell is mixed in by the fraction. Cells are not padded, so a filtered sheet bleeds a texel at cell edges.
  • Facing: camera as before; velocity turns the quad's up to the velocity on screen (stretched by stretch, a streak); up lies flat in the XZ plane; custom uses the emitter's normal.
  • Blend: alpha; additive (emission, never lit, emissive scales it by 1 + emissive); premultiplied (the sheet's colour already carries its alpha: it is not multiplied by it again).
  • Light: lit sprites are lit once at their centre like presets; unlit ones are flat at the engine's unlit unit; emissive adds that unit times its value to a non-additive sprite.
  • Soft: soft.distance metres 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 explicit soft, 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 (after stretch) 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, is vfx-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.

emitterwhat
sprayround droplets thrown up and out at a share of the impact speed, with gravity and drag
mistlong-lived soft puffs that rise, drift downstream, swell and sway (curl noise)
footthe same, low and bigger: the churned cloud the curtain falls into
parammeaning (set by the app from the fall)
intensityrate scale of the row's instance: mistPercent/100 x min(mist, 1) x (0.4 + 0.6 power) split over the row
strengthopacity: mist x (0.5 + 0.5 power) (0..2)
puffpuff size, m (from the foam radius and width, 2 to 14)
dropdroplet size scale (from the drop and the width, 0.5 to 2.5)
liftrise speed scale (drop / 20, 0.4 to 2)
impactthe 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.