# Shaders

A **shader file** is a `*.shader.wgsl` (asset kind `shader`): a few `//!` header lines, then plain WGSL with a
`surface()` function. A material file names it and sets its `params`. You write the look of a surface; the engine
writes everything else (bindings, transforms, skinning, shadows, batching, pipelines, caching, fallbacks).

This page is the whole shader system, example first. Sections 1 and 2 are all most agents need; the rest is
reference. Every WGSL block and every material JSON block on this page is compiled by a test
(`crates/shader/tests/compile`, `every_example_on_the_shaders_page_compiles`), so a block that is here works.

1. [Quick start](#1-quick-start)
2. [Which kind of file](#2-which-kind-of-file)
3. [The file and its header](#3-the-file-and-its-header)
4. [What a shader sees and returns](#4-what-a-shader-sees-and-returns)
5. [Models, blend modes and the other keys](#5-models-blend-modes-and-the-other-keys)
6. [The prelude](#6-the-prelude)
7. [Limits, the sandbox and cost](#7-limits-the-sandbox-and-cost)
8. [Errors and codes](#8-errors-and-codes)
9. [Materials](#9-materials)
10. [Particles](#10-particles)
11. [Screen effects (`kind: post`)](#11-screen-effects-kind-post)
12. [Escape hatch (`kind: raw`, `kind: pass`)](#12-escape-hatch-kind-raw-kind-pass)
13. [Editing live: hot reload and the editor](#13-editing-live-hot-reload-and-the-editor)
14. [Agent tools and the critic](#14-agent-tools-and-the-critic)
15. [How it runs, export and pre-warm](#15-how-it-runs-export-and-pre-warm)
16. [Measured numbers](#16-measured-numbers)
17. [Renderers and platforms](#17-renderers-and-platforms)
18. [Examples](#18-examples)

## 1. Quick start

Write the shader, write a material that names it, done. No pipeline, no binding, no compile step to run.

`shaders/pulse.shader.wgsl`

```wgsl
//! model: unlit
//! blend: add
//! param tint: color = (0.2, 0.9, 1.0)
//! param speed: f32 = 2.0 [0, 20]
fn surface(in: SurfaceInput) -> SurfaceOutput {
  var s = surface_default();
  let pulse = 0.5 + 0.5 * sin(in.time * params.speed);
  s.emission = params.tint * pulse * fresnel(in.normal, in.viewDir, 2.0);
  return s;
}
```

`pulse.material.json`

```json
{ "shader": "shaders/pulse.shader.wgsl", "params": { "tint": [1.0, 0.4, 0.1] } }
```

Put the material on a mesh like any other material. That is the whole loop.

- With the `shaders` toolset, `shader.write {path, source, material?}` checks the source and writes it only if it
  passes; mistakes come back as `{code, line, col, message, hint}` in **your own line numbers** (section 14).
- Edit the file and the next frame shows it (section 13). A mistake keeps the last good version drawing.
- Changing a param is a buffer write, not a compile. 2,000 materials of one look are one shader and one pipeline.
- Vary a look with `param`s (per material) and `instance` floats (per entity, section 9), not with copies of the file.
- Start from a surface shader. Reach for `kind: post` (section 11) only when the effect is the whole screen, and for
  the escape hatch (section 12) only when nothing else can say it.

## 2. Which kind of file

| you want | write | draws | section |
|---|---|---|---|
| a look for a mesh (toon, glow, water, dissolve, wind) | `*.shader.wgsl` with `fn surface`, named by a `.material.json` | the standard renderer today (HD: section 17) | 3 to 9 |
| the same look on particle sprites | the same file, `blend: add` or `blend: blend`, named by a particles component's `material` | both renderers (HD: section 17) | 10 |
| an effect over the whole screen (vignette, posterize, outline) | `//! kind: post` with `fn screen`, listed in `meta.render.screenEffects` | the standard renderer, none by default | 11 |
| something the surface function cannot say | `//! kind: raw` or `kind: pass`, off unless `render.shaders.raw` | the standard renderer, through its Rust API (section 12) | 12 |
| car paint, wheels, tyres | no shader: a material file with a car preset | the engine's own stock module (section 9) | 9 |

## 3. The file and its header

| function | required | runs | returns |
|---|---|---|---|
| `fn surface(in: SurfaceInput) -> SurfaceOutput` | yes | per pixel | the surface |
| `fn vertex(in: VertexInput) -> vec3f` | no | per vertex | a world-space offset in metres, clamped to `bounds` |
| `fn light(p: ShadePoint, l: Light) -> vec3f` and `fn ambient(p: ShadePoint, irradiance: vec3f) -> vec3f` | only with `model: stylized` | per light; the engine owns the loop | a colour |

The header is one item per line, before the first line of WGSL. Blank lines and ordinary `//` comments may sit
among them; a trailing `// comment` ends a line.

```
//! model: lit | unlit | stylized         (default lit)
//! blend: opaque | mask | blend | add    (default opaque)
//! reads: depth                          (scene depth behind a blended surface)
//! bounds: 0.3                           (metres, above 0; required with fn vertex(), and only then)
//! tonemap: on | off                     (unlit only; default on)
//! param <name>: f32|vec2f|vec3f|vec4f|color|bool|i32 = <default> [min, max]
//! instance <name>: f32 = <default> [min, max]
//! texture <name>: color|data|normal = "white"|"black"|"flat"|"builtin:noise"|"<path>"
//! texture <name>: array(color|data|normal) = <default>     (an array slot: a list of layers per material)
//! drawTexture <name>: color|data|normal = <default>         (bound per draw; at most 2, beside the 6 textures)
//! paramBytes: <n>                       (stock modules only: 256 < n <= 2048, a multiple of 16)
//! costCap: <units> <fetches>            (stock modules only: this module's own hard cost caps)
//! kind: surface | post | raw | pass     (default surface)
//! slot: beforeTonemap | afterTonemap    (post only, default beforeTonemap)
//! renderers: standard, hd               (raw and pass only; required)
//! targets: desktop, web, mobile         (raw and pass only; required)
//! at: afterOpaque | beforeTonemap | afterTonemap | overlay   (pass only; required)
//! input: color | depth | color, depth   (a fragment pass only)
```

- A default is typed: `1.5`; `(0.9, 0.35, 0.3)` for a vector or a `color` (linear); `true`; a whole number for
  `i32`. `[min, max]` is optional (not on `bool`) and the default must lie inside it.
- Names are camelCase and unique across params, instances, textures and draw textures. A texture path is relative to the shader
  file (no leading slash, drive or `..`).
- From the header the engine generates `params.<name>`, `instance.<name>` and `tex_<name>(uv) -> vec4f`
  (`tex_<name>_lod(uv, lod)` in `vertex()`). Textures exist only through `tex_*`; there are no raw handles.
  The accessors of each kind of slot (WGSL has no overloading, so a slot has one set of signatures):

  | slot | accessors |
  |---|---|
  | `texture <name>: color\|data\|normal` | `tex_<name>(uv: vec2f) -> vec4f`, `tex_<name>_lod(uv, lod: f32)`, `tex_<name>_grad(uv, ddx: vec2f, ddy: vec2f)`, `tex_<name>_load(texel: vec2i)` (`data` only), `tex_<name>_size() -> vec2u` |
  | `texture <name>: array(color\|data\|normal)` | `tex_<name>(uv, layer: u32)`, `tex_<name>_lod(uv, lod, layer)`, `tex_<name>_grad(uv, ddx, ddy, layer)`, `tex_<name>_load(texel, layer)` (`data` only), `tex_<name>_size() -> vec2u`, `tex_<name>_layers() -> u32` |
  | `drawTexture <name>: color\|data\|normal` | as a plain slot, bound per draw (section 9) |

  `_load` reads the exact texel of level 0, no filtering, no sampler, the texel clamped to the edge (for per-texel
  data such as a weight map); on a `color` or `normal` slot it is a compile error naming the rule. `_size` is level
  0's size in texels (an array's is its first layer's; every layer has it). `_layers` is the material's layer count;
  a `layer` at or past it reads the last layer, never out of bounds. `_grad` takes explicit gradients and works in any
  function (`vertex()` included). Only the accessors your code names are generated, so a shader that uses none of
  these compiles to exactly the module it always did. A `data` (and `normal`) slot or array is stored as linear
  RGBA8 (`Rgba8Unorm`, never sRGB, not block-compressed), so `_load` returns the file's bytes divided by 255 exactly;
  a `color` slot is sRGB and reads linear.
- `model: stylized` needs both `fn light(` and `fn ambient(`, and they are used only with it. `tonemap` is only
  for `unlit`.
- `param` is per material (every entity wearing the material shares it); `instance` is per entity, up to 4 floats,
  set by a behaviour without making a new material or batch (`ctx.render.setShaderParam`, section 9).
- `bool` and `i32` params are branches on a uniform value, which is effectively free: use them instead of
  variants. There is no `#if`.
- The surface keys (`model`, `blend`, `reads`, `bounds`, `tonemap`) are refused in a `post`, `raw` or `pass` file;
  `instance` is refused in `post`; `param` and `texture` are refused in `raw` and `pass` (resources are WGSL there).

The grammar is parsed by `packages/contract/src/shader/header.ts` and its Rust twin `crates/contract/src/shader.rs`,
which agree on `fixtures/shaders/manifest.json`.

### The packed params block

Params are laid out in declaration order, each at the next offset its alignment allows (std430), and the total is
rounded up to 16 bytes. `f32`, `i32` and `bool` take 4 bytes (`bool` is a `u32`), `vec2f` 8 (aligned to 8), `vec3f`
and `color` 12, `vec4f` 16 (aligned to 16). An `f32` after a `vec3f` packs into its last 4 bytes. Both parsers
return this layout (`offset`, `size` per param, `totalBytes`); `shader.write` reports it.

## 4. What a shader sees and returns

```wgsl
struct SurfaceInput {
  uv: vec2f, uv1: vec2f,            // uv1 mirrors uv (one UV set today)
  worldPos: vec3f, objectPos: vec3f,
  normal: vec3f,                    // world, interpolated
  tangent: vec4f,                   // xyz tangent, w bitangent sign
  viewDir: vec3f,
  color: vec4f,                     // vertex colour times the instance tint
  screenUv: vec2f,
  time: f32,                        // sim seconds, wrapped every 4096 s
  frontFacing: bool,
  instanceRandom: f32,              // 0..1, stable per entity
  wind: vec3f,                      // the scene wind, zero if none
  particleAge: f32,                 // particles only: 0 at birth, 1 at death (0 on a mesh)
  particleRandom: f32,              // particles only: 0..1 fixed per particle (instanceRandom on a mesh)
  particleColor: vec4f,             // particles only: the particle's linear RGBA (color on a mesh)
  worldDdx: vec3f, worldDdy: vec3f, // dpdx(worldPos), dpdy(worldPos): metres per pixel (see below)
}
struct SurfaceOutput {
  baseColor: vec3f, opacity: f32,
  normal: vec3f,                    // tangent space, default (0, 0, 1)
  roughness: f32, metallic: f32, ao: f32, specular: f32,
  emission: vec3f,
  transmission: f32,                // 0..1, default 0: thin-surface light through from behind (leaves, cloth, paper)
}
struct VertexInput { objectPos: vec3f, worldPos: vec3f, normal: vec3f, uv: vec2f, color: vec4f,
                     worldOrigin: vec3f, time: f32, wind: vec3f, cameraPos: vec3f }
struct ShadePoint  { worldPos: vec3f, normal: vec3f, viewDir: vec3f, baseColor: vec3f, roughness: f32,
                     metallic: f32, ao: f32, specular: f32, emission: vec3f, transmission: f32, time: f32 }
struct Light       { direction: vec3f, color: vec3f, shadow: f32, distance: f32 }
```

`worldDdx` / `worldDdy` are the screen-space derivatives of `worldPos` (metres per pixel along the screen's x and y),
taken by the wrapper before `surface()` runs, for gradient sampling (`tex_<name>_grad`) and footprint-aware detail.
Where there are no screen neighbours (HD's ray-hit and uv-bake variants) they are a stand-in from the hit's ray-cone
footprint: two orthogonal vectors in the surface's tangent plane (along the tangent and the bitangent) of length
`clamp(exp2(lod) / 1024, 1e-5, 0.5)`, the width `aa_step` uses there (in a bake, level 0: 1/1024, the bake's uv being
its world xy). They are not a downgrade (`render.shader.downgraded` is not raised for them). They exist only in
`SurfaceInput` (`vertex()` has none), and only in a module whose code names them.

`VertexInput.cameraPos` is the main view's camera position in world space (metres), for width or detail that should hold
in screen terms, e.g. `n * params.width * max(1.0, distance(in.worldPos, in.cameraPos) / 10.0)`. In a shadow pass it is still the
main camera's position (not the light's), so a shader that reads it moves the shadow caster the same way as the visible mesh.

`surface_default()` is the engine's grey dielectric; start from it and change what you mean. Colours are linear.
`opacity` matters with `blend: blend` and `blend: mask` (mask clips below the material's `alphaCutoff`; there is
no `discard`).

The engine reads what `surface()` returns through one guard, the same in every pass and on both renderers, so a
careless value cannot reach another pixel (bloom spreading an infinite colour over the frame, say): a NaN or infinite
component reads 0; `baseColor`, `emission` and the lit colour are clamped to [0, 65504] (the HDR targets' largest
value); `opacity` to [0, 1]; a zero-length `normal` is (0, 0, 1). Values in range are untouched.

In `light()`: `direction` points toward the light, `color` is radiance divided by pi (so
`baseColor * color * dot(n, direction)` is Lambert), `shadow` is 0 (shadowed) to 1 (lit; the sun's cascade shadow,
1 for local lights), `distance` is metres (1e6 for the sun). `p.normal` is world space and faces the viewer.

A stylized shader, complete:

```wgsl
//! model: stylized
//! param bands: f32 = 3 [2, 8]
fn surface(in: SurfaceInput) -> SurfaceOutput {
  var s = surface_default();
  s.baseColor = vec3f(0.8, 0.5, 0.3);
  return s;
}
fn light(p: ShadePoint, l: Light) -> vec3f {
  let ndl = saturate(dot(p.normal, l.direction)) * l.shadow;
  return p.baseColor * l.color * (floor(ndl * params.bands) / params.bands);
}
fn ambient(p: ShadePoint, irradiance: vec3f) -> vec3f {
  return p.baseColor * irradiance * p.ao * 0.5;
}
```

## 5. Models, blend modes and the other keys

**Models**

| `model` | what you write | lit by |
|---|---|---|
| `lit` | `surface()` | the renderer's own lighting (PBR-lite in the standard renderer); `emission` is added; `transmission` adds light arriving from behind the surface, tinted by `baseColor` (zero cost when 0) |
| `unlit` | `surface()` | nothing: `baseColor + emission`; `tonemap: off` skips the tonemap for the surface |
| `stylized` | `surface()` plus `light()` and `ambient()` | your functions: toon, cel, painterly. The engine calls `light()` for the sun and each local light that reaches the object (at most 8), and sums the results, so the loop is bounded |

**Blend**

| `blend` | draws | notes |
|---|---|---|
| `opaque` (default) | in the opaque pass, depth-writing | early-z intact |
| `mask` | opaque with a clip from `opacity` | has its own shadow variant; alpha-to-coverage under MSAA |
| `blend` | alpha-blended, sorted per object back to front | no depth write |
| `add` | additive | glow, hologram, fire; draws after every alpha-blended surface, grouped by pipeline (built-in blended materials and particles keep their order) |

- `reads: depth` (blended surfaces) gives `scene_depth_behind(in) -> f32`: metres along the view ray from this
  surface to the opaque scene behind it, 1000 where nothing is behind (soft particles, shorelines, shields). The
  depth is copied only in frames where such a surface is in view. In a multisampled or reduced-scale pass there is no
  such copy: the surface sees nothing behind it and `render.shader.downgraded` says so once.
- `vertex()` returns an offset in metres that the engine clamps to `bounds` with one `min`, so culling stays right.
  It runs in the main and shadow passes. Use `tex_<name>_lod` there. Complete example:

```wgsl
//! model: lit
//! bounds: 0.3
//! param sway: f32 = 0.1 [0, 0.3]
//! instance gust: f32 = 0 [0, 1]
fn vertex(in: VertexInput) -> vec3f {
  let h = max(in.objectPos.y, 0.0);
  let s = sin(in.time * 2.0 + in.worldOrigin.x);
  return vec3f(s * params.sway * h * (1.0 + instance.gust), 0.0, 0.0) + in.wind * 0.05 * h;
}
fn surface(in: SurfaceInput) -> SurfaceOutput {
  return surface_default();
}
```

- A blended surface that reads the depth:

```wgsl
//! model: unlit
//! blend: blend
//! reads: depth
//! param tint: color = (0.1, 0.5, 0.7)
fn surface(in: SurfaceInput) -> SurfaceOutput {
  var s = surface_default();
  s.emission = params.tint;
  s.opacity = saturate(scene_depth_behind(in) / 2.0) * 0.6;
  return s;
}
```

- Skinned meshes, double-sided materials and shadow casting come from the material (`doubleSided`, `castShadows`),
  never from the shader; the engine builds the matching variants.

## 6. The prelude

Versioned and tree-shaken: only the functions your file names are compiled in. Engine-internal helpers are
prefixed `pl_`. `shader.library {section: "functions"}` lists the current set from the prelude itself.

| | |
|---|---|
| hashes | `hash11`, `hash21`, `hash33`: PCG on u32, in [0, 1), identical bits on every GPU |
| noise | `noise1`, `noise2`, `noise3` (value noise); `fbm2`, `fbm3(p, octaves)`; `voronoi2(p)` (a `vec3f`: nearest and second nearest distance, and the cell's random value) |
| shapes | `fresnel(n, v, power)`; `remap(x, a, b, c, d)`; `rotate2(p, angle)`; `aa_step(edge, x)` (a one-pixel ramp from `fwidth`; in a ray hit or a bake it takes its width from the hit's level of detail instead, so use it rather than raw `fwidth`) |
| colour | `luminance`, `srgb_to_linear`, `linear_to_srgb`; constants `PI`, `TAU`; `saturate` is the WGSL builtin |
| texture tricks | `triplanar_weights(n, sharpness)` and `triplanar(cx, cy, cz, w)` (take the three weights, sample three times yourself, blend); `parallax_uv(uv, tangent_view(in), height, scale)` |
| surface | `surface_default()`; `scene_depth_behind(in)` (with `reads: depth`) |

`shader.library {section: "glsl"}` is a 15-line GLSL-to-WGSL cheat sheet: `texture()` becomes `tex_x()`, `mod`
becomes the `fract` form, `atan(y, x)` becomes `atan2`, and so on. In a `kind: post` file the helpers differ
(section 11).

## 7. Limits, the sandbox and cost

**Limits, the same on every target, so one source runs everywhere**

| | limit | code |
|---|---|---|
| source | 16 KB (16384 bytes) | `shader-header-invalid` |
| params | 256 B packed (16 vec4) | `shader-param-invalid` |
| instance floats | 4 | `shader-param-invalid` |
| textures | 6 (an array slot counts as one) | `shader-texture-invalid` |
| layers of one array slot (per material) | 64 | material codes (section 9) |
| `drawTexture` slots | 2 (not counted in the 6) | `shader-texture-invalid` |
| pipelines per shader per renderer | 10 (typical 1 to 3) | `shader-variants-over-cap` |
| function-local arrays | 64 elements | `shader-forbidden` |
| prefix operators in a row (`!`, `-`, `~`, `*`, `&`) | 64 | `shader-compile-error` |
| brackets nested (`(`, `[`, `{` together) | 64 | `shader-compile-error` |

**The sandbox** is checked on naga's IR after validation, so a comment or a macro cannot hide anything.

- Allowed at the top level: `fn`, `const`, `struct`, `alias`. Refused: module-scope `var`, `@group`/`@binding`,
  entry points, `override`, `enable` (`shader-forbidden`).
- Refused: `textureStore`, atomics, barriers, `arrayLength`, raw texture and sampler handles, `discard`, and reading
  the engine's bound resources (`user_params`, `user_instances`, the frame) by name: params come through `params`,
  per-entity floats through `instance`, textures through `tex_<name>()`. The accessors are the only way to
  `textureLoad` and `textureSampleGrad`: the slot's binding is the engine's like the others, and a raw load or
  gradient sample on it is refused (`shader-forbidden`).
- Derivatives (`dpdx`, `fwidth`, `aa_step`) are allowed in `surface()`, `light()`, `ambient()` and `screen()`, not in
  `vertex()`.
- Where there are no screen neighbours (the ray-hit and uv-bake variants an HD project compiles) `aa_step()` and the
  `tex_<name>()` reads take their width and level from the hit, so one `surface()` serves every path. A raw `dpdx`,
  `dpdy` or `fwidth` in `surface()` reads as 0 there, and the compiler says so by name
  (`render.shader.downgraded`, with the line and the affected variants) instead of doing it silently.
- Loops: only `for` with a constant trip count of at most 32, at most 256 iterations across nested loops. `while` and
  `loop` are refused (`shader-loop-unbounded`).

**Cost.** Each shader gets a static estimate from naga's IR, in cost units per pixel (1 per ALU op, 4 per
transcendental, 8 per texture fetch, times loop trip counts; prelude functions carry precomputed costs). Every
`tex_*` sample, `_lod`, `_grad` and `_load` is one fetch (8 units, plus the few ALU ops of its layer clamp); `_size`
and `_layers` are not fetches and cost next to nothing.
A warning (`shader-cost-high`) above the soft cap; a refusal (`shader-cost-over-cap`) above the hard cap. A
project's export targets pick the stricter column (`web` also means mobile); a project may raise the hard caps with
`render.shaders.costCapUnits` and `costCapFetches` in `argus.project.json` (they can only raise).

**The engine's stock modules** (`crates/shader/src/stock/`, compiled with `CompileOptions::stock`) may say two more
header items a project's shader may not (`shader-header-invalid`, "for the engine's stock modules only"):
`paramBytes: <n>` sizes their params block past the 256-byte limit (up to 2048, a multiple of 16; params past
256 bytes without it are refused), and `costCap: <units> <fetches>` replaces the hard caps for that module alone (the
soft warnings and every project's caps are unchanged). The engine's tests validate and cost each stock module under
its own cap, so a shipped build draws it without a WGSL front end.

| | desktop / HD soft | desktop / HD hard | web / mobile soft | web / mobile hard |
|---|---|---|---|---|
| cost units | 400 | 2,000 | 150 | 600 |
| texture fetches | 8 | 16 | 4 | 8 |

The static estimate is a screen, not a measurement: `shader.measure` reports it and says when no GPU timer is
there to measure ms per screen (section 14).

## 8. Errors and codes

Every fault names your own `line:col` (1-based, columns count Unicode scalar values) and carries
`{code, severity, line, col, message, hint, next}`, with a "did you mean" against prelude names, params and
`SurfaceInput` fields. Run `explain.rule` on any code.

| source | codes |
|---|---|
| header | `shader-header-invalid`, `shader-param-invalid`, `shader-texture-invalid`, `shader-model-mismatch` |
| compiler | `shader-compile-error`, `shader-forbidden`, `shader-loop-unbounded`, `shader-cost-over-cap`, `shader-cost-high` (warning), `shader-variants-over-cap` |
| escape hatch | `shader-raw-disabled` |
| material file | `material-shader-missing`, `material-shader-param-unknown`, `material-shader-param-range`, `material-field-ignored` (warning) |
| export | `build.shader-scene-prewarm-heavy` (warning) |
| runtime (docs/RUNTIME-CODES.md) | `render.shader.fallback`, `render.shader.compiled-after-load`, `render.shader.downgraded`, `render.shader.families-many`, `render.screen-effect` |

## 9. Materials

A material with a `shader` is drawn by that shader instead of by glTF factors and maps (`docs/MATERIALS.md`,
"Shader files", is the material side).

- `shader` is a scene asset id of kind `shader`, or a path relative to the material file (no leading slash, drive or
  `..`). `params` keys are checked against the header (unknown name, wrong type, out of range, texture not found). A
  colour is 3 linear numbers or `#RRGGBB` (read as sRGB and converted). A texture param is `white`, `black`, `flat`,
  `builtin:noise` or a path relative to the material file.
- `doubleSided`, `alphaCutoff`, `castShadows` and the extension-lobe keys still apply; `baseColor`, texture maps,
  `shading` and `layers` do nothing (`material-field-ignored`).
- **A new material compiles nothing.** A material is a row of params in one shared buffer; a new shader adds at most
  one pipeline per path it is drawn on. Share one shader across a look and vary its `params`.
- **Fallback.** Every shader material has a built-in PBR stand-in (the surface drawn on a 32 by 32 card at t=0,
  averaged to base colour, emission, roughness, metallic). It draws while the real pipeline builds, if the shader
  fails (`render.shader.fallback`, once per family), and wherever the shader cannot run. A not-ready shader
  therefore never waits and never draws garbage.
- **Per-entity values.** `instance` floats are data only: no new material, no new batch. A behaviour sets one with
  `ctx.render.setShaderParam(entity, name, value)` (for that entity only); a shared `param` changes for everything
  wearing the material with `ctx.render.setMaterialParam(materialAssetId, name, value)` (a number, boolean, array of
  numbers or `"#RRGGBB"`; texture params are set in the file). Both are buffer writes, applied at the next frame in a
  fixed order (last write in a tick wins); a name the shader does not declare is logged once and ignored; a headless
  run has no renderer and drops them (`docs/API.md`, "Shader parameters").

```ts
update(ctx) {
  ctx.render.setShaderParam(ctx.entity, "gust", Math.min(1, ctx.time.now / 2))
}
```

- **Textures and batching.** A family's materials whose textures share a size and format are packed into texture
  arrays as the materials register; a material's slots are layer indices in its params, so those materials share
  one bind group and draw in one call. A material that leaves a texture unset shares the texture set of one that
  sets it. Draws group by (family, texture set, draw state), so instancing spans materials (section 16 has the
  draw-call counts).
- **Array slots.** A `texture <name>: array(<kind>)` param is a JSON array of texture values, one per layer, 1 to
  64 of them (`["grass.png", "rock.png", "builtin:noise", "white"]`); a single string is one layer, and a material
  that leaves it out has one layer, the header's default. Every layer is resampled to the **first** layer's size and
  format on the GPU when the material registers (bilinear, reading the source's mip at the matching scale, then its
  mips made again), so layers of different sizes are fine and `_load` / `_size` read the same texels everywhere; a
  layer that does not load reads the header default (a warning; a load failure in strict mode). The layers of a
  material sit together in the slot's texture array (on HD: a list of bindless indices), so materials of a family
  still share arrays and draw calls as above. From the engine: `UserMaterialSpec::texture_layers(name, layers)`.
- **Per-draw textures (`drawTexture`).** A family may declare up to 2 slots that are bound per draw, not per material
  (a per-cell weight map, say). The engine binds them with `Renderer::user_draw_material(material, [Option<TextureId>; 2])`,
  which returns a material to draw with: it shares the base material's params (a change shows in every draw),
  textures, draw state and pipelines, and binds the given textures to the slots in declaration order (`None`: the
  slot's default, the material file's own value for the slot if it names one, else the header's). The same pair
  returns the same material; draws with different textures cannot share a draw call. A material file does not set
  draw slots per draw; a path default of a `drawTexture` slot is loaded as the material's default.
- **Car paint, wheels and tyres need no shader.** A material file that uses a car preset (`carPaint`,
  `wheelAluminium`, `wheelGlossBlack`, `tyreRubber`) or any car key (`flakeColor`, `flakeDensity`, `flakeSize`,
  `flakeSparkle`, `clearcoatOrangePeel`, `clearcoatOrangePeelScale`, `flipColor`, `flipStrength`, a non-`uv`
  `anisotropyPattern`, `anisotropyAxis`), on an opaque pbr material without a `shader`, is drawn in the standard
  renderer with a **stock module** the engine ships (`crates/shader/src/stock/car.wgsl`, a `stylized` surface), through the
  same path as a user shader. It is built per feature set, so a plain clear coat does not pay for flakes. The module
  is a constant that the engine's tests validate and cost, so a **shipped build draws it too**: its family is in the
  export's shader pack and the scene's pre-warm list (`stock:car:<features>`), and the shipped engine needs no WGSL
  front end for it. An HD export needs none (HD draws these keys natively). **Web, Android and iOS get a lighter form** when the full module
  is over their cost cap (every full set is: no orange peel, colour flip or anisotropy; flakes as a static speckle
  with an averaged glint; one lobe whose roughness follows the clear coat). The keys and the look are in
  `docs/MATERIALS.md`, "Presets" and "Car paint, wheels and tyres in the standard renderer".
- **Terrain is a stock family too** (`crates/shader/src/stock/terrain.wgsl`, `stock:terrain:<features>` in an export's
  pack and pre-warm list; the standard renderer only, HD draws terrain from its own virtual-geometry variant, which
  shares the arithmetic). Its `surface()` is `terrain_math.wgsl` (a prelude section: a shader may call the
  `terrain_*` helpers that need no record, such as `terrain_macro` or `terrain_rule_weight`; the module defines the
  record hooks `terrain_layer_params`, `terrain_globals`, ... itself). A layer set is one material of the family:
  array slots `albedo` (colour, the layer's height in alpha), `normal` (the normal map, roughness in alpha) and `orm`
  (only with a packed ORM map: R occlusion, B metallic), packed by the engine from each layer's `.material.json`;
  each cell is a draw of it with `drawTexture` slots `layerMap` (one texel per terrain sample) and `farMap`. The
  record is 28 vec4 params read field by field from the params buffer (`user_base_`, stock modules only; their
  `params` struct is not loaded at all), never copied whole. A stock module's sets filter 8x anisotropic; other families' sets keep the plain sampler. Its
  `costCap` (40000 units, 192 fetches) replaces the hard caps for every target, the web's included; its distance
  blend uses the distance from `user_frame.camera_pos`.

## 10. Particles

A `particles` component may name a material file with a shader (`material`, like a mesh's): its sprites draw with
that shader instead of the built-in soft disc, on both renderers (HD simulates such an emitter on the CPU and draws it over the upscaled image with the standard particle pipeline; an emitter without a shader keeps HD's own GPU-simulated sprites).
The shader must be `blend: add` or `blend: blend`, with no `fn vertex(` (the quad is the engine's) and no
`reads: depth`; any other shader leaves the built-in sprite and says so once (`render.shader.fallback`), and export
refuses it (`shader-model-mismatch`, "cannot draw particles: ..."). `model` may be `unlit`, `lit` or `stylized`;
`bounds`, `mask` and shadows do not apply to a sprite.

`SurfaceInput` carries the particle: `uv` is the quad (0 to 1, y down), `color` and `particleColor` the particle's
linear RGBA (the emitter's colour curve), `particleAge` 0 at birth to 1 at death, `particleRandom` a number from 0
to 1 fixed for the particle's life, `normal` faces the camera. On a mesh `particleAge` is 0, `particleRandom` is
`instanceRandom` and `particleColor` is `color`. The pipeline is built behind the scene load with the others, and
the emitter draws the built-in sprite until it is ready.

```wgsl
//! model: unlit
//! blend: add
//! param tint: color = (1.0, 0.6, 0.2)
fn surface(in: SurfaceInput) -> SurfaceOutput {
  var s = surface_default();
  let soft = saturate(1.0 - length(in.uv - vec2f(0.5)) * 2.0);
  s.emission = params.tint * in.particleColor.rgb * soft * soft * (1.0 - in.particleAge);
  s.opacity = soft;
  return s;
}
```

## 11. Screen effects (`kind: post`)

A `*.shader.wgsl` with `//! kind: post` is a full-screen effect over the scene (standard renderer only), listed in
the scene's `meta.render.screenEffects`: up to 4 of `{ "shader": "<path>", "params": { ... } }`, the path relative
to the project like the scene's asset paths, `params` checked against the header.

```json
"render": { "screenEffects": [ { "shader": "shaders/vignette.shader.wgsl", "params": { "strength": 0.5 } } ] }
```

```wgsl
//! kind: post
//! slot: afterTonemap
//! param strength: f32 = 0.4 [0, 1]
fn screen(in: ScreenInput) -> vec4f {
  let v = 1.0 - smoothstep(0.4, 0.8, distance(in.uv, vec2f(0.5)));
  return vec4f(in.color * mix(1.0, v, params.strength), 1.0);
}
```

- `slot: beforeTonemap | afterTonemap` (default `beforeTonemap`). Before-effects see the HDR scene, after bloom, and
  keep its alpha (the lit flag); after-effects see display colour at full size before FXAA and write alpha 1.
  Effects of one slot run in list order; every before-effect runs ahead of every after-effect.
- `ScreenInput`: `uv`, `pixel`, `color` (scene colour), `depth` (linear metres, the far plane where nothing was
  drawn), `time`, `resolution`; plus `scene_color(uv)` and `scene_depth(uv)` for neighbour taps (clamped to the
  screen). The sandbox and cost rules are a surface shader's, with derivatives allowed. `param`s and `texture`s as
  usual: an effect's `texture`s are the header's defaults (a built-in name, or a file beside the shader), or the
  entry's `params` value for that texture name (a built-in name or a path relative to the scene); the files load like a
  material's. The surface-only keys, `instance` and the surface functions are refused.
- With none listed there is no extra pass or target and frames are byte-identical; each effect is one pass. An effect
  that cannot be built is skipped with one `render.screen-effect` line; the scene and the other effects still run.
- Screen effects run in shipped games too. The export parses, assembles and validates each effect a scene lists
  (a problem refuses the export with the shader's file and line, as for a surface shader), and packs one file per
  scene, `shader-pack/screen/<hash>.json`: the normalised shader, its header, the params already packed from the
  scene's entry and the texture references. A shipped engine, which has no WGSL front end, checks the file's hash and
  versions and builds the pipeline from it at load, so nothing is compiled or created afterwards. An engine with the
  front end (the editor, `argus run`) reads the source file instead, so a hot reload of the file shows at once, and
  falls back to the pack when the source is not in the payload. An HD export packs none. A web export ships each effect's
  source file instead (stored under its content hash, the scene's path rewritten), and the page, which has the front
  end, compiles it.
- Built in beside it: `bloom` (`meta.render` strength/radius; not a shader file). HD draws `screenEffects` too, beside its
  own post chain (a `beforeTonemap` effect ahead of exposure, bloom and the tonemap, an `afterTonemap` one on the finished colour).
- A behaviour sets an effect's params with `ctx.screenEffects.setParam(effect, param, value)`: `effect` is the shader as
  the list writes it (every entry running that file) or its index in the list, `value` as `ctx.render.setMaterialParam`
  takes it (a `#RRGGBB` string for a colour). It is drawn over the entry's `params` from the next frame until the
  world restarts; a hot reload that rebuilds the effects draws it again, and it is never written to the scene. One
  buffer write, no rebuild, so it is cheap every frame (a rain streak's strength following a storm). An effect the
  scene does not run, a param its header does not declare, or a value its type or `[min, max]` refuses is one
  `screen-effects.param-refused` warning and is not drawn; an `effect` that is neither a string nor an index, or a
  number that is not finite, throws `screen-effects.param-invalid`.

```ts
update(ctx) {
  ctx.screenEffects.setParam("shaders/vignette.shader.wgsl", "strength", 0.3 + 0.2 * Math.sin(ctx.time.now))
}
```

## 12. Escape hatch (`kind: raw`, `kind: pass`)

For work the surface function cannot express. **Off by default**: set `render.shaders.raw: true` in
`argus.project.json`. Without it a `kind: raw` or `kind: pass` file is refused with `shader-raw-disabled` at its
`//! kind:` line. Templates and examples never use it; write a surface shader first.

**Status.** The header, the compile and validation side, the export check and the renderer's Rust API
(`Renderer::register_hatch_shader(source, raw_enabled)`, `register_raw_material`, `set_hatch_buffer`,
`set_hatch_texture`, `set_hatch_dispatch`) exist and are tested. No scene or project field loads a raw or pass file
yet, so today a host program registers them through that API; a material's `shader` and `screenEffects` are for
surface and post files.

```wgsl
//! kind: raw
//! renderers: standard
//! targets: desktop, web
@group(1) @binding(0) var<uniform> tint: vec4f;
struct VOut { @builtin(position) pos: vec4f };
@vertex fn vs_main(@location(0) p: vec3f, @builtin(instance_index) ii: u32) -> VOut {
  return VOut(vec4f(p, 1.0));
}
@fragment fn fs_main() -> @location(0) vec4f { return tint; }
```

```wgsl
//! kind: pass
//! renderers: standard
//! targets: desktop
//! at: beforeTonemap
//! input: color, depth
@fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
  let c = textureSample(scene_color, scene_sampler, uv);
  return vec4f(c.r, 0.0, 0.0, 1.0);   // alpha 1 replaces the pixel
}
```

- **`kind: raw`**: a material pipeline with your `@vertex` and `@fragment` entry points. **`kind: pass`**: a
  full-screen fragment pass or a compute pass (`@fragment` or `@compute`) at `at:` (`afterOpaque`,
  `beforeTonemap`, `afterTonemap`, `overlay`).
- **Group 0** is the engine's frame (camera, `instances`, lights, shadow maps, environment), already declared; a
  `@group(0)` of your own is an error. **Group 1** is yours: `@group(1) @binding(n)` uniform, storage and
  storage-rw buffers, `texture_2d<f32>` and filtering samplers. **Group 2** exists only for a fragment pass that
  declares `//! input:`; a binding of your own there is refused.
- **The header declares support**, not the engine: `renderers` (`standard`, `hd`) and `targets` (`desktop`, `web`,
  `mobile`) are required. `argus_shader::compile::unsupported_targets` reports a project export target a file does
  not declare; the export does not call it yet. Validation uses the standard renderer's frame declarations; the
  file must list `standard` to register in the standard renderer.
- **Raw materials**: opaque, forward, static meshes, the engine's vertex layout (`@location(0)` position vec3f, `1`
  normal vec3f, `2` tangent vec4f, `3` uv vec2f, `6` colour unorm8x4). No ids (not pickable), no shadow, no skinned
  or blended variant. The pipeline builds on the registry's workers; until it is built, or if it fails, **it draws
  nothing** (there is no fallback).
- **Passes** run every frame at their point: `afterOpaque` and `beforeTonemap` on the HDR scene (before and after
  bloom), `afterTonemap` on display colour (before FXAA), `overlay` on the final colour (under the UI). A fragment pass
  is drawn over the target with premultiplied-alpha blending (output alpha 1 replaces the pixel). `//! input: color`,
  `depth` or `color, depth` makes the engine declare in group 2 `scene_color` and `scene_sampler` (bindings 0, 1;
  linear clamp) and `scene_depth` (binding 2, `textureLoad`, 1.0 where nothing was drawn), copied as the pass starts.
  The copy is made only for a pass that declares it. A compute pass dispatches `set_hatch_dispatch` workgroups
  (default: the target size over 8 by 8) and cannot declare inputs.
- Resources are set with `set_hatch_buffer(id, binding, bytes)` (zeroed, 256 B, grows to 64 KiB) and
  `set_hatch_texture(id, binding, texture)` (defaults to white; `sampler` bindings get a linear repeating sampler).
  Other resource kinds (depth, comparison, cube, array, storage textures) are refused at registration.

**Kept:** naga validation (errors in your own line numbers), hot reload, the content-hash cache. **Lost:**

- the sandbox: loops of any kind, `discard`, atomics, barriers, `textureStore` are allowed, so a bad shader can
  hang the GPU;
- the cost caps: no static estimate, no `shader-cost-high` or `shader-cost-over-cap`; nothing estimates it for you;
- automatic variants: no skinned, shadow, coverage or HD bin pipelines; you write and declare each one you need;
- HD G-buffer integration: forward-only in HD (and HD does not run them yet: only `standard` does today);
- batching across materials and the fallback proxy;
- the cross-renderer and web guarantee: it runs only where its header says, and nothing checks that WGSL you wrote
  for one renderer behaves on another.

## 13. Editing live: hot reload and the editor

An edit is a re-validate (a few ms, cached by content hash), a background build of only the variants that are
drawn, and an atomic swap at a frame boundary. The old pipeline draws until then; a version that does not validate
or build leaves the last good one in place with the error reported (`render.shader.reload-failed`, and the code
panel). A comment or whitespace change hashes the same and does nothing.

**On HD** an edit re-registers the family in HD's own passes: its pipelines are built when the edit lands (the variants its
materials draw, as after a load), every material of the family moves to the new version with its own
params, a material baked for ray-traced hits is baked again behind the frames (the same coalesced path as a
param change), and the old version's pipelines are released. Nothing restarts. The same contract holds: a compile error
keeps the last good version drawing and comes back as the error; so does an edit HD cannot draw, of a family HD drew until now
(an edit that adds `vertex()`): the reload fails with `render.shader.reload-failed` naming why, and the last good version
keeps drawing.

- **`run --watch`** (the `hot-reload` feature, on in the default engine): watches the folders of the shader files
  and of the material files that name a shader. A changed shader is reloaded; a changed material file has its
  **params** re-read and written (a changed `shader` key, or a changed texture, needs a scene reload). Shipped builds
  have no watcher.
- **The editor** takes the same change from its journal: a `*.shader.wgsl` or `*.material.json` written by this
  editor, by the agent (`shader.write`), or by an outside edit that was applied, is hot-reloaded in the viewport
  with no restart. Opening a shader in the Content Browser shows the **code panel**: syntax colours, sliders and
  pickers made from the header, red underlines with the fix hint, a cost meter, and a live preview (sphere, cube,
  plane, capsule or the selected entity's mesh, with a time scrubber). A shader material opens the same controls.
  **On an HD project** (`render.profile: hd`) the preview is drawn by HD (its user-surface passes: G-buffer, forward,
  transparent), not the standard renderer. A family HD cannot draw shows the named code under the picture
  (`render.hd.user-shader-partial` and why, the flat fallback being what is on screen), and an HD that cannot run on
  this machine says `render.hd.unavailable` and that the picture is the standard renderer's; the tile never passes the
  standard picture off as HD's.
- Screen effects are re-read with the rest of `meta.render` on a scene hot reload.

## 14. Agent tools and the critic

The `shaders` toolset is opt-in, like `terrain`: `tools.enable {"toolsets": ["shaders"]}`, or the first `shader.*`
call. It is never on the default tool list and costs at most 1,200 tokens. This branch lists four tools:
`shader.write`, `shader.library`, `shader.preview`, `shader.measure`.

- `shader.write {path, source, material?, dryRun?}` checks the source and writes it only if it passes: the header
  (the contract's parser), then the WGSL assembled for every wrapper variant it needs, validated by naga, linted
  against the sandbox and costed. On success `{ok, hash, cost, variants, params, instances, textures, warnings}`
  (each param's name, type, default, range and packed offset). `material: {path, params}` also writes a
  `.material.json` naming the shader (the material must sit in the shader's folder or above it: a material names its
  shader by a path that may not use `..`), its `params` checked against the header first. `dryRun` only checks.
- `shader.library {section}`: `models`, `functions` (read from the prelude), `glsl` (the cheat sheet) or `examples`
  (the six complete example shaders). No section lists the four.
- `shader.preview {material, shape?, strip?, renderers?, out?}` draws the material through
  `POST /capture {"kind":"material"}` at every cell of the critic's capture set and answers one PNG contact sheet
  (256 by 256 per cell; `shape`: `sphere`, `cube` or `plane`): columns are the four turntable angles (0, 90, 180, 270
  degrees), rows are the rig (`day`, `backlit`, `night`) and then the shader time (0, 1.5, 3 s). `strip: true` draws
  the time strip instead, eight frames 1/30 s apart. `renderers: ["standard", "hd"]` adds an HD block: the editor draws
  it when the open project renders with HD (through HD's user-surface passes). Otherwise, or for a family HD cannot draw,
  the renderer is listed under `skipped` with the reason (`not available ...`, naming `render.hd.user-shader-partial` and why
  for a family), never drawn as something else.
- `shader.measure {path}` answers the static estimate (`static.units`, `fetches`, `vertexUnits`), the caps it meets
  (desktop and web), the variant count and a pre-warm estimate from the P0 numbers. `measuredMsFullScreen` needs the
  GPU timer and is `null` where there is none; the critic then judges on the static estimate and says so. The **HD
  columns**: `hdStatic` (the HD cost, uncalibrated) and `hd`: `kind`, `path` (`g-buffer`, `forward-opaque`,
  `forward-blend`), `drawn` (and `fallback`, the reason, when not), `passes` (`g-buffer`, `forward`,
  `transparent-sorted`, `transparent-oit`, `shadow-cutout`, `particle`, or a screen effect's or pass's), `pipelines`
  HD builds when the family registers and `pipelinesWithRayTracing` (one more: its bake), `rayHits.handling`
  (`baked`, `baked-frozen` with what the bake fixes under `frozen`, `fallback` when HD cannot draw the family), `pending`
  (paths HD does not draw yet) and `codes` (the runtime codes an HD session raises for it). `argus-export
  --shader-check` prints the same object. The standard columns are unchanged.
- A `scene.patch` that names a material also checks its shader (`material-shader-missing`, header codes, param
  checks), and a build fails on an error (`build.scene-invalid`) as it does for a scene rule.

**The critic.** `packages/critic/src/shader/` is the shader critic, in the foliage critic's pattern: evidence in,
sheets and a prompt packet out, a Sonnet critic's reply in, score table and decision out. No model is called by the
package. The capture set is `packages/critic/src/shader/captures.json`, which `shader.preview` and the critic both
read: the standard renderer (and HD where there is one), turntable and times above, three rigs, the time strips (fast:
8 frames 1/30 s; slow: 8 frames 0.5 s), two in-scene distances (8 m and 30 m) and, for HD, the ray-tracing by virtual
geometry matrix.

```
pnpm --filter @argus/critic shader-critic plan [--hd]        # the captures to take, as JSON
pnpm --filter @argus/critic shader-critic round --look lava --round 1 --evidence <dir> [--reply file]
```

The rubric (`rubric.json`) scores matches the brief (3), readable at gameplay distance (2), no aliasing or flicker
across the time strip (2), works in both renderers (2, needs HD) and consistent across the HD RT on/off by VG on/off
matrix (1.5, needs the matrix). An item whose evidence was not captured is not applicable: it is not asked for and not
in the mean. Cost is a hard gate (static units and fetches against the desktop or web hard caps, the scene's frame
budget, web pipelines per scene); a missing GPU number is a note. Stop at a weighted mean of 9.0 with no item under 8
and the gate passing, or escalate after three rounds. The first-try compile rate (target 80%) of a folder of an
agent's first attempts: `cargo run -p argus-shader --example compile_rate -- <folder> [--web]` prints the rate and the
error codes as JSON and exits 1 under the target.

## 15. How it runs, export and pre-warm

**From file to pixels.** Header (contract, TS and Rust) -> assemble (your code wrapped into the engine's entry points
with the prelude subset and generated declarations; a line map keeps errors in your lines) -> naga validate, sandbox
lint, cost -> normalise and hash (comments and whitespace do not change the hash, so copies are one family) ->
validated-shader cache, shared by every project on the machine, so a shader validates once -> pipelines for only the
(pass by mesh kind by sidedness by MSAA coverage) a scene uses, on a worker pool, never on the render thread, through
one registry per device (workers `clamp(cores/2, 4, 12)`; none where there are no threads; on Vulkan one
`PipelineCache` per adapter and driver, in a folder held to 64 MB: least recently used files go first, and a file
past the cap on its own starts again). The file is named by adapter and engine build, and opening it removes the
same adapter's file of any other build, so an engine update replaces the cache instead of adding to it. The build is the crate version, the wgpu/naga stack and the executable's size and modification time, so uncommitted
dev rebuilds count. (The HD content cache, `hd/engine/content.rs`, is keyed by the crate version alone and has the same
dev-build blind spot; not fixed here.)

**No permutation explosion.** User code adds no axes. The hard cap is 10 pipelines per shader per renderer and the
typical count 1 to 3. Params live in one std430 buffer; materials never create pipelines.

## In an exported game

A shader in an exported game is read from the export's shader pack, not compiled: export compiles and validates every
shader the scenes' materials reach (all wrapper variants; an error refuses the export with the contract's code, file
and line) and writes one family record per shader, an index from each shader file's project path to its family, and a
per-scene pre-warm list (docs/EXPORT.md, "The shader pack and pre-warm lists"). The shipped engine has no WGSL front
end and the payload carries no `*.shader.wgsl`: a material file with a `shader` finds its family through the index,
takes its params and textures from the header stored in the record, and the scene's list builds every pipeline behind
the load, so nothing is created while playing. `ctx.render.setShaderParam` and `setMaterialParam` work as in Play.
A material may name its shader by path (from the material file's folder) or by a scene `shader` asset id; both resolve. Web exports with user-shader materials are refused for now; the editor's export and `engine export` ship them for a native game, standard or HD (an HD project's families HD cannot draw yet are refused at export, naming the family and why; a family it draws with a gap exports with the warning `build.shader-hd-partial`). A shader the export's scan did not reach (a material named only by a path built at run time) is not in the pack: the
load fails naming the material and the shader. List such a material in a scene.

## Escape hatch: `kind: raw` and `kind: pass`

**What ships (the `authoring` feature).** Everything that parses, validates, lints, costs and caches WGSL (naga's front
end among it) is behind `authoring`, off by default in `argus-shader` and `argus-render`. The engine
(`argus`'s default features), the editor, the workspace service, the export and the web runtime turn it on. A **shipped
native game** (`--no-default-features --features deploy,window,gamepad`) and the agent do not: the game links only
pack records, assembly by string concatenation and pre-warm lists, checks a family's hash, ABI and prelude version,
and builds the pipeline. The agent's shader tools run `argus-export --shader-check` instead. This is why the export
validates everything and why a shipped game "never validates a shader".

**Pass shaders are not exported.** Nothing in a project runs a `kind: pass` shader yet, so the export warns (`build.shader-pass-not-exported`) once per such file, naming it, and leaves it out of the pack.

**Export and load.** Export compiles every shader a project's materials use (a refusal fails the build, with the
code, file, line and column; raw and pass files compile when `render.shaders.raw` is on, and raised
`costCapUnits`/`costCapFetches` apply) and writes `app/shader-pack/<hash>.family.json` per family plus a **pre-warm
list** per scene (`shader-pack/prewarm/<scene>.json`): every (family, wrapper variant, double-sided) pipeline the
scene can need, nearest the start camera first (`docs/EXPORT.md`, "The shader pack and pre-warm lists"). Load builds
the list on the workers behind the loading screen, so a shipped game compiles nothing at run time: the gate is
`created_after_load = 0` and `fallback_draws = 0` after load. A pipeline the scan could not predict is created on first
use, draws its fallback and counts (`render.shader.compiled-after-load`). On the web there are no worker threads, so
loading builds in 8 ms slices and draws one degenerate triangle per pipeline offscreen before the first frame.
`build.shader-scene-prewarm-heavy` warns when a scene's predicted pre-warm (pipelines x 28 ms / 8 workers = 3.5 ms)
exceeds 400 ms (about 114 pipelines).

**Pay-for-use.** A scene with no user shaders builds nothing, draws bit-identical frames and loads as before. The
shader system, a screen effect's targets and the texture arrays each exist only once something uses them.

**One copy of each texture.** On the standard renderer a registered texture is uploaded at its first use. A texture
a user material packs is written straight into its family's texture array layer (the writes go in 16 MB chunks the
device finishes before the next, so a scene's load never stages all its textures at once beside the arrays); it never
has an upload of its own, so the allocator's reserved memory matches a built-in scene's (replica-n50 on llvmpipe: 192 MB
in 2 blocks, as built-in; it was 320 MB in 3). Anything else that binds a texture uploads it then, and the start of a
frame uploads the rest. A texture that was uploaded first is released once an array layer holds it: the array layer is
its only copy. A replaced texture (`Renderer::replace_texture`, a texture that streams in after the first frame) is written into
every layer that holds it, all mips, when its size and format match; at another size its materials are packed again
into a set of that size. A texture a built-in material, a particle group, a screen effect, a hatch shader or the environment also binds
keeps its upload, and one bound later gets it back (copied from its layer). A texture packed into another set later
(a material edit, a hot reload, another family) is copied from its layer. A material that names a builtin `black`,
`white` or `flat` gets it at its array's size, so it shares a set with materials that have a real texture there.
`UserShaderStats::standalone_textures_released` and `standalone_bytes_released` count the released uploads. HD keeps
its own share of every texture, so it releases nothing. Meshes are kept once too: a mesh a user-shader group draws is
moved into the multi-draw arena (made at the size the scene's meshes need, doubling after that), and every draw of it,
built-in materials' included, reads it there.

## 16. Measured numbers

Each number says where it was measured. The RTX 5080 is a Core Ultra 9 285K machine (24 threads) with NVIDIA driver
617.14 and wgpu 30.0.1; llvmpipe is the software Vulkan driver in WSL, so its timings are a lower bound that says
nothing about a player's machine.

| what | number | where measured |
|---|---|---|
| P0: 1,000 shaders, Vulkan, true cold | **2.7 s at 12 threads** (4 threads 8.1 s, 2 threads 15.8 s, 1 thread 33.9 s) | RTX 5080, pipeline bench |
| P0: 1,000 shaders, Vulkan, warm (wgpu `PipelineCache` plus warm driver) | **0.43 s at 12 threads** (4 threads 1.1 s, 1 thread 3.1 s) | RTX 5080, pipeline bench |
| P0: one pipeline, cold / warm | about 28 to 30 ms / 0.5 to 0.7 ms; the `PipelineCache` file is 4.9 MB per 1,000 | RTX 5080 |
| P0: DX12 (wgpu has no pipeline cache there) | about 57 ms per shader (FXC) on **every launch**; 3.8 s for 1,000 at 24 threads, so it misses the warm budget | RTX 5080 |
| Stress scene A (200 shaders, 2,000 materials, 10,000 instances): warm load | **184 ms** (budget 1,000), created after load 0, fallback draws 0 | RTX 5080, `argus-shader-stress` |
| Stress scene A: frame | user shaders **+0.05 ms** over the same scene with every material on its fallback; CPU encode **2.08 ms** (budget 2.0, borderline) | RTX 5080, `argus-shader-stress` |
| 1,000 shaders on screen at once (pathological scene) | CPU encode **7.8 to 8.6 ms**; a GPU-driven path is a later step | RTX 5080 |
| replica-n50 (50 materials, 63 textures), standard renderer: GPU memory | 117 MB with user shaders (was 250 MB) vs 79 MB built-in; the difference is the ORM and emission maps the built-in material does not load (36 MB): without them, 81 MB (+2.6%) | llvmpipe, wgpu allocator report |
| 1,000 materials, 200 families: draw calls | 206 with texture arrays and merged instancing, 1,006 without; CPU encode 0.56 to 0.79 ms merged, about 1.0 ms unmerged | llvmpipe, loaded machine |
| 1,000 families / 10,000 materials: compile and export | compile cold 355 ms, warm 24 ms; export 520 ms, 1.49 MB, 2,279 pipelines listed | llvmpipe (CPU only) |
| Hot reload, edit to visible | **5 to 65 ms** | llvmpipe, headless |

Decisions the numbers made: the standard renderer opens **Vulkan first on Windows**, DX12 as the fallback
(`render.backend` can force one); pipeline creation uses 4 to 12 threads (more only raises per-pipeline latency).

**Budgets the stress gate enforces** (`argus-shader-stress --gate`): `created_after_load`, `fallback_draws`, failed
builds and compile errors 0; params at most 1 MiB; 1,000-family compile at most 15 s cold and 3 s warm (5,000: 60 s
and 12 s); warm scene load at most 1 s; frame at most +0.3 ms and +15 % over the all-fallback frame; CPU encode at
most 2.0 ms. The timing rows are enforced on a real GPU only. The 0.6 ms encode with texture arrays is reported, not
enforced.

## 17. Renderers and platforms

| | standard renderer (desktop, web, Android, iOS) | HD |
|---|---|---|
| `surface()` shaders (`lit`, `unlit`; `blend: opaque` or `mask`, no `vertex()`) | **yes** | **yes**, in the raster G-buffer pass and on virtual geometry; ray-traced hits see a bake of it (a mask's shadows follow its surface) |
| `surface()` shaders (`stylized`, `blend: blend`, `blend: add`, `reads: depth`; no `vertex()`) | yes | **yes**, in HD's forward and weighted-OIT passes, lit by HD's own lighting; ray-traced hits see a bake of it |
| `vertex()` | yes | not yet: the family's materials draw their flat PBR fallback, said once (`render.hd.user-shader-partial`) |
| skinned, shadows | yes | yes (a mask family's shadow cut-out is its `surface()`'s) |
| particle shaders | yes | yes: an emitter whose material is a user shader draws with it over HD's upscaled image, before exposure, bloom and the tonemap; plain emitters stay HD's own lit, soft, order-independent sprites |
| `kind: post` screen effects | yes | yes: `beforeTonemap` on HD's upscaled HDR image, `afterTonemap` on the finished display colour (below) |
| escape hatch | yes (`renderers: standard`), through the renderer API | `kind: pass` at `afterTonemap` and `overlay` runs; at `afterOpaque` and `beforeTonemap` it is skipped with `render.hd.pass-point-skipped`; `raw` not run yet |
| car presets and keys | the stock module (section 9) | HD's native car-paint layers |

**Screen effects and particle shaders on HD.** HD's frame is not the standard frame, so the two effect slots map
like this:

| effect slot | standard renderer | HD |
|---|---|---|
| `beforeTonemap` | the scene's HDR image after bloom, at render scale | HD's upscaled HDR image (display resolution; HD's pre-exposure is divided out and put back around the effect, so `in.color` is in the standard renderer's units), after the upscaler and the look's fog and depth of field, **before exposure, bloom and the tonemap** |
| `afterTonemap` | display colour, before FXAA | the finished display colour (after the tonemap and grade, and after the scale-up of a reduced render scale), **before** particles, overlay lines, labels and UI |
| `in.depth` | the scene depth in metres | the same, in metres: before the tonemap from HD's depth rewritten to the standard projection at display size (nearest, so silhouettes sit within one render texel at a reduced render scale), after it from the entity-id and depth pass |

Three differences to know: a `beforeTonemap` effect runs before HD's exposure and bloom, not after bloom as on the
standard renderer (and HD's own exposure mode, not the scene's `exposure`, decides how bright the result ends up);
the effect runs after the temporal upscaler, not before it, because the
upscaler assumes its input is the jittered scene and an effect that recolours pixels ahead of it would smear through its
history; and the after-tonemap depth comes from the entity-id pass, which skips instances under 2 pixels wide.
A frame of several views (a headset's stereo pair) draws neither effects nor user particles and says so once
(`render.hd.screen-fx-views`).

An emitter with a user particle shader draws its sprites with the shader's particle pipeline over a copy of the upscaled
image, depth-tested against the rewritten depth, in emitter order. HdOit's own particles and transparent meshes are
already in the image, so a user particle always draws over them. Lit particle shaders see the engine's lights and
ambient but not HD's shadows; `reads: depth` sees nothing behind, as on the standard renderer. Until the shader's
pipeline is built, its group draws as a plain HD sprite and counts as a `fallback_draw`.

HD simulates particles on the GPU, and those sprites cannot run a user shader, so an emitter whose material is a user
shader is simulated on the CPU in an HD world too (the rest stay on the GPU): its particles move as on the standard
renderer, not as HD's GPU particles do (docs/API.md, "Particles in HD projects move differently").

**Derived metadata.** The compiler records, per family, what a renderer needs to choose a path (`shader.write` and
`shader.measure` return it as `meta`): `readsWorldSpace`, `hasOpacity`, `opacityDependsOn`, `usesVertex`, `bounds`,
`usesDerivatives` (a `dpdx`/`dpdy`/`fwidth`/`aa_step` is reachable from `surface()`), `usesRawDerivatives` (the
author wrote one, so HD's hit and bake variants downgrade it), and `usesScreenInputs` (`surface()` reads `screenUv`,
or `reads: depth`). Variants are compiled per render profile: `standard` is today's set exactly; `hd` adds the hit,
bake and (with `vertex()`) vertex-callable variants. `shader.measure` also reports an `hd` cost column and cap: a
placeholder equal to the desktop numbers, marked `calibrated: false` until HD's built-in costs are measured.

**HD status.** An opaque or mask, lit or unlit family draws its own `surface()` in HD's raster G-buffer pass: the
wrapper's G-buffer variant (`Pass::GBuffer`, `crates/shader/src/wrapper/gbuffer.wgsl`) runs `surface()` and hands its
output to the engine include `hd/frame/shaders/gbuffer_user.wgsl` (versioned: `GBUFFER_ABI`), which writes the same
G-buffer attributes a built-in material writes, so a user pixel lights, shadows and reflects like any other, plus the
USER bit in the flags word (`extra.a` bit 3 on a pixel that does not scatter, `GBUFFER_USER_BIT`; no built-in pixel
sets it, and the lighting pass does not read it yet: transmission will). Each
family's pipelines are built per *variant* the scene draws (a sidedness and a skinning; the other variants are never
built): a material asks for its sidedness when it registers, a draw for its mesh's skinning, a pre-warm list for the variants
it names. At the end of the load (the app's pre-warm of the scene's draws, or the first frame) they are built together on
worker threads, and the materials' first bakes for ray-traced hits run in one submission, so nothing is created after the
load. A variant first drawn after the load (a skinned mesh no load-time draw used) is built behind the frames: its draws show
the material's fallback until it lands, counted (`HdFrameStats::user_variant_fallback_draws`,
`UserSurfaceStats::late_variants`) and named once (`render.shader.compiled-after-load`); a material registered after the
load builds its own variant at once. Draws are grouped by family. Its textures are indices into
HD's bindless table (a device without it draws the fallback and says so). A mask family's shadow passes run its `surface()` too (`Pass::GShadow`).

**HD virtual geometry.** A mesh the quality makes virtual geometry keeps a G-buffer family's material on it (since 8 Oct 2026;
before, such a submesh stayed raster): the family's own merge draws its pixels (`Pass::VgMerge`,
`crates/shader/src/wrapper/vg_merge.wgsl`'s `user_vg_surface`, appended to HD's merge module built with `USER_FAMILY`; the
engine half is `gbuffer_user.wgsl`'s `hd_user_vg_input` / `hd_user_vg_material`, the same material translation as the
raster pass). The merge rebuilds the pixel's attributes from the visibility buffer, so `in.worldDdx` / `in.worldDdy` are the
barycentrics' exact change one pixel over; a texture read takes the screen's derivatives of its UV as in a full-screen pass
(a pixel on a triangle's edge may sample one mip level off where its 2x2 quad straddles two triangles). `in.instanceRandom`
hashes the virtual-geometry instance, not the entity: stable per instance, not the raster draw's number. The merge is built
only for a family a virtual-geometry mesh uses, one pipeline per family, and draws one full-screen pass per family in view;
until it is built (or where it does not build, said once) the pixels show the material's fallback. A forward family and a
`vertex()` family keep their meshes raster.

A family the G-buffer cannot hold draws in HD's forward passes instead (`Pass::Forward`, `crates/shader/src/wrapper/forward.wgsl`,
engine include `hd/frame/shaders/forward_user.wgsl`, `FORWARD_ABI`; `wrapper::hd_path` picks the pass from the header):
`model: stylized` (opaque or mask) draws in a forward pass after the deferred lighting, writing depth, before the water; `blend: blend`,
`blend: add` and any family with `reads: depth` draw in the transparent pass with HD's built-in blended materials, sorted back
to front or, with weighted blended OIT on, in the OIT sums. They are lit with what a built-in forward material gets: the sun
with its cascade (or ray-traced) shadow, the clustered local lights with their shadows, image-based ambient and specular. A
stylized family's `light()` gets the same `Light` records as on the standard renderer (`color` is radiance over pi, `shadow` is HD's
sun shadow, and for a local light its own shadow); `ambient()` gets HD's irradiance, and `env_specular()` / `ambient_irradiance()` work.
All of them are in the standard renderer's units (1 = as bright as an unlit white): HD divides what it hands them by its pre-exposed
unit and multiplies what they return by it, so a constant term (a rim, a glow) is as bright on HD as on the standard renderer.
On HD a user surface's normal is turned around only on a back face (a two-sided material), as HD's built-in materials do; the
standard renderer turns it toward the viewer. The two agree on any mesh whose winding matches its normals.
An unlit family is lit as the built-in unlit material is (a 4% sky reflection included, `blend: add` excepted: emission only).
With `blend: blend` that reflection fades with `opacity` as the colour does (as a built-in unlit blended material's does), so a
clear unlit surface adds nothing; at opacity 1 it reflects as an opaque unlit surface does.
`reads: depth` binds the opaque depth (the passes attach it read-only), so `scene_depth_behind()` reads the surface behind.
Fog is HD's own, over the whole lit frame. A forward family's pipelines (sorted and OIT for the blended classes) are
built per variant the scene draws, as above. Not drawn yet, each said once per family by `render.hd.user-shader-partial`:
`vertex()` families (their materials draw the flat fallback), and for the drawn ones, per-entity floats, `transmission`, and
`tonemap: off` on `unlit`
(HD tonemaps every pixel).

**HD ray-traced hits.** With ray tracing on, each material of a drawn family is baked when it registers (load time, never
per frame): the bake variant of the same `surface()` (`user_bake_surface`: t = 0, instanceRandom 0.5, white vertex colour,
no wind, a flat card facing the viewer) is drawn once at 256x256 (`Renderer::set_hd_user_bake_size`) into four float
targets, read back, and encoded to the maps the built-in hit path already samples: base colour with the opacity in its
alpha, ORM, tangent-space normal and emissive. Those are ordinary textures in HD's bindless table that the material's HD
record names, so reflections, global illumination and ray-traced shadows see the surface's colours and pattern with no
per-ray cost, and a mask's holes let shadow rays through (the any-hit test reads the baked alpha). A map that comes out
constant is a factor, not a texture, so a plain material costs no memory; a patterned one costs about 350 KB per map with
mips (`Renderer::hd_user_rt_bake` reports the maps and bytes per material, `hd_user_surface_stats` the total). A family that
reads time, the entity, the view or world space (or declares per-entity floats) bakes at those fixed values and says so
once, by `render.hd.user-rt-static`. The bake at registration is load time and waits for the GPU. A later param or texture
change bakes the material again behind the frames, so no frame waits for it and rays follow raster a few frames late: a
frame submits the bake's draw, its readback maps while frames go on, a worker thread decodes and encodes it, and a later
frame registers it. A map that keeps its shape is written into its texture in place, so the bindless table is not rebuilt,
and a record whose factors come out the same is not touched. A material whose params keep changing (a gauge written at
15 Hz) is baked again at most every 250 ms (`Renderer::set_hd_user_rebake_interval`), a lone change at once, and the last
value always lands; no pipeline is created. `settle_user_materials` finishes every rebake (it waits), for a capture or
test that reads the bake without drawing frames; `hd_user_surface_stats` counts the rebakes (`rt_rebakes`,
`rt_rebakes_in_flight`, `rt_maps_rewritten`).
One approximation is not named by the code: in the G-buffer pass `SurfaceOutput.specular` above 0.5 reads as 0.5 (HD's 8-bit specular slot saturates at
the 4% dielectric; the forward passes keep it). The compile side for the other HD paths exists in `argus-shader`: the wrapper builds
ray-tracing hit-shading, UV-bake and vertex-callable variants of each shader with per-family metadata and a
crack-safety lint (tests in `crates/shader/tests/rtvg`), but only the bake variant is called from HD (for ray-traced hits); the hit-shading variant is not.

**HD in a shipped game.** The pack is compiled for the project's `render.profile`; an HD project's pre-warm lists name HD's pipelines (`gbuffer`, `gshadow`, `forward`). The shipped HD engine (`deploy,window,gamepad,hd`, no `authoring`) registers each pack family through the same call as the standard one: HD builds the family's G-buffer or forward pipelines for the variants the list names (the sidedness and skinning the export found the scene drawing) behind the load, in parallel, from the record's user text alone (no `.wgsl` in the payload, nothing parsed or validated), and creates none while playing. The shipped-game acceptance stage (`crates/app/tests/shipped_shader_pack.rs`, `hd_game`) loads an exported HD project and checks the user colours, `created_after_load == 0` and `fallback_draws == 0`.

**Array slots, draw slots and the new accessors per renderer.** The standard renderer binds an array slot as the
`texture_2d_array` its texture set already is, reading a material's range (first layer and count in the params tail);
`drawTexture` slots are group 1 bindings 7 and 8 (after the six slots and the sampler) in a second group-1 layout used
only by families that have them. HD reads an array slot's layers through a list of bindless indices after the
material's params and its `drawTexture` slots through indices in the tail, with the engine include's
`hd_user_texture_grad` / `_load` / `_size` beside `hd_user_texture`; a device without the bindless table draws these
families' PBR fallback like any other user family, said by `render.hd.user-shader-partial`
(`HdEngineSettings::allow_bindless = false` runs that path in tests). HD's ray-traced bake binds an array slot as an
array copied from its layers; it has no draw, so a `drawTexture` slot reads its default's colour there (white, or the
flat normal; a path or `builtin:noise` default also reads white), and each draw material is baked once at load like
any user material.

**Web and mobile.** The limits of section 7 fit WebGPU's default limits and CI validates every family against them
(the largest family, six array slots, two draw slots and `reads: depth`, builds on them; an array slot holds at most
64 layers, under WebGPU's default of 256);
no `f16`, subgroup or binding arrays are reachable from user code. The stricter cost column (150 / 600 units, 4 / 8
fetches) applies, and a scene with more than 64 shader families warns (`render.shader.families-many`); more than 128
is refused on web. There is no worker pool or pipeline cache API on the web, so pre-warm runs in slices (section 15)
and the stock car module takes its lite form (section 9).

## 18. Examples

`fixtures/shaders/valid/` (shared by both parsers and the compiler tests) holds complete examples: `toon`,
`glow-pulse`, `dissolve`, `water-surface`, `hologram`, `vertex-wind`, `reads-depth-blend`, `every-type` (every param
type), `max-limits`, `minimal`, the screen effects `post-outline`, `post-posterize`, `post-vignette` and the escape
hatch `raw-material`, `raw-pass`, `raw-pass-reads-scene`. `docs/examples/shaders/` holds twelve complete looks (the six
above plus marble, lava, force field, wet stone, rim light and flag wave), all compiled by a test under the default
caps and, for the web target, at worst with a cost warning. `shader.library {section: "examples"}` returns the six
originals inline.

