# Materials

A **material file** is a physical material a person or an agent writes by hand: a small JSON file
whose name ends in `.material.json`. Its keys are glTF 2.0's metallic-roughness material and its
`KHR_materials_*` extensions, flattened and without the `Factor` suffix, so a glTF material maps onto
it key for key. A `preset` gives tuned starting values for common surfaces, and any key the file sets
overrides the preset.

```json
{ "preset": "brushedMetal", "roughness": 0.25 }
```

An agent writes a material with `material.write` and checks it with `material.measure` (toolset `materials`,
[Agent tools](#agent-tools)): the first names every fault at once and what each renderer will not draw, the
second gives albedo, roughness, emission in nits and the tile size in metres on a mesh, and `material.compare` draws it
and says how it differs from a reference picture.

The schema is `packages/contract/src/material/` (Rust mirror: `crates/contract/src/material.rs`).
`packages/contract/material.schema.json` is the generated JSON Schema. It carries the defaults and
presets under `x-argus-material`.


For a look (moss in the joints, worn edges, a wet street, tile size in metres, matching a reference
picture), start at [Recipes](#recipes).
## Using one

Declare the file as a scene asset of kind `material`, then name it in a `mesh` component's
`material`. The path follows the scene's asset path rule: it is relative to the scene file, with no
absolute path, drive or `..` (docs/PROJECT-MANIFEST.md, "Asset paths inside a scene").

```json
"assets": {
  "crate":  { "kind": "glb", "path": "assets/crate.glb" },
  "steel":  { "kind": "material", "path": "materials/brushed-steel.material.json" }
},
"entities": [
  { "id": "crate-1", "components": [ { "type": "mesh", "asset": "crate", "material": "steel" } ] }
]
```

A material goes on a built-in mesh the same way: `builtin:cube`, `builtin:sphere`, `builtin:capsule`,
`builtin:plane`, `builtin:cylinder` and `builtin:cone` are the six (docs/AGENT.md, "Built-in meshes",
has their sizes). Any other `builtin:` mesh name is refused (`asset-builtin-unknown`).

The override replaces every submesh's material, as a glTF material override always has. `mesh.color`
still multiplies into the base colour. A `material` asset that is not a `.material.json` is read as
glTF and contributes its first material, as before.

### One material on part of a model

`mesh.materials` replaces the material of some submeshes and leaves the rest alone. It maps a glTF
material **name** (the model's `materials[].name`) to a scene asset of kind `material`, under the same
rule as `material`. `instancedMesh` takes the same field.

```json
{ "type": "mesh", "asset": "car", "materials": { "Body": "red-paint", "Glass": "tinted-glass" } }
```

- Every submesh whose glTF material has that name wears the given material; the others keep their own.
  Two glTF materials with one name are both replaced. Names match exactly: case, spaces and any
  character, with no pattern.
- A glTF material without a `name` cannot be matched by any key.
- With `material` also set, `material` is the default for every submesh and an entry in `materials`
  wins for its own. The combination is allowed rather than refused because it has one reading and no
  conflict: "everything this, except the glass that." Without `materials` nothing changes.
- A name the model has no material of changes nothing and logs one warning per model and name,
  `mesh-material-name-unknown`, listing the names the model does have (docs/RUNTIME-CODES.md). It is a
  warning because the contract reads the scene, not the model file, and the model can change under it.
- An entry naming an asset the scene does not hold is `dangling-asset`, at
  `/entities/N/components/M/materials/<name>`. A material file that fails to load or validate behaves as
  for `material`: a strict load refuses it, a lenient one draws the builtin material on those submeshes.
- `mesh.color` still multiplies into every submesh's base colour, overridden or not. Levels of detail
  (`MSFT_lod`) are matched by the same names.

Examples are in `fixtures/materials/examples/`:

- `brushed-steel.material.json`: a preset and one override.
- `tiles/tiles.material.json`: colour, ORM and normal maps beside it, tiled 4×4.
- `neon.material.json`: an emissive, blended, double-sided sign.

## Recipes

Short answers for the looks asked for most. Each names the keys; the tables under [Keys](#keys) and
[Layers](#layers) have the detail. Check a file with `material.write` and compare its look with a reference
picture with `material.compare` ([Agent tools](#agent-tools)).

**Tile size in metres.** `uvTransform.scale` is repeats per UV unit, not metres. `scale` = the metres one UV
unit spans on the mesh ÷ the metres one repeat should cover. A 4 m panel with UVs 0..1 and a set that should
repeat every 2 m takes `scale: [2, 2]`; every 1 m, `[4, 4]`. `material.measure` with the mesh reports
`texelDensity.tileSizeM`, and `material.compare` reports `rigTileM` on its 4 m panel. A `projection` takes
metres directly (`scaleM`, [Projection](#projection)).

**Moss, dirt or water in the joints and crevices** (between roof tiles, cobbles, stone blocks, wood grain): a
layer with `maskFrom.cavity`. Cavity is 1 - the occlusion of the base's ORM red or `occlusionTexture`, so the
base needs one. Do not paint a `layerMask` for it: coverage is the mask times `cavity`, and with no `layerMask` a
built-in or vertex-white mesh's mask is 1, so the cavity alone decides, and it lines up with the texture's own
joints wherever it tiles.

```json
{ "baseColorTexture": "roof/basecolor.png", "normalTexture": "roof/normal.png", "ormTexture": "roof/orm.png",
  "uvTransform": { "scale": [2.667, 2.667] },
  "layers": [{ "material": "moss.material.json", "maskFrom": { "cavity": [0.15, 0.45] }, "contrast": 1.5 }] }
```

with `moss.material.json` `{ "baseColor": [0.05, 0.08, 0.025, 1], "roughness": 1, "metallic": 0 }` (dark moss:
real moss is dark, about 0.05 to 0.1 linear albedo). A lower `[lo, hi]` spreads the layer out of the deepest
joints onto more of the surface; `contrast` above 1 sharpens its edge; `heightBlend` lets the base's bumps
push through it. Set how much of the surface the layer covers with the `cavity` range, and keep the base's colour
the colour of the bare surface: tinting the base toward the layer to fake more coverage matches one light and
misses the other. `cavity` reads the occlusion map of the set the colour map comes from; an occlusion map from
another set puts the layer in the wrong joints.

**Dirt rising up a wall's base:** `maskFrom.heightM` `[lo, hi]` metres. **Snow, moss or dust on top faces:**
`maskFrom.facing: "up"`. **Worn edges and corners:** `maskFrom.edge` (from the normal map) or, on a mesh,
`convexity`. **A stain that tints without flattening the relief:** `"surface": "base"`, often with
`"blend": "multiply"`. One wear signal per layer; use two layers for two.

**Wet:** lower roughness (wet stone and cobbles about 0.1 to 0.35 against 0.6 to 0.9 dry) and a darker colour
(about 0.7 to 0.85 x `baseColor`); puddles are a layer with low roughness in the low spots (`maskFrom.cavity`
with a high `lo`).

**Glowing windows or signs:** `emissiveTexture` with an `emissive` colour; `emissiveStrength` 1 is 33,333
nits, so 150 nits of warm window glass is about `emissiveStrength` 0.0045 over a full-white map
(`material.measure` reports the nits).

**Matching a reference picture:** pick the set whose colour map looks most like it, set the factors, then
`material.compare` under `key` and under `backlit` and act on the hints until both read close to 1 and the
colour difference is small. Check the tile size (`rigTileM`) against the brief. When two sets look alike, or a
layer hides most of the surface, do not choose by eye: draw a plain material from each (same tile size, no
layer) and compare each with the reference. The right set's `grid` is even (its pattern lines up cell by cell);
a look-alike's has large differences in most cells even when the mean colours agree. Then add the layer to the
set that won.

## Keys

Every key is optional; `{}` is the default material. Colours are **linear** RGB(A), not sRGB and not
hex. Unknown keys are refused (see [Validation](#validation)).

| Key | glTF source | Range | Default | Notes |
| --- | --- | --- | --- | --- |
| `preset` | – | a preset name | none | See [Presets](#presets). |
| `name` | `name` | any string | none | Informational. |
| `baseColor` | `baseColorFactor` | 4 numbers, each 0..1 | `[0.8, 0.8, 0.8, 1]`; `[1, 1, 1, 1]` with a `baseColorTexture` | Three numbers are accepted and stored with alpha 1. |
| `baseColorTexture` | `baseColorTexture` | texture ref | none | sRGB; multiplies `baseColor`; its alpha is opacity. |
| `metallic` | `metallicFactor` | 0..1 | 0; 1 with an `ormTexture` or `metallicRoughnessTexture` | |
| `roughness` | `roughnessFactor` | 0..1 | 0.6; 1 with an `ormTexture` or `metallicRoughnessTexture` | Perceptual. |
| `metallicRoughnessTexture` | `metallicRoughnessTexture` | texture ref | none | Linear: G roughness, B metallic. |
| `ormTexture` | – | texture ref | none | Linear: R occlusion, G roughness, B metallic (an "ARM" map). Set this or `metallicRoughnessTexture`, not both. |
| `occlusionTexture` | `occlusionTexture` | texture ref | none | Linear, R. Packed into the ORM's R when the file also has an ORM or MR map. |
| `heightTexture` | – | texture ref | none | Linear, R (read as linear whatever the image's own tag says). Terrain layers blend by it: the higher layer wins an even split. Other surfaces ignore it: a mesh draws no relief from it, and says so with one warning (`build.material-height-on-mesh` in the build and on a scene write, a log line at load). |
| `occlusionStrength` | `occlusionTexture.strength` | 0..1 | 1 | |
| `normalTexture` | `normalTexture` | texture ref | none | Linear, tangent space, +Y up (OpenGL/glTF). |
| `normalScale` | `normalTexture.scale` | any finite number | 1 | Scales X and Y. |
| `emissive` | `emissiveFactor` | 3 numbers, each 0..1 | `[0, 0, 0]`; `[1, 1, 1]` with an `emissiveTexture` | |
| `emissiveTexture` | `emissiveTexture` | texture ref | none | sRGB; multiplies `emissive`. |
| `emissiveStrength` | `KHR_materials_emissive_strength` | 0 or more | 1 | 1 is as bright as an unlit white surface. |
| `alphaMode` | `alphaMode` | `opaque`, `mask`, `blend` | `opaque` | Lower case (glTF writes `BLEND`). |
| `alphaCutoff` | `alphaCutoff` | 0..1 | 0.5 | Only with `mask`. |
| `doubleSided` | `doubleSided` | boolean | false | |
| `clearcoat` | `KHR_materials_clearcoat` | 0..1 | 0 | |
| `clearcoatRoughness` | `KHR_materials_clearcoat` | 0..1 | 0 | |
| `clearcoatGeometricNormal` | – | boolean | false | Opt in: with a `normalTexture` and `clearcoat`, shade the coat (and, as the G-buffer has one normal, the base) on the geometric normal, as glTF specifies for a coat without its own normal map; the coat takes over fully from `clearcoat` 0.1 and blends in below. Clean mirror reflections, but the base normal map's detail is lost under the coat. HD renderer only. |
| `clearcoatOrangePeel` | – | 0..1 | 0 | Strength of the clear coat's procedural wavy normal, the dimpled look of sprayed lacquer. Needs `clearcoat`. |
| `clearcoatOrangePeelScale` | – | millimetres, 2..30 | 8 | Wavelength of the orange peel, in object space with the instance's scale included. |
| `specular` | `KHR_materials_specular` | 0..1 | 1 | Strength of a dielectric's reflection, at every angle: 0 takes the sheen off a matte surface seen at a grazing angle (a road ahead). Both renderers. |
| `specularColor` | `KHR_materials_specular` | 3 numbers, each 0 or more | `[1, 1, 1]` | |
| `ior` | `KHR_materials_ior` | 1 or more | 1.5 | |
| `transmission` | `KHR_materials_transmission` | 0..1 | 0 | |
| `sheenColor` | `KHR_materials_sheen` | 3 numbers, each 0..1 | `[0, 0, 0]` | HD only; see [What each renderer draws](#what-each-renderer-draws). |
| `sheenRoughness` | `KHR_materials_sheen` | 0..1 | 0 | HD only. |
| `subsurface` | – | 0..1 | 0 | Share of the diffuse light that scatters under the surface, as in skin. HD blurs it in screen space; see [Skin and hair](#skin-and-hair). More than 0 turns the clear coat off. |
| `subsurfaceRadius` | – | 3 numbers in metres, each 0 or more | `[0.0022, 0.00072, 0.00034]` | How far light scatters per channel (Burley's diffusion distance `d`). The default is skin's: red travels furthest. |
| `subsurfaceTransmission` | – | 0..1 | 0 | Sunlight through thin parts: ears and fingers glow red when the sun is behind them, by how much flesh the light crossed. Needs `subsurface` above 0 and sun shadow cascades, so Low (no scattering) and a ray-traced sun (Ultra) draw none; only the sun, not lamps. HD only. |
| `anisotropyStrength` | `KHR_materials_anisotropy` | 0..1 | 0 | HD only. The highlight stretches along the direction, as on brushed metal. |
| `anisotropyRotation` | `KHR_materials_anisotropy` | radians | 0 | Turns the direction from the mesh tangent toward the bitangent. |
| `anisotropyTexture` | `KHR_materials_anisotropy` | texture ref | none | Linear: RG a tangent-space direction, B scales `anisotropyStrength`. |
| `anisotropyPattern` | – | `uv`, `radial`, `circular` | `uv` | Where the brushed direction points: along the mesh tangent (`uv`, as in glTF), outward from the part's centre (`radial`), or around its axis (`circular`, machined wheel faces). The centre is the mesh's local origin unless `anisotropyTexture` is given. |
| `anisotropyAxis` | – | `x`, `y`, `z` | `y` | The mesh-local axis `radial` and `circular` turn about. Wheels from DCC tools often spin about local X, so a scene sets this; `wheelAluminium` leaves it unset. |
| `iridescenceFactor` | `KHR_materials_iridescence` | 0..1 | 0 | HD only. A thin film's interference colours, as on soap bubbles or oil. |
| `iridescenceIor` | `KHR_materials_iridescence` | 1 or more | 1.3 | The film's index of refraction. |
| `iridescenceThicknessMaximum` | `KHR_materials_iridescence` | nanometres, 0 or more | 400 | glTF's thickness runs from a minimum to this maximum through a thickness map. HD reads no map yet and uses this as the film's thickness. |
| `diffuseTransmissionFactor` | `KHR_materials_diffuse_transmission` | 0..1 | 0 | HD only. Share of the diffuse light that passes through a thin surface lit from behind: leaves, paper, lampshades. |
| `diffuseTransmissionColorFactor` | `KHR_materials_diffuse_transmission` | 3 numbers, each 0..1 | `[1, 1, 1]` | Tint of the transmitted light. |
| `flakeColor` | – | 3 numbers, each 0..1 | none (off) | Colour of the metallic flakes under the clear coat. The `carPaint` preset sets it to the resolved `baseColor` × 1.2. |
| `flakeDensity` | – | 0..1 | 0.5 | Share of the surface the flakes cover. Needs `flakeColor`. |
| `flakeSize` | – | millimetres, 0.05..2 | 0.3 | Flake cell size in object space, instance scale included, so it does not swim with the UV scale. Below about one pixel the flakes fade to their averaged tint. Needs `flakeColor`. |
| `flakeSparkle` | – | 0..1 | 0.5 | How strongly each flake's random orientation makes it glint (the spread of the flake normals). Needs `flakeColor`. |
| `flipColor` | – | 3 numbers, each 0..1 | none (off) | Colour seen at grazing angles: a two-tone or colour-flip paint, blended by view angle. |
| `flipStrength` | – | 0..1 | 1 | How far `flipColor` replaces the base colour at grazing angles. Needs `flipColor`. |
| `layers` | – | up to 3 of `{ material, mask, contrast, heightBlend, edgeWidth, edgeNoise, edgeNoiseM, edgeStreakM, edgeNoiseSpace, maskFrom, blend }` | none | Materials blended over this one before lighting, in order; see [Layers](#layers). |
| `layerMask` | – | texture ref | none | Linear. The layers' mask, read with `uvTransform`. Absent: the vertex colour masks the layers. |
| `uvTransform` | `KHR_texture_transform` | `{ offset, scale, rotation }` | identity | Applies to every texture. `scale` is tiling and is non-zero; `rotation` is radians. |
| `uvScroll` | – | `[u, v]`, UV units per second | none (still) | Slides every texture over time with no script: a boost-pad chevron, a conveyor belt, a water channel, a sign. See [UV scroll](#uv-scroll). |
| `projection` | – | `{ mode, space, scaleM, sharpness }` | none (the mesh's UV0) | Maps the textures from the surface position instead of UV0; see [Projection](#projection). |
| `variation` | – | `{ value, hue }`, each 0..1 | none | Each instance's own shade: darker by up to `value`, shifted towards its own hue by up to `hue`; see [Variation](#variation). |
| `maskGraph` | – | `{ nodes, layers }` | none | A small typed graph that computes layers' coverage in place of `mask` and `maskFrom`; see [Mask graph](#mask-graph). |
| `shading` | `KHR_materials_unlit` for `unlit` | `pbr`, `unlit`, `vertex`, `toon`, `hair` | the preset's, else the scene's | How the surface is lit; see [Shading and filtering](#shading-and-filtering). |
| `toonSteps` | – | whole number 1..16 | 3 | Light bands of `toon` shading. |
| `filter` | – | `linear`, `nearest`, `three-point` | the scene's | How the textures are filtered; see [Shading and filtering](#shading-and-filtering). |
| `shader` | – | asset id or path | none | A `*.shader.wgsl` whose `surface()` draws the material; see [Shader files](#shader-files). |
| `params` | – | object | none | Values for the shader's `param` and `texture` names; needs `shader`. |
| `castShadows` | – | `true`, `false` | `true` | Whether the surface casts shadows. |

The defaults are glTF's, with three exceptions: `baseColor`, `metallic` and `roughness` are the
engine's builtin grey dielectric. glTF's own defaults describe a white rough metal. A hand-written
file that sets only `baseColor` means paint, not metal. A glTF importer writes every key it has, so
the difference never reaches an import.

**A map that is set draws at full value.** The exceptions above are the defaults of a file with no map
for the factor. When the file sets the map and nothing else sets the factor, the factor is 1, as in
glTF: a `baseColorTexture` with no `baseColor` resolves `baseColor` to `[1, 1, 1, 1]`; an `ormTexture`
or `metallicRoughnessTexture` with no `roughness` or `metallic` resolves both to 1 (the map's G and B
channels then carry the values); an `emissiveTexture` with no `emissive` resolves `emissive` to
`[1, 1, 1]`, so the map glows. Only the default changes: a factor the file sets, or its `preset`
sets, stands. Before this rule a colour map was drawn at 80% of itself, a roughness map at 60%, the
metal channel was ignored and an emission map did nothing. To keep that old look, write the factor
explicitly (`"baseColor": [0.8, 0.8, 0.8, 1]`, `"roughness": 0.6`, `"metallic": 0`). Both resolvers
(Rust and TypeScript) apply the rule, so the editor's material panel shows the resolved value for an
absent field.

One trap follows from it. The engine has no key for a roughness map alone, and a scanned set ships a grey
`roughness.png`. Set that as `ormTexture` (or `metallicRoughnessTexture`) and the map's B channel, which
copies its G, is now read as metal. When such a map loads as a grey image (single channel, or R, G and B
equal) while `metallic` resolves above 0, the app logs one warning per material; write `"metallic": 0`, or
pack a real ORM (R occlusion, G roughness, B metal).

`uvTransform` follows KHR_texture_transform: `uv' = offset + R(rotation) × (scale × uv)`. A glTF
import has already baked its transform into the model's UVs, so an imported material never carries
one.

### UV scroll

`uvScroll: [u, v]` slides the material's textures by `[u, v]` UV units a second: the offset is added to the
UV, so `[0, 1]` moves the picture one whole repeat a second up the image (toward v = 0, the top), `[0.25, 0]` a
quarter repeat a second to the left, and negative numbers run the other way.
No script runs: the renderer works the offset out each frame from the game clock (`sim.now`, in f64,
wrapped to one repeat, so a long session keeps its precision and the wrap never shows). A paused game
stands still, a recording replays the same frames, and the wall clock is never read.

```json
{ "baseColorTexture": "chevrons.png", "shading": "unlit", "uvTransform": { "scale": [1, 4] }, "uvScroll": [0, -1] }
```

The standard renderer adds the offset to the UV of the base colour, normal and ORM maps, in every shading
(`pbr`, `unlit`, `toon`, `vertex`); it applies on top of the mesh's UVs. The texture has to repeat (the
default sampler does) or the edge smears. A material without `uvScroll` draws bit for bit as before and
costs nothing per frame. Not drawn yet: HD, the standard renderer's stock car-paint and layered surfaces,
and a material with a `shader` (which reads `time` itself); each logs `render.uv-scroll-ignored` once and
keeps its textures still, and a `shader` also gets the contract's `material-field-ignored` warning.

### Projection

A model whose UVs are missing or collapsed (a kit building) has nothing to sample a texture by.
`projection` maps the material's textures (base colour, metallic-roughness or ORM, normal,
occlusion, emissive) from the surface position instead: along the three axes of `space`, blended
by the surface normal. `uvTransform` still applies on top, to the projected UV.

| Field | Values | Default | Meaning |
|---|---|---|---|
| `mode` | `triplanar` (required) | – | Project along x, y and z and blend the three samples by the normal. |
| `space` | `world`, `object` | `world` | `world` uses fixed axes, so adjacent pieces of a kit stay continuous across the seam. `object` uses the instance's axes and follows its transform, so the texture moves, turns and scales with the model. |
| `scaleM` | above 0 | 1 | Metres per texture repeat. |
| `sharpness` | 1..64 | 4 | The exponent of the blend across the planes: higher keeps the planes apart with a narrower seam. |

```json
{ "baseColorTexture": "textures/plaster.png", "normalTexture": "textures/plaster-n.png",
  "projection": { "mode": "triplanar", "space": "world", "scaleM": 2, "sharpness": 4 } }
```

A layer's own `projection` applies to that layer, whatever the base does: a kit piece whose base
is its palette (mapped by UV0) can take a tiled set over it as a projected layer, and the layer
projects while the palette keeps its UVs (HD; the variants that compile the projection are the
ones the material draws with when it or one of its layers projects).

Limits: normal maps are reoriented per plane (a whiteout blend, as in UDN), not sampled through
the mesh's tangents. Only textures are projected; vertex colour and other vertex data are read as
they are. The key is optional, and a file without it maps by UV0 as before.

### Variation

`variation` gives every instance of the material its own shade, so the copies of one kit piece
differ: each multiplies its colour (the base and every layer) by `1 - value x u` for its own `u`
in 0..1, and by a hue tint whose channels are `1 - hue x (1 - cos(a - k 2pi/3)) / 2` for its own
angle `a`. Nothing brightens and alpha is kept.

```json
{ "baseColorTexture": "planks.png", "variation": { "value": 0.25, "hue": 0.1 } }
```

Each instance's draw is a hash of its entity's scene id (and, in an `instancedMesh`, of the
instance's stable id), never its load, spawn or draw order: a scene draws the same shades on every
load and machine, and an edit elsewhere in the scene does not reshuffle them. An entity without a
scene id (spawned at run time) is not varied. The jitter is applied to the instance colour on the
CPU, so every renderer draws it and it costs no shader work; a scene without a `variation` builds
exactly the instance colours it did before.

Limits: an `instancedMesh` kept whole as virtual geometry (a white set on HD) draws its rows from
the renderer's own copy and is not varied. The shade is per instance, not per board or tile.

### Shading and filtering

`shading` and `filter` say how the surface is drawn rather than what it is made of. Absent, a
material takes its preset's shading (only `hair` sets one), else the scene's `meta.render.shading` and `meta.render.filter` (docs/RENDERING.md,
"Stylised looks"), which default to `pbr` and `linear`. So one scene-level word restyles every
material, and a material that sets its own keeps it.

| `shading` | What it draws |
| --- | --- |
| `pbr` | Lit per pixel: GGX specular, Lambert diffuse, the sky's image-based light. |
| `unlit` | `baseColor` × colour map × vertex colour, exactly: no light, no tonemapping, no exposure. |
| `vertex` | Gouraud: diffuse light and the sky's ambient worked out at each vertex and interpolated across the triangle, no specular. The sun's shadow still falls per pixel. The late-1990s look. |
| `toon` | Diffuse light in `toonSteps` bands plus a rim. |
| `hair` | Hair cards: fibre highlights running across the strands, which follow the mesh tangent (HD); see [Skin and hair](#skin-and-hair). The standard renderer draws it as `pbr`. |

Every mode multiplies in the mesh's vertex colours (glTF `COLOR_0`).

| `filter` | What it draws |
| --- | --- |
| `linear` | Bilinear with mipmaps, or whatever a glTF model's sampler says. |
| `nearest` | The nearest texel: hard texel edges when a texture is magnified. |
| `three-point` | Each pixel blends the three nearest texels (a triangle, not a square), the filter some late-1990s console hardware used. Four texture reads instead of one. |

A glTF model's texture sampler (its filters and its wrap modes: repeat, clamp, mirrored repeat) is
honoured; a material file's textures repeat and filter linearly unless `filter` says otherwise.

```json
{ "baseColorTexture": "bricks.png", "shading": "vertex", "filter": "nearest" }
```

This is not a fourth material. `unlit` and `toon` are the engine's unlit and toon materials, so HD
draws them too. `vertex`, `filter` and the scene defaults are drawing styles of the standard
renderer's one surface pipeline; HD lights a `vertex` material per pixel and filters linearly.

To see a material before placing it, capture it on a preview shape: `POST /capture` with
`{"kind": "material", "material": "<asset id or the file's JSON>", "shape": "sphere"}`
(`play.capture` takes the same), drawn alone under the scene's sky, sun and look.

### Texture references

A texture reference is either of these:

- **A scene asset id** of kind `texture`, e.g. `"brick-albedo"`. The file is that asset's `path`.
  Use this for textures several materials share.
- **A path relative to the material file's folder**, e.g. `"tiles-albedo.png"` for a file beside it.
  As with scene asset paths, an absolute path, a drive or a `..` segment is refused
  (`material-texture-invalid`), so the material moves with its folder. Keep a material and its maps
  in one folder: `materials/tiles/tiles.material.json` beside `materials/tiles/tiles-*.png`.

The id is tried first. PNG, JPEG and KTX2 load through the same loader as glTF textures, and read
from the exported payload in a shipped game. Colour and emission maps are read as sRGB and every
other map as linear data, whatever the file says.

A texture that does not load leaves its slot empty and prints one warning. The material still
loads, even in strict mode. An editor build (Play, test workers, export) counts every texture a
material file names by path as one of the scene's files. The build packs each one, refuses to run
when one is missing, and marks the artifact stale when one changes.

## Shader files

A material with a `shader` is drawn by that shader instead of by glTF's factors and maps. The shader is
a `*.shader.wgsl` file (asset kind `shader`): a short header of `//!` lines, then WGSL with a
`surface()` function. `docs/SHADERS.md` has the format; this section is what the material file adds.

```json
{ "shader": "shaders/dissolve.shader.wgsl",
  "params": { "edgeColor": [0.2, 0.6, 1.0], "albedo": "crate.png" },
  "castShadows": true }
```

- `shader` is a scene asset id of kind `shader`, or a path relative to the material file, with the
  texture path rule (no leading slash, drive or `..`). A file that is not there is
  `material-shader-missing`; a fault in its header is reported under the header's own code
  (`shader-header-invalid`, `shader-param-invalid`, `shader-texture-invalid`, `shader-model-mismatch`)
  with the shader's `file:line:col`.
- Each `params` key must be a `param` or `texture` the shader declares, else
  `material-shader-param-unknown` (it suggests the nearest name; an `instance` value is set per entity
  by a behaviour with `ctx.render.setShaderParam`, not here). A value of the wrong type is `shader-param-invalid`; outside the declared
  `[min, max]` it is `material-shader-param-range`.
- A colour is 3 linear numbers or `#RRGGBB`. A texture is `white`, `black`, `flat`, `builtin:noise` or a
  path relative to the material file.
- `baseColor`, every texture map, `shading` and `layers` do nothing on a material with a shader
  (`material-field-ignored`, a warning). `doubleSided`, `alphaCutoff`, `castShadows` and the extension-lobe
  keys still apply. `params` without a `shader` also warns.

## Presets

A preset sets only the keys it lists; the rest are the defaults. Colours are linear. `carPaint` also derives its `flakeColor` at expansion: the resolved `baseColor` RGB × 1.2, so a file's own `baseColor` paints the flakes too; a `flakeColor` in the file wins.

| Preset | What it is |
| --- | --- |
| `plastic` | Smooth-ish white dielectric, a moulded plastic housing; set baseColor for the colour. |
| `paintedMetal` | Satin paint over metal: a dielectric (the paint is what you see), a little rougher than plastic. |
| `brushedMetal` | Brushed aluminium: full metal, medium roughness (the brushing direction is not modelled). |
| `polishedMetal` | Polished steel or chrome: full metal, near-mirror. |
| `rubber` | Black rubber: dark, rough, a weak reflection. |
| `glass` | Clear glass: smooth, fully transmissive, ior 1.5 (HD draws it as thin glass). |
| `carPaint` | Metallic car paint: a coloured base with flakes (flakeColor = baseColor * 1.2, whatever baseColor the file sets) under a full clear coat with a light orange peel. |
| `wheelAluminium` | Machined aluminium wheel face: full metal, brushed in circles about the wheel's axis. The axis is unset (y): wheels from DCC tools often spin about local X, so a scene sets anisotropyAxis. |
| `wheelGlossBlack` | Gloss black wheel: near-black dielectric under a smooth clear coat. |
| `tyreRubber` | Tyre rubber: dark, rough, with a dusty grey sheen. Lettering comes from the metallicRoughnessTexture or ormTexture roughness channel (smoother and darker where the letters are). |
| `fabric` | Cloth: fully rough with a soft sheen and a weak reflection (HD draws the sheen). |
| `wood` | Unfinished wood: a warm brown dielectric, fairly rough; pair it with a grain texture. |
| `concrete` | Cast concrete: light grey, matte. |
| `emissiveLight` | A lit panel or bulb: warm white emission at 5x unlit white, so it blooms. |
| `skin` | Human skin: light scatters under the surface (HD), and the reflection is weak (F0 0.028); set baseColor for the tone. |
| `hair` | Hair cards: fibre highlights along the mesh tangent (HD), alpha-masked strands, both sides; set baseColor for the hair colour. |
| `moss` | Moss: a dark olive green, fully rough, a weak reflection; a layer over stone, bark or roof tiles. |
| `wet` | A water film or puddle: dark and near-mirror; a layer over a dry surface, gathered in its hollows by heightBlend. |
| `dirt` | Dry soil or dust: a warm mid brown, fully rough; a layer over floors, walls and props. |
| `snow` | Snow: bright white with a cool tint, fairly rough; with heightBlend it fills the hollows first and covers the high points as the mask rises. |

The numbers are the `MATERIAL_PRESETS` table in `packages/contract/src/material/presets.ts`. It is
emitted into `crates/contract/src/gen.rs` and `material.schema.json`, so both languages resolve a
preset from one table. Resolution happens in three layers, and each later layer wins: the defaults,
then the preset, then the file's keys.

## Validation

Both languages report the same violations, with the same codes, pointers and messages
(`fixtures/materials/manifest.json` is the shared list; `argus-validate --material <file>` prints
the Rust verdict and the resolved material).

| Code | Severity | When |
| --- | --- | --- |
| `schema-shape` | error | Not JSON, not an object, a wrong type, an **unknown key**, or a `preset` / `alphaMode` / `shading` / `filter` that is not a member. |
| `material-value-range` | error | A number outside its range, as in the table above; more than 3 `layers` without a `maskGraph` (more than 6 with one); a layer's `contrast` below 1; a number too large for a 32-bit float (the GPU reads these as f32), one that rounds to 0 where 0 is refused, and a `[lo, hi]` range whose ends are equal once rounded. |
| `material-texture-invalid` | error | A texture reference, or a layer's `material` reference, is empty, absolute, names a drive or has a `..` segment; or `ormTexture` and `metallicRoughnessTexture` are both set. |
| `material-height-from-mixed` | error | The layers of one material that have `maskFrom.heightM` disagree on `maskFrom.heightFrom`. |
| `material-mask-from-wear-both` | error | A layer's `maskFrom` has more than one of `edge`, `cavity`, `ao`, `convexity` and `concavity` (one wear signal per layer). |
| `material-graph-too-many-nodes` | error | A `maskGraph` has more than 32 nodes. |
| `material-graph-unknown-node` | error | A `maskGraph` node's `in`, or an entry of its `layers`, names a node that is not in `nodes`. |
| `material-graph-cycle` | error | The nodes of a `maskGraph` feed each other in a loop. One report, at the lowest id on the loop. |
| `material-layer-needs-graph` | error | A layer past the third (a material with a `maskGraph` can have up to 6) has no node in `maskGraph.layers`: such a layer has no coverage of its own. Reported at `/maskGraph/layers/<i>`. |
| `material-graph-over-budget` | warning | A material with a `maskGraph` costs more than an HD budget tier allows after its layers' `lod` drops: 9000 units for Low, Medium and the XR profiles, 16000 for High and Ultra. One report per tier, at `/maskGraph`, naming the cost, the budget and the layers the runtime drops. See [Cost and budgets](#cost-and-budgets). |
| `material-field-ignored` | warning | A key that has no effect: `alphaCutoff` without `mask`, `normalScale` without a normal map, `occlusionStrength` without an occlusion or ORM map, an `emissiveTexture` or `emissiveStrength` on a black `emissive`, `clearcoatRoughness` with no clear coat, `sheenRoughness` with no sheen, `clearcoat` on a material with `subsurface` above 0, `subsurfaceRadius` while `subsurface` is 0, `subsurface` with `hair` shading, `anisotropyRotation`, `anisotropyTexture`, `anisotropyPattern` or `anisotropyAxis` while `anisotropyStrength` is 0 (the axis also with `uv` brushing), `clearcoatGeometricNormal` with no clear coat or no `normalTexture`, `clearcoatOrangePeel` with no clear coat, `clearcoatOrangePeelScale` with no orange peel or clear coat, `flakeDensity`, `flakeSize` or `flakeSparkle` without `flakeColor`, `flipStrength` without `flipColor`, `iridescenceIor` or `iridescenceThicknessMaximum` while `iridescenceFactor` is 0, `diffuseTransmissionColorFactor` while `diffuseTransmissionFactor` is 0, `layerMask` without `layers`, `sheenColor`, `anisotropyStrength`, `iridescenceFactor` or `diffuseTransmissionFactor` on a skin (`subsurface` above 0) or `hair` material, `toonSteps` without `toon` shading, a `filter` with no texture, a layer's `maskFrom.edge` without a normal map or `maskFrom.cavity` without an occlusion or ORM map, a `maskGraph.layers` longer than the material's `layers`, a `maskGraph` node no entry of `layers` reaches, a `mask` or `maskFrom` on a layer past the third, an `edgeNoiseSpace` without an `edgeNoise` above 0. |

Unknown keys and names come with the key they were probably meant as:

```
unknown key "baseColorFactor"; did you mean "baseColor"?
unknown key "normal_texture"; did you mean "normalTexture"?
uvTransform: unknown key "tiling"; did you mean "scale"?
preset "car-paint" is not one of plastic, paintedMetal, …; did you mean "carPaint"?
alphaMode "BLEND" is not one of opaque, mask, blend; did you mean "blend"?
shading "gouraud" is not one of pbr, unlit, vertex, toon, hair; did you mean "vertex"?
```

The suggestion is chosen in this order. First comes glTF's own spelling of the key, plus a few
common guesses (`color`, `albedo`, `metalness`, `tiling`). Then the camelCase of a snake_case or
kebab-case spelling, then a case-insensitive match, then the closest key within two edits.

glTF's trap is worth knowing: its `emissiveFactor` defaults to black, so an emission map on its own
glows nowhere. Here an `emissiveTexture` with no `emissive` resolves `emissive` to `[1, 1, 1]` and the
map glows. `material-field-ignored` fires only when the file (or its preset) sets `emissive` to black on
purpose, which makes the map and `emissiveStrength` do nothing.

At load, a material file that does not validate fails a strict load (`LoadOpts::placeholder_assets`
off) with its violations, as a model that does not load does. Otherwise (the default) the builtin
material stands in, and one warning names the file and the first violation. Warnings print once
per key.

## Skin and hair

Characters use two presets, and each keeps working when its keys are overridden.

```json
{ "preset": "skin", "baseColor": [0.62, 0.42, 0.33] }
{ "preset": "hair", "baseColorTexture": "strands.png", "baseColor": [0.2, 0.12, 0.06] }
```

- **Skin** (`subsurface` above 0).
  - HD takes that share of the pixel's diffuse light and spreads it over the surface around it, by
    `subsurfaceRadius` per channel. The kernel is the screen-space separable one from Jimenez et
    al. 2015, fitted to Burley's diffusion profile.
  - The scattering moves light and keeps its total: soft terminators, red bleeding into shadow
    edges, softened pores.
  - The colour map is applied half before and half after the scattering, so texture detail
    stays sharp.
  - A scattering material has no clear coat.
  - Cost on an RTX 5080 at 1440p: about 0.06 ms when skin fills half the screen, and nothing
    when no skin is in view. Quality knob `materials.sss` (off on Low).
- **Hair cards** (`shading: "hair"`).
  - Each card's strands run along its mesh tangent (the direction of increasing U in a glTF
    model's tangents), and the highlights run across them.
  - The lobes follow Karis 2016: a white highlight toward the root, a tinted one toward the tip,
    and light through the fibres from behind.
  - Use `alphaMode: "mask"`. Texels at or above `alphaCutoff` are opaque. The thinner fringe
    below it is dithered, and the temporal upscaler turns the dither into soft coverage.
  - The quality knob `hair.blendedFringe` (Ultra) blends the fringe in the order-independent
    transparency pass instead. It looks softer at silhouettes, and costs about three times as
    much.
  - Cost on an RTX 5080: about 0.09 ms for a close-up head of 200 cards.
- **Non-character uses.** Both are general. `subsurface` suits wax, marble, jade or leaves;
  `hair` shading suits fur, grass cards and any strand-like card.

## Layers

`layers` blends up to three other materials over this one, in order, before the surface is lit (up to six on HD with a `maskGraph`, see [Six layers](#six-layers)).
This is how dirt over paint, wear over metal or a wet patch over asphalt is drawn. Each entry is:

| Key | Values | Default | Notes |
| --- | --- | --- | --- |
| `material` | a scene asset id of kind `material`, or a `.material.json` path relative to this file | required | The layer's own layers and HD-only effects are not read, so layers cannot cycle. |
| `mask` | `r`, `g`, `b`, `a` | `r` | The mask channel. |
| `contrast` | 1 or more | 1 | Sharpens the mask around 0.5; 1 keeps it as is. |
| `heightBlend` | 0..1 | 0 | How much the heights of the layer and of the surface below it (the base and the layers before it) move the mask's edge. 0 is the plain mask. |
| `edgeWidth` | 0..1 | 0 | How soft the layer's edge is. 0 is the edge `contrast` and `heightBlend` make; 1 fades the layer in smoothly along the mask (where the mask is full, the layer still covers). |
| `edgeNoise` | 0..1 | 0 | How far deterministic world-space noise (object space on a prop) moves the edge, as a share of the layer's mask value. 0 is no noise. |
| `edgeNoiseM` | 0.01..1000 | 1 | The edge noise's wavelength in metres. Only matters when `edgeNoise` is above 0. |
| `edgeStreakM` | 0.01..1000 | none | The edge noise's wavelength in metres along the object's up axis, so a long value stretches the noise into vertical streaks (rain, grime drips). Only matters when `edgeNoise` is above 0 (a warning otherwise); HD only, and absent keeps the same wavelength every way. |
| `edgeNoiseSpace` | `object`, `placed` | `object` | Where the edge noise is taken. `object` is the point on the mesh, so every placed copy of a mesh has the same noise. `placed` is the copy's point in the world along the object's own axes (streaks still run down it), so copies of one kit piece differ and copies side by side with the same rotation carry the noise across. For pieces that stay put; see the limits below. Only matters when `edgeNoise` is above 0; HD only. |
| `maskFrom` | `{ heightM, facing, heightFrom, edge, cavity, ao, convexity, concavity }` | none | A procedural term multiplied with the layer's mask channel: `heightM` is `[lo, hi]` metres above the bottom of the mesh's bounds (full cover up to `lo`, none at `hi`), `facing` is `up`, `down` or `any` (default), `edge` or `cavity` a wear term from the material's own normal or occlusion map, `ao`, `convexity` or `concavity` one from the mesh's baked geometry. HD only; see below. |
| `lod` | `low`, `medium`, `high`, `ultra` | none | The lowest HD quality preset that keeps the layer: a lower preset draws the material without it (and a `maskGraph`'s outputs follow the layers that stay). Absent: every preset keeps it. Works on any layered material; HD only. |
| `blend` | `mix`, `multiply` | `mix` | How the layer's base colour combines with the colour below. `mix` blends toward the layer's colour by the mask. `multiply` multiplies the colour below by the layer's colour by the mask (`c * mix(1, layer, w)`), so the surface keeps its hue and only darkens. Every other field mixes in both modes, unless `surface` is `base`. |
| `surface` | `layer`, `base` | `layer` | What the layer changes where it covers. `layer` mixes every field toward the layer's own: its normal, roughness, occlusion and the rest. `base` changes the colour only (by `blend`) and keeps the surface below's normal, roughness, occlusion and every other field, so a stain, soot or a dirt film tints without flattening the relief, even from a layer file with no maps. HD only; the standard renderer mixes every field and names it among what it does not draw. |

The mask is `layerMask`, read with this material's `uvTransform`. Without it, the mesh's vertex
colour is the mask, and it no longer tints the surface. Each layer reads its own textures with its
own `uvTransform`, so a dirt layer can tile on its own. Base colour, normal, roughness, metallic,
occlusion, emission, clear coat and specular blend.

With `heightBlend`, the edge follows the surfaces' heights: where the mask is half way the higher
surface wins, so moss gathers between cobbles and snow on top of bumps, while a mask of 0 or 1
stays 0 or 1. A material's height is its occlusion map (`occlusionTexture` or the red of
`ormTexture`), and without one its colour's luminance. `contrast` applies after the height blend.

`edgeWidth`, `edgeNoise` and `edgeNoiseM` are optional and soften and break up the edge. A file
that writes none of them draws and resolves exactly as before (no `edge*` key appears in the
resolved layer). A terrain layer takes the same three keys (below).

`edgeStreakM` is optional too: with `edgeNoise` above 0 it sets the noise's wavelength along the
object's up axis apart from `edgeNoiseM`, so a long value (4 m against 0.3) stretches the noise into
vertical streaks. Only a material that writes it compiles it (a terrain layer has none).

`edgeNoiseSpace: "placed"` is for modular kits: by default the noise is in the mesh's own space, so
every wall piece of every house carries the same stains. Placed, each copy reads the noise where it
stands, the same on every run and on raster and virtual geometry alike. Copies side by side with the
same rotation continue each other's noise across the seam; a copy turned another way reads the
noise along its own axes, so it does not join its neighbour. Limits:

- It is for pieces that stay put. On anything that moves, the noise slides over the surface: a moving
  object, a skinned mesh, and a plant swaying in the wind.
- A blended (transparent) piece reads the noise in the world in both modes.
- Under a non-uniform scale the noise stretches with the piece, as `object` noise does.
- The noise point is the position in the world, so far from the world's origin its precision
  coarsens: about a millimetre at 10 km, which only shows on wavelengths under a few centimetres.
- The noise costs the same as before (no extra LOD cost); only a material that writes it compiles it.

`maskFrom` is optional and gives the layer a coverage that comes from the surface instead of only
from painted masks, so dirt can rise up a wall's base or snow can sit on upward faces. Both members
are optional and an empty `{}` is coverage 1 (the plain mask). `heightM` is `[lo, hi]` in metres with
`lo` below `hi`: the layer covers fully from the bottom of the mesh's bounds (its minimum y) up to
`lo` metres above it and fades out to none at `hi`. It is measured in the object's space, at the
object's scale. `heightFrom` says what `heightM` is measured from: `mesh` (the default, as just
described) or `root`, the world height of the origin of the mesh's topmost ancestor entity. A kit
building is a group entity at its ground line with a mesh per storey piece, so `mesh` puts a grime
band at the bottom of every storey and `root` raises it from the building's foot only. All the
layers of one material that have `heightM` use the same `heightFrom`
(`material-height-from-mixed` otherwise): one drawn instance carries one height reference. `root`
is exact for upright instances (a kit piece turned about the vertical) and is computed only while
some material asks for it; an instance the scene gives no ground (an instanced mesh, a sprite)
measures from its mesh as before. `facing` is `up` (surfaces facing up by their world normal take the layer, fading out
toward vertical), `down` (surfaces facing down) or `any` (no facing term, the default). `edge` and
`cavity` are wear terms read from this material's own maps, per pixel, before any layer: `edge` is
how far the normal map tilts the surface from the mesh's normal (the sine of the angle, so the
bevels a trim sheet bakes into its normal map: worn edges and corners), `cavity` is 1 - the
occlusion of the ORM or occlusion map (joints, crevices, grain cracks). Each is `[lo, hi]` in 0..1
with `lo` below `hi`: none at `lo`, full from `hi`. A layer has one wear
signal (`material-mask-from-wear-both` if more than one of `edge`, `cavity`, `ao`, `convexity`,
`concavity` is present; use two layers for both), and without the map `edge` or `cavity` reads
the term does nothing (`material-field-ignored`). `ao`, `convexity` and `concavity` need no texture:
they read signals the engine bakes per vertex at load from the mesh's own geometry. `ao` is 1 - the
baked ambient occlusion (how enclosed the surface is by the mesh itself: inner corners, under
ledges), `convexity` is the outer edges and corners, `concavity` the inner creases and corners. The
bake is cached and runs only for meshes whose materials use one of them. A large mesh bakes after
load on a worker, so until it arrives the layer shows no wear (a mesh that becomes virtual geometry
bakes before its build, which carries it). Opaque and cut-out materials read it; a blended
(transparent) material's layers read no baked wear. The wear terms compile only into the
variants of a material that has one (`LAYER_WEAR`). The layer's
coverage is the product of the terms present, **multiplied by** the layer's mask channel value (the
`layerMask` texture's channel, or the vertex colour where there is no `layerMask`): the mask still
decides where the layer may appear and `maskFrom` limits it further, so a mask of 0 stays 0. A file
without `maskFrom` draws and resolves exactly as before (no `maskFrom` key appears in the resolved
layer, and written members are not filled with defaults). The standard renderer and the web build draw
`maskFrom` with the same terms (the height from the mesh's bounds bottom, or its ground for
`heightFrom: "root"`, at the instance's vertical scale); they have no baked wear stream, so `ao`,
`convexity` and `concavity` read no wear there, and such a material logs one note
(`render.standard.layer-mask-from-ignored`). On HD a forward or blended piece has no object space, so its
height term reads 0 there.

The `moss`, `wet`, `dirt` and `snow` presets are layer materials: they need no maps, so a layer works
with nothing but a preset, and a scanned set's maps can be added to the layer file later. Each is a
file of its own, named by the layer:

```json
{ "preset": "moss" }
```

```json
{ "preset": "concrete", "baseColorTexture": "cobbles.png", "occlusionTexture": "cobbles_ao.png",
  "layers": [{ "material": "moss.material.json", "heightBlend": 1, "contrast": 3 }], "layerMask": "moss_mask.png" }
```

Moss between the cobbles: the cobbles' occlusion map is their height, so the moss, flat at the
luminance of its colour, wins in the dark joints. The others work the same way:

```json
{ "preset": "paintedMetal", "layers": [{ "material": "wet.material.json", "mask": "b", "heightBlend": 0.7 }] }
```

A film that should darken the surface rather than replace its colour, such as a wet patch on painted
or stained material, uses `"blend": "multiply"`. The layer file is a grey that multiplies:

```json
{ "preset": "wet", "baseColor": [0.5, 0.5, 0.5] }
```

```json
{ "preset": "wood", "layers": [{ "material": "wet.material.json", "mask": "b", "blend": "multiply" }] }
```

```json
{ "preset": "wood", "layers": [{ "material": "dirt.material.json", "mask": "g", "contrast": 2 }] }
```

```json
{ "preset": "concrete", "occlusionTexture": "rock_ao.png", "layers": [{ "material": "snow.material.json", "heightBlend": 1 }] }
```

A layer that does not load is left out, with one warning. A layer file, or a texture it names, that the project does not hold is also said before the run: `build.material-layer-missing` on the scene write and in the build. The standard renderer
draws an opaque pbr material's layers with the engine's stock layered module (the same blend; of each
set it draws what it draws of a plain material: colour, normal, roughness, metallic and occlusion, with
their maps and texture transform); a material it cannot draw that way shows its base.

```json
{ "preset": "paintedMetal", "layers": [{ "material": "rust.material.json", "contrast": 3 }], "layerMask": "wear.png" }
```

### Mask graph

A layer's coverage is its `mask` channel times its `maskFrom` terms. A `maskGraph` computes it instead
from a few signals and operators, so one layer can be "grime up the wall, where the paint is painted, or
on the worn edges" without a new key for every combination.

```json
"maskGraph": {
  "nodes": {
    "grime": { "op": "height", "range": [0.05, 0.7] },
    "paint": { "op": "mask", "channel": "g" },
    "worn":  { "op": "edge", "range": [0.6, 0.85] },
    "base":  { "op": "multiply", "in": ["grime", "paint"] },
    "both":  { "op": "max", "in": ["base", "worn"] }
  },
  "layers": ["both", null, "worn"]
}
```

`nodes` is a map from an id to a node (at most 32). `layers` has one entry per layer, in order: the id
of the node whose value is that layer's coverage, or `null` to keep the layer's own `mask` and
`maskFrom`. The graph replaces only that first part of the coverage: `heightBlend`, `contrast`,
`edgeWidth`, `edgeNoise` and `edgeStreakM` still shape it, per layer, unchanged.

Sources read the same signals as `maskFrom`:

| `op` | Keys | Value |
|---|---|---|
| `mask` | `channel` (`r`, `g`, `b`, `a`) | The `layerMask` channel, or the vertex colour's without a `layerMask`. |
| `height` | `range` `[lo, hi]` metres, lo below hi | `maskFrom.heightM`: full up to lo, none at hi. It uses the material's one `heightFrom`. |
| `facing` | `dir` (`up`, `down`) | `maskFrom.facing`. |
| `edge`, `cavity` | `range` `[lo, hi]` in 0..1, lo below hi | The wear terms of `maskFrom.edge` and `maskFrom.cavity`, from this material's maps. |
| `constant` | `value` 0..1 | That number. |

Operators read other nodes by id:

| `op` | Keys | Value |
|---|---|---|
| `remap` | `in` (one id), `range` `[lo, hi]` in 0..1, `smooth` (optional) | lo..hi mapped to 0..1, clamped; `smooth` uses smoothstep. |
| `invert` | `in` (one id) | 1 - x. |
| `multiply`, `add`, `min`, `max` | `in` (2 to 4 ids) | The product, the sum clamped to 0..1, the smallest, the largest. |
| `lerp` | `in` (exactly 3 ids: a, b, t) | a + (b - a) x t. |

A key an op does not take, an unknown `op` and a missing key are `schema-shape`, like any unknown
material key. A parameter out of range, or the wrong number of `in` ids, is `material-value-range`. A
node that names a node which is not there (in `in` or `layers`) is `material-graph-unknown-node`; more than 32
nodes is `material-graph-too-many-nodes`; a loop is `material-graph-cycle`, reported once at the lowest id
on it. `layers` longer than the material's `layers`, and a node no entry of `layers` reaches, are
`material-field-ignored` warnings.

A file without `maskGraph` resolves to exactly what it did before: no key appears. A resolved graph
lists every node with `in` as a list and a `remap`'s `smooth` decided, and `outputs` maps each layer index that has one to its node (the `null` entries are left out). Its canonical form orders the nodes
topologically, ties broken by id, and the graph's hash is taken over that form, so the order of keys in
the file does not matter; two files with the same graph have the same hash.

### Six layers

With a `maskGraph` a material can have up to six layers (HD only). A layer past the third has no
coverage of its own: its `mask` channel and `maskFrom` do nothing (`material-field-ignored`), and
`maskGraph.layers` must name a node for it (`material-layer-needs-graph`, an error). Its `contrast`,
`heightBlend`, `blend`, `edge*` and `lod` apply as for any layer. Without a graph the limit stays three
(`material-value-range`). A layer costs 2400 units whatever its index, so a material with more than three
layers is over the Low, Medium and XR budget (9000), draws its first three there and warns
`material-graph-over-budget`; High and Ultra (16000) keep all six, whatever the graph. The standard and web
renderers draw at most three layers: the first three that their `lod` keeps (see below), and note it once
per material.

### Cost and budgets

A material with a `maskGraph` has a static cost and an HD budget per quality preset. Nodes and edge noise are in `crates/shader` cost units (an ALU op 1, a transcendental 4). A layer and the mask texture are priced against those from a run on the RTX 5080 at 1440p: a full frame of a layer takes about 0.09 ms of G-buffer and a node unit about 0.00004 ms, so a graph's nodes barely count and the budget mostly limits layers. The numbers are one table in `crates/contract/src/material.rs` and `packages/contract/src/material/lod.ts`, with identical values.

| What | Units |
| --- | --- |
| each kept layer (base colour, normal and ORM fetches, and its blend) | 2400 |
| the layer mask texture, once | 800 |
| a layer's `edgeNoise` above 0 / its `edgeStreakM` | 28 / 2 more |
| nodes: `mask`, `constant` | 0 |
| `height` / `facing` up, down / `edge`, `cavity` | 4 / 2, 3 / 2 |
| `remap` plain, smooth / `invert` / `lerp` | 4, 2 / 1 / 2 |
| `multiply`, `min`, `max` of n inputs / `add` of n | n - 1 / n |

Only nodes some kept layer's output reaches count. A material without a `maskGraph` has no cost, no budget and no warning.

| Preset | Budget |
| --- | --- |
| Low, Medium, the XR profiles | 9000: three layers with any graph, and no fourth |
| High, Ultra | 16000: six layers with any graph |

To calibrate the budgets, `crates/render/tests/layered_cost.rs` times full-frame layered materials of rising cost on
the GPU, on the standard renderer and, with the `hd` feature, on HD: run it with the `LAYERED_CALIBRATE` variable (the engine's prefix) naming a JSON
file (and `LAYERED_CALIBRATE_SIZE`, `WxH`) on the reference GPU. It writes each case's median and the fits: ms per
unit, per layer and per node unit. Until a budget is set, the standard renderer's tier of `material-graph-over-budget`
says nothing.

The standard renderer and the web build have no quality preset: they read a layer's `lod` as Medium and as
Low, so a layer marked `high` or `ultra` is an HD-only extra there. Their budgets are not set yet (they come
from the calibration): a graph material is costed the same way, but nothing is dropped for cost there and
no warning names them.

For the current HD preset the renderer first drops the layers whose `lod` is above it, then, for a graph material, drops the highest-index layers until the cost is within the budget; the graph's outputs follow the layers that stay. This happens when the material loads and when the preset changes, between frames. The XR profiles are matched by their built-in names (`xr`, `xr-plus`) and use the Low budget on the High preset's layers; a Custom profile of any other name uses its own preset. On Auto, the preset read is Auto's current choice: when Auto steps to another preset at run time, the next frame maps again the materials that have a layer `lod` or a graph (a headless or capture run keeps Auto fixed, so it never steps). `material-graph-over-budget` says in advance what that will drop. Bakes (HLOD impostors) draw the base surface and are not affected.

The standard renderer draws a graph as HD does: each distinct graph is its own family of
the stock layered module (its constants compiled in, so it costs only its own operations), at most 16
distinct graphs per scene; the layers of a graph past them draw their own `mask` and `maskFrom`
(`material-graph-variants`, said once). A layer the graph has no output for keeps its own coverage. The
`ao`, `convexity` and `concavity` sources are not graph sources. The web build draws a graph the same way.

## Terrain layers

A terrain (`docs/status/hd-terrain.md`) blends up to eight layers, each an existing material file
named in the terrain's `layers` (`material`, a scene asset id or a project-relative path). What a
layer takes from its file: `baseColor` times `baseColorTexture` (the colour), `normalTexture`,
`roughness` times the green of `metallicRoughnessTexture` (or `ormTexture`), and
`heightTexture` (linear, R) as its height map, else `occlusionTexture`, else the colour's luminance (a
`heightTexture` that does not load says so once, counts in `report.terrain_maps_missing` and falls back the same
way; one of another size is resampled like the other maps), and, in the standard
renderer, the occlusion (R) and metallic (B) of `ormTexture` (a layer without one is unoccluded and
non-metallic). Everything else in the file is ignored for a layer. The engine packs
the textures at load, so authors never pack by hand: the colour with the height in its alpha, the normal
map with the roughness in its alpha, and the ORM map as it is. The roughness map multiplies `roughness`, as glTF's does: a scanned set
whose map holds absolute roughness wants `roughness: 1`. The layer's own optional `tint` (three
numbers, 0..4 each, linear) multiplies its colour past what `baseColor` (at most 1) can, to calibrate
a texture set to the ground it stands for: a grey scanned snow brightened to snow's albedo (about
0.8 linear), a straw-coloured grass greened. Plausible linear albedo means: grass 0.10-0.20, soil
0.08-0.20, rock 0.20-0.35, sand 0.35-0.45, snow 0.8 and up; `argus material check <terrain or
material file>` measures each layer's (tint included) against them and prints the factor that
brings it into range. The layer's own `uvScaleM` is the metres one tile covers, and
`triplanar: "auto"` (the default) lets it follow steep ground: from 25 degrees (fully from 37) it
maps by the two of 18 world projections that face the surface best (up, down, horizontal and tilted
45 degrees, every 45 degrees of azimuth), so a bank does not stretch and no face is mirrored;
`"off"` keeps it top-down. The layer's optional `edgeWidth` (0..1, default 0), `edgeNoise` (0..1,
default 0) and `edgeNoiseM` (0.01..1000 metres, default 1) soften and break up its edge where it
meets another layer: 0 `edgeWidth` is the edge the blend's contrast and the heights make, and 1 fades
it in smoothly along the painted weights; `edgeNoise` moves the edge by world-space noise of wavelength `edgeNoiseM`,
as a share of the layer's weight. For a pair of layers the wider width and the stronger noise of the
two apply (renderer only; a file without the keys draws as before). Layer textures are kept at their own size up to 1024 texels a side.

A pixel of terrain shades two layers, however many the terrain has: the two heaviest among the four
samples around it (looked up at a position moved by a little world-space noise, so painted edges
leave the sample grid), with the terrain's per-pixel slope and altitude layers laid over them,
blended by height (the higher layer's texture fills between the lower one's), whether the standard
renderer or HD draws it. Near, each layer is read at two noise-picked offsets and height-blended so
its tile does not visibly repeat, and a finer read of its normal map adds detail close up; farther,
a read at a fifth of the frequency takes over, and that one fades to the layer's mean colour far
away. A low-contrast layer (its colour's luminance varies by less than a fifth of its mean over the
tile: sand, snow, fine soil) has nothing but a few features to show, which would repeat as a grid of
dots across the middle distance: its far read takes the two-offset anti-tiling too, its contrast
fades towards the mean as that read's texels shrink, and noise at two scales breaks up its value. Where the normal map's relief is no longer drawn (its mips average it away, and far away the
normal is flat), it is folded into the roughness (Toksvig), so distant ground does not glint like
polished stone. Low-frequency noise varies the albedo's value and hue by about 10%, and ground in and beside
rivers and lakes (the terrain's `river`, `lake` and `waterDistance` channels) is darker and smoother.
The standard renderer swaps to a baked far colour map (one texel a metre) beyond 64 m; HD shades
the layers directly at every distance.

The terrain file's optional `surface` block tunes this (renderer only; the evaluated terrain is
unchanged):

```json
"surface": {
  "slopeLayers": [{ "layer": "rock", "startDeg": 32, "endDeg": 42, "noiseM": 4, "noiseAmpDeg": 3, "minHeightM": 0 }],
  "altitudeLayers": [{ "layer": "snow", "startM": 1150, "endM": 1250, "slopeMaxDeg": 45, "noiseM": 6, "noiseAmpM": 25 }],
  "wetness": { "bandM": 4, "darken": 0.6, "roughness": 0.3 },
  "biplanarAboveDeg": 25,
  "tileTexels": 1024,
  "macroVariation": 0.1
}
```

- `slopeLayers`, `altitudeLayers`: layers laid per pixel from the shaded slope or the height (terrain
  metres), a smooth ramp from `start` to `end`, the edge moved by world-space noise (`noiseM`
  wavelength, three octaves, so a few metres gives a ragged sub-metre edge); an altitude layer stays
  off ground steeper than `slopeMaxDeg`, measured on the ground smoothed over about 32 m (the
  mountainside, not each gully the fine mesh carves; a crag more than 12 degrees steeper than that
  counts as its own slope less 12, so rock shows on truly steep faces) (an edge the same noise moves by up to 6 degrees, so snow
  gives way to rock raggedly on a steepening face); a slope layer stays off ground below
  `minHeightM` (2 m soft edge; for a shelf that falls away under the sea). An altitude layer whose
  `endM` is under its `startM` lies below instead: none above `startM`, all of it from `endM` down
  (a lighter, smoother seabed under shallow water, so the shallows read turquoise). Slope layers apply first, then altitude layers, each over
  what came before; at most four in all. Use them for rock on cliffs and snow by altitude; keep
  `material.rules` for painted and channel-driven layers (riverbeds, shores).
  A `material.rules` rule takes `softEdge: true`: where it contributes to a sample's winning pair, its
  `where.falloff` ramp blends the layer into the ground already there (without it a layer is painted in
  full wherever its ramp is above 0, a hard patch edge one terrain sample wide), and the pair yields the
  third-best layer's score, so layer B fades out before a third layer takes its place. A hard rule
  beside a soft one in the same op is unchanged. `noiseM` on a soft rule moves the edge only inside the
  falloff ramp, so at the default falloff 0.1 it is subtle; widen `falloff` for visible irregularity.
  Absent or false: unchanged. (When B ties the third it keeps 0.1% of A's score rather than zero.)
  An altitude layer's `hollowM` (metres, default 0) bends its edge to the landform: a full hollow reads that
  many metres higher and a full ridge that many lower, so snow holds down gullies and couloirs and lets go
  of crests. It reads the ground's macro cavity, so it needs `macroCavity`; the ray-traced bounce, which
  has no cavity, draws the plain edge.
- `wetness`: the wet band beside water (`bandM`, default 4; 0 turns wetness off), the albedo
  multiplier (`darken`, default 0.6) and roughness (`roughness`, default 0.3) of fully wet ground.
- `biplanarAboveDeg` (default 25), `tileTexels` (default 1024), `macroVariation` (default 0.1).
- `macroCavity` (`strength` 0..1, default 0 = off; `scalesM`, one to three baselines in metres, default
  `[8, 32, 128]`): the heightfield's curvature over those baselines (height against the mean of four
  neighbours that far away) darkens hollows (up to 55% at strength 1) and lifts ridges (up to 25%), so
  gullies and crevices read dark and crests light at 10 to 500 m. It is baked once per mesh vertex into
  the vertex colour's blue channel at load (no per-pixel cost, no extra texture); absent or at strength
  0 nothing is baked and the terrain draws exactly as before.
- `macroDetail` (`strength` 0..1, default 0 = off; `wavelengthM`, 8..500, default 40): a world-space term on rock
  at tens of metres, so far rock is not flat once the layer textures have faded to their mean colour
  (`TERRAIN_MEAN_PX`). It acts where the ground is steep (it fades in over a macro slope of 30 to 46 degrees) and
  the layer colour under the pixel is rock-like (neutral and dark: saturated layers such as meadow and forest, and
  bright ones such as snow, are left alone). Albedo gets mottled value (hollows darker than crests are lighter),
  yellow lichen and green moss patches and a warm lift on crests (a value noise at `wavelengthM` and a third of it,
  mapped on the vertical plane that faces the surface, the vertical squeezed so bands run along the face); the shaded
  normal gets shallow ribs from the same noise's gradient. It fades out where its wavelength is only a few pixels, so
  it does not shimmer. It costs a few noise reads on steep rock pixels only, and nothing when absent or at strength 0.
  The ray tracer's hits take the albedo part. `form` (default `"banded"`, the term above) can be `"broken"`: the
  squeeze drops to 1.3, the noise is domain-warped and its fine octave turned 37 degrees off the grid, the ribs
  are softer (no cracks: the iso-lines of a 2D value noise are closed loops, which drew rings and round blotches on smooth faces, so they were removed). Seen from kilometres away, the banded form's
  squeezed cells read as horizontal smeared dashes; the broken form reads as broken stone. It costs two more
  noise reads per projection on steep rock pixels.
  `textureStrength` (0..1, default 0 = off, the frame byte for byte as without it) reads the pixel's leading layer's
  own textures again at the macro scale, on the same steep rock-like ground and vertical plane, at `wavelengthM`
  and 2.7 times it (scales that do not divide, so neither's repeat shows): the albedo's luminance against the
  layer's mean sets the value (the hue stays the layer's), the layer's height against a coarser read of it darkens
  joints and lifts edges, the normal map tilts the shaded normal, and sparse pale lichen and green moss add a little
  hue. Far cliffs keep the texture's blocks and joints instead of its flat mean. It multiplies onto the term above
  and works with `strength` 0 too. It costs four to five texture reads per projection on steep rock pixels.
  Both terms ride in `look2.w` (`TerrainBlend::look_flags`): bit 0 anti-tiling, 1..8 `strength`, 9..16 the
  wavelength, 17 `form`, 18..22 `textureStrength` in 1/31
  (the word stays under 2^23, where the shader's `u32(w + 0.5)` keeps bit 0).
- `groundDetail` (`strength` 0..1, default 0 = off; `wavelengthM`, 4..200, default 24; `creep` 0..1, default 0.5; HD, and
  every renderer that shades terrain through the shared arithmetic): the non-rock counterpart of `macroDetail`, so a
  grass or meadow hill is not one smooth blob under a low sun. It acts on ground up to cliff steepness (it fades out over a
  macro slope of 50 to 62 degrees, so a grass hill's 30 to 45 degree face takes it) where the layer colour under the
  pixel is NOT rock-like (the `macroDetail` test, complemented: complementary by colour, so a clearly rock or clearly grass colour takes one term, a mid-saturation colour some of both; bright, neutral snow is
  left alone). The shaded normal
  gets shallow hummocks (about 5 degrees at strength 1) from the gradient of a value noise at `wavelengthM` and a third
  of it; albedo gets crests drier and yellower, hollows lusher and greener and a value change of up to 12%, from the
  same field. `creep` stretches the relief along the contour on slopes (the noise of the vertical planes that face the
  surface, its vertical coordinate the height, so ridges run across the hillside like terracettes and soil creep); 0 is
  round hummocks, and flat ground has no contour direction, so it is hummocks whatever the creep. It fades out where its
  wavelength is only a few pixels, so it does not shimmer. It costs two to six noise reads on non-rock pixels
  only, and nothing when absent or at strength 0 (the shader text path is behind that check, and the record's lanes are
  zero). The ray tracer's hits take the albedo part. Set it beside `macroDetail` for a terrain with both rock and
  meadow: the two never stack on one pixel.
- A layer's `heightTexture` (a real height map) is rescaled about its own median when the layers are packed so its 5th to 95th
  percentile span 0.4 (gain at most 4): a full-range scan no longer outbids a flatter layer in the height
  blend, while a flat map keeps its level. Layers that blend by the occlusion map or the colour's luminance are unchanged.
- `rockDetail` (HD only, drawn only: collision and queries keep the heightfield): the finest terrain
  mesh is refined on steep ground (`startDeg`..`endDeg`, default the slope layer of `layer`, else
  38..48) and where `layer` is painted, and displaced along the normal by `amplitudeM` (default 0.8)
  of a strata-and-erosion pattern: bands `strataM` thick (default 3; 0 none) that jut at their base
  and slope back, warped so they dip and pinch, plus pits, chips and vertical runnels (their pitch, depth and track vary by place, and a band's jut rises over at least one mesh spacing, so there is no comb of identical grooves or drip spikes). Edges split
  down to `maxEdgeM` (default 2.5) while the cell gains no more than `maxGrowth` (default 0.15) of
  its triangles (the edge length coarsens until it fits); a cell's border never moves, so cells
  meet without cracks. Deterministic: a pure function of the world position. Every added
  triangle costs virtual-geometry memory (about 170 bytes each), which is what `maxGrowth` bounds.
  Optional ledge-and-block form, in metres on top of that pattern (all absent: the same bytes as
  before): `benchM` (0..8) makes the beds (`strataM` thick, 3 if 0) hard or soft, `benchDutyPct`
  (default 35) of them hard by hash: a hard bed juts out 0.6 to 1 times `benchM` and a soft one
  recedes 0.3 times it, with a flat tread and a riser over `benchEdge` (0..1, default 0.7) of a bed
  (0.2 of it at 0, 0.03 at 1); benches replace the sawtooth strata. `jointSpacingM` (0.5..50) with
  `jointDepthM` (0..4) cut two square sets of vertical grooves, the first spaced along
  `jointAzimuthDeg` (degrees from +x toward +z), into blocks that each sit back up to half the depth
  and stack in courses. `dipDeg` (0..60) tilts the beds so they descend toward `dipAzimuthDeg` (the geological dip direction).
  `strataFrom` names an `erode.thermal` op: its `strata` bands (hardness 0.9 or more is hard; the
  lowest 64) are the beds, so benches sit where that op carved its shelves (benches only; a band runs to the next band's `fromHeightM`, the last one up, and the bed
  table has no noise warp; more than 64 bands keep the lowest 64; the op's `strataTiltM` tilt is followed, so the benches stay on its shelves). All pure functions of the world position.
- `rockBeds` (HD only): per-bed tint and water streaks on the rock `layer` (it uses no extra layer slot). The beds are
  `rockDetail`'s sawtooth beds (the same noise and hash, so a tinted bed ends where its ledge does; the CPU geometry is deterministic across machines, and the shader's tint matches it except for sub-pixel slivers at bed boundaries, where WGSL's fused multiply-add and division may round a bed edge differently): `fromRockDetail` (default
  true when `rockDetail` exists) takes `bedM` (its `strataM`), `dipDeg`, `dipAzimuthDeg` and `benchDutyPct` from it, and a field
  set here wins. A hard bed (`benchDutyPct` of them, by hash) multiplies the albedo by `hardTint` (default pale, cool) and its
  roughness by `hardRoughness`, a soft one by `softTint` (warm, darker) and `softRoughness`; one bed in `ironEveryN` (default 7,
  0 none) takes `ironTint` (rust); `bedVariation` (0..1, default 0.25) jitters each bed's value and hue; `seed` seeds the per-bed
  hashes. `streakStrength` (0..1, default 0.5; 0 off), `streakLengthM`, `streakWidthM` and `streakWetness` draw dark, smoother
  streaks below each bed's lip on faces over 50 degrees, weakened in hollows (the mesh's cavity: half at a full hollow; flat and convex faces, and a mesh with no cavity colour, take full). `crackDarken` (0..1, default 0.6 with this block; without it the recesses do not darken) sets how far
  `rockDetail`'s recesses (chips, runnels, a lip's underside) darken the cavity colour. Terms fade to a bed's mean tint under
  about 3 pixels a bed, so they do not shimmer. Ray-traced hits (reflections, bounces) see the tint but not the streaks; the
  standard (non-HD) renderer ignores the block. Absent: the terrain draws and saves as before.
  `strataFrom` names an `erode.thermal` op and puts the tint bands on its beds instead of the hashed sawtooth ones: the op's
  `strata` bands (the lowest 64) are the beds, a band of hardness 0.9 or more is hard (`benchDutyPct` and `bedM` are unused), the
  bed index (so the iron and jitter hashes) is the band's number, the streaks run below each band's lip, and the op's
  `strataTiltM` tilt (the same noise, so a tinted bed ends where the op's shelf does) and the block's dip tilt them. The bed
  below the first band and the last band are 40 m thick for the fade and the streaks. An op with no `strata` gives no table, and the
  beds stay the sawtooth ones. Ray-traced hits (reflections, bounces) do not read the table: where they tint at all they tint by the sawtooth beds. The table rides in three extra
  material-table slots (336 bytes) of the terrain's record, only for a terrain that sets it (the others keep their six slots and
  the shader without the band search); a scene that has one compiles the band path once. The tint follows the op's table, not the
  final ground: later ops (the flute, the talus apron) move ledges by a few metres, mostly at cliff feet.

**`albedoMap`** (a top-level field of the terrain file, not under `surface`): a drape, an image laid over the terrain's
whole extent whose colours replace the layers' albedo with distance. It is meant for aerial or satellite imagery of a real
place, so the ground matches the globe around it from the air (docs/status/globe.md). Absent, the terrain draws and saves
as before.

| key | meaning | default |
|---|---|---|
| `image` | PNG or JPEG, sRGB, project-relative; north (-z) up; its first pixel at the terrain's minimum x and z corner | required |
| `nearM` | distance from the eye (m) where the image starts to show; nearer, only the layers draw | 60 |
| `farM` | distance from which the image shows at full `strength` | 250 |
| `strength` | 0 to 1: how much of the albedo the image takes at full distance | 1 |
| `layers` | names of the layers that take the image; the others draw as they are | every layer |

- **What stays the layers':** each layer keeps its normals, roughness and occlusion, so the near ground keeps its
  surface detail.
- **Image colours:** they are albedo. The real-world terrain plugin's bake (`geodata-globe --drape`) writes
  Copernicus Sentinel-2 true colour as reflectance (0.04 to 0.35), with the satellite's own shadows flattened a little.
- **Standard renderer:** bakes the image into each cell's far colour map and shows that map from `nearM`, fully by
  `farM` (instead of its usual 64 m and 96 m).
- **HD:** mixes the image in per pixel, filtered for the pixel's footprint. It sits after the far ground cover, so the
  image wins far out.
- **Ray-traced hits:** reflections and bounces don't read the image.
- **Cost:** one more texture. A terrain without one compiles nothing new: the HD merge and its record are as before.

Rocks on a terrain: a `scatter` op whose `asset` is `rock:boulder/<size m>` or `rock:outcrop/<size
m>` is a procedural rock. The terrain ignores the op; `flora-scatter-rocks --scene <scene>` cooks
each op's placements (its `where` rules: slope, height, channels such as a `mask.derive` curvature
mask), six seeded variants per asset (fractured, eroded, outcrops layered) as glbs, and adds one
`instancedMesh` per variant to the scene (HD draws them as instanced virtual geometry).
`--color #RRGGBB` writes a tint on every rock entity (`instancedMesh.color`), to bring the rocks to
the colour of the ground they stand on. Inside a project the tool does as the engine does: the
terrain's `source` is found from the project root, the instances' `source` is written
project-relative, and the terrain is read from the project's on-disk cache when the engine or the
agent has already evaluated it.

## What each renderer draws

| | Standard renderer | HD (`render.profile: "hd"`) |
| --- | --- | --- |
| Base colour, colour map, metallic, roughness, normal map | yes | yes |
| ORM / metallic-roughness / occlusion maps, occlusion strength | yes: G scales roughness, B metallic, R (times `occlusionStrength`) darkens the image-based light, as in HD; the mesh's own UVs | yes |
| Emission colour and strength | yes: added to the lit colour before exposure and the tonemap; `unlit` surfaces ignore it | yes |
| Emission map | no | yes |
| Alpha mask and blend, double-sided | yes (blend sorted back to front per object) | yes |
| `shading` | yes | `unlit` and `toon`; `pbr` and `vertex` are lit per pixel |
| `filter`, a glTF sampler | yes | linear |
| Specular, specular colour | yes: the dielectric's reflectance F0 = min(0.04 x specularColor, 1) x specular and its grazing reflectance F90 = min(specular, 1), in the sun's term and the sky reflection, as HD reads them (specular 0 takes a dielectric's sheen off; the defaults are glTF's 4%) | yes |
| Clear coat, ior | no (a second lobe only for the car keys below) | yes (ior folds into the specular tint) |
| Transmission | no | as thin glass: blended by what it does not transmit, no refraction |
| `uvTransform` | no (the mesh's own UVs) | yes, in every pass: raster, shadow, virtual geometry and ray tracing |
| `uvScroll` | yes: the base colour, normal and ORM maps of a plain material, from the game clock | no (logs `render.uv-scroll-ignored`) |
| Sheen | no | yes (Charlie sheen over the base, `KHR_materials_sheen`) |
| Anisotropy | only with the car keys below: a stock shader stretches the highlight along `anisotropyPattern`/`anisotropyAxis` | yes (not on skin or hair materials) |
| Iridescence, diffuse transmission | no | yes (not on skin or hair materials) |
| Flakes (`flakeColor` and tuning), orange peel, flip colour, clear coat on the new car keys | yes: a stock surface/light module the engine ships, drawn through the shader path (cell-noise flake normals in object-space millimetres, faded to the averaged tint below about one pixel; clear coat as a second specular lobe) | yes: HD's car-paint layers |
| `layers` | yes: a stock layered module the engine ships, drawn through the shader path (each set's colour, normal, roughness, metallic and occlusion with their maps and texture transform; an opaque pbr material only), up to three layers, with `maskFrom` (not its baked `ao`, `convexity`, `concavity`) and `maskGraph` (not in the web build); no layer edges | yes, blended before lighting |
| `subsurface`, `subsurfaceRadius` | no | yes: screen-space subsurface scattering (quality knob `materials.sss`) |
| `hair` shading | as `pbr` | yes: fibre lobes along the tangent; the fringe under `alphaCutoff` dithered, or blended with `hair.blendedFringe` |

**Car paint, wheels and tyres in the standard renderer.** A material file that uses a car preset
(`carPaint`, `wheelAluminium`, `wheelGlossBlack`, `tyreRubber`) or any car key (`flakeColor`,
`flipColor`, `clearcoatOrangePeel`, a non-`uv` `anisotropyPattern`), on an opaque pbr material without
a `shader`, is drawn with a stock surface module the engine ships, through the same path as a user
shader. It is built for the keys the material uses, so a plain clear coat does not pay for flakes.
Flakes are a cell noise of random normals sized in object-space millimetres (the instance scale is
included) with a glint under each light, and fade to their averaged tint below about a pixel, so they
do not shimmer at distance. Orange peel is a low-amplitude wavy normal on the clear coat, faded the
same way. The colour flip blends by view angle. The clear coat is a second specular lobe; anisotropy
bends the normal along the pattern (the highlight stretches along the pattern's direction, as in glTF;
`radial` and `circular` turn about `anisotropyAxis` of the mesh's local origin). Sheen is a dusty rim.
The clear coat reflects the scene's sky or environment, and flakes glint under the sun and every local light. The stock modules draw no normal map. A web, Android or iOS build, whose shader cost cap the full module exceeds, gets a lighter set (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 module ships in an exported game's shader pack and pre-warm list, so a shipped build draws it too (an HD export draws these keys natively and needs none). Materials without these keys draw
exactly as before.

The standard renderer draws a material file exactly as it draws a glTF material. It uses the
PBR-lite of the colour, roughness, metallic, colour map and normal map (`PbrMaterial::lite`), with
the material's shading, filter, alpha and sidedness (a `MaterialStyle` registered beside it). Its
frames of existing opaque scenes are unchanged. When a material file sets something only HD draws,
the standard renderer prints one line naming those keys. The maps only HD reads (ORM, occlusion,
emission) are loaded only under HD. Emission colour times strength is the exception: the standard
renderer adds it to a lit surface's colour (pbr, toon and vertex), so a glowing material needs no
`unlit` shading. A material with no emission renders byte for byte as before.

Alpha in the standard renderer: `mask` cuts texels below `alphaCutoff` out of the colour, depth and
id buffers; `blend` draws after every opaque surface and the sky, each blended object on its own,
farthest first by its centre, without writing depth. Two limits: shadows are cast by the whole
surface either way, and a blended surface keeps the tonemap state of what is behind it, so under
`aces` or `neutral` an *unlit* blended surface over a lit one is tonemapped with it (with
`tonemap: "none"` there is no difference).

## Agent tools

The `materials` toolset (`tools.enable` with `materials`, or a first `material.*` call; docs/AGENT.md) lets an
agent write, see and measure a material without debugging it by hand.

- `material.write {path, material, dryRun?}` runs the contract's check (what `argus-validate --material` says) and
  returns every error and every warning, each `{code, pointer, message}`, not the first. On top of the contract it
  checks that every texture the file names (and every layer file's textures, from the layer file's own folder)
  exists, is inside the project and is a PNG, JPEG or KTX2; a missing one is an error. A valid answer carries
  `resolved` (the factors after the preset and defaults), `maps`, and `notDrawn`: for each renderer the keys the file
  sets that it will not draw, with the reason (the list the engine's own log lines print: `beyond_standard`,
  `render.uv-scroll-ignored`, `render.standard.layer-mask-from-ignored`, one function in `material_file.rs`).
  `heightTexture` is in both lists when set: a terrain layer reads it, a mesh draws no relief. Nothing is written
  while there is an error.
- `material.preview {material, shape?, renderers?}` draws the material through the editor (`POST /capture
  {kind: material}`) and returns the contact sheet with the same errors and warnings. A `.material.json` path is
  read by the editor and its textures and shader resolve from that file's folder, as a scene asset's would.
- `material.measure {material, mesh?, meshName?, class?}` reports albedo (mean linear RGB and luminance), roughness,
  metallic, emission in nits (linear emissive x `emissiveStrength` x the map x 33,333, the engine's light unit),
  and, with a mesh, the tile size in metres one repeat covers and texels per metre. The class check uses the ranges
  of `argus material check` (grass, soil, rock, sand, snow) and the building ones, by mean linear albedo luminance:
  plaster 0.5 to 0.7, brick 0.2 to 0.35, clay tile 0.25 to 0.4, timber 0.1 to 0.2, stone 0.2 to 0.35, cobble 0.15
  to 0.3, iron 0.05 to 0.15 (metallic near 1). `argus material check` takes the same classes.
- `material.compare {reference, material | image, light?, region?, referenceRegion?, quality?}` says how a
  material differs from a reference picture, in numbers and hints. `reference` and `image` are pictures inside the
  project (a project path, or an absolute path inside it such as `view.render` answers). With `material` it draws
  the file headless (no editor; the engine `view.render` finds: `ARGUS_BIN`, beside the agent, or a checkout's
  build) on the comparison rig: a 4 m x 4 m `builtin:plane` seen from 3.2 m at 55 degrees down, fixed exposure,
  under one `light`:
  - `key` (default): afternoon sun from the side, so colour and brightness read as albedo;
  - `backlit`: the sun behind the panel at 55 degrees, facing the camera, so highlights show and roughness reads;
  - `dusk`: almost no sun, so what glows reads as emission.
  `quality` `hd` (default, how the rig's references are drawn) takes about a minute on a software adapter and up
  to 15; `standard` takes seconds. The picture is saved under `.argus/captures/` and shown. With `image` it compares a
  picture already captured, for example a `view.render` of a scene against a crop of a photo. `region` (fractions
  `[x0, y0, x1, y1]`) defaults to the centre 60% for a rig picture and the whole for an image; `referenceRegion`
  defaults to `region`. Both pictures are averaged in linear light. The answer, candidate over reference (1 = equal;
  null where the reference is black or has no texture):
  - `colourDifference`: CIEDE2000 of the mean colours (under 1 is invisible side by side, about 2 just noticeable);
  - `brightnessRatio` (mean linear luminance) and `channelRatios` (R, G, B);
  - `contrastRatio`: spread of L*, how much texture shows;
  - `highlightRatio` and `shadowRatio`: the 99th and 5th percentiles of luminance;
  - `detailRatio`: the mean L* gradient over its spread; above 1 the texture is finer, so the tile may be too small;
  - `grid`: CIEDE2000 per cell of a 4 x 4 grid, rows from the top left;
  - `rigTileM` (with `material`): the metres one repeat covers on the 4 m panel, from `uvTransform.scale` or
    `projection.scaleM`, to check against the size the surface should tile at;
  - `hints`: the material change most likely to close each large difference, largest first, for example "about 12%
    too dark: multiply baseColor's RGB by about 1.14". A black candidate (a failed render) says so and nothing else;
    `rig.note` says when the engine reported a black frame, drew standard for hd, or drew on a software adapter.
  There is no pass mark on purpose: compare under `key` and `backlit` (and `dusk` for anything that glows), look at
  both pictures, change the material and compare again. The pictures need not line up: a different texture set or
  offset still compares. The material-match eval's references are drawn on this rig, so a matching material
  compares at 0.
  The editor's material preview tile draws the same rig under the same three lights (`argus::material_rig` is the
  one copy; it adds the eval's 2 m review sphere and a cube), so a person sees what `material.compare` measures
  (docs/EDITOR.md, "The preview tile").

## Reloading

The running world caches a material file per asset id. It re-reads the file when the scene's asset
entry changes, which a hot reload and a scene switch both detect, and on a lost device. Editing the
`.material.json` in place, or one of its textures, is not seen by a running `argus run`: its watcher
watches the scene file, as it does for models. The exception is a material with a `shader`: under
`--watch` the watcher also covers its shader file (reloaded in the background, the old version drawing
until the new one is built) and the material file's `params`, which are re-read and written to the
shader's buffer (docs/SHADERS.md, "Editing live"); a changed `shader` key or texture still needs a scene reload. In the editor, the material file and every texture
it names are build inputs, so an edit makes the current artifact stale and the next Play loads it.

