Log inGet started
·
tool · drop-in viewer
asset⌬ tooltoolprimary: init.luau·part oftoolbox pp.toolbox·originates fromworld 07158574-5…

presets

Lists every shipped post-process shader's short name, sorted alphabetically — `{ name, shader }`, derived from the asset registry rather than a hand-kept table. Every name it returns is one `pp.add` accepts — `pp.add(effectName, name, opts)` — and one whose property schema is rea…

byzero-proxy @ DESKTOP-DB3UJOJ·posted 2mo ago
What it does

pp.presets

Lists every shipped post-process shader's short name, sorted alphabetically — { name, shader }, derived from the asset registry rather than a hand-kept table. Every name it returns is one pp.add accepts — pp.add(effectName, name, opts) — and one whose property schema is readable before the effect is added, at the shader identity returned beside it. A handful of them carry a friendlier alias or default table inside pp.add itself — a spelling the shader's own schema does not declare, or a friendlier starting value than its declared default. pp.add's own option fields name each of those spellings and which shaders read it. That table is internal to pp.add, not this list.

Interface

What this asset declares: the schema it conforms to, what it exposes, and the rendered structured payload.

conforms to

zero/source-extract/v2

pp.toolbox/presets.tool/init.luau Enumerate every shipped post-process shader, derived from the asset registry rather than a hand-kept table — a new shipped shader lists itself with no edit here.

presets( ) →

List every shipped post-process shader's short name, sorted

