# 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 }
```

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

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

## 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]` | 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 | |
| `roughness` | `roughnessFactor` | 0..1 | 0.6 | 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. |
| `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]` | |
| `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 | |
| `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. |
| `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, 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. |
| `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). |
| `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.

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

### 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`; a layer's `contrast` below 1. |
| `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-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), `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. |

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. `emissiveFactor` defaults to black, and the emission map multiplies
it, so an `emissiveTexture` on its own glows nowhere. `material-field-ignored` says so. Set
`emissive` to `[1, 1, 1]` to use the map as it is.

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

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

`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). HD only: the standard renderer ignores
`maskFrom` and logs one note per material. 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" }
```

## Terrain layers

A terrain 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.)
- `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.
- 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.

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

## 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 |
| 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) | 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).

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

