Talos docs

Particle effects (.vfx.json)

Since 0.7Changed in next

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. The vfx toolset (Reviewing an effect) adds what an agent needs on top. 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}, spline {points}, mesh {mesh, where}, image {texture, threshold, size} (see Shapes from data)
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, bend, soft {distance}, lit, emissive, maxScreen, minScreenPx, shade {wrap, backlight, thickness}} 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. In place of gradient, a temperature {from, to, ease} (kelvin, 500 to 12000; ease linear (default), easeIn, easeOut or smooth) gives a body cooling (or heating) over the life: its blackbody colour (Charity's table, from a 1000 K red to a 10000 K blue-white), times its brightness relative to the hotter end, (T / Tmax)^4. Flames and sparks that cool from yellow-white to a dim red come out right with no gradient to tune; tint and alpha still apply. It is baked like a gradient (one LUT), so it costs nothing more. Both keys at once is vfx-value-range
  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).
  • Ripples (Squall V4): "ripple": { "height": -0.15, "radius": 0.4 } on an event (with or without emit and decal; an event needs at least one of the three) rings the water where the particle died or hit: a bump of height metres (-1 to 1; negative presses the water down) over radius metres (0.05 to 4). It counts only where the hit is within half a metre of the water's surface and the scene has meta.environment.ripples (Water ripples); like decals it is HD only, and a GPU pool's hits arrive about two frames late. Splash drops with {"on": "death", "ripple": {}} make rain or spray land on a lake.
  • 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)
minScreenPxthe narrowest the strip draws, in pixels across, on an HD GPU pool (the standard renderer and a CPU pool ignore it): a strip of sparks thinner than a pixel breaks into dashes, and 1.5 keeps it one line with soft edges. Its colour is not dimmed. Omitted: no floor
facingcamera (the strip's flat side turns to the camera), up (lies flat, in the XZ plane), custom (turns to the emitter's normal)
trail{points, every, tailWidth, tailAlpha}: each particle draws its own strip instead (see Per-particle trails)

There is no flipbook, stretch or velocity facing on a ribbon (schema-shape), and a role: "child" emitter cannot be a ribbon through its particles (vfx-event-invalid: child slots are not in birth order); it may have a trail.

  • 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. For every spark leaving its own, give the ribbon a trail (below).
  • Cost. A ribbon point is a 64-byte pool record like a sprite plus one instance per segment; the row is 448 bytes (432 before trail, 416 before maxScreen, 400 before ribbons).

Per-particle trails

A ribbon with a trail draws one strip per particle, through the positions it has passed: sparks, embers, debris and tracers that each leave a streak, bending as their paths bend.

<!-- vfx-md:spark-trails -->

{
  "name": "spark-trails",
  "emitters": [ {
    "name": "sparks",
    "capacity": 64,
    "spawn": { "burst": [ { "time": 0, "count": 40 } ], "loop": false, "duration": 0.1 },
    "init": { "life": [0.4, 0.9], "speed": [4, 8], "size": 1, "direction": { "axis": [0, 1, 0], "spread": 50 } },
    "update": [ { "type": "gravity", "value": [0, -9.8, 0] }, { "type": "drag", "linear": 0.6 } ],
    "render": { "type": "ribbon", "width": 0.04, "blend": "additive",
                "trail": { "points": 8, "every": 0.02, "tailWidth": 0.2, "tailAlpha": 0 } }
  } ]
}
fieldmeaning
pointspositions each particle keeps, 2 to 16 (required); the strip is those, oldest first, then where the particle is now
everyseconds between samples (default 1/30, at most 10). The emitter's clock decides, so every particle of the emitter samples on the same ticks: a sample is taken on a tick that crosses a multiple of every
tailWidth, tailAlphathe oldest point's width and alpha as shares of the head's, 0 to 1 (default 0: the streak tapers and fades to nothing); linear between
  • A strip is points samples long at most, so its length is about points x every seconds of the particle's path. A newborn particle has one sample, where it is, and draws nothing until it has moved; a sample taken this tick is the head, drawn once.
  • Width and colour are the particle's (as for a ribbon through the pool: size x width x widthOverLife, the tint times colourOverLife, all at its age), times the taper along the strip. uv and tileLength apply per strip.
  • A role: "child" emitter may have a trail (each strip is one particle's, so birth order does not matter).
  • Draw and cost. Still one draw per emitter: each strip's first point is not linked to the strip before it. The history is points + 1 positions of 16 bytes per particle slot (12 + 4 on a CPU pool), kept only for emitters with a trail. On the GPU backend the simulation appends to a shared trail store (a header and a ring per slot, particle_sim.wgsl) and prepare_trails (particle_prepare.wgsl) writes points + 1 records per slot after the CPU ribbon points; a CPU pool builds the same points in fx/ribbon.rs (build_trails), and the two agree to float rounding (tests/gpu.rs). Without a trail there is no store, pass or record.

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)
minScreenPxthe narrowest a sprite draws, in pixels (see render.minScreenPx)
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, extend the preset. 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
builtin:fx/smoke-plumecontinuous: a rising, swelling column of lit smoke (a generated billow sheet, shaded toward the sun), drifting with the windintensity (0-2), size (0.2-4), tint; at most 145 live
builtin:fx/campfirecontinuous: flame tongues cooling from 1800 K (temperature), embers with trails on the updraft, dark shaded smoke aboveintensity (0-2), size (0.2-3); at most 160 live
builtin:fx/spark-showercontinuous: a grinder's shower of white-hot sparks cooling to red as they arc down, each with its own trailintensity (0-2), size (0.2-4); at most 140 live

