# Material (asset type)
A material is a named, configured instance of a `.shader`. The shader
defines the surface look (lighting model, available property fields,
texture slots); the material picks values for those fields and a
shader to bind to. Renderable entities reference materials, not
shaders, so the same geometry can wear different materials and the
same material can be reused across many entities.
Entities sharing one material can still differ in a value: every drawn
object carries four `vec4` lanes of its own that a surface shader reads
as `input.shader_data[lane]` and `renderer.instanceData.set` writes. Use
that for a dissolve at its own progress per subject, a hit flash at its
own age, a fill level per instance — a material per entity is for when
the LOOK differs, not when a number does.
> New to how a material relates to its shader, its textures, and the live GPU
> material behind it? Read `core/resource-model` first. It also covers the
> difference between an authored `.material` asset (this type) and a runtime GPU
> material made with `renderer.material.create`.
## When to use one
- You want a named surface configuration an entity wears by referencing it
from its `Model` component (`material = "@.../<Name>"`).
- You want runtime property tweaks (`asset.resolve(ref):setProperty(...)`) to
affect every entity using the material at once.
- You want to publish a curated look (`Gold`, `RedGlow`,
`BrickWall`) other authors can reuse without re-typing shader
bindings.
If you need a brand-new lighting model or a custom visual effect, you
need a `.shader` first; the material wraps it.
## Where it lives
- Source: `/zero/source/.../<Name>.material/`
- Identity: `<Name>` (the `.material` suffix strips).
- Folder shape:
- `mat.yaml` — canonical material spec (shader + property values).
**Required.**
- `README.md` / `.metadata` — optional prose + tags, indexed when an
author writes real ones.
- `textures/` — optional folder for embedded textures. Validator
allows the folder; its interior is unrestricted.
- `shaders/` — optional folder for a co-located custom shader.
Same rule.
## How to create one
```luau
asset.create("material", "<Name>")
-- Creates: /zero/source/<Name>.material/
-- mat.yaml (shader binding + property scaffold)
-- `folder` places it in a subfolder of /source instead of the root:
asset.create("material", "<Name>", { folder = "materials" })
```
Pass the shader and initial values through `asset.create`, then tune with
`setProperty` and keep what you tuned with `saveDefinition`:
```luau
local mat = asset.create("material", "<Name>", { shader = "@builtin::shaders.pbr" })
mat:setProperty("base_color", { 0.8, 0.6, 0.1, 1 })
mat:setProperty("roughness", 0.3)
mat:saveDefinition() -- writes the tuned values into mat.yaml
```
This mints the `.material` asset and returns a handle you tune with
`setProperty`. A set is a runtime change: it reaches the GPU material and every
entity wearing it, and `saveDefinition` is the call that writes it into
`mat.yaml`, so a property driven every frame costs no file write and leaves the
authored file as its author wrote it. A purely runtime `asset.create` lives only
for the session.
For an ephemeral, runtime-only GPU material that is not an asset at all, use
`renderer.material.create` instead (see `core/resource-model`).
## How it operates
1. **Property reflection.** The bound shader's uniform struct fields
become the material's configurable properties — no manual property
declarations needed.
2. **Property values.** `mat.yaml` groups values by type, each block
keyed by the shader's field names: `properties:` holds scalars and
colors/vectors, `ints:` holds integer-typed fields, `bools:` holds
boolean-typed fields, and `textures:` holds texture slots. Omitted
fields take the shader's default. A scalar is a bare number
(`roughness: 0.5`); a color or vector is an array
(`base_color: [1, 1, 1, 1]`). `shader:` is the shader's identity
string. This is the shape every material the engine writes uses. A
`saveDefinition` writes the changed values into the file where they
already stand and leaves the rest of it — its comments, the order of
its keys and blocks, the spelling of its literals, untouched.
3. **Render state.** `render:` holds how the mesh is drawn rather than
what the shader reads: `blend` (the equation the fragment composites
with, also spelled `type`), `cull`, `depth_write`, `depth_compare`,
`topology` and `queue` (the draw order, a whole number: `2000` opaque,
`3000` transparent, `4000` overlay). The `.shader` type README's "The
render block" section lists every value each key takes, and
`asset.create`'s `render` argument takes the same keys and values.
4. **Application.** An entity wears a material by referencing it from its
`Model` component (`material = "@.../<Name>"`). Materials affect every entity
that references them; runtime `asset.resolve(ref):setProperty(...)` changes
propagate to all of them immediately, and last until the next mode change
unless `saveDefinition` writes them into `mat.yaml`.
5. **Texture binding.** Texture slots accept `color:r,g,b,a` for
solid colors, `default:white` / `default:black` / `default:normal` for the
engine's own fallback texels, `@builtin::textures.<name>` for builtins,
`path/to/image.png` for file textures, or a render-target handle
for camera renders. The GPU texture cache reads the `color:` and
`default:` forms from the string itself, so a slot written in one names no
asset and carries nothing with the material when it travels; every other
form names a texture asset the slot depends on.
A texture in `base_color_texture` is multiplied by `base_color`: every
built-in surface shader declaring both draws the texture's colour times
`base_color`, channel by channel. Under the default white `base_color` the
texture shows as itself, and a coloured `base_color` tints it. The two
read a colour differently: `base_color` is linear, and the slot reads its
texels as sRGB-encoded, a `color:` value's included, the way it reads an
image. A colour stated in both places is therefore multiplied by a darker
copy of itself: `color:0.62,0.14,0.05,1` in the slot decodes to
`0.34, 0.017, 0.0039`, so beside `base_color` `0.62, 0.14, 0.05` the
surface draws `0.21, 0.0024, 0.0002`, far darker than either. State a
solid colour once, as `base_color`, and leave the slot at its default
white.
6. **Hot reload.** Editing `mat.yaml` repacks the GPU uniform buffer
on the next frame; visible immediately.
**Properties that follow a named value.** `values:` binds a property to a
named value of the entity wearing the material (`values.set(entity, name,
v)`, the `topics/values` guide): `property: valueName`, or
`property: { value: "name", scale: s, offset: o }`, and the property draws
`value * scale + offset` on each entity holding the value, read on the draw.
A boolean reads as 1 or 0, and a number fills every channel of a colour or
vector property. The wearer's own value is read, else its nearest
ancestor's, so a lamp mesh under a car lights from the car's `brake`; a
wearer holding none draws the material's own value. A scalar, vector,
colour or bool property binds (at most 32 per material); a texture slot
does not. `asset.create("material", name, { values = { ... } })` and
`renderer.material.create({ ..., values = { ... } }, key)` take the same
map.
```yaml
values:
emissive_intensity: { value: "brake", scale: 3 }
```
7. **Inspector.** `type.yaml` names `inspector: { type: "inspector.luau" }`,
and `inspector.luau` declares the material's sections beside the generic
ones: `shader` (the bound shader, swappable), `properties` (each shader
field as its typed control) and `textures` (each texture slot as an asset
chip). They read the material through `mat:getShaderRef()` (the bound
shader's ref, `~` expanded) and `mat:getTextures()` (slot name →
`{ ref, name }`: the bound texture's guid and the id the material names
it by). An edit there is an authoring write: `mat:writeProperty(name,
value)` and `mat:writeTexture(slot, texture)` write the value into
`mat.yaml` (and drop the session's own value for it, so the material
reads what the file holds), and `setShader` rewrites the file; inside an
undo operation each file write is recorded, so undo puts `mat.yaml` back.
`setProperty` / `setTexture` stay runtime writes that last until the next
mode change.
## Discovery
- `asset.list("material")` — every registered material.
- `asset.inspect("<name>")` — shader binding, property values,
source path, this type README.
- `ls /zero/source/<Name>.material` lists the files the folder holds.
- `asset.resolve("<name>"):getProperties()` — the property fields exposed by
the bound shader, each at the value it currently holds: what `mat.yaml`
authored until something writes to the material, and the written value from
then on, whichever call wrote it. That is the value the surface is drawn
with. Read them back rather than guessing names; which fields exist comes
from the shader.
- `asset.resolve("<name>"):getDefinition()` — the authored `mat.yaml` bytes,
the value the material rests at.
- `asset.resolve("<name>"):getShader()` — the shader the material binds.
## Authoring conventions
- Use lowercase, descriptive names (`brushed_steel`, `red_glow`,
`tarmac_wet`). Avoid shader-name suffixes in the material name —
the binding lives in `mat.yaml::shader`.
- Drive every property the shader exposes; defaults are a fallback,
not a contract for which fields exist.
- Embed textures alongside the material (`textures/` subfolder) when
they're used only by this material; reference shared textures from
`@builtin::textures.*` or a library.
## Common pitfalls
- **Property names must match the shader's uniform struct exactly**
(case-sensitive). Misnamed properties silently get the shader's
default.
- **`base_color_texture` multiplies `base_color`.** A `color:` texture holding
the same value as `base_color` multiplies the colour by its own
sRGB-decoded value, so the surface renders far darker than the value
written. Write a solid colour as `base_color` alone.
- **Broken shaders break the material.** A material whose shader has a
parse error renders as the magenta checkerboard. Inspect the shader
first if the material looks wrong.
- **`setProperty` targets the material, not the entity.** Changes affect
every entity that references the material.
- **`setProperty` is a runtime change.** It reaches the GPU, not the file:
call `saveDefinition` to write the current values into `mat.yaml`. For a
per-entity override that no other entity sees, give the entity its own copy
with `Model:applySessionMaterial(renderer.material.create(...))`.
- **Renaming the folder changes the identity.** A `Model` component or
other reference to the material is recorded against the material
itself, so renaming or moving it does not break them.
## Related types
- `.shader` — defines what properties exist; required before a
material can bind to it.
- `.preset` — captures a component configuration; conceptually
similar to a material, but for components instead of shaders.
# Unlit
The minimal **unlit** surface shader: it emits `base_color * base_color_texture
+ emissive` with **no lighting**. Use it for UI-in-world, flat-shaded props,
emissive signage, debug geometry — anything that should display its own colour
regardless of scene lights.
It is the deliberate counterpart to [`pbr`](../pbr.shader/README.md): where PBR
carries the full StandardMaterial parameter set (roughness, metallic, normal,
clearcoat, transmission, …), `unlit` declares **only** what an unlit surface
uses. Authored as a self-shading `fragment()` shader, so it owns the final pixel
and works in both the forward and deferred paths via the engine-generated entry
points.
## Properties
| Property | Type | Default | Meaning |
|---|---|---|---|
| `base_color` | color | `1,1,1,1` | Flat colour, multiplied by `base_color_texture`. |
| `emissive` | color | `0,0,0,1` | Added on top (multiplied by `emissive_texture`). |
| `alpha_cutoff` | range | `0` | `>0` enables masked transparency (discard below the cutoff). |
| `uv_scale` | float2 | `1,1` | UV tiling, applied as `uv * uv_scale + uv_offset`. |
| `uv_offset` | float2 | `0,0` | UV translation. |
| `base_color_texture` | texture | white | Albedo texture. |
| `emissive_texture` | texture | white | Emissive mask/texture. |
| `instance_color_lane` | float | `-1` | Which of the instance's four shader-data lanes colours it (rgb colour, a coverage). `-1` reads none. |
| `instance_emission` | float | `0` | How much of the lane colour is added again on top where it covers. |
Entity tint (the per-instance `Model`/`SkinnedModel` tint) is applied by the
engine after `fragment()` returns, so this shader does not handle it.
## Per-instance colour
`instance_color_lane` names one of the four shader-data lanes every drawn
instance carries (`renderer.instanceData.set` for an entity, the
`instanceDataBuffer` of `renderer.mesh.drawInstanced` for a population). The
lane's `rgb` is this instance's colour and its `a` how much of it covers the
material's own colour, which is the mix `Model.tint` makes, so the picture is
the one a tint of the same colour and blend gives. It is the
`UNLIT_INSTANCE_COLOR` feature, so a material that leaves the property at `-1`
draws a program without it. `@builtin::materials.instance_color_unlit` is this
material ready made, reading lane 2.
```luau
renderer.instanceData.set(cell, 2, heat, 0, 1 - heat, 1) -- blue cold, red hot
```
# assetType (asset type)
An asset type says what a kind of asset is: the files an asset of the kind is
made of, the code that gives it behaviour, the interface a person uses to work
with it, and how it is found, pictured and checked. A `<typename>.assetType/`
folder declares a type called `<typename>`, and every `<name>.<typename>/`
folder is an asset of it. `assetType` is itself a type, so a type is an asset
like any other: it is created, versioned, installed and contributed the same way.
## Making a type
`asset.create("assetType", "<typename>")` scaffolds a type. The type registers
the moment its folder is written, anywhere under `/zero/source/`, and from then
on `<name>.<typename>/` folders are its assets. An asset of a type that is not
registered yet is not an asset, so make the type first.
A type folder is a root: `~.x` in any file inside it names the type's own `x`,
wherever the type is installed. Keep the type's whole implementation inside its
folder and reach it that way.
What a type folder must hold is set by this type's own `type.yaml`, the same way
every type's `type.yaml` sets what its assets hold. `asset.validate` on a type
reports anything missing, and the scaffold produces a folder that passes.
## What sets your type's schema (`type.yaml`)
`type.yaml` is the structural contract every asset of the type is checked
against. The keys below are the ones the engine reads; a key not listed here is
ignored.
| Key | What it controls |
|---|---|
| `suffix` | The folder suffix of the type's assets. Defaults to `.<typename>`. |
| `primary` | The file or files that are the asset's content: what the asset resolves to when it is used. |
| `entry_files`, `primary_aliases` | Further files that load as the asset or share its identity. |
| `is_container` | Whether the suffix stays in the identities of what the asset holds. |
| `is_opaque_walker` | Whether a walk of the tree stops at the asset and registers only its primary. |
| `plural_dir`, `classify_exts` | Loose files in a folder of this name, or with these extensions, are assets of the type. |
| `is_text_default` | Whether the asset's files are text unless stated otherwise. |
| `broadcasts_subassets` | Whether assets nested inside an asset of the type are found by their own short name. |
| `root` | `true` makes every asset of the type a root, so `~.x` inside one names that asset's own `x`. |
| `contract_ref: { field, category }` | A key of the primary file that names another asset the asset is built on. |
| `files: { required, optional }` | The files an asset holds (below). |
| `allow_unlisted` | `false` reports every file the type does not declare. |
| `aliases_from: [{ file, key }]` | Further names an asset answers to, read from its own files. |
| `settings:` | Typed per-asset settings (below). |
| `inspector:` | The interface people use for the type and its assets (below). |
| `preview:` | How an asset is pictured (below). |
| `references:` | Names of other assets the type's files carry (below). |
| `indexing:` | What search reads for each asset (below). |
| `describe:` | How a missing description is generated (below). |
### The files an asset holds
Each entry under `files.required` or `files.optional` is
`{ path, kind: file | folder, one_of_group?, content? }`. `*` in a path matches
within one segment. Entries that share a `one_of_group` are satisfied by any one
of them. `content` lists checks on the file's bytes:
| Check | Passes when |
|---|---|
| `non_empty` | the file has content |
| `json_field { key, type?, non_empty?, accept? }` | the JSON holds the key (a dotted path) with that shape |
| `yaml_field { key, type?, non_empty?, accept? }` | the same for YAML |
| `described { keys?, headings?, message? }` | the file says what the asset is (see "Ask your assets to say what they are") |
Each check takes a `message`, which is what the author is told when it fails.
### Settings
`settings: { values, schema: { type | instance, export? } }` gives every asset
typed settings. `values` names the file an asset's values live in; `schema.type`
declares one schema for the whole type, and `schema.instance` names a file each
asset declares its own in. `asset.create` writes the resolved values, and a write
to the values file reaches `onChange` with what changed.
## The human side (`inspector.luau`)
A type is used by people through its Inspector, and without one the type is
usable only from code. The Inspector is the type's interface for a person: for a
procedural graph it is the graph editor, for a kit it is the tracking view of
what the kit holds and what is still missing. Writing it is part of writing the
type.
`inspector: { type, instance? }` in `type.yaml` names the files. `type` is the
interface for the type itself and for its assets; `instance` names a file an
asset may ship to add its own. Each returns:
```luau
return {
sections = { { id = "graph", title = "Graph", build = function(ctx) ... end } },
replace = { contents = { ... } }, -- replace a default section by id
order = { "graph", "contents" }, -- section order
}
```
`build(ctx)` receives the asset and builds the section. A section's `title`, and
the text its rows carry, is a string or an `i18n.text` value read from a catalog
(`i18n.text("inspectors.mesh.title")`), which follows the editor's language. `asset.create("assetType",
...)` scaffolds a working `inspector.luau` to start from. `asset.validate` on a
type that names no inspector, or names one that does not load, reports it.
## Say what your type is for (`about.yaml`)
Every type carries an `about.yaml` that says what the type is for, in three
plain-language texts:
```yaml
intent: "Sets the typeface of the text in your game: UI, labels, signs and 3D lettering."
function: "A font, from a TTF, OTF or WOFF file, made available to all of Zero's text, including 3D text."
usage: "You choose it by name for your UI or your text, and the text appears in that typeface."
```
- **`intent`** is what the type is for in someone's game, in the words a game maker uses. Up to 800 bytes.
- **`function`** is how it works today: what an asset of it is made of and what the engine does with it. Up to 1500 bytes.
- **`usage`** is what a person or an agent does with one. Up to 1200 bytes.
Write about the type as a whole, not one asset of it, and say what it does now.
Agents read these texts to learn when to reach for the type and how; people
searching find the type by them. The scaffold starts each text with
`[[describe]]` and a note on what to write; a text that still starts that way
counts as not written. `services describe` can write them.
They show up in `asset.inspect` as `detail.about`, in
`assetTypeRef:getAbout()`, at the end of the type's `types/<typename>` guide,
and at the top of the type's Inspector.
## Ask your assets to say what they are
A type can require each asset to carry a description: a file, written by the
author or generated, saying what the asset is and what it is for. Declare it with
a `described` check:
```yaml
files:
required:
- path: "README.md"
kind: "file"
content:
- { check: "described", message: "Say what this widget is for and where it goes." }
```
A file that is missing, empty or still has a line starting with `[[describe]]`
fails the check, and so does a YAML file whose named `keys` are missing or over
their `max_bytes`, or a Markdown file missing a named heading. `asset.validate`
names the asset, and a push that carries it is refused with `message`.
`services describe` finds such assets and writes their descriptions.
`describe:` steers what is generated:
```yaml
describe:
guidance: "Say what the widget shows, what input it reacts to, and where it fits in a UI."
examples:
- "<identity of an asset of this type whose description reads well>"
```
An example is an asset of your type whose description is a true, written one,
never a placeholder and never the asset being described; with none, leave
`examples` empty. A type whose assets the model cannot perceive (a sound, a
motion) says so in `guidance`, and the description is drawn only from what the
asset's own files state.
Start the file unwritten in your `template/`: a heading and one line starting
with `[[describe]]`, never example content, which every new asset would carry
verbatim. A type whose assets say what they are through their own content (a
mesh, a texture) declares no check.
## The type's code (`behavior.luau`)
`behavior.luau` returns one table. These are the keys the engine reads from it;
anything else on the table is the type's own.
### What the type's assets can do
| Key | Shape | What the engine does with it |
|---|---|---|
| `ref` | `{ [name] = function(self, ...) }` | Every function becomes a method on every asset of the type: `ref:name(...)`. |
| `modules` | `{ [name] = require(".x") }` | Code the type's assets reach with `asset.containing(__FILE__).modules.<name>`. |
| `events` | an events schema | Events an asset fires. Readers subscribe through `ref.events.<name>:connect(...)`; the type fires them. |
| `refShapes` | `{ [method] = function(self) -> (typeExpr, source?) }` | The result type of a `ref` method for one particular asset (below). |
| `namePattern` | a Lua pattern | The names `asset.create` accepts for the type's assets. Default `^[A-Za-z][A-Za-z0-9_]*$`. |
| `derive` | `{ output, module, compute, fingerprint, matches?, required?, timeout? }` | A file computed from the asset when it is committed; a `required` one holds the commit until it lands. |
Some `ref` names mean the same for every type, and the engine calls them:
- **`instantiate(self, target?, opts?) -> (root, idMap)`**: puts the asset in a scene. Defining it is what makes the type scene-instantiable. Its return is a contract (below).
- **`inspect(self) -> detail`**: what `asset.inspect` reports for the asset beyond the common record (below).
- **`preview(self, opts)`**: renders the asset's picture; it wins over the type's `preview:` recipe.
- **`previewMaterial(self) -> { material }`**: the material a `surface` preview paints.
- **`saveDefinition(self)`**: writes the asset's in-memory edits back to its files in edit mode.
Some names are answered for every asset before the type is asked, so a `ref`
entry of the same name is never reached: `getSource`, `getBytes`, `getText`,
`exists`, `deps`, `canInstantiate`, `canPreview`, `preview` (which calls yours
when you define one), `meta`, `runtime`, `events`, `typeRef`, `modules`, and the
residency flags `has_backing_asset`, `has_runtime_changes`, `cpu_resident`,
`gpu_resident`.
### When the engine calls the type
| Hook | Signature | When it runs |
|---|---|---|
| `onCreate` | `(name, opts) -> { [path] = contents }` | On `asset.create`. Returns the files of the new asset, laid over the type's `template/`. Annotate `opts`: its type is the schema `asset.create` checks the caller's arguments against. A `""` key writes a single loose file. |
| `onReplace` | `(dest, files) -> files` | On an `asset.create` over an asset already at `dest`, after `onCreate`, so what the author added can be carried into the replacement. |
| `onRegister` | `(self)` | Once per asset, when it first registers: when the world loads, on a live `asset.create`, and when an asset arrives later (installed, pulled, synced). This is what makes an asset take effect the moment it is written. |
| `onChange` | `(ref, change)` | On every write to a file inside an asset of the type. `change` is `{ path, asset, type, kind, origin }`, plus `settings` and `settingsChanged` for a write to the settings values file; `kind` is `edited` or `seeded`, `origin` is `local` or `remote`. Filter on `change.path`. Writes to one asset arrive in order, each after the hook for the one before has finished, and the hook may wait on what it reads. A write the hook makes inside its own asset arrives later like any other, so keep the hook convergent. |
| `onDependencyChange` | `(ref, change)` | When a module the asset's code requires is re-run, or a name the asset carries starts resolving. `change.kind` is `dependency`. |
| `onDelete` | `(ref)` | When an asset of the type leaves the world: deleted, or the world it is in is swapped away. |
| `validate` | `(ref) -> { { code, message, severity?, path? } }` | On `asset.validate`, after the `type.yaml` checks, for the type's own rules. `severity` defaults to `error`. Runs on every push, so a type's rules hold at publish. A hook that fails reports `validate.hook_failed`. |
### Settings hooks
| Hook | When it runs |
|---|---|
| `settingsDefaults(name, opts, asked) -> table` | Once per `asset.create`, for values the schema's defaults do not give. |
| `settingsCorrections(values, name, opts) -> (corrections?, reasons?)` | On every settings write. A correction to a field the writer named refuses the write, giving the reason. |
| `settingsDerivedDefaults(values, explicit, changed)` | On a settings write, for fields derived from others. |
| `normalizeSettings(values, stored?)` | On every read and write, to map an earlier spelling of a field to the current one. |
| `settingsHealed(values, stored)` | On a read, to repair stored values. |
| `settingsSchemaProblem(schema, ref) -> string?` | When an asset's own schema is read; a returned string refuses the schema. |
`creationInputs` (`{ [opt] = true | selectorOpt }`) names `asset.create` options
that are inputs to the type rather than settings.
### Reloading
An edit to `behavior.luau` reaches existing assets with no restart.
## How an asset is pictured (`preview:`)
| `kind` | The picture is | The type provides |
|---|---|---|
| `instantiate` | the asset placed in the preview studio through its `instantiate` | `ref.instantiate` |
| `image` | a file the asset holds, fitted to the frame | `source: { files }`, the candidate files in order |
| `surface` | the asset painted on a mesh | `mesh`, and `ref.previewMaterial` |
| `custom` | whatever `ref.preview` returns | `ref.preview` |
```yaml
preview:
kind: surface
mesh: sphere
```
`angle: [yaw, pitch]` turns the camera. The studio owns the light, the backdrop
and the framing, so an asset pictures the same in every world. A type with no
`preview:` that defines `instantiate` still answers `ref:preview()`.
A picture is rendered when something asks to view it (the editor showing the
asset, `ref:preview()`), when previews are generated on request
(`assets.generatePreviews`), and when the asset is staged. An edit alone
renders nothing. The picture records what it was made
from, so a stale one is told from a current one.
## The names your type's assets hold (`references:`)
Where your type's own format or API carries the name of another asset, declare
it here:
```yaml
references:
keys: # a top-level key of the type's files that holds a name, or a table of them
- { key: "shader", type: "shader" }
args: # an argument of a call, counting from 1
- { call: "g:node", position: 2, type: "procNode" }
fields: # a field of the options table a call takes
- { call: "fx.beam", field: "material", type: "material" }
```
`type` is the category the name resolves in. `asset.declareReferenceKey`,
`asset.declareReferenceArg` and `asset.declareReferenceField` declare the same
from a script.
## What search reads (`indexing:`)
`indexing:` is how assets are found. Search reaches an asset only through what
its type's `indexing:` names, and adds nothing of its own. That includes you: an
asset indexed badly is one you cannot find later, when you are making something
else and need it. A type whose assets are never looked for says `indexing: none`.
```yaml
indexing:
data: # text, read from the asset's files
- role: identity
extractor: verbatim
source: { file: "README.md" }
- role: interface
extractor: symbols
source: { files: ["init.luau"] }
- role: implementation
extractor: verbatim
source: { files: ["init.luau"] }
content: # the picture
- role: identity
modality: image
source: { file: "preview.png" }
facets: # what a query filters and re-ranks by
file: ".metadata"
rigged: { type: bool, from: { components: { contains: SkinnedModel } } }
roughness: { type: number, filter: range, from: { file: "mat.yaml", field: "floats.roughness" } }
```
**Roles.** Each `data` and `content` entry has a `role`, and a query matches in
two slots:
- **`identity`**: what an asset is. For anything you look at, the picture; for anything else, the text that says what it is.
- **`capability`**: what it does and how it is made. `interface` (the signatures or shape a caller uses) and `implementation` (the body) both count as capability, and a type with code usually declares both.
Ask what a person would recognise an asset of the type by, and what it does, and
point each role at the file that says it. For text, that is the asset's
description: the file your template starts with a `[[describe]]` block, which the
author writes or `services describe` fills in (see "Ask your assets to say what
they are").
**`data`.** `extractor: verbatim` embeds a file's text as it is; `symbols` embeds
the declared signatures of code. `source` is `{ file }` or `{ files: [...] }`.
Never index a file name (`source: { field: name }`): one
or two words carry nothing to match.
**`content`.** `modality: image` embeds the picture itself, so a text query
matches what the asset looks like. A type whose assets are looked at makes the
picture its identity.
**`facets`.** What a query narrows and re-ranks the matches by. `file` is embedded
whole, so keys nobody declared in it are still found. Each further key is
computed for every asset and states a value for every one, including `false`: a
file can imply "not rigged" but never say it, and a query separates rigged from
unrigged only because the unrigged ones say `rigged: false`. A key is
`{ type: bool | number | enum, filter?: range, from }`, where `from` is
`{ file, field }` (a field of one of the asset's files, dotted for nesting),
`{ metadata: key }`, or `{ components: { contains | any_outside } }` (what the
asset's entities carry).
## What blocks a push
A push checks every asset it carries against its type. These refuse it:
- a required file that is missing, a `content` check that fails, a `described` file not written yet;
- an error from the type's `validate`.
Unlisted files and warnings do not. A `described` file blocks only a push that
changes the asset. When a push is refused, the message names each asset and what
it lacks. `world.publishBlockers()` and `zm status` read the same list without
pushing.
## Typing a result from the asset (`refShapes`)
A `ref` method whose result depends on the asset (one entry per child folder, per
row of a config, per control a map declares) can only be annotated as
`{ [string]: Thing }`, which accepts any key. `refShapes` states the result for
each asset instead: a function returning a Luau type expression for that asset,
and the module whose types the expression uses. Read the asset; never run it.
```lua
-- inputMap.assetType/behavior.luau
M.refShapes = {
activate = function(self): (string, string)
local names = {}
for _, control in ipairs(M.ref.controls(self)) do
table.insert(names, control.name .. ": Handle")
end
if #names == 0 then return "", "" end
return "{ " .. table.concat(names, ", ") .. " }", "@builtin::modules.zinput.scheme"
end,
}
```
With it, `map.jump:onPressed(fn)` is checked and `map.noexisting` is reported.
A per-asset type applies where the call site names which asset: a component
field whose default names it, or an annotation `AssetRef<"inputMap", "<identity>">`.
`asset.resolve(name, category)` carries only the category, and a name computed
at runtime carries nothing, so calls on those keep the declared return. The type
belongs to the call's result: index it there, or bind what you need from it
there, and the names stay checked.
`require("@builtin::assetTypes.assetType.shared.refShapes").published()` shows
what was published: `categories` (each type's method surface) and `returns`
(per-asset results by identity, then method). An identity missing from
`returns` was never published. Results are recomputed when an asset is written;
at most 64 assets of one type are read.
## Putting an asset in a scene (`ref.instantiate`)
A caller writes one piece of code against every instantiable type, so what
`instantiate` returns is fixed:
```lua
local Instantiable = require("@builtin::assetTypes.assetType.shared.instantiable")
M.ref = {
instantiate = function(self, target, opts)
local root = Instantiable.root(self, target, opts)
-- compose the asset under `root`, now
return Instantiable.result(self, root, idMap)
end,
}
```
**In.** `target` is the entity the asset lands under, or onto for a hierarchy
type; with none, the type spawns a new root. `position`, `rotation` (three
numbers in degrees, or a quaternion), `scale`, `name` and `temporary` mean the
same for every type: `Instantiable.root` applies them to a root you make,
`Instantiable.place` to one you adopt. Document any options of your own in the
hook's `--!arg` docs.
**Out.** `(root, idMap)`. `root` is the composed root, live when you return:
build synchronously, never defer to a component's `awake`. `idMap` maps each
original id to the spawned one, so a component that composes the asset again on
every load can pass it back in and keep references into the composition. Every
call goes through `Instantiable.result`, which refuses a root that is not live
and turns a missing `idMap` into `{}`.
## Inspecting an asset (`ref.inspect`)
`asset.inspect(ref)` builds a record every asset shares (`identity`, `name`,
`guid`, `source`, `typeName`, `typeDefinitionPath`, `scope`, `origin`,
`description`, `tags`), then calls `ref.inspect(self)` for the type's own
`detail`:
```lua
M.ref = {
inspect = function(self)
-- read the asset's own files, never runtime state: inspect works on an asset
-- that has not been compiled, uploaded or registered
return { --[[ what makes this asset what it is ]] }
end,
}
```
Without it, the record is the shared part alone, and an agent inspecting the
asset learns nothing of what it is. A hook that raises leaves `detail` empty and
sets `record.warning`.
## Discovery
- `asset.list("assetType")`: every registered type.
- `asset.inspect("<typename>")`: the type, its README and its `about.yaml` texts.
- `asset.resolve("<typename>", "assetType")`: the type itself.
# material_remap
Canonical property/texture-role dictionary used when a material **swaps
shaders**. The new shader exposes a different vocabulary than the old one —
one shader's `MAIN_TEX` is another's `albedo` is a third's
`base_color_texture` — so values would be lost on a naive swap. This module
maps any known alias onto a canonical **role** so floats, colors, and texture
links carry across the swap to the best of our ability.
Consumed by `material.assetType`'s `setShader` ref method (and, transitively,
by the `appearance` toolbox's `swapShader`).
## API
```luau
local remap = require("modules.material_remap")
remap.propertyRole("MAIN_COLOR") -- "base_color" (scalar/color side)
remap.textureRole("MAIN_TEX") -- "base_color_texture" (texture side)
-- Remap current property values onto the new shader's accepted names.
local mapped, carried, dropped =
remap.remapProperties(currentProps, shaderRef:getProperties())
-- Canonicalize texture slot names so links bind on the new shader.
local texMapped, texCarried = remap.canonicalizeTextures(currentTextures)
```
## Why two tables
`albedo` as a **color** is the base-color factor; `albedo` as a **texture** is
the base-color map. `mat.yaml` keeps `colors:` / `floats:` separate from
`textures:`, so the caller always knows which side it's remapping. Splitting
`PROPERTY_ROLES` from `TEXTURE_ROLES` removes the ambiguity instead of guessing
from the value shape.
## Property remap vs texture remap
- **Properties** are remapped against the target shader's *real* reflected
field names (`shaderRef:getProperties()`, naga reflection). Direct name match
wins; otherwise role-to-role. Properties the new shader doesn't expose are
dropped.
- **Textures** are canonicalized to the engine's builtin on-disk slot
convention (`base_color_texture`, `normal_texture`, …) because the engine
does not yet surface a target shader's reflected texture-slot list to Luau.
Unknown slots pass through unchanged.
The alias lists are deliberately broad (glTF / Unity / Unreal / Godot /
hand-rolled WGSL conventions). Add new aliases here rather than special-casing
a shader at a call site.
# material_utils
High-level wrappers around the `__material` FFI global. Convenience
helpers for the common material-authoring operations: create a named
material, apply it to an entity, query / set material properties, and
bind textures.
Exposed as the `Material` Luau global via `--!global` (auto-injected by
the prelude). Material lives ON the Model / SkinnedModel component — there
is no standalone `Material` component, because a material only renders where
there is a mesh. To apply a material, use `Material.apply()` (which errors if
the entity has no model) or set the model's `material` field directly:
`entity(id).component.add("Model", { model = "cube", material = "gold" })`.
PascalCase aliases (`Material.Create`, `Material.Apply`, etc.) are kept
for back-compat with the wider codebase — 88+ call sites across demos,
scenes, components, guides, and the engine test suite use the legacy
names. The lowercase forms are canonical.
## Exports
Canonical (lowercase) surface:
- `Material.create(name: string, opts: MaterialOpts?) -> MaterialRef?` — register a named material; returns the AssetRef envelope.
- `Material.apply(entityId: string, materialRef: MaterialRefOrName) -> boolean` — set the `material` field on the entity's Model / SkinnedModel. Errors if neither is present.
- `Material.exists(name: MaterialRefOrName) -> boolean` — registry predicate.
- `Material.getPropertyNames(name: MaterialRefOrName) -> { string }?` — list property names exposed by a material.
- `Material.getProperty(name: MaterialRefOrName, prop: string) -> any` — read a property value.
- `Material.setProperty(entityId: string, property: string, value: any)` — set a property on the material assigned to an entity's Model / SkinnedModel.
- `Material.setTexture(name: MaterialRefOrName, slot: string, textureRef: string) -> boolean` — bind a texture slot.
PascalCase back-compat aliases:
- `Material.Create(name, opts_or_shader, props?) -> MaterialRef?` — accepts the legacy `(name, shaderString, opts)` 3-arg form OR the canonical `(name, opts)` form.
- `Material.Apply` / `Material.Exists` / `Material.GetPropertyNames` / `Material.GetProperty` / `Material.SetTexture` — direct aliases for the lowercase counterparts.
- `Material.SetProperty(materialName, property, value)` — writes to the REGISTERED material by name (NOT on a component). Different from lowercase `setProperty`.
Types:
- `Color = { number } | { r: number, g: number, b: number, a?: number }`
- `Textures = { [string]: string }`
- `MaterialOpts = { shader?, color?, roughness?, metallic?, emissive?, textures?, [string]: any }`
- `MaterialRef = { __ref?, type?, name?, guid? }`
- `MaterialRefOrName = MaterialRef | string`
## Usage
```luau
local mat = Material.create("gold", {
color = { 1, 0.85, 0.2 },
metallic = 1,
roughness = 0.15,
})
-- entityId must already carry a Model / SkinnedModel (a material only
-- renders on a mesh); apply errors otherwise.
Material.apply(entityId, mat) -- AssetRef envelope
Material.apply(entityId, "gold") -- bare name also works
Material.setProperty(entityId, "roughness", 0.3)
Material.setTexture("gold", "albedo", "@builtin::textures.gold")
```
## Notes
- `Material.apply` accepts either an AssetRef envelope or a bare name —
the model's `material` field auto-wraps strings.
- Material is a field on the Model / SkinnedModel component (declared as
`material`, an `AssetRef<material>`). There is no standalone Material
component — `apply` / `setProperty` route to whichever model is present
and error when neither is.
- `Material.setProperty` writes the property on the material assigned to
the entity's model; `Material.SetProperty` writes on the REGISTERED
material by name. They behave differently — keep them straight.
- All argument validation raises on bad input rather than silently
no-oping, so call sites surface mistakes early.
# yaml.module
YAML decode + encode for authored engine content. Pure Luau, no engine
dependencies — a general parsing library, not data-system plumbing.
```luau
local Yaml = require("@builtin::modules.yaml")
local doc = Yaml.decode(vfs.read(path))
vfs.write(path, Yaml.encode(doc))
```
## Supported subset
- Block mappings (`key: value`) and block sequences (`- item`), nested
to any depth.
- Compact sequence-of-mapping entries (`- path: x` / ` kind: y`).
- Flow collections — `{ a: 1, b: 2 }` and `[1, 2, 3]` — single line.
- Plain scalars with type inference: `~` / `null` → nil, `true` /
`false` → boolean, numeric literals → number, everything else →
string.
- Single- and double-quoted strings (`''` escapes a literal quote
inside a single-quoted string; `\n` `\t` `\r` `\\` `\"` inside a
double-quoted one). A quote opens a quoted scalar where a scalar
begins — after `key:`, after a `- ` entry marker, or inside a flow
collection — so a plain scalar carries an apostrophe as data
(`note: the world's rules`). Quoted keys are read in flow mappings.
- `#` comments — honored outside quotes, so a `#` inside a quoted
string is kept as data.
- Literal (`|`) and folded (`>`) block scalars, with a chomping
indicator (`-` strips the final newline, `+` keeps every trailing
blank line, none keeps one newline) and an indentation indicator
(`1`-`9`: the content's indent past the parent's), in either order
(`|-`, `>+`, `|2`, `>1-`). A block scalar's content is literal text:
quotes (balanced or not), `#`, `key: value` and `---` inside it are
data.
- An optional leading `---` document marker.
## Loud-error contract
Constructs outside the subset above raise instead of misparsing:
anchors/aliases (`&`, `*`), tags (`!`), directives (`%`),
multi-document streams (a second `---`), a block-scalar header
it cannot read (`|x`, `|-+`, `|0`), tab indentation, and inconsistent indentation (both block structure and
block-scalar bodies). Every error carries the offending line number
(`yaml: line N: ...`), so a bad config points straight at the line to
fix.
Tab indentation is rejected everywhere — including block-scalar body
lines whose leading whitespace contains a tab. A tab after the first
non-space character of a line is content and passes through.
## API
- `Yaml.decode(text: string): any` — decode a YAML document into a
Luau value (table, scalar, or nil for an empty document). Raises on
malformed input or an unsupported construct.
- `Yaml.decodeWithComments(text: string): (any, boolean)` — decode as
`decode` does, and report whether the text carries a comment by the
decoder's own rule: a `#` at a line's start or after whitespace, outside
a quoted scalar and outside a block scalar. Re-encoding the value drops
such comments, so an editor that writes a document back reads this first.
- `Yaml.encode(value: table): string` — encode a Luau table as YAML
(block style, two-space indent, alphabetically sorted keys). Raises
on values YAML can't represent (functions, userdata, non-string
mapping keys).
A top-level empty table encodes to an empty document, which decodes
back to `nil` (the YAML empty-document ambiguity).
# material_records
The material GPU records this session has released and nothing has filed again.
A `.material` asset files its GPU record at its point of use — `matRef:handle()`
— and memoises the handle it filed on the ref's runtime, so a second bind of the
same material costs a table read where the first cost a parse and an upload.
That memo names a record the renderer's registry holds, and
`renderer.material.destroy` takes the record away while the asset and its memo
stand. The asset is what files the next record, so it has to know which names
that applies to.
This set is that knowledge: a release puts the key in, filing a record under the
key takes it out, and the point of use reads it for the cost of one table index.
Written by `renderer.material.destroy` and `renderer.material.create`; read by
the `.material` assetType's `handle()`.
## API
```luau
local MaterialRecords = require("modules.material_records")
-- A point of use holding a memo asks whether the record it names still stands.
if MaterialRecords.released[identity] ~= nil then
-- The record is gone: file another one, which takes the mark off.
end
MaterialRecords.noteReleased(key) -- the record under `key` has been released
MaterialRecords.noteFiled(key) -- a record stands under `key` again
```
## Lifetime
The set lives in the session store, so it holds for as long as the GPU records
it answers for: a VM reload leaves those records standing and leaves this
standing with them, and both die with the engine process.
# material_schema
A shader's declared property vocabulary, and the routing of an authored key
onto it.
A material's properties belong to its **shader**. The uniform buffer is packed
by reflected field name, so a key the shader does not declare is never read by
anything: it sits in the material's property table, reads back as though it
applied, and contributes nothing to the render. This module is the single place
that answers "is this key real, is it a texture slot, or is it neither" — so
every path that accepts material properties gives the same answer.
Consumed by `material.assetType` (the `.material` asset path) and by
`renderer.material.create` (the runtime GPU path).
## API
```luau
local schema = require("modules.material_schema")
local s = schema.forShader("pbr") -- nil when the shader can't resolve
schema.route(s, "base_color", {1,0,0,1}) -- "property", "base_color"
schema.route(s, "color", {1,0,0,1}) -- "property", "base_color" (alias)
schema.route(s, "base_color_texture", tex) -- "texture", "base_color_texture"
schema.route(s, "baseColor", {1,0,0,1}) -- "unknown", "baseColor"
schema.declaredNamesList(s) -- "alpha_cutoff, anisotropy, …"
schema.undeclaredMessage(name, key, "pbr", s)
schema.acceptsSlot(s, "base_color_texture") -- true: the shader declares the slot
schema.acceptsSlot(s, "base_color") -- false: a property, not a texture slot
schema.declaredSlotsList(s) -- "base_color_texture, emissive_texture, …"
schema.undeclaredSlotMessage(name, slot, "pbr", s)
schema.isTextureSlotType("texture") -- true
schema.isTextureSlotType("texture_array") -- true
schema.isTextureSlotType("color") -- false
```
A `properties.yaml` entry of type `texture` or `texture_array` declares a texture
slot; every other type except `buffer` declares a property.
A texture reaches the bind group only through a slot the shader declares, since
the material interface is generated from those slots. `renderer.material.create`,
`renderer.material.setTexture` and a material asset's `setTexture` /
`writeTexture` refuse any other slot through `acceptsSlot` and report it with
`undeclaredSlotMessage`. A schema that holds no names at all (a shader whose
vocabulary has not been read yet) accepts every slot.
## Routing order
`route` applies the alias table first, then decides by surface:
1. **Alias** — `color` → `base_color`, but only when the shader declares the
canonical name and *not* the alias, so a shader that genuinely exposes
`color` keeps its own meaning.
2. **Texture** — the schema declares the name as a texture slot, *or* the value
is itself a texture (handle / `AssetRef<texture>`). A texture stored as a
uniform property reads back via `getProperty` but never reaches the bind
group, so the value's own shape decides regardless of the key.
3. **Property** — the schema declares the name as a uniform property.
4. **Unknown** — neither. Nothing reads it; the caller warns with
`undeclaredMessage` and drops it.
## When the shader can't be resolved
`forShader` returns `nil` for a shader that is not registered yet (a runtime
shader still compiling, a name that resolves later). Callers stay permissive in
that case rather than reject every key against an empty vocabulary — the check
applies where the vocabulary is actually known.
# material (procRecord)
The material record kind: a surface shader, its property values by name, its texture slots by name and, optionally,
render state (`blend`, `cull`, `depthWrite`) and a buffer property filled from elements. `material.pbrMat` and
`material.shader` answer one, held on one element as `Material = { material = <record> }`.
Live, a record becomes the runtime material the `Material` component's `material` field holds. Saved, it is a
`.material` asset, and a Texture record in one of its slots is written as a texture beside it. `io.import` of a
`.material` asset answers the same element, its record read from the asset.
# texture_ref
The GPU key a material's texture slot binds by, resolved from whatever form the
author wrote it in.
The renderer binds a texture slot by **exact GPU-cache key**, and that key is
the guid an upload lands under. A reference in any other form — an asset
identity (`wall.texture`), a bare name, a `.texture` path, the image path a
texture was imported from — names a real texture the cache has never heard of.
Bound as written it leaves the slot on the shader's declared fallback: white for
a `base_color_texture`, black for a sky panorama. The material then renders as
though nothing was bound, and nothing distinguishes that from a texture the
author meant to be dark.
This module is the single place that answers "what key does this reference
bind by", so the same string binds the same texture wherever it is written.
Consumed by `material.assetType` (the `.material` asset path),
`renderer.material.create`, and `renderer.material.setTexture`.
## API
```luau
local TextureRef = require("modules.texture_ref")
TextureRef.resolve("wall.texture") -- "9f2c…", "asset"
TextureRef.resolve("9f2c…") -- "9f2c…", "asset" (idempotent)
TextureRef.resolve("/source/sky/pano.png") -- "f181…", "asset" (→ pano.texture)
TextureRef.resolve("color:1,0,0,1") -- unchanged, "procedural"
TextureRef.resolve("video_0") -- unchanged, "live"
TextureRef.resolve("nothing_here") -- unchanged, "unresolved"
TextureRef.isProcedural("default:white") -- true
TextureRef.unresolvedMessage(ref, "renderer.material.setTexture")
```
`resolve` also **materialises** the texture (Disk→CPU→GPU via `texRef:handle()`)
— the guid only becomes a cache key once the upload has landed.
## Resolution order
1. **Procedural** — `color:` / `default:` / `runtime:`. The GPU texture cache
produces these itself, so they pass through untouched.
2. **The reference itself** — a guid, an asset identity, a bare name, or a
`.texture` path, resolved through `asset.resolve(ref, "texture")`.
3. **The imported container** — when the reference is a loose raster path
(`png` / `jpg` / `jpeg` / `webp`), the sibling `<stem>.texture` the texture
importer promotes it into. The importer *removes* the loose original, so the
path an author copied out of an import log names a file that no longer
exists; this step follows the image to where its texture actually went.
4. **The alternate identity** — a slot's stable identity, passed by the
`.material` path so a binding whose guid was orphaned by a delete + recreate
still finds the texture the author named.
5. **A resident GPU key** — a live texture with no asset behind it: a video
frame (`video_0`), a rasterised text texture, a render target. Binds exactly
as written.
Anything left is `"unresolved"`. The reference is still returned unchanged — a
texture that lands later is picked up by the renderer's own pending retry — but
the caller has the outcome and reports it with `unresolvedMessage` instead of
letting the slot fall back in silence.
# pbr
The engine's standard physically-based material — the full glTF metallic-roughness
workflow lit by the shared `@builtin::shaderModules.pbr_shading` model. This is the default surface shader a
model uses when its material doesn't specify another, and it is also registered
under the legacy alias **`standard`** (the name many materials reference).
It is a `surface()` shader: it fills a `PbrSurface` and the engine generates every
pipeline entry point and lights it. Every parameter defaults to a neutral value, so
a material that sets nothing renders as a clean dielectric; the advanced lobes only
cost anything when a material opts into them.
## Properties
| Property | Type | Default | Meaning |
|---|---|---|---|
| `base_color` | color | `[1,1,1,1]` | Albedo tint, multiplied by `base_color_texture` and vertex colour. |
| `emissive` | color | `[0,0,0,1]` | Emissive tint, multiplied by `emissive_texture` and `emissive_intensity`. |
| `emissive_intensity` | float | `1.0` | HDR multiplier on the emissive output. |
| `roughness` | range | `0.5` | Perceptual roughness, multiplied by the green channel of `roughness_metallic_texture`. |
| `metallic` | range | `0.0` | Metalness, multiplied by the blue channel of `roughness_metallic_texture`. |
| `reflectance` | range | `0.5` | Dielectric reflectance knob (neutral 0.5 → 4% F0). |
| `ior` | float | `1.5` | Index of refraction → dielectric F0 and the refraction direction. |
| `normal_scale` | float | `1.0` | Strength of the tangent-space `normal_texture`. |
| `occlusion_strength` | range | `1.0` | Blend of the `occlusion_texture` (R) into the ambient term. |
| `clearcoat` | range | `0.0` | Strength of a second dielectric clearcoat specular lobe. |
| `clearcoat_roughness` | range | `0.0` | Roughness of the clearcoat lobe. |
| `anisotropy` | float | `0.0` | Anisotropic highlight stretch along the tangent ([-1,1]). |
| `sheen_color` | color | `[0,0,0,1]` | Tint of the sheen lobe — the retroreflective rim of velvet, satin and brushed fabric. Black leaves the base untouched. |
| `sheen_roughness` | range | `0.3` | Width of the sheen lobe. |
| `iridescence` | range | `0.0` | Blend toward the thin-film response, the hue that shifts with view angle. |
| `iridescence_thickness` | float | `400.0` | Film thickness in nanometres — which wavelengths cancel and which reinforce. |
| `iridescence_ior` | float | `1.3` | Index of refraction of the film. |
| `subsurface_color` | color | `[0,0,0,1]` | Tint of the light that entered the surface, scattered beneath it and left again. Black leaves the base untouched. |
| `subsurface_radius` | float | `0.5` | How far the diffuse falloff wraps past the terminator, taken per channel against `subsurface_color`. |
| `fibre_color` | color | `[0,0,0,1]` | Tint of the second of the two highlights a hair or fur strand gives. Black leaves the base untouched. |
| `fibre_specular` | range | `0.35` | Strength of the first strand highlight, in the light's own colour. |
| `fibre_shift` | float | `0.06` | How far apart the two strand highlights sit along the fibre. |
| `fibre_roughness` | range | `0.1` | Width of the first strand highlight. |
| `transmission` | range | `0.0` | Specular transmission — refracts the environment through the surface. |
| `thickness` | float | `0.0` | Volume thickness for Beer–Lambert transmission absorption. |
| `attenuation_color` | color | `[1,1,1,1]` | Transmission absorption tint. |
| `attenuation_distance` | float | large | Beer–Lambert absorption distance. |
| `uv_scale` | float2 | `[1,1]` | UV tiling applied before every texture sample. |
| `uv_offset` | float2 | `[0,0]` | UV offset applied before every texture sample. |
| `alpha_cutoff` | range | `0.0` | Mask threshold — fragments with `alpha <` this are discarded (0 = no cutoff). |
| `double_sided` | bool | `false` | Flip the shading normal on back faces. |
| `receives_fog` | bool | `true` | Whether the screen-space fog passes (height fog, aerial perspective) reach this surface. `false` leaves an opaque deferred draw at the colour it shaded to, however deep in the medium it stands — see below. |
| `instance_color_lane` | float | `-1` | Which of the instance's four shader-data lanes colours it (rgb colour, a coverage). `-1` reads none. See below. |
| `instance_emission` | float | `0` | How brightly the lane colour glows where it covers. |
### Emissive authoring
The glow output is `emissive × emissive_intensity` (× the `emissive_texture`). The glow
**colour** is `emissive`, a vec; the **intensity** over it is `emissive_intensity`, a
scalar. So `{ emissive = {0.1, 0.9, 1, 1}, emissive_intensity = 5 }` is a cyan glow at 5×,
and it is these two names that `getProperties()` lists and `setProperty` writes.
A spelling another engine's convention uses for the same role — `emissive_color`,
`emission`, `emissive_tint` — is resolved onto `emissive` when the material is authored,
so content carried in from glTF, Unity or Godot reaches the property under the name this
shader declares. Where two such spellings meet on one of the two names, the shape of each
value says which of them it is: a vec is the colour and a number is the intensity, so
`{ emissive_color = {0.2, 0.8, 1, 1}, emissive = 5 }` is the same cyan glow at 5x. Two
values of one shape are one property's, and the one written under the name the shader
declares applies while the other is named in a warning.
A bare positive scalar `emissive` with no colour beside it is the "turn emission on"
shorthand and means a white glow at that intensity, so `emissive = 20` on a white material
lights up.
### The lobes over the base layer
Beyond metallic-roughness, the shading model carries six layers this material
declares. Each is gated on its own neutral value, so a material that sets none of
them shades as a plain dielectric and each one costs only where it is asked for.
| Layer | Turned on by | What it looks like |
|---|---|---|
| Clearcoat | `clearcoat > 0` | A second dielectric specular lobe on top — car paint, lacquer, a varnished surface. |
| Anisotropy | `anisotropy ~= 0` | The highlight stretches along the tangent — brushed metal, a vinyl record. |
| Sheen | `sheen_color` above black | A retroreflective rim that brightens toward grazing angles — velvet, satin, dusty cloth. |
| Iridescence | `iridescence > 0` | The specular hue shifts with view angle — an oil slick, a soap film, anodised metal, a beetle shell. Thickness picks the colours. |
| Subsurface | `subsurface_color` above black | Light wraps past the terminator and glows through thin geometry lit from behind — skin, wax, marble, a leaf. `thickness` gates how much reaches the far side. |
| Fibre | `fibre_color` above black | The two offset highlights a strand gives instead of the one a surface gives — hair, fur. Taken around the world tangent as the fibre axis. |
All six are evaluated inside the engine's own light loop, once for every light
that reaches the surface, so each one is shadowed, attenuated and cone-masked
with the base layer it sits over.
Each layer, and specular transmission beside them, is a shader feature its
gating property switches on (`features:` in `properties.yaml`: `PBR_CLEARCOAT`,
`PBR_ANISOTROPY`, `PBR_SHEEN`, `PBR_IRIDESCENCE`, `PBR_SUBSURFACE`, `PBR_FIBRE`,
`PBR_TRANSMISSION`; the per-instance colour below is `PBR_INSTANCE_COLOR`). A material that sets the property away from its neutral
value draws a program holding that layer; one that leaves every layer at rest
draws a program holding none of them, so the default material pays only for the
metallic-roughness model it shades as. Setting a property with
`renderer.material.setProperty` moves the material between the two programs, and
`renderer.shaderVariants()` lists the ones a scene has asked for.
An oil-slicked wet surface is `iridescence = 1`, an `iridescence_thickness`
around 300-500 nm and a low `roughness` over a dark `base_color`. Skin is a warm
`subsurface_color` with a `subsurface_radius` near 0.5. Velvet is a `sheen_color`
at the fabric's own hue over a dark base.
### Standing outside the fog
`receives_fog = 0` declares that the screen-space fog passes — `heightFog` and
`aerialPerspective` in `@builtin::systems.atmosphere` — leave this surface at the
colour it shaded to.
The declaration travels to those passes as a per-pixel surface flag written into
the alpha of the G-buffer normal (`@builtin::shaderModules.surface_flags`). Only
the deferred G-buffer fragment entry writes that channel, so the declaration
reaches the fog from an **opaque** draw on the **deferred** path. A draw the
renderer sends through the forward fragment entry — every draw on the forward
path, and the transparent phase on either path — writes no G-buffer channel to
carry it, and its pixels take the fog like any other.
### Per-instance colour
`instance_color_lane` names one of the four shader-data lanes every drawn
instance carries (`renderer.instanceData.set` for an entity, the
`instanceDataBuffer` of `renderer.mesh.drawInstanced` for a population). The
lane's `rgb` is this instance's colour and its `a` is how much of it covers the
material's own base colour and emission, 0 none through 1 all, so one material
draws every member of a structure, a crowd or a population in a colour of its
own: a stress map, a heat map, damage tints, team colours, a selection
highlight. A lane never written reads zero and leaves the material's own look.
```luau
local mat = renderer.material.create({ shader = "@builtin::shaders.pbr",
properties = { instance_color_lane = 2, roughness = 0.6 } }, "beams")
beam.component.add("Model", { model = "cube", material = mat }) -- the handle
renderer.instanceData.set(beam, 2, 1, 0.2, 0, 1) -- this beam orange-red
```
The colour replaces the base colour before lighting, so the surface still shades
and the `albedo` capture pass reads the lane colour. `instance_emission` adds
the lane colour as emission where it covers. Lanes 0 and 1 carry a baked
lightmap's rect and layer, so a lit material takes lane 2 or 3. The read is the
`PBR_INSTANCE_COLOR` feature, so a material that leaves `instance_color_lane` at
`-1` draws a program without it. `@builtin::materials.instance_color` is this
material ready made, reading lane 2.
A write is local to the engine that makes it and replicates nothing, which is
what makes it cheap enough to recolour hundreds of members every frame;
`Model.tint` is the synced tint every participant sees.
## Textures (glTF metallic-roughness set)
| Slot | Default | Channels |
|---|---|---|
| `base_color_texture` | white | RGBA albedo (sRGB). |
| `roughness_metallic_texture` | white | G = roughness, B = metallic (glTF packing). |
| `emissive_texture` | white | RGB emissive (sRGB). |
| `normal_texture` | flat-normal | Tangent-space normal map. |
| `occlusion_texture` | white | R = ambient occlusion. |
Because the engine holds the full `PbrSurface`, the capture tool's debug passes
(albedo / roughness / metallic / AO / emissive / world-normal / shadow / depth /
motion) all read real material data.
# assetType runtime
The machinery that reads a `<typename>.assetType/behavior.luau` and turns its
declarations into live behaviour.
A `behavior.luau` is a table of declarations — `ref` methods, `modules` shared
code, an `events` schema, a `namePattern`, `refShapes`, and the `onCreate` /
`onRegister` / `onChange` / `onDelete` / `validate` / `settingsDefaults` /
`settingsCorrections` hooks. None of it runs itself. This is the code that
runs it, and it lives here because reading a type's declarations is the
**assetType type's own behaviour** — the same way `material.assetType` owns
what a `.material` does.
## Sub-modules
| Module | Reads | Responsibility |
|---|---|---|
| `ref.module` | `ref`, `modules` | Builds every `AssetRef` envelope, attaches the shared metatable, and dispatches per-type methods and shared modules onto it. Also owns edit-mode write-behind persistence. |
| `create.module` | `onCreate`, `namePattern`, `validate` | Backs `asset.create`: resolves the type, checks the name against its pattern, runs `onCreate` (or clones `template/`), and writes the instance. `validate.module` beside it checks a creation `opts` table against the type's `createSchema`. Before running `onCreate`, resolves the type's settings chain (below) and hands the result to the hook as `opts.settings`. |
| `settings.module` | `settings`, `settingsDefaults`, `settingsCorrections` | A type's declared settings schema and where its values live (`type.yaml`'s `settings:` block). `Settings.resolveForCreate` layers the schema's own defaults, the type's own `settingsDefaults(name, opts, asked)` hook (a DEFAULT the schema can't express — a name-derived convention, a fact read off a payload the caller handed it, a value following a field `asked` shows a higher layer naming; any later layer overrides it), the Preset Manager, an importer's settings, the caller's `settings` table, then the type's own `settingsCorrections(values, name, opts)` hook (a CROSS-FIELD CONSTRAINT the schema can't express, applied last over the fully merged values). A correction over a default/preset/importer value lands silently; a correction that would override what the caller's own `settings` table explicitly asked for refuses the creation instead, naming the field and why. Neither hook is ever handed a table to mutate; each returns what it wants changed (`settingsCorrections` may also return a `{ [field]: reason }` table for that refusal message). `Settings.cloneValues` deep-copies a values table — `create.module` hands `onCreate` a copy, never the table it stamps. `Settings.valuesOf` / `writeValues` read and write an asset's own values file. |
| `typeYaml.module` | `type.yaml` | The folder a type lives in and its parsed `type.yaml`, decoded once per content version of the file. `settings.module` and `inspector.module` read their blocks through it. `about` / `aboutAt` read a type's `about.yaml` (`intent`, `function`, `usage`), leaving out any key that is empty or still starts with `[[describe]]`; `aboutRows` and `aboutMarkdown` lay the texts out for the Inspector and the `types/<name>` guide. |
| `inspector.module` | `type.yaml`'s `inspector:` block | Which inspector a type declares for its assets (`inspector: { type: ... }` or `{ instance: ... }`), and the declaration that file returns — the sections it adds, the generic ones it replaces, and their order — or the problem that kept it from loading. |
| `onRegister.module` | `onRegister` | Fires an instance's once-only registration hook — on arrival, on world load, and for `@builtin` library instances. |
| `changeDispatch.module` | `onChange`, `onDelete`, `refShapes` | Routes a VFS source write or a folder delete to the enclosing typed asset's type hooks. |
| `refShapes.module` | `refShapes`, `ref` | Publishes what every `AssetRef<category>` answers to, so a member read on an asset-typed value is checked against that category's real surface. |
| `events.module` | `events` | The per-asset event runtime behind `ref.events`. |
| `instantiable.module` | `ref.instantiate` | The instantiation contract an asset type opts into: `root` stands the root entity, `place` applies the base placement opts to one the type adopted, and `result` shapes the `(root, idMap)` every `instantiate` returns. Also registers the `sceneInstantiable` field-constraint validator. |
| `declaration.module` | `type.yaml` | The type's decoded declaration, read once and held until a write inside an `.assetType` folder; what every question about a type as a whole reads. |
| `previewRecipe.module` | `type.yaml` `preview:` | How an instance's `preview.png` is produced: which of the four recipes (`instantiate`, `image`, `surface`, `custom`) the type declares, and what that recipe needs. |
## Reaching it
```luau
local rt = require("@builtin::assetTypes.assetType.shared")
rt.ref.loadTypeBehavior("material")
```
Fields resolve lazily. The prelude requires the sub-modules directly, in
dependency order (`ref`, then `changeDispatch`, `onRegister.install()`,
`refShapes.install()`) — a table that required all six on load would take that
ordering away from it.
# 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 reference for writing **every** kind of
shader — it covers all domains, the surface authoring contract, the material
property schema, the engine library functions you can call, and how a shader
registers and compiles.
> 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).
A shader's material properties are exactly the ones its `properties.yaml`
declares: a shader with no `properties.yaml` declares none, and the engine
reads no property from the WGSL body. A `.wgsl` file standing on its own,
outside a `.shader` folder, is WGSL source rather than a shader: a material
draws with the `.shader` folder that holds it as `shader.wgsl`. 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, in the space it declares (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. | An `@compute @workgroup_size(...)` entry over storage buffers; you write the `@group/@binding` lines yourself. For compute work, the `.computeShader` assetType (see below) generates them for you. |
`// @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`. `shaderRef:domain()` answers the domain a body
declares, a further spelling as the domain it names. 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 have 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`.
**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 `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 the material rows), and the material interface generated from
`properties.yaml` (`material` and your texture slots). 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 punctual lights (`zero_point_light(i)`)
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 read as `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;
}
```
#### Declaring how far a hook moves geometry: `vertexBoundsDilation`
Static geometry is culled per cluster against its bounds as authored. A hook that moves vertices — wind sway, an outline hull, a vertex wave — can move them outside those bounds, and the cull then drops clusters whose displaced triangles are still on screen. Declare the farthest the hook moves any vertex, in world units, as a `vertexBoundsDilation` float property, with a default that covers the shader's own properties across their ranges:
```yaml
properties:
- { name: sway, type: float, default: 0.5, min: 0, max: 4 }
# 0.4 x sway, at sway's largest value.
- { name: vertexBoundsDilation, type: float, default: 1.6, min: 0, max: 100 }
```
Each material sets its own value — a material that sways less can set less, one moved farther sets more — and a material that sets none takes the default. The cull adds the material's value to every bound in its frustum and occlusion tests (not to the level-of-detail cut). A shader with a hook and no `vertexBoundsDilation` property is named once in the engine log when it compiles; its geometry is culled against its undisplaced bounds.
### 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, unit length; surface→sun is `-light_direction`. Where no sun reaches the camera it points straight down |
| `light_color` | `vec3<f32>` | sun colour |
| `light_intensity` | `f32` | sun intensity; 0 where no sun reaches the camera |
| `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 — its `ecs.ShaderData` component's `lane0`..`lane3`, or what `renderer.instanceData.set` put in them (zero until something does). A procedural graph's elements set them through the `data0`..`data3` attributes |
| `front_facing` | `bool` | true for front faces (double-sided shading) |
| `time` | `f32` | gameplay seconds: advances with the world at `engine.timeScale` and stands still while gameplay is paused (edit mode starts paused), so an effect slows and stops with the world |
`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), and where the vertex stands on its instance:
`local_position` (its position in the mesh, after `vertex()` and before the
instance's world transform), `object_origin` (the world position of the instance's
origin), `object_scale` (the world length of the instance's x, y and z axes) and
`object_axes` (`mat3x3<f32>`, the world direction of those axes, unit length) —
what a camera-facing card, a shard grown about its own base, or anything placed
per instance is built from. **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).
`vertex()` and `vertex_clip()` run in the vertex stage, which reads the frame
uniforms (`uniforms.*`, and the `ClipVertex` members built from them), the scene
buffer (`zero_object_data`, `zero_object_visuals`, the node transforms), the
material interface and a `read` buffer property. The point, spot and area lights
(`zero_point_light`, `zero_punctual_projection`), the shadow maps, the
environment and light probes, the feature textures, the scene depth, colour and
reflection copies and a `@group(2)` buffer the shader declares `read_write` are
visible to the fragment stage alone: read them in `fragment()` or `surface()`. A
shader whose vertex hook reaches one fails to compile, and the error names the
stage, the binding and the calls that reach it.
### 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 |
| `zero_point_light(i)` | `PointLightData` | one point or spot light (fields below) |
| `zero_punctual_projection(i)` | `PunctualProjection` | a spot or area light's projection: `view_proj`, the frustum its cookie and shadow are placed by, and `cookie_layer` |
`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 the punctual 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 = zero_point_light(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.
A sky pipeline binds group(0) and the group(1) interface alone, so a sky that
reads a `buffer` property or any other `@group(2)` binding fails to compile, and
the error names the binding and the calls that reach it.
`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` | gameplay seconds: advances with the world at `engine.timeScale` and stands still while gameplay is paused (edit mode starts paused), so an effect slows and stops with the world |
```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's `properties.yaml` carries one further, top-level key
beside `properties:` — `space`, either `light` (the default) or `display`:
```yaml
space: display
properties:
- { name: amount, type: float, default: 1.0, min: 0, max: 1 }
```
`light` is the scene's linear radiance — what every surface, the sky and every
`afterLighting` feature write, unbounded above 1.0. `display` is the code values
the `__display__` pass leaves behind after applying exposure and its tone curve
— the 0-1 range a colourist's tool and a dithered or quantized effect expect.
`postprocess.add`'s `opts.space` overrides what the asset declares for one
registration. The chain converts at both ends of an effect that runs on a
segment in the other space, so a light-space vignette still darkens a frame
graded after `__display__`, and a lookup table built from a `.cube` export
always sees the code values it was authored against, wherever the chain
runs it.
`space` also decides which layer a registration that names none lands on: a
`light` shader grades `scene`, the segment holding the scene's own light, and a
`display` shader grades `all`, the picture after `__display__` with the UI on
it. So `postprocess.add("glow", asset.resolve("~.shaders.bloom", "shader"))`
thresholds highlights that still stand above 1.0, and a lookup table registered
the same way reads the code values it was built for. `opts.layer` names one
explicitly — `"all"` is what an effect that must grade the interface says.
A **post-process** shader's `properties.yaml` carries a further, optional
top-level key — `passes:`, the pass list the frame draws through, in order:
```yaml
space: light
passes:
- { name: bright, entry: bright, scale: 0.5, inputs: [scene] }
- { name: down1, entry: downsample, scale: 0.25, inputs: [bright] }
- { name: up0, entry: upsample, scale: 0.5, inputs: [down1, bright] }
- { name: composite, entry: composite, inputs: [scene, up0] }
```
Every pass's entry — `fn <entry>(in: PostInput) -> vec4<f32>` — lives in the
one `shader.wgsl` the assetType recompiles on. `scale` is the fraction of the
view a pass draws at; the LAST pass takes none, since it draws at the view's
size and writes the chain's output. `inputs` names what a pass reads —
`"scene"`, the chain's input for this effect, or the name of an EARLIER pass —
and `inputs[0]` is what `zero_sample_scene(uv)` reads inside that pass's body;
every input, `inputs[0]` included, is also reachable by index through
`zero_pass_input(i, uv)` and `zero_pass_input_size(i)`, below. The one place
the two reads of `inputs[0]` disagree: in an effect whose declared space
differs from the segment it draws on, `zero_sample_scene` converts a `scene`
input into that space while `zero_pass_input(0, uv)` hands back the segment's
own, unconverted value. A shader declaring no `passes:` is one pass whose
entry is `fragment`, and its generated WGSL is byte-identical to what it is
without the key.
A pass that is not the last writes a scratch target the effect owns — in the
space the shader declared, view-sized times its own `scale` — allocated on the
first draw and replaced whenever the size changes. `engine.params`, below,
carries the drawing pass's own index, its scale, and its first input's texel
size, so one shader body serves every level of a chain built this way.
The worked example is the bloom: `@builtin::shaders.post.bloom` declares
twelve passes — a threshold pass, five downsamples, five tent upsamples and a
composite — out of four entries, since the five downsamples share one and the
five upsamples share another, each level reading the one below it through
`zero_pass_input` and telling itself apart by `engine.params`. Its own README
walks the declaration.
A declaration is refused at compile time — reported through `compileStatus()`,
`postprocess.add`'s second return value, or a live effect's
`status()[i].error` — for: an entry `passes:` names that `shader.wgsl` does
not define; an input naming a later pass, or naming neither `scene` nor an
earlier pass; a `scale` outside `(0, 1]`; a `scale` declared on the last pass;
a `buffer` property (group(2) carries the pass inputs); or a shader whose
group(0) plus declared texture slots plus widest pass's inputs ask for more
than the 16 FRAGMENT sampled textures one shader stage has.
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`, converted into the space this effect declared (`zero_scene_light` is the same call, for content that reads it by that name) |
| `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` |
| `zero_pass_input(i, uv)` | `vec4<f32>` | the pass's declared input `i`, at `uv`. Input 0 is what `zero_sample_scene` reads |
| `zero_pass_input_size(i)` | `vec2<f32>` | that input's size in texels, so one entry serves every level of a chain |
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) |
| `params` | `vec4<f32>` | `x`=this pass's own index, `y`=the scale it draws at, `zw`=1/size of its first input — for a pass the post-process chain draws. A pass a `renderFeature` fragment pass draws carries that feature's `spec.params` here instead; a shader declaring `passes:` is always drawn by the chain, so the two never meet in one shader. |
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. What a capture opts out
of is the registered effects; `__display__` still runs on it, so the exposure
and tone curve that turn the scene's light into a picture are on what a capture
returns.
A diagnostic capture (`albedo`, `normal`, `motion_vectors`, …) bypasses
`__display__` itself, since a channel like that is a reading of the scene
rather than a graded picture of it. The environment-cube capture bypasses it
too, and for the same reason: a probe face carries radiance into the cube
array, and `__display__` would fit that radiance to a display's code values —
a picture of it rather than the value the PBR indirect term multiplies into
its lighting.
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 })
```
The registration names no layer, so the effect grades the layer its `space`
belongs to: this vignette reads light, so it runs on `scene`, over the picture
before the UI reaches it. 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` | gameplay seconds: advances with the world at `engine.timeScale` and stands still while gameplay is paused (edit mode starts paused), so an effect slows and stops with the world |
| `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 |
| `@builtin::shaderModules.gbuffer_material` (`zero::gbuffer_material`) | `struct ZeroGbufferMaterial { base_color, metallic, perceptual_roughness }`; `zero_gbuffer_unpack_material(texel: vec4<u32>) -> ZeroGbufferMaterial`; `zero_gbuffer_material(t: texture_2d<u32>, coord: vec2<i32>) -> ZeroGbufferMaterial`, `zero_gbuffer_base_color`/`zero_gbuffer_metallic`/`zero_gbuffer_roughness(t, coord)` — the one unpacking of `@scene.material`'s `Rgba8Uint` texel into what a surface reflects |
| `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
`~.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`: a module's declared alias
answers as well as its identity, so a shader written against `zero::pbr`
compiles and pins the asset that name resolves to. Prefer 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 },
})
```
### A property that switches a feature on
A feature can also be switched on by a material's property values. List the
properties under the feature's `properties:`, and the feature is on for every
material whose value for one of them stands away from that property's declared
default:
```yaml
properties:
- { name: clearcoat, type: range, default: 0.0, min: 0, max: 1 }
features:
- { name: CLEARCOAT, properties: [clearcoat], description: the coat lobe }
```
```wgsl
#ifdef CLEARCOAT
s.clearcoat = material.clearcoat;
#endif
```
A material at `clearcoat: 0` draws a program holding none of the coat's code; one
that sets `clearcoat: 0.6`, in its `mat.yaml`, through `asset.create` or with
`renderer.material.setProperty`, draws the `CLEARCOAT` variant, and setting it
back to 0 returns it to the base program. The property stays a runtime value the
program reads; the feature decides only whether the code reading it is there.
This is how a shader with costly optional parts keeps every material that leaves
them at rest on the cheap program without each material having to name a
feature. A material naming the feature off under `features:` keeps it off
whatever the property holds. Write the guarded code so that, with the feature
off, the shader computes what it would with the property at its default: the
material sees one look either way, and pays for the part only where it shows.
Each distinct set of switched-on features is a program to compile the first time
a material reaches it. A material keeps the programs its values switched it onto
compiled while it lives, so a property crossing back and forth reuses them; when
the shader's variant budget is spent, the ones no material draws with are given
back first. A `properties:` entry is a value property (`float`, `range`, `int`,
`bool`, `color`, `float2` to `float4`) the shader declares; one naming no declared
property, or a texture or buffer, is refused by name.
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
properties:
roughness: 0.5
base_color: [1.0, 1.0, 1.0, 1.0]
textures:
base_color_texture: { ref: "default:white" }
```
`shader:` is a plain identity string, and a colour is an array. A key's
value is the rest of its line, so every value above stands alone on one.
### The render block
Render behaviour (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 |
| `queue` | a whole number: `2000` opaque, `3000` transparent, `4000` overlay | the draw order of the material; a number inside a queue orders within it |
`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, or for `queue`, that it takes a number. 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`, `topology` to `triangleList` and `queue` to
`2000`. 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 compiles it: the body + the property schema are read, the group(1)
interface and (for surface) every render-mode entry point are generated,
`#include`s are expanded, the result is **validated**, and the shader is
registered. 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?, pending? }`), 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 surface shader is also checked for the material resolve, the pass that
shades each pixel of the visibility buffer through the shader's own code. That
check reaches its verdict a few frames after the raster compile. Until it
does, `compileStatus()` reads `"pending"` with `pending` saying so, and the
shader already draws. A shader the check refuses reads `"failed"`, and `error`
names the construct the resolve cannot evaluate and where it sits; its pixels
resolve through the error shader until it changes.
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?, pending? }`, no
log needed. `"pending"` with `pending` set means the material resolve check
is still reaching its verdict; a resolve refusal reads as `"failed"`.
- `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.
## In the Inspector
Selecting a shader opens its **Shader** section: its domain and shading model (and, for a post-process shader, the space its `fragment()` reads and returns), whether it compiled (and the compiler's error when it did not), a table of its declared properties (type, default, range), its post-process passes when it has any, the bindings it declares, and openers for its source. The **Materials** section (collapsed at first) has a Find materials action that lists, as links, every material drawing with the shader; it scans every material, so it runs only when asked.