---
title: "Shader (asset type)"
description: "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…"
section: "Types"
slug: "types-shader"
canonical: "https://origozero.ai/docs/types-shader"
updated: "2026-09-05T16:41:47.413811135+00:00"
tags: ["asset-type", "reference"]
---

# Shader (asset type)

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

```wgsl
// @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:`:

```wgsl
// @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:

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:
```yaml
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:
```wgsl
// @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

```wgsl
// 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.

```wgsl
const DISSOLVE_LANE: u32 = 0u;

fn fragment(input: FragmentData) -> vec4<f32> {
    let progress = input.shader_data[DISSOLVE_LANE].x;
    ...
}
```

```lua
-- 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:

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

```wgsl
// @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.

```yaml
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) |

```wgsl
// @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`:
```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`:
```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:

```luau
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 |

```wgsl
// @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:

```yaml
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`:

```wgsl
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`:

```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`:

```lua
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:

```lua
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:

```yaml
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.

```lua
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
`#include`s, **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.

```lua
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`.

## Related types

- `.material` — the required wrapper that applies a shader to entities.
- `.style` — UI cascade, not GPU shaders.
