# Particle effects (`*.vfx.json`)

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:

```json
"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](#presets), 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`):

```ts
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

```jsonc
{
  "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 -->
```json
{
  "name": "campfire",
  "params": {
    "intensity": { "type": "number", "default": 1, "min": 0, "max": 4 },
    "pull":      { "type": "vec3",   "default": [0, 2, 0] },
    "tint":      { "type": "colour", "default": "#ff8040" }
  },
  "emitters": [ {
    "name": "flames",
    "capacity": 512,
    "spawn": { "rate": { "param": "intensity", "scale": 40 } },
    "shape": { "type": "cone", "angle": 15, "radius": 0.3 },
    "init": { "life": [0.6, 1.2], "speed": [1, 2], "size": [0.2, 0.4], "colour": { "param": "tint" } },
    "update": [
      { "type": "attractor", "position": { "param": "pull" }, "strength": 2 },
      { "type": "sizeOverLife", "curve": { "preset": "easeOut", "from": 1, "to": 0 } },
      { "type": "colourOverLife", "gradient": [ { "t": 0, "colour": "#ffffff", "alpha": 1 }, { "t": 1, "colour": "#ff2000", "alpha": 0 } ] }
    ],
    "render": { "type": "sprite", "shape": "puff", "blend": "additive" }
  } ]
}
```

### Values and bindings

A **number** field takes a literal, or a **binding** `{ "param": "intensity", "scale": 2, "offset": 0 }`
that reads a number param (result `value * scale + offset`), or `{ "input": "speed" | "age" }`: `speed` is
how fast the instance itself moves (m/s), `age` the seconds since it started. A **vector** field takes
`[x, y, z]` (each a number or binding) or `{ "param": name }` of a vec3 param. A **colour** field takes
`[r, g, b]`, `[r, g, b, a]`, `"#rrggbb"` or `{ "param": name }` of a colour param. Binding the wrong
type of param is `vfx-param-invalid`.

An **init scalar** (`life`, `speed`, `size`, `rotation`, `angularSpeed`, `flipbookStart`, `drag`) is a
value, a `[min, max]` range drawn uniformly per particle, or `{ "curve": ..., "span": seconds }` sampled
at the emitter's age over `span` (default: the emitter's `spawn.duration`; required if it has none) and
held after.

### Curves and gradients

A **curve** is up to 8 keys `[[t, value], ...]` with `t` strictly increasing in 0..1 (held flat outside
the ends), or `{ "preset": "linear" | "easeIn" | "easeOut" | "smooth", "from": 0, "to": 1 }`. A
**gradient** is up to 8 keys `{ "t", "colour", "alpha" }`. Both are baked at load to 32 samples
(`t = i/31`, f64 then f32) in one arena shared by every effect; the CPU and GPU read the same samples with
the same linear interpolation, so they agree.

### Emitter

