Shader (asset type)
New to how shaders, materials, meshes, and textures relate as assets and live GPU resources? Read
core/resource-modelfirst.
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
| Domain | What it is | You write |
|---|---|---|
surface | A material on a mesh (the common case). | fragment() (+ optional vertex()/vertex_clip()). The engine generates everything else. |
post-process | A 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. |
screen | A 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. |
sky | The 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. |
compute | GPU 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
| Value | The coordinate | A 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. |
world | The 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:
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 theFragmentDatatable below.material.*and your textures — the material inputs you declare inproperties.yaml. The mapping is mechanical: a scalar property namedfoobecomesmaterial.foo; a texture property namedbarbecomesbar(the texture) andbar_sampler(its sampler).materialhas exactly the fields yourproperties.yamldeclares and no others — to add an input, add a property. List what a shader exposes withshaderRef:getProperties()orasset.inspect("<name>").- 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 thepoint_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. - 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:
| Body | The engine gets | PBR debug passes read |
|---|---|---|
fn fragment(input: FragmentData) -> vec4<f32> | the finished pixel, and the PbrSurface too when the body hands one to zero_pbr_shade | the 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) -> PbrSurface | the surface (albedo, roughness, metallic, emissive, normal, occlusion), which the engine then lights | the 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()):
| Field | Type | Meaning |
|---|---|---|
world_position | vec3<f32> | fragment world-space position |
world_normal | vec3<f32> | interpolated geometric normal (normalized) |
world_tangent | vec4<f32> | xyz = world tangent, w = handedness (mikktspace) |
uv | vec2<f32> | primary texture coordinates (perspective-correct) |
affine_uv | vec2<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_color | vec4<f32> | interpolated per-vertex colour (white if unset; carries per-vertex lighting if vertex_clip() wrote it — see Gouraud below) |
view_position | vec3<f32> | camera world position |
view_direction | vec3<f32> | normalized world-space direction to the camera |
light_direction | vec3<f32> | directional (sun) travel direction; surface→sun is -light_direction |
light_color | vec3<f32> | sun colour |
light_intensity | f32 | sun intensity |
ambient_color | vec3<f32> | ambient colour |
ambient_intensity | f32 | ambient intensity |
frag_coord | vec4<f32> | framebuffer pixel coords (xy) + depth (z) — for screen-space dither |
receives_shadows | f32 | per-instance: > 0.5 if this surface receives shadows |
material_index | u32 | per-instance: the index standing for the material this surface draws with — the number renderer.materialIdentity() maps to a name |
shader_data | array<vec4<f32>, 4> | per-instance: this entity's own four vec4 lanes, whatever renderer.instanceData.set put in them (zero until something does) |
front_facing | bool | true for front faces (double-sided shading) |
time | f32 | elapsed 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.timeis the clock of the evaluation being run;uniforms.timeis always this frame's. A displacement built onuniforms.timeis identical on both evaluations, so its motion vector cancels to exactly zero. The same holds forv.view_projagainstuniforms.view_proj: a clip hook that rebuilds its position from world space throughuniforms.view_projreprojects 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 fromuniforms.timeand the position within the cycle fromv.time, and both evaluations share the same cycle, so the surface reports the speed it fell at.@builtin::shaders.rain_materialandsnow_materialdo 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:
| Expression | Type | Meaning |
|---|---|---|
uniforms.point_light_count | u32 | number of active point/spot lights |
point_lights[i] | PointLightData | one 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 itskind, for a loop that walkspoint_lightsand 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
PbrSurfaceand callzero_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 }
type | WGSL field type | Notes |
|---|---|---|
float | f32 | optional min/max for editor sliders |
float2/float3/float4 | vec2/3/4<f32> | |
color | vec4<f32> | sRGB colour, default is [r,g,b,a] |
int | i32 | |
bool | u32 | |
texture | texture_2d<f32> + sampler | default: 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:
| Field | Type | Meaning |
|---|---|---|
ray_dir | vec3<f32> | normalized world-space ray through this pixel |
ndc | vec2<f32> | screen position in NDC [-1, 1] |
sun_direction | vec3<f32> | unit vector TOWARD the sun (from the directional light) |
camera_pos | vec3<f32> | world-space camera position |
time | f32 | elapsed 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:
| Field | Type | Meaning |
|---|---|---|
frag_coord | vec4<f32> | framebuffer pixel coords (@builtin(position)) |
uv | vec2<f32> | screen UV, (0,0) top-left … (1,1) bottom-right |
Framework helpers (always in scope — no include needed):
| Call | Returns | Meaning |
|---|---|---|
zero_sample_scene(uv) | vec4<f32> | the rendered scene colour at uv |
zero_scene_depth(uv: vec2<f32>) | f32 | scene 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) | f32 | that 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):
| Field | Type | Meaning |
|---|---|---|
resolution | vec4<f32> | x=width, y=height, z=1/width, w=1/height |
time | vec4<f32> | x=elapsed seconds, y=dt, z=frame index |
view_proj | mat4x4<f32> | this frame's view-projection |
prev_view_proj | mat4x4<f32> | last frame's view-projection (motion) |
inv_view_proj | mat4x4<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:
| Field | Type | Meaning |
|---|---|---|
time | f32 | elapsed seconds (animation) |
scroll | f32 | UI scroll offset |
resolution | vec2<f32> | render-target size in pixels |
mouse | vec2<f32> | mouse position |
velocity | f32 | scroll 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:
| Include | Exposes |
|---|---|
@builtin::shaderModules.pbr_shading | struct 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.shadows | compute_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_bindings | the group(0) declarations (camera, transforms, lights, shadow maps) — surface shaders get this implicitly; include it directly only for advanced raw access |
@builtin::shaderModules.shadow_bias | zshadow_offset_receiver(...), zshadow_directional_depth_bias(...) and the rest of the bias math, as pure functions |
@builtin::shaderModules.texturing | ztex_stochastic(...) (tile without a visible repeat grid), ztex_blend_normals(...) (reoriented normal blending) |
@builtin::shaderModules.depth | zero_linear_depth(depth, near, far) — the one reading of a stored depth |
zero::scene_depth | zero_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_color | zero_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:
| Key | Values | Sets |
|---|---|---|
blend | opaque transparent alpha alphaBlend additive add cutout alphaCutout premultiplied premultipliedAlpha multiply | the equation the fragment composites with |
cull | back front none off disabled | which faces the rasterizer keeps |
depth_write | true false | whether the draw writes depth |
depth_compare | less lessEqual lequal equal greater greaterEqual gequal always never | the test a fragment's depth passes |
topology | triangleList lineList lines line | what 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 parsedproperties.yamlschema.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 doesasset.inspect("<name>")), andrenderer.drawDiagnostics()names which renderables are in which of the three states — a row carriessubstitutedfor the placeholder,stalefor 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 inshader.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. #includedoes 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— seeman compute.
Related types
.material— the required wrapper that applies a shader to entities..style— UI cascade, not GPU shaders.