Shader (asset type)
A shader is a WGSL program that runs on the GPU. Materials reference shaders and supply property values; renderable entities reference materials, not shaders directly. This README is the canonical…
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. |
particle | Particle simulation / rendering. | The particle system's entry points. |
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 with compute.dispatch / compute.dispatchEx. 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 and the particle
domain write their own entry points directly — see man compute, man rendering,
and the particle system's contracts.
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 "zero::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 "zero::pbr" // exposes 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.
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 |
front_facing | bool | true for front faces (double-sided shading) |
time | f32 | elapsed seconds |
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. 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).
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 "zero::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(...)— per punctual light.
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 "zero::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.
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) | f32 | scene depth at uv (exact texel — an R32Float copy) |
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) |
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);
}
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).
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
#include "zero::<module>" is a pure library: it only EXPOSES functions and
structs — it does nothing on its own (exactly like a Unity .cginc). You must
call what you include.
| Include | Exposes |
|---|---|
zero::pbr | 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) |
zero::shadows | compute_directional_shadow_factor(...), compute_spot_shadow_factor(...), compute_point_shadow_factor(...) |
zero::engine_bindings | the group(0) declarations (camera, transforms, lights, shadow maps) — surface shaders get this implicitly; include it directly only for advanced raw access |
zero::surface helpers | safe_normalize, mat3_from_mat4, apply_normal_map(FragmentData, tangent_normal) |
Shader features (flags)
Features are a separate concept from properties. A property is runtime uniform
data (a colour, a float, a texture) — set per-material without recompiling. A
feature (flag) is a compile-time variant: the shader #ifdefs on it and the
engine compiles a distinct permutation, exactly like Unity shader_feature /
Unreal static switches. Features are declared in the shader and toggled by the
material that uses it; they do not live in properties.yaml. Use a property when
the value changes at runtime, a feature when it changes which code compiles.
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" # the shader asset's identity (a plain string)
render:
queue: 2000
type: opaque # opaque | mask | blend — drives the pipeline, not a flag
cull: back
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.
Render behaviour that used to be material "flags" — alpha blend/mask, cull/
double-sided — lives in the render: block and drives the pipeline, not the
fragment.
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.
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:getProperties()— the parsedproperties.yamlschema.shaderRef:listMaterialsUsing()— materials bound to this shader.
Common pitfalls
- Compile error → magenta error shader. Read
shaderRef:compileStatus()for the status + error text (orasset.inspect("<name>")). A geometry-pass-only shader has no magenta tell (it isn't on a forward mesh), socompileStatus()is the reliable check there. - 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
compute.readBufferor 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.