Talos docs

Shaders

Since 0.6Changed in 0.7Changed in next

A shader file is a .shader.wgsl (asset kind shader): a few //! header lines, then plain WGSL with a surface() function.

Plain text

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

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

  1. Quick start
  2. Which kind of file
  3. The file and its header
  4. What a shader sees and returns
  5. Models, blend modes and the other keys
  6. The prelude
  7. Limits, the sandbox and cost
  8. Errors and codes
  9. Materials
  10. Particles
  11. Screen effects (kind: post)
  12. Escape hatch (kind: raw, kind: pass)
  13. Editing live: hot reload and the editor
  14. Agent tools and the critic
  15. How it runs, export and pre-warm
  16. Measured numbers
  17. Renderers and platforms
  18. Examples

1. Quick start

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

shaders/pulse.shader.wgsl

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

pulse.material.json

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

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

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

2. Which kind of file

you wantwritedrawssection
a look for a mesh (toon, glow, water, dissolve, wind)*.shader.wgsl with fn surface, named by a .material.jsonthe standard renderer today (HD: section 17)3 to 9
the same look on particle spritesthe same file, blend: add or blend: blend, named by a particles component's materialboth renderers (HD: section 17)10
an effect over the whole screen (vignette, posterize, outline)//! kind: post with fn screen, listed in meta.render.screenEffectsthe standard renderer, none by default11
something the surface function cannot say//! kind: raw or kind: pass, off unless render.shaders.rawthe standard renderer, through its Rust API (section 12)12
car paint, wheels, tyresno shader: a material file with a car presetthe engine's own stock module (section 9)9

3. The file and its header

functionrequiredrunsreturns
fn surface(in: SurfaceInput) -> SurfaceOutputyesper pixelthe surface
fn vertex(in: VertexInput) -> vec3fnoper vertexa world-space offset in metres, clamped to bounds
fn light(p: ShadePoint, l: Light) -> vec3f and fn ambient(p: ShadePoint, irradiance: vec3f) -> vec3fonly with model: stylizedper light; the engine owns the loopa colour

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