Every preset draws built-in shapes, generated sheets or strips, so it needs no texture file. 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/.

Extending an effect

A file can name a parent and list only what differs. The child stays small, the diff of a change is the change itself, and a fix to the parent reaches every child:

<!-- vfx-md:tall-smoke -->

{
  "name": "tall-smoke",
  "extends": "builtin:fx/smoke-plume",
  "params": { "tint": "#505050" },
  "set": { "/emitters/@smoke/init/life": [6, 8], "/emitters/@smoke/capacity": 220 },
  "unset": ["/emitters/@smoke/render/shade"]
}
keymeaning
extendsthe parent: a preset, or a *.vfx.json path from this file (at most 4 levels; a loop is refused)
paramsvalues, each replacing that param's default
setJSON pointers into the parent to the value each takes. An emitter is @ and its name. The last key may be new; everything before it must be in the parent, so a typo is refused
unsetJSON pointers to keys of the parent to remove

name is the only other key. The child resolves to one plain file, which then passes every rule any file does; a value that breaks a rule is reported at the child's own key (/set/~1emitters~1@smoke~1capacity), and anything else wrong is vfx-extends-invalid. A texture path in a parent is read from the parent's folder wherever the child is. vfx diff compares the files as written. An export packs a child as one plain file, so the parents do not ship, and a parent that is not in the project stops the export. A change to a parent reaches its children when they next load: changing a child's parent does not hot-reload the child. The contract's validateVfx cannot read files, so with a file parent it checks the child's own keys; argus validate checks the whole.

A param may carry what an editor shows, none of which the run reads: label, group, unit and doc.

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.

Generated textures

"texture": "gen:billow", "gen:flame", "gen:wisp", "gen:cloud" or "gen:fireball" draws a sheet the engine makes at load from hash noise, so an effect needs no texture file: billow is a lumpy round puff (smoke, dust, steam clouds), flame a tongue of fire wide at the bottom, wisp thin curling strands (steam, mist, a smoke trail). # and a seed of 0 to 9999 picks another sheet of the same kind (gen:billow#3); two emitters with different seeds do not look alike. Anything else after gen: is vfx-texture-invalid.

Each sheet is 8 x 8 frames of 64 pixels (512 x 512, white with the shape in its alpha; the tint and colourOverLife colour it), and the frames loop: frame 64 is frame 0. On a sprite with no flipbook the sheet plays as { "cols": 8, "rows": 8, "blend": true } over each particle's life; give a flipbook (an fps, say) to play it otherwise. On a ribbon it is a plain texture (frame 0 across the strip). Every frame fades out before its edge, so cells do not bleed into each other. The sheets are the same bytes on every machine and are not files: export and validation leave them alone (crates/particles/src/fx/gen_tex.rs).

cloud and fireball carry their own picture in the colour, at 128 pixels a frame (1024 x 1024, 4 MB, and about a quarter of a second of the load). cloud is a lumpy mass with creases, shaded from the upper left: smoke with a body. Tint it, leave it lit, and give it "rotation": 0 so the light agrees across puffs. fireball is one blast played over a particle's life, from a white-hot core through orange to eroding soot, so it needs no colourOverLife. Draw it blended with an emissive of about 5 rather than additive, so near puffs hide far ones. Its frames do not loop.

<!-- vfx-md:gen-smoke -->