| field | meaning |
|---|---|
| `name` | for `burst(..., emitter)`; default `e<index>` |
| `capacity` | 1-65536 live particles (the pool size; a spawn into a pool slot still alive is dropped) |
| `space` | `local` (default): the shape and emission axis follow the instance's rotation; `world`: they stay world-axis. Particles always simulate in world space |
| `spawn` | `rate` /s, `burst` `[{time, count, cycles, interval}]` (at most 8; `cycles` 0 repeats forever), `perMetre` (per metre the instance moves, spread along the path), `duration`, `loop` (default true), `delay` |
| `shape` | `point` (default), `sphere {radius, surface}`, `box {size}` (full extents), `cone {angle, radius}` (base disc; its `angle` is the emission spread), `ring {radius}` |
| `init` | `life`, `speed`, `size` (required); `direction {axis, spread, radial}`, `rotation` (radians, default random), `angularSpeed`, `colour` (a tint or `[a, b]` mixed per particle), `flipbookStart`, `drag` (a multiplier on the `drag` module), `inherit` (below) |
| `update` | modules, each type at most once (below) |
| `render` | `sprite {texture, shape, band, rim {colour, width}, flipbook {cols, rows, fps, blend}, facing, normal, stretch {bySpeed, max}, blend, soft {distance}, lit, emissive, maxScreen}` or `ribbon {...}` (below); `shape` and the rest: [Shapes](#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

```json
{ "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:

```jsonc
"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`:

```jsonc
"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.

```jsonc
"render": { "type": "ribbon", "texture": "trail", "width": 0.3, "widthOverLife": [[0, 1], [1, 0]],
            "uv": "stretch", "tileLength": 1.0, "facing": "camera" }
```

| field | meaning |
|---|---|
| `texture`, `blend`, `soft`, `lit`, `emissive` | as for sprites |
| `width` | metres, times the particle's size (default 0.2; must be above 0) |
| `widthOverLife` | a curve over the particle's life, baked to the shared LUT like `sizeOverLife` (default constant 1) |
| `uv` | `stretch` (default): the texture's u runs 0 to 1 along each live run; `tile`: one repeat per `tileLength` metres of path |
| `tileLength` | metres per repeat with `tile` (default 1; must be above 0) |
| `facing` | `camera` (the strip's flat side turns to the camera), `up` (lies flat, in the XZ plane), `custom` (turns to the emitter's `normal`) |

There is no `flipbook`, `stretch` or `velocity` facing on a ribbon (`schema-shape`), and a `role: "child"` emitter cannot be a
ribbon (`vfx-event-invalid`: child slots are not in birth order).

- **Linking.** Slot `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 -->
```jsonc
// effects/land-puff.vfx.json
{ "name": "land-puff", "preset": "builtin:fx/puff-burst", "params": { "tint": "#D8B48A", "strength": 1.2 } }
```

`params` here are values, not definitions: each replaces that param's default. A file that names a preset
takes only `name`, `preset`, `params` and `render`; an unknown preset, a param the preset does not have or any other
key is `vfx-preset-invalid` (a value of the wrong type is refused as that param's default would be, at `/params/<name>/default`).
`render` is an optional override of the preset's sprites, written over **every** emitter's render block (a ribbon
emitter takes `soft`, `emissive` and `texture` only). It takes only these keys; any other is `vfx-preset-invalid`
at `/render/<key>` (the values are checked as in any render block, e.g. `vfx-max-screen-invalid`):

| key | meaning |
|---|---|
| `maxScreen` | the largest a sprite draws, a share of the viewport height (see `render.maxScreen`) |
| `soft` | `{ "distance": m }`, the depth fade against opaque geometry |
| `emissive` | the glow, replacing the preset's |
| `texture` | draw this texture instead of the preset's built-in shape (drops its `shape`, `band` and `rim`) |
| `shape` | draw this built-in shape instead (not with `texture`) |

<!-- vfx-md:land-puff-capped -->
```jsonc
{ "name": "land-puff", "preset": "builtin:fx/puff-burst",
  "params": { "tint": "#D8B48A" },
  "render": { "maxScreen": 0.2, "soft": { "distance": 0.5 }, "emissive": 0.2 } }
```

There is no opacity or fade override; to change blend, size or colour over life, copy the preset's file into the game. A script can
also play a preset directly: `ctx.particles.play("builtin:fx/confetti", at, { amount: 0.5 })`.

| preset | kind | params |
|---|---|---|
| `builtin:fx/sparks` | continuous: hot streaks (blades, additive), a few stars and an optional ground ring, e.g. drift sparks per tier | `intensity` (0-1.5), `size` (a tier's scale), `glow` (0-1: the ground ring), `tint`; at most 60 live per instance at 30 m/s |
| `builtin:fx/flame` | continuous: rimmed puffs fading from `core` to `tint` with a hot core streak, e.g. a boost or an exhaust | `intensity` (0-1.5), `size`, `tint`, `core`; at most 40 live |
| `builtin:fx/dust` | continuous, per wheel or foot: puffs per metre moved and per second of slip | `intensity` (0-1, per metre), `slip` (0-1, per second), `size`, `tint`, `shade`; at most 40 live per instance at 30 m/s |
| `builtin:fx/puff-burst` | one-shot: a ring of puffs and a few chips, e.g. a landing | `strength` (0-2: count and size), `tint`, `chips` |
| `builtin:fx/hit` | one-shot: a star flash, a ring pop, stars and flakes flung out, e.g. an item hit | `strength` (0-2: size and count), `tint`, `accent`; at most 80 live |
| `builtin:fx/confetti` | one-shot: 4 x 80 flakes in four colours that flutter down over 2.5 s | `amount` (0-1), `colour1`-`colour4` |

Every preset draws built-in [shapes](#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 -->
```json
{
  "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:

```json
"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 -->
```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.

| effect | for | emitters | params (set every tick) |
|---|---|---|---|
| `builtin:water-bow` | a hull with a bow throwing spray (`WaterInteractor::bow_spray`) | `port`, `starboard` (streaks off the stem), `mist` | `speed` m/s through the water, `intensity` particles a second |
| `builtin:water-jet` | a wet jet or propeller (`plume`) | `plume` (rooster tail), `mist` | `speed`, `intensity` |
| `builtin:water-splash` | a slam or hard entry (`splash`) | `crown` droplets, `mist`, `ripple` (a child on the droplets' water `collide` hit) | `strength` m/s of the hit, `footprint` m (body radius); a burst of 16 to 64 on each slam |

`intensity` is the old sprite count per sample (10 sheets, 12 plume drops, times the spray share) per track sample,
a sample each 0.6 m, so it grows with speed. An instance is made when its body first has something to throw,
moves with it, goes quiet (rate 0) when the body stops throwing or leaves the water, and is released 2.5 s after it
last worked. The ripple child and the droplets' landing need the `vfx-collide` build; without it the droplets
fade out where their life ends and no ripples start.

A game replaces one by declaring an `effect` asset with the id `water-bow`, `water-jet` or `water-splash`
(`scene.assets`). The replacement should declare the same param names; params it lacks are skipped silently, and
the splash's `crown` and `mist` emitter names are the ones that receive the burst. The built-ins cannot be named
from a `particles` component. The wake texture, the foam trails and the sea-crest spray are not effects.

## Determinism and backends

The effect path uses a hash RNG (draw `k` of birth serial `n`: `hash(seed ^ hash(n*16 + k))`), a fixed
draw table, slot-indexed pools and no per-tick random numbers, so a run repeats byte for byte and the CPU
interpreter (`crates/particles/src/fx`) and the HD GPU simulation run the same per-slot algorithm
(row: `FxEmitterRow`, 416 bytes, in `crates/render/src/particles.rs`). Presets on the CPU backend are the
legacy emitters, bit-identical to before; `tests/fx.rs` compares their built-in effects to the legacy
emitter (exact without scatter, statistical with it, and field by field for the GPU row).

## GPU and draw (HD)

One compute shader (`hd/transparency/shaders/particle_sim.wgsl`) simulates every GPU pool, one thread per
slot: the row's `module_mask` switches gravity, attractor, vortex, curl noise, wind, drag, speed limit and
the age curves on (the order of `integrate` in `fx/emitter.rs`), a spawn is written by serial, and the
row's RESET flag kills the pool first. Curves and gradients are one append-only LUT buffer shared by all
effects. A legacy preset emitter is converted to the same row in the renderer
(`FxEmitterRow::from_legacy`, the `RAMP` bit: size and colour ramp start to end), so HD has one simulation,
one light pass and one draw. Every tick carries a row for every emitter (an idle one with no spawns), because
the renderer lights and draws from the latest tick's rows alone.

Parity (`crates/particles/tests/gpu.rs`, `--features hd`, any adapter): for the same seed and ticks the
CPU pool and the GPU pool read back agree. `serial`, `tint`, `frame0` exactly; `life`, `size0`, `rnd`,
`drag_mul` within 1e-6 relative, `spin` 1e-5, `age` 1e-5; position, velocity and rotation within 2e-3
(5e-3 with curl noise, for 99% of the slots). The GPU has its own `sin`, `cos`, `pow` and `sqrt`, and adds
in a different order, so exact equality is not promised; llvmpipe measures 6e-7 to 2e-6.

The draw reads one 64-byte record per particle:
- **Flipbook**: the sheet's cell for `frame` (row 0 on top); 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.

| emitter | what |
|---|---|
| `spray` | round droplets thrown up and out at a share of the impact speed, with gravity and drag |
| `mist` | long-lived soft puffs that rise, drift downstream, swell and sway (curl noise) |
| `foot` | the same, low and bigger: the churned cloud the curtain falls into |

| param | meaning (set by the app from the fall) |
|---|---|
| `intensity` | rate scale of the row's instance: `mistPercent/100 x min(mist, 1) x (0.4 + 0.6 power)` split over the row |
| `strength` | opacity: `mist x (0.5 + 0.5 power)` (0..2) |
| `puff` | puff size, m (from the foam radius and width, 2 to 14) |
| `drop` | droplet size scale (from the drop and the width, 0.5 to 2.5) |
| `lift` | rise speed scale (drop / 20, 0.4 to 2) |
| `impact` | the water's speed at the pool, m/s |

`power` is the fall's share of 500 kW (0.1 to 1). A game replaces the effect by declaring an `effect` asset with the
id `falls-mist` (`scene.assets`); the replacement should declare the same param names (a missing one is skipped).
The built-in cannot be named from a `particles` component.

## Limits

1-8 emitters, 16 params, 8 bursts, 8 curve/gradient keys, 65536 particles per emitter and 131072 in all per effect (each takes 64 bytes while the instance lives), a file of at most 1 MiB, 12 modules, and a ribbon strip of at most its emitter's `capacity` points. A param value must be finite as a 32-bit float; a non-finite or negative step does nothing. Not in
the format yet: per-particle trails, mesh particles, distortion, LOD and budgets,
the editor preview, a user-shader `material` with an effect.

## Rule codes

`vfx-value-range`, `vfx-param-invalid`, `vfx-shape-invalid`, `vfx-max-screen-invalid`, `vfx-inherit-invalid`, `vfx-preset-invalid`, `vfx-collide-invalid`, `vfx-event-invalid`, `vfx-texture-invalid` (the file), `vfx-effect-missing` and
`particles-source` (the scene), plus `schema-shape` for a key or type the format does not have. Runtime:
`particles-param-unknown`, `particles-effect-unknown`, `particles-texture-missing`, `particles-collide-unavailable` (docs/RUNTIME-CODES.md).

**Export.** An exported game carries each effect asset under its content hash, and with it every
texture the effect names by a path from its file (a sprite's, a ribbon's, a decal's): each is packed
once under its own hash beside the effect, and the packed effect names it so. A texture that is a scene
asset id travels as that asset. An effect naming a texture that is not there refuses the export
(`vfx-texture-missing`, with the effect's field), rather than shipping a game that draws the built-in sprite.