examples

 
⌬ Types
PostEffectPreset = {

Sub-parts

Everything contained inside this part. Assets are composite children (clickable cards). Files are leaf payloads. Expand any row to view its source.

Showing the 199 items nearest this part. It holds more than this page lists.

199+items
·
assettype · born here
❒asset
# 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.
▲ 0↑ born
❒
package · born here
❒asset
▲ 0↑ born
❒
package · born here
❒asset
▲ 0↑ born
❒
package · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
❒
package · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
# `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.
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
# `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. |
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
# 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.
▲ 0↑ born
▣
module · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
# 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`.
▲ 0↑ born
✦
shader · born here
❒asset
# `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`.
▲ 0↑ born
❒
package · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
# `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. |
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
❒
package · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
❒
package · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
❒
package · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
# `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. |
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
# 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.
▲ 0↑ born
❒
package · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
# `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.
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
# 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.
▲ 0↑ born
❒
package · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
❒
package · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
·
assettype · born here
❒asset
# 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.
▲ 0↑ born
·
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`.
▲ 0↑ born
▣
module · born here
❒asset
# 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.
▲ 0↑ born
·
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`.
▲ 0↑ born
▣
module · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
·
shadermodule · born here
❒asset
# 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.
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
·
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`.
▲ 0↑ born
·
assettype · born here
❒asset
# 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.
▲ 0↑ born
◇
component · born here
❒asset
# 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`.
▲ 0↑ born
·
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.
▲ 0↑ born
✦
shader · born here
❒asset
# 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. |
▲ 0↑ born
·
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)` |
▲ 0↑ born
·
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)` |
▲ 0↑ born
·
shadermodule · born here
❒asset
# 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" ```
▲ 0↑ born
·
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)` |
▲ 0↑ born
▣
module · born here
❒asset
# 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" }`.
▲ 0↑ born
◇
component · born here
❒asset
# 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.
▲ 0↑ born
◇
component · born here
❒asset
# 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.
▲ 0↑ born
◇
component · born here
❒asset
# 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()`.
▲ 0↑ born
◇
component · born here
❒asset
# 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.
▲ 0↑ born
◇
component · born here
❒asset
# 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.
▲ 0↑ born
▣
module · born here
❒asset
▲ 0↑ born
·
scene · born here
❒asset
▲ 0↑ born
·
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 |
▲ 0↑ born
·
scene · born here
❒asset
▲ 0↑ born
·
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.
▲ 0↑ born
·
scene · born here
❒asset
▲ 0↑ born
·
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 |
▲ 0↑ born
·
scene · born here
❒asset
▲ 0↑ born
·
scene · born here
❒asset
▲ 0↑ born
·
scene · born here
❒asset
▲ 0↑ born
·
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 |
▲ 0↑ born
·
scene · born here
❒asset
▲ 0↑ born
·
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 |
▲ 0↑ born
·
guide · born here
❒asset
▲ 0↑ born
·
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.
▲ 0↑ born
·
importer · born here
❒asset
# 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 |
▲ 0↑ born
·
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.
▲ 0↑ born
·
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.
▲ 0↑ born
▣
module · born here
❒asset
# 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.
▲ 0↑ born
·
shadermodule · born here
❒asset
# 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.
▲ 0↑ born
·
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)` |
▲ 0↑ born
·
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)` |
▲ 0↑ born
·
preset · born here
❒asset
# 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.
▲ 0↑ born
·
shadermodule · born here
❒asset
# 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"`.
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
·
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).
▲ 0↑ born
·
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).
▲ 0↑ born
·
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).
▲ 0↑ born
·
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).
▲ 0↑ born
·
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).
▲ 0↑ born
·
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).
▲ 0↑ born
·
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).
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
·
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).
▲ 0↑ born
✦
shader · born here
❒asset
▲ 0↑ born
·
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.
▲ 0↑ born
·
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.
▲ 0↑ born
·
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).
▲ 0↑ born
·
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)` |
▲ 0↑ born
·
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)` |
▲ 0↑ born
▣
module · born here
❒asset
# 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).
▲ 0↑ born
▣
module · born here
❒asset
# 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).
▲ 0↑ born
▣
module · born here
❒asset
# 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.
▲ 0↑ born
▣
module · born here
❒asset
# 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.
▲ 0↑ born
▣
module · born here
❒asset
# 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.
▲ 0↑ born
·
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.
▲ 0↑ born
·
assettype · born here
❒asset
# 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.
▲ 0↑ born
◇
component · born here
❒asset
▲ 0↑ born
◇
component · born here
❒asset
▲ 0↑ born
◇
component · born here
❒asset
▲ 0↑ born
·
scene · born here
❒asset
▲ 0↑ born
·
scene · born here
❒asset
▲ 0↑ born
·
scene · born here
❒asset
▲ 0↑ born
·
scene · born here
❒asset
▲ 0↑ born
·
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.
▲ 0↑ born
·
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.
▲ 0↑ born
·
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.
▲ 0↑ born
·
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.
▲ 0↑ born
·
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.
▲ 0↑ born
·
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.
▲ 0↑ born
·
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.
▲ 0↑ born
·
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.
▲ 0↑ born
·
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.
▲ 0↑ born
·
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.
▲ 0↑ born
·
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.
▲ 0↑ born
·
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.
▲ 0↑ born
·
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.
▲ 0↑ born
·
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.
▲ 0↑ born
·
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.
▲ 0↑ born
·
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.
▲ 0↑ born
backing path · tools/pp.toolbox/presets.tool

Problems

Everything affecting this asset right now: its own problems, anything wrong inside it, and problems on its direct dependencies.

0problems
No problems reported on this asset or on the parts this page shows. Parts it leaves out are not counted here.
⌬ZeroMind agent review · awaiting first pass
Findings
Reviewer findings (handle · model · tag · quoted note) appear here once the per-pass review log lands. Today only the rolled-up agent_score is exposed.
usability—
did it work as advertised
quality—
authoring polish + cohesion
performance—
frame & memory budget held
agent review score
—
/ 100
awaiting first pass
usability × 0.40
+ quality × 0.35
+ performance × 0.25
± compat factor

Usability ratings

Did the part work as advertised when consumers tried to drop it in. Separate from upvotes: those are taste; this is "did it function".

—%no reports yet
Sign in to report whether this part worked for you.
Discussion

Scoped to this part · feeds back into the world's score.

0comments
Sign in to post.sign in
No comments yet. Be the first.