{
  "name": "chimney",
  "emitters": [ {
    "capacity": 128,
    "spawn": { "rate": 12 },
    "init": { "life": [2, 3], "speed": [0.4, 0.8], "size": [0.6, 1.0], "direction": { "axis": [0, 1, 0], "spread": 12 } },
    "update": [
      { "type": "wind", "scale": 0.6 },
      { "type": "sizeOverLife", "curve": { "preset": "easeOut", "from": 1, "to": 3 } },
      { "type": "colourOverLife", "gradient": [ { "t": 0, "colour": "#c8c4bc", "alpha": 0.7 }, { "t": 1, "colour": "#8a8780", "alpha": 0 } ] }
    ],
    "render": { "type": "sprite", "texture": "gen:billow#2" }
  } ]
}

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); velocityRelativeToCamera does the same with the camera's velocity taken off the particle's, and stretches by that relative speed: rain that streaks toward a fast car without flying backwards in the world (which would empty a camera-centred box). The camera's velocity is measured each tick before shake; a jump faster than 1000 m/s is a cut and counts as still; up lies flat in the XZ plane; custom uses the emitter's normal. Sprites only, not ribbons.
  • Stretch: stretch {bySpeed, max} multiplies the sprite's own height by 1 + bySpeed x speed (at most max). It lies along the motion only with facing: "velocity" (or "velocityRelativeToCamera"); with any other facing the sprite just grows along its own up, a bigger square or card rather than a streak. On HD an emitter that stays on a CPU pool (stateless, drawn as meshes or lights, shared, or in a gas field) draws camera-facing cards whatever its facing; only GPU-pool emitters streak along their motion there. Split views all streak against view 1's camera.
  • 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).
  • Distort: "blend": "distort" (HD, sprites on a GPU pool; the standard renderer draws nothing for it) is a lens. It draws no colour and bends what is already behind it: the scene and every other sprite, once they are drawn. The sprite is a height, its texture's alpha or, with a shape, a band along the shape's edge, and the picture moves up that height's slope: a soft sprite magnifies a little, like a drop of water, a ring that grows is a shock front, and a rising gen:wisp is heat haze. bend (0 to 0.25, default 0.03) is how far, as a share of the screen's height, at full alpha; colourOverLife alpha scales it, so a lens fades as a sprite does. Lenses that overlap add up. A frame with no lens has no copy and no pass, and nothing is made before the first lens; with one it costs one copy of the frame and one full-screen pass. bend without the blend, or the blend on a ribbon or an emitter that stays on the CPU, is vfx-distort-invalid.
  • 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.
  • Min screen size: minScreenPx (sprites only) is the narrowest a sprite draws, in pixels across: a sprite whose width (before stretch) would be fewer pixels is drawn that wide, about its centre, its streak keeping its ratio, on both renderers (2: a far spark stays a 2-pixel dot instead of shimmering out under one pixel). Its colour is not dimmed, so a far sprite reads brighter than its true size: that is the point, for sparks, glints and tracer dots that must read at range. maxScreen is applied after it and wins. It changes only the drawing, never the simulation. Omitted: no floor, as before. A value outside (0, 64] is vfx-min-screen-invalid. A ribbon takes it too (Ribbons).
  • Shade: shade {wrap, backlight, thickness} (sprites) lights a lit, blended sprite as a soft ball instead of flat, per pixel, on both renderers: smoke, dust and cloud puffs are brighter on the sun's side, glow at the rim when the sun is behind them, and hold the sun back in their thick middle. wrap (0 to 1, default 0.5) is how far the light wraps round the ball (0: a hard edge between lit and unlit); backlight (0 to 4, default 1) the bright rim against the sun; thickness (0 to 1, default 0.5) how much the core darkens away from the sun. {} takes the defaults. It is a factor on the colour the sprite was lit with (HD lights each particle once, at its centre, with the sun's shadow), applied to the sun's share of that light only, so ambient light and the shadow stay as they were. The standard renderer, which does not light sprites, applies it to the sprite's colour with the scene's sun and fill. It does nothing on an additive sprite (light, not lit), on "lit": false or with a built-in shape; a value out of range is vfx-value-range. Omitted: lit flat, as before, byte for byte.

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.

Water ripples

Squall V4 (HD, render only): a small wave simulation around the camera that bodies, splashes, effects and scripts disturb, so rings run out from where something touches the water and fade. A damped wave equation on a square of cells (default 256 of 0.25 m: 64 m across, 0.5 MB) centred on the eye and scrolled by whole cells as it moves, stepped at 60 Hz on the simulation clock (at most 4 steps a frame), with a sponge at its edges so rings leave instead of bouncing back (crates/render/src/hd/water/ripples.rs). Its height is added into the wake texture the water surfaces already read, so the sea, lakes and rivers rise and bend with it at no extra surface cost.

A scene turns them on with meta.environment.ripples (render only; never part of a replay or a physics hash):

"environment": { "ocean": { "seaLevel": 0 }, "ripples": {} }   // or { "cells": 256, "cellM": 0.25, "speedMps": 2, "damping": 0.6 }

cells 64..512, cellM 0.1..1, speedMps 0.1..20 and damping 0..4; outside is clamped with the warning ripples-out-of-range. Then every floating body (buoyancy) in the water presses on it as it moves and bobs, and a body slamming down drops a bump where it hit (crates/app/src/water_fx.rs, ripple_impulses); a scene without the key has no simulation at all. Scripts disturb it with ctx.water.poke(x, z, height?, radius?) (docs/API.md) and effects with a ripple event (Events); both reach the same simulation the next frame.

The renderer takes it as set_hd_water_ripples(Some(RippleSettings { cells, cell_m, speed, damping })) (off with None) and disturb_hd_water(&[RippleImpulse { x, z, height, radius }]): a smooth bump of height metres (negative presses the water down) over radius metres, at most 64 a frame. speed (m/s) is held below the step's stability limit; damping (per second, default 0.6) sets how long a ring lives, and once the last disturbance has died away (4.6 / damping seconds, 2 to 30) the field is cleared and the frame is the frame without ripples, byte for byte. Nothing is stepped or drawn while the water is undisturbed. Nothing feeds physics or gameplay.

Hull masks

