Talos docs

Materials

Since 0.6Changed in 0.7

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.

Plain text

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.

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

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

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

KeyglTF sourceRangeDefaultNotes
preset–a preset namenoneSee Presets.
namenameany stringnoneInformational.
baseColorbaseColorFactor4 numbers, each 0..1[0.8, 0.8, 0.8, 1]Three numbers are accepted and stored with alpha 1.
baseColorTexturebaseColorTexturetexture refnonesRGB; multiplies baseColor; its alpha is opacity.
metallicmetallicFactor0..10
roughnessroughnessFactor0..10.6Perceptual.
metallicRoughnessTexturemetallicRoughnessTexturetexture refnoneLinear: G roughness, B metallic.
ormTexture–texture refnoneLinear: R occlusion, G roughness, B metallic (an "ARM" map). Set this or metallicRoughnessTexture, not both.
occlusionTextureocclusionTexturetexture refnoneLinear, R. Packed into the ORM's R when the file also has an ORM or MR map.
heightTexture–texture refnoneLinear, 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.
occlusionStrengthocclusionTexture.strength0..11
normalTexturenormalTexturetexture refnoneLinear, tangent space, +Y up (OpenGL/glTF).
normalScalenormalTexture.scaleany finite number1Scales X and Y.
emissiveemissiveFactor3 numbers, each 0..1[0, 0, 0]
emissiveTextureemissiveTexturetexture refnonesRGB; multiplies emissive.
emissiveStrengthKHR_materials_emissive_strength0 or more11 is as bright as an unlit white surface.
alphaModealphaModeopaque, mask, blendopaqueLower case (glTF writes BLEND).
alphaCutoffalphaCutoff0..10.5Only with mask.
doubleSideddoubleSidedbooleanfalse
clearcoatKHR_materials_clearcoat0..10
clearcoatRoughnessKHR_materials_clearcoat0..10
clearcoatOrangePeel–0..10Strength of the clear coat's procedural wavy normal, the dimpled look of sprayed lacquer. Needs clearcoat.
clearcoatOrangePeelScale–millimetres, 2..308Wavelength of the orange peel, in object space with the instance's scale included.
specularKHR_materials_specular0..11Strength 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.
specularColorKHR_materials_specular3 numbers, each 0 or more[1, 1, 1]
iorKHR_materials_ior1 or more1.5
transmissionKHR_materials_transmission0..10
sheenColorKHR_materials_sheen3 numbers, each 0..1[0, 0, 0]HD only; see What each renderer draws.
sheenRoughnessKHR_materials_sheen0..10HD only.
subsurface–0..10Share of the diffuse light that scatters under the surface, as in skin. HD blurs it in screen space; see 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.
anisotropyStrengthKHR_materials_anisotropy0..10HD only. The highlight stretches along the direction, as on brushed metal.
anisotropyRotationKHR_materials_anisotropyradians0Turns the direction from the mesh tangent toward the bitangent.
anisotropyTextureKHR_materials_anisotropytexture refnoneLinear: RG a tangent-space direction, B scales anisotropyStrength.
anisotropyPattern–uv, radial, circularuvWhere 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, zyThe 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.
iridescenceFactorKHR_materials_iridescence0..10HD only. A thin film's interference colours, as on soap bubbles or oil.
iridescenceIorKHR_materials_iridescence1 or more1.3The film's index of refraction.
iridescenceThicknessMaximumKHR_materials_iridescencenanometres, 0 or more400glTF'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.
diffuseTransmissionFactorKHR_materials_diffuse_transmission0..10HD only. Share of the diffuse light that passes through a thin surface lit from behind: leaves, paper, lampshades.
diffuseTransmissionColorFactorKHR_materials_diffuse_transmission3 numbers, each 0..1[1, 1, 1]Tint of the transmitted light.
flakeColor–3 numbers, each 0..1none (off)Colour of the metallic flakes under the clear coat. The carPaint preset sets it to the resolved baseColor × 1.2.
flakeDensity–0..10.5Share of the surface the flakes cover. Needs flakeColor.
flakeSize–millimetres, 0.05..20.3Flake 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..10.5How strongly each flake's random orientation makes it glint (the spread of the flake normals). Needs flakeColor.
flipColor–3 numbers, each 0..1none (off)Colour seen at grazing angles: a two-tone or colour-flip paint, blended by view angle.
flipStrength–0..11How 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 }noneMaterials blended over this one before lighting, in order; see Layers.
layerMask–texture refnoneLinear. The layers' mask, read with uvTransform. Absent: the vertex colour masks the layers.
uvTransformKHR_texture_transform{ offset, scale, rotation }identityApplies 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.
variation–{ value, hue }, each 0..1noneEach instance's own shade: darker by up to value, shifted towards its own hue by up to hue; see Variation.
shadingKHR_materials_unlit for unlitpbr, unlit, vertex, toon, hairthe preset's, else the scene'sHow the surface is lit; see Shading and filtering.
toonSteps–whole number 1..163Light bands of toon shading.
filter–linear, nearest, three-pointthe scene'sHow the textures are filtered; see Shading and filtering.
shader–asset id or pathnoneA *.shader.wgsl whose surface() draws the material; see Shader files.
params–objectnoneValues for the shader's param and texture names; needs shader.
castShadows–true, falsetrueWhether 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.

