Shaders
A shader file is a .shader.wgsl (asset kind shader): a few //! header lines, then plain WGSL with a surface() function.
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.
- Quick start
- Which kind of file
- The file and its header
- What a shader sees and returns
- Models, blend modes and the other keys
- The prelude
- Limits, the sandbox and cost
- Errors and codes
- Materials
- Particles
- Screen effects (
kind: post) - Escape hatch (
kind: raw,kind: pass) - Editing live: hot reload and the editor
- Agent tools and the critic
- How it runs, export and pre-warm
- Measured numbers
- Renderers and platforms
- 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
shaderstoolset,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) andinstancefloats (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 want | write | draws | section |
|---|---|---|---|
| a look for a mesh (toon, glow, water, dissolve, wind) | *.shader.wgsl with fn surface, named by a .material.json | the standard renderer today (HD: section 17) | 3 to 9 |
| the same look on particle sprites | the same file, blend: add or blend: blend, named by a particles component's material | both renderers (HD: section 17) | 10 |
| an effect over the whole screen (vignette, posterize, outline) | //! kind: post with fn screen, listed in meta.render.screenEffects | the standard renderer, none by default | 11 |
| something the surface function cannot say | //! kind: raw or kind: pass, off unless render.shaders.raw | the standard renderer, through its Rust API (section 12) | 12 |
| car paint, wheels, tyres | no shader: a material file with a car preset | the engine's own stock module (section 9) | 9 |
3. The file and its header
| function | required | runs | returns |
|---|---|---|---|
fn surface(in: SurfaceInput) -> SurfaceOutput | yes | per pixel | the surface |
fn vertex(in: VertexInput) -> vec3f | no | per vertex | a world-space offset in metres, clamped to bounds |
fn light(p: ShadePoint, l: Light) -> vec3f and fn ambient(p: ShadePoint, irradiance: vec3f) -> vec3f | only with model: stylized | per light; the engine owns the loop | a 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 acolor(linear);true; a whole number fori32.[min, max]is optional (not onbool) 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>andtex_<name>(uv) -> vec4f(tex_<name>_lod(uv, lod)invertex()). Textures exist only throughtex_*; there are no raw handles. model: stylizedneeds bothfn light(andfn ambient(, and they are used only with it.tonemapis only forunlit.paramis per material (every entity wearing the material shares it);instanceis per entity, up to 4 floats, set by a behaviour without making a new material or batch (ctx.render.setShaderParam, section 9).boolandi32params 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 apost,raworpassfile;instanceis refused inpost;paramandtextureare refused inrawandpass(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
model | what you write | lit by |
|---|---|---|
lit | surface() | 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) |
unlit | surface() | nothing: baseColor + emission; tonemap: off skips the tonemap for the surface |
stylized | surface() 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
blend | draws | notes |
|---|---|---|
opaque (default) | in the opaque pass, depth-writing | early-z intact |
mask | opaque with a clip from opacity | has its own shadow variant; alpha-to-coverage under MSAA |
blend | alpha-blended, sorted per object back to front | no depth write |
add | additive | glow, hologram, fire; draws after every alpha-blended surface, grouped by pipeline (built-in blended materials and particles keep their order) |
reads: depth(blended surfaces) givesscene_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 andrender.shader.downgradedsays so once.vertex()returns an offset in metres that the engine clamps toboundswith onemin, so culling stays right. It runs in the main and shadow passes. Usetex_<name>_lodthere. 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.
| hashes | hash11, hash21, hash33: PCG on u32, in [0, 1), identical bits on every GPU |
| noise | noise1, noise2, noise3 (value noise); fbm2, fbm3(p, octaves); voronoi2(p) (a vec3f: nearest and second nearest distance, and the cell's random value) |
| shapes | fresnel(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) |
| colour | luminance, srgb_to_linear, linear_to_srgb; constants PI, TAU; saturate is the WGSL builtin |
| texture tricks | triplanar_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) |
| surface | surface_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
| limit | code | |
|---|---|---|
| source | 16 KB (16384 bytes) | shader-header-invalid |
| params | 256 B packed (16 vec4) | shader-param-invalid |
| instance floats | 4 | shader-param-invalid |
| textures | 6 | shader-texture-invalid |
| pipelines per shader per renderer | 10 (typical 1 to 3) | shader-variants-over-cap |
| function-local arrays | 64 elements | shader-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-scopevar,@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 insurface(),light(),ambient()andscreen(), not invertex(). - Where there are no screen neighbours (the ray-hit and uv-bake variants an HD project compiles)
aa_step()and thetex_<name>()reads take their width and level from the hit, so onesurface()serves every path. A rawdpdx,dpdyorfwidthinsurface()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
forwith a constant trip count of at most 32, at most 256 iterations across nested loops.whileandloopare 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 soft | desktop / HD hard | web / mobile soft | web / mobile hard | |
|---|---|---|---|---|
| cost units | 400 | 2,000 | 150 | 600 |
| texture fetches | 8 | 16 | 4 | 8 |
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.
| source | codes |
|---|---|
| header | shader-header-invalid, shader-param-invalid, shader-texture-invalid, shader-model-mismatch |
| compiler | shader-compile-error, shader-forbidden, shader-loop-unbounded, shader-cost-over-cap, shader-cost-high (warning), shader-variants-over-cap |
| escape hatch | shader-raw-disabled |
| material file | material-shader-missing, material-shader-param-unknown, material-shader-param-range, material-field-ignored (warning) |
| export | build.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).
shaderis a scene asset id of kindshader, or a path relative to the material file (no leading slash, drive or..).paramskeys 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 iswhite,black,flat,builtin:noiseor a path relative to the material file.doubleSided,alphaCutoff,castShadowsand the extension-lobe keys still apply;baseColor, texture maps,shadingandlayersdo 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.
instancefloats are data only: no new material, no new batch. A behaviour sets one withctx.render.setShaderParam(entity, name, value)(for that entity only); a sharedparamchanges for everything wearing the material withctx.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-uvanisotropyPattern,anisotropyAxis), on an opaque pbr material without ashader, is drawn in the standard renderer with a stock module the engine ships (crates/shader/src/stock/car.wgsl, astylizedsurface), 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 indocs/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(defaultbeforeTonemap). 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; plusscene_color(uv)andscene_depth(uv)for neighbour taps (clamped to the screen). The sandbox and cost rules are a surface shader's, with derivatives allowed.params andtextures as usual: an effect'stextures are the header's defaults (a built-in name, or a file beside the shader), or the entry'sparamsvalue 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,instanceand 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-effectline; 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.renderstrength/radius; not a shader file). HD drawsscreenEffectstoo, beside its own post chain (abeforeTonemapeffect ahead of exposure, bloom and the tonemap, anafterTonemapone 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@vertexand@fragmententry points.kind: pass: a full-screen fragment pass or a compute pass (@fragmentor@compute) atat:(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) andtargets(desktop,web,mobile) are required.argus_shader::compile::unsupported_targetsreports 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 liststandardto register in the standard renderer. - Raw materials: opaque, forward, static meshes, the engine's vertex layout (
@location(0)position vec3f,1normal vec3f,2tangent vec4f,3uv vec2f,6colour 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:
afterOpaqueandbeforeTonemapon the HDR scene (before and after bloom),afterTonemapon display colour (before FXAA),overlayon 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,depthorcolor, depthmakes the engine declare in group 2scene_colorandscene_sampler(bindings 0, 1; linear clamp) andscene_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 dispatchesset_hatch_dispatchworkgroups (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) andset_hatch_texture(id, binding, texture)(defaults to white;samplerbindings 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,textureStoreare allowed, so a bad shader can hang the GPU; - the cost caps: no static estimate, no
shader-cost-highorshader-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
standarddoes 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(thehot-reloadfeature, 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 changedshaderkey, or a changed texture, needs a scene reload). Shipped builds have no watcher.- The editor takes the same change from its journal: a
*.shader.wgslor*.material.jsonwritten 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-partialand why, the flat fallback being what is on screen), and an HD that cannot run on this machine saysrender.hd.unavailableand 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.renderon 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.jsonnaming 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..), itsparamschecked against the header first.dryRunonly checks.shader.library {section}:models,functions(read from the prelude),glsl(the cheat sheet) orexamples(the six complete example shaders). No section lists the four.shader.preview {material, shape?, strip?, renderers?, out?}draws the material throughPOST /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,cubeorplane): 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: truedraws 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 underskippedwith the reason (not available ..., namingrender.hd.user-shader-partialand 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.measuredMsFullScreenneeds the GPU timer and isnullwhere there is none; the critic then judges on the static estimate and says so. The HD columns:hdStatic(the HD cost, uncalibrated) andhd:kind,path(g-buffer,forward-opaque,forward-blend),drawn(andfallback, the reason, when not),passes(g-buffer,forward,transparent-sorted,transparent-oit,shadow-cutout,particle, or a screen effect's or pass's),pipelinesHD builds when the family registers andpipelinesWithRayTracing(one more: its bake),rayHits.handling(baked,baked-frozenwith what the bake fixes underfrozen,fallbackwhen HD cannot draw the family),pending(paths HD does not draw yet) andcodes(the runtime codes an HD session raises for it).argus-export --shader-checkprints the same object. The standard columns are unchanged.- A
scene.patchthat 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.
| what | number | where measured |
|---|---|---|
| P0: 1,000 shaders, Vulkan, true cold | 2.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 / warm | about 28 to 30 ms / 0.5 to 0.7 ms; the PipelineCache file is 4.9 MB per 1,000 | RTX 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 budget | RTX 5080 |
| Stress scene A (200 shaders, 2,000 materials, 10,000 instances): warm load | 184 ms (budget 1,000), created after load 0, fallback draws 0 | RTX 5080, argus-shader-stress |
| Stress scene A: frame | user 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 step | RTX 5080 |
| replica-n50 (50 materials, 63 textures), standard renderer: GPU memory | 117 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 calls | 206 with texture arrays and merged instancing, 1,006 without; CPU encode 0.56 to 0.79 ms merged, about 1.0 ms unmerged | llvmpipe, loaded machine |
| 1,000 families / 10,000 materials: compile and export | compile cold 355 ms, warm 24 ms; export 520 ms, 1.49 MB, 2,279 pipelines listed | llvmpipe (CPU only) |
| Hot reload, edit to visible | 5 to 65 ms | llvmpipe, 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()) | yes | yes, 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()) | yes | yes, in HD's forward and weighted-OIT passes, lit by HD's own lighting; ray-traced hits see a bake of it |
vertex() | yes | not yet: the family's materials draw their flat PBR fallback, said once (render.hd.user-shader-partial) |
| skinned, shadows | yes | yes (a mask family's shadow cut-out is its surface()'s) |
| particle shaders | yes | yes: 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 effects | yes | yes: beforeTonemap on HD's upscaled HDR image, afterTonemap on the finished display colour (below) |
| escape hatch | yes (renderers: standard), through the renderer API | kind: 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 keys | the 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 slot | standard renderer | HD |
|---|---|---|
beforeTonemap | the scene's HDR image after bloom, at render scale | HD'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 |
afterTonemap | display colour, before FXAA | the 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.depth | the scene depth in metres | the 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.