Log inGet started

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-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.
particleParticle 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:

  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 "zero::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 "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()):

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
front_facingbooltrue for front faces (double-sided shading)
timef32elapsed 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:

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 "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 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.

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)f32scene 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):

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)

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:

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

#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.

IncludeExposes
zero::pbrstruct 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::shadowscompute_directional_shadow_factor(...), compute_spot_shadow_factor(...), compute_point_shadow_factor(...)
zero::engine_bindingsthe group(0) declarations (camera, transforms, lights, shadow maps) — surface shaders get this implicitly; include it directly only for advanced raw access
zero::surface helperssafe_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 parsed properties.yaml schema.
  • shaderRef:listMaterialsUsing() — materials bound to this shader.

Common pitfalls

  • Compile error → magenta error shader. Read shaderRef:compileStatus() for the status + error text (or asset.inspect("<name>")). A geometry-pass-only shader has no magenta tell (it isn't on a forward mesh), so compileStatus() 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.
  • #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 compute.readBuffer 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