//! model: lit | unlit | stylized         (default lit)
//! blend: opaque | mask | blend | add    (default opaque)
//! reads: depth                          (scene depth behind a blended surface)
//! bounds: 0.3                           (metres, above 0; required with fn vertex(), and only then)
//! tonemap: on | off                     (unlit only; default on)
//! param <name>: f32|vec2f|vec3f|vec4f|color|bool|i32 = <default> [min, max]
//! instance <name>: f32 = <default> [min, max]
//! texture <name>: color|data|normal = "white"|"black"|"flat"|"builtin:noise"|"<path>"
//! texture <name>: array(color|data|normal) = <default>     (an array slot: a list of layers per material)
//! drawTexture <name>: color|data|normal = <default>         (bound per draw; at most 2, beside the 6 textures)
//! paramBytes: <n>                       (stock modules only: 256 < n <= 2048, a multiple of 16)
//! costCap: <units> <fetches>            (stock modules only: this module's own hard cost caps)
//! kind: surface | post | raw | pass     (default surface)
//! slot: beforeTonemap | afterTonemap    (post only, default beforeTonemap)
//! renderers: standard, hd               (raw and pass only; required)
//! targets: desktop, web, mobile         (raw and pass only; required)
//! at: afterOpaque | beforeTonemap | afterTonemap | overlay   (pass only; required)
//! input: color | depth | color, depth   (a fragment pass only)
  • A default is typed: 1.5; (0.9, 0.35, 0.3) for a vector or a color (linear); true; a whole number for i32. [min, max] is optional (not on bool) and the default must lie inside it.
  • Names are camelCase and unique across params, instances, textures and draw textures. A texture path is relative to the shader file (no leading slash, drive or ..).
  • From the header the engine generates params.<name>, instance.<name> and tex_<name>(uv) -> vec4f (tex_<name>_lod(uv, lod) in vertex()). Textures exist only through tex_*; there are no raw handles. The accessors of each kind of slot (WGSL has no overloading, so a slot has one set of signatures): | slot | accessors | |---|---| | texture <name>: color\|data\|normal | tex_<name>(uv: vec2f) -> vec4f, tex_<name>_lod(uv, lod: f32), tex_<name>_grad(uv, ddx: vec2f, ddy: vec2f), tex_<name>_load(texel: vec2i) (data only), tex_<name>_size() -> vec2u | | texture <name>: array(color\|data\|normal) | tex_<name>(uv, layer: u32), tex_<name>_lod(uv, lod, layer), tex_<name>_grad(uv, ddx, ddy, layer), tex_<name>_load(texel, layer) (data only), tex_<name>_size() -> vec2u, tex_<name>_layers() -> u32 | | drawTexture <name>: color\|data\|normal | as a plain slot, bound per draw (section 9) | _load reads the exact texel of level 0, no filtering, no sampler, the texel clamped to the edge (for per-texel data such as a weight map); on a color or normal slot it is a compile error naming the rule. _size is level 0's size in texels (an array's is its first layer's; every layer has it). _layers is the material's layer count; a layer at or past it reads the last layer, never out of bounds. _grad takes explicit gradients and works in any function (vertex() included). Only the accessors your code names are generated, so a shader that uses none of these compiles to exactly the module it always did. A data (and normal) slot or array is stored as linear RGBA8 (Rgba8Unorm, never sRGB, not block-compressed), so _load returns the file's bytes divided by 255 exactly; a color slot is sRGB and reads linear.
  • model: stylized needs both fn light( and fn ambient(, and they are used only with it. tonemap is only for unlit.
  • param is per material (every entity wearing the material shares it); instance is per entity, up to 4 floats, set by a behaviour without making a new material or batch (ctx.render.setShaderParam, section 9).
  • bool and i32 params are branches on a uniform value, which is effectively free: use them instead of variants. There is no #if.
  • The surface keys (model, blend, reads, bounds, tonemap) are refused in a post, raw or pass file; instance is refused in post; param and texture are refused in raw and pass (resources are WGSL there).

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

The packed params block

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

4. What a shader sees and returns

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

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

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

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

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

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

A stylized shader, complete:

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

5. Models, blend modes and the other keys

Models

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

Blend

blenddrawsnotes
opaque (default)in the opaque pass, depth-writingearly-z intact
maskopaque with a clip from opacityhas its own shadow variant; alpha-to-coverage under MSAA
blendalpha-blended, sorted per object back to frontno depth write
addadditiveglow, hologram, fire; draws after every alpha-blended surface, grouped by pipeline (built-in blended materials and particles keep their order)
  • reads: depth (blended surfaces) gives scene_depth_behind(in) -> f32: metres along the view ray from this surface to the opaque scene behind it, 1000 where nothing is behind (soft particles, shorelines, shields). The depth is copied only in frames where such a surface is in view. In a multisampled or reduced-scale pass there is no such copy: the surface sees nothing behind it and render.shader.downgraded says so once.
  • vertex() returns an offset in metres that the engine clamps to bounds with one min, so culling stays right. It runs in the main and shadow passes. Use tex_<name>_lod there. Complete example:
//! model: lit
//! bounds: 0.3
//! param sway: f32 = 0.1 [0, 0.3]
//! instance gust: f32 = 0 [0, 1]
fn vertex(in: VertexInput) -> vec3f {
  let h = max(in.objectPos.y, 0.0);
  let s = sin(in.time * 2.0 + in.worldOrigin.x);
  return vec3f(s * params.sway * h * (1.0 + instance.gust), 0.0, 0.0) + in.wind * 0.05 * h;
}
fn surface(in: SurfaceInput) -> SurfaceOutput {
  return surface_default();
}
  • A blended surface that reads the depth:
//! model: unlit
//! blend: blend
//! reads: depth
//! param tint: color = (0.1, 0.5, 0.7)
fn surface(in: SurfaceInput) -> SurfaceOutput {
  var s = surface_default();
  s.emission = params.tint;
  s.opacity = saturate(scene_depth_behind(in) / 2.0) * 0.6;
  return s;
}
  • Skinned meshes, double-sided materials and shadow casting come from the material (doubleSided, castShadows), never from the shader; the engine builds the matching variants.

6. The prelude

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

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

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

7. Limits, the sandbox and cost

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

limitcode
source16 KB (16384 bytes)shader-header-invalid
params256 B packed (16 vec4)shader-param-invalid
instance floats4shader-param-invalid
textures6 (an array slot counts as one)shader-texture-invalid
layers of one array slot (per material)64material codes (section 9)
drawTexture slots2 (not counted in the 6)shader-texture-invalid
pipelines per shader per renderer10 (typical 1 to 3)shader-variants-over-cap
function-local arrays64 elementsshader-forbidden
prefix operators in a row (!, -, ~, *, &)64shader-compile-error
brackets nested ((, [, { together)64shader-compile-error

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

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

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

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

desktop / HD softdesktop / HD hardweb / mobile softweb / mobile hard
cost units4002,000150600
texture fetches81648

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

8. Errors and codes

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

sourcecodes
headershader-header-invalid, shader-param-invalid, shader-texture-invalid, shader-model-mismatch
compilershader-compile-error, shader-forbidden, shader-loop-unbounded, shader-cost-over-cap, shader-cost-high (warning), shader-variants-over-cap
escape hatchshader-raw-disabled
material filematerial-shader-missing, material-shader-param-unknown, material-shader-param-range, material-field-ignored (warning)
exportbuild.shader-scene-prewarm-heavy (warning)
runtime (docs/RUNTIME-CODES.md)render.shader.fallback, render.shader.compiled-after-load, render.shader.downgraded, render.shader.families-many, render.screen-effect

9. Materials

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

  • shader is a scene asset id of kind shader, or a path relative to the material file (no leading slash, drive or ..). params keys are checked against the header (unknown name, wrong type, out of range, texture not found). A colour is 3 linear numbers or #RRGGBB (read as sRGB and converted). A texture param is white, black, flat, builtin:noise or a path relative to the material file.
  • doubleSided, alphaCutoff, castShadows and the extension-lobe keys still apply; baseColor, texture maps, shading and layers do nothing (material-field-ignored).
  • A new material compiles nothing. A material is a row of params in one shared buffer; a new shader adds at most one pipeline per path it is drawn on. Share one shader across a look and vary its params.
  • Fallback. Every shader material has a built-in PBR stand-in (the surface drawn on a 32 by 32 card at t=0, averaged to base colour, emission, roughness, metallic). It draws while the real pipeline builds, if the shader fails (render.shader.fallback, once per family), and wherever the shader cannot run. A not-ready shader therefore never waits and never draws garbage.
  • Per-entity values. instance floats are data only: no new material, no new batch. A behaviour sets one with ctx.render.setShaderParam(entity, name, value) (for that entity only); a shared param changes for everything wearing the material with ctx.render.setMaterialParam(materialAssetId, name, value) (a number, boolean, array of numbers or "#RRGGBB"; texture params are set in the file). Both are buffer writes, applied at the next frame in a fixed order (last write in a tick wins); a name the shader does not declare is logged once and ignored; a headless run has no renderer and drops them (docs/API.md, "Shader parameters").
update(ctx) {
  ctx.render.setShaderParam(ctx.entity, "gust", Math.min(1, ctx.time.now / 2))
}
  • Textures and batching. A family's materials whose textures share a size and format are packed into texture arrays as the materials register; a material's slots are layer indices in its params, so those materials share one bind group and draw in one call. A material that leaves a texture unset shares the texture set of one that sets it. Draws group by (family, texture set, draw state), so instancing spans materials (section 16 has the draw-call counts).
  • Array slots. A texture <name>: array(<kind>) param is a JSON array of texture values, one per layer, 1 to 64 of them (["grass.png", "rock.png", "builtin:noise", "white"]); a single string is one layer, and a material that leaves it out has one layer, the header's default. Every layer is resampled to the first layer's size and format on the GPU when the material registers (bilinear, reading the source's mip at the matching scale, then its mips made again), so layers of different sizes are fine and _load / _size read the same texels everywhere; a layer that does not load reads the header default (a warning; a load failure in strict mode). The layers of a material sit together in the slot's texture array (on HD: a list of bindless indices), so materials of a family still share arrays and draw calls as above. From the engine: UserMaterialSpec::texture_layers(name, layers).
  • Per-draw textures (drawTexture). A family may declare up to 2 slots that are bound per draw, not per material (a per-cell weight map, say). The engine binds them with Renderer::user_draw_material(material, [Option<TextureId>; 2]), which returns a material to draw with: it shares the base material's params (a change shows in every draw), textures, draw state and pipelines, and binds the given textures to the slots in declaration order (None: the slot's default, the material file's own value for the slot if it names one, else the header's). The same pair returns the same material; draws with different textures cannot share a draw call. A material file does not set draw slots per draw; a path default of a drawTexture slot is loaded as the material's default.
  • Car paint, wheels and tyres need no shader. A material file that uses a car preset (carPaint, wheelAluminium, wheelGlossBlack, tyreRubber) or any car key (flakeColor, flakeDensity, flakeSize, flakeSparkle, clearcoatOrangePeel, clearcoatOrangePeelScale, flipColor, flipStrength, a non-uv anisotropyPattern, anisotropyAxis), on an opaque pbr material without a shader, is drawn in the standard renderer with a stock module the engine ships (crates/shader/src/stock/car.wgsl, a stylized surface), through the same path as a user shader. It is built per feature set, so a plain clear coat does not pay for flakes. The module is a constant that the engine's tests validate and cost, so a shipped build draws it too: its family is in the export's shader pack and the scene's pre-warm list (stock:car:<features>), and the shipped engine needs no WGSL front end for it. An HD export needs none (HD draws these keys natively). Web, Android and iOS get a lighter form when the full module is over their cost cap (every full set is: no orange peel, colour flip or anisotropy; flakes as a static speckle with an averaged glint; one lobe whose roughness follows the clear coat). The keys and the look are in docs/MATERIALS.md, "Presets" and "Car paint, wheels and tyres in the standard renderer".
  • Terrain is a stock family too (crates/shader/src/stock/terrain.wgsl, stock:terrain:<features> in an export's pack and pre-warm list; the standard renderer only, HD draws terrain from its own virtual-geometry variant, which shares the arithmetic). Its surface() is terrain_math.wgsl (a prelude section: a shader may call the terrain_* helpers that need no record, such as terrain_macro or terrain_rule_weight; the module defines the record hooks terrain_layer_params, terrain_globals, ... itself). A layer set is one material of the family: array slots albedo (colour, the layer's height in alpha), normal (the normal map, roughness in alpha) and orm (only with a packed ORM map: R occlusion, B metallic), packed by the engine from each layer's .material.json; each cell is a draw of it with drawTexture slots layerMap (one texel per terrain sample) and farMap. The record is 28 vec4 params read field by field from the params buffer (user_base_, stock modules only; their params struct is not loaded at all), never copied whole. A stock module's sets filter 8x anisotropic; other families' sets keep the plain sampler. Its costCap (40000 units, 192 fetches) replaces the hard caps for every target, the web's included; its distance blend uses the distance from user_frame.camera_pos.

10. Particles

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

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

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

11. Screen effects (kind: post)

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

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

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

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

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

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

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

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

13. Editing live: hot reload and the editor

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

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

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

14. Agent tools and the critic

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

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

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

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

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

15. How it runs, export and pre-warm

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

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

In an exported game

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

Escape hatch: kind: raw and kind: pass

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

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

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

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

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

16. Measured numbers

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

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

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

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

17. Renderers and platforms

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

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

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

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

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

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

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

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

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

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

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

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

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

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

18. Examples

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