Talos docs

Shaders

Since 0.6

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>"
//! 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 and 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.
  • 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)
}
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 }
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 }

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

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
textures6shader-texture-invalid
pipelines per shader per renderer10 (typical 1 to 3)shader-variants-over-cap
function-local arrays64 elementsshader-forbidden

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

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

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

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 at the start of the next frame (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).

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 material's textures are copied into its family's texture arrays, and the separate upload of a texture is then released when nothing else binds it: the array layer is its only copy. 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; ray-traced hits see a bake of it (not yet virtual-geometry bins; 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 mesh whose material is a user material draws as raster, not virtual geometry. A mask family's shadow passes run its surface() too (Pass::GShadow).

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). 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, virtual-geometry bins (such meshes draw as raster), 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; a later param or texture change bakes the material again at the start of the next frame (once per material per frame, no pipeline created), so rays follow raster. 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.

Web and mobile. The limits of section 7 fit WebGPU's default limits and CI validates every family against them; 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.