Log inGet started

Shader (asset type)

Updated 5 September 2026

New to how shaders, materials, meshes, and textures relate as assets and live GPU resources? Read core/resource-model first.

Folder shape

A .shader is a folder asset:

<name>.shader/
  shader.wgsl        # the WGSL body (required)
  properties.yaml    # the material property schema (optional; surface shaders)
  README.md          # a one-paragraph description of THIS shader (required)
  .metadata          # asset metadata

Each file carries a committed .meta sidecar pinning its stable guid. The identity is <name> (the .shader suffix strips). Materials and other assets reference a shader by AssetRef (a typed, guid-backed handle), never by a raw string.

Domains

The first line of shader.wgsl declares the domain:

// @domain: surface
DomainWhat it isYou write
surfaceA material on a mesh (the common case).fragment() (+ optional vertex()/vertex_clip()). The engine generates everything else.
post-processA full-screen pass over the rendered frame (bloom, vignette, grade).fragment(in: PostInput) -> vec4 only. The engine generates the fullscreen vs_main/fs_main and the group(1) interface from properties.yaml.
screenA standalone full-screen visual (UI background, procedural).fragment(in: ScreenInput) -> vec4 only. The engine generates the fullscreen vs_main/fs_main and the group(1) interface from properties.yaml.
skyThe sky background (a fullscreen material).fragment(in: SkyInput) -> vec4 only. The engine generates the fullscreen vs_main/fs_main and the group(1) interface from properties.yaml.
computeGPU data processing, no rasterization.(legacy) An @compute @workgroup_size(...) entry over storage buffers — you write the @group/@binding lines yourself. The canonical path is now the separate .computeShader assetType (see below); .shader + // @domain: compute still compiles for back-compat.

// @domain: resolves the names above and postprocess / screenspace / ui as further spellings of post-process and screen. A body carrying no @domain: line is surface. Any other name is refused where the shader registers, with an error naming the spellings that resolve, so a shader reaches the renderer only under a domain the compiler scaffolds.

Particles are drawn and simulated through these domains like anything else: the particle package's particle_sprite and particle_mesh are surface shaders, and its simulation, culling and sorting steps are .computeShader assets.

Texture coordinate space

A shader that samples a texture through a coordinate it builds itself declares which space that coordinate lives in, on a line beside @domain::

// @domain: surface
// @uv_space: world
ValueThe coordinateA material's uv_scale means
mesh (the default)The mesh's own UV attribute, input.uv.How many times the texture repeats across a surface.
worldThe fragment's world position, as a triplanar projection samples.How many times the texture repeats per world unit, so a larger surface carries more.

Texture residency reads the declaration to work out how finely a frame samples each texture: a material laying eight copies of its texture across a surface samples each copy at an eighth of the surface's size, and the mip level that serves it is three levels coarser than the surface's own footprint implies. Leaving the line out puts the shader in mesh space, which is what sampling input.uv is. renderer.textureStreaming().textures reads back the span each texture was measured at and the level that span asked for.

Compute shaders are now their own assetType. A .computeShader/ folder gives compute the same zero-scaffolding contract surface/sky/post/screen get: you write only @compute fn main (+ helper fns/structs, no @group/@binding lines) and declare bindings in a sibling bindings.yaml (storage buffers, textures, samplers, storage textures, plus a params: uniform); the engine generates the @group(0)/@binding(N) declarations. Dispatch it through a resolved reference (asset.resolve("name", "computeShader"):dispatch(...)). See the .computeShader assetType README and man compute. The .shader compute domain above remains only for back-compat.

