# 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. The `vfx` toolset ([Reviewing an effect](#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:

```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}`, `spline {points}`, `mesh {mesh, where}`, `image {texture, threshold, size}` (see [Shapes from data](#shapes-from-data)) |
| `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, bend, soft {distance}, lit, emissive, maxScreen, minScreenPx, shade {wrap, backlight, thickness}}` 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. 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

```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).
- **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](#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`:

```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) |
| `minScreenPx` | the 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 |
| `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`) |
| `trail` | `{points, every, tailWidth, tailAlpha}`: each particle draws its own strip instead (see [Per-particle trails](#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 -->
```json
{
  "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 } }
  } ]
}
```

| field | meaning |
|---|---|
| `points` | positions each particle keeps, 2 to 16 (required); the strip is those, oldest first, then where the particle is now |
| `every` | seconds 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`, `tailAlpha` | the 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 -->
```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`) |
| `minScreenPx` | the narrowest a sprite draws, in pixels (see `render.minScreenPx`) |
| `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, [extend](#extending-an-effect) the preset. 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` |
| `builtin:fx/smoke-plume` | continuous: a rising, swelling column of lit smoke (a [generated](#generated-textures) billow sheet, shaded toward the sun), drifting with the wind | `intensity` (0-2), `size` (0.2-4), `tint`; at most 145 live |
| `builtin:fx/campfire` | continuous: flame tongues cooling from 1800 K ([temperature](#update-modules)), embers with [trails](#per-particle-trails) on the updraft, dark shaded smoke above | `intensity` (0-2), `size` (0.2-3); at most 160 live |
| `builtin:fx/spark-shower` | continuous: a grinder's shower of white-hot sparks cooling to red as they arc down, each with its own trail | `intensity` (0-2), `size` (0.2-4); at most 140 live |

Every preset draws built-in [shapes](#shapes), [generated sheets](#generated-textures) 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 -->
```json
{
  "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"]
}
```

| key | meaning |
|---|---|
| `extends` | the parent: a preset, or a `*.vfx.json` path from this file (at most 4 levels; a loop is refused) |
| `params` | values, each replacing that param's default |
| `set` | JSON 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 |
| `unset` | JSON 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 -->
```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.

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

| 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); `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](#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):

```jsonc
"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](#events-and-child-emitters)); 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):

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

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

| Field | Default | Range | What it does |
|---|---|---|---|
| `strength` | 1 | 0..1 | Scale 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`, `halfWidth` | from the `buoyancy` probes, or 2.5 and 1 | 0.05..200, 0.05..100 | The hull's size at the waterline, metres |
| `draft` | 0.5 | 0.01..50 | With 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`, `fillFoam` | 1 | 0..4 | Scales on the white of the arms, the trail and the fill. At `armFoam` 0 the arms are waves with no white |
| `trailWidth`, `trailGrowth`, `trailMaxWidth` | 0.7, 0.35, 5 | 0..50, 0..50, 0..100 | The trail's half width at the stern, its growth each second and its limit, in hull half widths |
| `armLife`, `trailLife`, `fillLife` | 7, 5, 1.2 | 0.05..60 | Seconds 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:

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

| Field | Default | Range | What it does |
|---|---|---|---|
| `cells` | 768 | 64..1024 | Cells across the window, a multiple of 8. Two fields of 8 bytes a cell: 9.4 MB at 768 |
| `cellSize` | 0.25 | 0.1..1 | Metres per cell (768 cells: 192 m across) |
| `lead` | 35 | 0..512 | Metres the window reaches ahead, at most half the window |
| `depth` | 0.55 | 0..2 | How far the hull presses the water down at speed, metres |
| `planingDepth` | 0.55 | 0..1 | The share of `depth` left on the plane |
| `pulse` | 0.6 | 0..1 | The share of the press that comes and goes, which spaces the crests along the V |
| `waveSpeed` | 0.33 | 0.05..0.95 | How fast the waves run as a share of the hull's speed: the V's half angle has this sine |
| `minWaveSpeed` | 1.5 | 0.1..20 | The slowest they run, m/s |
| `damping` | 0.9985 | 0.9..1 | The share of the height kept each step |
| `sponge` | 48 | 1..256 | Cells toward each edge that soak up the waves, at most a quarter of `cells` |
| `strength` | 3 | 0..4 | Scale on the height the water shows |
| `clearAft` | 4 | 0..20 | How 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 |
| `transomRise` | 0 | 0..1 | How 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.

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

```text
argus vfx report effects/campfire.vfx.json --param intensity=2 --distances 10,30,120
argus vfx report builtin:fx/smoke-plume --json
```

| reads | meaning |
|---|---|
| `peak_live`, `capacity`, `peak_need` | live 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`, `dropped` | what 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`, `timeline` | where it goes: p95 height and spread from the instance, the box, and 16 samples of life and extent over the run |
| `size_m`, `at_distance` | drawn 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_alpha` | the 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.

```text
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.

```text
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.

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

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

```jsonc
{
  "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" } }]
}
```

| Key | Range, default | Does |
|---|---|---|
| `cells` | 64 to 256 | Detail each way; the cost grows with its square |
| `sizeM` | 0.1 to 100 | The field's width and height, metres. The instance is the middle of its bottom edge |
| `buoyancy` | 0 to 50, 3 | How hard heat pushes up |
| `vorticity` | 0 to 4, 0.5 | How much small curl is kept: the billow |
| `dissipation` | 0 to 10, 0.5 | How fast smoke thins |
| `cooling` | 0 to 20, 1 | How fast heat fades |
| `iterations` | 4 to 40, 16 | Pressure steps: more holds the swirl truer |
| `heat`, `smoke` | 0 to 10, 1 | What each particle brings the gas under it up to. However many overlap, the source is that hot and no hotter |
| `turbulence` | 0 to 100, 20 | Random gusts, m/s²: what stops a column rising straight |
| `glow` | 0 to 20, 2 | What the picture is multiplied by: how much a flame outshines the scene |
| `ramp` | a gradient | The 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:

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

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

```jsonc
"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 } ]
```

| renderer | field | meaning |
|---|---|---|
| `mesh` | `mesh` | a scene asset id of kind glb |
| | `material` | a scene asset id of kind material; absent: the mesh's own |
| | `facing` | `random` (default): a fixed random turn, spun by `rotation`. `velocity`: Y along its motion. `up`: upright, turned by `rotation` |
| | `scale` | times the particle's size on each axis (default 1) |
| `light` | `intensity`, `radius` | required. The intensity fades with the particle's alpha |
| | `colour` | absent: the particle's own colour |
| | `max`, `share` | most 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.

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

| field | meaning |
|---|---|
| `maxDistanceM` | past this distance from the view an instance spawns nothing and what is alive finishes; it comes back 10% inside that |
| `maxInstances` | most instances of the class spawning at once. Over it the least significant wait (`significance / (1 + distance)`, ties by key) |
| `significance` | how much it matters against distance (default 1) |
| `spawnScale` | times spawn counts at each quality tier, 0 to 1 (default 1). The tier is HD's preset; the standard renderer counts as `high` |
| `budgetMs` | GPU 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.