A hull is rigid and the sea is a height field that knows nothing of it, so in a rough sea a crest comes up through the deck. A hullMask component holds the drawn water down inside a footprint that rides with the entity (render only; the wave and buoyancy queries, ctx.water and every physics result read the sea as it is):

{ "type": "hullMask", "center": [0, 0, 0.08], "halfLength": 3.45, "halfWidth": 1.4, "bowLength": 1.8, "bowWidth": 0.25,
  "edgeSoftness": 0.25, "capHeight": 0.15, "skirtWidth": 2.2, "skirtCap": 0.15 }

The footprint is a box along local Z with its bow toward -Z, the engine's forward, and sizes are in metres whatever the entity's scale. bowLength narrows the last stretch of it to a bowWidth share of its width at the bow's end (0: a plain box). center is in the entity's local space, and capHeight is how high the water may stand above the plane through center, tilted with the hull: a deck 40 cm above that point wants a cap below 0.4. Water under the cap stays exactly where it is. edgeSoftness blends the mask into the open water. halfLength 0.05..200, halfWidth 0.05..100, bowLength 0..400, bowWidth 0..1, edgeSoftness 0.01..10, capHeight and skirtCap -20..20 and skirtWidth 0..100; outside is clamped with the warning hull-mask-out-of-range. A hull rolled nearly onto its side holds nothing.

Over skirtWidth the held water climbs back to the open sea: the limit rises from skirtCap by the square of the way out, to 6 m above it at the outer edge, while the push fades to nothing there. So a crest 2 m over the cap on a 2.2 m skirt is cut fully at the hull, halfway at half strength under a limit 1.65 m up, and is left alone from about 80% of the way out. Water pressed flat is lit flat too. A taller crest over the same skirt makes a steeper slope, and skirtWidth 0 leaves a wall at the footprint's edge, so give a hull in rough water a skirt about as wide as its beam (default 2 m).

The mask takes the pose the hull's mesh is drawn with in the same frame, so it cannot lag the mesh at any speed. Any number of entities may carry one and the nearest to the camera are used.

  • HD moves the water's vertices (hull_held in crates/render/src/hd/water/shaders/surface.wgsl, the twin of HullMask::held in crates/render/src/hull_mask.rs), after the waves, wakes and ripples, on the sea and lakes, for the 16 nearest; rivers are not held. skirtWidth holds the water around the footprint as well, which stops a crest standing up beside the hull and hiding it. The first mask builds the water's wake pipelines in the background, so the water draws unmasked until they are ready. The cap is per vertex: on the coarse rings of a far hull the mask is as coarse as the mesh, and water can show at the deck's edges where a cell spans the footprint and the water climbing beyond it. Make the footprint reach the hull's outer edge.
  • Standard draws the sea as one flat plane, so there is nothing to push down: the plane is hidden inside the footprint wherever it stands above the hull's cap, for the 3 nearest (crates/render/src/water_stock.rs). There is no skirt, and skirtWidth and skirtCap do nothing.

Without a mask nothing changes: the frame is the frame of a scene that never had one, byte for byte.

The camera sees the held water too: a camera over the deck is in the air under a crest the hull holds down, with no underwater pass, waterline or lens drops, and in the water only where the held water really reaches it.

Wake settings

A hull in the water leaves a wake on its own: two Kelvin arms, a trail behind the stern and whitewater filling the young V. A waterWake component changes how it looks. Every field is optional; a field left out keeps the engine's value:

{ "type": "waterWake", "halfLength": 3.6, "halfWidth": 1.35, "armFoam": 0, "trailFoam": 1.2, "fillFoam": 0.5,
  "trailWidth": 1.1, "trailGrowth": 0.8, "trailMaxWidth": 8, "trailLife": 6, "fillLife": 2 }
FieldDefaultRangeWhat it does
strength10..1Scale on what the hull itself makes: the arms' waves and foam and the collar at its waterline. The trail is churn, from thrust and speed; trailFoam scales it
halfLength, halfWidthfrom the buoyancy probes, or 2.5 and 10.05..200, 0.05..100The hull's size at the waterline, metres
draft0.50.01..50With no buoyancy: how far the hull reaches below the entity's origin. The wake stops once the water has stayed below that for 0.6 s (a skip off a crest holds it)
armFoam, trailFoam, fillFoam10..4Scales on the white of the arms, the trail and the fill. At armFoam 0 the arms are waves with no white
trailWidth, trailGrowth, trailMaxWidth0.7, 0.35, 50..50, 0..50, 0..100The trail's half width at the stern, its growth each second and its limit, in hull half widths
armLife, trailLife, fillLife7, 5, 1.20.05..60Seconds each foam takes to fade to about a third

Outside its range a value is clamped with the warning water-wake-out-of-range. A track keeps 12 s and 256 samples, about 150 m of water, so a longer life thickens the foam but does not lengthen the wake. The standard renderer's foam puffs follow the same settings.

