# Tool (asset type)
A tool is a single agent-callable function whose schema is its
`typed function` signature. Tools are the workflow surface — composed
Luau operations that bundle multiple engine APIs into one named call,
suitable for agents (and for use from `execute`). Each tool has a
declared input shape, return shape, and a strict result envelope
(`ZmToolResult`).
**Every exported tool function is declared `typed function`.** The
`typed` keyword is what makes the signature real: it compiles to a
runtime dispatch wrapper that checks each argument against its
declared type and raises on a mismatch. A plain `function` — even one
carrying Luau type annotations — is checked by nothing at call time,
so the tool accepts any input and reports success on garbage. See
"The typed signature is the schema" below.
## When to use one
- You're composing several engine APIs into a workflow that's worth
naming and discovering (`tools.list`, `search_tools`).
- You want an agent to be able to call this function by name without
reading source code — the typed signature is the API contract.
- The operation has clear success / failure modes that fit
`{ exitCode, data?, stdout?, message? }`.
If the function is just a Luau utility called from other code, use a
`.module`. If it's stateful background work, use a `.service`. If
it's pure GPU compute, use a compute `.shader`.
## Where it lives
Tools nest under a `.toolbox/` parent — toolboxes are the namespace.
- Source: `/zero/source/.../<Toolbox>.toolbox/<name>.tool/`
- Identity: `<Toolbox>.<name>` (dotted form — the toolbox name is
part of the tool identity).
- Folder shape:
- `init.luau` (or `init.lua`) — exports the function, and carries its
schema in `--!desc` / `--!arg` / `--!return` / `--!example`
annotations above it. The typed signature IS the tool's signature.
**Required.**
- `README.md` — instance documentation. **Required.**
- `.metadata` — tool-level tier and tags. **Required.**
## How to create one
Use the workflow tool that scaffolds the right `.tool/` shape under
an existing toolbox:
```luau
-- The `tools` global authors a tool inside an existing toolbox:
tools.create({
name = "<name>",
toolbox = "<Toolbox>",
description = "What this tool does, agent-facing.",
code = "return shared.ok(true)", -- the function body
})
-- Or scaffold the folder manually:
local box = asset.resolve("<Toolbox>.toolbox")
asset.create("tool", "<name>", { into = box })
```
Always nest under an existing toolbox — if you don't have one yet,
call `tools.createToolbox({ name = "<Name>", description = "..." })`
first.
## How it operates
1. **Registration.** Writing `init.luau` into a `.tool/` folder
registers the tool with the tools registry. It becomes callable
as `tools.use("<toolbox>", "<name>", ...)` from Luau,
`use_tool { toolbox, tool, args }` over MCP, and
`zero <toolbox> <name> [args]` from the engine shell.
2. **Schema validation.** The `typed function` signature declares the
parameter shape. Its runtime dispatch wrapper checks each argument
before the body runs and raises a message naming the argument, the
expected type, and what arrived.
3. **Return envelope.** Tools MUST return `ZmToolResult`:
```
{ exitCode = 0|n, data? = any, stdout? = string, message? = string }
```
`exitCode = 0` means success; non-zero means failure, with
`message` carrying the human-readable reason.
4. **Discovery.** The tool surfaces in `tools.list()`,
`search_tools { query: "..." }`, and `asset.inspect`'s
`exposes:` section (parsed from the typed signature and the
`--!` annotations).
5. **Hot reload.** Editing `init.luau` re-registers the tool.
Subsequent calls see the new behaviour / schema.
## Discovery
- `tools.list()` — every registered tool (any toolbox).
- `search_tools { query: "..." }` — MCP-side tool search.
- `asset.list("tool")` — same set, asset-style listing.
- `asset.inspect("<toolbox>.<name>")` — signature, parameters,
return shape, this type README.
- `ls /zero/source/<Toolbox>.toolbox/<name>.tool` lists the files the folder holds.
## Authoring conventions
- **Implementation lives in `init.luau`.** The tool's full function
body — argument validation, the call into engine APIs, the
return value — goes in `init.luau`. The toolbox's
`shared.module/` is for genuinely cross-tool helpers (`ok` /
`fail` constructors, shared parsers / validators), not per-tool
logic. Do NOT make `init.luau` a one-line forwarder onto a
function defined in `shared.module/`.
- Use `shared.module/` in the parent toolbox for `ok` / `fail`
result constructors. Don't hand-roll the envelope per tool. Pull
it in with `local shared = require(".shared")` from any tool
that needs the helpers.
- Annotate every public function with `--!desc` / `--!arg` /
`--!return` / `--!example` directly above the function — the LSP
and toolbox surfaces extract these.
- **Declare every exported function `typed function M.name(arg: T,
opt: U?): R`.** This is not conditional on the signature being
"clear" — a loose argument is the case that needs the checked
contract most. Give the argument the type it actually accepts
(a union, a named table type, a literal enum) instead of dropping
to an unchecked `function`.
- Keep tools focused. One tool, one named workflow. Split rather
than grow.
- Tag tools in the sibling `.metadata` (`{ "tags": [...], "tier":
"core|content|specialized" }`) so `search_tools` can find them by
domain (e.g. `entity`, `scene`, `assets`, `physics`).
## The typed signature is the schema
The `typed` keyword compiles the function into a dispatch wrapper
that validates arguments at call time. Without it there is no check
at all, so the tool runs its body on whatever arrived and returns a
success envelope built from garbage. Type each argument as narrowly
as the operation actually accepts:
```luau
export type Mode = "edit" | "play"
export type SpawnOpts = {
position: { number }?,
parent: EntityRef?,
count: number?,
}
--!desc Spawn a primitive and return the entities it created.
--!arg kind Primitive shape to spawn.
--!arg opts Placement and repetition options.
--!return The spawned entity refs.
--!example "cube", { count = 4 }
typed function M.spawn(kind: ("cube" | "sphere" | "plane"), opts: SpawnOpts?): { EntityRef }
```
- **A fixed set of accepted words is a literal union**, never
`string`. `("cube" | "sphere")` rejects `"Cube"` at the call and
lists the accepted values in the error; `kind: string` accepts
`"banana"` and fails somewhere deeper, or not at all.
- **A structured argument is a named `export type`**, never
`{ [string]: any }`. Naming it puts every field and its type on
the tool's page and checks them on entry.
- **A named type is only as strong as its fields.** `opts: DrawOpts`
where `DrawOpts = { value: any }` passes for a table and checks
nothing inside it — the same hole, one level down. The argument
checker walks nested fields, so type each one; a type name is not
a place to put the work off.
- **A dispatch table keyed by an `op` string is several tools.**
`{ op: string, [string]: any }` is one unchecked entry point
wearing N signatures — split it so each operation carries its own
checked arguments.
- **An entity or asset argument is `EntityRef` / `AssetRef<kind>`**,
never `any` and never a bare name string.
- `any` is correct only for a value the tool genuinely does not
constrain — a passthrough payload, arbitrary user data. It is not
a placeholder for a type not yet written down.
## Common pitfalls
- **A plain `function` export.** The tool registers and dispatches,
so nothing looks wrong — it just never rejects bad input, and
every call reports success. Declare it `typed function`.
- **Annotations without `typed`.** `function M.f(x: string)` reads
as typed and checks nothing at runtime; the annotation only
informs the LSP. The keyword is what enforces it.
- **Wrong return envelope.** Returning a raw value instead of
`{ exitCode = 0, ... }` will be flagged by callers expecting
`ZmToolResult`.
- **Tool identity.** The fully-qualified identity is
`<toolbox>.<name>`, NOT just `<name>`. Toolbox-less tools don't
exist by design.
- **Missing dependency.** Tools requiring another package /
service must declare it; otherwise the call fails at runtime
with a missing-module error.
## Related types
- `.toolbox` — the required parent namespace for tools.
- `.module` — for Luau libraries with no agent-facing schema.
- `.service` — for stateful background work, not callable
request/response operations.
## In the Inspector
Tool: how the tool is called (its signature), what it does, each argument, what it returns, and its examples, with **Open** for its source.
# volumetrics
Volumetric (3D-texture) rendering, end to end, in one package. Built
entirely on the engine's generic `compute.*` GPU primitives — no
Rust changes are needed to author new volumetric content.
## Layout
| Asset | Identity | Role |
|---|---|---|
| `Volume.component` | `@builtin::systems.volumetrics.Volume` | Attach a 3D voxel texture to an entity; raymarches it through the entity's transform AABB. |
| `VolumetricSky.component` | `@builtin::systems.volumetrics.VolumetricSky` | Singleton procedural cloud layer with TAA. |
| `ParticipatingMedium.component` | `@builtin::systems.volumetrics.ParticipatingMedium` | Mark an entity as fog/smoke/dust that attenuates the light passing through it. |
| `mediaTransmittance.module` | `@builtin::systems.volumetrics.mediaTransmittance` | Light-space optical-depth map: the registry, the settings, and the basis the map is built in. |
| `mediaShadow.renderFeature` | `@builtin::systems.volumetrics.mediaShadow` | Builds the optical-depth map and attenuates each surface by the density in front of it. |
| `volume.module` | `@builtin::systems.volumetrics.volume` | Builders (`noise`/`sphere`/`box`/`wrap`) + the `.zvol` header reader + occupancy + brick paging + the per-entity raymarch dispatch. |
| `VolumeSequence.component` | `@builtin::systems.volumetrics.VolumeSequence` | Play an open volume sequence through an entity's transform AABB. |
| `volumeSequence.module` | `@builtin::systems.volumetrics.volumeSequence` | A run of `.zvol` frames streamed through a fixed ring of resident volumes. |
| `volumeDemos.module` | `@builtin::systems.volumetrics.volumeDemos` | Shared pipeline for the showcase scenes (fill + emissive raymarch + composite). |
| `volumetricSky.module` | `@builtin::systems.volumetrics.volumetricSky` | Cloud volume + raymarch + TAA + composite orchestration. |
| `shaders/*.shader` | `@builtin::systems.volumetrics.shaders.*` | `volume_raymarch`, `volume_pages`, `volume_pack`, `cloud_noise[_v3/_v5]`, `cloud_raymarch[_v3/_v4/_v5]`, `cloud_taa`. |
| `importers/vdb_to_zvol.importer` | (auto-discovered) | `.vdb` → dense `.zvol` via the `openvdb` WASM plugin (procedural fallback when the plugin isn't loaded). |
| `presets/cumulus_default.preset` | `@builtin::systems.volumetrics.cumulus_default` | Canonical full `VolumetricSky` weather preset. |
| `demo/*.scene` | `@builtin::systems.volumetrics.demo.*` | `sdf_solids`, `smoke_plume`, `nebula`, `godrays`, `zvol_viewer`, `sky`. |
## Path convention — fully-qualified builtin identities
Every intra-package reference uses the asset's fully-qualified builtin
identity, the same convention every other builtin package follows:
```luau
local Volume = require("@builtin::systems.volumetrics.volume")
local Sky = require("@builtin::systems.volumetrics.volumetricSky")
local V = require("@builtin::systems.volumetrics.volumeDemos")
-- shader identities, e.g. asset.resolve("@builtin::systems.volumetrics.shaders.volume_raymarch")
```
Qualified identities are caller-independent: they resolve identically
from a module, a scene entrypoint, or a component. Caller-relative
(`.sibling`) requires are deliberately avoided here because the
component runtime caller identity is `@builtin::components.<Name>`, so a
`.sibling` require from a component resolves against the wrong root.
(An earlier revision of this package used a `~volumetrics` package-root
alias for relocatability; that alias is not resolvable on current main,
so the package uses qualified identities throughout.)
## Usage
```luau
-- Per-entity volume. The component resolves the name through `Volume.get`,
-- so a volume paged into a brick pool renders from the pool under the same
-- name:
local Volume = require("@builtin::systems.volumetrics.volume")
local cloud = Volume.noise({ name = "cloud", size = 64, scale = 4 })
cloud:buildSparse({ brickSize = 8 })
cloud:releaseDense()
entity(id).component.add("Volume", { volumeName = "cloud", density = 6.0 })
-- Procedural sky:
local sky = entity.spawn("sky")
sky.component.add("VolumetricSky", {})
preset.apply("cumulus_default", { entity = sky.id, component = "VolumetricSky" })
-- VDB import: drop a `.vdb` into the VFS; the importer writes a `.zvol`
-- alongside it. Load it with the zvol_viewer demo scene.
-- Animated run: one `.zvol` per frame, played through a bounded ring.
local Seq = require("@builtin::systems.volumetrics.volumeSequence")
Seq.open({ name = "plume", frames = framePaths, resident = 3, fps = 24 })
entity(id).component.add("VolumeSequence", { sequenceName = "plume", density = 6.0 })
```
## Plugin dependency
The VDB importer delegates real OpenVDB parsing to the `openvdb` plugin
in the builtin library, `@builtin::plugins.openvdb`. Without the plugin
the importer falls back to a deterministic procedural torus so the
pipeline stays functional.
# pbr_layered
A surface composed from two complete PBR layers: a base, and a top layer laid
over it wherever coverage says so.
Rust over painted metal, snow over rock, mud over a vehicle, wear over a finish.
Pre-blending those into one texture set bakes the coverage in — it can no longer
change at runtime, and every entity wearing the material wears it identically.
Here both layers keep their own shading inputs and coverage is evaluated per
fragment, so the same material can be pristine on one entity and ruined on the
next.
## Coverage
Coverage is the product of three terms, each neutral by default:
| term | property | what it is for |
|---|---|---|
| mask | `top_mask_texture`, `top_mask_channel`, `top_mask_contrast` | *where* the top layer gathers — creases, edges, upward faces |
| material | `top_coverage` | *how far* the look has progressed, for every entity sharing the material |
| instance | `top_instance_lane`, `top_instance_component` | how far for *this* entity |
`top_coverage` defaults to 0, so a material that sets nothing renders as its base
layer alone.
`top_mask_contrast` shapes the mask: 1 is a linear ramp, higher values pull the
top layer back into the mask's brightest areas, which is what makes rust and snow
gather in creases instead of washing evenly over everything.
## Driving it per instance
Point the material at an `instanceData` lane, then write that lane per entity:
```lua
-- material: top_instance_lane = 0, top_instance_component = 0
local clean, rusted = entity.find("barrel_a"), entity.find("barrel_b")
if clean and rusted then
renderer.instanceData.set(clean, 0, 0.1) -- nearly clean
renderer.instanceData.set(rusted, 0, 0.9) -- heavily rusted
end
```
Both entities share one material and one draw path; only the lane differs.
## Texture slot budget
WebGPU guarantees 16 sampled textures per fragment stage and the engine's
group(0) already holds several. This shader declares **9**, which leaves room for
those plus a margin. Two full five-map layers would not fit, so the top layer
carries the three maps that make it read as a different material — albedo,
roughness/metallic, normal — and takes occlusion and emissive from the base.
Layer count is **two**. The cost is fixed and predictable: both layers are always
sampled, and coverage selects between their inputs rather than branching.
## Why the inputs blend rather than the results
Lighting each layer separately and adding puts more energy back out than arrived,
so a half-covered surface would read brighter than either layer alone.
Interpolating the shading inputs and lighting once cannot do that.
# `referenceChart` surface shader
A reference chart drawn on a surface, unlit: each point holds light its
properties and its UV state, so a picture of the surface is measured against
known input. The post-verification reference scenes
(`@builtin::scenes.postReference.*`) are built from its materials
(`@builtin::materials.postReference.*`).
| `pattern` | Draws |
|---|---|
| 0 | A grey wedge: one column per whole stop from `evMin` to `evMax`, each middle grey (0.18) at that stop. |
| 1 | The 24 ColorChecker Classic patches, 6 x 4, each its published sRGB value read as linear reflectance. |
| 2 | A ramp from `low` at u = 0 to `high` at u = 1. |
| 3 | A Siemens star of `frequency` spokes, `high` on `low`. |
| 4 | A checker `frequency` cells across, `low` and `high`. |
| 5 | Focus bars: four rows of bar pairs, `frequency` pairs across the top row, doubling on each row below. |
Every pattern's light is multiplied by `color` and `gain`. UV (0, 0) is the
surface's top-left, as the front face of `@builtin::meshes.cube` maps it.
# `fog` post-process shader
Depth-keyed distance haze. `pp.add(name, "fog", { density = ..., color = { r, g, b } })`
registers it, and `asset.resolve("@builtin::shaders.post.fog", "shader"):getProperties()`
reads the knobs below before it is added.
The pass reads the scene depth already drawn at a pixel, turns it into a distance
in metres with `zero_linear_depth(d, near, far)`, and mixes the frame toward
`fogColor` by `1 - exp(-distance * density)` — an exponential falloff, so density
sets how many metres of air it takes to reach the fog colour.
`near` and `far` are the planes the frame was projected through. They are what a
stored depth is turned back into metres by, so they belong to the camera the
scene was drawn with rather than to the look: setting them to something the
camera does not use moves every surface's distance and with it the whole haze.
| Property | Meaning |
|---|---|
| `density` | Falloff rate. 0 leaves the frame untouched. |
| `fogColor` | The colour a fully fogged pixel reaches. |
| `near` | The scene camera's near plane, in metres. |
| `far` | The scene camera's far plane, in metres. |
# grass_material.shader
Grass, shaded root to tip with per-blade colour variation and a soft
half-lambert wrap.
The colouring reads `uv.y` as height along a blade — 0 at the root, 1 at the tip
— and darkens the roots while warming the tips. Per-blade variation comes from a
hash of the fragment's world xz on a 10 cm grid, so every fragment of one blade
shares a seed and neighbouring blades drift apart in hue and brightness. The
lighting is a diffuse half-lambert wrap kept below saturation, so this shader
returns its own final colour instead of a PBR surface.
`sway` is how far the vertex stage bends each vertex along its blade, and it is
what tells this shader that its mesh is a blade mesh: the bend scales with
`uv.y²`, so a vertex at the root holds still while the tip swings hardest. It
defaults to 0, at which every vertex arrives where the mesh put it — a material
that leaves it there shades a surface without moving it. `@builtin::materials.grass`
is that ground surface; `@builtin::materials.grass_blades` sets `sway` to 1 for a
blade mesh.
## Properties
- `base_color` — the grass green the root→tip gradient is built from.
- `sway` (`0`..`4`, default `0`) — how far each vertex bends along its blade.
# procedural_sky
The editable atmospheric sky: a day/night gradient with a sun disc and
glow, a procedural star field, and a moon opposite the sun, authored
entirely from material properties — no texture backs it.
Day, night and the dawn/dusk blend all derive from the directional
light's elevation, so the sky follows whatever drives the scene's sun;
there is no time-of-day uniform. The gradient's zenith, horizon and
ground colours, the sun's size and intensity, the stars, the moon,
turbidity and exposure come from `properties.yaml`.
Output is radiance scaled by `exposure` — the scene's own tone-mapping
does the range compression, the same contract as every sky-domain
shader.
The `ProceduralSky` component owns the runtime material over this shader
(`__procedural_sky`) and pushes its fields here; `sky.get()` reports the
resulting configuration.
# error_marker
The surface of the ERROR sign: the magenta and dark purple world-space checker
the engine's error pipeline draws, as an unlit surface shader. The
`error_marker` material draws the sign with it.
# shared Module
Cross-tool helpers for the `pp` toolbox. Every tool inside this
toolbox can pull these in with `require(".shared")`.
## Purpose
This module holds the small set of helpers that more than one tool in
the toolbox needs — typically the `ok(value, formatted?)` /
`fail(message, exitCode?)` constructors that produce the canonical
`ppToolResult` shape, plus any toolbox-wide parsers,
validators, or shared state. Keep it focused: per-tool implementation
stays inside each `<x>.tool/init.luau` (with its own `--!desc` /
`--!arg` / `--!return` / `--!example` annotations).
## Usage
```luau
local shared = require(".shared")
function M.someTool(...)
if not ok then
return shared.fail("reason", 1)
end
return shared.ok(value, "")
end
```
## Exports
See `init.luau` for the full export surface. Typical exports include
`shared.ok(value, formatted?)` and `shared.fail(message, exitCode?)`
for building the toolbox's standard result envelope.
# eye.shader
An eye: an iris refracting behind a clear cornea.
The iris sample is displaced by refracting the view ray at the cornea and
walking it `iris_depth` inward, so the iris slides as the viewing angle changes.
The cornea itself is the engine's clearcoat lobe, which keeps one continuous
specular highlight across the iris and the sclera.
`iris_radius`, `pupil_radius` and `limbus_softness` shape the disc; `iris_texture`
is sampled in the refracted frame, `sclera_texture` in the surface's own UVs.
# loading_backdrop (screen shader)
A calm, slowly drifting full-screen backdrop for the world loading screen. A deep
near-black field carries two large, soft glows that move along slow independent
orbits, finished with a gentle vignette and a faint animated grain that keeps the
gradient from banding. Animation is driven entirely by `engine.time`, so it
advances on its own during boot with no per-frame uniform updates. It is quiet by
design, so it sits behind the world's cover image, title, and progress bar. Drawn
through the `backgroundShader` UI style after registration with
`ui.registerBackgroundShader`.
# `hdrChart` post-process shader
The synthetic HDR chart: a picture whose every pixel holds light its
properties and its position state, drawn in place of the scene. Registered
first in the chain (`postMetrics.chart.show`, priority -1000), it is the input
the display transform and every effect after it read, so they are measured
against known light with no lighting, geometry or material in between.
`@builtin::modules.postMetrics` computes the same light for any pixel on the
CPU (`chart.light`) and builds the picture a display curve should present for
it (`chart.reference`).
| Property | Meaning |
|---|---|
| `pattern` | 0 ramp, 1 grey wedge, 2 ColorChecker, 3 colour wedge over its grey. |
| `low`, `high` | The ramp's light at the left and right edges. |
| `evMin`, `evMax` | The wedges' leftmost and rightmost stops; each column is middle grey (0.18) at a whole stop. |
| `gain` | The ColorChecker's light, as a multiple of its reflectance. |
| `color` | The ramp's tint, and the colour wedge's saturated patch (scaled to its column's grey luminance). |
| `dither` | Triangular noise of that many code values either way, added in display-encoded sRGB. |
The effect declares `space: light`.
# fluidSurface
A GPU particle simulation, rendered as a continuous liquid.
Water made of particles has a problem: the particles are spheres, and a pile of
spheres looks like a pile of spheres. Turning them into a mesh every frame is
expensive and the mesh is thrown away immediately. This package skips the mesh —
it renders the particles into screen-space fields and shades those, so the
particle data never leaves the GPU and the cost scales with screen pixels rather
than with the surface's geometric complexity.
## What it does
```lua
local fluidSurface = require("@builtin::systems.fluidSurface.modules.fluidSurface")
local rts = fluidSurface.attach("lake", {
particles = { buffer = sim, stride = 16, posOffset = 0, count = 400000, velOffset = 4 },
radius = 0.35,
})
```
`particles` describes the record layout of the buffer the caller's simulation
writes: how wide a record is, where the position sits in it, and how many are
live. Pass the handle a compute shader's `createBuffer` answered. Everything
after that happens per frame until `detach`.
`update` moves the live knobs — the particle count as a sim grows, the sun
direction as the day turns, any composite property:
```lua
fluidSurface.update("lake", { count = active, sunDir = { 0.3, 0.8, 0.2 } })
```
## The chain
1. **Splat** (engine raster). Every particle draws as a velocity-stretched
billboard: nearest-surface depth, hardware z-tested; additive thickness and
thickness-weighted colour; and, when the records carry a foam weight, an
additive foam sprite layer.
2. **Narrow-range filter** (`fluid_narrow_x` → `fluid_narrow_y`, ping-ponged
`iterations` times). Neighbour depths are clamped into a band around the
centre before averaging, so particles fuse into a surface while silhouettes
stay crisp. The body is the `fluid_narrow_filter` shader module, included by
both axes.
3. **Resolve** (one of `fluid_resolve`, `fluid_resolve_col`,
`fluid_resolve_foam`, `fluid_resolve_col_foam`). Packs smoothed depth,
blurred thickness and the foam fraction into `data`, and the mean liquid
colour along each ray into `liq`. Which one runs is decided by whether the
records carry a colour and a foam weight, because that changes which textures
the pass binds.
4. **Composite** (`fluid_surface_composite`, a post-process shader). Refracts the
already-rendered scene through the surface normals, absorbs along the view
ray by Beer–Lambert, marches the reflected ray against scene depth so the
water mirrors what is above it, adds sun specular, and lays foam on the thin
edges. Depth-tested against the scene, so fluid behind geometry stays hidden.
Every stage runs at `resolutionScale` of the screen (half by default).
## Sharing
The passes are compiled once and shared by every surface. What a surface owns is
its render targets, its own filter-parameter buffer, and its own composite —
which is why two lakes at different particle radii filter at different widths
without interfering.
# `colorGrade` post-process shader
Brightness, saturation and contrast as multipliers around an identity of 1.0.
`pp.add(name, "colorGrade", { brightness = 1.1, saturation = 0.8 })` registers it, and
`asset.resolve("@builtin::shaders.post.colorGrade", "shader"):getProperties()`
reads the knobs below before it is added.
The three run in that order on the sampled frame: brightness scales the colour,
saturation mixes it against its own Rec. 709 luminance, and contrast pushes it
away from mid grey. Each is a multiplier, so 1.0 leaves the frame as it was and
all three at 1.0 pass it through unchanged.
`color_correction` grades the same three quantities as ADDITIVE offsets around
0.0 and adds a tint colour, so a look authored for one does not transfer to the
other by copying its numbers.
| Property | Meaning |
|---|---|
| `brightness` | Multiplies the colour. |
| `saturation` | 0 is luminance alone, 1 is the frame's own colour. |
| `contrast` | Scales distance from mid grey. |
# oceans
A sea and the ground under it, as one package: the wave simulation, the surface
that renders it, the seabed it is seen through, and the answer to "how high is
the water here" that gameplay needs to float things on it.
```lua
local Ocean = require("@builtin::systems.oceans.modules.ocean")
Ocean.create({ preset = "gale" })
```
That is a whole ocean — waves out to the horizon, foam on the breaking crests, a
seabed below, and a surface that follows whoever is looking at it. To float
something on it:
```lua
local ocean = Ocean.forEntity(oceanEntityId)
boat.position = { x, ocean:sampleHeight(x, z), z }
```
It is a building block. The sun the water glitters under, the ambient its foam
sits in and the sky it reflects all come from the scene, through the same
directional light and reflection probes every other material in the engine
reads — so an ocean drops into a world that already has lighting instead of
bringing a second set, and changing the scene's light changes the water with it.
## What it does
The simulation is **spectral**: instead of animating individual waves, it builds
the sea's energy across every wavelength and direction from an oceanographic
spectrum, then inverse-transforms that into a height field. A real sea is what
comes out — wind speed alone decides wave height, and it rises with wind the way
a fully developed sea does, following the Pierson–Moskowitz law.
It runs in **cascades** — independent wave fields over different patch sizes,
each band-limited to its own range of wavelengths. A tile of roughly two
kilometres carries the long waves and one of a few hundred metres the short
ones; they sum into a surface with detail at every scale, and because the bands
are disjoint no wavelength is synthesised twice. Above them a **Gerstner bank**
adds the swell analytically, which no cascade can hold: a wave longer than its
own patch has nowhere to live, and swell arrives from a distant storm on its own
heading anyway.
**Foam** is not painted on. It comes from the Jacobian of the wave displacement:
where the surface folds over itself, the wave is breaking. Whitecaps appear
where and when the geometry actually breaks, accumulate into a persistent
world-space field, and streak behind the crest as they decay.
**Colour and transparency are the same mechanism.** Per-channel Beer–Lambert
extinction over the distance light travelled through water gives both — red dies
within a metre, blue survives tens. That is why shallow water over sand reads
turquoise and the open sea reads near-black blue without switching materials.
**The seabed** is one noise field read twice: scaled it is the terrain's relief,
unscaled it picks between sand and rock, so outcrops stand on the high ground
and sand fills the hollows. The sun's caustic web moves over both, bent by the
horizontal displacement of the water directly above it. Bodies land on it: the
pass that raises the ground writes the same field out as a height patch that
travels with the viewer, so what a body stands on is what the frame drew.
Nothing here ships an image. Every map the surface and the seabed sample — the
foam patterns, the sand, the rock, the caustics — is grown on the GPU at
startup.
## The pieces
| Asset | What it is |
|---|---|
| `components/Ocean` | Puts a sea on an entity. Its fields are the sea state; the simulation is rebuilt from them, so an ocean survives a scene reload. |
| `modules/ocean` | The substrate: `attach`, `create`, `forEntity`, `sampleHeight`, `sampleDisplacement`. |
| `components/Buoyancy` | Floats a rigid body on the sea. Its fields are the hull: how dense it is, how steady, and what box displaces water. |
| `modules/buoyancy` | The forces: `attach`, `forEntity`, `columns`, `submergedFraction`, `restingY`. |
| `modules/oceanPresets` | Nine named sea states, from `calmClear` through `gale`. |
| `modules/oceanQuality` | The four tiers — how finely a sea is resolved, as distinct from what sea it is. |
| `modules/oceanClipmap` | The camera-following ring geometry both the surface and the seabed are built on. |
| `modules/oceanFloor` | The seabed: terrain, ground materials, caustics and the ground physics collides with. |
| `modules/oceanScene` | The seam between the scene capture and the materials that sample it. |
| `shaders/oceanSurface` | The water: cascade normals, absorption, exact dielectric Fresnel, subsurface scattering, sun glitter, foam, and the view from below. |
| `shaders/oceanFloor` | The seabed: sand and rock blended by terrain noise, under a caustic web. |
| `materials/oceanWater`, `materials/oceanSeabed` | The default looks. |
| `renderFeatures/oceanScene` | Copies the frame's colour and depth so the surface can refract what is behind it and measure its own water column. |
| `computeShaders/ocean_*` | The ten-stage GPU pipeline. |
## Wind is the control
Significant wave height goes as the **square** of wind speed, so that one field
does most of the work:
| Wind | Sea | Wave height |
|---|---|---|
| 3 m/s | glassy, a mirror | ~0.2 m |
| 11 m/s | working sea, occasional whitecap | ~2.5 m |
| 24 m/s | gale, breaking and streaked | ~12 m |
Everything else shapes character rather than size: `choppiness` pinches crests
into peaks, `standingWaveRatio` runs waves both ways instead of only downwind,
`jonswapGamma` sharpens a fetch-limited sea, and the foam layers decide how
readily a breaking crest whitens and how long it streaks.
Read `documentation/oceans.guide` for the full walkthrough, including the
seabed, buoyancy, reflections and what each tier costs.
# subsurface
A metallic-roughness surface that also transports light beneath itself, for
skin, foliage, wax, marble and thin fabric.
Set `subsurface_color` to the tint of the light that scatters through; leaving
it black makes this a plain metallic-rough surface. `subsurface_radius` sets how
far light spreads beneath — larger values wrap the shading further past the
terminator, and reach further in whichever channels the colour is strongest in.
`thickness` (and `thickness_texture`) control how readily a backlit surface
glows. Thin geometry transmits; thick geometry stays solid.
Both effects are evaluated in the engine's light loop, so they are shadowed and
attenuated like every other lobe.
# billboard
Camera-facing billboard shader for textured quads — labels, sprites, and 3D
text. All four corners of the quad sit at the same anchor, so the vertex's world
position is the anchor under any entity or parent transform. Each corner carries
its in-plane offset in the vertex colour; the vertex stage offsets the corner in
world space facing whichever camera renders the pass, so the quad is visible and
correctly framed from the main viewport, an offscreen capture, and a
render-texture camera alike. Because the offset is applied in world space before
projection, the quad keeps real extent and stays drawn when the anchor leaves the
frustum. The horizontal axis stays world-level, so it tracks a pitching camera
without rolling.
The world size lives in the per-corner offset (resizing rebuilds the vertex
colours), so there are no size material properties, and the mesh declares its own
render bounds so frustum culling keeps it. The fragment mirrors `unlit`:
`base_color * base_color_texture` with masked-transparency cutoff.
# foliage.shader
Vegetation that sways with the scene's wind.
The motion comes from the engine's frame-global wind (`rendering.setWind`), so
every material reading it agrees on direction, speed and gust phase — a gust
crosses a whole field instead of each plant animating on its own clock.
`compliance` scales how far this material yields — 1 for a grass blade, 0.1 for
a trunk. The mesh's red vertex channel is how freely each vertex moves: 1 at a
blade's tip, 0 at its root, so a blade pivots from its base. A mesh with no
authored vertex colours reads white and sways as a whole.
The gust phase is a function of world position, so a gust travels across a field
rather than every instance of the same mesh swaying in unison.
With no wind set the sway is zero and the surface renders as a plain lit
material.
# `lut` post-process shader
Colour grading through a lookup table. `pp.add(name, "lut", { texture = ... })`
registers it; `@builtin::modules.lut` builds, imports and installs the tables it
reads.
`lut_texture` is a LUT **strip**: an N-entry colour cube laid out as one row of N
square tiles, so the image measures `N*N` wide by `N` tall. Tile `b` holds the
plane of constant blue index, red runs across a tile and green runs down it. N
comes from the texture's own height, so this one shader serves a 16-, 32- or
64-entry cube.
The eight lattice entries around a colour are fetched with `textureLoad` at exact
texel coordinates and interpolated in the shader. That makes the result
independent of the texture's filter and wrap mode, and each blue plane is read
from its own tile.
A texture whose shape is not a strip — including the 1×1 fallback bound when the
effect has no table — returns the frame untouched, so adding the effect before
its table exists changes nothing.
| Property | Meaning |
|---|---|
| `amount` | 0 = the ungraded frame, 1 = the table's answer. |
| `lut_texture` | The strip. |
# 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. |
### 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.
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.
## 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.
# hair.shader
A hair or fur strand.
Its specular is a pair of offset bands rather than one spot: a fibre reflects
into a cone around its own axis, so the highlights are taken around the world
tangent — which on strand geometry runs root to tip. `fibre_color` tints the
second, broader band and is what switches both on; `fibre_shift` sets how far
apart they sit and `fibre_roughness` how narrow the first one is.
Colour comes from `base_color` times the per-vertex colour, so a groom carries
its own root-to-tip gradient under one material. `transmission_color` and
`transmission` are the light that carries through a strand — the glow along a
backlit edge.
Both faces of a ribbon stand for the same round fibre, so back faces are lit
like front ones.
# `bloom` post-process shader
The light a bright surface spills into the pixels around it. A half-resolution
`bright` pass extracts what stands above `threshold`, a pyramid of `downsample`
passes carries it down to 1/64 of the view, and a chain of `upsample` passes
carries it back up, blending each octave into the one above it. `composite`
adds the result to the scene. `tools.use("pp", "add", "glow", "bloom")`
registers it — the shader declares `space: light`, so the effect takes the
scene layer: the rendered picture before the UI is drawn, over the scene's own
light in a float target, which is where a bloom belongs.
## Passes
| Pass | Scale | What it does |
|---|---|---|
| `bright` | 0.5 | Extracts what stands above `threshold` from a 2x2 block of the scene, soft-kneed by `softKnee`. |
| `down1`…`down5` | 0.25 … 0.015625 | Each halves the previous octave's resolution with a 13-tap filter; `down1` also Karis-averages its five taps to keep a single bright texel from dominating. |
| `up4`…`up0` | 0.03125 … 0.5 | Each carries the smaller octave up with a 3x3 tent and blends it into the next one by `scatter`. |
| `composite` | (view size) | Adds the bloomed image to the scene, scaled by `intensity`. |
## Properties
| Property | Default | Range | Meaning |
|---|---|---|---|
| `threshold` | 1.0 | 0–16 | The brightness the `bright` pass extracts above. |
| `softKnee` | 0.5 | 0–1 | How wide the roll-in is around `threshold`, so a surface crossing it brightens rather than pops. |
| `intensity` | 0.6 | 0–4 | How much of the bloomed image the `composite` pass adds back to the scene. |
| `scatter` | 0.7 | 0–1 | How much of each octave an `upsample` pass blends into the one above it, and so how far the halo carries. |
| `levels` | 6 | 1–8 | How many octaves of the pyramid draw. |
| `clampMax` | 16 | 0–64 | The per-texel cap the `bright` pass applies before extracting, so one blazing pixel cannot blow out its octave. |
`levels` and the 8-texel floor an octave's short side must clear are both read
per pass, not baked into the pass list: an octave past either one returns the
larger image it was handed unchanged, so a slider drag on `levels` changes what
the twelve passes DO and never how many there are.
Eleven scratch targets in the scene format, the largest at half the view —
about 11 MB at 1080p.
# PBR Parallax Occlusion
Surface shader that extends the engine's Cook-Torrance PBR pipeline with
Parallax Occlusion Mapping. Per fragment it ray-marches a height texture
in tangent space, discards pixels whose intersection escapes the [0,1]
UV tile, softly fades a horizon margin so the discard isn't a hard step,
and runs a second short ray-march toward the directional light for
self-shadowing. The rest of the fragment shader is the standard PBR you get from
`@builtin::shaders.standard` — directional + point lights + cascade
shadow map + procedural-sky IBL approximation — so a material using
this shader still receives full scene lighting.
## Domain
`surface` — bind to a `.material`.
## Files
- `shader.wgsl` — vertex + fragment shader. Naga-validated.
- `README.md` — this file.
## Texture slots
Standard PBR slots plus a sixth `height_texture`:
| `@group(1)` binding | Slot name | Notes |
|---|---|---|
| 1/2 | `base_color_texture` | albedo |
| 3/4 | `normal_texture` | tangent-space normals |
| 5/6 | `roughness_metallic_texture` | G = roughness, B = metallic (glTF) |
| 7/8 | `emissive_texture` | optional emissive |
| 9/10 | `occlusion_texture` | optional AO |
| 11/12 | `height_texture` | R = depth into surface (0 = at face, 1 = deep) |
The engine reflects @group(1) bindings via naga and builds the
bind-group layout per shader, so adding the sixth slot needs no Rust
changes.
## Parameters
The standard `StandardMaterial` uniform fields apply unchanged; the two
fields that drive POM are:
- `parallax_depth_scale: f32` — max UV offset, in UV-space units.
Useful range ~0.05–0.15. **POM is gated on this being > 0** — set
it to 0 and the shader degrades to plain textured PBR. It is also
the width of the band the tile clip trims inward from the border of
the [0,1] tile, since the march never offsets the UV further.
- `max_parallax_layer_count: f32` — ray-march iteration cap. 16 reads
as stair-stepped at oblique angles; 48–64 is the smooth-quality
band; 128 is the cost ceiling.
## Notes
- Heightmap convention: **black = at surface, white = deep**. If you
bring in a heightmap authored the other way (white = high), invert
it in your import step or you'll get inside-out parallax.
- The vertex shader is the engine-standard one; the parallax effect is
entirely in the fragment shader and uses no extra vertex attributes.
- The march runs in the forward `fragment` entry point, so a material
using this shader under a deferred camera renders as plain textured
PBR.
- For curved meshes, tangent-space POM produces mild distortion at
high curvature. For roughly-planar surfaces (walls, floors, signs)
it looks correct.
# ps1
A full PS1-style retro shader and a worked example of a **self-shading
`fragment()` shader** — one that returns the final pixel itself instead of
describing a PBR surface. It emulates the three signature traits of original
PlayStation hardware, each mapped onto a generic engine capability:
1. **Vertex snapping** (`vertex_clip`) — the integer-grid vertex wobble, snapping
NDC xy to a low-res grid set by `snap_resolution` (low = chunkier).
2. **Per-vertex (Gouraud) lighting** (`vertex_clip` writes `ClipVertex.color`) —
the PS1 lit at vertices and the rasterizer interpolated the colour; there was
no per-pixel lighting. The shader computes Lambert sun + ambient from the
world normal at each vertex and writes it into the vertex colour, which the
engine forwards into the interpolated `vertex_color` the fragment reads. Flat,
gradient-banded shading — never a per-pixel highlight.
3. **Affine texture mapping** (`FragmentData.affine_uv`) — the PS1 had no
perspective-correct UV interpolation, so textures "swim" / warp across
triangles. We sample with the engine's non-perspective-correct `affine_uv`
channel instead of `uv`, reproducing the warp exactly.
On top of those: the texture is quantized to a `tex_resolution`×`tex_resolution`
texel grid (the PS1 256×256 texture page) and the final colour is run through a
4×4 Bayer ordered dither + colour-level quantization (the PS1 banded look). No
shadow-map sampling — the PS1 had none.
## Properties
| Property | Type | Default | Meaning |
|---|---|---|---|
| `base_color` | color | `[1,1,1,1]` | Tint multiplied into the texture. |
| `snap_resolution` | float | `160` | Vertex-snap grid resolution (lower = chunkier wobble). |
| `tex_resolution` | float | `256` | Texture sampled at `tex_resolution`×`tex_resolution` texels (PS1 256×256 page). |
| `dither_amount` | float | `0.06` | Strength of the Bayer dither applied before quantization. |
| `color_levels` | float | `16` | Number of quantization levels per channel (lower = more banding). |
| `base_color_texture` | texture (white) | — | Albedo texture, sampled affinely in `fragment()`. |
## Framework capabilities this exercises
- **`ClipVertex` is a post-transform vertex-shading hook**: it carries world-space
geometry (`world_normal`, `world_position`) and the sun/ambient light context,
and writing its `color` field performs per-vertex (Gouraud) shading — the engine
forwards it to the interpolated `vertex_color`. Leave `color` untouched and the
mesh's per-vertex colour passes through.
- **`FragmentData.affine_uv`** is the primary UV interpolated *without* perspective
correction — always available alongside the perspective-correct `uv`. Sample with
it for the PS1 swim; sample with `uv` for normal mapping.
# debugBillboard
Camera-facing billboard shader for the debug overlay's icon sprites. Each
instance renders a procedural glyph (camera, light, audio, reflection
probe, player) selected by glyph id — drawn analytically in the fragment shader,
so icons stay crisp at any zoom with no texture atlas. Consumed by the
debugViz icon system.
# 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. |
Entity tint (the per-instance `Model`/`SkinnedModel` tint) is applied by the
engine after `fragment()` returns, so this shader does not handle it.
# sheen_film.shader
A metallic-roughness surface carrying two lobes the standard material does not:
a sheen lobe for fabric, and thin-film iridescence.
`sheen` scales the lobe, `sheen_color` tints it and `sheen_roughness` sets its
width — a low roughness gives satin's tight rim, a high one velvet's broad glow.
`iridescence` blends toward the thin-film response, `iridescence_thickness` (in
nanometres) sets which wavelengths interfere and so which hues appear, and
`iridescence_ior` sets how strongly the film refracts.
Both are off at their defaults, and both are evaluated by the engine's light
loop, so they are shadowed and lit like any other surface.
# loading_bar (screen shader)
The world loading-screen progress-bar fill. A horizontal ember→deep orange
gradient with a soft highlight that sweeps across it and a faint vertical gloss,
so the bar reads as actively working rather than a flat block. Animated entirely
by `engine.time`. Drawn as the `backgroundShader` fill of the progress element,
whose width is the load fraction.
# pbrReflective
`pbr`, plus a reflection the surface traces for itself against the scene already
drawn. Every property means exactly what it means on `pbr`; `screen_reflection`
scales how much of the traced reflection reaches the pixel.
Reach for this on a surface a reflection PASS cannot serve: anything in the
transparent queue — glass, a canopy, a wet sheet, a puddle. A pass traces the
surface the depth buffer holds, so it has no pixel to trace for a sheet of glass
and no normal to trace with. This fragment has both.
## Why it is a separate shader
Whether a shader reads the scene-colour binding is decided from its compiled
module, and the surface framework injects `zero::pbr` into every surface shader
in the engine. A trace reachable from there would make every material in every
scene a scene-colour reader, and the renderer would take the scene-colour copy
and route through the sampleable intermediate for every world, reflective or
not. Held in this shader, that cost lands on the scenes that asked for it.
An opaque surface does not need this: the reflections render feature traces
those in one pass, under its own settings, which is cheaper than a march per
fragment.
# deprecatedParticles (deprecated)
> **Deprecated.** This belongs to the earlier particle substrate. It stays loadable by identity and guid for content that already uses it; nothing new should use it, and search does not offer it. Particles are procgen sim nodes (`sim.spawn`, `sim.particles`, `sim.shader`) played through an effect: use `@builtin::assetTypes.effect`.
The package holds the GPU particle engine module (`engine`), the `deprecatedParticles` module over it, the `DeprecatedParticleEmitter` and `DeprecatedSimpleParticleEmitter` components, the shaders, compute shaders and materials they draw with, and a demo scene. The package's `.metadata` carries the deprecation marker, so every asset inside is deprecated.
# assetType (asset type)
The `assetType` is the **type of types** — the meta-type that every
`<typename>.assetType/` folder is an instance of, including itself. A
`<typename>.assetType/` folder declares a new asset type called
`<typename>`: its on-disk shape, its identity rule, and the structural
contract instances of the type must satisfy.
This type is self-hosting. `assetType.assetType/` conforms to its own
schema — a `type.yaml` plus a `README.md` — so the type system is
defined in terms of itself rather than a hidden engine special-case
(the same way a C compiler is written in C). Every asset in the engine,
including each `.assetType` definition, now resolves to a registered
assetType: a `.material` resolves to `material.assetType`, and
`material.assetType` resolves to `assetType.assetType`, which resolves
to itself.
## Why it exists
Assets used to be mapped to their type *implicitly*, by matching the
folder suffix against a name-keyed registry. That match carried no
stored link: nothing on a `Foo.material` recorded *which*
`material.assetType` it was written against, so two worlds shipping a
same-named type could silently disagree. Making `assetType` a real,
resolvable asset closes that gap — the link from an asset to its type is
now an explicit reference pinned in the asset's `.refs` sidecar
(`via: "asset_type"`), exactly like every other dependency.
## Where it lives
- Source: `/zero/source/.../<typename>.assetType/`
- Identity: `<typename>` (the `.assetType` suffix strips from the
identity; the folder retains the suffix on disk).
- Folder shape:
- `type.yaml` — the structural spec the validator reads. **Required.**
- `README.md` — type-level documentation. **Required.**
- `about.yaml` — three short plain-language texts saying what the type
is for (see "Say what your type is for" below). Every built-in type
ships one.
- `behavior.luau` — optional behavior/scaffolding module.
- `template/` — optional canonical placeholder body the templater
copies into a new asset of the type.
- `<name>.module/` — optional shared code the type ships to **every
instance** of it, conventionally `shared.module/` (see below).
## Shared code its instances reach (`asset.containing` + `.modules`)
A type ships code every instance of it uses by declaring the modules in
its own `behavior.luau`, as a map of tracked `require`s:
```luau
-- inside <typename>.assetType/behavior.luau
M.modules = { shared = require(".shared") } -- the sibling shared.module/
```
An instance reaches it with one location-independent line:
```luau
-- inside any foo.<typename>/init.luau
local api = asset.containing(__FILE__).modules.shared
```
`__FILE__` is the chunk's own VFS path; `asset.containing` resolves the
calling asset, and `.modules.<name>` follows the instance's pinned
`typeRef` (the type's **guid**, recorded in the instance's `.refs` as
`via = "asset_type"`) to the module its type declares. Because
resolution follows that link rather than a category name, the same line
in an instance of a different type resolves to that type's module, and
identically-named modules in two types never collide. `asset.typeRef(target)`
reads that guid for any target; `asset.resolve(asset.typeRef(target))`
gives the full `AssetRef<assetType>`. See `assetTypes/README.md`
§ "Shared code: type-owned modules" for the full mechanism.
## How to create one
Authoring a new asset type is just creating a `<typename>.assetType/`
folder under `/zero/source/`. The engine registers `<typename>` as a
category **the moment the folder is written** — no engine restart, no
Rust change, and it works for built-in (`@builtin/assetTypes/`) and
user-defined types identically. Once `<typename>` is registered,
`<name>.<typename>/` folders are recognised as assets of that type and
resolve their `asset_type` link back to this definition.
Creating a `<name>.<typename>/` folder *before* its
`<typename>.assetType/` exists fails fast: the suffix has no registered
type, so the folder can't be a valid asset. Author the type first.
## Say what your type is for (`about.yaml`)
Every type carries a small `about.yaml` beside its `type.yaml`, and the README
you write for the type documents how to author an asset of it. Both are
required, and both are meant to be written as you make the type, while you know
what it is for. `about.yaml` answers three questions in plain words, one key
each:
```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. Name the whole range
of things it covers, in the words a game maker would use for them.
- **`function`** is how it works today: what an asset of the type is made of and
what the engine does with it.
- **`usage`** is what a person or an agent does with one: how they pick it, place
it, call it, or hand it to something else.
A few habits keep it useful. Write about the type as a whole, so the text holds
for every asset of it, and leave out any one asset's name. Say what the type
does right now. Stay with words a game maker would say, and mention a file name
or a call only where a game maker would know it. The limits are in bytes:
`intent` 800, `function` 1500, `usage` 1200. Each is generous, and a few
sentences usually say it.
Why bother: agents working in a world read these three texts to learn what your
type is for, so they reach for it when it fits and use it the way you meant. People
searching find the type by what they want to make, and the three texts let them
tell at a glance whether it is the one they need.
`asset.create("assetType", name)` scaffolds the file with all three keys, and a
README whose first paragraph is a `[[describe]]` note. Each value starts with
`[[describe]]` followed by a note on what to write; replace the whole value with
your own words. A value or a README line that still starts with `[[describe]]`
counts as not written yet, which the push's check of each asset it carries against
its type reports, naming the file. A type you leave as it was is not asked, so
older types stand.
Many types at once, or one you would rather not write by hand: `services
describe` lists the types that lack these texts and what filling them costs, and
`{ mode = "all" }` writes them into the same files, where you edit them like
anything else you wrote.
Where the texts show up: `asset.inspect("<typename>", "assetType")` returns them
as `detail.about`, `assetTypeRef:getAbout()` returns them as a table with the
keys `intent`, `function` and `usage`, the type's `types/<typename>` guide page
ends with an "About this type" section holding them, and the Inspector's Type
section lists them at the top. The guide page is this README plus that section,
so a README that explains the type well and an `about.yaml` that says what it is
for read well together.
## Ask your assets to say what they are
A type can ask each of its assets to carry a description: a file, written by the
author or generated, that says what that asset is and what it is for. The people
and agents who find an asset read it to decide whether it fits, and the author
knows what it is for at the moment of making it.
**1. Declare the file with a `described` check.** This is the whole requirement:
```yaml
files:
required:
- path: "README.md" # any file name works
kind: "file"
content:
- { check: "described", message: "Say what this widget is for and where it goes." }
```
With that in place:
- An asset whose file is missing, empty, or still has a line starting with
`[[describe]]` does not meet the type's requirements. `asset.validate` names it,
`zm status` and `world.publishBlockers()` list it, and a push that carries it is
refused with `message`.
- `services describe` finds it (`mode = "find"` lists it and the price) and can
write the description for it (`mode = "all"`). It writes only this file.
For a YAML file that holds several texts, name the keys and their limits in bytes,
the way this type does for `about.yaml`:
```yaml
content:
- check: "described"
keys:
- { key: "intent", max_bytes: 800 }
- { key: "usage", max_bytes: 1200 }
message: "Write intent and usage."
```
`headings` names sections a Markdown file must have. `assetTypes/README.md` has the
full check.
**2. Optionally, steer what gets generated with a `describe:` block:**
```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>"
```
`guidance` says what a description of your type covers, and `examples` names assets
whose descriptions show the shape you want. Without the block, generation still
works from the general describing guide and the check's `message`.
An example is an asset of your type in the library whose description is a true,
written one, never a scaffold's placeholder, and never the asset being described.
When the library has none, leave `examples` empty rather than name one that is not.
A type whose assets the model cannot perceive (a sound it cannot hear, a motion it
cannot see) says so in `guidance`: the description comes only from what the asset's
own files state, such as its name and the `.metadata` where a generation service
records the prompt that made it, and when they state nothing the model answers
`cannot` with that reason. `services describe` then fails that one asset with the
reason and leaves its file as it was.
**3. Start the file unwritten in your template.** Put a heading and one line that
starts with `[[describe]]` and a short note on what to write, in
`template/<file>`, so a new asset starts unwritten rather than with text that only
looks written. Do not put example content in it: every new asset carries the
template verbatim, and the same text on many assets makes them impossible to tell
apart in search.
Types whose assets say what they are through their own content (a mesh, a texture,
a sound) leave this out: declare nothing and nothing is asked.
## The `behavior.luau` contract
`behavior.luau` is the type's code. It returns ONE table, and the keys below
are the ones the framework reads off it for EVERY type — the surface you get
by declaring them. Anything else on the table is your own: reachable by name
through `require("@builtin::assetTypes.assetType.shared.ref").loadTypeModule("<typename>")` for a
system that knows your type (`computeShader` publishes `dispatchKey` that
way), and never called by the framework.
```luau
local M = {}
M.ref = { ... } -- methods on every AssetRef of this type
M.modules = { ... } -- shared code the instances reach
M.global = {} -- reserved key; ship the empty table
function M.onCreate(name, opts) ... end
return M
```
### The tables
| Key | Shape | What the engine does with it |
|-----|-------|------------------------------|
| `M.ref` | `{ [name] = function(self, ...) }` | Every function becomes a method on every `AssetRef` of this type: `ref:name(...)`, with `self` the ref. This is how a type gives its instances an API. Two names in here are contracted — see below. |
| `M.modules` | `{ [name] = require(".sibling") }` | Shared code the type's instances reach through `asset.containing(__FILE__).modules.<name>`, resolved by the instance's pinned `typeRef` guid. See the section above. |
| `M.refShapes` | `{ [method] = "TypeName" }` | Declares the result type of a `M.ref` method so the LSP can check what a call site does with it. See the `M.refShapes` section below. |
| `M.events` | an events schema | The events an ASSET of this type fires, declared the way a component declares its own. Readers subscribe through `ref.events.<name>:connect(...)`; firing authority stays with the type. Keyed by the asset's guid, so every resolver of the same asset shares the signals and a re-resolved ref re-attaches to subscriptions already there. |
| `M.namePattern` | a Lua pattern string | The name shape `asset.create` enforces for instances of this type, replacing the default `^[A-Za-z][A-Za-z0-9_]*$`. |
| `M.global` | `{}` | Reserved. Ship the empty table. |
### The lifecycle hooks
Each is optional; a type that omits one costs nothing. The engine calls them:
| Hook | Signature | When it fires |
|------|-----------|---------------|
| `M.onCreate` | `(name, opts) -> { [filename] = contents }` | On `asset.create("<type>", name, opts)`. Returns the files that scaffold the new instance, as a map of relative path to contents; the framework writes them. The `opts` type annotation is the schema `asset.create` validates the caller's arguments against, so annotate it. |
| `M.onReplace` | `(dest, files) -> { [filename] = contents }` | On an `asset.create` that writes over an asset already at `dest`, after `onCreate`. Receives the files `onCreate` produced and returns the files to write, so a type that re-derives its content (a rig re-imported from its source file) can carry what an author added to the asset it replaces. Optional. |
| `M.onRegister` | `(self)` | Once per instance, the first time it registers: on `engine.onWorldLoaded` for instances already in the world, immediately on a live `asset.create`, and when an instance arrives after that sweep (installed, pulled, or synced from a peer). Guarded by the instance's path, so a double trigger never double-registers. An instance that leaves the runtime releases the guard, so one arriving at the same path again registers. This is what makes "write an asset into the world and it takes effect live" work for content that registers into a runtime registry. The `@builtin` library is excluded from the sweep. |
| `M.onChange` | `(ref, change)` | On every VFS write to a file INSIDE one of this type's instances. `change` is `{ path, asset, type, kind, origin }`: `kind` is `"edited"` or `"seeded"`, `origin` is `"local"` or `"remote"` (an importer runs on the originator only). **Filter on `change.path`**: the hook fires for any file under the folder, so act on the one you care about. It runs as its own task and may wait on what it reads: a `vfs.read` of bytes only the blob store holds (a binary file on the web build) fetches them. The writes to one asset reach the hook in the order they were made, each once the hook before it has finished. A re-entrancy guard suppresses writes back into the same asset routed inline from the hook, and a write the hook makes is routed on a later frame like any other, so the hook must be convergent on its own: diff the meaningful state and short-circuit while your own regeneration is in flight. |
| `M.onDelete` | `(ref)` | When an instance of this type leaves the runtime: it is deleted, or the engine swaps away from the world binding that held it. Its files are gone from `/zero/source` by then. A removal routed while the instance's `onChange` is waiting reaches `onDelete` once that hook, and every write queued behind it, has finished. On a swap the instance still exists in the world it belongs to, and a VFS write the hook makes reaches the world now bound. |
| `M.validate` | `(assetRef) -> { { code, message, severity? } }` | On `asset.validate`, after the structural `type.yaml` check, for the type's own SEMANTIC validation. `severity` defaults to `"error"`; error-severity problems flip `ok` to false, warnings do not. A hook that raises or returns a non-table is itself reported as a `validate.hook_failed` error. `world.push` runs this per user asset, so declaring it enforces your type's rules at publish time with no further wiring. |
### The two contracted `M.ref` names
Most `M.ref` methods are your type's own surface: name them what you like,
return what you like. Two are read by the engine and mean the same thing for
every type, so their shape is fixed:
- **`instantiate(self, target?, opts?) -> (root, idMap)`** — makes an instance
part of the scene. Defining it is the whole opt-in: `ref:canInstantiate()`
is true exactly when it exists. Its **return is a contract** — see the next
section.
- **`inspect(self) -> detail`** — the type-specific half of `asset.inspect`.
See its section below.
### Names a type cannot shadow
Some keys resolve on every `AssetRef` before per-type dispatch, so an `M.ref`
entry of the same name is never reached: `canInstantiate`, `getSource` /
`getBytes` / `getText`, `exists`, `deps`, `meta`, `runtime`, `events`,
`modules`, `typeRef`, and the residency flags `has_backing_asset`,
`has_runtime_changes`, `cpu_resident`, `gpu_resident`. These are the
behaviours every asset must expose identically, which is why they win.
### Reloading
A `behavior.luau` edit ripples to existing refs with no restart: dispatch
reads `mod.ref` fresh each time and `require`'s cache is re-run in place. A
type whose folder appears at runtime is picked up on the next dispatch —
negative results are not cached either.
## The names your type's assets hold (`references:`)
An asset that names another asset by a string depends on it: the named asset
travels with it into a pack or a pull, and a name nothing answers to is an
unresolved dependency `worldValidation` reports and the push gate refuses. The
engine reads `require(...)` and the asset lookups on its own; a name your type's
format or API carries anywhere else is one you declare, in `type.yaml`:
```yaml
references:
# A top-level key of this type's own files that holds a name (or a table of them).
keys:
- { key: "shader", type: "shader" }
# A call's argument, counting from 1, wherever the call is written.
args:
- { call: "g:node", position: 2, type: "procNode" }
# A field of the options table a call takes.
fields:
- { call: "fx.beam", field: "material", type: "material" }
```
`type` is the category the name resolves in, one of `asset.categories()`. An
argument declared once per category, at the same `call` and `position`, takes
the name from whichever category answers. A key or a field holds one category:
declared again with another `type`, it takes the later one. The block is
declared as the type registers, when the library or the world holding it is
laid into the VFS, so every reference table reads it whatever script has or has
not run. `asset.declareReferenceKey`, `asset.declareReferenceArg` and
`asset.declareReferenceField` declare the same things from a script, for a
surface that is no type's.
## What search does with your type (`indexing:`)
A type declares what search embeds for its instances. **This is not optional
and there is no useful default**: ZeroMind runs your declaration and adds
nothing of its own, so anything you do not point at is absent from the index —
silently, and for every instance of the type forever. Twenty types once shipped
with no `indexing:` block and every instance of them was unfindable.
A type whose instances are never searchable (a run record, a log) says so with
`indexing: none`; every other type declares a recipe.
A type without an `about.yaml` beside its `type.yaml` refuses every push that
carries it, whether or not the push edited the type. Texts in it that are not
written yet follow the rule every `described` file follows: they refuse the
push that edits, stages or commits the type.
Two words carry the whole thing, and they are the same two the search tool
exposes:
- **`identity`** — what an instance IS. For anything you can look at, that is
the picture (`content:` + `modality: image`, matched directly by a text
query). For everything else it is the authored text that says what it is.
- **`capability`** — what it DOES and how it is made. Code, settings, the
model-written summary of them.
A type with no behaviour has no `capability` entry. A type with nothing to look
at has no image. Leaving a slot empty is a statement; filling it with whatever
happens to be lying around is not.
The one you have to decide when authoring a type is: *for an instance of this,
what is the thing a person would recognise it by, and what is the thing it
does?* A texture answers "the image" and "its compression settings". A module
answers "its README" and "its code". Write those two answers into `indexing:`
and the rest follows.
Three specifics worth knowing before you write one:
- **`derive` is instructed by this type's own `README.md`.** A model reads an
instance's source and writes a description; what it embeds is the
description, not the source. It follows your README to know what it is
looking at, so a type whose README says what its instances are gets good
derivations for free. There is no prompt to name.
- **`derive` loses detail.** It keeps the main technique and drops secondary
ones. If the source is code you want searchable by its own vocabulary,
declare the same files a second time as `capability` with
`extractor: verbatim`. Two entries, same role, different failure modes.
- **Never `source: { field: name }`.** A filename is one or two words with no
usable embedding, and a corpus indexed that way answers "language runtime"
with `say_runtime_2.soundClip`.
`facets:` is the second stage: a `file:` embedded whole (`.metadata`, so keys
nobody declared still get indexed) plus computed keys that state a value for
every instance including `false` — the raw file can imply "not rigged" and can
never say it.
The full schema, every extractor and the facet sources are in
[`assetTypes/README.md`](../README.md); the scaffolded block with inline
guidance is in [`template/type.yaml`](template/type.yaml).
## A container whose contents work together (`system: true`)
A type that is a container can say its contents are one working whole by
declaring `system: true` at the top level of its `type.yaml`. `package` declares
it: the components, modules, scenes and tools a package ships are parts of one
system. The declaration is plain data, read from the decoded `type.yaml`
(`require("@builtin::assetTypes.assetType.shared.declaration").of("package").system`),
and a type that leaves it out is an ordinary type.
## What your type looks like (`preview:`)
A type declares how its instances are pictured, and the engine makes the
picture when something needs it: staging an instance whose `preview.png` is
missing or pictures content it no longer holds queues a render, a view that
shows the instance asks for one, and `ref:preview()` renders one on demand. An
edit renders nothing on its own. Each `preview.png` records the content it
pictured, which is how a stale one is told from a current one. The preview studio owns the light, the
backdrop, the framing and the camera, and nothing in the world reaches it, so
an instance pictures the same in every world. Declare one of four recipes:
| `kind` | The picture is | Your type provides |
|---|---|---|
| `instantiate` | the asset stood in the studio through its own `instantiate` | `ref.instantiate` |
| `image` | a file the instance holds, fitted to the frame, nothing rendered | `source.files`, the candidate files in order |
| `surface` | the asset painted on a builtin mesh | `mesh`, and `ref.previewMaterial(self) -> { material }` |
| `custom` | whatever `ref.preview(self, opts)` returns | `ref.preview` |
```yaml
preview:
kind: surface
mesh: sphere
```
A scene-instantiable type that declares nothing still answers `ref:preview()`
through its `instantiate`, but persists no picture: the declaration is what
makes the engine keep one. List `preview.png` among the type's files so an
instance may hold it.
The picture is findable only when `indexing.content` names it. The two are
separate statements, and neither is derived from the other:
```yaml
indexing:
content:
- role: identity
modality: image
source: { file: "preview.png" }
```
`@builtin::assetTypes.assetType.shared.previewRecipe` reads the declaration;
the scaffolded block with inline guidance is in
[`template/type.yaml`](template/type.yaml).
## Typing a result from the instance (`M.refShapes`)
Every method on `M.ref` is checked against the type its `--!return` names,
and every asset of the type gets the same one. That is right for most
methods and wrong for the ones whose result is shaped by the ASSET: a
method answering one entry per child folder, per row of a config, per
binding an input map declares. The widest true annotation for those is
`{ [string]: Thing }`, and an indexer accepts every key — so a caller's
typo reads as valid and nothing reports.
`M.refShapes` lets the type state the result per instance. Each entry is
`function(self) -> (typeExpression, source?)`: a Luau type expression for
THIS asset, and the module whose type vocabulary the expression uses.
Read the asset; never run it — stating a type must not take effect.
```lua
-- inputMap.assetType/behavior.luau — the shipped example.
-- `activate()` hands back one handle per control the map declares.
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)` resolves and `map.noexisting` is
reported with the map's real controls. Without it, both pass silently.
### What the call site has to say
A per-instance type is matched by the asset's IDENTITY, so it applies only
where the reference the method is called on says WHICH asset. Two spellings
do:
```lua
-- a component field, whose declared default names the asset
public = { map = Field.assetRef("inputMap", "@builtin::inputMaps.default", Sync) }
local controls = public.map:activate() -- typed for THAT map
-- an annotation, naming category and identity
local m: AssetRef<"inputMap", "@builtin::inputMaps.default">
```
`asset.resolve("<identity>", "<category>")` carries the CATEGORY, so the
methods the category defines are checked on the result — but not which
asset it found, so a per-instance result keeps the category's declared
return. A reference that names no asset (`asset.resolve(someVariable)`, a
value passed in as a parameter) carries neither, and calls on it are
unchecked. That is the same rule everywhere: the checker states what the
code states, and a name computed at runtime states nothing.
### The result is a value, not a binding
The type belongs to the expression, so it travels the way any inferred type
travels. Indexing the call's result is checked; stashing it in a module
local and indexing it from another function is not, because the local's
declared type is what carries across — and `any` (or no annotation) carries
nothing:
```lua
-- checked: `d.greting` is reported
function awake()
local d = public.dialogue:lines()
d.greeting:say()
end
-- NOT checked: `lines` is a module local typed `any`
local lines: any = nil
function awake() lines = public.dialogue:lines() end
function start() lines.greting:say() end
```
Bind what you need at the call site and the names stay checked in both
places — the asset-derived type at the boundary, ordinary scoping after it:
```lua
local lineGreeting = nil
function awake()
local d = public.dialogue:lines()
lineGreeting = d.greeting -- `d.greting` is reported here
end
function start() lineGreeting:say() end
```
### When nothing is reported and you expected something
From a call site, a type that is correct and a type that was never published
look the same: no diagnostic either way.
`require("@builtin::assetTypes.assetType.shared.refShapes").published()` answers which it
is. It returns two maps — the category surfaces, and the per-asset results
keyed by identity and then by method:
```lua
local shapes = require("@builtin::assetTypes.assetType.shared.refShapes").published()
shapes.categories.dialogue
--> "{ lines: () -> any, lineNames: () -> { string } }"
shapes.returns.Shopkeeper.lines
--> "{ browsing: Line, farewell: Line, greeting: Line, wares: Line }"
```
An identity absent from `returns` was never published for, which is a
different thing from published-and-correct — and the reason to look here
rather than at the call site.
The answers are recomputed when an instance is written, so a control added
to a map reaches the checker with no restart. Only types that declare
`refShapes` are read per instance, and at most 64 assets of one type are —
past that the type keeps its declared return and the engine log names what
was dropped.
## The instantiate hook (`M.ref.instantiate`) — a contracted return
A type opts into becoming part of the scene by defining `instantiate` on its
`ref` table. That is the whole opt-in: `ref:canInstantiate()` is true exactly
when the hook exists, so consumers offer a scene path — an `Asset.source`
field, a viewport drop, a tool argument — by CAPABILITY rather than by a list
of type names.
Unlike `inspect`, this hook's **return is a contract**. A caller writes one
piece of code against every instantiable type, so what comes back cannot vary
by type:
```lua
-- behavior.luau
local Instantiable = require("@builtin::assetTypes.assetType.shared.instantiable")
local M = {}
M.ref = {
instantiate = function(self, target, opts)
local root = Instantiable.root(self, target, opts) -- IN: the base opts
-- ... compose whatever this type is, under `root`, NOW ...
return Instantiable.result(self, root, idMap) -- OUT: the contract
end,
}
return M
```
**IN.** `target` is an owning entity ref: the instance lands under (or, for a
hierarchy type, onto) that owner. With no target the type spawns a fresh root.
`position`, `rotation`, `scale`, `name` and `temporary` mean the same for every
type — `Instantiable.root` applies them to a root you mint, `Instantiable.place`
to one you adopt, so you never re-read the spec. Honour more opts of your own if
your type needs them, and document them in the hook's `--!arg` docs.
**OUT.** Return `(root, idMap)` through `Instantiable.result`:
- **`root`** — the composed root, as an `EntityRef`. Composition is
**synchronous**: everything your type builds is live when you return, so the
caller can parent to it and read its components in the same statement. Do not
defer the work to a component's `awake` and return an empty shell — a root
that is not live is refused.
- **`idMap`** — the `originalId -> runtimeId` map naming what you spawned, or
nil for a type with no addressable children (`result` normalises it to `{}`).
**A caller never receives nil.** A component that re-composes your asset on
every load keeps this map and passes it back in, which is how a cross-entity
reference into the composition survives a reload.
`AssetRef` runs every `instantiate` through `result` on the way out whether or
not your type called it, so returning something else fails at your own call
rather than handing a caller a nil root. Calling it yourself is still how you
say what you return, and running it twice changes nothing.
`@builtin::assetTypes.assetType.shared.instantiable`'s README is the full reference for both halves.
## The inspect hook (`M.ref.inspect`)
`asset.inspect(ref)` resolves `ref`, builds a common envelope shared by
every asset (`identity`, `name`, `guid`, `source`, `typeName`,
`typeDefinitionPath`, `scope`, `origin`, `description`, `tags`), then
calls the resolved type's own inspect hook — `M.ref.inspect(self)` on
`behavior.luau` — for the type-specific `detail`. The hook returns
*only* `detail`; the framework fills in everything else:
```lua
-- behavior.luau
local M = {}
M.ref = {
inspect = function(self)
-- self.path is the asset's own folder. Parse the asset's OWN
-- files — source text, a native payload header, its `.metadata`
-- — never runtime state (a live component instance, a GPU
-- upload, a compiled schema): inspect must work on an asset that
-- hasn't been compiled, uploaded, or registered yet.
return {
-- type-specific fields, whatever this type wants to surface
}
end,
}
return M
```
A type that omits `M.ref.inspect` yields the generic record: the
envelope alone, `detail = nil`. That's a valid, working state — not an
error — but it means agents calling `asset.inspect` /
`tools.use("assets","describe", ...)` on an instance of the type see
only identity and scope, none of what makes an instance of it
meaningful. Declaring the hook is what makes a type's instances
inspectable for what they actually are.
A hook that raises leaves `detail = nil` and sets `record.warning` to
the error text — `asset.inspect` itself never raises for a resolved
ref.
## Discovery
- `asset.list("assetType")` — every registered asset type.
- `asset.inspect("<typename>")` — the type's identity, source path, and
this README, plus its `about.yaml` texts as `detail.about`.
- `asset.resolve("<typename>", "assetType")` — the `<typename>.assetType`
asset itself (used by the validator to find the type's `type.yaml`).
## Related
- `assetTypes/README.md` — the full `type.yaml` schema and validator
rules.
- `@builtin::assetTypes.assetType.shared.instantiable` — the instantiation contract's shared
implementation, both halves.
- Every other `*.assetType/` here — the concrete types built on this
meta-type.
## The Inspector a type ships
Every type names the file its Inspector sections come from with `inspector: { type: "inspector.luau" }` in its type.yaml. `asset.create("assetType", name)` scaffolds the block and an `inspector.luau` whose Contents section lists an asset's files, built from `ctx.parts`. `asset.validate` on a type that names none reports `assetType.inspector_required` (an error for a `@builtin` type the engine's library authors; a warning for a world's own, and for one the library carries by reference, which the world that published it answers for), and one whose file does not load `assetType.inspector_invalid`.
## In the Inspector
Type: what the type.yaml declares (suffix, container, primary files, the settings and inspector blocks), whether the type ships a behavior, how many instances it has, and **Open** for its files. Schema: the settings every instance carries, as read-only typed controls at their defaults (a type whose schema is per instance says where it is declared). Instances: a link to each of the first 20 assets of the type, and how many there are when there are more.
·renderfeature · born here
❒asset # cascades render feature
Draws the `cascades` capture view: which directional-shadow cascade each pixel
samples.
A geometry pass over `@geometry.opaque` replaces every surface's own material
with the cascade shader, which asks `zshadow_select_cascade` — the same function
the lit path calls — for the cascade covering that fragment and emits one flat
band colour for it. Because the colour comes from the selection itself, a split
that moves on screen is a split that moved in the renderer.
Bands: red cascade 0 (nearest), green 1, blue 2, yellow 3, grey past the last
split. Where the shadow term fades out over the tail of the range
(`fadeFraction`), the band mixes toward that grey over exactly that tail, so the
gradient on screen is the fade; with `fadeFraction = 0` the boundary is hard.
Magenta everywhere means no cascade data reached the frame: the shadow pass is
off, or the scene has no directional light casting into it.
Brightness carries the surface normal so the scene stays readable; the hue is
the cascade and nothing else changes it.
The pass carries the view's debug channel, so it does not draw for a camera that
is not a capture of `pass = "cascades"`. It tests `lessEqual` against the depth
the opaque pass already wrote and writes none of its own. The feature is created
on demand by `@builtin::systems.renderDiagnostics.renderDiagnostics`.
# countingView module
Builds a camera view that counts, per pixel, how many fragments satisfy a depth
predicate, and colours the count. `overdraw` and `zfighting` are both instances
of it; a diagnostic of your own is one more.
```luau
local CountingView = require("@builtin::systems.renderDiagnostics.countingView")
return CountingView.new({
view = "myView", -- the name `capture pass=myView` selects
geometry = "@geometry.all", -- which drawables are counted
depthCompare = "always", -- which of their fragments count
levels = 16, -- counts that fit before the sum saturates
mapShader = asset.resolve("~.shaders.myViewMap", "shader"),
})
```
The return value is a `.renderFeature` module — `render` enqueues the view's
passes, `teardown` releases them — so a feature's whole `init.luau` is this one
call. Register the name separately with `renderer.captureView.register` so
`capture pass=<name>` resolves to it.
## The two knobs that decide the question
`depthCompare` runs against the scene depth the frame already resolved:
- `"always"` counts every fragment the rasterizer produced, hidden ones
included — the shading work a pixel costs.
- `"lessEqual"` counts only fragments reaching the winning depth — one per
pixel, unless surfaces are close enough that the depth buffer cannot tell them
apart.
`geometry` narrows what is counted: `"@geometry.all"`, `"@geometry.opaque"` or
`"@geometry.transparent"`.
## Reading the count
`levels` is how many counts fit before the accumulated sum saturates at 1.0, so
a view's open-ended "this many or more" band belongs at or below that number.
`mapShader` is a `@domain: post-process` `.shader` asset declaring
`fn zero_count_color(n: i32) -> vec3<f32>` — the view's reading of the decoded
count, `0` where nothing was counted — over the shared decode body:
```wgsl
// @domain: post-process
fn zero_count_color(n: i32) -> vec3<f32> {
...
}
#include "~.shaders.countMap"
```
Flat, nameable bands read better from a still than a continuous ramp: the
question is which band a region falls in, not how bright it is.
·renderfeature · born here
❒asset # overdraw render feature
Draws the `overdraw` capture view: how many fragments each pixel cost.
A geometry pass over `@geometry.all` blends a whole unit additively into a
private float target with the depth test switched off, so each pixel accumulates
one unit per fragment that rasterized over it — the fragments a depth test would
reject included, because they cost the same shading. A full-screen pass then
reads the count and writes one flat band colour per layer count.
Bands: black 0, blue 1, cyan 2, green 3, yellow 4, orange 5-6, red 7-9,
white 10+.
Both passes carry the view's debug channel, so neither draws for a camera that
is not a capture of `pass = "overdraw"`. The feature is created on demand by
`@builtin::systems.renderDiagnostics.renderDiagnostics`.
# renderDiagnostics module
Publishes the `overdraw` and `zfighting` capture views, so `capture pass=<name>`
resolves to them. Requiring the module registers both.
```luau
local diagnostics = require("@builtin::systems.renderDiagnostics.renderDiagnostics")
diagnostics.register() -- idempotent; already done by the require
diagnostics.unregister() -- withdraw both views and drop their features
```
Registration is cheap and permanent: each view's passes are gated to the debug
channel the registration hands out, so they are skipped for every camera that is
not a capture selecting that view. The feature that draws a view is created the
first time it is selected, through the registration's `ensure`.
`renderer.captureView.list()` enumerates every registered view, and the package
readme has what each colour band means.
# countMap
The decode a counting camera view reads its result through: it recovers a
per-pixel count from the fraction a counting geometry pass accumulated, and
calls the view's own reading of that count.
A counting view blends `1 / levels` additively for every fragment that satisfies
its depth predicate, so its target holds `count / levels`. This body reads that
fraction back out of the scene colour, multiplies by the `levels` the pass used
— carried in `engine.params.x` — and rounds to the whole count.
## Using it
The including shader declares the view's reading of a count and includes this
body:
```wgsl
// @domain: post-process
fn zero_count_color(n: i32) -> vec3<f32> {
if (n <= 0) { return vec3<f32>(0.0, 0.0, 0.0); }
return vec3<f32>(1.0, 1.0, 1.0);
}
#include "~.shaders.countMap"
```
`n` is 0 where nothing was counted, and saturates at `levels` — so a view's
open-ended "this many or more" band belongs at or below that number. Flat,
nameable bands read better from a still than a continuous ramp: the question is
which band a region falls in, not how bright it is.
·renderfeature · born here
❒asset # zfighting render feature
Draws the `zfighting` capture view: where two opaque surfaces sit too close
together for the depth buffer to keep them apart.
A full-screen fill blacks out the lit scene, a geometry pass over
`@geometry.opaque` re-runs the depth test the scene already resolved
(`lessEqual`, writing no depth of its own) and blends a quarter-unit additively
into the scene target, and a full-screen pass turns the resulting count into a
verdict colour. The count accumulates into the scene target rather than a
private one because only that target carries the scene's depth buffer, and the
depth test is the whole measurement.
The counting pass carries a depth bias, which is what makes the measurement
match what the eye sees. Unbiased, the test is exact to the last step the buffer
holds, so it counts a second surface only where the two land on the identical
value — a contour through the fight rather than the fight. The bias widens the
test to a band 16 steps deep, plus four times the fragment's depth gradient, so
every surface close enough for the winner to change hands is counted. Stating
the band in depth-buffer steps keeps it honest at range: the steps grow with
distance and so does the fight.
Verdict: near-black no opaque surface, dark grey one surface (healthy), red two
surfaces the buffer cannot separate, white three or more.
All three passes carry the view's debug channel, so none draws for a camera that
is not a capture of `pass = "zfighting"`. The feature is created on demand by
`@builtin::systems.renderDiagnostics.renderDiagnostics`.
# Package (asset type)
A package is an asset — a `<Name>.package/` folder that bundles
components, modules, scenes, presets, tools, and any other typed
sub-assets under one identity, installed into a world as one unit.
Packages are how authoring sets get distributed across worlds.
A **library** is the addressable root scope content resolves under
(`@builtin`, `@<name>`) — a whole world mounted into this one under a
namespace, via `zeromind.installLib` / `world.installLibrary`. A package is
an asset that lives inside such a root, and one library holds as many
packages as its author put there. A package is installed by guid the
way every asset is (`zeromind.install`, `zeromind.installAsset`).
## When to use one
- You're authoring a coherent feature suite (character controller
stack, voxel engine, UI kit, animation library) and want to ship
it as one install target.
- You want a stable identity worlds can install and resolve sub-assets
against (`@<lib>::pkg.sub_module`).
- You want versioning + a per-package metadata layer that travels with
the contents.
If you're authoring inside a single world and don't need a separate
install lifecycle, you don't need a package — drop your `.component`
/ `.module` / etc. directly in `/zero/source/`. Packages are for
cross-world distribution.
## Where it lives
- Source: `/zero/source/.../<Name>.package/` (or, for legacy
packages, a folder containing `package.yaml` at its root).
- Identity: `<Name>` (the `.package` suffix collapses in identity —
mirrors `.module`). A file at `<Name>.package/<sub>/init.luau`
resolves under the same identity it would at `<Name>/<sub>/init.luau`.
- Folder shape (container-style — open child set):
- `package.yaml` — metadata (name, version, description).
**Required.**
- `README.md` — instance documentation. **Required.**
- `.metadata` — agent-editable tags + free-form fields. **Required.**
- Sub-asset folders: any combination of `.component/`,
`.module/`, `.scene/`, `.preset/`, `.tool/`, `.toolbox/`,
`.material/`, `.shader/`, etc.
The validator permits unlisted children (`allow_unlisted: true`) by
design — packages are open containers.
## How to create one
```luau
asset.create("package", "<Name>")
-- Creates: /zero/source/<Name>.package/
-- package.yaml (metadata scaffold)
-- README.md (instance README template)
-- `folder` places it in a subfolder of /source instead of the root:
asset.create("package", "<Name>", { folder = "systems" })
```
Then scaffold sub-assets inside it:
```luau
local pkg = asset.resolve("<Name>.package")
asset.create("component", "Spinner", { into = pkg })
asset.create("module", "util", { into = pkg })
```
## How it operates
1. **Registration.** Writing `package.yaml` indexes the package with
the package registry.
2. **Install.** A package is installed by guid, the way every asset
is: `zeromind.install` from the shared library, or
`tools.use("zeromind", "installAsset", <guid>)` inside the engine. The
install writes the package's files (and the deps its `.refs`
record) into this world's source, so its sub-assets resolve here.
`tools.use("zeromind", "preview", <guid>)` reports what an install would
write before it writes anything.
3. **Sub-asset resolution.** Because `.package` collapses in
identity, sub-asset paths work whether the package folder is
suffixed (`<Name>.package/<sub>`) or not (`<Name>/<sub>`).
4. **Updates.** `tools.use("zeromind", "updates")` lists installed content
with upstream changes available; re-running the install pulls them.
5. **Metadata.** `package.yaml::name` overrides the folder stem if
present (rare); `version` + `description` are surfaced in the
package index.
6. **Hot reload.** Edits to sub-assets hot-reload as normal; edits to
`package.yaml` reload package metadata.
## Discovery
- `asset.list("package")` — every registered package.
- `asset.inspect("<name>")` — sub-asset catalog, source, this type
README.
- `ls /zero/source/<Name>.package` lists the files the folder holds.
- `library.list()` — the library assets registered in this world,
including the ones packages under an installed library contribute.
## Authoring conventions
- Name packages by their feature (`voxels`, `cameraRigs`,
`dialogSystem`), not by their owner — the host library is the
`@<lib>::` scope prefix.
- Cluster cohesive sub-assets together. A "character controller"
package should ship the controller component, its preset configs,
its supporting modules, and a demo scene — not just the component.
- Document the package's surface in `README.md` — what install
installs, what each sub-asset does, how they relate. Agents reading
`cat /zero/source/<Name>.package/README.md` get the full picture in one shot.
- Version conservatively — breaking a package means breaking every
world that installed it. Use semantic version bumps.
## Common pitfalls
- **Missing `package.yaml`.** Without it the folder isn't recognized
as a package even if it has a `.package/` suffix.
- **Dependency installs.** A package that depends on another package
must declare it (in `package.yaml::dependencies` once supported)
and the parent world must install dependencies in order.
- **Identity collapse.** Sub-asset paths skip the `.package` suffix.
Don't include `.package` in `require` strings.
## Referencing inside a package: the `~` rule
`~` means **this package**, resolved structurally from the referring file's own
location. It is the only package-specific syntax. It is rename-safe (no package
name is ever written) and library-agnostic (it never names the host library, so
it survives `@builtin` being renamed or the package being mounted elsewhere).
`~.tail` maps to `<pkg>.package/tail` from anywhere inside the package.
| Referencing from | Use | Example |
|---|---|---|
| An asset/module **inside this package** | `~.tail` | `require("~.modules.myThing")`, `shader: "~.shaders.myThingBlend"` |
| A **different package** under the same root | root-relative identity | `require("@root::systems.otherPkg.modules.x")` |
| A **different library's** public API (a deliberate external dependency) | `@otherlib::…` | `require("@physics::modules.rigidbody")` |
**Never write `@builtin::` (or any host-library name) to reference your own
package** — that couples the package to the library it currently lives in and
breaks if the library is renamed or the files move. Use `~.`. It works from
Luau `require()` and from asset-reference strings in data files (a material's
`mat.yaml` `shader:` field, a component's default asset ref); the engine
resolves it against the referring file's own package. `asset.resolve` takes a
`~.` name the same way, against the package of the file that calls it:
`asset.resolve("~.materials.myThingStandard", "material")`.
`~.` is the package-relative self-reference; `@root::` is the root-relative one
(the library root, or the world source tree for non-library content). The
`core/requiring-modules` guide covers the root model in full.
A package that ships substantial functionality should include a `.guide` asset
(a `<name>.guide/guide.md` folder anywhere under the package); the `guides` tool
discovers it live, so collaborators find the system the way they find built-in
guides. A package's test suite reaches the shared `Test` framework through its
enclosing asset: `local Test = asset.containing(__FILE__).modules.shared`
(`asset.containing` returns the deepest enclosing typed asset, the `.testSuite`,
not the outer `.package`).
## Related types
- Any of `.component`, `.module`, `.scene`, `.preset`, `.tool`,
`.toolbox`, `.material`, `.shader`, `.style`, `.service`,
`.importer` — packages bundle these.
## In the Inspector
Package: the name, version and description package.yaml declares, how many things the package holds and what they are, and **Open** for package.yaml. A package.yaml that does not decode is listed under Problems with the decoder's error.
# SubsurfaceScattering
Marks an entity's meshes as carrying light beneath the surface. The terminator
softens and warms instead of ending on a line, and light spreads through a thin
edge instead of stopping at it.
```lua
entity(headId).component.add("SubsurfaceScattering", {
color = { 1.0, 0.3, 0.16 },
radius = 0.02,
})
```
Every field is live — editing one in the inspector moves the shading on the next
frame, and the values persist with the scene. Removing the component stops the
entity scattering, and the pass is released with the last marked entity.
| field | effect |
|---|---|
| `color` | What light becomes beneath the surface. Its channels also set how far each one travels, so a tint weighted to red spreads red furthest. |
| `radius` | How far light travels beneath the surface before leaving, in world units over [0, 1]. Skin at human scale sits near 0.02. |
| `strength` | How much of the spread is mixed back into the frame, in [0, 1]. 0 leaves the shading exactly as it was lit. |
A radius below zero is reported as a warning and the last good setting stays
live, so a typo in the inspector does not stop the component.
Code that marks meshes at runtime can drive the same settings through
`@builtin::systems.subsurfaceScattering.subsurface`.
·renderfeature · born here
❒asset # sssDiffusion
The render pass behind the `SubsurfaceScattering` component and the `subsurface`
module.
The transport it draws is a spread of exitant light across a surface, so it runs
on the frame that was just drawn. Each marked mesh is drawn into a mask carrying
its tint and radius — one draw per group of meshes sharing both. Two passes then
spread the lit colour along that mask, once across and once down; two lines
rather than a disc is what makes the reach cost twice the taps instead of their
square. A final pass mixes the result back by `strength`.
Weights are a Gaussian in units of each channel's own reach, and the reach is
the tint: a channel the surface passes freely is the one that travels furthest,
which is what carries red past the terminator while green and blue stop at it.
Taps are confined to the mask and to a depth tolerance, so light spreads across
one surface and stops at its silhouette rather than dragging the background into
it. The radius is world-space, converted to a pixel span from each pixel's own
depth, so a surface keeps the same softness as the camera moves toward it.
Depth and camera come from the render target being drawn, so the pass is correct
in offscreen captures and RTT cameras at their own resolutions.
# sssMask
Fills a marked mesh's silhouette with the scattering it was authored with: the
tint in RGB, and in alpha the world-space distance light travels beneath the
surface before it leaves, over [0, 1]. Alpha above zero is what marks a pixel as
scattering at all, so a target cleared to zero reads as "no subsurface here".
A geometry pass draws through a material pipeline built for the frame's own
colour format, which is why the mask is 8-bit rather than float. One group holds
one radius, so that quantisation lands on the authored value rather than varying
across a surface.
One material carries one group's values and `sssDiffusion.renderFeature` issues
one draw per group, so meshes scattering differently keep their own numbers.
| property | meaning |
|---|---|
| `scatter_color` | The tint, and per channel how far that channel travels. |
| `scatter_radius` | The distance light travels beneath the surface, in world units. |
·computeshader · born here
❒asset # sss_across
Blurs subsurface diffusion across the image — one half of the separable pair.
The body is `sss_diffuse`; this shader supplies the axis.
## Includes
- `sss_diffuse`
## Bindings
| name | kind |
|------|------|
| `sss_src` | `texture2d` |
| `sss_mask` | `texture2d` |
| `scene_depth` | `texture_depth` |
| `frame_cam` | `buffer` |
| `sss_params` | `buffer` |
| `sss_out` | `storage2d (rgba16f)` |
·computeshader · born here
❒asset # sss_composite
Composites the diffused subsurface term back over the lit scene.
## Bindings
| name | kind |
|------|------|
| `scene_color` | `texture2d` |
| `sss_diffused` | `texture2d` |
| `sss_mask` | `texture2d` |
| `sss_params` | `buffer` |
| `composite_out` | `storage2d (rgba16f)` |
# sss_diffuse
One separable subsurface-diffusion blur. The including shader defines `AXIS` —
the direction this pass blurs along — so the same body serves the horizontal and
the vertical halves of the separable pair.
```wgsl
const AXIS: vec2<f32> = vec2<f32>(1.0, 0.0);
#include "@builtin::systems.subsurfaceScattering.sss_diffuse"
```
·computeshader · born here
❒asset # sss_down
Blurs subsurface diffusion down the image — one half of the separable pair.
The body is `sss_diffuse`; this shader supplies the axis.
## Includes
- `sss_diffuse`
## Bindings
| name | kind |
|------|------|
| `sss_src` | `texture2d` |
| `sss_mask` | `texture2d` |
| `scene_depth` | `texture_depth` |
| `frame_cam` | `buffer` |
| `sss_params` | `buffer` |
| `sss_out` | `storage2d (rgba16f)` |
# subsurface
Light that enters a surface at one point and leaves at another — the transport
that softens and warms the terminator on skin.
```lua
local subsurface = require("@builtin::systems.subsurfaceScattering.subsurface")
subsurface.mark(headId, { color = { 1, 0.3, 0.16 }, radius = 0.02 })
subsurface.unmark(headId) --> true when it had been marked
subsurface.marked(headId) --> { color, radius }, or nil
subsurface.list() --> every entity scattering, sorted
subsurface.groups() --> the marked set gathered by what it scatters
subsurface.setStrength(0.7) --> how much of the spread reaches the frame
subsurface.active() --> is the pass running
subsurface.clear() --> unmark everything, release the pass
```
`mark` on an entity that is already marked replaces what it scatters. A radius
below zero is an error rather than a silent clamp, and an entity that does not
exist is an error rather than a mark that never draws. The pass is created with
the first mark and released with the last.
`groups` is what the render feature draws from: meshes sharing a tint and a
radius are one draw, so per-entity scattering costs one pass per distinct
setting rather than one per mesh. It also drops entities that have since been
despawned, so a removed character does not keep its group alive.
For scattering a scene should simply have, author the `SubsurfaceScattering`
component instead — same settings, persists with the scene, edits live in the
inspector.
The full description of the knobs and how the passes work is in the package
readme: `guides { path = "systems/subsurfaceScattering" }`.
# ParticipatingMedium
Marks its entity as a volume of fog, smoke, dust or steam — something light has
to travel through. What is behind the volume comes out dimmer than what is
beside it, and overlapping volumes stack.
```luau
local id = entity.spawn("fog_bank").id
entity(id).component.add("ParticipatingMedium", {
sizeX = 30, sizeY = 8, sizeZ = 30,
density = 0.15,
})
```
## Fields
| Field | Meaning |
|---|---|
| `shape` | `"box"` (default) or `"sphere"` |
| `density` | Extinction per world unit. `0` releases the medium. |
| `sizeX` / `sizeY` / `sizeZ` | Half-extents, for a box |
| `radius` | Radius, for a sphere |
`density` is the physical knob: a ray crossing `d` world units of a medium of
density `k` leaves with `exp(-k * d)` of the light it entered with. A 20-unit
bank at `0.15` therefore passes about 5% of the light straight through it.
The entity's position places the volume; move the entity and the shadow moves
with it.
## Notes
- The light-space map is sized to the bounding sphere of everything registered.
A small bank sharing a scene with a very large one lands on few texels — set
`extent` and `centre` through `mediaTransmittance.set` to bias the map toward
the region you care about.
- Attenuation applies to surfaces. The sky keeps its own colour.
- The attenuated light is the sun's: `sunDirection` sets which way the map's
rays travel.
See `mediaTransmittance` for the scene-wide settings and the direct API.
# Volume
Per-entity attachment that renders a 3D voxel texture (cloud, smoke,
nebula, imported VDB grid) into the scene as a volumetric body. The
component does NOT own the volume's lifetime — producers (the
`volume` module, a custom compute fill, or `compute.writeTexture3D`)
build the texture and pass its name in `volumeName`. The entity's
world transform defines the AABB the raymarcher samples.
`volumeName` is resolved through `Volume.get`, so a volume the `volume`
module built is rendered as it stands — at its own dimensions, from its own
density channel, through its occupancy grid, and out of its brick pool if it
is paged. A name no volume answers to is taken as a bare 3D texture some
other system owns, and `occupancyName` / `isRgba` describe it.
## Usage
```luau
local Volume = require("@builtin::systems.volumetrics.volume")
local cloud = Volume.noise({ name = "cloud", size = 64, scale = 4 })
cloud:buildOccupancy({ brickSize = 8 })
local e = entity.spawn("cloud_entity")
e.localScale.set(8, 4, 8)
e.component.add("Volume", {
volumeName = "cloud",
occupancyName = cloud.occupancyName,
density = 6.0,
})
```
A paged volume costs what it holds and is named the same way:
```luau
local cloud = Volume.noise({ name = "cloud", size = 128, threshold = 0.6 })
cloud:buildSparse({ brickSize = 8, threshold = 0.01 })
cloud:releaseDense() -- the brick pool is now the only copy
local e = entity.spawn("cloud_entity")
entity(e.id).localScale = { 8, 4, 8 }
e.component.add("Volume", { volumeName = "cloud", density = 6.0 })
```
## Public fields
| Field | Type | Sync | Default | Meaning |
|---|---|---|---|---|
| `volumeName` | string | Sync | `""` | Required. Name of the volume, or of a bare 3D texture, to render. |
| `occupancyName` | string | Sync | `""` | R8 occupancy texture for a bare texture's empty-space skipping. Empty = no occupancy. A volume the module built carries its own, and a name set against one is reported as left aside. |
| `density` | number | Sync | `1.0` | Density gain multiplier. |
| `absorption` | number | Sync | `1.0` | Beer-Lambert absorption coefficient. |
| `emission` | number | Sync | `1.0` | Emission gain. |
| `color` | color | Sync | `{1,1,1,0}` | RGB tint + density floor in alpha. |
| `stepSize` | number | NoSync | `0` | World units per ray step. `0` = auto-derive from volume extent. |
| `maxSteps` | number | NoSync | `256` | Ray-march step cap. |
| `target` | string | Sync | `"scene_volume_rt"` | Output render target name (composite key). |
| `targetWidth` | number | NoSync | `1280` | Output width (px). |
| `targetHeight` | number | NoSync | `720` | Output height (px). |
| `useOccupancy` | boolean | NoSync | `true` | Toggle empty-space skipping. |
| `isRgba` | boolean | Sync | `true` | `false` reads density from the red channel. Left `true`, a volume the module built is read from the channel it was built with, and a bare texture as RGBA. |
All public fields use the `Field.<kind>(default, Sync|NoSync)` schema
(PR #3019). Visual params (look, tint, density) propagate Sync;
quality knobs (stepSize, maxSteps, target resolution, useOccupancy)
are NoSync — each client picks its own perf/quality point.
## Lifecycle
- **`awake`** — no-op. The volume texture is owned by the producer
and looked up lazily on the first `update`.
- **`update(dt)`** — resolves `volumeName` through `Volume.get`, falling
back to a wrapper over a bare texture of that name, and dispatches the
compute raymarcher into `target`. The resolution runs every frame, so a
volume built, paged or destroyed after this component attached is picked
up on the next one.
- **`onPropertyChanged(key, value, old)`** — drops the resolved volume
when `volumeName`, `occupancyName`, or `isRgba` changes, forcing a
re-resolve on the next frame.
## Related
- `volume` module — texture builders and name lookup (`Volume.noise`,
`Volume.get`, `Volume.wrap`, `:buildSparse`, `:render`).
- `vdb_to_zvol.importer` — VDB → dense voxel pipeline that produces
textures consumable by this component.
- `volumeDemos` / `volumetricSky` packages — worked examples.
# VolumeSequence
Plays an open volume sequence through an entity's transform AABB.
The sequence is opened by a producer and named here — the same split the
`Volume` component makes between "what data to render" and "where it appears
in the world". Each tick the component advances the playhead and raymarches
the frame that is resident into the shared `scene_volume_rt` target, in both
play and edit mode, so the run animates while it is being authored.
```luau
local Seq = require("@builtin::systems.volumetrics.volumeSequence")
Seq.open({ name = "plume", frames = paths, resident = 3, fps = 24 })
local id = entity.spawn("plume_entity").id
entity(id).localScale = { 8, 8, 8 }
entity(id).component.add("VolumeSequence", { sequenceName = "plume", density = 6.0 })
```
`fps` and `playing` are pushed into the sequence when they change, so a
script calling `seq:setFps()` / `seq:pause()` between two ticks is left
standing until the authored value itself moves.
The component does not own the sequence: despawning the entity leaves the
ring allocated, and `volumeSequence.close(name)` is what frees it.
## Sharing
The playhead, the rate and the ring belong to the sequence, so several
entities naming one run play the same frame from the same three textures:
the playhead advances once per engine frame however many components tick it,
and the rate is whichever component's `fps` moved last. Give each entity its
own run when they should play independently.
`target` is a render target, and a raymarch writes the whole of it — two
volumes visible at once (a sequence beside a `Volume`, or two sequences) need
a `target` name each, or the later write replaces the earlier one and only
one of them appears. Composite each target the way any volume render is
composited — see `volume.compositeShaderSource()`.
# VolumetricLighting
The air between the camera and the scene, as a component. Add it to an entity
and light travelling through that air scatters toward the viewer, so a lamp
becomes a glow rather than an invisible point and the sun cuts shafts where
something crosses it.
```lua
local id = entity.spawn("air").id
entity(id).component.add("VolumetricLighting", {
density = 0.06,
anisotropy = 0.7,
})
```
Every field is live — editing one in the inspector changes the air on the next
frame, and the values persist with the scene. Setting `density` to 0 empties the
air and releases the passes while keeping the rest authored; removing the
component clears it.
| field | effect |
|---|---|
| `density` | How much medium there is per world unit at the reference altitude. 0 is off. |
| `heightFalloff` / `heightRef` | How fast density thins with altitude, and the altitude `density` is quoted at. 0 falloff fills the scene evenly. |
| `steps` | Samples along each view ray. More resolves a beam's edge more cleanly. |
| `colorR/G/B` | What the medium scatters. Warm dust and cold mist differ here. |
| `anisotropy` | How forward-biased the scattering is, in (-1, 1). Positive values throw light onward, so haze blooms around a source you look toward. |
| `sunIntensity` / `sunColorR/G/B` | The sun's contribution to the medium. |
| `lightScale` | The scene lights' contribution to the medium. |
| `shadowSteps` / `shadowDistance` | Samples taken back toward the sun to decide whether a point in the air is lit, and how far they reach. **0 leaves the sun's contribution unshadowed**, so the haze glows evenly and no shafts form. |
| `maxDistance` | How far along the view ray the march runs. A ray that escapes to sky stops here. |
The lights are the scene's own, each scattering on the terms its kind emits on:
a point fills the air around it, a spot fills its cone, a rect reaches the air on
the side its face emits along, and a parallel light lays an even glow across the
whole medium. One added, moved, re-aimed or recoloured at runtime changes the
glow it throws with nothing to keep in step here.
One body of air fills the scene, so one of these per scene is enough — the last
one to push wins.
The work runs in `lightScattering.renderFeature`; the settings live in
`@builtin::systems.volumetrics.volumetricLighting`, which is also how to drive
them from a script.
# VolumetricSky
Singleton component that drives the procedural cloud layer. Attach
to one entity (usually a sky placeholder), and the component owns
the cloud volume + render target lifecycle, bakes the noise field
when what it is baked from changes, and dispatches the compute
raymarcher every frame into `scene_volumetric_sky`.
A composite (post-process or material) blends `scene_volumetric_sky`
over the scene — see `demo/sky.scene/` for the full wiring.
## Usage
```luau
-- Defaults (the v4-tuned cumulus look — `Sky.defaultPreset()` hands out a
-- copy of the same table this field defaults to)
local sky = entity.spawn("sky")
sky.component.add("VolumetricSky", {})
-- Or apply a captured preset:
preset.apply("cumulus_default", { entity = sky.id, component = "VolumetricSky" })
-- Inline tweak. `component.add` writes the `preset` field as it is given,
-- so a table handed here carries the whole configuration:
local p = require("@builtin::systems.volumetrics.volumetricSky").defaultPreset()
p.noiseScale, p.octaves, p.coverage = 9.0, 5, 0.20
p.densityGain = 0.04 -- v5 σ_t
sky.component.add("VolumetricSky", { shaderVariant = "v5", preset = p })
```
`volumetricSky.module`'s `M.spawn` / `M.configure` take the same parameters
by name, folding each into the current configuration and leaving the rest
where they stand:
```luau
local Sky = require("@builtin::systems.volumetrics.volumetricSky")
Sky.spawn({ shaderVariant = "v5", noiseScale = 9.0, coverage = 0.20 })
```
## Schema (Field<T> / Sync / NoSync)
The 28 shaping / weather / lighting params travel as ONE Sync field
— `preset = Field.table(DEFAULT_PRESET, Sync)` — so replicating a
sky change is **one mutation, not 28**, and copying a sky between
scenes is **copying one field, not 28**. Per-client quality knobs
sit outside the preset as NoSync fields so each client picks its
own tier without disturbing peers.
`SkyPreset` and `DEFAULT_PRESET` are declared by `volumetricSky.module`,
which this component reads its default from and which routes an option by
that same key set — so the shape below and the module's `M.spawn` options
are one declaration.
| Field | Type | Sync | Default | Meaning |
|---|---|---|---|---|
| `preset` | `SkyPreset` table | Sync | `DEFAULT_PRESET` (v4 cumulus) | All shaping / weather / lighting (see below). |
| `shaderVariant` | string | Sync | `"v4"` | `"v3" \| "v4" (default) \| "v5"`. |
| `wind` | vec3 | Sync | `{0,0,0}` | Animated drift offset (advanced per frame from `preset.windSpeed`). |
| `cloudSize` | number | NoSync | `256` | Volume W,D resolution. |
| `cloudHeight` | number | NoSync | `128` | Volume H resolution. |
| `stepSize` | number | NoSync | `4.0` | World units per ray step. |
| `maxSteps` | number | NoSync | `224` | Ray-march step cap. |
| `shadowSteps` | number | NoSync | `8` | Shadow ray step count. |
| `alphaCutoff` | number | NoSync | `0.99` | Early-exit α threshold. |
| `jitter` | number | NoSync | `1.0` | Ray-origin jitter strength. |
| `taaBlendAlpha` | number | NoSync | `0.1` | TAA current-sample weight. |
| `targetWidth` | number | NoSync | `0` | Render target width (`0` = viewport). |
| `targetHeight` | number | NoSync | `0` | Render target height. |
| `noiseRegenRate` | number | NoSync | `10` | Times per second wind drift re-bakes the cloud volume (`0` = every frame the wind moved). |
### Preset (`SkyPreset`) shape
Noise / shaping: `noiseScale, octaves, coverage, densityGain,
altitudeLow, altitudeHigh, anvilBias, detailScale, detailOctaves,
detailStrength, detailThreshold, baseLowRemap, baseHighRemap, color,
windSpeed, seed`.
Volume placement: `horizExtent, cloudBaseAltitude, cloudTopAltitude`.
Look (visual, not perf): `phaseG`.
Sun / sky: `sunDir, sunColor, sunIntensity, sunDiscSize,
horizonColor, zenithColor, groundColor, ambient`.
v5-only: `cloudType, worleyBlend`.
See `presets/cumulus_default.preset/preset.yaml` for the canonical
full preset shape, and the `init.luau` `DEFAULT_PRESET` table for
the runtime defaults.
## Where the frame time goes
Two passes carry the cost, and different fields reach each:
- **The bake** (`cloud_noise_*`) fills every voxel of the
`cloudSize × cloudHeight × cloudSize` volume with layered noise. It is
the most expensive pass the layer has (around 10 ms at 256×128×256 on a
desktop GPU), and it runs only when the volume's inputs move: at once
for a change to `preset`, `cloudSize`, `cloudHeight`, `shaderVariant`
or the render device, once the program it compiles is resident for an
edit to the noise shader's own source, and at most `noiseRegenRate`
times per second for wind drift. A sky whose `preset.windSpeed` is zero
bakes once. `cloudSize` / `cloudHeight` set what one bake costs;
`noiseRegenRate` and `windSpeed` set how often it is paid.
- **The raymarch** (`cloud_raymarch_*`) runs for every view of every
frame. `stepSize`, `maxSteps`, `shadowSteps`, `alphaCutoff` and
`targetWidth` / `targetHeight` set its cost.
`profiler.gpuFrame()` reports both as `compute.<shader>` spans; a span's
`frames` count is how many frames of its window the pass ran in, and
`volumetricSky.volumeBakes()` counts the bakes themselves.
## Field-mutation gotcha (and why `update()` reassigns the whole vec3)
Under the new Field<T> / Sync / NoSync schema, `public` is not a
plain Lua table — it's wrapped in a proxy whose `__newindex`
metamethod performs type-checks, marks the field dirty, and queues
Sync replication. Writing `public.wind = {x,y,z}` triggers all four
steps. Writing `public.wind[1] = x` reads `public.wind` (getter),
mutates the returned table directly, and **bypasses the setter** —
no Sync replication, no dirty flag, no `onPropertyChanged`. Always
assign the whole vec3 / preset / color table; never index-mutate.
The same caveat applies to `public.preset`: `public.preset.coverage = 0.2`
won't replicate. To change one preset field, copy the table, mutate,
reassign:
```luau
local p = public.preset
local next = {}
for k, v in pairs(p) do next[k] = v end
next.coverage = 0.2
public.preset = next
```
## Lifecycle
- **`awake`**: starts the sky's clock and creates the render feature
that runs the layer's passes.
- **`update(dt)`** — advances `wind` (reading `preset.windSpeed`),
hands the noise inputs to `volumetricSky.fillClouds`, which bakes the
volume when they moved (see "Where the frame time goes"), then queues
the raymarch dispatch for the frame's deferred phase.
- **`onDespawn`** — releases the volume, the render target and the
composite effect via `M.destroy`.
### Why the raymarch is dispatched from `task.defer`
The raymarch produces ONE full-screen layer for ONE view, and the frame
composites it over everything that frame drew — so it has to be rendered
from the view the frame ends up drawn with. Which view that is settles
only once the frame's script work is finished: a cutscene walking the
camera from a task loop, a camera controller, a component ordered after
this one, an editor tool — each writes the camera after `update` has
run, while the renderer reads the camera once all of them are done.
Taking the camera snapshot in the deferred phase reads what every one of
them has already written, and still queues the dispatch ahead of the
frame's render. Snapshotting in `update` instead renders the layer for
whatever the camera was midway through the frame; on a hard cut that is
a different place entirely, and the frame the cut lands on composites a
cloud layer belonging to the shot it just left.
A caller driving `volumetricSky.dispatchRaymarch` directly owes the same
discipline.
## Related
- `volumetricSky.module` — orchestration backend.
- `presets/cumulus_default.preset` — canonical full preset; copy
to author named weather (storm, overcast, dawn, …).
- `Volume.component` — per-entity flow for non-cloud volumetric content.
- `demo/sky.scene` — worked example.
·computeshader · born here
❒asset # godrays_fill
Fills a volume with shafted light. One invocation per voxel.
## Bindings
| name | kind | what it is |
|------|------|------------|
| `vol` | `storage3d` (rgba16f) | the volume written — RGB emission, A density |
| `U` | `buffer` (read, f32) | the demo's fill parameters |
·computeshader · born here
❒asset # nebula_fill
Fills a volume with a spiral nebula: RGB carries emission, A carries density.
One invocation per voxel at workgroup size 4x4x4.
## Bindings
| name | kind | what it is |
|------|------|------------|
| `vol` | `storage3d` (rgba16f) | the volume written, read-write so a pass can accumulate |
| `U` | `buffer` (read, f32) | the demo's fill parameters, in the order the shader reads them |
`U` holds: elapsed time, spiral frequency, arm thickness, core brightness, edge
brightness, dust density, noise strength.
·computeshader · born here
❒asset # sdf_solids_fill
Fills a volume with signed-distance solids. One invocation per voxel.
## Bindings
| name | kind | what it is |
|------|------|------------|
| `vol` | `storage3d` (rgba16f) | the volume written — RGB emission, A density |
| `U` | `buffer` (read, f32) | the demo's fill parameters |
·computeshader · born here
❒asset # smoke_plume_fill
Fills a volume with a rising smoke plume. One invocation per voxel.
## Bindings
| name | kind | what it is |
|------|------|------------|
| `vol` | `storage3d` (rgba16f) | the volume written — RGB emission, A density |
| `U` | `buffer` (read, f32) | the demo's fill parameters |
·computeshader · born here
❒asset # zvol_viewer_fill
Leaves the volume as it is. The ZVOL viewer uploads voxels straight into the
volume's 3D texture from the CPU, so the fill stage this shader occupies has
nothing to compute — it reads one value from each binding so both stay live.
## Bindings
| name | kind | what it is |
|------|------|------------|
| `vol` | `storage3d` (rgba16f) | the volume the viewer writes into from the CPU |
| `U` | `buffer` (read, f32) | the fill parameters the spawn API supplies |
·computeshader · born here
❒asset # emission_raymarch
Marches a camera ray through an emissive volume and writes the result to a 2D
target. Emission accumulates along the ray and density attenuates what is behind
it, so a volume reads as light rather than as a surface.
## Bindings
| name | kind | what it is |
|------|------|------------|
| `vol` | `texture3d` | the volume marched — RGB emission, A density |
| `smp` | `sampler` | how `vol` is filtered along the ray |
| `out_tex` | `storage2d` (rgba16f) | the image the march writes |
| `U` | `buffer` (read, f32) | camera, volume placement and march settings |
`U` carries the inverse view-projection, the volume's world transform, the step
count, density and emission gains, and the target's inverse size.
# VDB → ZVOL importer
Claims `.vdb` (OpenVDB) writes and produces a sibling `.zvol` — the
engine-native dense RGBA8 volumetric format consumed by the volumetrics
system.
This is a standard `importer` asset: `init.luau` returns a
`{ canImport, import }` module that the importer dispatcher
(`@builtin::assetTypes.importer`) loads lazily and runs only when a matching
file is written. It does **not** execute anything at load time.
## Pipeline
1. **Real parse** — if the `openvdb` plugin in the builtin library
(`@builtin::plugins.openvdb`) is loaded, the VDB B-tree is parsed into a
dense RGBA8 voxel buffer and wrapped in a 64-byte ZVOL header.
2. **Procedural fallback** — without the plugin, a deterministic
noise-modulated torus is synthesised (FNV-1a hash of the first 256 source
bytes seeds it) so the importer + demo wiring stays functional. The
`produced_by` field in the result distinguishes the two paths.
## ZVOL header (64 bytes)
| offset | size | field |
|--------|------|-------|
| 0 | 4 | magic `ZVOL` |
| 4 | 4 | u32 version (1) |
| 8 | 4 | u32 width |
| 12 | 4 | u32 height |
| 16 | 4 | u32 depth |
| 20 | 4 | u32 format (1 = rgba8) |
| 24 | 12 | f32[3] aabb_min |
| 36 | 12 | f32[3] aabb_max |
| 48 | 16 | reserved |
| 64 | … | voxel data, `w*h*d*4` bytes, XY-major Z-slowest |
·renderfeature · born here
❒asset # lightScattering
The render passes behind the `VolumetricLighting` component and the
`volumetricLighting` module.
Two compute passes after lighting.
**`vol_scatter`** marches each view ray at half resolution, from the camera to
the surface the ray hits — or to the medium's reach when it escapes to sky, since
an unbounded march would integrate the whole horizon into every sky pixel. At
each step it evaluates the medium's density, gathers the light arriving there,
and accumulates the fraction sent toward the camera, attenuating by everything
already crossed.
A surface-only renderer draws the pool of light a lamp throws on the floor but
not the beam that carried it there, because nothing between the lamp and the
floor is ever shaded. Marching the ray shades that space.
The lights are the scene's own. `@scene.lights` is the same buffer the lit passes
read, so a light added, moved or recoloured at runtime changes the beam it throws
with no bookkeeping here. It holds **twelve vec4s per light** — the light, then
its punctual shadow record — read as raw words so no struct declaration has to
mirror the engine's layout. A spot's cone gate is
evaluated at the sample itself rather than at a surface, which is what makes the
cone visible as a volume instead of only where it lands. Attenuation matches the
lit passes' windowed falloff, so a beam and the pool of light it lands in agree
about how far the source reaches. Area lights are skipped — their contribution to
a medium is an integral over the rect rather than a single direction.
The sun's contribution is gated by a short march back toward it, tested against
the depth buffer. That is what turns uniform haze into shafts: the medium is lit
only where the path back to the sun is clear, so an occluder carves a dark volume
out of the beam rather than only shading the surface behind it. Because the test
reads the depth buffer, an occluder outside the view frustum does not cast into
the medium.
Scattering follows Henyey-Greenstein, so the medium brightens toward a source you
are looking at and stays dim when it is behind you — the difference between haze
that reads as air and haze that reads as a grey wash.
Each ray's first sample is offset by a per-pixel fraction of a step, trading the
banding a fixed step pattern produces for noise.
**`vol_composite`** upsamples that result and composites it. The taps are weighted
by how closely their depth matches the full-resolution pixel's, which keeps a beam
from bleeding across a silhouette — the halo a plain bilinear upsample leaves
behind.
The camera comes from `@frame.camera`, the camera of the render target being
drawn, and the forward matrix is derived by inverting it in the shader rather than
passed in from script — so an offscreen capture or an RTT camera marches through
its own matrix at its own resolution.
Parameters arrive through the `vol_params` buffer, packed by the
`volumetricLighting` module.
·renderfeature · born here
❒asset # mediaShadow
The shadow a fog bank casts on what is behind it.
Two compute passes run after lighting:
| Order | Pass | Work |
|---|---|---|
| 36 | `media_tau_map` | One ray per texel of a light-space map; accumulate optical depth |
| 37 | `media_shadow_apply` | Attenuate each surface by the density in front of it |
| 38 | composite | Blit the result |
## Why not a shadow map
A depth-based shadow map answers one question per texel: is this point nearer
the light than the first thing the light hit? Density has no first thing. What
a medium does to light is continuous — a ray crossing `d` units of extinction
`k` keeps `exp(-k*d)` of its energy — so what a receiver needs is the optical
depth between it and the light, not a depth to compare against.
The first pass therefore stores optical depth rather than distance, at four
points down each ray. Two consequences fall out of that choice:
- **Overlapping media stack.** Optical depth is additive, so two volumes that
intersect contribute to the same running total with nothing to resolve.
- **Geometry inside a volume is lit correctly.** A receiver looks up its own
position along the ray and reads the depth accumulated *up to that point*, so
a post standing in the middle of a fog bank is dimmed by the half in front of
it rather than by the whole column.
## Inputs
`@scene.color`, `@scene.depth`, `@frame.camera`, and the `media_params` /
`media_volumes` buffers packed by
`@builtin::systems.volumetrics.mediaTransmittance`.
Sky pixels are passed through untouched.
# mediaTransmittance
Light attenuation through participating media, in a form both media and
surfaces can sample.
A shadow map stores one depth per texel and answers a yes/no question, so
nothing made of density can cast with it. This module owns the other
representation: a light-space map holding the **optical depth** accumulated
along each light ray, recorded at four points down its length.
Optical depth is additive. Every medium a ray crosses contributes to the same
texel, so overlapping volumes stack with nothing to resolve between them. And
because four points are stored rather than one total, a receiver standing
halfway through a fog bank is attenuated by the half in front of it, not by the
whole column.
## Direct use
```luau
local Media = require("@builtin::systems.volumetrics.mediaTransmittance")
Media.set({ sunDirection = { 0.55, 0.55, 0.55 }, steps = 32 })
Media.setMedium("bank", {
position = { 0, 8, 0 },
size = { 20, 6, 20 },
density = 0.15,
})
```
Most scenes attach `ParticipatingMedium` to an entity instead and let it drive
this module.
## API
| Call | Effect |
|---|---|
| `set(opts)` | Scene-wide settings; omitted fields keep their value |
| `setMedium(key, opts)` | Register or move a medium under a stable key |
| `removeMedium(key)` | Drop one medium |
| `get()` | A copy of the settings in force |
| `clear()` | Drop everything and release the passes |
| `active()` | Whether the passes are running |
### Settings
| Field | Meaning |
|---|---|
| `sunDirection` | Points **toward** the sun; light travels the other way |
| `strength` | How much of the computed attenuation is applied, `[0, 1]` |
| `steps` | Samples along each light ray, `[4, 128]` |
| `extent` | Half-width of the map in world units; `0` sizes it to the media |
| `centre` | Point the map is centred on; omit to centre it on the media |
### Media
| Field | Meaning |
|---|---|
| `position` | World-space centre |
| `shape` | `"box"` (default) or `"sphere"` |
| `size` | Half-extents, for a box |
| `radius` | Radius, for a sphere |
| `density` | Extinction per world unit |
Up to 32 media are packed into the records buffer.
## Cost and limits
The map is 512×512 and is rebuilt each frame; `steps` sets the march length.
With nothing registered the passes are released entirely, so a scene with no
media pays nothing.
The map is sized to the bounding sphere of everything registered, so a small
bank sharing a scene with a much larger one lands on few texels — set `extent`
and `centre` explicitly to bias the map toward the region that matters.
Attenuation applies to surfaces. The sky keeps its own colour.
# media_common
The media-shadow passes' shared ground: the volume record layout, the transmittance
accumulation along a ray, and the cap on how many volumes one frame carries
(`MAX_VOLUMES` = 32). That cap sizes a fixed array, so it is a constant here
rather than a param.
·computeshader · born here
❒asset # media_shadow_apply
Applies the media's transmittance to the lit scene.
## Bindings
| name | kind |
|------|------|
| `source_map` | `texture2d` |
| `scene_depth` | `texture_depth` |
| `tau_map` | `texture2d` |
| `frame_cam` | `buffer` |
| `media_params` | `buffer` |
| `media_out` | `storage2d (rgba16f)` |
·computeshader · born here
❒asset # media_tau_map
Builds the transmittance map through the media, so a later pass reads how much light survives to a point instead of marching for it.
## Includes
- `media_common`
## Bindings
| name | kind |
|------|------|
| `media_params` | `buffer` |
| `media_volumes` | `buffer` |
| `media_tau_out` | `storage2d (rgba16f)` |
# cumulus_default
Default weather preset for `VolumetricSky.component`. Captures the
v4-tuned cumulus look: visible discrete clouds, warm-afternoon sun,
moderate coverage.
## Use
```luau
local sky = entity.spawn("sky")
sky.component.add("VolumetricSky", {})
preset.apply("cumulus_default", { entity = sky.id, component = "VolumetricSky" })
```
The values set the component's `preset` Field.table as a unit — one
Sync replication carries every shaping/lighting parameter to peers. Tweak
by reading the values and changing them before the component is added:
```luau
local values = preset.values("cumulus_default")
values.shaderVariant = "v5" -- swap raymarcher
values.preset.coverage = 0.6 -- sparser sky
sky.component.add("VolumetricSky", values)
```
## Authoring more presets
Capture a sky you have tuned with
`preset.capture({ entity = sky.id, component = "VolumetricSky" }, "<name>")`,
or drop another `<name>.preset/` folder next to this one (or anywhere under
`/zero/source/`); the asset indexer registers it and `preset.values("<name>")`
resolves it. Common alternatives:
- `stratocumulus.preset` — broad overcast layer (`coverage ~= 0.15`,
`cloudType = 0.5`, `altitudeHigh ~= 0.65`).
- `clear.preset` — barely any cloud (`coverage ~= 0.7`,
`densityGain ~= 0.2`).
- `storm.preset` — dense black anvils (`coverage ~= 0.1`,
`densityGain ~= 0.85`, `sunColor ~= [0.6, 0.55, 0.5]`).
`preset` is one `Field.table`: applying a preset replaces it whole, so
provide every field.
# scattering_common
The light-scattering passes' shared ground: the phase function, the shadow lookup along a ray, and the froxel-to-world mapping.
Included with `#include "@builtin::systems.volumetrics.scattering_common"`.
·computeshader · born here
❒asset # cloud_noise
Procedural volumetric-cloud noise fill (Schneider/Hillaire style). A low-frequency
FBM gives the base blob silhouette, a high-frequency FBM erodes it near the surface
for the fluffy cumulus look, plus altitude shaping (round bottom, full middle, anvil
top). Writes the 3D cloud density texture.
Bindings (see bindings.yaml): `clouds` (storage3d rgba16f — the cloud volume), `U`
(read `array<f32>` uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_noise", ...)` (storage texture requires resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_noise_v3
Volumetric-cloud noise fill (Schneider/Hillaire style), v3 variant. Low-frequency
FBM base silhouette + high-frequency FBM surface erosion for the billowed cumulus
look, with altitude shaping (round bottom, full middle, anvil top). Writes the 3D
cloud density texture.
Bindings (see bindings.yaml): `clouds` (storage3d rgba16f — the cloud volume), `U`
(read `array<f32>` uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_noise_v3", ...)` (storage texture requires resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_noise_v5
Volumetric-cloud noise generator, v5 (Nubis/Schneider style). Builds two
foundational noises — Perlin-Worley FBM for the lumpy "cauliflower" base shape and
Worley FBM for high-frequency detail erosion — under weather-map control. Writes
the 3D cloud density texture.
Bindings (see bindings.yaml): `clouds` (storage3d rgba16f — the cloud volume), `U`
(read `array<f32>` uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_noise_v5", ...)` (storage texture requires resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_raymarch
Volumetric-sky cloud raymarcher with Frostbite-style lighting: Wrenninge/Hillaire
multi-scatter approximation (3 octaves), a 5-sample cone-traced soft self-shadow,
and combined Beer-Lambert + Powder so cloud edges glow toward the sun. Reads the
cloud volume + occupancy textures and writes the lit raymarch result.
Bindings (see bindings.yaml): `clouds` (texture3d), `occupancy` (texture3d),
`cloud_smp` (sampler), `out_tex` (storage2d rgba16f), `U` (read `array<f32>`
uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_raymarch", ...)` (textures/sampler/storage texture require resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_raymarch_v3
Volumetric-sky cloud raymarcher (Frostbite-style lighting), v3 variant:
Wrenninge/Hillaire multi-scatter (3 octaves), 5-sample cone-traced soft shadow, and
combined Beer-Lambert + Powder edge glow. Reads the cloud volume + occupancy
textures and writes the lit raymarch result.
Bindings (see bindings.yaml): `clouds` (texture3d), `occupancy` (texture3d),
`cloud_smp` (sampler), `out_tex` (storage2d rgba16f), `U` (read `array<f32>`
uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_raymarch_v3", ...)` (textures/sampler/storage texture require resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_raymarch_v4
Volumetric-sky cloud raymarcher, v4. Adds procedural detail noise computed
in-shader on top of the large-scale texture silhouette (removes v3's voxel-grid
blockiness) and uses multi-distance shadow sampling (4 geometrically spaced strides)
instead of a cone trace. Reads the cloud volume + occupancy textures and writes the
lit raymarch result.
Bindings (see bindings.yaml): `clouds` (texture3d), `occupancy` (texture3d),
`cloud_smp` (sampler), `out_tex` (storage2d rgba16f), `U` (read `array<f32>`
uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_raymarch_v4", ...)` (textures/sampler/storage texture require resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_raymarch_v5
Production volumetric-cloud raymarcher, v5 (Nubis/Hillaire style): Hillaire
multi-scatter octaves (fixes black cloud cores), energy-conserving Beer-Powder-MS
lit-edge glow, a 6-offset Wrenninge cone-trace shadow, and a dual-lobe
Henyey-Greenstein phase. Reads the cloud volume + occupancy textures and writes the
lit raymarch result.
Bindings (see bindings.yaml): `clouds` (texture3d), `occupancy` (texture3d),
`cloud_smp` (sampler), `out_tex` (storage2d rgba16f), `U` (read `array<f32>`
uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_raymarch_v5", ...)` (textures/sampler/storage texture require resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_taa
Cloud TAA composite — temporal anti-aliasing for the raymarch output. Reprojects
last frame's blended output through the previous view-projection, color-clamps the
history to the current sample's neighborhood to suppress ghosting, and exponentially
blends the two. Writes both the next-frame history slot and the externally-sampled
`scene_volumetric_sky` texture the composite path reads.
Reprojection runs through the world point named by uniform `[37]`, the NDC z of the
range the cloud stands at along the view ray — `volumetricSky.reprojectionDepthNdc`
computes it. The weight the history carries falls to zero across the last
`REPROJECT_BORDER_TEXELS` of the reprojected frame, so a pixel whose history lies
off the previous frame reaches the raymarch's own output down a gradient rather
than across a line.
Bindings (see bindings.yaml — slot order must match the Luau dispatch): `cur_tex`
(texture2d), `hist_prev` (texture2d), `smp` (sampler), `out_hist` (storage2d
rgba16f), `out_scene` (storage2d rgba16f), `U` (read `array<f32>` uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_taa", ...)` (textures/sampler/storage textures require resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # volume_pack
Fills the brick pool from the volume `volume_pages` has just paged.
One invocation per pool voxel. The pool is a grid of blocks, one block per
slot; a block holds a brick plus a one-voxel apron taken from the neighbouring
bricks, so a trilinear sample inside a brick reads what the dense volume would
have given it rather than whatever brick the pool stores next door.
## Bindings
| # | name | what |
|---|---|---|
| 0 | `source` | the dense volume being paged (sampled 3D texture) |
| 1 | `atlas` | the brick pool (`rgba16f` storage) |
| 2 | `page_slots` | four words per slot, written by `volume_pages` |
| 3 | `K` | `PackParams` — volume dimensions and brick edge |
## Why the pool is always `rgba16f`
A pool is written as a storage texture and sampled with filtering. `rgba16f` is
the format that is both: the one-channel formats cannot be written as storage
textures, and `r32f` cannot be filtered without a device feature the engine
does not require. So a scalar volume's density rides in the pool's red channel
— which is where the raymarcher reads density from in density mode — and pays
`rgba16f` per resident voxel. `Volume:sparseStats()` reports the dense and
resident sizes side by side rather than assuming paging is the smaller of the
two.
The pass writes every voxel of the pool, including blocks whose slot was never
handed out: those hold whatever brick their stale slot entry names, and the
page table never points at them.
·computeshader · born here
❒asset # volume_pages
Decides which bricks of a volume are worth storing, and where each one lives.
One invocation per brick. It scans the brick's voxels in the source volume,
and a brick holding anything over `threshold` claims a slot from an atomic
counter. The slot number lands in the page table (`slot + 1`, so 0 keeps
meaning "empty"), and the brick's grid coordinates land in the slot list so
`volume_pack` knows what to copy into that slot.
Nothing is read back to the CPU and no brick waits on another, so the pass can
run every frame — which is what makes the page table usable as the acceleration
structure for a volume whose contents change while it renders.
## Bindings
| # | name | what |
|---|---|---|
| 0 | `source` | the dense volume being paged (sampled 3D texture) |
| 1 | `pages` | page table, one texel per brick (`rgba16f` storage) |
| 2 | `page_alloc` | `[0]` slots handed out, `[1]` bricks that found the pool full |
| 3 | `page_slots` | four words per slot: the brick's x, y, z, and 1 |
| 4 | `P` | `PageParams` — volume dimensions, brick edge, threshold, channel, capacity |
`page_alloc` must be zeroed before each run; the counter is what allocates, so
a stale count hands out slots past the pool.
## The page value
A resident brick's page value is `slot + 1`, split into two digits base 1024
across the texel's red and green channels. The page table is `rgba16f` because
that is the format that is both storage-writable and filterable — the
raymarcher binds it where a sampled 3D texture is expected — and a half float
only carries integers exactly up to 2048, which one digit stays under. A brick
holding nothing writes zero in both channels, so "is anything here" is the sum
against zero, the same test the older single-channel occupancy grid answers.
## Overflow
A brick that arrives after the pool is full is counted in `page_alloc[1]` and
written as empty. The volume renders without it rather than sharing a slot.
`page_alloc[0]` keeps counting past the capacity, so a caller that reads it
back learns the capacity the volume actually needs.
·computeshader · born here
❒asset # volume_raymarch
Generic volume raymarcher. One invocation per output pixel: builds a world-space
ray from the camera inverse view-projection, clips against the volume AABB, and
marches front-to-back, using the occupancy grid to skip empty bricks. Output is
RGBA16F (premultiplied RGB, accumulated alpha) for the caller to composite.
A paged volume marches through the same shader: `volume` is then the brick pool
and `occupancy` the page table, whose texel per brick carries the pool slot that
brick lives in (0 for a brick holding nothing, which is what the empty-space
skip already reads it as). `VolumeUniforms.paged` carries the flag and the
volume's own dimensions, which the pool no longer describes.
Both the skip and the pool lookup find a point's brick by the voxel it lands on
divided by the brick edge — the way the page table and the occupancy grid were
written. A volume that is not a whole number of bricks across has a grid wider
than `dims / brick`, so dividing the normalized coordinate by the grid instead
would name a different brick for the same point. When the dimensions are unknown
(a wrapper was never told them) the grid times the brick edge stands in for them.
Bindings (see bindings.yaml): `volume` (texture3d), `occupancy` (texture3d),
`volume_smp` (sampler), `out_tex` (storage2d rgba16f), `U` (read buffer, a single
`VolumeUniforms` struct).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.volume_raymarch", ...)` (textures/samplers/storage texture require resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # vol_composite
Composites the scattered light over the scene, depth-aware so geometry occludes it.
## Includes
- `scattering_common`
## Bindings
| name | kind |
|------|------|
| `scene_color` | `texture2d` |
| `scene_depth` | `texture_depth` |
| `scatter_map` | `texture2d` |
| `vol_params` | `buffer` |
| `vol_out` | `storage2d (rgba16f)` |
·computeshader · born here
❒asset # vol_scatter
Accumulates in-scattered light through the volume, one froxel at a time.
## Includes
- `scattering_common`
## Bindings
| name | kind |
|------|------|
| `scene_depth` | `texture_depth` |
| `frame_cam` | `buffer` |
| `lights` | `buffer` |
| `vol_params` | `buffer` |
| `scatter_out` | `storage2d (rgba16f)` |
# volume
High-level Luau API for 3D textures and volumetric rendering. Composes
the engine's generic `compute.*` primitives into volume-specific
helpers — procedural builders (`sphere`, `box`, `noise`), a sparse
NanoVDB-style occupancy grid for empty-space skipping, and a one-call
raymarcher that composites a volume through any entity's transform.
Pairs with the `Volume` component which attaches a built (or imported)
texture to an entity.
## Exports
- `M.create({name, width, height, depth, format?, storage?}) -> Volume` — allocate a fresh, zero-filled 3D texture and return a Volume wrapper.
- `M.wrap({name, format, occupancyName?, occupancyBrick?}) -> Volume` — wrap an existing named texture without recreating it (used by the .zvol viewer).
- `M.get(name) -> Volume?` — the live volume built under that texture name, with its dimensions, channel count, occupancy grid and brick pool.
- `M.names() -> {string}` — every name `M.get` answers to, sorted.
- `M.register(volume) -> Volume` — publish a wrapped volume under its own texture name.
- `M.unregister(name) -> Volume?` — stop answering for a name and hand back the volume that held it, leaving its GPU resources alone.
- `M.releaseTarget(name) -> boolean` — destroy a render target this module built for `render` and forget its size.
- `M.sphere(opts) -> Volume` — procedural Volume filled with a soft sphere SDF.
- `M.box(opts) -> Volume` — procedural Volume filled with a soft box SDF.
- `M.noise(opts) -> Volume` — procedural Volume filled with FBM noise.
- `M.raymarchShaderIdentity() -> string` — absolute identity of the shared raymarch shader.
- `M.compositeShaderSource() -> string` — WGSL source for the post-process composite blending `scene_volume_rt` over the scene.
- `M.FORMATS` — table of canonical format strings (`R8`, `R16F`, `R32F`, `RGBA8`, `RGBA16F`, `RGBA32F`).
- `volume:destroy()` — release the GPU texture + occupancy.
- `volume:writeBytes(bytes) / :writeFloats(floats)` — upload payload to the 3D texture (matches the texture's format).
- `volume:fill(fn)` — CPU procedural fill via `fn(x,y,z) -> color`.
- `volume:readback() -> bytes` — pull the texture's current contents back to Luau.
- `volume:buildOccupancy({brickSize, sampler?})` — build an R8 occupancy companion for empty-space skipping.
- `volume:buildSparse({brickSize?, threshold?, headroom?, capacity?, mode?})` — page the volume into bricks, keeping only the ones holding something. Yields while the brick count reads back — twice unless `capacity` says how many slots to take.
- `volume:refreshSparse()` — re-page from the volume's current contents; two dispatches and no wait.
- `volume:releaseDense()` — free the dense texture and render from the brick pool alone.
- `volume:releaseSparse()` — free the brick pool and page table and go back to rendering the dense texture.
- `volume:readSparseCounts()` — `{ bricks, wanted, overflow, capacity }` off the GPU; yields.
- `volume:sparseStats()` — byte accounting: `denseBytes`, `poolBytes`, `pageBytes`, `residentBytes`, `ratio`.
- `volume:fillCompute(shaderName, opts)` — drive a custom fill shader against this volume.
- `volume:render(opts)` — dispatch the shared compute raymarcher into a 2D storage target, using `opts.entity` for AABB. A paged volume renders through its pool with no change at the call site.
## Usage
```luau
local Volume = require("@builtin::systems.volumetrics.volume")
-- Build a procedural noise cloud + occupancy.
local cloud = Volume.noise({
name = "cloud", size = 64, scale = 4, octaves = 4, threshold = 0.55,
})
cloud:buildOccupancy({ brickSize = 8 })
-- Render through the shared raymarcher; output to scene_volume_rt.
cloud:render({
entity = entity(id),
target = "scene_volume_rt",
targetWidth = 1280, targetHeight = 720,
density = 8.0, absorption = 1.2, emission = 0.9,
color = { 1.0, 0.9, 0.7 },
maxSteps = 256, mode = "rgba",
})
```
The `Volume.component` is the per-entity wrapper around this module — it
resolves the name it carries through `Volume.get` and dispatches
`volume:render` from its `update` hook.
## Addressing a volume by name
Every volume this module builds answers to its texture name, so anything
holding only the name — an entity's `Volume` component, a second script, a
tool — reaches the volume itself:
```luau
local cloud = Volume.noise({ name = "cloud", size = 128 })
cloud:buildSparse({ brickSize = 8 })
cloud:releaseDense()
-- somewhere else entirely
Volume.get("cloud"):render({ entity = entity.find("cloud_host") })
```
What the name alone cannot express is what makes this matter: the voxel
dimensions, which channel holds density, and whether the volume is paged into
a brick pool. A holder that reconstructs a wrapper from the name gets a dense
volume that the paging may already have released.
One volume owns a name at a time: the 3D texture, the page table and the brick
pool all address it, so `create` over a name a live volume holds releases that
volume first and says so in the log. `destroy` clears the entry. A wrapper
around a texture another system owns is published with `Volume.register` and
dropped with `Volume.unregister`, which hands the volume back so it can still
be destroyed. The names live where this module is loaded — one set per Luau
VM, alongside the raymarch target registry.
`render` builds its `target` on the first call that names one and keeps its
size, so repeated renders reuse it; `Volume.releaseTarget(name)` destroys that
target and forgets the size, and the next render builds it again.
## Paging a volume into bricks
A dense volume costs its bounding box. `buildSparse` splits it into bricks,
keeps only the ones holding anything over `threshold`, and stores those in a
brick pool; a page table one texel per brick says which slot each resident
brick took, and 0 says the brick holds nothing. That page table is also the
grid the raymarcher already skips empty space with, so paging and empty-space
skipping are the same structure.
```luau
local cloud = Volume.noise({ name = "cloud", size = 128, threshold = 0.6 })
local stats = cloud:buildSparse({ brickSize = 8, threshold = 0.01 })
print(stats.bricks, "of", stats.grid[1] * stats.grid[2] * stats.grid[3], "bricks")
print(stats.poolBytes, "vs dense", stats.denseBytes)
cloud:releaseDense() -- the pool is now the only copy
cloud:render({ entity = entity.find("cloud_host") })
```
Both passes run on the GPU with no readback between them, so a volume a
compute shader rewrites keeps a matching page table for the cost of two
dispatches:
```luau
cloud:fillCompute(myFillShader, { extraResources = { { kind = "buffer", name = fillU } } })
cloud:refreshSparse()
```
The pool is fixed at the size the build measured, so a volume that grows past
it loses the surplus bricks. `refreshSparse` asks the GPU for the counts
without waiting on them and folds them in on a later call, so `sparseStats()`
reports the overflow — and the log carries a warning naming the capacity to
page at — a frame or two after the refresh that caused it. `buildSparse`'s
`headroom` buys the slots up front for a volume that is expected to grow.
What it costs:
- A block in the pool is the brick plus a one-voxel apron on every side, so a
resident brick costs `(brick + 2)³` voxels. At `brickSize = 8` that is 1.95x
per resident brick, at 16 it is 1.42x. The apron is what makes a trilinear
sample inside a brick read exactly what the dense volume would have given
it.
- A pool is always `rgba16f`: it has to be writable as a storage texture and
sampled with filtering, and that is the format that is both. A scalar volume
therefore spends more per resident voxel than it did dense (its density rides
in the pool's red channel), so `sparseStats()` reports `denseBytes` and
`residentBytes` side by side rather than assuming paging is the smaller of
the two.
- `refreshSparse` re-pages from the dense volume, so a volume that will keep
changing keeps its dense copy; `releaseDense` is for one that is finished.
- Classification reads one channel: alpha for a four-channel volume, red for a
scalar one. A four-channel volume that carries density in red — the one
`render`'s `mode = "density"` marches — has to say so, with the same word:
`buildSparse({ mode = "density" })`. A `render` that names no `mode` marches
a paged volume from the channel its pool was filled from, so the two agree
without the caller repeating the choice.
## Notes
- Volumes are GPU-owned; the Luau wrapper just holds the named handle.
`volume:destroy()` releases both the texture and any occupancy
companion.
- The composite target `scene_volume_rt` is shared across every Volume
entity in the scene, so multiple volumes composite into one image.
Override `opts.target` if you need separate render targets per volume.
- Occupancy grids use a brick size (default 8) — smaller = more
precise skipping at the cost of grid memory.
- The `vdb_to_zvol.importer` in this package produces dense voxel
buffers consumable by `compute.writeFloatsTexture3D`, which can be
wrapped via `Volume.wrap` (see the `zvol_viewer.scene/` demo for the
full path).
# volumeDemos
Shared orchestration backend for the `volumeDemos.package` showcase
scenes. Owns the per-demo resource pack (3D texture, 2D render target,
two uniform buffers) and the auto-registered composite, exposing a
small public surface the demo scenes drive once per frame.
## Exports
- `M.spawn({name, size, fillShader, fillUniformBytes}) -> ()` — register a new demo pipeline. Creates the 3D volume, the 2D storage target, registers the per-demo fill shader, ensures the shared raymarcher is registered, and auto-installs the depth-aware composite.
- `M.destroy(name) -> ()` — release everything `spawn` created (texture + render target + buffers + composite registration).
- `M.dispatchFill(name, fillUniforms) -> ()` — pack `fillUniforms` (Lua array of f32) into the demo's fill UBO and dispatch the fill shader against the volume.
- `M.dispatchFrame(name, {camera, aabbMin, aabbMax, width, height, stepSize, maxSteps, alphaCutoff, densityGain, emissionGain}) -> ()` — pack camera + raymarch params into the shared 40-float UBO and dispatch the raymarcher into the demo's render target.
- `M.NAMES(name) -> {volume, rt, composite}` — engine-side resource names for the demo (used by the `.zvol` viewer to bypass the helpers and upload voxels via `compute.writeFloatsTexture3D` directly).
## Usage
```luau
-- From a demo scene at <pkg>/demo/<name>.scene/entrypoint.luau,
-- `...` climbs to the package root, then resolves volumeDemos.module:
local V = require("@builtin::systems.volumetrics.volumeDemos")
-- onLoad: spin up the pipeline.
V.spawn({
name = "smoke_plume",
size = { 96, 192, 96 },
fillShader = SMOKE_FBM_WGSL,
fillUniformBytes = 64,
})
-- update(dt): repaint the volume + raymarch into the RT.
V.dispatchFill("smoke_plume", { elapsed, density_amp, spread, ... })
V.dispatchFrame("smoke_plume", {
camera = cam.get(),
aabbMin = { -40, 0, -40 },
aabbMax = { 40, 160, 40 },
width = 1280, height = 720,
stepSize = 0.9, maxSteps = 240,
densityGain = 2.5, emissionGain = 1.0, alphaCutoff = 0.995,
})
-- onUnload: free GPU resources.
V.destroy("smoke_plume")
```
## Notes
- `dispatchFrame` packs 40 f32s into the raymarch UBO:
`[0..16)` inv_view_proj (mat4 row-major), `[16..19)` aabb_min,
`[19..22)` aabb_max, `[22..23)` step_size, `[23..24)` max_steps,
`[24..25)` alpha_cutoff, `[25..26)` density_gain, `[26..30)`
target_width/height + inv reciprocals, `[30..31)` emission_gain,
`[31..40)` padding.
- Pipeline state lives in module-locals keyed by `name` — the same
module instance can host many demos in parallel, but two scenes
must not pick the same `name`.
- Each demo scene supplies its own fill shader; the raymarcher is
shared (one shader name, one compiled pipeline). Replacing
`fillShader` between frames is supported; replacing the raymarcher
isn't (use the volumetrics package for that use case).
# volumeSequence
Playback for a run of `.zvol` frames, through a ring of resident volumes.
A sequence names its frames as VFS paths in playback order. It allocates
`resident` 3D textures — one frame each — and nothing else: the frames the
playhead is about to reach are read off the VFS on their own tasks and
uploaded into whichever ring slot the playhead has travelled furthest past.
So a 240-frame run and a 4-frame run cost the same on the GPU, and what a
sequence costs is decided when it is opened rather than by how long it is.
```luau
local Seq = require("@builtin::systems.volumetrics.volumeSequence")
local frames = {}
for i = 0, 239 do
frames[#frames + 1] = string.format("/source/sim/plume_%04d.zvol", i)
end
local seq = Seq.open({ name = "plume", frames = frames, resident = 3, fps = 24 })
local s = seq:stats()
print(s.residentBytes, s.allBytes) --> 3145728 251658240
```
Drive it from a component, which is also what makes it visible:
```luau
entity(id).component.add("VolumeSequence", { sequenceName = "plume", density = 6.0 })
```
or by hand, if the caller already has a tick of its own:
```luau
seq:tick(dt)
local frame = seq:current()
if frame then
frame:render({ entity = entity(id), density = 6.0 })
end
```
The playhead advances once per engine frame however many callers tick the
sequence — a component and a script, or two entities naming one run, share
one playhead rather than each moving it. Every call still promotes the frame
that has landed and starts the reads ahead of it.
## Residency
Every frame of a run has the dimensions frame 1 declares, so one ring serves
the whole run. `stats()` reports both what the ring holds (`residentBytes`)
and what holding every frame at once would cost (`allBytes`); `residency()`
says which frame each slot holds right now.
The ring needs at least two slots for a multi-frame run — one holds the
picture on screen while the other takes the read — and `open` raises the
count to two if it was asked for less. `prefetch` says how many frames ahead
of the playhead reads are started, and defaults to every slot the ring has
spare — a slot left empty is a frame that gets waited on later. It is bounded
by those same slots, so asking for more than fits reads as far ahead as it can.
## Keeping up
A tick that wants a frame no slot holds yet keeps showing the frame it had
and counts a `stall`. Playback is therefore never blank and never blocks, and
`stats().stalls` is the number that says whether the ring and the prefetch
distance are big enough for the rate: a run whose reads keep up shows a stall
count that stops rising once the ring has filled.
`errors` counts frames that failed to read or whose header disagreed with the
ring. Each one is counted and logged once, with the path and what was wrong
with it, and the run steps over it from then on rather than re-reading it
every tick — so `errors` answers how many frames of the run are broken, and a
mistyped path costs one line in the log rather than one per frame.
The rate and the playhead are the sequence's, not a caller's: `setFps` keeps
the frame the playhead stands on and the fraction of the way through it, and
`seek` lands on exactly the frame asked for.
# volumetricLighting
The settings behind the `VolumetricLighting` component, and the way to drive the
scene's participating medium from a script — fog that rolls in over a cutscene, a
quality slider that drops the step count, a room that fills with smoke.
```lua
local volumetricLighting = require("@builtin::systems.volumetrics.volumetricLighting")
volumetricLighting.set({ density = 0.06, anisotropy = 0.7 })
volumetricLighting.get() -- a copy of the settings in force
volumetricLighting.active() -- whether the march is running
volumetricLighting.clear() -- empty the air, keep the settings
```
`set` takes any subset of the fields; whatever is omitted keeps its current
value. A `density` of 0 releases the render feature rather than marching a medium
that scatters nothing, and `clear` is the named way to say the same thing.
`sunDirection` tracks the scene's sun on its own. Pin it through `set` when a
scene wants the medium lit from somewhere else.
The module packs the settings into the `vol_params` buffer the passes read, so a
change lands on the next frame without recompiling anything.
## Cost
The march is the expensive part, and its cost is `steps` × (`shadowSteps` + the
number of live scene lights) per marched pixel. Those are the two knobs to reach
for on a budget: `steps` controls how cleanly a beam's edge resolves,
and `shadowSteps` controls whether the sun's contribution is shadowed at all.
Setting `shadowSteps` to 0 removes the per-sample visibility work entirely, which
is the single largest saving available — at the cost of the shafts, since the
medium is then lit evenly wherever it reaches.
# volumetricSky
Orchestration backend for the `VolumetricSky.component`. Owns the
GPU resources the cloud layer needs (cloud volume, raymarch render
target, ping-pong TAA history pair, post-process composite) and
exposes a small public surface the component drives every frame.
## Exports
- `M.spawn(opts?) -> id` — spawn the singleton sky entity, or configure the one already standing, and return its id. Every weather parameter (`coverage`, `sunDir`, `sunColor`, `sunIntensity`, `windSpeed`, …) is settable at the top level or inside a `preset` sub-table; every `VolumetricSky` component field (`shaderVariant`, `cloudSize`, `taaBlendAlpha`, …) by its own name; `name` names the entity. Parameters left out keep their current values. An option that names neither raises, listing both sets, and leaves no entity behind. A weather parameter takes the shape its default carries — a number, or an array of that many numbers — and a value of another shape raises naming the parameter.
- `M.configure(opts) -> boolean` — apply those same weather parameters and component fields to the sky already standing. Returns `false` when none has been spawned.
- `M.defaultPreset() -> table` — a fresh copy of the weather configuration a sky spawned with no options carries. The copy is the caller's to edit.
- `M.destroy() -> ()` — release every resource `spawn` created.
- `M.fillClouds(noiseParams) -> boolean`: bake the cloud volume with the active `cloud_noise_*` shader when what it holds is not what `noiseParams` describe, and return whether this call baked. A change to a shaping parameter, the colour, the seed, the size, the variant or the render device bakes at once, and a write to the noise shader's `shader.wgsl` or `bindings.yaml` bakes once the program it compiles is resident; a change to the wind alone bakes at most `noiseParams.noiseRegenRate` times per second (default `M.DEFAULT_NOISE_REGEN_RATE`, `0` = every call the wind moved) on `noiseParams.clock` (default `getTime()`). `noiseParams` is the flat table produced by `VolumetricSky.component`'s `snapshotNoiseParams()`.
- `M.volumeBakes() -> number`: how many times the cloud volume has been baked since the module loaded.
- `M.dispatchRaymarch(renderParams, viewport) -> ()` — dispatch the active `cloud_raymarch_*` shader, blend with history via the TAA pass, and write into the composite target. `renderParams` is the flat table from `snapshotRenderParams()`.
- `M.find() -> id?` — return the id of the active VolumetricSky entity, or `nil` if none has been spawned by `M.spawn`.
- `M.despawn() -> ()` — despawn the entity `M.spawn` minted (does NOT free GPU resources — call `M.destroy()` for that).
- `M.setCoverage(c) / M.setDensity(g) / M.setSun(dir, color, intensity?) / M.setWind(v)` — convenience setters that route through the active component's `public` fields.
- `M.reprojectionDepthNdc(camPos, fwd, aabbMin, aabbMax, near, far) -> number` — the NDC z the TAA pass carries history through, taken from where the cloud stands along the centre view ray. A point at range `d` travels `translation / d` across the screen, so this is what keeps a translating camera's history landing on the part of the picture the cloud moved to.
## Usage
```luau
-- The component drives this module automatically. Direct M.* use is
-- rare; the canonical entry point is attaching VolumetricSky to an
-- entity (or loading a preset and passing it to component.add):
local sky = entity.spawn("sky")
sky.component.add("VolumetricSky", {})
-- Driving the layer by hand instead, a frame at a time. Each dispatch
-- creates the resources it needs at the size its own arguments name —
-- the cloud volume from `noiseParams.cloudSize / cloudHeight`, the
-- render target and composite from the viewport:
local Sky = require("@builtin::systems.volumetrics.volumetricSky")
Sky.fillClouds(noiseParams)
Sky.dispatchRaymarch(renderParams, { width = 1280, height = 720 })
```
## Notes
- `shaderVariant` selects which compute shader pair (`cloud_noise_v3 +
cloud_raymarch_v3` / `cloud_noise + cloud_raymarch_v4` /
`cloud_noise_v5 + cloud_raymarch_v5`). The module resolves the
variant strings to absolute identities — relative paths don't work
from a module body (the chunkname retains an `.init` segment that
throws off the rsplit-to-parent step in `resolve_relative_dot`).
- The cloud volume + history pair are RGBA16F — 256×128×256 ≈ 32 MiB
VRAM. Drop to 192/96 via `cloudSize` / `cloudHeight` if tight.
- The TAA pass is a single compute dispatch blending current sample
with the previous frame's RT through the history pair. Set
`taaBlendAlpha = 1.0` to disable while still going through the same
code path (useful for benchmarking).
- All public setters write through to `VolumetricSky.component`'s
`public` fields, which means they replicate as Sync if the
component is sync-enabled. Tweaks survive scene reloads as long as
the same entity is alive.
- `SkyPreset` and its defaults are declared here, and the component
declares its `preset` field from them. `M.spawn` / `M.configure` read
that same key set to tell a weather parameter from a component field, so
a parameter added to the preset reaches the schema and the option routing
in one edit.
·renderfeature · born here
❒asset # volumetricSky
The cloud layer's render passes.
The layer is raymarched for the camera being drawn, copied into the target the
composite reads, temporally accumulated where there is a previous frame of that
view to accumulate against, and laid over the frame behind whatever the depth
buffer holds.
Every pass but the accumulation runs for each view the frame draws, an
environment cube's six faces included — which is what puts the clouds into
reflections. The accumulation blends against the previous frame of the same
view, and a cube face is rendered once with no previous frame, so it declares
`environmentCapture = false` and the copy before it is what those faces read.
`VolumetricSky` creates this feature; it is not meant to be created by hand.
# 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). 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. | *(legacy)* An `@compute @workgroup_size(...)` entry over storage buffers — you write the `@group/@binding` lines yourself. **The canonical path is now the separate `.computeShader` assetType** (see below); `.shader` + `// @domain: compute` still compiles for back-compat. |
`// @domain:` resolves the names above and `postprocess` / `screenspace` / `ui`
as further spellings of `post-process` and `screen`. A body carrying no
`@domain:` line is `surface`. `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 are now their own assetType.** A `.computeShader/` folder gives compute the same zero-scaffolding contract surface/sky/post/screen get: you write only `@compute fn main` (+ helper fns/structs, **no `@group/@binding` lines**) and declare bindings in a sibling `bindings.yaml` (storage buffers, textures, samplers, storage textures, plus a `params:` uniform); the engine generates the `@group(0)/@binding(N)` declarations. Dispatch it through a resolved reference (`asset.resolve("name", "computeShader"):dispatch(...)`). See the `.computeShader` assetType README and `man compute`. The `.shader` compute domain above remains only for back-compat.
**Surface**, **sky**, **post-process**, and **screen** shaders all get
engine-generated scaffolding — you write only `fragment()` and declare properties in
`properties.yaml`; the engine generates the entry points and the group(1) material
interface. (Each domain's `fragment` takes a domain-specific input instead of
`FragmentData`: `SkyInput`, `PostInput`, or `ScreenInput` — see "Sky shaders" and
"Post-process & screen shaders" below.) Compute shaders get the same treatment via
the separate `.computeShader` assetType (write only `@compute fn main`, declare
bindings in `bindings.yaml`). The legacy `compute` domain here writes its own
entry points directly — see `man compute`.
## Surface shaders
A surface shader writes **only** the parts that are unique to it. The engine owns
all the scaffolding — the vertex stage, every render-mode entry point
(`vs_main` / `fs_forward` / `fs_gbuffer`), group(0) (camera, transforms, lights,
shadows, and 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 now `material.base_color`,
`material.roughness`, `material.metallic`, and the `base_color_texture` /
`base_color_texture_sampler` pair:
```wgsl
// @domain: surface
#include "@builtin::shaderModules.pbr_shading" // the PBR helpers — see "Library includes"
fn fragment(input: FragmentData) -> vec4<f32> {
var s = zero_pbr_default_surface(input);
s.albedo = material.base_color.rgb
* textureSample(base_color_texture, base_color_texture_sampler, input.uv).rgb;
s.perceptual_roughness = material.roughness;
s.metallic = material.metallic;
return vec4<f32>(zero_pbr_shade(input, s), material.base_color.a);
}
```
`fragment()` returns the final colour. The engine forces no lighting model on
you — flat, toon, PS1, dithered, or PBR are all just different `fragment()`
bodies. Calling `zero_pbr_shade` is opt-in.
### The two surface bodies, and what each gives up
A surface shader exposes one of two entry points, and which one decides how
much of the shader the engine can see:
| Body | The engine gets | PBR debug passes read |
|---|---|---|
| `fn fragment(input: FragmentData) -> vec4<f32>` | the finished pixel, and the `PbrSurface` too when the body hands one to `zero_pbr_shade` | the real channel where a surface was handed over; magenta, the "no data here" marker, where the body shades by its own math |
| `fn surface(input: FragmentData) -> PbrSurface` | the surface (albedo, roughness, metallic, emissive, normal, occlusion), which the engine then lights | the real channel |
Both are first-class: `fragment()` is what you want when the shader owns its
own look, `surface()` when you want engine lighting. `capture { pass = "albedo" }`
(and `roughness` / `metallic` / `ao` / `emissive`) answers about your material
for either body wherever the engine has the surface — writing the same
`PbrSurface` behind `surface()` or behind `fragment()` + `zero_pbr_shade` gives
one answer per channel. A body that lights itself by its own math draws those
channels magenta. `pbr.shader` exposes `surface()`.
`capture { pass = "normal" }` is a geometry read rather than a material one, so
it answers for every shader: the normal of the surface where one was handed
over, and the interpolated normal of the triangle where none was. It is the same
normal the frame's normal buffer carries, which is what a screen-space pass
reading `@scene.normal` sees.
`shaderRef:shadingModel()` reports which one a shader ended up with —
`"engine-lit"` for `surface()`, `"self-shading"` for `fragment()`.
### Optional vertex hooks
```wgsl
// Displace geometry in OBJECT space. Runs on skinned meshes too (it sees the
// already-skinned, morph-applied local position). Return the modified vertex.
// The `v` parameter is immutable (WGSL function params are), so copy it into a
// `var` first and modify that.
fn vertex(v: VertexData) -> VertexData {
var out = v;
out.position += out.normal * sin(out.time) * 0.1;
return out;
}
// Post-transform vertex hook (runs AFTER the engine's world/skin/projection). Two
// things you can do here, separately or together:
// * move `clip_position` — vertex jitter, PS1 snapping, render-resolution lowering;
// * write `color` — PER-VERTEX (Gouraud) shading: light the vertex from its
// world_normal + the light context and the engine forwards the result into the
// interpolated `vertex_color` the fragment reads.
fn vertex_clip(v: ClipVertex) -> ClipVertex {
var out = v;
// Gouraud: Lambert sun + ambient, evaluated once per vertex (flat-banded shading).
let n_dot_l = max(dot(v.world_normal, -normalize(v.light_direction)), 0.0);
let lit = v.ambient_color * v.ambient_intensity + v.light_color * v.light_intensity * n_dot_l;
out.color = vec4<f32>(v.color.rgb * lit, v.color.a);
out.clip_position = round(out.clip_position * 80.0) / 80.0; // PS1 snap
return out;
}
```
#### 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).
### 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_shadow(i)` | `PunctualShadowEntry` | that light's shadow record (its projected map or cube) |
`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.
`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 now); 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
package-relative `~.name` form.
The `zero::`-named rows come from the renderer itself. Their WGSL is baked into
the engine binary and registered under those names before any asset exists, so
they answer an `#include` in every engine and appear in no asset index. A shader
that includes one records no dependency on it: the engine reading the shader is
the engine that already holds the module.
Two spellings reach the same `.shaderModule`: an earlier name is kept working
through the module's declared alias, so a shader written against `zero::pbr`
still compiles and still pins the asset that name resolves to. Newly written
shaders should use the identity — it says which asset travels with the content.
Whichever punctuation an include is written with — `#include "name"`,
`#include 'name'` or `#include <name>` — names the same module and records the
same dependency.
The engine's own scaffolding — `zero::surface`, `zero::sky`, `zero::post`,
`zero::screen` — is injected by the compiler around the matching domain and is
never written by hand. The surface framework puts `safe_normalize`,
`mat3_from_mat4` and `apply_normal_map(FragmentData, tangent_normal)` in scope
for every surface shader.
## Shader features — compile-time variants
A **property** is runtime uniform data (a colour, a float, a texture): set per
material, read as `material.<name>`, no recompile. A **feature** decides which
code is in the program at all. Use a property when the value changes at runtime,
a feature when it changes what the shader is.
Declare features in `properties.yaml`, alongside the properties:
```yaml
properties:
- { name: base_color, type: color, default: [1, 1, 1, 1] }
features:
- { name: PARALLAX, default: false, description: silhouette parallax march }
- { name: DETAIL, default: true, description: detail-normal overlay }
```
Guard the code each feature owns with `#ifdef`:
```wgsl
fn surface(input: FragmentData) -> PbrSurface {
var s = zero_pbr_default_surface(input);
#ifdef PARALLAX
s.normal = parallax_normal(input); // absent when PARALLAX is off
#else
s.normal = input.world_normal;
#endif
return s;
}
```
`#ifdef` / `#ifndef` / `#else` / `#endif`, one per line, and they nest. The
directives resolve before the WGSL reaches the compiler, so the program holds
exactly the lines its feature set selected and every other line is an empty one,
which keeps a compiler message on the line number the file shows. An unbalanced
block is a compile error naming its line.
A material is what chooses a feature set, so `features:` shapes the shaders a
material draws with — the surface, sky and screen-space domains.
A material names the features it wants in its `mat.yaml`:
```yaml
name: worn_rock
shader: "@builtin::shaders.pbr_layered"
features:
PARALLAX: true # on, over the shader's default
DETAIL: false # off, against the shader's default
```
or through `asset.create`:
```lua
asset.create("material", "worn_rock", {
shader = "@builtin::shaders.pbr_layered",
features = { PARALLAX = true, DETAIL = false },
})
```
The engine compiles one program per feature set its materials ask for, named
`<shader>#<features>`. Materials sharing a set share that program. A feature the
shader does not declare selects nothing, so a stray name cannot make the engine
compile anything, and the compile names both halves of a mismatch: a guard on a
name `features:` does not declare, and a declared name no guard tests.
**Permutations are the cost.** A shader with n features can express 2^n
programs, each its own compile and pipeline. One shader holds at most 32
variants; a material asking for a set past that draws with the shader's default
features and the engine logs which shader ran out. Read what a scene is
spending:
```lua
for _, s in renderer.shaderVariants() do
print(s.shader, #s.variants, "of", s.budget)
print(renderer.variantSource(s.base.program)) -- what it ships as
for _, v in s.variants do
print(v.program, renderer.variantSource(v.program)) -- what one asked for
end
end
```
A shader registers under every key it answers to — its identity, its guid, each
alias — and each key is a row. A variant lands on the row for the key the
material named, which for authored content is the guid, so take a program name
from the report rather than composing one.
## Materials reference shaders
A `.material` applies a shader to entities. Its `mat.yaml` references the shader by
its identity string and supplies property values:
```yaml
name: my_material
shader: "@builtin::shaders.pbr"
render:
blend: alphaBlend
cull: back
depth_write: false
floats:
roughness: 0.5
colors:
base_color: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }
textures:
base_color_texture: { ref: "default:white" }
```
`shader:` is a plain identity string, and each `colors:` entry is an
`{ r, g, b, a }` map — the two forms `mat.yaml` accepts for those keys. A key's
value is the rest of its line, so every value above stands alone on one.
### The render block
Render behaviour that used to be material "flags" — the blend equation, alpha
cutout, which faces are drawn — lives in the `render:` block and drives the
pipeline, not the fragment. These are the keys that reach the pipeline from both
authoring routes, and the values each one takes:
| Key | Values | Sets |
|-----|--------|------|
| `blend` | `opaque` `transparent` `alpha` `alphaBlend` `additive` `add` `cutout` `alphaCutout` `premultiplied` `premultipliedAlpha` `multiply` | the equation the fragment composites with |
| `cull` | `back` `front` `none` `off` `disabled` | which faces the rasterizer keeps |
| `depth_write` | `true` `false` | whether the draw writes depth |
| `depth_compare` | `less` `lessEqual` `lequal` `equal` `greater` `greaterEqual` `gequal` `always` `never` | the test a fragment's depth passes |
| `topology` | `triangleList` `lineList` `lines` `line` | what consecutive vertices assemble into |
`blend` is also spelled `type`, and the two name one key. Its equations, over
the fragment the shader returned (`src`) and the value already in the target
(`dst`): `opaque` is `src`; `transparent`, `alpha` and `alphaBlend` are
`src * src.a + dst * (1 - src.a)`; `additive` and `add` are `src + dst`, the
mode a glow, a light shaft or a spark card is written for; `premultiplied` and
`premultipliedAlpha` are `src + dst * (1 - src.a)`, taking a source that already
carries its own coverage; `multiply` is `src * dst`, which is how a soot, grime
or shadow card darkens what stands behind it. `cutout` and `alphaCutout` keep
the fragment or discard it on its alpha and go on writing depth.
The same block is what `asset.create`'s `render` argument takes, key for key and
value for value, so one material named in Luau and the same material standing on
disk ask for their pipeline with the same words. `depth_write` is the one key
whose value takes the shape of its route: the word `false` on a `mat.yaml` line,
the boolean `false` in the Luau table.
```lua
asset.create("material", "heat_plume", {
shader = "@builtin::shaders.pbr",
render = { blend = "alphaBlend", cull = "none", depth_write = false },
})
```
Where a value outside a key's set is reported is what the two routes differ on.
`asset.create` refuses the call on any of these keys and names the set it
measured the value against. A `mat.yaml` standing on disk registers either way,
each key falling back to a value of its own: `blend` to `opaque`, `cull` to
`back`, `depth_write` to `true`, `depth_compare` to `less` and `topology` to
`triangleList`. The engine log carries the blend fallback, naming the value and
the set it was measured against, so a `mat.yaml` value is worth putting through
`asset.create` once — that is where every key answers with its set.
## Registration & hot reload
A `.shader` is compiled by the asset system, not by the renderer: writing
`shader.wgsl` or `properties.yaml` fires the `.shader` assetType's `onChange`,
which calls `shader.compile` — that reads the body + the property schema, generates
the group(1) interface and (for surface) every render-mode entry point, expands
`#include`s, **validates the result with naga**, and registers it. Validation
happens at this compile step — the whole point of "compile". A shader with invalid
WGSL (an unknown field, a type mismatch, an undefined function) **fails to compile**
with the real reason — readable directly from Luau via
`shaderRef:compileStatus()` (`{ status = "compiled" | "failed" | "pending",
error?, 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.
·voxeltemplate · born here
❒asset # bookshelf
Voxel template `bookshelf` (demo asset). Live instances spawned via `Voxel.fromTemplate("bookshelf")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # bot_seated
Voxel template `bot_seated` (demo asset). Live instances spawned via `Voxel.fromTemplate("bot_seated")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # bot_standing
Voxel template `bot_standing` (demo asset). Live instances spawned via `Voxel.fromTemplate("bot_standing")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # cabinet
Voxel template `cabinet` (demo asset). Live instances spawned via `Voxel.fromTemplate("cabinet")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # chair
Voxel template `chair` (demo asset). Live instances spawned via `Voxel.fromTemplate("chair")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # clock
Voxel template `clock` (demo asset). Live instances spawned via `Voxel.fromTemplate("clock")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # desk
Voxel template `desk` (demo asset). Live instances spawned via `Voxel.fromTemplate("desk")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # keyboard
Voxel template `keyboard` (demo asset). Live instances spawned via `Voxel.fromTemplate("keyboard")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # lamp
Voxel template `lamp` (demo asset). Live instances spawned via `Voxel.fromTemplate("lamp")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # linked_workstation_baked
Voxel template `linked_workstation_baked` (demo asset). Live instances spawned via `Voxel.fromTemplate("linked_workstation_baked")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # monitor
Voxel template `monitor` (demo asset). Live instances spawned via `Voxel.fromTemplate("monitor")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # mug
Voxel template `mug` (demo asset). Live instances spawned via `Voxel.fromTemplate("mug")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # plant
Voxel template `plant` (demo asset). Live instances spawned via `Voxel.fromTemplate("plant")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # printer
Voxel template `printer` (demo asset). Live instances spawned via `Voxel.fromTemplate("printer")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # trash
Voxel template `trash` (demo asset). Live instances spawned via `Voxel.fromTemplate("trash")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # whiteboard
Voxel template `whiteboard` (demo asset). Live instances spawned via `Voxel.fromTemplate("whiteboard")` reload automatically when this template's `template.voxbin` payload is rewritten.