FieldValuesDefaultMeaning
modetriplanar (required)–Project along x, y and z and blend the three samples by the normal.
spaceworld, objectworldworld 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.
scaleMabove 01Metres per texture repeat.
sharpness1..644The exponent of the blend across the planes: higher keeps the planes apart with a narrower seam.
{ "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.

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

shadingWhat it draws
pbrLit per pixel: GGX specular, Lambert diffuse, the sky's image-based light.
unlitbaseColor × colour map × vertex colour, exactly: no light, no tonemapping, no exposure.
vertexGouraud: 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.
toonDiffuse light in toonSteps bands plus a rim.
hairHair cards: fibre highlights running across the strands, which follow the mesh tangent (HD); see Skin and hair. The standard renderer draws it as pbr.

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

filterWhat it draws
linearBilinear with mipmaps, or whatever a glTF model's sampler says.
nearestThe nearest texel: hard texel edges when a texture is magnified.
three-pointEach 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.

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

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

PresetWhat it is
plasticSmooth-ish white dielectric, a moulded plastic housing; set baseColor for the colour.
paintedMetalSatin paint over metal: a dielectric (the paint is what you see), a little rougher than plastic.
brushedMetalBrushed aluminium: full metal, medium roughness (the brushing direction is not modelled).
polishedMetalPolished steel or chrome: full metal, near-mirror.
rubberBlack rubber: dark, rough, a weak reflection.
glassClear glass: smooth, fully transmissive, ior 1.5 (HD draws it as thin glass).
carPaintMetallic 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.
wheelAluminiumMachined 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.
wheelGlossBlackGloss black wheel: near-black dielectric under a smooth clear coat.
tyreRubberTyre rubber: dark, rough, with a dusty grey sheen. Lettering comes from the metallicRoughnessTexture or ormTexture roughness channel (smoother and darker where the letters are).
fabricCloth: fully rough with a soft sheen and a weak reflection (HD draws the sheen).
woodUnfinished wood: a warm brown dielectric, fairly rough; pair it with a grain texture.
concreteCast concrete: light grey, matte.
emissiveLightA lit panel or bulb: warm white emission at 5x unlit white, so it blooms.
skinHuman skin: light scatters under the surface (HD), and the reflection is weak (F0 0.028); set baseColor for the tone.
hairHair cards: fibre highlights along the mesh tangent (HD), alpha-masked strands, both sides; set baseColor for the hair colour.
mossMoss: a dark olive green, fully rough, a weak reflection; a layer over stone, bark or roof tiles.
wetA water film or puddle: dark and near-mirror; a layer over a dry surface, gathered in its hollows by heightBlend.
dirtDry soil or dust: a warm mid brown, fully rough; a layer over floors, walls and props.
snowSnow: 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).