The component also gives a wake to a hull with no buoyancy, such as a boat a script floats itself: the wake takes the entity's pose and its body's velocity, or the distance it moved when its body reports none (a kinematic body, or an entity with no body). ctx.entity.setWaterWake feeds thrust, planing and the bow from the script (docs/API.md).

How white a wake reads is the sea's: meta.environment.ocean.look takes wakeFoamGain (default 1.2, 0..4), which is what a wake's foam adds to the sea's at full strength, and wakeFoamBreakup (default 0, 0..1), which breaks that foam into soft clumps with dark water between them, about wakeFoamBreakupSize metres across (default 4, 0.5..200) and twice that far from the camera, where the fine lace blurs to one flat value. HD only. Render only, like all of this: ctx.water and every physics result read the sea as it is.

The solved wake

solve turns the hull's waves from drawn arms into a real simulation (HD only). "solve": {} is on with the defaults below; with no solve it is off and costs nothing:

{ "type": "waterWake", "halfLength": 3.6, "halfWidth": 1.35, "armFoam": 0, "solve": { "strength": 3 } }

A wave equation runs at 60 steps a second of simulation time, in a window that reaches lead metres ahead of the hull and trails its course, turning at most 25 degrees a second. The hull is a moving pressure patch, sized by halfLength and halfWidth. The height joins the sea's and its slope the normal, and it replaces the height of the arms; all the foam stays, and is still set by the fields above.

Its GPU time is the pass water-wake-solve, present only on frames that step. To time it, compare crates/app/tests/scenes/watercraft-wake/solve.scene.json with trail.scene.json, the same scene less the solve: gpu_audit runs with scripts off, so a motor joint keeps the hull under way, and a hull at rest measures nothing. Going astern the hull stops pressing, the waves fade in about a second and it lays no new foam.

FieldDefaultRangeWhat it does
cells76864..1024Cells across the window, a multiple of 8. Two fields of 8 bytes a cell: 9.4 MB at 768
cellSize0.250.1..1Metres per cell (768 cells: 192 m across)
lead350..512Metres the window reaches ahead, at most half the window
depth0.550..2How far the hull presses the water down at speed, metres
planingDepth0.550..1The share of depth left on the plane
pulse0.60..1The share of the press that comes and goes, which spaces the crests along the V
waveSpeed0.330.05..0.95How fast the waves run as a share of the hull's speed: the V's half angle has this sine
minWaveSpeed1.50.1..20The slowest they run, m/s
damping0.99850.9..1The share of the height kept each step
sponge481..256Cells toward each edge that soak up the waves, at most a quarter of cells
strength30..4Scale on the height the water shows
clearAft40..20How far behind the transom the hull's own dip fades from view, metres: under the hull and this far aft the water shows no dip, so the hull sits in it, while crests still show. The waves still come from the dip. 0 shows all of it
transomRise00..1How high the water stands in a hump on the centreline just behind the transom on the plane, metres before strength. Off the plane the waves rise there by themselves, at about 10 to 16 knots most. 0 is none

One hull at a time gets it: the first entity in the water that asks, and it keeps it while it is in the water, even in a list too full for every body. Any other hull that asks keeps its drawn arms. While the hull is at rest nothing is stepped, and once its waves have died (51 s at the default damping) the field is cleared and the frame is as without it, byte for byte. The same course gives the same waves at any frame rate above 15 a second for a straight run at one speed, and close to them on a course that turns or changes speed within a frame (a slower frame drops time). Render only: ctx.water, buoyancy and every physics result read the sea without it. It reaches the screen through the wake texture, 256 m around the camera, so a camera far from the hull sees less of it.

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: a user-shader material with an effect.

Reviewing an effect

argus vfx report says what an effect does, measured, in a moment and with no GPU or project: it runs the CPU pool, which agrees with HD's GPU pool (tests/gpu.rs), and reads the result. The same effect and options give the same bytes.

argus vfx report effects/campfire.vfx.json --param intensity=2 --distances 10,30,120
argus vfx report builtin:fx/smoke-plume --json
readsmeaning
peak_live, capacity, peak_needlive at once, the pool, and the slots it can ask for: spawns per second at each param's maximum (perMetre at 30 m/s) times the longest life plus a tick, and its bursts
spawned, droppedwhat started and what was lost to a slot still alive. A dropped spawn draws nothing and says nothing, so an effect reads thinner than it was written
rise_m, spread_m, bounds, timelinewhere it goes: p95 height and spread from the instance, the box, and 16 samples of life and extent over the run
size_m, at_distancedrawn width in metres, and in pixels at each distance with minScreenPx and maxScreen applied, with the share of particles each one changed and the sprite area over the viewport (ribbons are not in the area)
peak_alphathe most opaque any particle gets: 0 draws nothing

Run length is long enough to see the effect whole, at most 30 s. An effect that spawns by distance runs at 10 m/s, or --speed. Params hold their defaults unless --param name=value sets them. Lints: vfx-capacity-short (warning; argus validate gives it too) when capacity is under peak_need; vfx-invisible (warning, or info when a param at zero is what holds spawning off) when nothing is alive or no alpha rises above 0; vfx-floor-inert (info) when minScreenPx never applies at any distance asked. --strict exits 1 on a warning. Exit 2 is a bad argument or an effect that does not compile. Collisions and wind are not in the run: there is no ground, water or air.