Surface, sky, post-process, and screen shaders all get engine-generated scaffolding — you write only fragment() and declare properties in properties.yaml; the engine generates the entry points and the group(1) material interface. (Each domain's fragment takes a domain-specific input instead of FragmentData: SkyInput, PostInput, or ScreenInput — see "Sky shaders" and "Post-process & screen shaders" below.) Compute shaders get the same treatment via the separate .computeShader assetType (write only @compute fn main, declare bindings in bindings.yaml). The legacy compute domain here writes its own entry points directly — see man compute.

Surface shaders

A surface shader writes only the parts that are unique to it. The engine owns all the scaffolding — the vertex stage, every render-mode entry point (vs_main / fs_forward / fs_gbuffer), group(0) (camera, transforms, lights, shadows), and group(1) (your material, generated from properties.yaml). Adding a render path never touches your shader.

Inside fragment() you have access to exactly four things — nothing is hidden:

  1. input: FragmentData — the interpolated per-fragment surface data the engine hands you: world position, world normal, uv, view direction, vertex colour, and more. Every field is in the FragmentData table below.
  2. material.* and your textures — the material inputs you declare in properties.yaml. The mapping is mechanical: a scalar property named foo becomes material.foo; a texture property named bar becomes bar (the texture) and bar_sampler (its sampler). material has exactly the fields your properties.yaml declares and no others — to add an input, add a property. List what a shader exposes with shaderRef:getProperties() or asset.inspect("<name>").
  3. Engine state — the scene's camera, lights, time, and shadows that the renderer binds for every surface shader. uniforms.* (sun direction/colour, ambient, camera_pos, time) and the point_lights[] array are always in scope; the shadow functions come from #include "@builtin::shaderModules.shadows". This is how you light a surface without the prebuilt PBR — see "Engine state" and "Writing your own shading" below.
  4. Library functions you #include (see "Library includes").

A shader and its property schema go together. The two files below are a complete surface shader:

properties.yaml — declares the material inputs:

properties:
  - { name: base_color, type: color, default: [1, 1, 1, 1] }
  - { name: roughness,  type: float, default: 0.5, min: 0, max: 1 }
  - { name: metallic,   type: float, default: 0.0, min: 0, max: 1 }
  - { name: base_color_texture, type: texture, default: white }

shader.wgsl — those declarations are now material.base_color, material.roughness, material.metallic, and the base_color_texture / base_color_texture_sampler pair:

// @domain: surface
#include "@builtin::shaderModules.pbr_shading"   // the PBR helpers — see "Library includes"

fn fragment(input: FragmentData) -> vec4<f32> {
    var s = zero_pbr_default_surface(input);
    s.albedo = material.base_color.rgb
        * textureSample(base_color_texture, base_color_texture_sampler, input.uv).rgb;
    s.perceptual_roughness = material.roughness;
    s.metallic = material.metallic;
    return vec4<f32>(zero_pbr_shade(input, s), material.base_color.a);
}

fragment() returns the final colour. The engine forces no lighting model on you — flat, toon, PS1, dithered, or PBR are all just different fragment() bodies. Calling zero_pbr_shade is opt-in.

The two surface bodies, and what each gives up

A surface shader exposes one of two entry points, and which one decides how much of the shader the engine can see:

BodyThe engine getsPBR debug passes read
fn fragment(input: FragmentData) -> vec4<f32>the finished pixel, and the PbrSurface too when the body hands one to zero_pbr_shadethe real channel where a surface was handed over; magenta, the "no data here" marker, where the body shades by its own math
fn surface(input: FragmentData) -> PbrSurfacethe surface (albedo, roughness, metallic, emissive, normal, occlusion), which the engine then lightsthe real channel

Both are first-class: fragment() is what you want when the shader owns its own look, surface() when you want engine lighting. capture { pass = "albedo" } (and roughness / metallic / ao / emissive) answers about your material for either body wherever the engine has the surface — writing the same PbrSurface behind surface() or behind fragment() + zero_pbr_shade gives one answer per channel. A body that lights itself by its own math draws those channels magenta. pbr.shader exposes surface().

capture { pass = "normal" } is a geometry read rather than a material one, so it answers for every shader: the normal of the surface where one was handed over, and the interpolated normal of the triangle where none was. It is the same normal the frame's normal buffer carries, which is what a screen-space pass reading @scene.normal sees.

shaderRef:shadingModel() reports which one a shader ended up with — "engine-lit" for surface(), "self-shading" for fragment().

Optional vertex hooks

// Displace geometry in OBJECT space. Runs on skinned meshes too (it sees the
// already-skinned, morph-applied local position). Return the modified vertex.
// The `v` parameter is immutable (WGSL function params are), so copy it into a
// `var` first and modify that.
fn vertex(v: VertexData) -> VertexData {
    var out = v;
    out.position += out.normal * sin(out.time) * 0.1;
    return out;
}

// Post-transform vertex hook (runs AFTER the engine's world/skin/projection). Two
// things you can do here, separately or together:
//   * move `clip_position` — vertex jitter, PS1 snapping, render-resolution lowering;
//   * write `color` — PER-VERTEX (Gouraud) shading: light the vertex from its
//     world_normal + the light context and the engine forwards the result into the
//     interpolated `vertex_color` the fragment reads.
fn vertex_clip(v: ClipVertex) -> ClipVertex {
    var out = v;
    // Gouraud: Lambert sun + ambient, evaluated once per vertex (flat-banded shading).
    let n_dot_l = max(dot(v.world_normal, -normalize(v.light_direction)), 0.0);
    let lit = v.ambient_color * v.ambient_intensity + v.light_color * v.light_intensity * n_dot_l;
    out.color = vec4<f32>(v.color.rgb * lit, v.color.a);
    out.clip_position = round(out.clip_position * 80.0) / 80.0;  // PS1 snap
    return out;
}

Structs the engine passes you

FragmentData (input to fragment()):

FieldTypeMeaning
world_positionvec3<f32>fragment world-space position
world_normalvec3<f32>interpolated geometric normal (normalized)
world_tangentvec4<f32>xyz = world tangent, w = handedness (mikktspace)
uvvec2<f32>primary texture coordinates (perspective-correct)
affine_uvvec2<f32>the same UVs interpolated without perspective correction — the PS1 texture "swim". Always available; sample with it instead of uv for affine texture mapping
vertex_colorvec4<f32>interpolated per-vertex colour (white if unset; carries per-vertex lighting if vertex_clip() wrote it — see Gouraud below)
view_positionvec3<f32>camera world position
view_directionvec3<f32>normalized world-space direction to the camera
light_directionvec3<f32>directional (sun) travel direction; surface→sun is -light_direction
light_colorvec3<f32>sun colour
light_intensityf32sun intensity
ambient_colorvec3<f32>ambient colour
ambient_intensityf32ambient intensity
frag_coordvec4<f32>framebuffer pixel coords (xy) + depth (z) — for screen-space dither
receives_shadowsf32per-instance: > 0.5 if this surface receives shadows
material_indexu32per-instance: the index standing for the material this surface draws with — the number renderer.materialIdentity() maps to a name
shader_dataarray<vec4<f32>, 4>per-instance: this entity's own four vec4 lanes, whatever renderer.instanceData.set put in them (zero until something does)
front_facingbooltrue for front faces (double-sided shading)
timef32elapsed seconds

shader_data is the per-instance channel, and it is what lets ONE material serve many entities that differ in a value — a dissolve at its own progress per subject, a hit flash at its own age, a fill level per instance. The alternative is a material per entity, which is a separate pipeline binding and a separate row in the material table for every one of them.

const DISSOLVE_LANE: u32 = 0u;

fn fragment(input: FragmentData) -> vec4<f32> {
    let progress = input.shader_data[DISSOLVE_LANE].x;
    ...
}
-- one material on twenty entities, each at its own progress
renderer.instanceData.set(subject, 0, progress)
renderer.instanceData.clear(subject)     -- back to zero lanes

The engine attaches no meaning to a lane: a shader and whatever writes it agree what each one carries. Name the indices in the module that writes them. renderer.instanceData.laneCount() is how many there are. The same lanes reach a hardware-instanced population through renderer.mesh.drawInstanced's instanceDataBuffer, on slots no entity owns.

VertexData (in/out of vertex()): position (object-local, model-scale + morph applied), normal, uv, color, time.

ClipVertex (in/out of vertex_clip()) — the post-transform vertex. Read context: clip_position, world_position, world_normal, uv, view_position, view_direction, light_direction, light_color, light_intensity, ambient_color, ambient_intensity, time, view_proj (the view-projection of the frame being evaluated). Writable: clip_position (snap / jitter) and color (write a lit colour here for per-vertex Gouraud shading; the engine forwards it to the interpolated vertex_color — left untouched, the mesh's own vertex colour passes through).

Each hook runs twice: this frame and the one before it

A vertex hook decides where the surface IS, so it also decides where the surface WAS. The engine runs every hook a shader declares a second time, over the same vertex standing one frame back — the previous-frame position stream, with time = the clock that frame was drawn at and (for vertex_clip()) view_proj = the view-projection it was drawn with. The motion vector the renderer publishes is taken between the two results, so a displacement that moves with time reports the velocity of the displaced surface, and a displacement that does not move reports the motion of the transform alone because the identical offset appears on both evaluations and cancels.

What that asks of a hook:

  • Take the frame from the argument. v.time is the clock of the evaluation being run; uniforms.time is always this frame's. A displacement built on uniforms.time is identical on both evaluations, so its motion vector cancels to exactly zero. The same holds for v.view_proj against uniforms.view_proj: a clip hook that rebuilds its position from world space through uniforms.view_proj reprojects the previous frame with this frame's camera, and reports no camera motion at all. The wind helpers come in both shapes for this reason — zero_wind_sway_at_time(world_pos, stiffness, v.time) inside a hook, zero_wind_sway(world_pos, stiffness) outside one.
  • Anchor a wrap to uniforms.time. A displacement that jumps discontinuously — a falling drop that restarts at the top, a looping offset — differences its jump as velocity on whichever frame it wraps. Compute the wrap COUNT from uniforms.time and the position within the cycle from v.time, and both evaluations share the same cycle, so the surface reports the speed it fell at. @builtin::shaders.rain_material and snow_material do exactly that.

The per-vertex attributes (world_normal, uv, color) and the lighting and camera-position context are this frame's on both evaluations, so a hook that displaces along its normal reports the motion of the transform plus this frame's displacement.

Engine state — point lights & shadows

The common lighting state — the sun (input.light_direction / light_color / light_intensity), the ambient (input.ambient_color / ambient_intensity), the camera (input.view_position / view_direction), and input.time — is already on FragmentData, so most shaders never touch the raw engine state. The one thing not on FragmentData is the punctual-light array (its length varies), which is always in scope:

ExpressionTypeMeaning
uniforms.point_light_countu32number of active point/spot lights
point_lights[i]PointLightDataone point or spot light (fields below)

PointLightData: position, radius, color, intensity, direction, cone_outer_cos, cone_inner_cos, kind (0 = point, 1 = spot).

Shadows come from #include "@builtin::shaderModules.shadows":

  • compute_directional_shadow_factor(world_pos, normal, light_dir) -> f32 — 1.0 lit, 0.0 fully shadowed.
  • compute_spot_shadow_factor(...) / compute_point_shadow_factor(...) / compute_area_shadow_factor(...) — one shadow shape each.
  • compute_punctual_shadow_factor(light_index, world_pos, normal) -> f32 — the row's own shape, read off its kind, for a loop that walks point_lights and does not want the switch.
  • zshadow_light_reach(light_index, world_pos, normal, object_channels) -> f32 — how much direct light that row puts on a surface on those light channels: the channel mask, falloff, cone or rect orientation, and the cosine, times intensity. Zero exactly where the light contributes nothing.

Writing your own shading

The engine forces no lighting model — zero_pbr_shade is just one option. You can light a surface yourself from the state above. Here is a complete custom-lit (Lambert diffuse) surface with no PBR:

// @domain: surface
#include "@builtin::shaderModules.shadows"   // exposes compute_directional_shadow_factor

fn fragment(input: FragmentData) -> vec4<f32> {
    let n = normalize(input.world_normal);
    let albedo = material.base_color.rgb;

    // Directional (sun) light: Lambert N·L, attenuated by its shadow.
    let sun = normalize(-input.light_direction);          // surface -> sun
    let sun_shadow = compute_directional_shadow_factor(input.world_position, n, input.light_direction);
    var lit = input.light_color * input.light_intensity * max(dot(n, sun), 0.0) * sun_shadow;

    // Point + spot lights (the punctual-light array, varying length).
    for (var i = 0u; i < uniforms.point_light_count; i = i + 1u) {
        let pl = point_lights[i];
        let to_light = pl.position - input.world_position;
        let dist = length(to_light);
        let l = to_light / max(dist, 1e-4);
        let atten = max(1.0 - dist / max(pl.radius, 1e-4), 0.0);
        lit += pl.color * pl.intensity * max(dot(n, l), 0.0) * atten;
    }

    // Ambient fill.
    lit += input.ambient_color * input.ambient_intensity;

    return vec4<f32>(albedo * lit, material.base_color.a);
}
  • Unlit: skip the lights and return your colour directly.
  • Toon / PS1 / custom: start from the loop above and band, quantize, or warp it however you like.
  • PBR: build a PbrSurface and call zero_pbr_shade (see "Library includes") instead of writing the BRDF by hand.

Material properties — properties.yaml

A surface shader declares its material properties in a sibling properties.yaml. The engine GENERATES the entire group(1) interface from it — the uniform struct (material.<name>) plus a texture/sampler pair per declared texture. You write no @group/@binding lines.

properties:
  - { name: base_color, type: color, default: [1, 1, 1, 1] }
  - { name: roughness,  type: float, default: 0.5, min: 0, max: 1 }
  - { name: metallic,   type: float, default: 0.0, min: 0, max: 1 }
  - { name: emissive,   type: color, default: [0, 0, 0, 1] }
  - { name: base_color_texture, type: texture, default: white }
  - { name: normal_texture,     type: texture, default: flat_normal }
typeWGSL field typeNotes
floatf32optional min/max for editor sliders
float2/float3/float4vec2/3/4<f32>
colorvec4<f32>sRGB colour, default is [r,g,b,a]
inti32
boolu32
texturetexture_2d<f32> + samplerdefault: is a built-in texture name (white, black, flat_normal)

In the shader you read scalars as material.<name> and sample textures as textureSample(<name>, <name>_sampler, uv). A texture slot with no bound texture samples its default, so a shader can always sample — no presence flag needed.

vertex() reads the same material interface, so a height or deformation map drives displacement from the slot it was bound to. A vertex stage has no implicit derivatives, so sample it at an explicit mip there: textureSampleLevel(<name>, <name>_sampler, uv, 0.0).

Sky shaders

A // @domain: sky shader draws the sky as a fullscreen pass. You write only fn fragment(in: SkyInput) -> vec4<f32> — the colour along the view ray for each pixel — and declare tunables in properties.yaml exactly like a surface shader (read as material.<name>, textures via textureSample(<name>, <name>_sampler, uv)). The engine generates the fullscreen vs_main/fs_main, the group(1) interface, and reconstructs the world-space ray; there is no separate sky renderer — a sky material runs through the same material pipeline, just on a fullscreen triangle.

SkyInput (the only argument) carries:

FieldTypeMeaning
ray_dirvec3<f32>normalized world-space ray through this pixel
ndcvec2<f32>screen position in NDC [-1, 1]
sun_directionvec3<f32>unit vector TOWARD the sun (from the directional light)
camera_posvec3<f32>world-space camera position
timef32elapsed seconds (animation)
// @domain: sky
fn fragment(in: SkyInput) -> vec4<f32> {
    let up = max(in.ray_dir.y, 0.0);
    let col = mix(material.horizon_color.rgb, material.zenith_color.rgb, up);
    return vec4<f32>(col * material.exposure, 1.0);
}

Day/night comes from sun_direction.y (the directional light's elevation), so a sky shader never needs a time-of-day uniform — orient the light and the sky follows. The builtin sky shaders (procedural_sky, solid_sky, cubemap_sky, equirect_sky) are all authored this way; author a custom sky material and point a Skybox component at it.

Post-process & screen shaders

// @domain: post-process and // @domain: screen shaders are authored exactly like surface and sky — you write only fn fragment(in: PostInput) -> vec4<f32> (post) or fn fragment(in: ScreenInput) -> vec4<f32> (screen) and declare tunables in properties.yaml (read as material.<name>, textures via textureSample(<name>, <name>_sampler, uv)). The engine generates the fullscreen vs_main/fs_main and the group(1) material interface. There are no uniforms.params[i] slots — that convention is retired; every input is a named property.

A post-process shader runs as a full-screen pass over the already-rendered frame. Its fragment argument and framework helpers:

PostInput:

FieldTypeMeaning
frag_coordvec4<f32>framebuffer pixel coords (@builtin(position))
uvvec2<f32>screen UV, (0,0) top-left … (1,1) bottom-right

Framework helpers (always in scope — no include needed):

CallReturnsMeaning
zero_sample_scene(uv)vec4<f32>the rendered scene colour at uv
zero_scene_depth(uv: vec2<f32>)f32scene depth at uv — a screen UV here, where the surface domain's zero::scene_depth module takes a pixel coordinate under the same name (exact texel — an R32Float copy). Reversed: 1.0 is the near plane, 0.0 the far plane and empty sky, so a nearer surface reads GREATER and d > 0.0 means "a surface was drawn here"
zero_linear_depth(d, near, far)f32that sample as a distance in metres — the one inversion of the reversed range
zero_scene_motion(uv)vec2<f32>per-pixel screen-space motion vector at uv

Engine data uniform engine (a PostEngineData):

FieldTypeMeaning
resolutionvec4<f32>x=width, y=height, z=1/width, w=1/height
timevec4<f32>x=elapsed seconds, y=dt, z=frame index
view_projmat4x4<f32>this frame's view-projection
prev_view_projmat4x4<f32>last frame's view-projection (motion)
inv_view_projmat4x4<f32>inverse view-projection (world-space reconstruction)

The engine fills all of it per render target. A registered effect runs over the live viewport's frame AND over every offscreen one — a capture from a world-space station or orbiting an entity, a capture of a named camera, a render-to-texture camera — and in each of those, view_proj, prev_view_proj and inv_view_proj are the camera THAT render was drawn from, while resolution is that target's own pixel size. So a pass reconstructing world space from zero_scene_depth(uv) reconstructs against the station and lens the capture asked for, and an offscreen frame is a picture of the effect as it stands at that station. (A render feature's passes reach an offscreen render the same way, against the same camera — the renderFeature assetType README carries that half, including @frame.camera, its own per-target camera input.)

An offscreen render keeps no view history of its own, so prev_view_proj there holds that same matrix rather than the frame before it, and a pass taking camera motion from the two reads none. On the viewport the pair is a frame apart, so camera motion is the one reading a capture answers differently from the screen.

postProcessing = false on a capture is the one control that takes the chain off the frame it returns; it names the camera property the ephemeral capture camera is built with, so it scopes to that one capture.

A complete post-process effect — a vignette via fragment() + properties.yaml:

properties.yaml:

properties:
  - { name: intensity, type: float, default: 0.5,  min: 0, max: 1 }
  - { name: radius,    type: float, default: 0.75, min: 0, max: 1 }
  - { name: softness,  type: float, default: 0.45, min: 0, max: 1 }

shader.wgsl:

// @domain: post-process
fn fragment(in: PostInput) -> vec4<f32> {
    let color = zero_sample_scene(in.uv);
    let intensity = max(material.intensity, 0.01);
    let radius = max(material.radius, 0.01);
    let softness = max(material.softness, 0.01);

    let center = in.uv - vec2<f32>(0.5);
    let dist = length(center);
    let vignette = smoothstep(radius, radius - softness, dist);
    let darkened = color.rgb * mix(vec3<f32>(1.0), vec3<f32>(vignette), intensity);
    return vec4<f32>(darkened, color.a);
}

The asset above defines the effect. It enters the frame when something registers it:

postprocess.add("vignette", asset.resolve("~.shaders.vignette", "shader"), { priority = 200 })

Registering it is the one step that makes it render, so a world's chain holds exactly the passes its content asked for. Editing the shader body afterwards recompiles every effect registered from it, in place — the pass keeps its enabled state, priority, layer, tuned property values and bound textures, and draws the new pixels on the next frame. postprocess.remove(name) takes it back out of the chain and leaves the asset alone.

Drive the named properties at runtime with postprocess.setProperty(name, prop, value) and bind texture properties with postprocess.setTexture(name, prop, path) (both keyed by the property name, never a slot index — see the post-process section of man shaders / the postprocess.* API).

Read them back with postprocess.describe(name): it carries the chain state postprocess.status() lists for that effect, the properties schema it was registered with, and the values each of those properties currently holds. Before the effect exists, shaderRef:getProperties() on the asset names the same schema, so what a pass takes is answerable either side of the registration.

A screen shader is a standalone full-screen visual (UI background, procedural pattern). ScreenInput mirrors PostInput (frag_coord + uv); there is no scene to sample, so it has no zero_sample_scene helpers. Its engine uniform is a ScreenEngineData:

FieldTypeMeaning
timef32elapsed seconds (animation)
scrollf32UI scroll offset
resolutionvec2<f32>render-target size in pixels
mousevec2<f32>mouse position
velocityf32scroll velocity
// @domain: screen
fn fragment(in: ScreenInput) -> vec4<f32> {
    let t = engine.time * 0.5;
    let col = vec3<f32>(
        sin(in.uv.x * 3.14159 + t) * 0.5 + 0.5,
        sin(in.uv.y * 3.14159 + t * 0.7) * 0.5 + 0.5,
        material.brightness
    );
    return vec4<f32>(col, 1.0);
}

Library includes

A library module is a .shaderModule asset: it only EXPOSES functions and structs and does nothing on its own (exactly like a Unity .cginc). You must call what you include. Include one by its identity, the same string you would hand asset.resolve:

IncludeExposes
@builtin::shaderModules.pbr_shadingstruct PbrSurface { albedo, normal (world), metallic, perceptual_roughness, emissive, occlusion }; zero_pbr_default_surface(FragmentData) -> PbrSurface; zero_pbr_shade(FragmentData, PbrSurface) -> vec3<f32> (the full forward PBR lighting)
@builtin::shaderModules.shadowscompute_directional_shadow_factor(...), compute_spot_shadow_factor(...), compute_point_shadow_factor(...), compute_area_shadow_factor(...), compute_punctual_shadow_factor(...), zshadow_light_reach(...)
@builtin::shaderModules.engine_bindingsthe group(0) declarations (camera, transforms, lights, shadow maps) — surface shaders get this implicitly; include it directly only for advanced raw access
@builtin::shaderModules.shadow_biaszshadow_offset_receiver(...), zshadow_directional_depth_bias(...) and the rest of the bias math, as pure functions
@builtin::shaderModules.texturingztex_stochastic(...) (tile without a visible repeat grid), ztex_blend_normals(...) (reoriented normal blending)
@builtin::shaderModules.depthzero_linear_depth(depth, near, far) — the one reading of a stored depth
zero::scene_depthzero_scene_depth(px: vec2<f32>), zero_scene_depth_uv(uv: vec2<f32>), zero_scene_depth_texel(px: vec2<i32>), zero_scene_depth_size(), zero_scene_depth_is_sky(d: f32), zero_scene_world_position(px: vec2<f32>), zero_scene_world_normal(px: vec2<f32>), zero_scene_depth_fade(px: vec2<f32>, world_position: vec3<f32>, fade_distance: f32) — every px is a pixel coordinate in two components, so a surface fragment passes input.frag_coord.xy, not the vec4<f32> FragmentData.frag_coord holds. The depth already drawn UNDER a surface fragment, and the world position and geometric normal of the surface standing there. What a decal projector box, a soft particle, a forcefield or water intersection foam shades against. Surface domain; the depth belongs to the view being drawn
zero::scene_colorzero_scene_color(px: vec2<f32>), zero_scene_color_uv(uv: vec2<f32>), zero_scene_color_texel(px: vec2<i32>), zero_scene_color_size(), zero_scene_color_refract(px: vec2<f32>, offset_px: vec2<f32>), zero_scene_color_refract_uv(px: vec2<f32>, offset_fraction: vec2<f32>) — every px is a pixel coordinate in two components, so a surface fragment passes input.frag_coord.xy, not the vec4<f32> FragmentData.frag_coord holds. The scene colour already composed BEHIND a surface fragment, and the displaced read that bends it. What heat haze, a refraction shockwave, a cloaking or shield warp, glass and ice shade with. Surface domain; the colour belongs to the view being drawn, and is the lit opaque scene as it stood immediately before the transparent pass — so a blend-queue surface reads this frame's, an opaque one reads the previous frame's, and two overlapping blended surfaces both read the same picture and do not bend each other

asset.list("shaderModule") enumerates the .shaderModule assets in this engine, builtin and world-authored alike, and each one's README says what it exposes. Those are the @builtin::shaderModules.* rows above; a module you write is included exactly the same way, by its own identity or by the package-relative ~.name form.

The zero::-named rows come from the renderer itself. Their WGSL is baked into the engine binary and registered under those names before any asset exists, so they answer an #include in every engine and appear in no asset index. A shader that includes one records no dependency on it: the engine reading the shader is the engine that already holds the module.

Two spellings reach the same .shaderModule: an earlier name is kept working through the module's declared alias, so a shader written against zero::pbr still compiles and still pins the asset that name resolves to. Newly written shaders should use the identity — it says which asset travels with the content.

Whichever punctuation an include is written with — #include "name", #include 'name' or #include <name> — names the same module and records the same dependency.

The engine's own scaffolding — zero::surface, zero::sky, zero::post, zero::screen — is injected by the compiler around the matching domain and is never written by hand. The surface framework puts safe_normalize, mat3_from_mat4 and apply_normal_map(FragmentData, tangent_normal) in scope for every surface shader.

Shader features — compile-time variants

A property is runtime uniform data (a colour, a float, a texture): set per material, read as material.<name>, no recompile. A feature decides which code is in the program at all. Use a property when the value changes at runtime, a feature when it changes what the shader is.

Declare features in properties.yaml, alongside the properties:

properties:
  - { name: base_color, type: color, default: [1, 1, 1, 1] }
features:
  - { name: PARALLAX, default: false, description: silhouette parallax march }
  - { name: DETAIL,   default: true,  description: detail-normal overlay }

Guard the code each feature owns with #ifdef:

fn surface(input: FragmentData) -> PbrSurface {
    var s = zero_pbr_default_surface(input);
#ifdef PARALLAX
    s.normal = parallax_normal(input);       // absent when PARALLAX is off
#else
    s.normal = input.world_normal;
#endif
    return s;
}

#ifdef / #ifndef / #else / #endif, one per line, and they nest. The directives resolve before the WGSL reaches the compiler, so the program holds exactly the lines its feature set selected and every other line is an empty one, which keeps a compiler message on the line number the file shows. An unbalanced block is a compile error naming its line.

A material is what chooses a feature set, so features: shapes the shaders a material draws with — the surface, sky and screen-space domains.

A material names the features it wants in its mat.yaml:

name: worn_rock
shader: "@builtin::shaders.pbr_layered"
features:
  PARALLAX: true      # on, over the shader's default
  DETAIL: false       # off, against the shader's default

or through asset.create:

asset.create("material", "worn_rock", {
    shader = "@builtin::shaders.pbr_layered",
    features = { PARALLAX = true, DETAIL = false },
})

The engine compiles one program per feature set its materials ask for, named <shader>#<features>. Materials sharing a set share that program. A feature the shader does not declare selects nothing, so a stray name cannot make the engine compile anything, and the compile names both halves of a mismatch: a guard on a name features: does not declare, and a declared name no guard tests.

Permutations are the cost. A shader with n features can express 2^n programs, each its own compile and pipeline. One shader holds at most 32 variants; a material asking for a set past that draws with the shader's default features and the engine logs which shader ran out. Read what a scene is spending:

for _, s in renderer.shaderVariants() do
    print(s.shader, #s.variants, "of", s.budget)
    print(renderer.variantSource(s.base.program))          -- what it ships as
    for _, v in s.variants do
        print(v.program, renderer.variantSource(v.program))  -- what one asked for
    end
end

A shader registers under every key it answers to — its identity, its guid, each alias — and each key is a row. A variant lands on the row for the key the material named, which for authored content is the guid, so take a program name from the report rather than composing one.

Materials reference shaders

A .material applies a shader to entities. Its mat.yaml references the shader by its identity string and supplies property values:

name: my_material
shader: "@builtin::shaders.pbr"
render:
  blend: alphaBlend
  cull: back
  depth_write: false
floats:
  roughness: 0.5
colors:
  base_color: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }
textures:
  base_color_texture: { ref: "default:white" }

shader: is a plain identity string, and each colors: entry is an { r, g, b, a } map — the two forms mat.yaml accepts for those keys. A key's value is the rest of its line, so every value above stands alone on one.

The render block

Render behaviour that used to be material "flags" — the blend equation, alpha cutout, which faces are drawn — lives in the render: block and drives the pipeline, not the fragment. These are the keys that reach the pipeline from both authoring routes, and the values each one takes:

KeyValuesSets
blendopaque transparent alpha alphaBlend additive add cutout alphaCutout premultiplied premultipliedAlpha multiplythe equation the fragment composites with
cullback front none off disabledwhich faces the rasterizer keeps
depth_writetrue falsewhether the draw writes depth
depth_compareless lessEqual lequal equal greater greaterEqual gequal always neverthe test a fragment's depth passes
topologytriangleList lineList lines linewhat consecutive vertices assemble into

blend is also spelled type, and the two name one key. Its equations, over the fragment the shader returned (src) and the value already in the target (dst): opaque is src; transparent, alpha and alphaBlend are src * src.a + dst * (1 - src.a); additive and add are src + dst, the mode a glow, a light shaft or a spark card is written for; premultiplied and premultipliedAlpha are src + dst * (1 - src.a), taking a source that already carries its own coverage; multiply is src * dst, which is how a soot, grime or shadow card darkens what stands behind it. cutout and alphaCutout keep the fragment or discard it on its alpha and go on writing depth.

The same block is what asset.create's render argument takes, key for key and value for value, so one material named in Luau and the same material standing on disk ask for their pipeline with the same words. depth_write is the one key whose value takes the shape of its route: the word false on a mat.yaml line, the boolean false in the Luau table.

asset.create("material", "heat_plume", {
    shader = "@builtin::shaders.pbr",
    render = { blend = "alphaBlend", cull = "none", depth_write = false },
})

Where a value outside a key's set is reported is what the two routes differ on. asset.create refuses the call on any of these keys and names the set it measured the value against. A mat.yaml standing on disk registers either way, each key falling back to a value of its own: blend to opaque, cull to back, depth_write to true, depth_compare to less and topology to triangleList. The engine log carries the blend fallback, naming the value and the set it was measured against, so a mat.yaml value is worth putting through asset.create once — that is where every key answers with its set.

Registration & hot reload

A .shader is compiled by the asset system, not by the renderer: writing shader.wgsl or properties.yaml fires the .shader assetType's onChange, which calls shader.compile — that reads the body + the property schema, generates the group(1) interface and (for surface) every render-mode entry point, expands #includes, validates the result with naga, and registers it. Validation happens at this compile step — the whole point of "compile". A shader with invalid WGSL (an unknown field, a type mismatch, an undefined function) fails to compile with the real reason — readable directly from Luau via shaderRef:compileStatus() ({ status = "compiled" | "failed" | "pending", error? }), and also in the author log and asset.inspect; it is not registered. This fires on create and on every edit, so saving a .wgsl recompiles with no world reload; the change is visible on the next frame. Compilation is async, so a compileStatus() of "pending" immediately after a write means check again next frame.

The stage the compile checks comes from one of two places: the hook the engine scaffolds entry points around (fragment(), or surface() for an engine-lit surface), or the vs_main / fs_main pair the body writes itself. The pair is a pair: a body that writes its own entry points owes both stages. A body carrying neither route — a hook whose name is a letter off, say, or half a pair — holds no stage the rasterizer can enter, so the compile refuses it and names both the entry point it looked for and what the body carries, in the same compileStatus() error every other compile failure reads back through.

Reading a compile error against the text it is about

What the compiler reads is not the file you wrote. The engine generates the group(1) material interface from properties.yaml, wraps the body in its domain's framework and every entry point that domain needs, expands every #include, and resolves every #ifdef — so a 100-line body reaches naga as tens of thousands of lines. Every line number in a compile error, and every [N] handle index naga prints where it has no name to print, is a position in that composed module. A fault at module scope (no definition in scope for identifier: 'zero_scene_depth') reads fine without it; a fault inside a function body does not.

renderer.compiledSource(name) returns that composed module, and shaderRef:compiledWgsl() returns it for a ref you already hold. Both answer for a shader that declares no features, needs no material, entity or draw, and — the case they exist for — answer for a shader whose compile failed, holding the text the compiler was refused over for as long as compileStatus() reports that failure. renderer.compiledShaders() lists every name they answer for.

local ref = asset.resolve("myShader", "shader")
local status = ref:compileStatus()
if status.status == "failed" then
    print(status.error)              -- the line, the caret, and the cause
    local wgsl = ref:compiledWgsl()  -- the text those positions are in
    local line = tonumber(string.match(status.error, "wgsl:(%d+):"))
    print(string.split(wgsl, "\n")[line])
end

The composed text carries every zero::* module the shader included, so the signature a call was rejected against is in the same text as the rejection — which is the whole of what a "wrong argument type" error needs to be actionable.

A material bound to a broken (or missing) shader renders with the magenta error shader — never invisibly — so a shader problem is impossible to miss on screen as well as in the log. Fixing the shader and re-saving recovers it live, with no engine restart: the recompile clears the failed-shader state and the object returns to normal on the next frame.

Discovery

  • asset.list("shader") — every registered shader.
  • asset.inspect("<name>") — domain, status, source, parse errors, this README.
  • shaderRef:compileStatus() — did it compile? { status, error? }, no log needed.
  • shaderRef:compiledWgsl() / renderer.compiledSource("<name>") — the WGSL the compiler received, which is what a compile error's positions index into. Answers for a failed compile too.
  • renderer.compiledShaders() — every name that call answers for.
  • shaderRef:getProperties() — the parsed properties.yaml schema.
  • shaderRef:listMaterialsUsing() — materials bound to this shader.

Common pitfalls

  • A compile error shows up in one of three ways at the draw. A shader that never compiled has no pipeline, so the renderer binds the magenta placeholder. A shader that compiled once and was then edited into brokenness KEEPS the pipeline that build produced — the surface goes on drawing the last good program, pixel for pixel, and looks entirely intact while the source is broken. A geometry-pass-only shader has no magenta tell either, since it is not on a forward mesh. shaderRef:compileStatus() gives the status + error text (as does asset.inspect("<name>")), and renderer.drawDiagnostics() names which renderables are in which of the three states — a row carries substituted for the placeholder, stale for a program that no longer compiles, and the cause either way.
  • A compile error's line numbers are lines of the composed module, not of your file. renderer.compiledSource("<name>") / shaderRef:compiledWgsl() is that module; resolve the position against it rather than counting lines in shader.wgsl. The [N] a message prints in place of a name is a naga handle index — it moves with the size of the body and is the same value for two unrelated faults in one file, so it is neither a line number nor a way to tell two failures apart.
  • Don't declare group(0) or group(1) in a surface shader. The engine owns group(0) and generates group(1) from properties.yaml. Re-declaring them is the fastest way to a layout mismatch.
  • #include does nothing by itself — it only exposes functions; you must call them.
  • Renaming a property changes the generated uniform field; materials setting the old name silently fall back to its default.
  • Compute shaders don't render — verify output via a buffer readback or a debug pass. Author them as a .computeShader (its own assetType), not a .shader — see man compute.
  • .material — the required wrapper that applies a shader to entities.
  • .style — UI cascade, not GPU shaders.
  • asset-type
  • reference