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>"
//! texture <name>: array(color|data|normal) = <default> (an array slot: a list of layers per material)
//! drawTexture <name>: color|data|normal = <default> (bound per draw; at most 2, beside the 6 textures)
//! paramBytes: <n> (stock modules only: 256 < n <= 2048, a multiple of 16)
//! costCap: <units> <fetches> (stock modules only: this module's own hard cost caps)
//! kind: surface | post | raw | pass (default surface)
//! slot: beforeTonemap | afterTonemap (post only, default beforeTonemap)
//! renderers: standard, hd (raw and pass only; required)
//! targets: desktop, web, mobile (raw and pass only; required)
//! at: afterOpaque | beforeTonemap | afterTonemap | overlay (pass only; required)
//! input: color | depth | color, depth (a fragment pass only)
- A default is typed:
1.5;(0.9, 0.35, 0.3)for a vector or 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, textures and draw textures. A texture path is relative to the shader file (no leading slash, drive or
..). - From the header the engine generates
params.<name>,instance.<name>andtex_<name>(uv) -> vec4f(tex_<name>_lod(uv, lod)invertex()). Textures exist only throughtex_*; there are no raw handles. The accessors of each kind of slot (WGSL has no overloading, so a slot has one set of signatures): | slot | accessors | |---|---| |texture <name>: color\|data\|normal|tex_<name>(uv: vec2f) -> vec4f,tex_<name>_lod(uv, lod: f32),tex_<name>_grad(uv, ddx: vec2f, ddy: vec2f),tex_<name>_load(texel: vec2i)(dataonly),tex_<name>_size() -> vec2u| |texture <name>: array(color\|data\|normal)|tex_<name>(uv, layer: u32),tex_<name>_lod(uv, lod, layer),tex_<name>_grad(uv, ddx, ddy, layer),tex_<name>_load(texel, layer)(dataonly),tex_<name>_size() -> vec2u,tex_<name>_layers() -> u32| |drawTexture <name>: color\|data\|normal| as a plain slot, bound per draw (section 9) |_loadreads the exact texel of level 0, no filtering, no sampler, the texel clamped to the edge (for per-texel data such as a weight map); on acolorornormalslot it is a compile error naming the rule._sizeis level 0's size in texels (an array's is its first layer's; every layer has it)._layersis the material's layer count; alayerat or past it reads the last layer, never out of bounds._gradtakes explicit gradients and works in any function (vertex()included). Only the accessors your code names are generated, so a shader that uses none of these compiles to exactly the module it always did. Adata(andnormal) slot or array is stored as linear RGBA8 (Rgba8Unorm, never sRGB, not block-compressed), so_loadreturns the file's bytes divided by 255 exactly; acolorslot is sRGB and reads linear. 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)
worldDdx: vec3f, worldDdy: vec3f, // dpdx(worldPos), dpdy(worldPos): metres per pixel (see below)
}
struct SurfaceOutput {
baseColor: vec3f, opacity: f32,
normal: vec3f, // tangent space, default (0, 0, 1)
roughness: f32, metallic: f32, ao: f32, specular: f32,
emission: vec3f,
transmission: f32, // 0..1, default 0: thin-surface light through from behind (leaves, cloth, paper)
}
struct VertexInput { objectPos: vec3f, worldPos: vec3f, normal: vec3f, uv: vec2f, color: vec4f,
worldOrigin: vec3f, time: f32, wind: vec3f }
struct ShadePoint { worldPos: vec3f, normal: vec3f, viewDir: vec3f, baseColor: vec3f, roughness: f32,
metallic: f32, ao: f32, specular: f32, emission: vec3f, transmission: f32, time: f32 }
struct Light { direction: vec3f, color: vec3f, shadow: f32, distance: f32 }
worldDdx / worldDdy are the screen-space derivatives of worldPos (metres per pixel along the screen's x and y), taken by the wrapper before surface() runs, for gradient sampling (tex_<name>_grad) and footprint-aware detail. Where there are no screen neighbours (HD's ray-hit and uv-bake variants) they are a stand-in from the hit's ray-cone footprint: two orthogonal vectors in the surface's tangent plane (along the tangent and the bitangent) of length clamp(exp2(lod) / 1024, 1e-5, 0.5), the width aa_step uses there (in a bake, level 0: 1/1024, the bake's uv being its world xy). They are not a downgrade (render.shader.downgraded is not raised for them). They exist only in SurfaceInput (vertex() has none), and only in a module whose code names them.
surface_default() is the engine's grey dielectric; start from it and change what you mean. Colours are linear. opacity matters with blend: blend and blend: mask (mask clips below the material's alphaCutoff; there is no discard).
The engine reads what surface() returns through one guard, the same in every pass and on both renderers, so a careless value cannot reach another pixel (bloom spreading an infinite colour over the frame, say): a NaN or infinite component reads 0; baseColor, emission and the lit colour are clamped to [0, 65504] (the HDR targets' largest value); opacity to [0, 1]; a zero-length normal is (0, 0, 1). Values in range are untouched.
In light(): direction points toward the light, color is radiance divided by pi (so baseColor * color * dot(n, direction) is Lambert), shadow is 0 (shadowed) to 1 (lit; the sun's cascade shadow, 1 for local lights), distance is metres (1e6 for the sun). p.normal is world space and faces the viewer.
A stylized shader, complete:
//! model: stylized
//! param bands: f32 = 3 [2, 8]
fn surface(in: SurfaceInput) -> SurfaceOutput {
var s = surface_default();
s.baseColor = vec3f(0.8, 0.5, 0.3);
return s;
}
fn light(p: ShadePoint, l: Light) -> vec3f {
let ndl = saturate(dot(p.normal, l.direction)) * l.shadow;
return p.baseColor * l.color * (floor(ndl * params.bands) / params.bands);
}
fn ambient(p: ShadePoint, irradiance: vec3f) -> vec3f {
return p.baseColor * irradiance * p.ao * 0.5;
}
5. Models, blend modes and the other keys
Models
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 (an array slot counts as one) | shader-texture-invalid |
| layers of one array slot (per material) | 64 | material codes (section 9) |
drawTexture slots | 2 (not counted in the 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 |
prefix operators in a row (!, -, ~, *, &) | 64 | shader-compile-error |
brackets nested ((, [, { together) | 64 | shader-compile-error |
The sandbox is checked on naga's IR after validation, so a comment or a macro cannot hide anything.
- Allowed at the top level:
fn,const,struct,alias. Refused: module-scopevar,@group/@binding, entry points,override,enable(shader-forbidden). - Refused:
textureStore, atomics, barriers,arrayLength, raw texture and sampler handles,discard, and reading the engine's bound resources (user_params,user_instances, the frame) by name: params come throughparams, per-entity floats throughinstance, textures throughtex_<name>(). The accessors are the only way totextureLoadandtextureSampleGrad: the slot's binding is the engine's like the others, and a raw load or gradient sample on it is refused (shader-forbidden). - Derivatives (
dpdx,fwidth,aa_step) are allowed 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). Every tex_* sample, _lod, _grad and _load is one fetch (8 units, plus the few ALU ops of its layer clamp); _size and _layers are not fetches and cost next to nothing. A warning (shader-cost-high) above the soft cap; a refusal (shader-cost-over-cap) above the hard cap. A project's export targets pick the stricter column (web also means mobile); a project may raise the hard caps with render.shaders.costCapUnits and costCapFetches in argus.project.json (they can only raise).
The engine's stock modules (crates/shader/src/stock/, compiled with CompileOptions::stock) may say two more header items a project's shader may not (shader-header-invalid, "for the engine's stock modules only"): paramBytes: <n> sizes their params block past the 256-byte limit (up to 2048, a multiple of 16; params past 256 bytes without it are refused), and costCap: <units> <fetches> replaces the hard caps for that module alone (the soft warnings and every project's caps are unchanged). The engine's tests validate and cost each stock module under its own cap, so a shipped build draws it without a WGSL front end.
| desktop / HD 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).
- Array slots. A
texture <name>: array(<kind>)param is a JSON array of texture values, one per layer, 1 to 64 of them (["grass.png", "rock.png", "builtin:noise", "white"]); a single string is one layer, and a material that leaves it out has one layer, the header's default. Every layer is resampled to the first layer's size and format on the GPU when the material registers (bilinear, reading the source's mip at the matching scale, then its mips made again), so layers of different sizes are fine and_load/_sizeread the same texels everywhere; a layer that does not load reads the header default (a warning; a load failure in strict mode). The layers of a material sit together in the slot's texture array (on HD: a list of bindless indices), so materials of a family still share arrays and draw calls as above. From the engine:UserMaterialSpec::texture_layers(name, layers). - Per-draw textures (
drawTexture). A family may declare up to 2 slots that are bound per draw, not per material (a per-cell weight map, say). The engine binds them withRenderer::user_draw_material(material, [Option<TextureId>; 2]), which returns a material to draw with: it shares the base material's params (a change shows in every draw), textures, draw state and pipelines, and binds the given textures to the slots in declaration order (None: the slot's default, the material file's own value for the slot if it names one, else the header's). The same pair returns the same material; draws with different textures cannot share a draw call. A material file does not set draw slots per draw; a path default of adrawTextureslot is loaded as the material's default. - Car paint, wheels and tyres need no shader. A material file that uses a car preset (
carPaint,wheelAluminium,wheelGlossBlack,tyreRubber) or any car key (flakeColor,flakeDensity,flakeSize,flakeSparkle,clearcoatOrangePeel,clearcoatOrangePeelScale,flipColor,flipStrength, a non-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". - Terrain is a stock family too (
crates/shader/src/stock/terrain.wgsl,stock:terrain:<features>in an export's pack and pre-warm list; the standard renderer only, HD draws terrain from its own virtual-geometry variant, which shares the arithmetic). Itssurface()isterrain_math.wgsl(a prelude section: a shader may call theterrain_*helpers that need no record, such asterrain_macroorterrain_rule_weight; the module defines the record hooksterrain_layer_params,terrain_globals, ... itself). A layer set is one material of the family: array slotsalbedo(colour, the layer's height in alpha),normal(the normal map, roughness in alpha) andorm(only with a packed ORM map: R occlusion, B metallic), packed by the engine from each layer's.material.json; each cell is a draw of it withdrawTextureslotslayerMap(one texel per terrain sample) andfarMap. The record is 28 vec4 params read field by field from the params buffer (user_base_, stock modules only; theirparamsstruct is not loaded at all), never copied whole. A stock module's sets filter 8x anisotropic; other families' sets keep the plain sampler. ItscostCap(40000 units, 192 fetches) replaces the hard caps for every target, the web's included; its distance blend uses the distance fromuser_frame.camera_pos.
10. Particles
A particles component may name a material file with a shader (material, like a mesh's): its sprites draw with that shader instead of the built-in soft disc, on both renderers (HD simulates such an emitter on the CPU and draws it over the upscaled image with the standard particle pipeline; an emitter without a shader keeps HD's own GPU-simulated sprites). The shader must be blend: add or blend: blend, with no fn vertex( (the quad is the engine's) and no reads: depth; any other shader leaves the built-in sprite and says so once (render.shader.fallback), and export refuses it (shader-model-mismatch, "cannot draw particles: ..."). model may be unlit, lit or stylized; bounds, mask and shadows do not apply to a sprite.
SurfaceInput carries the particle: uv is the quad (0 to 1, y down), color and particleColor the particle's linear RGBA (the emitter's colour curve), particleAge 0 at birth to 1 at death, particleRandom a number from 0 to 1 fixed for the particle's life, normal faces the camera. On a mesh particleAge is 0, particleRandom is instanceRandom and particleColor is color. The pipeline is built behind the scene load with the others, and the emitter draws the built-in sprite until it is ready.
//! model: unlit
//! blend: add
//! param tint: color = (1.0, 0.6, 0.2)
fn surface(in: SurfaceInput) -> SurfaceOutput {
var s = surface_default();
let soft = saturate(1.0 - length(in.uv - vec2f(0.5)) * 2.0);
s.emission = params.tint * in.particleColor.rgb * soft * soft * (1.0 - in.particleAge);
s.opacity = soft;
return s;
}
11. Screen effects (kind: post)
A *.shader.wgsl with //! kind: post is a full-screen effect over the scene (standard renderer only), listed in the scene's meta.render.screenEffects: up to 4 of { "shader": "<path>", "params": { ... } }, the path relative to the project like the scene's asset paths, params checked against the header.
"render": { "screenEffects": [ { "shader": "shaders/vignette.shader.wgsl", "params": { "strength": 0.5 } } ] }
//! kind: post
//! slot: afterTonemap
//! param strength: f32 = 0.4 [0, 1]
fn screen(in: ScreenInput) -> vec4f {
let v = 1.0 - smoothstep(0.4, 0.8, distance(in.uv, vec2f(0.5)));
return vec4f(in.color * mix(1.0, v, params.strength), 1.0);
}
slot: beforeTonemap | afterTonemap(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, in a folder held to 64 MB: least recently used files go first, and a file past the cap on its own starts again). The file is named by adapter and engine build, and opening it removes the same adapter's file of any other build, so an engine update replaces the cache instead of adding to it. The build is the crate version, the wgpu/naga stack and the executable's size and modification time, so uncommitted dev rebuilds count. (The HD content cache, hd/engine/content.rs, is keyed by the crate version alone and has the same dev-build blind spot; not fixed here.)
No permutation explosion. User code adds no axes. The hard cap is 10 pipelines per shader per renderer and the typical count 1 to 3. Params live in one std430 buffer; materials never create pipelines.
In an exported game
A shader in an exported game is read from the export's shader pack, not compiled: export compiles and validates every shader the scenes' materials reach (all wrapper variants; an error refuses the export with the contract's code, file and line) and writes one family record per shader, an index from each shader file's project path to its family, and a per-scene pre-warm list (docs/EXPORT.md, "The shader pack and pre-warm lists"). The shipped engine has no WGSL front end and the payload carries no *.shader.wgsl: a material file with a shader finds its family through the index, takes its params and textures from the header stored in the record, and the scene's list builds every pipeline behind the load, so nothing is created while playing. ctx.render.setShaderParam and setMaterialParam work as in Play. A material may name its shader by path (from the material file's folder) or by a scene shader asset id; both resolve. Web exports with user-shader materials are refused for now; the editor's export and engine export ship them for a native game, standard or HD (an HD project's families HD cannot draw yet are refused at export, naming the family and why; a family it draws with a gap exports with the warning build.shader-hd-partial). A shader the export's scan did not reach (a material named only by a path built at run time) is not in the pack: the load fails naming the material and the shader. List such a material in a scene.
Escape hatch: kind: raw and kind: pass
What ships (the authoring feature). Everything that parses, validates, lints, costs and caches WGSL (naga's front end among it) is behind authoring, off by default in argus-shader and argus-render. The engine (argus's default features), the editor, the workspace service, the export and the web runtime turn it on. A shipped native game (--no-default-features --features deploy,window,gamepad) and the agent do not: the game links only pack records, assembly by string concatenation and pre-warm lists, checks a family's hash, ABI and prelude version, and builds the pipeline. The agent's shader tools run argus-export --shader-check instead. This is why the export validates everything and why a shipped game "never validates a shader".
Pass shaders are not exported. Nothing in a project runs a kind: pass shader yet, so the export warns (build.shader-pass-not-exported) once per such file, naming it, and leaves it out of the pack.
Export and load. Export compiles every shader a project's materials use (a refusal fails the build, with the code, file, line and column; raw and pass files compile when render.shaders.raw is on, and raised costCapUnits/costCapFetches apply) and writes app/shader-pack/<hash>.family.json per family plus a pre-warm list per scene (shader-pack/prewarm/<scene>.json): every (family, wrapper variant, double-sided) pipeline the scene can need, nearest the start camera first (docs/EXPORT.md, "The shader pack and pre-warm lists"). Load builds the list on the workers behind the loading screen, so a shipped game compiles nothing at run time: the gate is created_after_load = 0 and fallback_draws = 0 after load. A pipeline the scan could not predict is created on first use, draws its fallback and counts (render.shader.compiled-after-load). On the web there are no worker threads, so loading builds in 8 ms slices and draws one degenerate triangle per pipeline offscreen before the first frame. build.shader-scene-prewarm-heavy warns when a scene's predicted pre-warm (pipelines x 28 ms / 8 workers = 3.5 ms) exceeds 400 ms (about 114 pipelines).
Pay-for-use. A scene with no user shaders builds nothing, draws bit-identical frames and loads as before. The shader system, a screen effect's targets and the texture arrays each exist only once something uses them.
One copy of each texture. On the standard renderer a registered texture is uploaded at its first use. A texture a user material packs is written straight into its family's texture array layer (the writes go in 16 MB chunks the device finishes before the next, so a scene's load never stages all its textures at once beside the arrays); it never has an upload of its own, so the allocator's reserved memory matches a built-in scene's (replica-n50 on llvmpipe: 192 MB in 2 blocks, as built-in; it was 320 MB in 3). Anything else that binds a texture uploads it then, and the start of a frame uploads the rest. A texture that was uploaded first is released once an array layer holds it: the array layer is its only copy. A replaced texture (Renderer::replace_texture, a texture that streams in after the first frame) is written into every layer that holds it, all mips, when its size and format match; at another size its materials are packed again into a set of that size. A texture a built-in material, a particle group, a screen effect, a hatch shader or the environment also binds keeps its upload, and one bound later gets it back (copied from its layer). A texture packed into another set later (a material edit, a hot reload, another family) is copied from its layer. A material that names a builtin black, white or flat gets it at its array's size, so it shares a set with materials that have a real texture there. UserShaderStats::standalone_textures_released and standalone_bytes_released count the released uploads. HD keeps its own share of every texture, so it releases nothing. Meshes are kept once too: a mesh a user-shader group draws is moved into the multi-draw arena (made at the size the scene's meshes need, doubling after that), and every draw of it, built-in materials' included, reads it there.
16. Measured numbers
Each number says where it was measured. The RTX 5080 is a Core Ultra 9 285K machine (24 threads) with NVIDIA driver 617.14 and wgpu 30.0.1; llvmpipe is the software Vulkan driver in WSL, so its timings are a lower bound that says nothing about a player's machine.
| 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 and on virtual geometry; ray-traced hits see a bake of it (a mask's shadows follow its surface) |
surface() shaders (stylized, blend: blend, blend: add, reads: depth; no vertex()) | 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 mask family's shadow passes run its surface() too (Pass::GShadow).
HD virtual geometry. A mesh the quality makes virtual geometry keeps a G-buffer family's material on it (since 8 Oct 2026; before, such a submesh stayed raster): the family's own merge draws its pixels (Pass::VgMerge, crates/shader/src/wrapper/vg_merge.wgsl's user_vg_surface, appended to HD's merge module built with USER_FAMILY; the engine half is gbuffer_user.wgsl's hd_user_vg_input / hd_user_vg_material, the same material translation as the raster pass). The merge rebuilds the pixel's attributes from the visibility buffer, so in.worldDdx / in.worldDdy are the barycentrics' exact change one pixel over; a texture read takes the screen's derivatives of its UV as in a full-screen pass (a pixel on a triangle's edge may sample one mip level off where its 2x2 quad straddles two triangles). in.instanceRandom hashes the virtual-geometry instance, not the entity: stable per instance, not the raster draw's number. The merge is built only for a family a virtual-geometry mesh uses, one pipeline per family, and draws one full-screen pass per family in view; until it is built (or where it does not build, said once) the pixels show the material's fallback. A forward family and a vertex() family keep their meshes raster.
A family the G-buffer cannot hold draws in HD's forward passes instead (Pass::Forward, crates/shader/src/wrapper/forward.wgsl, engine include hd/frame/shaders/forward_user.wgsl, FORWARD_ABI; wrapper::hd_path picks the pass from the header): model: stylized (opaque or mask) draws in a forward pass after the deferred lighting, writing depth, before the water; blend: blend, blend: add and any family with reads: depth draw in the transparent pass with HD's built-in blended materials, sorted back to front or, with weighted blended OIT on, in the OIT sums. They are lit with what a built-in forward material gets: the sun with its cascade (or ray-traced) shadow, the clustered local lights with their shadows, image-based ambient and specular. A stylized family's light() gets the same Light records as on the standard renderer (color is radiance over pi, shadow is HD's sun shadow, and for a local light its own shadow); ambient() gets HD's irradiance, and env_specular() / ambient_irradiance() work. All of them are in the standard renderer's units (1 = as bright as an unlit white): HD divides what it hands them by its pre-exposed unit and multiplies what they return by it, so a constant term (a rim, a glow) is as bright on HD as on the standard renderer. On HD a user surface's normal is turned around only on a back face (a two-sided material), as HD's built-in materials do; the standard renderer turns it toward the viewer. The two agree on any mesh whose winding matches its normals. An unlit family is lit as the built-in unlit material is (a 4% sky reflection included, blend: add excepted: emission only). reads: depth binds the opaque depth (the passes attach it read-only), so scene_depth_behind() reads the surface behind. Fog is HD's own, over the whole lit frame. A forward family's pipelines (sorted and OIT for the blended classes) are built per variant the scene draws, as above. Not drawn yet, each said once per family by render.hd.user-shader-partial: vertex() families (their materials draw the flat fallback), and for the drawn ones, per-entity floats, transmission, and tonemap: off on unlit (HD tonemaps every pixel).
HD ray-traced hits. With ray tracing on, each material of a drawn family is baked when it registers (load time, never per frame): the bake variant of the same surface() (user_bake_surface: t = 0, instanceRandom 0.5, white vertex colour, no wind, a flat card facing the viewer) is drawn once at 256x256 (Renderer::set_hd_user_bake_size) into four float targets, read back, and encoded to the maps the built-in hit path already samples: base colour with the opacity in its alpha, ORM, tangent-space normal and emissive. Those are ordinary textures in HD's bindless table that the material's HD record names, so reflections, global illumination and ray-traced shadows see the surface's colours and pattern with no per-ray cost, and a mask's holes let shadow rays through (the any-hit test reads the baked alpha). A map that comes out constant is a factor, not a texture, so a plain material costs no memory; a patterned one costs about 350 KB per map with mips (Renderer::hd_user_rt_bake reports the maps and bytes per material, hd_user_surface_stats the total). A family that reads time, the entity, the view or world space (or declares per-entity floats) bakes at those fixed values and says so once, by render.hd.user-rt-static; 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.
Array slots, draw slots and the new accessors per renderer. The standard renderer binds an array slot as the texture_2d_array its texture set already is, reading a material's range (first layer and count in the params tail); drawTexture slots are group 1 bindings 7 and 8 (after the six slots and the sampler) in a second group-1 layout used only by families that have them. HD reads an array slot's layers through a list of bindless indices after the material's params and its drawTexture slots through indices in the tail, with the engine include's hd_user_texture_grad / _load / _size beside hd_user_texture; a device without the bindless table draws these families' PBR fallback like any other user family, said by render.hd.user-shader-partial (HdEngineSettings::allow_bindless = false runs that path in tests). HD's ray-traced bake binds an array slot as an array copied from its layers; it has no draw, so a drawTexture slot reads its default's colour there (white, or the flat normal; a path or builtin:noise default also reads white), and each draw material is baked once at load like any user material.
Web and mobile. The limits of section 7 fit WebGPU's default limits and CI validates every family against them (the largest family, six array slots, two draw slots and reads: depth, builds on them; an array slot holds at most 64 layers, under WebGPU's default of 256); no f16, subgroup or binding arrays are reachable from user code. The stricter cost column (150 / 600 units, 4 / 8 fetches) applies, and a scene with more than 64 shader families warns (render.shader.families-many); more than 128 is refused on web. There is no worker pool or pipeline cache API on the web, so pre-warm runs in slices (section 15) and the stock car module takes its lite form (section 9).
18. Examples
fixtures/shaders/valid/ (shared by both parsers and the compiler tests) holds complete examples: toon, glow-pulse, dissolve, water-surface, hologram, vertex-wind, reads-depth-blend, every-type (every param type), max-limits, minimal, the screen effects post-outline, post-posterize, post-vignette and the escape hatch raw-material, raw-pass, raw-pass-reads-scene. docs/examples/shaders/ holds twelve complete looks (the six above plus marble, lava, force field, wet stone, rim light and flag wave), all compiled by a test under the default caps and, for the web target, at worst with a cost warning. shader.library {section: "examples"} returns the six originals inline.