argus vfx sheet draws it: the effect on a stage the engine owns, at set moments and distances, as one labelled image, with no project and no scene to make. The stage is dark (shows light: fire, sparks, anything additive), bright (shows dark smoke) or sky, or void, black with no horizon, for an effect on its own; a 1 m red cube beside the effect gives the scale.

argus vfx sheet builtin:fx/campfire --out shots --stage dark --times 0.5,1,2,4 --distances 8,30
argus vfx sheet effects/smoke.vfx.json --out shots --sweep intensity=0.5,1,2 --hd

It writes sheet.png, each frame under frames/, and sheet.json with the effect's hash, the options and each frame's hash. The same effect, options and machine give the same frames. It uses the standard renderer unless --hd is given (a build with hd).

argus vfx clip films it: the same stage, with no cube, every frame of a run, so the movement can be judged and not only guessed at from stills.

argus vfx clip effects/campfire.vfx.json --out film --hd --seconds 8 --warmup 2 --distance 10 --orbit 6

It writes each frame under frames/, clip.json with the effect's hash and the options, and clip.mp4 where ffmpeg is on the path. --warmup runs the effect first, --orbit circles the camera in degrees a second from --angle, --dolly moves it in or out, and --look-y and --eye-y place it. The same effect, options and machine give the same frames.

argus vfx diff <a> <b> shows what changed between two effects: each field that differs, an emitter by name (/emitters/@smoke/init/life), then each number the report moved. With --out DIR it also draws both and writes diff.md and diff.png (before, after, the difference times eight). argus vfx baseline <effect> --dir DIR stores the report; argus vfx check <effect> --dir DIR [--tolerance 0.05] exits 1 when a number has moved past that share, and at any change in dropped spawns, capacity or memory. Those two read no GPU, so a gate can run them. A check also fails on a warning that was not there and on an emitter added or removed, and a baseline belongs to one effect: a second file of the same name in the same --dir is refused. An effect that spawns only as it moves has no sheet, since the stage stands still: vfx report runs it moving.

An agent has the same through the vfx toolset: vfx.describe (every setting, its range and what it does to the look), vfx.read, vfx.patch (RFC 6902 ops, emitters by name, all or nothing, refused if the result does not compile, with dryRun, answering with what moved), vfx.report, vfx.sheet and vfx.explain (plain reasons from the measurements, each with a fix).

Expressions

When no module does what you want, write it. An expr module runs small expressions on every particle, each tick, and attributes gives a particle up to four numbers of its own to keep between ticks.

"attributes": { "heat": { "init": "rand(0.5, 1)" } },
"update": [
  {
    "type": "expr",
    "ramp": [{ "t": 0, "colour": "#301000" }, { "t": 1, "colour": "#ffe0a0" }],
    "set": {
      "heat": "max(heat - dt * 0.4, 0)",
      "velocity.y": "velocity.y + heat * params.lift * dt",
      "colour": "ramp(heat)"
    }
  }
]

The language has no loops, no recursion and no shader text, so an expression cannot hang or crash a frame.

  • Reads: position, velocity (vectors, or .x, .y, .z of one), age, life, lifeT (0 to 1 over the life), size, rotation, rand (0 to 1, fixed for the particle's life), dt, time (the emitter's clock), your attributes, and params.<name>.
  • Writes (set): position, velocity (whole, or .x, .y, .z), size, rotation, colour, and your attributes.
  • Operators: + - * / and brackets. A vector works with a vector, and can be multiplied or divided by a number.
  • Functions on numbers: abs, clamp(x, lo, hi), cos, floor, fract, max, min, mix(a, b, t), pow, rand(a, b), sin, smoothstep(a, b, x), sqrt, step(edge, x).
  • Others: length(v) and noise(v) (from -1 to 1) take a vector. vec3(x, y, z) makes one. rgba(r, g, b, a) makes a colour, and ramp(t) reads the module's ramp gradient. A colour can only be written to colour.

What it does, exactly:

  • Expressions run after the tick's moves and spawns. A particle's init expressions run on the tick it is born and its set from the next one on, so its first move is before its first set.
  • Every expression in set reads the particle as it was, so the order of the keys does not matter. position.x and position cannot both be set.
  • rand(a, b) draws once per particle and per call, and stays the same for the particle's life.
  • A division by zero is 0, and a root of a negative number is 0. A result that is not a finite number leaves its target as it was.
  • size is the birth size: sizeOverLife still multiplies it. colour is the tint: colourOverLife still multiplies it. Its 8 bits a channel show if you add tiny steps to it, so compute the colour from an attribute instead of adding to it.
  • An init reads everything but your attributes.

An emitter may use 64 operations in all (each operator and each function call counts one) and 4 attributes. Mistakes are vfx-expr-invalid, with the pointer to the string and the column in the message. An emitter with expressions needs an expr module for its attributes to mean anything, so attributes alone is refused.