CodeSeverityWhen
schema-shapeerrorNot JSON, not an object, a wrong type, an unknown key, or a preset / alphaMode / shading / filter that is not a member.
material-value-rangeerrorA number outside its range, as in the table above; more than 3 layers; a layer's contrast below 1.
material-texture-invaliderrorA 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-mixederrorThe layers of one material that have maskFrom.heightM disagree on maskFrom.heightFrom.
material-mask-from-wear-botherrorA layer's maskFrom has more than one of edge, cavity, ao, convexity and concavity (one wear signal per layer).
material-field-ignoredwarningA 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.

{ "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). subsurfaceRadius per channel. The kernel is the screen-space separable one from Jimenez et al. 2015, fitted to Burley's diffusion profile. edges, softened pores. stays sharp. when no skin is in view. Quality knob materials.sss (off on Low).
    • HD takes that share of the pixel's diffuse light and spreads it over the surface around it, by
    • The scattering moves light and keeps its total: soft terminators, red bleeding into shadow
    • The colour map is applied half before and half after the scattering, so texture detail
    • 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
  • Hair cards (shading: "hair"). model's tangents), and the highlights run across them. and light through the fibres from behind. below it is dithered, and the temporal upscaler turns the dither into soft coverage. transparency pass instead. It looks softer at silhouettes, and costs about three times as much.
    • Each card's strands run along its mesh tangent (the direction of increasing U in a glTF
    • The lobes follow Karis 2016: a white highlight toward the root, a tinted one toward the tip,
    • Use alphaMode: "mask". Texels at or above alphaCutoff are opaque. The thinner fringe
    • The quality knob hair.blendedFringe (Ultra) blends the fringe in the order-independent
    • 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:

KeyValuesDefaultNotes
materiala scene asset id of kind material, or a .material.json path relative to this filerequiredThe layer's own layers and HD-only effects are not read, so layers cannot cycle.
maskr, g, b, arThe mask channel.
contrast1 or more1Sharpens the mask around 0.5; 1 keeps it as is.
heightBlend0..10How 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.
edgeWidth0..10How 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).
edgeNoise0..10How 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.
edgeNoiseM0.01..10001The edge noise's wavelength in metres. Only matters when edgeNoise is above 0.
edgeStreakM0.01..1000noneThe 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 }noneA 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.
blendmix, multiplymixHow 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:

{ "preset": "moss" }
{ "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:

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

{ "preset": "wet", "baseColor": [0.5, 0.5, 0.5] }
{ "preset": "wood", "layers": [{ "material": "wet.material.json", "mask": "b", "blend": "multiply" }] }
{ "preset": "wood", "layers": [{ "material": "dirt.material.json", "mask": "g", "contrast": 2 }] }
{ "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.

{ "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):

"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 rendererHD (render.profile: "hd")
Base colour, colour map, metallic, roughness, normal mapyesyes
ORM / metallic-roughness / occlusion maps, occlusion strengthyes: G scales roughness, B metallic, R (times occlusionStrength) darkens the image-based light, as in HD; the mesh's own UVsyes
Emission colour and strengthyes: added to the lit colour before exposure and the tonemap; unlit surfaces ignore ityes
Emission mapnoyes
Alpha mask and blend, double-sidedyes (blend sorted back to front per object)yes
shadingyesunlit and toon; pbr and vertex are lit per pixel
filter, a glTF sampleryeslinear
Specular, specular colouryes: 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, iorno (a second lobe only for the car keys below)yes (ior folds into the specular tint)
Transmissionnoas thin glass: blended by what it does not transmit, no refraction
uvTransformno (the mesh's own UVs)yes, in every pass: raster, shadow, virtual geometry and ray tracing
Sheennoyes (Charlie sheen over the base, KHR_materials_sheen)
Anisotropyonly with the car keys below: a stock shader stretches the highlight along anisotropyPattern/anisotropyAxisyes (not on skin or hair materials)
Iridescence, diffuse transmissionnoyes (not on skin or hair materials)
Flakes (flakeColor and tuning), orange peel, flip colour, clear coat on the new car keysyes: 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
layersyes: 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, subsurfaceRadiusnoyes: screen-space subsurface scattering (quality knob materials.sss)
hair shadingas pbryes: 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.