On the CPU pool expressions are interpreted, one small step per operation. On an HD pool the same steps are written out as a small compute shader that runs after the tick's simulation, one per distinct set of expressions, and the two agree to the same tolerances as the rest of the simulation. The shader builds in the background: until it is ready the emitter runs without its expressions and nothing stalls, so an effect's first moments can look plain the first time it plays in a run, and particles born meanwhile take their init when it is ready. A capture (--deterministic, argus vfx sheet) waits for it instead; a session that went deterministic for a remote player does not. On a CPU pool the cost is particles times operations each tick, on the sim's thread, so keep a big emitter's expressions short or give it an HD pool. Attributes take 16 bytes for every slot of the pool while any effect uses expressions.

Shapes from data

An emitter can spawn along a line, off a model or in the shape of a picture.

"shape": { "type": "spline", "points": [[0, 0, 0], [2, 1, 0], [4, 0, 1]] }
"shape": { "type": "mesh", "mesh": "models/hull.glb", "where": "surface" }
"shape": { "type": "image", "texture": "logo.png", "threshold": 0.5, "size": [4, 2] }
  • spline: a smooth line through 2 to 64 points, in metres from the emitter, spawning evenly along its length.
  • mesh: a scene asset id or a path from the effect file, a .glb. where is surface (evenly by area, the default) or vertices. Every mesh in the file is used, each in its own space: node transforms are not applied.
  • image: a scene asset id or a path from the effect file, read as a PNG or JPEG. A pixel takes part when its brightest channel times its alpha reaches threshold (default 0.5), and brighter pixels get more particles. The picture lies on the ground plane, size metres wide and deep, with its top edge towards -z.

Each shape bakes once, when the effect loads, to at most 4096 equally likely points, so a spawn costs one look-up on the CPU and the GPU and the two agree. init.direction.radial pushes particles out from the shape's centre: a mesh's surface normals are not kept. A file that cannot be read is particles-shape-missing at run time, and the emitter spawns at its own origin. vfx-source-missing reports it at validation and refuses the export, and the export packs the file with the effect.

An animated mesh spawns from its rest pose: skinned meshes are not sampled. A spline is written in the effect, and it does not follow a scene's road.

Gas

A particle is a spot; a fire or a column of smoke is a field. gas at the top of an effect gives it one: a square of cells facing the camera, where each particle gives heat and smoke, heat rises and curls, and the result draws as one sprite in place of the particles.

{
  "gas": { "cells": 96, "sizeM": 3, "buoyancy": 4, "vorticity": 0.5, "dissipation": 0.5, "cooling": 1.5,
           "ramp": [{ "t": 0, "colour": "#202020" }, { "t": 0.5, "colour": "#ff6020" }, { "t": 1, "colour": "#ffe0a0" }] },
  "emitters": [{ "capacity": 64, "spawn": { "rate": 40 }, "shape": { "type": "ring", "radius": 0.3 },
                 "init": { "life": 0.6, "speed": 0.3, "size": 0.4 }, "render": { "type": "sprite" } }]
}
KeyRange, defaultDoes
cells64 to 256Detail each way; the cost grows with its square
sizeM0.1 to 100The field's width and height, metres. The instance is the middle of its bottom edge
buoyancy0 to 50, 3How hard heat pushes up
vorticity0 to 4, 0.5How much small curl is kept: the billow
dissipation0 to 10, 0.5How fast smoke thins
cooling0 to 20, 1How fast heat fades
iterations4 to 40, 16Pressure steps: more holds the swirl truer
heat, smoke0 to 10, 1What each particle brings the gas under it up to. However many overlap, the source is that hot and no hotter
turbulence0 to 100, 20Random gusts, m/s²: what stops a column rising straight
glow0 to 20, 2What the picture is multiplied by: how much a flame outshines the scene
rampa gradientThe colour against heat, 0 to 1; its alpha thins the field. Absent: smoke, embers, flame

The field has a floor and is open on its other three sides, thinning out toward them so its square never shows. It solves on the CPU, 30 steps a second of simulation time, on the same step everywhere, so a run repeats and a bake shows what the live effect does. A 96-cell field costs about a millisecond of one CPU core a step and a 256-cell one about seven, its picture included: measure with /profile before shipping a big live field, and bake it instead. The field faces the camera. Particles in a field stay on a CPU pool, at most 4096 an emitter. Where nothing draws (a server, a headless run) the field still steps. It is flat: it does not wrap round what is in front of it. Mistakes are vfx-gas-invalid.

argus vfx bake <effect> --out fire.png [--frames 64] [--cell 128] runs the field offline and writes a looping flipbook, with its size and frame rate in fire.png.json. A flipbook plays anywhere, including the web and the standard renderer, at the cost of one sprite:

"render": { "type": "sprite", "texture": "fire.png", "flipbook": { "cols": 8, "rows": 8, "fps": 30, "blend": true } }

Shared effects

"shared": true at the top of an effect makes every ctx.particles.play of it add to one instance. Each play keeps its own position, so two hundred impacts a second are one instance, one draw and one pool, not two hundred instances.

{ "name": "hit", "shared": true, "emitters": [{ "capacity": 1024, "spawn": { "burst": [{ "time": 0, "count": 8 }] }, ... }] }

A shared effect's emitters spawn only by bursts at time 0, once each, with no delay, since a play is that burst somewhere else, and a child emitter works as usual. Its class's maxDistanceM is measured from each play, and a play past it does nothing. It stays on a CPU pool on every renderer, at most 4096 particles an emitter. Plays cannot differ, so play takes no params on one, and a play past the pool drops spawns as any full pool does: read dropped in vfx report. A particles component that names a shared effect plays it as any other effect. Mistakes are vfx-shared-invalid.

Stateless emitters

"stateless": true on an emitter keeps no pool and runs no simulation: each particle is worked out from the clock when the sprite list is built. Ambient dust or motes cost no particle memory and no step of their own.

A stateless emitter spawns at a constant rate and nothing else, holds at most 4096 particles, draws one sprite, and takes only gravity, drag (in closed form), sizeOverLife and colourOverLife. Its init is constant: a value bound to a param or a curve would change every particle alive. It has no events, is no child, and its particles start from where the instance is now, so they follow it. A class holds it by starting nothing new and thins it by serial. It is worked out on the CPU on every renderer, so HD draws it through the CPU's sprites. Its paths are exact where a pool's are stepped a tick at a time, so it does not match a pool to the bit. rotationOverLife is not in the list, because it would need the curve's integral per particle. Mistakes are vfx-stateless-invalid.

Meshes and lights

render may be a list of up to three renderers, at most one of them a sprite or ribbon, so one simulation can draw several ways: debris as real meshes, embers that also light what is around them.

"render": { "type": "mesh", "mesh": "rock", "material": "stone", "facing": "velocity", "scale": [1, 2, 1] }
"render": [ { "type": "sprite", "blend": "additive" }, { "type": "light", "colour": "#ff8040", "intensity": 2, "radius": 4, "max": 4, "share": 0.25 } ]
rendererfieldmeaning
meshmesha scene asset id of kind glb
materiala scene asset id of kind material; absent: the mesh's own
facingrandom (default): a fixed random turn, spun by rotation. velocity: Y along its motion. up: upright, turned by rotation
scaletimes the particle's size on each axis (default 1)
lightintensity, radiusrequired. The intensity fades with the particle's alpha
colourabsent: the particle's own colour
max, sharemost particles of the emitter lit at once (1 to 64, default 8), and the share of particles that are lights (default 1), chosen by each one's fixed random number

An emitter with one of these simulates on the CPU, on both renderers, so it holds at most 4096 particles. Its particles become ordinary mesh instances and point lights each frame: meshes are lit and shadowed like any mesh, and a light behaves like any light, on both renderers. The sim is drawing only. HD takes up to 64 particle lights a frame; the standard renderer takes 2, in whatever room its 8 lights leave after the scene's own. A render override on a preset does not reach them. A mesh that names no asset draws nothing and warns once.

Effect classes

A project says how effects give way under load. Without an effects block nothing is scaled and frames are as they were.

// argus.project.json
"effects": {
  "classes": {
    "hero":    { "maxInstances": 8,  "maxDistanceM": 400, "significance": 3 },
    "ambient": { "maxInstances": 64, "maxDistanceM": 120, "spawnScale": { "low": 0.25, "medium": 0.5 } },
    "impact":  { "maxInstances": 32, "maxDistanceM": 150, "significance": 2, "spawnScale": { "low": 0.5, "medium": 0.75 } }
  },
  "budgetMs": { "low": 0.5, "medium": 1, "high": 2, "ultra": 3 }
}

The names are the project's own. An effect file names its class with "class": "ambient"; a particles component's class is used over it. An effect with no class, or a class the project does not define, is left alone. The numbers above are an example and no defaults are shipped: a budget is the game's call.

fieldmeaning
maxDistanceMpast this distance from the view an instance spawns nothing and what is alive finishes; it comes back 10% inside that
maxInstancesmost instances of the class spawning at once. Over it the least significant wait (significance / (1 + distance), ties by key)
significancehow much it matters against distance (default 1)
spawnScaletimes spawn counts at each quality tier, 0 to 1 (default 1). The tier is HD's preset; the standard renderer counts as high
budgetMsGPU ms the particle passes may take at each tier (HD). Over it, spawn scale and distances ease down to at most a quarter, and come back when there is room

A held-off instance stops spawning rather than vanishing, so nothing pops. The rules run on the CPU, so both renderers follow them, and they are drawing only: nothing reaches physics, gameplay or a replay. The budget reads the clock, so a fixed-step run (a capture, a replay, vfx sheet) leaves it out and repeats exactly. GET /fx on the query API (and play.query for an agent) lists every live instance with its class, state, distance and live count, and how the budget stands.

Rule codes

vfx-value-range, vfx-param-invalid, vfx-extends-invalid, vfx-shape-invalid, vfx-max-screen-invalid, vfx-min-screen-invalid, vfx-distort-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.