ZeroJS is a JavaScript interpreter for the Zero engine, written in Luau. It parses and executes ES2017-practical JavaScript entirely inside the Luau VM — no native code, no WebAssembly, no external dependency — and marshals values back to Luau transparently.
## Importing
```luau
local zerojs = require("~zerojs.api")
```
`~zerojs` is this package's alias, registered from the `name` in its `package.yaml` wherever the package sits. The same spelling therefore works from the world that authors the package and from a world that installed it, at any depth. Every module is reachable the same way — `~zerojs.parser`, `~zerojs.stdlib.promise` — and the package's own modules reference each other with those ids.
## API surface
### Module-level
| Symbol | Type | Description |
|--------|------|-------------|
| `zerojs.eval(src)` | `(string) -> any` | Parse and execute `src` in a fresh one-shot VM. Returns the marshaled JS completion value. Raises a Luau error on a JS exception. |
| `zerojs.new(opts?)` | `(table?) -> VM` | Create a persistent VM. `opts.env` seeds additional JS globals from a Luau table. `opts.engine` picks the tier (`"auto"`, `"compiled"`, `"interp"`). `opts.console` is a table of writers by level (`log`, `info`, `debug`, `warn`, `error`): each receives the formatted text of one console call, untagged, and a level without a writer uses `log`'s. With no `opts.console`, the console writes `[zerojs]`-tagged lines to the engine log. |
| `zerojs.null` | sentinel | The JS `null` value (distinct from Luau `nil`). |
| `zerojs.undefined` | sentinel | The JS `undefined` value, which marshals to Luau `nil` when returned. |
| `zerojs.version` | `string` | Version string of this package. |
| `zerojs.lastError(vm)` | `(VM) -> table?` | Returns the structured payload `{ name, message, stack }` from the most recent JS exception on `vm`. `nil` if no exception has occurred on that VM. |
### VM instance
| Method | Description |
|--------|-------------|
| `vm:run(src, opts?)` | Execute `src` in this VM's persistent realm. `opts.sourceName` names the source for error messages and stack frames. Returns the marshaled completion value. Raises directing to `runAsync` when the script leaves a timer armed or a coroutine suspended across a frame. |
| `vm:runAsync(src, opts?)` | Execute `src`, driving its event loop across engine frames until it settles. Returns a `RunHandle` immediately. `opts.timeoutMs` rejects the run if it outlasts that many milliseconds. Only one `runAsync` may be in flight per VM at a time; a second call while the first is unsettled raises. |
| `vm:get(name)` | Read a global from the VM's realm. |
| `vm:set(name, value)` | Write a marshaled Luau value as a JS global in the VM's realm. |
### `RunHandle` (returned by `vm:runAsync`)
| Field | Description |
|-------|-------------|
| `done` | `false` until the run reaches a terminal state, then `true`. |
| `ok` | Once `done`, `true` for a resolved run and `false` for a rejection or timeout. |
| `value` | The marshaled completion value on a resolved run. A run that timed out after its script had already completed with a value (an armed `setInterval`, say, kept the run open) keeps that value here; every other failure leaves it `nil`. |
| `error` | The rendered message text on a rejected or timed-out run. |
| `lastError` | The structured `{ name, message, stack }` payload for a failed run. |
| `thread` | The engine `task.spawn` driver pumping the loop each frame — present only while the run is still pumping, and `nil` once the run settles on its arming frame (a synchronous or microtask-only completion needs no per-frame driver). |
| `handle:await(maxFrames?)` | From a yieldable Luau context, poll `done` frame-by-frame until the handle settles, then return it. |
A `runAsync` stays pending while the loop has work: an armed `setInterval` keeps it pending exactly as an un-`unref`'d handle keeps Node alive — clear the interval or bound the run with `timeoutMs`.
## Marshaling
Luau values passed to `opts.env` or `vm:set` are converted to JS values. JS values returned from `run`/`eval` are converted back to Luau: numbers, strings, and booleans are transparent; arrays become 1-based Luau tables; objects become string-keyed Luau tables; JS functions become callable Luau closures; `undefined` becomes `nil`; `null` becomes `zerojs.null`.
## Step-budget sandbox
Each VM runs with a default execution budget of 1,000,000 interpreter steps. A script that exceeds the budget raises a JS `RangeError: step budget exceeded`, which surfaces as a Luau error. The budget resets on each `run`/`eval` call.
## Language scope
- Variable declarations: `var` (function-scoped hoisting), `let` and `const` with full TDZ enforcement
- Functions: declarations, expressions, arrow functions, closures, rest parameters, default parameters
- Classes: declarations and expressions, inheritance via `extends`, `super()` in constructors and methods, getter and setter accessors, instance fields, static fields and methods, computed method keys, named class expressions, TDZ before declaration
- Destructuring: object and array patterns in declarations, assignment, and function parameters; nested patterns; rename, default, rest; computed keys in object patterns; elision in array patterns
- Spread: array literals, call arguments, `new` arguments, object literals
- Template literals (untagged)
- Optional chaining: `a?.b`, `a?.[k]`, `f?.(x)`. A `null` or `undefined` base ends the whole chain with `undefined` and skips every later link and argument; parentheses end the chain; the chain is not an assignment, update or `new` target
- Nullish coalescing `??` and the logical assignments `??=`, `||=` and `&&=`, which evaluate the right side and write only when the current value calls for it. `??` does not mix with `||` or `&&` without parentheses
- Full coercion and equality semantics: `==`/`!=` abstract equality, `===`/`!==` strict equality, `typeof`, `instanceof`
- `try`/`catch`/`finally`, `throw`
- All loop forms: `for`, `while`, `do-while`, `for-in`, `for-of`; `switch` with fallthrough
- `async`/`await` — real event-loop semantics: an async function returns a promise, runs synchronously up to the first `await`, and resumes from a microtask when its awaited operand settles
- Property enumerability: `for-in` yields only own enumerable keys; object rest excludes prototype methods
### Asynchrony and the event loop
- `Promise` with the full ES surface: `then`/`catch`/`finally`, `resolve`/`reject`, and the combinators `all`, `allSettled`, `race`, and `any` (with `AggregateError` when every input to `any` rejects)
- `queueMicrotask` for scheduling a microtask directly
- Timers: `setTimeout`/`clearTimeout` and `setInterval`/`clearInterval`, with extra arguments forwarded to the callback
- `vm:runAsync(src, opts?)` drives a script's timers, microtasks, and promise settlements across engine frames and returns a `RunHandle` (see the VM table above)
- A long synchronous loop under `runAsync` yields at each step-budget boundary and resumes on a later frame instead of raising `RangeError`; the same script under `vm:run` still raises
### Standard library
- `Object`: callable coercion plus `keys`, `values`, `entries`, `assign`, `freeze`, `isFrozen`, `create`, `getPrototypeOf`, `defineProperty`, `fromEntries`, `hasOwn`, and the `Object.prototype` methods (`hasOwnProperty`, `toString`, `isPrototypeOf`)
- `Function.prototype`: `call`, `apply`, `bind`
- Global conversions and predicates: `parseInt`, `parseFloat`, `isNaN`, `isFinite`, `String`, `Number`, `Boolean`
- `Array` and `Array.prototype`: construction, `isArray`, `from`, `of`, and the iteration/mutation/query methods (`map`, `filter`, `reduce`, `forEach`, `find`, `some`, `every`, `slice`, `splice`, `concat`, `join`, `indexOf`, `includes`, `sort`, `reverse`, `push`, `pop`, `shift`, `unshift`, `flat`, `fill`, `keys`, `values`, `entries`)
- `String.prototype`: `slice`, `substring`, `substr`, `split`, `indexOf`, `includes`, `startsWith`, `endsWith`, `repeat`, `padStart`, `padEnd`, `trim`, `trimStart`, `trimEnd`, `toUpperCase`, `toLowerCase`, `charAt`, `charCodeAt`, `codePointAt`, `at`, `concat`, `replace`, `replaceAll`, `match`, `matchAll`, `search`
- `Number`: `isInteger`, `isSafeInteger`, `isFinite`, `isNaN`, `parseFloat`, `parseInt`, `MAX_SAFE_INTEGER`, `MIN_SAFE_INTEGER`, `MAX_VALUE`, `MIN_VALUE`, `EPSILON`, `POSITIVE_INFINITY`, `NEGATIVE_INFINITY`, `NaN`, and `Number.prototype` (`toFixed`, `toString`, `toPrecision`)
- `Math`: the constants and functions (`abs`, `floor`, `ceil`, `round`, `trunc`, `sign`, `min`, `max`, `pow`, `sqrt`, `cbrt`, `hypot`, `exp`, `log`, `log2`, `log10`, the trig family, `random`)
- `Date`: construction as `new Date()` (now) and `new Date(ms)` (epoch milliseconds), `now` (the wall clock at the moment of the call, never ahead of it and never moving backwards; whole-second readings of the wall clock bound it until a reading crosses a second boundary), `getTime`, `getFullYear`, `getMonth`, `getDate`, `getHours`, `getMinutes`, `getSeconds`, `getMilliseconds`, `getDay`, `toISOString`, `toJSON`
- `JSON`: `parse` and `stringify` (indent argument and array replacer whitelist)
- `Map` and `Set`: construction from iterables, `get`/`set`/`has`/`delete`/`clear`, `add`, `size`, `forEach`, `keys`, `values`, `entries`, insertion-order iteration
- `RegExp`: literals and the constructor, `test`, `exec`, `lastIndex`, `source`, `flags`, `global`, `ignoreCase`, `multiline`, `dotAll`, and the `String` regex methods above
- Errors: `Error`, `TypeError`, `RangeError`, `ReferenceError`, `SyntaxError` constructors and inheritance; `Error.prototype.name`, `message` and `toString`, so `String(e)`, `${e}` and `"" + e` read `<name>: <message>`
- `console`: `log`, `info`, `debug`, `warn`, `error`, `dir` and `assert`. Values print the way a JavaScript console prints them: strings bare at the top and quoted inside, `{ a: 1, b: [ 1, 2 ] }`, `Map(1) { 'a' => 1 }`, `[Function: f]`, errors as `Error: message`, nested values below depth 2 as `[Object]` and `[Array]`, arrays and collections cut after 100 entries, a self-containing value as `<ref *1> { self: [Circular *1] }`, and a rendering wider than 80 characters across lines. A leading `%s`, `%d`, `%i`, `%f`, `%j`, `%o`, `%O`, `%c` or `%%` formats the arguments after it
- `performance.now()` (milliseconds on a monotonic clock since the VM was created) and `performance.timeOrigin`
- `globalThis`, `NaN`, `Infinity`
### Out of scope by design
- Generators and iterators (`function*`, `yield`)
- `Proxy` and `Reflect`
- Labeled statements and `with`
- Tagged template literals
The full divergence list — where ZeroJS behavior differs from a browser engine — lives in the guide's divergence section.
## Test suites
Eleven suites ship with the package, in its `tests/` folder, run via `tools.tests.run`:
```luau
tools.tests.run({ suite = "zerojs_lexer" }) -- tokenization
tools.tests.run({ suite = "zerojs_parser" }) -- AST construction and error recovery
tools.tests.run({ suite = "zerojs_semantics" }) -- runtime semantics end-to-end
tools.tests.run({ suite = "zerojs_language" }) -- ?., ??, logical assignment, error text, console, Date.now, performance.now
tools.tests.run({ suite = "zerojs_stdlib" }) -- the standard-library surface
tools.tests.run({ suite = "zerojs_typed_arrays" }) -- typed arrays, ArrayBuffer, DataView
tools.tests.run({ suite = "zerojs_regex" }) -- the regex engine and RegExp methods
tools.tests.run({ suite = "zerojs_async" }) -- the event loop, promises, and timers
tools.tests.run({ suite = "zerojs_modules" }) -- the module graph
tools.tests.run({ suite = "zerojs_assettype" }) -- the zJs asset type
tools.tests.run({ suite = "zerojs_smoke" }) -- unmodified real-world JS programs
```
# editorPanel
A dock tab in the editor, as an asset. A `<name>.editorPanel/` folder carries an
`init.luau` that returns the tab's spec:
```luau
local edui = require("@builtin::~edui.framework")
return {
title = "My Tab", -- the tab's label (`label` is accepted too)
order = 60, -- sort position in the menu
dock = "bottom", -- seeds the FIRST arrangement: left | right | bottom | center
menu = "scene", -- which top-bar menu lists it; an unknown name creates that menu
startClosed = true, -- registered closed; opened from the menu
build = function() -- the tab's body, called again whenever it should change
return edui.panel{ body = edui.text{ text = "hello" } }
end,
}
```
`build` is the only required field. The tab id defaults to the folder stem
(`myTab.editorPanel` → `myTab`); an explicit `id` overrides it, and a duplicate
id replaces the prior registration. An init may return a LIST of specs when one
asset carries several tabs — a spec this registration no longer declares has its
tab taken down.
The spec is forwarded whole to `edui.shell.addPanel`, so anything the shell
accepts (`badge`, `badgeColor`, …) works here; `man edui.shell` is its contract.
`build` may return `nil` to mean "nothing changed, keep what is drawn" — the
shell rebuilds open tabs on a timer, and a panel that re-publishes an identical
tree pays for it. Only the ACTIVE tab of a dock leaf is rebuilt at all.
An `init.luau` that fails to load does not break the editor: its tab comes up
carrying the failure — the path and the error, selectable — so a panel that will
not compile says so where you would look for it.
Every instance of this type becomes a tab: the ones in the open world, and the
ones the built-in library ships, on the same terms.
Adding a tab to a project is creating a new `editorPanel` asset: it comes from
the template above and registers its tab at once, wherever under `/source` it
was made (`editorPanels/` is the type's conventional folder). Editing the
`init.luau` re-registers the tab, deleting the folder removes it. Nothing
registers panels by name anywhere.
`guides { path = "editor/edui/edui" }` — the guide beside this type, inside
the edui package — covers the build model, where panel state lives, and the
`edui.*` surface a body draws with.
## In the Inspector
Panels: each panel id this file registers (`panelRef:panelIds()`), whether it is open, an **Open** action for it, and **Open** for the source. A file whose init.luau does not load says why.
# version-control guide
Reference docs surfaced through `guides { path: "core/version-control" }` and
`man core/version-control`. The content lives in `guide.md`; this README is
just the asset summary.
# ecs guide
Reference docs surfaced through `guides { path: "..." }` and `man ecs`.
The content lives in `guide.md`; this README is just the asset summary.
# topics/byte-streams guide
Reference docs surfaced through `guides { path: "..." }` and `man topics/byte-streams`.
The content lives in `guide.md`; this README is just the asset summary.
# 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.
# edui (module)
The editor UI framework, built on the CSS-parity `ui.*` surface. `edui` is
**editor-only** — gameplay and normal UI author raw `ui.*` widget trees
(`{ type, style, props, children }`) and need no framework. `edui` provides the
one thing a framework earns its keep for: editor chrome — a reactive app, a
dock shell, and chrome primitives (panels, trees, toolbars, inspector rows,
context menus, drag-and-drop). It replaces the deprecated `zui` for the editor.
## The reactive core (`edui.app`)
`ui.*` dispatches interaction through **string callback ids** — a widget
carries `props.onClick = "<id>"`, and the screen's registered env receives
`onCallback(id, data)`. Closures can't cross the FFI boundary, so raw `ui.*`
makes you manage those ids by hand.
An `edui` **app** hides that. During a build it allocates an id per closure and
records id→closure; its single `onCallback` looks the id up, calls the closure,
and rebuilds:
```lua
local edui = require("@builtin::modules.edui")
local count = 0
local app = edui.createApp("counter", { layer = editorMask })
app:mount(function(a)
return {
type = "vertical",
children = {
{ type = "label", props = { text = "count: " .. count } },
{ type = "button", props = { text = "+1",
onClick = a:cb(function() count += 1 end, "inc") } },
},
}
end)
```
- `app:cb(closure, key?)` — allocate a callback id for `closure`. Pass a stable
`key` so the id survives rebuilds (required for focus / drag / `ui.widgetState`).
- `app:dispatch(id, data)` — what the broadcast calls; routes id→closure and
rebuilds. Returns whether this app owned the id.
- `app:markDirty()` — request a rebuild (coalesced during an in-progress build).
- `app:rebuild()` / `app:mount(fn)` / `app:unmount()` — lifecycle.
- `edui.current()` — the app whose builder is running (the primitives use it so
you don't thread the app through every call).
Builder and handler errors are logged, never thrown — one bad build never
wedges the editor.
# generating-assets-and-content guide
Reference docs surfaced through `guides { path: "..." }` and `man generating-assets-and-content`.
The content lives in `guide.md`; this README is just the asset summary.
# troubleshooting guide
Reference docs surfaced through `guides { path: "..." }` and `man core/troubleshooting`.
The content lives in `guide.md`; this README is just the asset summary.
# topics/microphone guide
Reference docs surfaced through `guides { path: "..." }` and `man topics/microphone`.
The content lives in `guide.md`; this README is just the asset summary.
# topics/building-a-game guide
Reference docs surfaced through `guides { path: "..." }` and `man topics/building-a-game`.
The content lives in `guide.md`; this README is just the asset summary.
# topics/frame-streaming guide
Reference docs surfaced through `guides { path: "..." }` and `man topics/frame-streaming`.
The content lives in `guide.md`; this README is just the asset summary.
# components guide
Reference docs surfaced through `guides { path: "..." }` and `man components`.
The content lives in `guide.md`; this README is just the asset summary.
# authoring guide
Reference docs surfaced through `guides { path: "core/authoring" }` and
`man authoring`. The content lives in `guide.md`; this README is just the asset
summary.
The four rules that make authored content usable by somebody else: a callable
is a `typed function`, it says what it is in `--!` directives, a value is a
`Field.<kind>` carrying its description, and a metatable-dispatched surface
declares its members with `--!members`.
# home guide
Reference docs surfaced through `guides { path: "core/home" }` and `man core/home`.
The content lives in `guide.md`; this README is the asset summary.
# topics/http-server guide
Reference docs surfaced through `guides { path: "..." }` and `man topics/http-server`.
The content lives in `guide.md`; this README is just the asset summary.
# topics/physics guide
The engine's own physics, simulated inside its step: structures that bend, show their stress and break (bridges, trusses, cranes and towers built from joints that carry tension and compression and break past a force), side-on 2D games held to a plane (a bridge builder, a platformer, an Angry Birds-like), vehicles, ragdolls, and how things move, collide and deform: rigid bodies (static, dynamic, kinematic) and the colliders that shape them; gravity that changes from place to place (planet gravity a player walks all the way around, orbits, a pull toward an axis or a surface, a zone pulling its own way) and which way is down at any point; soft bodies that squash, wobble, drape and dent (jelly, cushions, balloons that inflate, cloth that folds without passing through itself, a model made soft), including dents and crumples that stay after an impact, a car body that keeps its crash damage and is repaired, and stiff deformable parts that hold their shape (a beam, a chassis rail, a panel) on the FEM solver; soft regions held to rigid bodies; air and water acting on a body inside the step (buoyancy that floats a crate, a raft or a boat at the depth its density gives and tips a hull by where its load sits, water and air drag and a falling body's terminal speed, wind fields that blow and gust over a level or inside a box, the lift, drag and stall of a wing, spoiler, sail, keel, glider or kite with control surfaces a script deflects, a spinning ball's Magnus curve, and a flag or cape that streams and flutters in the wind); bodies and characters going into and out of the water, from a pool, a lake, a river or the sea (a splash at the point and speed it hit the surface, a splash sound, a swim state, a count of what is in the pool) and how much of each stands under and how deep; vehicles on wheels; drivetrains and geared machines (engines, clutches, gearboxes, gear ratios, differentials, shafts, winches) as networks of rotating parts; ragdolls of any rigged character or creature (a death, a knockout, a hit reaction, a crash-test dummy, one limp limb), each bone body taking every `Physics` call; joints, breakable joints and the load each carries (tension, compression, shear, bending: the stress a structure is coloured by); rails that hold a body to a track, a curve or a line (a rollercoaster car round a loop, a train on its line, a zip-line trolley, a door on a curved track, a bead on a wire) with end stops, a motor and friction; raycasts and overlaps; acceleration and g-force probes that read what a point on a body feels at every solver substep; the reads that report what the solver holds for a body and why it is not moving; replays: a stretch of simulation recorded and played back (an instant replay, a crash in slow motion, a kill cam, scrubbing back and forth, reversed, from any camera) while the live simulation keeps its state; snapshots that put the whole simulation back to a moment (a bridge tested and reset, a crash retried, an undo), saved and loaded as bytes; and state digests and determinism: a fingerprint of the whole simulation per step, the first step, body and property where two runs part, and when a replay, a verified run or a native and a browser engine reach the same bits.
Surfaced through `guides { path: "topics/physics" }` and `man topics/physics`. The content lives in `guide.md`.
# plugin-development guide
Reference docs surfaced through `guides { path: "..." }` and `man core/plugin-development`.
The content lives in `guide.md`; this README is just the asset summary.
# scenes guide
Reference docs surfaced through `guides { path: "..." }` and `man scenes`.
The content lives in `guide.md`; this README is just the asset summary.
# tool-system guide
Reference docs surfaced through `guides { path: "..." }` and `man tool-system`.
The content lives in `guide.md`; this README is just the asset summary.
# topics/screen-coordinates guide
Screen coordinates: projecting a world position to the screen (`worldToScreen`), the mouse cursor position and a touch, raycasting from a screen point through the camera (`screenToRay`, `Physics.raycastScreen`), and placing a HUD marker, a name tag, a health bar or an off-screen edge arrow over a 3D object. The scene viewport's physical pixels and the UI's logical points, and `ui.fromViewport` / `ui.toViewport`, the step between them.
Surfaced through `guides { path: "topics/screen-coordinates" }` and `man topics/screen-coordinates`. The content lives in `guide.md`.
# topics/ui guide
Reference docs surfaced through `guides { path: "..." }` and `man topics/ui`.
The content lives in `guide.md`; this README is just the asset summary.
# topics/text guide
Reference docs surfaced through `guides { path: "topics/text" }` and
`man topics/text`. The content lives in `guide.md`; this README is just the
asset summary.
# scenes-as-code guide
Reference docs surfaced through `guides { path: "core/scenes-as-code" }`. The
content lives in `guide.md`; this README is just the asset summary.
# development guide
Reference docs surfaced through `guides { path: "..." }` and `man core/development`.
The content lives in `guide.md`; this README is just the asset summary.
Terrain, voxels, spatial streaming and mesh LOD: what a world has resident of
its detail right now, and why a chunk, tile, cell or level is not on screen.
# topics/render-layers guide
Reference docs surfaced through `guides { path: "..." }` and `man topics/render-layers`.
The content lives in `guide.md`; this README is just the asset summary.
# worlds guide
Reference docs surfaced through `guides { path: "..." }` and `man worlds`.
The content lives in `guide.md`; this README is just the asset summary.
# editor-ui guide
Reference docs surfaced through `guides { path: "..." }` and `man editor-ui`.
The content lives in `guide.md`; this README is just the asset summary.
# ik guide
Reference docs surfaced through `guides { path: "topics/ik" }` and `man ik`.
The content lives in `guide.md`; this README is just the asset summary.
# readme guide
Reference docs surfaced through `guides { path: "..." }` and `man readme`.
The content lives in `guide.md`; this README is just the asset summary.
# discovering guide
Reference docs surfaced through `guides { path: "..." }` and `man discovering`.
The content lives in `guide.md`; this README is just the asset summary.
# topics/raytracing guide
Reference docs surfaced through `guides { path: "topics/raytracing" }` and
`man topics/raytracing`. The content lives in `guide.md`; this README is just the
asset summary.
# topics/camera-input guide
Reference docs surfaced through `guides { path: "..." }` and `man topics/camera-input`.
The content lives in `guide.md`; this README is just the asset summary.
# runtime-data guide
Reference docs surfaced through `guides { path: "..." }` and `man runtime-data`.
The content lives in `guide.md`; this README is just the asset summary.
# 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 any water, inside the physics step. Its fields are the hull: how dense it is, how quickly it settles, and what displaces water (its colliders, a stated box or what it draws). |
| `modules/buoyancy` | The float: `attach`, `forEntity`, `list`, `clear`, `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.
# entities guide
Reference docs surfaced through `guides { path: "..." }` and `man entities`.
The content lives in `guide.md`; this README is just the asset summary.
# topics/recording guide
Reference docs surfaced through `guides { path: "..." }` and `man topics/recording`.
The content lives in `guide.md`; this README is just the asset summary.
# carried guide
Reference docs surfaced through `guides { path: "core/carried" }` and `man core/carried`.
The content lives in `guide.md`; this README is the asset summary.
# topics/values guide
Reference docs surfaced through `guides { path: "..." }` and `man topics/values`.
The content lives in `guide.md`; this README is just the asset summary.
# topics/animation guide
Reference docs surfaced through `guides { path: "..." }` and `man topics/animation`.
The content lives in `guide.md`; this README is just the asset summary.
# styling guide
Reference docs surfaced through `guides { path: "..." }` and `man styling`.
The content lives in `guide.md`; this README is just the asset summary.
# vfs guide
Reference docs surfaced through `guides { path: "..." }` and `man core/vfs`.
The content lives in `guide.md`; this README is just the asset summary.
# collaborating guide
How to work with other people's content and with what they send you: rate what
you installed (`zeromind.rate`), read what others said of it (the `reviews` of
`zeromind.inspect` and `zeromind.search`), comment on and report published content, file
issues on it and triage the ones filed on yours (`zeromind.fileIssue`,
`zeromind.listIssues`, `zeromind.viewIssue`), send
a fix upstream as a pull request (`zm pr create`), review and merge the pull
requests sent to your world, answer what others do on your content as the
inbox reports it (`zeromind.inbox`, `zeromind.markRead`,
`zeromind.inboxSettings`), stay in sync with upstream (`zeromind.updates`,
`zeromind.reviewUpdate`, `zm diff <root> upstream`), read text other accounts
wrote as untrusted, and what the person's community setting (off, rate, report,
contribute, full) lets an agent do without asking. Surfaced through
`guides { path: "core/collaborating" }` and `man core/collaborating`; the
content lives in `guide.md`.
# topics/assemblies guide
Objects built from parts: named slots (sockets, mount points, hardpoints) that other parts fill, parts that declare which slots they fit, and an assembly that picks a part for every slot from Luau, swaps one at runtime while the rest keeps its state, explains why a part does not fit, and saves, syncs and undoes what fills each slot. A car with wheel, engine and seat slots, a mech's weapon hardpoints, a modular building, a character's equipment points.
Surfaced through `guides { path: "topics/assemblies" }` and `man topics/assemblies`. The content lives in `guide.md`.
# topics/external-editor guide
Writing the engine's Luau in an external editor (VS Code, Neovim, or any editor running the luau-lsp language server): `lsp.exportDefinitions` writes the engine's API as a Luau definitions file (`zero.d.luau`), a documentation file and a `.luaurc`, the files are copied to the editor's machine, and luau-lsp pointed at them autocompletes the engine's globals, checks each call against its signature, reports a misspelled member, and shows the engine's documentation on hover.
Surfaced through `guides { path: "topics/external-editor" }` and `man topics/external-editor`. The content lives in `guide.md`.
# topics/bash guide
Reference docs surfaced through `guides { path: "..." }`, `man bash` and `man topics/bash`.
The content lives in `guide.md`; this README is just the asset summary.
# topics/paths guide
Reference docs surfaced through `guides { path: "..." }` and `man topics/paths`.
The content lives in `guide.md`; this README is just the asset summary: curves,
roads, tracks, rails, lanes and routes, and moving things along them.
# getting-started guide
Reference docs surfaced through `guides { path: "..." }` and `man core/getting-started`.
The content lives in `guide.md`; this README is just the asset summary.
# resource model guide
The read-first guide for working with meshes, textures, materials, and shaders. Explains the three forms every GPU-backed resource takes (authored asset, CPU-decoded data, live GPU resource), the Luau to CPU to GPU upload path, residency and memory management (load / unload / upload / drop / isResident), what keeps a runtime resource alive and how it is released (`renderer.references`, `renderer.collect`, `renderer.hold` / `renderer.release`, and the collection a root scene load runs), the asset material vs runtime material distinction (`gold.material` vs `renderer.material.create`), the asset graph (material to shader to texture), render textures, and surfaces.
Surfaced through `guides { path: "core/resource-model" }`. Content lives in `guide.md`.
# multiplayer guide
Reference docs surfaced through `guides { path: "..." }` and `man multiplayer`.
The content lives in `guide.md`; this README is just the asset summary.
# topics/localization guide
The player's language, and showing a world in it: a world's text in
catalogs (`/zero/source/locales/<tag>/<namespace>.json`) read with `i18n.t`
and `i18n.text`, plurals, numbers, dates and sizes in the player's language,
the pseudo-locale and the catalog checks; which languages the player's
platform says they read, `i18n.pick` to choose among the languages a
world has text for, following a language change while they play, trying the
world under another visitor's languages, and how the engine picks its own
locale. Read it with `guides { path: "topics/localization" }` or
`man topics/localization`.
# topics/rendering guide
Reference docs surfaced through `guides { path: "..." }` and `man topics/rendering`.
The content lives in `guide.md`; this README is just the asset summary.
# asset-system guide
Reference docs surfaced through `guides { path: "..." }` and `man asset-system`.
The content lives in `guide.md`; this README is just the asset summary.
# subclient guide
Reference docs surfaced through `guides { path: "core/subclient" }` and `man subclient`.
The content lives in `guide.md`; this README is just the asset summary.
# topics/render-textures guide
Reference docs surfaced through `guides { path: "..." }` and `man topics/render-textures`.
The content lives in `guide.md`; this README is just the asset summary.
# edui guide
Building editor panels: creating the `editorPanel` asset, the spec it returns,
how a panel is drawn and redrawn, the whole widget vocabulary, and how a panel
talks to the selection and the dock. The content lives in `guide.md`; this
README is the asset summary.
# topics/input guide
How a world reads its players' hands on keyboard and mouse, a gamepad and a touch screen: controls as assets that carry every device, axis shaping, several live maps, and testing on every device; controllers beyond the standard pad (a steering wheel, separate pedals, a flight stick or joystick, a HOTAS, a button board), read and bound by each axis and button they report; and haptic output: rumble, trigger rumble, and force feedback (constant force, spring, damper, friction) on a wheel or stick, with the capability query that states what each device and platform offers.
Surfaced through `guides { path: "topics/input" }` and `man topics/input`. The content lives in `guide.md`.
# guides.shared
The guide catalogue, and the way a path resolves against it. Every tool in this toolbox reads it, so "which guides are there" has one answer whether it was asked from the shell, from Luau, or through MCP.
## What counts as a guide
A `.guide` asset wherever it lives — built into the engine, shipped inside a package, or authored in this world — and an asset TYPE, whose README documents it and which answers under `types/<name>`. Both are documentation, and the documentation search ranks over both, so both belong in the same catalogue.
## One guide, one entry
A guide reachable under two spellings is one asset, and the catalogue lists it once. The spelling that names a LOCATION wins the listing (`controlsKit.package/my-guide`, `topics/physics`) and every other spelling becomes an alias: the asset identity the engine states for it (`controlsKit.my-guide`, `@builtin::docs.topics.physics`), the form every other asset in a package is addressed by, and the spelling that names a PACKAGE. All of them keep resolving, so a path written against any of them still opens the guide, and `pathOf` is the one derivation of the listed path, which the documentation search hands back as a hit's `path` too.
## How a path widens
`resolve` takes the spelling a caller typed and widens only while the answer stays unique: the exact spelling (a listed path, an alias, the guide's guid, or the VFS path of its folder or its `guide.md`), then the same ignoring case, then any identity ending in `/<wanted>`, then a bare last segment, then a run of name segments, read the way `segments` reads them (split at `/`, `.` and `:`, with `@lib::` and `~` dropped), so `controlsKit/my-guide` reaches `controlsKit.package/my-guide`. The moment a widening matches more than one guide it stops and reports those spellings, because which one was meant is not knowable from what was typed — and answering with one of them would be a guess the caller could not see.
# core/observing
How to be told when something happens in a running world, instead of running
something, waiting, and hoping the timing was right.
Covers the `probe` toolbox: attaching an instrument to code that is already
running, the three things you can watch (a function call, a line, a value),
what a probe reports and why the text is required, and the case that costs the
most time — the world reaching a state you did not put it in, where every value
you read means something other than you assume.
Read it when the question is "when did this happen" or "what was true at that
instant". For "why does this look wrong" the guide is `core/troubleshooting`.
# engine guide
Reference docs surfaced through `guides { path: "..." }` and `man engine`.
The content lives in `guide.md`; this README is just the asset summary.
# topics/audio guide
Reference docs surfaced through `guides { path: "..." }` and `man topics/audio`.
The content lives in `guide.md`; this README is just the asset summary.
# topics/cutscenes guide
Reference docs surfaced through `guides { path: "topics/cutscenes" }` and
`man topics/cutscenes`. The content lives in `guide.md`; this README is just the
asset summary.
# topics/graphics-quality guide
Reference docs surfaced through `guides { path: "..." }` and `man topics/graphics-quality`.
The content lives in `guide.md`; this README is just the asset summary.
# performance guide
Reference docs surfaced through `guides { path: "..." }` and `man performance`.
The content lives in `guide.md`; this README is just the asset summary.
# scripting-and-tasks guide
Reference docs surfaced through `guides { path: "..." }` and `man scripting-and-tasks`.
The content lives in `guide.md`; this README is just the asset summary.
# content-graph guide
Reference docs surfaced through `guides { path: "..." }` and `man content-graph`.
The content lives in `guide.md`; this README is just the asset summary.
# topics/animated-images guide
Reference docs surfaced through `guides { path: "..." }` and `man topics/animated-images`.
The content lives in `guide.md`; this README is just the asset summary.
# topics/cameras guide
Reference docs surfaced through `guides { path: "..." }` and `man topics/cameras`.
The content lives in `guide.md`; this README is just the asset summary.
# requiring-modules guide
Reference docs surfaced through `guides { path: "..." }` and `man requiring-modules`.
The content lives in `guide.md`; this README is just the asset summary.
# calling-models guide
Call an AI language model, a judge model or any other metered offering from your own Luau and get its answer back directly: a reply's text, a verdict, a classification, with no run, job or file behind it.
Reference docs surfaced through `guides { path: "core/calling-models" }` and `man calling-models`.
# assetType (asset type)
An asset type says what a kind of asset is: the files an asset of the kind is
made of, the code that gives it behaviour, the interface a person uses to work
with it, and how it is found, pictured and checked. A `<typename>.assetType/`
folder declares a type called `<typename>`, and every `<name>.<typename>/`
folder is an asset of it. `assetType` is itself a type, so a type is an asset
like any other: it is created, versioned, installed and contributed the same way.
## Making a type
`asset.create("assetType", "<typename>")` scaffolds a type. The type registers
the moment its folder is written, anywhere under `/zero/source/`, and from then
on `<name>.<typename>/` folders are its assets. An asset of a type that is not
registered yet is not an asset, so make the type first.
A type folder is a root: `~.x` in any file inside it names the type's own `x`,
wherever the type is installed. Keep the type's whole implementation inside its
folder and reach it that way.
What a type folder must hold is set by this type's own `type.yaml`, the same way
every type's `type.yaml` sets what its assets hold. `asset.validate` on a type
reports anything missing, and the scaffold produces a folder that passes.
## What sets your type's schema (`type.yaml`)
`type.yaml` is the structural contract every asset of the type is checked
against. The keys below are the ones the engine reads; a key not listed here is
ignored.
| Key | What it controls |
|---|---|
| `suffix` | The folder suffix of the type's assets. Defaults to `.<typename>`. |
| `primary` | The file or files that are the asset's content: what the asset resolves to when it is used. |
| `entry_files`, `primary_aliases` | Further files that load as the asset or share its identity. |
| `is_container` | Whether the suffix stays in the identities of what the asset holds. |
| `is_opaque_walker` | Whether a walk of the tree stops at the asset and registers only its primary. |
| `plural_dir`, `classify_exts` | Loose files in a folder of this name, or with these extensions, are assets of the type. |
| `is_text_default` | Whether the asset's files are text unless stated otherwise. |
| `broadcasts_subassets` | Whether assets nested inside an asset of the type are found by their own short name. |
| `root` | `true` makes every asset of the type a root, so `~.x` inside one names that asset's own `x`. |
| `contract_ref: { field, category }` | A key of the primary file that names another asset the asset is built on. |
| `files: { required, optional }` | The files an asset holds (below). |
| `allow_unlisted` | `false` reports every file the type does not declare. |
| `aliases_from: [{ file, key }]` | Further names an asset answers to, read from its own files. |
| `settings:` | Typed per-asset settings (below). |
| `inspector:` | The interface people use for the type and its assets (below). |
| `preview:` | How an asset is pictured (below). |
| `references:` | Names of other assets the type's files carry (below). |
| `indexing:` | What search reads for each asset (below). |
| `describe:` | How a missing description is generated (below). |
### The files an asset holds
Each entry under `files.required` or `files.optional` is
`{ path, kind: file | folder, one_of_group?, content? }`. `*` in a path matches
within one segment. Entries that share a `one_of_group` are satisfied by any one
of them. `content` lists checks on the file's bytes:
| Check | Passes when |
|---|---|
| `non_empty` | the file has content |
| `json_field { key, type?, non_empty?, accept? }` | the JSON holds the key (a dotted path) with that shape |
| `yaml_field { key, type?, non_empty?, accept? }` | the same for YAML |
| `described { keys?, headings?, message? }` | the file says what the asset is (see "Ask your assets to say what they are") |
Each check takes a `message`, which is what the author is told when it fails.
### Settings
`settings: { values, schema: { type | instance, export? } }` gives every asset
typed settings. `values` names the file an asset's values live in; `schema.type`
declares one schema for the whole type, and `schema.instance` names a file each
asset declares its own in. `asset.create` writes the resolved values, and a write
to the values file reaches `onChange` with what changed.
## The human side (`inspector.luau`)
A type is used by people through its Inspector, and without one the type is
usable only from code. The Inspector is the type's interface for a person: for a
procedural graph it is the graph editor, for a kit it is the tracking view of
what the kit holds and what is still missing. Writing it is part of writing the
type.
`inspector: { type, instance? }` in `type.yaml` names the files. `type` is the
interface for the type itself and for its assets; `instance` names a file an
asset may ship to add its own. Each returns:
```luau
return {
sections = { { id = "graph", title = "Graph", build = function(ctx) ... end } },
replace = { contents = { ... } }, -- replace a default section by id
order = { "graph", "contents" }, -- section order
}
```
`build(ctx)` receives the asset and builds the section. A section's `title`, and
the text its rows carry, is a string or an `i18n.text` value read from a catalog
(`i18n.text("inspectors.mesh.title")`), which follows the editor's language. `asset.create("assetType",
...)` scaffolds a working `inspector.luau` to start from. `asset.validate` on a
type that names no inspector, or names one that does not load, reports it.
## Say what your type is for (`about.yaml`)
Every type carries an `about.yaml` that says what the type is for, in three
plain-language texts:
```yaml
intent: "Sets the typeface of the text in your game: UI, labels, signs and 3D lettering."
function: "A font, from a TTF, OTF or WOFF file, made available to all of Zero's text, including 3D text."
usage: "You choose it by name for your UI or your text, and the text appears in that typeface."
```
- **`intent`** is what the type is for in someone's game, in the words a game maker uses. Up to 800 bytes.
- **`function`** is how it works today: what an asset of it is made of and what the engine does with it. Up to 1500 bytes.
- **`usage`** is what a person or an agent does with one. Up to 1200 bytes.
Write about the type as a whole, not one asset of it, and say what it does now.
Agents read these texts to learn when to reach for the type and how; people
searching find the type by them. The scaffold starts each text with
`[[describe]]` and a note on what to write; a text that still starts that way
counts as not written. `services describe` can write them.
They show up in `asset.inspect` as `detail.about`, in
`assetTypeRef:getAbout()`, at the end of the type's `types/<typename>` guide,
and at the top of the type's Inspector.
## Ask your assets to say what they are
A type can require each asset to carry a description: a file, written by the
author or generated, saying what the asset is and what it is for. Declare it with
a `described` check:
```yaml
files:
required:
- path: "README.md"
kind: "file"
content:
- { check: "described", message: "Say what this widget is for and where it goes." }
```
A file that is missing, empty or still has a line starting with `[[describe]]`
fails the check, and so does a YAML file whose named `keys` are missing or over
their `max_bytes`, or a Markdown file missing a named heading. `asset.validate`
names the asset, and a push that carries it is refused with `message`.
`services describe` finds such assets and writes their descriptions.
`describe:` steers what is generated:
```yaml
describe:
guidance: "Say what the widget shows, what input it reacts to, and where it fits in a UI."
examples:
- "<identity of an asset of this type whose description reads well>"
```
An example is an asset of your type whose description is a true, written one,
never a placeholder and never the asset being described; with none, leave
`examples` empty. A type whose assets the model cannot perceive (a sound, a
motion) says so in `guidance`, and the description is drawn only from what the
asset's own files state.
Start the file unwritten in your `template/`: a heading and one line starting
with `[[describe]]`, never example content, which every new asset would carry
verbatim. A type whose assets say what they are through their own content (a
mesh, a texture) declares no check.
## The type's code (`behavior.luau`)
`behavior.luau` returns one table. These are the keys the engine reads from it;
anything else on the table is the type's own.
### What the type's assets can do
| Key | Shape | What the engine does with it |
|---|---|---|
| `ref` | `{ [name] = function(self, ...) }` | Every function becomes a method on every asset of the type: `ref:name(...)`. |
| `modules` | `{ [name] = require(".x") }` | Code the type's assets reach with `asset.containing(__FILE__).modules.<name>`. |
| `events` | an events schema | Events an asset fires. Readers subscribe through `ref.events.<name>:connect(...)`; the type fires them. |
| `refShapes` | `{ [method] = function(self) -> (typeExpr, source?) }` | The result type of a `ref` method for one particular asset (below). |
| `namePattern` | a Lua pattern | The names `asset.create` accepts for the type's assets. Default `^[A-Za-z][A-Za-z0-9_]*$`. |
| `derive` | `{ output, module, compute, fingerprint, matches?, required?, timeout? }` | A file computed from the asset when it is committed; a `required` one holds the commit until it lands. |
Some `ref` names mean the same for every type, and the engine calls them:
- **`instantiate(self, target?, opts?) -> (root, idMap)`**: puts the asset in a scene. Defining it is what makes the type scene-instantiable. Its return is a contract (below).
- **`inspect(self) -> detail`**: what `asset.inspect` reports for the asset beyond the common record (below).
- **`preview(self, opts)`**: renders the asset's picture; it wins over the type's `preview:` recipe.
- **`previewMaterial(self) -> { material }`**: the material a `surface` preview paints.
- **`saveDefinition(self)`**: writes the asset's in-memory edits back to its files in edit mode.
Some names are answered for every asset before the type is asked, so a `ref`
entry of the same name is never reached: `getSource`, `getBytes`, `getText`,
`exists`, `deps`, `canInstantiate`, `canPreview`, `preview` (which calls yours
when you define one), `meta`, `runtime`, `events`, `typeRef`, `modules`, and the
residency flags `has_backing_asset`, `has_runtime_changes`, `cpu_resident`,
`gpu_resident`.
### When the engine calls the type
| Hook | Signature | When it runs |
|---|---|---|
| `onCreate` | `(name, opts) -> { [path] = contents }` | On `asset.create`. Returns the files of the new asset, laid over the type's `template/`. Annotate `opts`: its type is the schema `asset.create` checks the caller's arguments against. A `""` key writes a single loose file. |
| `onReplace` | `(dest, files) -> files` | On an `asset.create` over an asset already at `dest`, after `onCreate`, so what the author added can be carried into the replacement. |
| `onRegister` | `(self)` | Once per asset, when it first registers: when the world loads, on a live `asset.create`, and when an asset arrives later (installed, pulled, synced). This is what makes an asset take effect the moment it is written. |
| `onChange` | `(ref, change)` | On every write to a file inside an asset of the type. `change` is `{ path, asset, type, kind, origin }`, plus `settings` and `settingsChanged` for a write to the settings values file; `kind` is `edited` or `seeded`, `origin` is `local` or `remote`. Filter on `change.path`. Writes to one asset arrive in order, each after the hook for the one before has finished, and the hook may wait on what it reads. A write the hook makes inside its own asset arrives later like any other, so keep the hook convergent. |
| `onDependencyChange` | `(ref, change)` | When a module the asset's code requires is re-run, or a name the asset carries starts resolving. `change.kind` is `dependency`. |
| `onDelete` | `(ref)` | When an asset of the type leaves the world: deleted, or the world it is in is swapped away. |
| `validate` | `(ref) -> { { code, message, severity?, path? } }` | On `asset.validate`, after the `type.yaml` checks, for the type's own rules. `severity` defaults to `error`. Runs on every push, so a type's rules hold at publish. A hook that fails reports `validate.hook_failed`. |
### Settings hooks
| Hook | When it runs |
|---|---|
| `settingsDefaults(name, opts, asked) -> table` | Once per `asset.create`, for values the schema's defaults do not give. |
| `settingsCorrections(values, name, opts) -> (corrections?, reasons?)` | On every settings write. A correction to a field the writer named refuses the write, giving the reason. |
| `settingsDerivedDefaults(values, explicit, changed)` | On a settings write, for fields derived from others. |
| `normalizeSettings(values, stored?)` | On every read and write, to map an earlier spelling of a field to the current one. |
| `settingsHealed(values, stored)` | On a read, to repair stored values. |
| `settingsSchemaProblem(schema, ref) -> string?` | When an asset's own schema is read; a returned string refuses the schema. |
`creationInputs` (`{ [opt] = true | selectorOpt }`) names `asset.create` options
that are inputs to the type rather than settings.
### Reloading
An edit to `behavior.luau` reaches existing assets with no restart.
## How an asset is pictured (`preview:`)
| `kind` | The picture is | The type provides |
|---|---|---|
| `instantiate` | the asset placed in the preview studio through its `instantiate` | `ref.instantiate` |
| `image` | a file the asset holds, fitted to the frame | `source: { files }`, the candidate files in order |
| `surface` | the asset painted on a mesh | `mesh`, and `ref.previewMaterial` |
| `custom` | whatever `ref.preview` returns | `ref.preview` |
```yaml
preview:
kind: surface
mesh: sphere
```
`angle: [yaw, pitch]` turns the camera. The studio owns the light, the backdrop
and the framing, so an asset pictures the same in every world. A type with no
`preview:` that defines `instantiate` still answers `ref:preview()`.
A picture is rendered when something asks to view it (the editor showing the
asset, `ref:preview()`), when previews are generated on request
(`assets.generatePreviews`), and when the asset is staged. An edit alone
renders nothing. The picture records what it was made
from, so a stale one is told from a current one.
## The names your type's assets hold (`references:`)
Where your type's own format or API carries the name of another asset, declare
it here:
```yaml
references:
keys: # a top-level key of the type's files that holds a name, or a table of them
- { key: "shader", type: "shader" }
args: # an argument of a call, counting from 1
- { call: "g:node", position: 2, type: "procNode" }
fields: # a field of the options table a call takes
- { call: "fx.beam", field: "material", type: "material" }
```
`type` is the category the name resolves in. `asset.declareReferenceKey`,
`asset.declareReferenceArg` and `asset.declareReferenceField` declare the same
from a script.
## What search reads (`indexing:`)
`indexing:` is how assets are found. Search reaches an asset only through what
its type's `indexing:` names, and adds nothing of its own. That includes you: an
asset indexed badly is one you cannot find later, when you are making something
else and need it. A type whose assets are never looked for says `indexing: none`.
```yaml
indexing:
data: # text, read from the asset's files
- role: identity
extractor: verbatim
source: { file: "README.md" }
- role: interface
extractor: symbols
source: { files: ["init.luau"] }
- role: implementation
extractor: verbatim
source: { files: ["init.luau"] }
content: # the picture
- role: identity
modality: image
source: { file: "preview.png" }
facets: # what a query filters and re-ranks by
file: ".metadata"
rigged: { type: bool, from: { components: { contains: SkinnedModel } } }
roughness: { type: number, filter: range, from: { file: "mat.yaml", field: "floats.roughness" } }
```
**Roles.** Each `data` and `content` entry has a `role`, and a query matches in
two slots:
- **`identity`**: what an asset is. For anything you look at, the picture; for anything else, the text that says what it is.
- **`capability`**: what it does and how it is made. `interface` (the signatures or shape a caller uses) and `implementation` (the body) both count as capability, and a type with code usually declares both.
Ask what a person would recognise an asset of the type by, and what it does, and
point each role at the file that says it. For text, that is the asset's
description: the file your template starts with a `[[describe]]` block, which the
author writes or `services describe` fills in (see "Ask your assets to say what
they are").
**`data`.** `extractor: verbatim` embeds a file's text as it is; `symbols` embeds
the declared signatures of code. `source` is `{ file }` or `{ files: [...] }`.
Never index a file name (`source: { field: name }`): one
or two words carry nothing to match.
**`content`.** `modality: image` embeds the picture itself, so a text query
matches what the asset looks like. A type whose assets are looked at makes the
picture its identity.
**`facets`.** What a query narrows and re-ranks the matches by. `file` is embedded
whole, so keys nobody declared in it are still found. Each further key is
computed for every asset and states a value for every one, including `false`: a
file can imply "not rigged" but never say it, and a query separates rigged from
unrigged only because the unrigged ones say `rigged: false`. A key is
`{ type: bool | number | enum, filter?: range, from }`, where `from` is
`{ file, field }` (a field of one of the asset's files, dotted for nesting),
`{ metadata: key }`, or `{ components: { contains | any_outside } }` (what the
asset's entities carry).
## What blocks a push
A push checks every asset it carries against its type. These refuse it:
- a required file that is missing, a `content` check that fails, a `described` file not written yet;
- an error from the type's `validate`.
Unlisted files and warnings do not. A `described` file blocks only a push that
changes the asset. When a push is refused, the message names each asset and what
it lacks. `world.publishBlockers()` and `zm status` read the same list without
pushing.
## Typing a result from the asset (`refShapes`)
A `ref` method whose result depends on the asset (one entry per child folder, per
row of a config, per control a map declares) can only be annotated as
`{ [string]: Thing }`, which accepts any key. `refShapes` states the result for
each asset instead: a function returning a Luau type expression for that asset,
and the module whose types the expression uses. Read the asset; never run it.
```lua
-- inputMap.assetType/behavior.luau
M.refShapes = {
activate = function(self): (string, string)
local names = {}
for _, control in ipairs(M.ref.controls(self)) do
table.insert(names, control.name .. ": Handle")
end
if #names == 0 then return "", "" end
return "{ " .. table.concat(names, ", ") .. " }", "@builtin::modules.zinput.scheme"
end,
}
```
With it, `map.jump:onPressed(fn)` is checked and `map.noexisting` is reported.
A per-asset type applies where the call site names which asset: a component
field whose default names it, or an annotation `AssetRef<"inputMap", "<identity>">`.
`asset.resolve(name, category)` carries only the category, and a name computed
at runtime carries nothing, so calls on those keep the declared return. The type
belongs to the call's result: index it there, or bind what you need from it
there, and the names stay checked.
`require("@builtin::assetTypes.assetType.shared.refShapes").published()` shows
what was published: `categories` (each type's method surface) and `returns`
(per-asset results by identity, then method). An identity missing from
`returns` was never published. Results are recomputed when an asset is written;
at most 64 assets of one type are read.
## Putting an asset in a scene (`ref.instantiate`)
A caller writes one piece of code against every instantiable type, so what
`instantiate` returns is fixed:
```lua
local Instantiable = require("@builtin::assetTypes.assetType.shared.instantiable")
M.ref = {
instantiate = function(self, target, opts)
local root = Instantiable.root(self, target, opts)
-- compose the asset under `root`, now
return Instantiable.result(self, root, idMap)
end,
}
```
**In.** `target` is the entity the asset lands under, or onto for a hierarchy
type; with none, the type spawns a new root. `position`, `rotation` (three
numbers in degrees, or a quaternion), `scale`, `name` and `temporary` mean the
same for every type: `Instantiable.root` applies them to a root you make,
`Instantiable.place` to one you adopt. Document any options of your own in the
hook's `--!arg` docs.
**Out.** `(root, idMap)`. `root` is the composed root, live when you return:
build synchronously, never defer to a component's `awake`. `idMap` maps each
original id to the spawned one, so a component that composes the asset again on
every load can pass it back in and keep references into the composition. Every
call goes through `Instantiable.result`, which refuses a root that is not live
and turns a missing `idMap` into `{}`.
## Inspecting an asset (`ref.inspect`)
`asset.inspect(ref)` builds a record every asset shares (`identity`, `name`,
`guid`, `source`, `typeName`, `typeDefinitionPath`, `scope`, `origin`,
`description`, `tags`), then calls `ref.inspect(self)` for the type's own
`detail`:
```lua
M.ref = {
inspect = function(self)
-- read the asset's own files, never runtime state: inspect works on an asset
-- that has not been compiled, uploaded or registered
return { --[[ what makes this asset what it is ]] }
end,
}
```
Without it, the record is the shared part alone, and an agent inspecting the
asset learns nothing of what it is. A hook that raises leaves `detail` empty and
sets `record.warning`.
## Discovery
- `asset.list("assetType")`: every registered type.
- `asset.inspect("<typename>")`: the type, its README and its `about.yaml` texts.
- `asset.resolve("<typename>", "assetType")`: the type itself.
# edui
The editor UI framework, on the CSS-parity `ui.*` surface. An editor-only
toolkit: panels author closures instead of callback ids, a dock shell over
`ui.*` native docking, and typed editor-chrome primitives. Gameplay and normal
world UI author raw `ui.*` trees and need none of this.
**`edui.guide` beside this file is how to use it** — creating a panel, the spec
it returns, how a panel is drawn and redrawn, the whole widget vocabulary, and
how a panel reaches the selection and the dock.
## What installs
- **`framework`** — everything under one table. From outside the package,
`require("@builtin::~edui.framework")`; inside it, `require("~.framework")`.
It carries `app`, `widgets`, `shell`, `topbar`, `query`, `fileDialog`,
`worldDialog`, `stats`, and the chrome primitives flat (`panel`, `tree`,
`field`, `section`, `text`, `button`, `menu`, …).
- **`app`** — maps a panel's closures to `ui.*` callback ids, dispatches to
them, and coalesces redraws.
- **`widgets`** — the primitives, plus the typed inspector layer
(`widgets.tree`, `widgets.fields`, `widgets.controls`).
- **`inspector`** — the Inspector as an ordered list of sections: the generic
asset sections, the Settings section and its actions, and the merge with
the sections a type's `inspector.luau` declares (`inspector.assetSections`,
`inspector.settingsSection`), and the building blocks those sections
compose (`inspector.parts`, reached as `ctx.parts`).
- **`shell`** — the dock: one `ui.*` `dockArea` holding a `dockPanel` per
registered panel, and the panel registry behind it.
## Referencing
- Inside this package, modules reference siblings with `~.` — e.g.
`require("~.app")`, `require("~.widgets.fields")`.
- Editor content built on the framework references it by package identity —
`require("~edui.framework")`, `require("~edui.shell")`.
# guide
A guide is a single-file documentation asset: a `<name>.guide/` folder whose body is `guide.md`, surfaced through the `guides` MCP tool, the `man` shell builtin and the Docs panel. Drop one anywhere under `/zero/source/`, a `.package` included, and it shows beside the built-in guides.
## Linking guides together
A markdown link in a guide is followed when it is clicked in the Docs panel: `[Bindings](controls-guide)` opens another guide by any name `guides read` takes, `[Slots](#save-slots)` a heading of the same guide (its text in lower case, punctuation removed, spaces turned into `-`), `[Slots](save-kit#save-slots)` a heading of another, and `[the module](../saveKit.module)` a file or asset, relative to the guide's own folder. A link the panel cannot follow says why above the guide.
## In the Inspector
Guide: the guide's word count, its headings, and **Open in Docs**, which opens it in the Docs panel.
# Module (asset type)
A module is a reusable, stateless (or globally-stateful) Luau library
that other code pulls in with `require()`. Modules are the unit of
factored-out logic in a world: helpers, math libraries, format
parsers, registries, shared algorithms.
## When to use one
- You have logic you want to share across multiple components,
services, scenes, or tools.
- You have a registry, lookup table, or shared cache that callers all
read from / write to.
- You want a stable API surface (`M.fn`) other code can depend on.
If the logic is bound to one entity, use a `.component`. If you want a
named UI of fields rather than functions, use a `.preset`.
## Where it lives
- Source: `/zero/source/.../<name>.module/`
- Identity: the source path with `/` written as `.` and each segment's
type suffix dropped — `/zero/source/demo/beats.module` is
`demo.beats`, and `/zero/source/kit.package/beats.module` is
`kit.beats`, the container's name kept and its `.package` suffix
gone. `asset.create` returns this string, `asset.resolve(...).identity`
reads it back, and the `require` forms below write it as
`<identity>`.
- Folder shape:
- `init.luau` (or `init.lua`) — module body. **Required.** Returns
a table (conventionally named `M`).
- `README.md` — instance-level documentation. **Required.**
- `.metadata` — agent-editable tags + free-form fields. **Required.**
## How to create one
```luau
asset.create("module", "<name>")
-- Creates: /zero/source/<name>.module/
-- init.luau (canonical `local M = {} … return M`)
-- README.md (instance README template)
-- `folder` places it in a subfolder of /source, and that subfolder
-- leads the identity: `{ folder = "lib" }` produces `lib.<name>`.
-- `into` authors it inside a resolved container (a package, a toolbox),
-- whose own identity leads the same way:
asset.create("module", "<name>", { folder = "lib" })
asset.create("module", "<name>", { into = asset.resolve("myPkg.package") })
```
## How it operates
1. **Registration.** Writing `init.luau` into a `.module/` folder
indexes the asset and registers the module path with the resolver.
2. **Loading.** The first `require("<identity>")` runs
the file's body and caches the returned table. Subsequent requires
from anywhere return the **same table** — this is the
shared-state lever for "module-local" registries. The body runs in
an environment of the module's own: a name it declares without
`local` is readable by the module's own functions and by no other
script, so an export reaches its callers through the returned table
and nothing else. Requiring a component's folder runs its chunk the
same way, once, and its `update`, `start` and `public` stay in that
environment; a component runs on an entity through
`entity(id).component.add(name)`.
3. **Hot reload.** Editing the file re-runs the module body and folds
what it returns into the table already loaded, so that table keeps
its identity: a caller that captured it with `require` reads the new
source's functions and values through the reference it already
holds, and a later `require` returns that same table. A key the new
source drops goes away; a key something set at runtime stays. The
body's own `local`s start again from their initial values, so state
that must outlive an edit lives in `modules.state()`
(`guides { path: "core/scripting-and-tasks" }`).
4. **Identity resolution.** A module is reached by its `<identity>`,
and where the caller lives decides which spelling of it resolves.
For a module in a **world**, from a caller in that same world —
another module, a component, a scene entrypoint, or an `execute`
chunk:
- `require("<identity>")` — the identity on its own, resolved
against the caller's own root. It carries every folder segment,
so a module created with `{ folder = "demo" }` is
`require("demo.beats")`.
- `require("@root::<identity>")` — the same root-relative
resolution, written out.
- `require(".sibling")` — a module in the same folder, by its bare
name; `..name` steps up one folder, `...name` two.
Logs, stack traces and hot-reload notices name that same module
`@local.source.<identity>` — its key under the world source root.
The `<identity>` tail of that key is the form above, so a trace
reading `@local.source.demo.beats` is `require("demo.beats")`.
For a module in a **library** — content under
`/zero/source/libs/@<lib>/` — `@<lib>::` addresses that library's
root from anywhere: `require("@builtin::modules.transform")`.
Mounting a world as a library moves its files under such a root and
flips the caller root to `@<lib>::`, which is what keeps the
root-relative forms above resolving across the move.
## Discovery
- `asset.list("module")` — every registered module.
- `asset.inspect("<name>")` — public functions, source path, this
type README.
- `ls /zero/source/<name>.module` lists the files the folder holds.
## Authoring conventions
- Return a single `M` table from `init.luau`. Top-level statements
with side effects run on first require — useful for one-time
initialization, dangerous if they touch the engine before it's
ready.
- Write exported functions as `typed function`, not `function`: the
argument types are enforced at the call and the checker knows the
signature, so a caller's mistake is reported where they made it.
- Annotate exported functions with `--!desc` / `--!arg` / `--!return`
/ `--!example` so the LSP, `tools.list`, and `asset.inspect`
surface them. A function with no `--!desc` is reachable only by
someone already reading this file.
- Declare a VALUE with `Field.<kind>(default, mode, description)`, and
a table whose members are reached through a metatable with
`--!members <TypeName>` — the latter gives the surface its accepted
member set, its type, and its documentation from one table. See
`guides { path: "core/authoring" }` for all four rules and
`modules/api/engine/entity.module/members.module` for a worked
member table.
- Keep module state in module-local upvalues or in `modules.state()`.
A name the body declares without `local` lives in the module's
environment, which every run of the module shares: a function the
previous run left in place reads what the reload assigned. Module-locals start again from their
initial values with each hot reload; the table `modules.state()`
returns is the same table on both sides of every reload.
- Prefer focused modules over kitchen-sink modules. If two halves of a
module have no shared state, split them.
## Common pitfalls
- **Cyclic requires.** A `require` chain that loops will return the
partial table (the half built before the cycle was detected).
Design for one-way dependencies.
- **Engine-time side effects.** Don't `entity.spawn` at module
top-level — the engine may not be ready. Expose an `M.init` and
call it from a scene entrypoint.
- **Hot-reload + module-local cache.** A module that caches
expensive computation in upvalues loses that cache on reload —
fine for dev, but be aware in performance work.
- **`init.lua` vs `init.luau`.** Either works, `.luau` is canonical.
- **Writing one while play runs.** A `/source` write made while play is
running lands on the play shadow, and a guarded play-exit discards it
unless it is kept. One `.module` write shadows `init.luau`, the
`README.md` and the `.metadata` together.
`vfs.write(path, bytes, { durable = true })` skips the shadow, and
`vfs.promotePlayShadow` keeps what is already on it — the `core/vfs`
guide has the model and what each route costs.
## Related types
- `.component` — for entity-bound state + lifecycle hooks.
- `.service` — generates content you don't have yet (a mesh, sound, texture, …) via a metered provider.
- `.tool` — for a single agent-callable function with a YAML schema.
- `.package` — to group several modules + components + scenes into
one shippable folder.
## In the Inspector
Exports: what the module exports (name, kind, signature), parsed from its source without running it, with **Check** (the LSP's diagnostics for its entry file) and **Open**.
# ParticipatingMedium
Marks its entity as a volume of fog, smoke, dust or steam — something light has
to travel through. What is behind the volume comes out dimmer than what is
beside it, and overlapping volumes stack.
```luau
local id = entity.spawn("fog_bank").id
entity(id).component.add("ParticipatingMedium", {
sizeX = 30, sizeY = 8, sizeZ = 30,
density = 0.15,
})
```
## Fields
| Field | Meaning |
|---|---|
| `shape` | `"box"` (default) or `"sphere"` |
| `density` | Extinction per world unit. `0` releases the medium. |
| `sizeX` / `sizeY` / `sizeZ` | Half-extents, for a box |
| `radius` | Radius, for a sphere |
`density` is the physical knob: a ray crossing `d` world units of a medium of
density `k` leaves with `exp(-k * d)` of the light it entered with. A 20-unit
bank at `0.15` therefore passes about 5% of the light straight through it.
The entity's position places the volume; move the entity and the shadow moves
with it.
## Notes
- The light-space map is sized to the bounding sphere of everything registered.
A small bank sharing a scene with a very large one lands on few texels — set
`extent` and `centre` through `mediaTransmittance.set` to bias the map toward
the region you care about.
- Attenuation applies to surfaces. The sky keeps its own colour.
- The attenuated light is the sun's: `sunDirection` sets which way the map's
rays travel.
See `mediaTransmittance` for the scene-wide settings and the direct API.
# Volume
Per-entity attachment that renders a 3D voxel texture (cloud, smoke,
nebula, imported VDB grid) into the scene as a volumetric body. The
component does NOT own the volume's lifetime — producers (the
`volume` module, a custom compute fill, or `compute.writeTexture3D`)
build the texture and pass its name in `volumeName`. The entity's
world transform defines the AABB the raymarcher samples.
`volumeName` is resolved through `Volume.get`, so a volume the `volume`
module built is rendered as it stands — at its own dimensions, from its own
density channel, through its occupancy grid, and out of its brick pool if it
is paged. A name no volume answers to is taken as a bare 3D texture some
other system owns, and `occupancyName` / `isRgba` describe it.
## Usage
```luau
local Volume = require("@builtin::systems.volumetrics.volume")
local cloud = Volume.noise({ name = "cloud", size = 64, scale = 4 })
cloud:buildOccupancy({ brickSize = 8 })
local e = entity.spawn("cloud_entity")
e.localScale.set(8, 4, 8)
e.component.add("Volume", {
volumeName = "cloud",
occupancyName = cloud.occupancyName,
density = 6.0,
})
```
A paged volume costs what it holds and is named the same way:
```luau
local cloud = Volume.noise({ name = "cloud", size = 128, threshold = 0.6 })
cloud:buildSparse({ brickSize = 8, threshold = 0.01 })
cloud:releaseDense() -- the brick pool is now the only copy
local e = entity.spawn("cloud_entity")
entity(e.id).localScale = { 8, 4, 8 }
e.component.add("Volume", { volumeName = "cloud", density = 6.0 })
```
## Public fields
| Field | Type | Sync | Default | Meaning |
|---|---|---|---|---|
| `volumeName` | string | Sync | `""` | Required. Name of the volume, or of a bare 3D texture, to render. |
| `occupancyName` | string | Sync | `""` | R8 occupancy texture for a bare texture's empty-space skipping. Empty = no occupancy. A volume the module built carries its own, and a name set against one is reported as left aside. |
| `density` | number | Sync | `1.0` | Density gain multiplier. |
| `absorption` | number | Sync | `1.0` | Beer-Lambert absorption coefficient. |
| `emission` | number | Sync | `1.0` | Emission gain. |
| `color` | color | Sync | `{1,1,1,0}` | RGB tint + density floor in alpha. |
| `stepSize` | number | NoSync | `0` | World units per ray step. `0` = auto-derive from volume extent. |
| `maxSteps` | number | NoSync | `256` | Ray-march step cap. |
| `target` | string | Sync | `"scene_volume_rt"` | Output render target name (composite key). |
| `targetWidth` | number | NoSync | `1280` | Output width (px). |
| `targetHeight` | number | NoSync | `720` | Output height (px). |
| `useOccupancy` | boolean | NoSync | `true` | Toggle empty-space skipping. |
| `isRgba` | boolean | Sync | `true` | `false` reads density from the red channel. Left `true`, a volume the module built is read from the channel it was built with, and a bare texture as RGBA. |
All public fields use the `Field.<kind>(default, Sync|NoSync)` schema
(PR #3019). Visual params (look, tint, density) propagate Sync;
quality knobs (stepSize, maxSteps, target resolution, useOccupancy)
are NoSync — each client picks its own perf/quality point.
## Lifecycle
- **`awake`** — no-op. The volume texture is owned by the producer
and looked up lazily on the first `update`.
- **`update(dt)`** — resolves `volumeName` through `Volume.get`, falling
back to a wrapper over a bare texture of that name, and dispatches the
compute raymarcher into `target`. The resolution runs every frame, so a
volume built, paged or destroyed after this component attached is picked
up on the next one.
- **`onPropertyChanged(key, value, old)`** — drops the resolved volume
when `volumeName`, `occupancyName`, or `isRgba` changes, forcing a
re-resolve on the next frame.
## Related
- `volume` module — texture builders and name lookup (`Volume.noise`,
`Volume.get`, `Volume.wrap`, `:buildSparse`, `:render`).
- `vdb_to_zvol.importer` — VDB → dense voxel pipeline that produces
textures consumable by this component.
- `volumeDemos` / `volumetricSky` packages — worked examples.
# VolumeSequence
Plays an open volume sequence through an entity's transform AABB.
The sequence is opened by a producer and named here — the same split the
`Volume` component makes between "what data to render" and "where it appears
in the world". Each tick the component advances the playhead and raymarches
the frame that is resident into the shared `scene_volume_rt` target, in both
play and edit mode, so the run animates while it is being authored.
```luau
local Seq = require("@builtin::systems.volumetrics.volumeSequence")
Seq.open({ name = "plume", frames = paths, resident = 3, fps = 24 })
local id = entity.spawn("plume_entity").id
entity(id).localScale = { 8, 8, 8 }
entity(id).component.add("VolumeSequence", { sequenceName = "plume", density = 6.0 })
```
`fps` and `playing` are pushed into the sequence when they change, so a
script calling `seq:setFps()` / `seq:pause()` between two ticks is left
standing until the authored value itself moves.
The component does not own the sequence: despawning the entity leaves the
ring allocated, and `volumeSequence.close(name)` is what frees it.
## Sharing
The playhead, the rate and the ring belong to the sequence, so several
entities naming one run play the same frame from the same three textures:
the playhead advances once per engine frame however many components tick it,
and the rate is whichever component's `fps` moved last. Give each entity its
own run when they should play independently.
`target` is a render target, and a raymarch writes the whole of it — two
volumes visible at once (a sequence beside a `Volume`, or two sequences) need
a `target` name each, or the later write replaces the earlier one and only
one of them appears. Composite each target the way any volume render is
composited — see `volume.compositeShaderSource()`.
# VolumetricLighting
The air between the camera and the scene, as a component. Add it to an entity
and light travelling through that air scatters toward the viewer, so a lamp
becomes a glow rather than an invisible point and the sun cuts shafts where
something crosses it.
```lua
local id = entity.spawn("air").id
entity(id).component.add("VolumetricLighting", {
density = 0.06,
anisotropy = 0.7,
})
```
Every field is live — editing one in the inspector changes the air on the next
frame, and the values persist with the scene. Setting `density` to 0 empties the
air and releases the passes while keeping the rest authored; removing the
last one in the scene clears it.
| field | effect |
|---|---|
| `density` | How much medium there is per world unit at the reference altitude. 0 is off. |
| `heightFalloff` / `heightRef` | How fast density thins with altitude, and the altitude `density` is quoted at. 0 falloff fills the scene evenly. |
| `steps` | Samples along each view ray. More resolves a beam's edge more cleanly. |
| `colorR/G/B` | What the medium scatters. Warm dust and cold mist differ here. |
| `anisotropy` | How forward-biased the scattering is, in (-1, 1). Positive values throw light onward, so haze blooms around a source you look toward. |
| `sunIntensity` / `sunColorR/G/B` | The sun's contribution to the medium. |
| `lightScale` | The scene lights' contribution to the medium. |
| `shadowSteps` / `shadowDistance` | Samples taken back toward the sun to decide whether a point in the air is lit, and how far they reach. **0 leaves the sun's contribution unshadowed**, so the haze glows evenly and no shafts form. |
| `maxDistance` | How far along the view ray the march runs. A ray that escapes to sky stops here. |
The lights are the scene's own, each scattering on the terms its kind emits on:
a point fills the air around it, a spot fills its cone, a rect reaches the air on
the side its face emits along, and a parallel light lays an even glow across the
whole medium. One added, moved, re-aimed or recoloured at runtime changes the
glow it throws with nothing to keep in step here.
One body of air fills the scene, so one of these per scene is enough — the last
one to push wins.
The work runs in `lightScattering.renderFeature`; the settings live in
`@builtin::systems.volumetrics.volumetricLighting`, which is also how to drive
them from a script.
# VolumetricSky
Singleton component that drives the procedural cloud layer. Attach
to one entity (usually a sky placeholder), and the component owns
the cloud volume + render target lifecycle, bakes the noise field
when what it is baked from changes, and dispatches the compute
raymarcher every frame into `scene_volumetric_sky`.
A composite (post-process or material) blends `scene_volumetric_sky`
over the scene — see `demo/sky.scene/` for the full wiring.
## Usage
```luau
-- Defaults (the v4-tuned cumulus look — `Sky.defaultPreset()` hands out a
-- copy of the same table this field defaults to)
local sky = entity.spawn("sky")
sky.component.add("VolumetricSky", {})
-- Or apply a captured preset:
preset.apply("cumulus_default", { entity = sky.id, component = "VolumetricSky" })
-- Inline tweak. `component.add` writes the `preset` field as it is given,
-- so a table handed here carries the whole configuration:
local p = require("@builtin::systems.volumetrics.volumetricSky").defaultPreset()
p.noiseScale, p.octaves, p.coverage = 9.0, 5, 0.20
p.densityGain = 0.04 -- v5 σ_t
sky.component.add("VolumetricSky", { shaderVariant = "v5", preset = p })
```
`volumetricSky.module`'s `M.spawn` / `M.configure` take the same parameters
by name, folding each into the current configuration and leaving the rest
where they stand:
```luau
local Sky = require("@builtin::systems.volumetrics.volumetricSky")
Sky.spawn({ shaderVariant = "v5", noiseScale = 9.0, coverage = 0.20 })
```
## Schema (Field<T> / Sync / NoSync)
The 28 shaping / weather / lighting params travel as ONE Sync field
— `preset = Field.table(DEFAULT_PRESET, Sync)` — so replicating a
sky change is **one mutation, not 28**, and copying a sky between
scenes is **copying one field, not 28**. Per-client quality knobs
sit outside the preset as NoSync fields so each client picks its
own tier without disturbing peers.
`SkyPreset` and `DEFAULT_PRESET` are declared by `volumetricSky.module`,
which this component reads its default from and which routes an option by
that same key set — so the shape below and the module's `M.spawn` options
are one declaration.
| Field | Type | Sync | Default | Meaning |
|---|---|---|---|---|
| `preset` | `SkyPreset` table | Sync | `DEFAULT_PRESET` (v4 cumulus) | All shaping / weather / lighting (see below). |
| `shaderVariant` | string | Sync | `"v4"` | `"v3" \| "v4" (default) \| "v5"`. |
| `wind` | vec3 | Sync | `{0,0,0}` | Animated drift offset (advanced per frame from `preset.windSpeed`). |
| `cloudSize` | number | NoSync | `256` | Volume W,D resolution. |
| `cloudHeight` | number | NoSync | `128` | Volume H resolution. |
| `stepSize` | number | NoSync | `4.0` | World units per ray step. |
| `maxSteps` | number | NoSync | `224` | Ray-march step cap. |
| `shadowSteps` | number | NoSync | `8` | Shadow ray step count. |
| `alphaCutoff` | number | NoSync | `0.99` | Early-exit α threshold. |
| `jitter` | number | NoSync | `1.0` | Ray-origin jitter strength. |
| `taaBlendAlpha` | number | NoSync | `0.1` | TAA current-sample weight. |
| `targetWidth` | number | NoSync | `0` | Render target width (`0` = viewport). |
| `targetHeight` | number | NoSync | `0` | Render target height. |
| `noiseRegenRate` | number | NoSync | `10` | Times per second wind drift re-bakes the cloud volume (`0` = every frame the wind moved). |
### Preset (`SkyPreset`) shape
Noise / shaping: `noiseScale, octaves, coverage, densityGain,
altitudeLow, altitudeHigh, anvilBias, detailScale, detailOctaves,
detailStrength, detailThreshold, baseLowRemap, baseHighRemap, color,
windSpeed, seed`.
Volume placement: `horizExtent, cloudBaseAltitude, cloudTopAltitude`.
Look (visual, not perf): `phaseG`.
Sun / sky: `sunDir, sunColor, sunIntensity, sunDiscSize,
horizonColor, zenithColor, groundColor, ambient`.
v5-only: `cloudType, worleyBlend`.
See `presets/cumulus_default.preset/preset.yaml` for the canonical
full preset shape, and the `init.luau` `DEFAULT_PRESET` table for
the runtime defaults.
## Where the frame time goes
Two passes carry the cost, and different fields reach each:
- **The bake** (`cloud_noise_*`) fills every voxel of the
`cloudSize × cloudHeight × cloudSize` volume with layered noise. It is
the most expensive pass the layer has (around 10 ms at 256×128×256 on a
desktop GPU), and it runs only when the volume's inputs move: at once
for a change to `preset`, `cloudSize`, `cloudHeight`, `shaderVariant`
or the render device, once the program it compiles is resident for an
edit to the noise shader's own source, and at most `noiseRegenRate`
times per second for wind drift. A sky whose `preset.windSpeed` is zero
bakes once. `cloudSize` / `cloudHeight` set what one bake costs;
`noiseRegenRate` and `windSpeed` set how often it is paid.
- **The raymarch** (`cloud_raymarch_*`) runs for every view of every
frame. `stepSize`, `maxSteps`, `shadowSteps`, `alphaCutoff` and
`targetWidth` / `targetHeight` set its cost.
`profiler.gpuFrame()` reports both as `compute.<shader>` spans; a span's
`frames` count is how many frames of its window the pass ran in, and
`volumetricSky.volumeBakes()` counts the bakes themselves.
## Field-mutation gotcha (and why `update()` reassigns the whole vec3)
Under the new Field<T> / Sync / NoSync schema, `public` is not a
plain Lua table — it's wrapped in a proxy whose `__newindex`
metamethod performs type-checks, marks the field dirty, and queues
Sync replication. Writing `public.wind = {x,y,z}` triggers all four
steps. Writing `public.wind[1] = x` reads `public.wind` (getter),
mutates the returned table directly, and **bypasses the setter** —
no Sync replication, no dirty flag, no `onPropertyChanged`. Always
assign the whole vec3 / preset / color table; never index-mutate.
The same caveat applies to `public.preset`: `public.preset.coverage = 0.2`
won't replicate. To change one preset field, copy the table, mutate,
reassign:
```luau
local p = public.preset
local next = {}
for k, v in pairs(p) do next[k] = v end
next.coverage = 0.2
public.preset = next
```
## Lifecycle
- **`awake`**: starts the sky's clock and creates the render feature
that runs the layer's passes.
- **`update(dt)`** — advances `wind` (reading `preset.windSpeed`),
hands the noise inputs to `volumetricSky.fillClouds`, which bakes the
volume when they moved (see "Where the frame time goes"), then queues
the raymarch dispatch for the frame's deferred phase.
- **`onDespawn`** — releases the volume, the render target and the
composite effect via `M.destroy`.
### Why the raymarch is dispatched from `task.defer`
The raymarch produces ONE full-screen layer for ONE view, and the frame
composites it over everything that frame drew — so it has to be rendered
from the view the frame ends up drawn with. Which view that is settles
only once the frame's script work is finished: a cutscene walking the
camera from a task loop, a camera controller, a component ordered after
this one, an editor tool — each writes the camera after `update` has
run, while the renderer reads the camera once all of them are done.
Taking the camera snapshot in the deferred phase reads what every one of
them has already written, and still queues the dispatch ahead of the
frame's render. Snapshotting in `update` instead renders the layer for
whatever the camera was midway through the frame; on a hard cut that is
a different place entirely, and the frame the cut lands on composites a
cloud layer belonging to the shot it just left.
A caller driving `volumetricSky.dispatchRaymarch` directly owes the same
discipline.
## Related
- `volumetricSky.module` — orchestration backend.
- `presets/cumulus_default.preset` — canonical full preset; copy
to author named weather (storm, overcast, dawn, …).
- `Volume.component` — per-entity flow for non-cloud volumetric content.
- `demo/sky.scene` — worked example.
·computeshader · born here
❒asset # godrays_fill
Fills a volume with shafted light. One invocation per voxel.
## Bindings
| name | kind | what it is |
|------|------|------------|
| `vol` | `storage3d` (rgba16f) | the volume written — RGB emission, A density |
| `U` | `buffer` (read, f32) | the demo's fill parameters |
·computeshader · born here
❒asset # nebula_fill
Fills a volume with a spiral nebula: RGB carries emission, A carries density.
One invocation per voxel at workgroup size 4x4x4.
## Bindings
| name | kind | what it is |
|------|------|------------|
| `vol` | `storage3d` (rgba16f) | the volume written, read-write so a pass can accumulate |
| `U` | `buffer` (read, f32) | the demo's fill parameters, in the order the shader reads them |
`U` holds: elapsed time, spiral frequency, arm thickness, core brightness, edge
brightness, dust density, noise strength.
·computeshader · born here
❒asset # sdf_solids_fill
Fills a volume with signed-distance solids. One invocation per voxel.
## Bindings
| name | kind | what it is |
|------|------|------------|
| `vol` | `storage3d` (rgba16f) | the volume written — RGB emission, A density |
| `U` | `buffer` (read, f32) | the demo's fill parameters |
·computeshader · born here
❒asset # smoke_plume_fill
Fills a volume with a rising smoke plume. One invocation per voxel.
## Bindings
| name | kind | what it is |
|------|------|------------|
| `vol` | `storage3d` (rgba16f) | the volume written — RGB emission, A density |
| `U` | `buffer` (read, f32) | the demo's fill parameters |
·computeshader · born here
❒asset # zvol_viewer_fill
Leaves the volume as it is. The ZVOL viewer uploads voxels straight into the
volume's 3D texture from the CPU, so the fill stage this shader occupies has
nothing to compute — it reads one value from each binding so both stay live.
## Bindings
| name | kind | what it is |
|------|------|------------|
| `vol` | `storage3d` (rgba16f) | the volume the viewer writes into from the CPU |
| `U` | `buffer` (read, f32) | the fill parameters the spawn API supplies |
·computeshader · born here
❒asset # emission_raymarch
Marches a camera ray through an emissive volume and writes the result to a 2D
target. Emission accumulates along the ray and density attenuates what is behind
it, so a volume reads as light rather than as a surface.
## Bindings
| name | kind | what it is |
|------|------|------------|
| `vol` | `texture3d` | the volume marched — RGB emission, A density |
| `smp` | `sampler` | how `vol` is filtered along the ray |
| `out_tex` | `storage2d` (rgba16f) | the image the march writes |
| `U` | `buffer` (read, f32) | camera, volume placement and march settings |
`U` carries the inverse view-projection, the volume's world transform, the step
count, density and emission gains, and the target's inverse size.
# VDB → ZVOL importer
Claims `.vdb` (OpenVDB) writes and produces a sibling `.zvol` — the
engine-native dense RGBA8 volumetric format consumed by the volumetrics
system.
This is a standard `importer` asset: `init.luau` returns a
`{ canImport, import }` module that the importer dispatcher
(`@builtin::assetTypes.importer`) loads lazily and runs only when a matching
file is written. It does **not** execute anything at load time.
## Pipeline
1. **Real parse** — if the `openvdb` plugin in the builtin library
(`@builtin::plugins.openvdb`) is loaded, the VDB B-tree is parsed into a
dense RGBA8 voxel buffer and wrapped in a 64-byte ZVOL header.
2. **Procedural fallback** — without the plugin, a deterministic
noise-modulated torus is synthesised (FNV-1a hash of the first 256 source
bytes seeds it) so the importer + demo wiring stays functional. The
`produced_by` field in the result distinguishes the two paths.
## ZVOL header (64 bytes)
| offset | size | field |
|--------|------|-------|
| 0 | 4 | magic `ZVOL` |
| 4 | 4 | u32 version (1) |
| 8 | 4 | u32 width |
| 12 | 4 | u32 height |
| 16 | 4 | u32 depth |
| 20 | 4 | u32 format (1 = rgba8) |
| 24 | 12 | f32[3] aabb_min |
| 36 | 12 | f32[3] aabb_max |
| 48 | 16 | reserved |
| 64 | … | voxel data, `w*h*d*4` bytes, XY-major Z-slowest |
·renderfeature · born here
❒asset # lightScattering
The render passes behind the `VolumetricLighting` component and the
`volumetricLighting` module.
Two compute passes after lighting.
**`vol_scatter`** marches each view ray at half resolution, from the camera to
the surface the ray hits — or to the medium's reach when it escapes to sky, since
an unbounded march would integrate the whole horizon into every sky pixel. At
each step it evaluates the medium's density, gathers the light arriving there,
and accumulates the fraction sent toward the camera, attenuating by everything
already crossed.
A surface-only renderer draws the pool of light a lamp throws on the floor but
not the beam that carried it there, because nothing between the lamp and the
floor is ever shaded. Marching the ray shades that space.
The lights are the scene's own. `@scene.lights` is the same buffer the lit passes
read, so a light added, moved or recoloured at runtime changes the beam it throws
with no bookkeeping here. It holds **twelve vec4s per light** — the light, then
its punctual shadow record — read as raw words so no struct declaration has to
mirror the engine's layout. A spot's cone gate is
evaluated at the sample itself rather than at a surface, which is what makes the
cone visible as a volume instead of only where it lands. Attenuation matches the
lit passes' windowed falloff, so a beam and the pool of light it lands in agree
about how far the source reaches. Area lights are skipped — their contribution to
a medium is an integral over the rect rather than a single direction.
The sun's contribution is gated by a short march back toward it, tested against
the depth buffer. That is what turns uniform haze into shafts: the medium is lit
only where the path back to the sun is clear, so an occluder carves a dark volume
out of the beam rather than only shading the surface behind it. Because the test
reads the depth buffer, an occluder outside the view frustum does not cast into
the medium.
Scattering follows Henyey-Greenstein, so the medium brightens toward a source you
are looking at and stays dim when it is behind you — the difference between haze
that reads as air and haze that reads as a grey wash.
Each ray's first sample is offset by a per-pixel fraction of a step, trading the
banding a fixed step pattern produces for noise.
**`vol_composite`** upsamples that result and composites it. The taps are weighted
by how closely their depth matches the full-resolution pixel's, which keeps a beam
from bleeding across a silhouette — the halo a plain bilinear upsample leaves
behind.
The camera comes from `@frame.camera`, the camera of the render target being
drawn, and the forward matrix is derived by inverting it in the shader rather than
passed in from script — so an offscreen capture or an RTT camera marches through
its own matrix at its own resolution.
Parameters arrive through the `vol_params` buffer, packed by the
`volumetricLighting` module.
·renderfeature · born here
❒asset # mediaShadow
The shadow a fog bank casts on what is behind it.
Two compute passes run after lighting:
| Order | Pass | Work |
|---|---|---|
| 36 | `media_tau_map` | One ray per texel of a light-space map; accumulate optical depth |
| 37 | `media_shadow_apply` | Attenuate each surface by the density in front of it |
| 38 | composite | Blit the result |
## Why not a shadow map
A depth-based shadow map answers one question per texel: is this point nearer
the light than the first thing the light hit? Density has no first thing. What
a medium does to light is continuous — a ray crossing `d` units of extinction
`k` keeps `exp(-k*d)` of its energy — so what a receiver needs is the optical
depth between it and the light, not a depth to compare against.
The first pass therefore stores optical depth rather than distance, at four
points down each ray. Two consequences fall out of that choice:
- **Overlapping media stack.** Optical depth is additive, so two volumes that
intersect contribute to the same running total with nothing to resolve.
- **Geometry inside a volume is lit correctly.** A receiver looks up its own
position along the ray and reads the depth accumulated *up to that point*, so
a post standing in the middle of a fog bank is dimmed by the half in front of
it rather than by the whole column.
## Inputs
`@scene.color`, `@scene.depth`, `@frame.camera`, and the `media_params` /
`media_volumes` buffers packed by
`@builtin::systems.volumetrics.mediaTransmittance`.
Sky pixels are passed through untouched.
# mediaTransmittance
Light attenuation through participating media, in a form both media and
surfaces can sample.
A shadow map stores one depth per texel and answers a yes/no question, so
nothing made of density can cast with it. This module owns the other
representation: a light-space map holding the **optical depth** accumulated
along each light ray, recorded at four points down its length.
Optical depth is additive. Every medium a ray crosses contributes to the same
texel, so overlapping volumes stack with nothing to resolve between them. And
because four points are stored rather than one total, a receiver standing
halfway through a fog bank is attenuated by the half in front of it, not by the
whole column.
## Direct use
```luau
local Media = require("@builtin::systems.volumetrics.mediaTransmittance")
Media.set({ sunDirection = { 0.55, 0.55, 0.55 }, steps = 32 })
Media.setMedium("bank", {
position = { 0, 8, 0 },
size = { 20, 6, 20 },
density = 0.15,
})
```
Most scenes attach `ParticipatingMedium` to an entity instead and let it drive
this module.
## API
| Call | Effect |
|---|---|
| `set(opts)` | Scene-wide settings; omitted fields keep their value |
| `setMedium(key, opts)` | Register or move a medium under a stable key |
| `removeMedium(key)` | Drop one medium |
| `get()` | A copy of the settings in force |
| `clear()` | Drop everything and release the passes |
| `active()` | Whether the passes are running |
### Settings
| Field | Meaning |
|---|---|
| `sunDirection` | Points **toward** the sun; light travels the other way |
| `strength` | How much of the computed attenuation is applied, `[0, 1]` |
| `steps` | Samples along each light ray, `[4, 128]` |
| `extent` | Half-width of the map in world units; `0` sizes it to the media |
| `centre` | Point the map is centred on; omit to centre it on the media |
### Media
| Field | Meaning |
|---|---|
| `position` | World-space centre |
| `shape` | `"box"` (default) or `"sphere"` |
| `size` | Half-extents, for a box |
| `radius` | Radius, for a sphere |
| `density` | Extinction per world unit |
Up to 32 media are packed into the records buffer.
## Cost and limits
The map is 512×512 and is rebuilt each frame; `steps` sets the march length.
With nothing registered the passes are released entirely, so a scene with no
media pays nothing.
The map is sized to the bounding sphere of everything registered, so a small
bank sharing a scene with a much larger one lands on few texels — set `extent`
and `centre` explicitly to bias the map toward the region that matters.
Attenuation applies to surfaces. The sky keeps its own colour.
# media_common
The media-shadow passes' shared ground: the volume record layout, the transmittance
accumulation along a ray, and the cap on how many volumes one frame carries
(`MAX_VOLUMES` = 32). That cap sizes a fixed array, so it is a constant here
rather than a param.
·computeshader · born here
❒asset # media_shadow_apply
Applies the media's transmittance to the lit scene.
## Bindings
| name | kind |
|------|------|
| `source_map` | `texture2d` |
| `scene_depth` | `texture_depth` |
| `tau_map` | `texture2d` |
| `frame_cam` | `buffer` |
| `media_params` | `buffer` |
| `media_out` | `storage2d (rgba16f)` |
·computeshader · born here
❒asset # media_tau_map
Builds the transmittance map through the media, so a later pass reads how much light survives to a point instead of marching for it.
## Includes
- `media_common`
## Bindings
| name | kind |
|------|------|
| `media_params` | `buffer` |
| `media_volumes` | `buffer` |
| `media_tau_out` | `storage2d (rgba16f)` |
# cumulus_default
Default weather preset for `VolumetricSky.component`. Captures the
v4-tuned cumulus look: visible discrete clouds, warm-afternoon sun,
moderate coverage.
## Use
```luau
local sky = entity.spawn("sky")
sky.component.add("VolumetricSky", {})
preset.apply("cumulus_default", { entity = sky.id, component = "VolumetricSky" })
```
The values set the component's `preset` Field.table as a unit — one
Sync replication carries every shaping/lighting parameter to peers. Tweak
by reading the values and changing them before the component is added:
```luau
local values = preset.values("cumulus_default")
values.shaderVariant = "v5" -- swap raymarcher
values.preset.coverage = 0.6 -- sparser sky
sky.component.add("VolumetricSky", values)
```
## Authoring more presets
Capture a sky you have tuned with
`preset.capture({ entity = sky.id, component = "VolumetricSky" }, "<name>")`,
or drop another `<name>.preset/` folder next to this one (or anywhere under
`/zero/source/`); the asset indexer registers it and `preset.values("<name>")`
resolves it. Common alternatives:
- `stratocumulus.preset` — broad overcast layer (`coverage ~= 0.15`,
`cloudType = 0.5`, `altitudeHigh ~= 0.65`).
- `clear.preset` — barely any cloud (`coverage ~= 0.7`,
`densityGain ~= 0.2`).
- `storm.preset` — dense black anvils (`coverage ~= 0.1`,
`densityGain ~= 0.85`, `sunColor ~= [0.6, 0.55, 0.5]`).
`preset` is one `Field.table`: applying a preset replaces it whole, so
provide every field.
# scattering_common
The light-scattering passes' shared ground: the phase function, the shadow lookup along a ray, and the froxel-to-world mapping.
Included with `#include "@builtin::systems.volumetrics.scattering_common"`.
·computeshader · born here
❒asset # cloud_noise
Procedural volumetric-cloud noise fill (Schneider/Hillaire style). A low-frequency
FBM gives the base blob silhouette, a high-frequency FBM erodes it near the surface
for the fluffy cumulus look, plus altitude shaping (round bottom, full middle, anvil
top). Writes the 3D cloud density texture.
Bindings (see bindings.yaml): `clouds` (storage3d rgba16f — the cloud volume), `U`
(read `array<f32>` uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_noise", ...)` (storage texture requires resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_noise_v3
Volumetric-cloud noise fill (Schneider/Hillaire style), v3 variant. Low-frequency
FBM base silhouette + high-frequency FBM surface erosion for the billowed cumulus
look, with altitude shaping (round bottom, full middle, anvil top). Writes the 3D
cloud density texture.
Bindings (see bindings.yaml): `clouds` (storage3d rgba16f — the cloud volume), `U`
(read `array<f32>` uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_noise_v3", ...)` (storage texture requires resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_noise_v5
Volumetric-cloud noise generator, v5 (Nubis/Schneider style). Builds two
foundational noises — Perlin-Worley FBM for the lumpy "cauliflower" base shape and
Worley FBM for high-frequency detail erosion — under weather-map control. Writes
the 3D cloud density texture.
Bindings (see bindings.yaml): `clouds` (storage3d rgba16f — the cloud volume), `U`
(read `array<f32>` uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_noise_v5", ...)` (storage texture requires resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_raymarch
Volumetric-sky cloud raymarcher with Frostbite-style lighting: Wrenninge/Hillaire
multi-scatter approximation (3 octaves), a 5-sample cone-traced soft self-shadow,
and combined Beer-Lambert + Powder so cloud edges glow toward the sun. Reads the
cloud volume + occupancy textures and writes the lit raymarch result.
Bindings (see bindings.yaml): `clouds` (texture3d), `occupancy` (texture3d),
`cloud_smp` (sampler), `out_tex` (storage2d rgba16f), `U` (read `array<f32>`
uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_raymarch", ...)` (textures/sampler/storage texture require resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_raymarch_v3
Volumetric-sky cloud raymarcher (Frostbite-style lighting), v3 variant:
Wrenninge/Hillaire multi-scatter (3 octaves), 5-sample cone-traced soft shadow, and
combined Beer-Lambert + Powder edge glow. Reads the cloud volume + occupancy
textures and writes the lit raymarch result.
Bindings (see bindings.yaml): `clouds` (texture3d), `occupancy` (texture3d),
`cloud_smp` (sampler), `out_tex` (storage2d rgba16f), `U` (read `array<f32>`
uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_raymarch_v3", ...)` (textures/sampler/storage texture require resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_raymarch_v4
Volumetric-sky cloud raymarcher, v4. Adds procedural detail noise computed
in-shader on top of the large-scale texture silhouette (removes v3's voxel-grid
blockiness) and uses multi-distance shadow sampling (4 geometrically spaced strides)
instead of a cone trace. Reads the cloud volume + occupancy textures and writes the
lit raymarch result.
Bindings (see bindings.yaml): `clouds` (texture3d), `occupancy` (texture3d),
`cloud_smp` (sampler), `out_tex` (storage2d rgba16f), `U` (read `array<f32>`
uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_raymarch_v4", ...)` (textures/sampler/storage texture require resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_raymarch_v5
Production volumetric-cloud raymarcher, v5 (Nubis/Hillaire style): Hillaire
multi-scatter octaves (fixes black cloud cores), energy-conserving Beer-Powder-MS
lit-edge glow, a 6-offset Wrenninge cone-trace shadow, and a dual-lobe
Henyey-Greenstein phase. Reads the cloud volume + occupancy textures and writes the
lit raymarch result.
Bindings (see bindings.yaml): `clouds` (texture3d), `occupancy` (texture3d),
`cloud_smp` (sampler), `out_tex` (storage2d rgba16f), `U` (read `array<f32>`
uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_raymarch_v5", ...)` (textures/sampler/storage texture require resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_taa
Cloud TAA composite — temporal anti-aliasing for the raymarch output. Reprojects
last frame's blended output through the previous view-projection, color-clamps the
history to the current sample's neighborhood to suppress ghosting, and exponentially
blends the two. Writes both the next-frame history slot and the externally-sampled
`scene_volumetric_sky` texture the composite path reads.
Reprojection runs through the world point named by uniform `[37]`, the NDC z of the
range the cloud stands at along the view ray — `volumetricSky.reprojectionDepthNdc`
computes it. The weight the history carries falls to zero across the last
`REPROJECT_BORDER_TEXELS` of the reprojected frame, so a pixel whose history lies
off the previous frame reaches the raymarch's own output down a gradient rather
than across a line.
Bindings (see bindings.yaml — slot order must match the Luau dispatch): `cur_tex`
(texture2d), `hist_prev` (texture2d), `smp` (sampler), `out_hist` (storage2d
rgba16f), `out_scene` (storage2d rgba16f), `U` (read `array<f32>` uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_taa", ...)` (textures/sampler/storage textures require resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # volume_pack
Fills the brick pool from the volume `volume_pages` has just paged.
One invocation per pool voxel. The pool is a grid of blocks, one block per
slot; a block holds a brick plus a one-voxel apron taken from the neighbouring
bricks, so a trilinear sample inside a brick reads what the dense volume would
have given it rather than whatever brick the pool stores next door.
## Bindings
| # | name | what |
|---|---|---|
| 0 | `source` | the dense volume being paged (sampled 3D texture) |
| 1 | `atlas` | the brick pool (`rgba16f` storage) |
| 2 | `page_slots` | four words per slot, written by `volume_pages` |
| 3 | `K` | `PackParams` — volume dimensions and brick edge |
## Why the pool is always `rgba16f`
A pool is written as a storage texture and sampled with filtering. `rgba16f` is
the format that is both: the one-channel formats cannot be written as storage
textures, and `r32f` cannot be filtered without a device feature the engine
does not require. So a scalar volume's density rides in the pool's red channel
— which is where the raymarcher reads density from in density mode — and pays
`rgba16f` per resident voxel. `Volume:sparseStats()` reports the dense and
resident sizes side by side rather than assuming paging is the smaller of the
two.
The pass writes every voxel of the pool, including blocks whose slot was never
handed out: those hold whatever brick their stale slot entry names, and the
page table never points at them.
·computeshader · born here
❒asset # volume_pages
Decides which bricks of a volume are worth storing, and where each one lives.
One invocation per brick. It scans the brick's voxels in the source volume,
and a brick holding anything over `threshold` claims a slot from an atomic
counter. The slot number lands in the page table (`slot + 1`, so 0 keeps
meaning "empty"), and the brick's grid coordinates land in the slot list so
`volume_pack` knows what to copy into that slot.
Nothing is read back to the CPU and no brick waits on another, so the pass can
run every frame — which is what makes the page table usable as the acceleration
structure for a volume whose contents change while it renders.
## Bindings
| # | name | what |
|---|---|---|
| 0 | `source` | the dense volume being paged (sampled 3D texture) |
| 1 | `pages` | page table, one texel per brick (`rgba16f` storage) |
| 2 | `page_alloc` | `[0]` slots handed out, `[1]` bricks that found the pool full |
| 3 | `page_slots` | four words per slot: the brick's x, y, z, and 1 |
| 4 | `P` | `PageParams` — volume dimensions, brick edge, threshold, channel, capacity |
`page_alloc` must be zeroed before each run; the counter is what allocates, so
a stale count hands out slots past the pool.
## The page value
A resident brick's page value is `slot + 1`, split into two digits base 1024
across the texel's red and green channels. The page table is `rgba16f` because
that is the format that is both storage-writable and filterable — the
raymarcher binds it where a sampled 3D texture is expected — and a half float
only carries integers exactly up to 2048, which one digit stays under. A brick
holding nothing writes zero in both channels, so "is anything here" is the sum
against zero, the same test the older single-channel occupancy grid answers.
## Overflow
A brick that arrives after the pool is full is counted in `page_alloc[1]` and
written as empty. The volume renders without it rather than sharing a slot.
`page_alloc[0]` keeps counting past the capacity, so a caller that reads it
back learns the capacity the volume actually needs.
·computeshader · born here
❒asset # volume_raymarch
Generic volume raymarcher. One invocation per output pixel: builds a world-space
ray from the camera inverse view-projection, clips against the volume AABB, and
marches front-to-back, using the occupancy grid to skip empty bricks. Output is
RGBA16F (premultiplied RGB, accumulated alpha) for the caller to composite.
A paged volume marches through the same shader: `volume` is then the brick pool
and `occupancy` the page table, whose texel per brick carries the pool slot that
brick lives in (0 for a brick holding nothing, which is what the empty-space
skip already reads it as). `VolumeUniforms.paged` carries the flag and the
volume's own dimensions, which the pool no longer describes.
Both the skip and the pool lookup find a point's brick by the voxel it lands on
divided by the brick edge — the way the page table and the occupancy grid were
written. A volume that is not a whole number of bricks across has a grid wider
than `dims / brick`, so dividing the normalized coordinate by the grid instead
would name a different brick for the same point. When the dimensions are unknown
(a wrapper was never told them) the grid times the brick edge stands in for them.
Bindings (see bindings.yaml): `volume` (texture3d), `occupancy` (texture3d),
`volume_smp` (sampler), `out_tex` (storage2d rgba16f), `U` (read buffer, a single
`VolumeUniforms` struct).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.volume_raymarch", ...)` (textures/samplers/storage texture require resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # vol_composite
Composites the scattered light over the scene, depth-aware so geometry occludes it.
## Includes
- `scattering_common`
## Bindings
| name | kind |
|------|------|
| `scene_color` | `texture2d` |
| `scene_depth` | `texture_depth` |
| `scatter_map` | `texture2d` |
| `vol_params` | `buffer` |
| `vol_out` | `storage2d (rgba16f)` |
·computeshader · born here
❒asset # vol_scatter
Accumulates in-scattered light through the volume, one froxel at a time.
## Includes
- `scattering_common`
## Bindings
| name | kind |
|------|------|
| `scene_depth` | `texture_depth` |
| `frame_cam` | `buffer` |
| `lights` | `buffer` |
| `vol_params` | `buffer` |
| `scatter_out` | `storage2d (rgba16f)` |
# volume
High-level Luau API for 3D textures and volumetric rendering. Composes
the engine's generic `compute.*` primitives into volume-specific
helpers — procedural builders (`sphere`, `box`, `noise`), a sparse
NanoVDB-style occupancy grid for empty-space skipping, and a one-call
raymarcher that composites a volume through any entity's transform.
Pairs with the `Volume` component which attaches a built (or imported)
texture to an entity.
## Exports
- `M.create({name, width, height, depth, format?, storage?}) -> Volume` — allocate a fresh, zero-filled 3D texture and return a Volume wrapper.
- `M.wrap({name, format, occupancyName?, occupancyBrick?}) -> Volume` — wrap an existing named texture without recreating it (used by the .zvol viewer).
- `M.get(name) -> Volume?` — the live volume built under that texture name, with its dimensions, channel count, occupancy grid and brick pool.
- `M.names() -> {string}` — every name `M.get` answers to, sorted.
- `M.register(volume) -> Volume` — publish a wrapped volume under its own texture name.
- `M.unregister(name) -> Volume?` — stop answering for a name and hand back the volume that held it, leaving its GPU resources alone.
- `M.releaseTarget(name) -> boolean` — destroy a render target this module built for `render` and forget its size.
- `M.sphere(opts) -> Volume` — procedural Volume filled with a soft sphere SDF.
- `M.box(opts) -> Volume` — procedural Volume filled with a soft box SDF.
- `M.noise(opts) -> Volume` — procedural Volume filled with FBM noise.
- `M.raymarchShaderIdentity() -> string` — absolute identity of the shared raymarch shader.
- `M.compositeShaderSource() -> string` — WGSL source for the post-process composite blending `scene_volume_rt` over the scene.
- `M.FORMATS` — table of canonical format strings (`R8`, `R16F`, `R32F`, `RGBA8`, `RGBA16F`, `RGBA32F`).
- `volume:destroy()` — release the GPU texture + occupancy.
- `volume:writeBytes(bytes) / :writeFloats(floats)` — upload payload to the 3D texture (matches the texture's format).
- `volume:fill(fn)` — CPU procedural fill via `fn(x,y,z) -> color`.
- `volume:readback() -> bytes` — pull the texture's current contents back to Luau.
- `volume:buildOccupancy({brickSize, sampler?})` — build an R8 occupancy companion for empty-space skipping.
- `volume:buildSparse({brickSize?, threshold?, headroom?, capacity?, mode?})` — page the volume into bricks, keeping only the ones holding something. Yields while the brick count reads back — twice unless `capacity` says how many slots to take.
- `volume:refreshSparse()` — re-page from the volume's current contents; two dispatches and no wait.
- `volume:releaseDense()` — free the dense texture and render from the brick pool alone.
- `volume:releaseSparse()` — free the brick pool and page table and go back to rendering the dense texture.
- `volume:readSparseCounts()` — `{ bricks, wanted, overflow, capacity }` off the GPU; yields.
- `volume:sparseStats()` — byte accounting: `denseBytes`, `poolBytes`, `pageBytes`, `residentBytes`, `ratio`.
- `volume:fillCompute(shaderName, opts)` — drive a custom fill shader against this volume.
- `volume:render(opts)` — dispatch the shared compute raymarcher into a 2D storage target, using `opts.entity` for AABB. A paged volume renders through its pool with no change at the call site.
## Usage
```luau
local Volume = require("@builtin::systems.volumetrics.volume")
-- Build a procedural noise cloud + occupancy.
local cloud = Volume.noise({
name = "cloud", size = 64, scale = 4, octaves = 4, threshold = 0.55,
})
cloud:buildOccupancy({ brickSize = 8 })
-- Render through the shared raymarcher; output to scene_volume_rt.
cloud:render({
entity = entity(id),
target = "scene_volume_rt",
targetWidth = 1280, targetHeight = 720,
density = 8.0, absorption = 1.2, emission = 0.9,
color = { 1.0, 0.9, 0.7 },
maxSteps = 256, mode = "rgba",
})
```
The `Volume.component` is the per-entity wrapper around this module — it
resolves the name it carries through `Volume.get` and dispatches
`volume:render` from its `update` hook.
## Addressing a volume by name
Every volume this module builds answers to its texture name, so anything
holding only the name — an entity's `Volume` component, a second script, a
tool — reaches the volume itself:
```luau
local cloud = Volume.noise({ name = "cloud", size = 128 })
cloud:buildSparse({ brickSize = 8 })
cloud:releaseDense()
-- somewhere else entirely
Volume.get("cloud"):render({ entity = entity.find("cloud_host") })
```
What the name alone cannot express is what makes this matter: the voxel
dimensions, which channel holds density, and whether the volume is paged into
a brick pool. A holder that reconstructs a wrapper from the name gets a dense
volume that the paging may already have released.
One volume owns a name at a time: the 3D texture, the page table and the brick
pool all address it, so `create` over a name a live volume holds releases that
volume first and says so in the log. `destroy` clears the entry. A wrapper
around a texture another system owns is published with `Volume.register` and
dropped with `Volume.unregister`, which hands the volume back so it can still
be destroyed. The names live where this module is loaded — one set per Luau
VM, alongside the raymarch target registry.
`render` builds its `target` on the first call that names one and keeps its
size, so repeated renders reuse it; `Volume.releaseTarget(name)` destroys that
target and forgets the size, and the next render builds it again.
## Paging a volume into bricks
A dense volume costs its bounding box. `buildSparse` splits it into bricks,
keeps only the ones holding anything over `threshold`, and stores those in a
brick pool; a page table one texel per brick says which slot each resident
brick took, and 0 says the brick holds nothing. That page table is also the
grid the raymarcher already skips empty space with, so paging and empty-space
skipping are the same structure.
```luau
local cloud = Volume.noise({ name = "cloud", size = 128, threshold = 0.6 })
local stats = cloud:buildSparse({ brickSize = 8, threshold = 0.01 })
print(stats.bricks, "of", stats.grid[1] * stats.grid[2] * stats.grid[3], "bricks")
print(stats.poolBytes, "vs dense", stats.denseBytes)
cloud:releaseDense() -- the pool is now the only copy
cloud:render({ entity = entity.find("cloud_host") })
```
Both passes run on the GPU with no readback between them, so a volume a
compute shader rewrites keeps a matching page table for the cost of two
dispatches:
```luau
cloud:fillCompute(myFillShader, { extraResources = { { kind = "buffer", name = fillU } } })
cloud:refreshSparse()
```
The pool is fixed at the size the build measured, so a volume that grows past
it loses the surplus bricks. `refreshSparse` asks the GPU for the counts
without waiting on them and folds them in on a later call, so `sparseStats()`
reports the overflow — and the log carries a warning naming the capacity to
page at — a frame or two after the refresh that caused it. `buildSparse`'s
`headroom` buys the slots up front for a volume that is expected to grow.
What it costs:
- A block in the pool is the brick plus a one-voxel apron on every side, so a
resident brick costs `(brick + 2)³` voxels. At `brickSize = 8` that is 1.95x
per resident brick, at 16 it is 1.42x. The apron is what makes a trilinear
sample inside a brick read exactly what the dense volume would have given
it.
- A pool is always `rgba16f`: it has to be writable as a storage texture and
sampled with filtering, and that is the format that is both. A scalar volume
therefore spends more per resident voxel than it did dense (its density rides
in the pool's red channel), so `sparseStats()` reports `denseBytes` and
`residentBytes` side by side rather than assuming paging is the smaller of
the two.
- `refreshSparse` re-pages from the dense volume, so a volume that will keep
changing keeps its dense copy; `releaseDense` is for one that is finished.
- Classification reads one channel: alpha for a four-channel volume, red for a
scalar one. A four-channel volume that carries density in red — the one
`render`'s `mode = "density"` marches — has to say so, with the same word:
`buildSparse({ mode = "density" })`. A `render` that names no `mode` marches
a paged volume from the channel its pool was filled from, so the two agree
without the caller repeating the choice.
## Notes
- Volumes are GPU-owned; the Luau wrapper just holds the named handle.
`volume:destroy()` releases both the texture and any occupancy
companion.
- The composite target `scene_volume_rt` is shared across every Volume
entity in the scene, so multiple volumes composite into one image.
Override `opts.target` if you need separate render targets per volume.
- Occupancy grids use a brick size (default 8) — smaller = more
precise skipping at the cost of grid memory.
- The `vdb_to_zvol.importer` in this package produces dense voxel
buffers consumable by `compute.writeFloatsTexture3D`, which can be
wrapped via `Volume.wrap` (see the `zvol_viewer.scene/` demo for the
full path).
# volumeDemos
Shared orchestration backend for the `volumeDemos.package` showcase
scenes. Owns the per-demo resource pack (3D texture, 2D render target,
two uniform buffers) and the auto-registered composite, exposing a
small public surface the demo scenes drive once per frame.
## Exports
- `M.spawn({name, size, fillShader, fillUniformBytes}) -> ()` — register a new demo pipeline. Creates the 3D volume, the 2D storage target, registers the per-demo fill shader, ensures the shared raymarcher is registered, and auto-installs the depth-aware composite.
- `M.destroy(name) -> ()` — release everything `spawn` created (texture + render target + buffers + composite registration).
- `M.dispatchFill(name, fillUniforms) -> ()` — pack `fillUniforms` (Lua array of f32) into the demo's fill UBO and dispatch the fill shader against the volume.
- `M.dispatchFrame(name, {camera, aabbMin, aabbMax, width, height, stepSize, maxSteps, alphaCutoff, densityGain, emissionGain}) -> ()` — pack camera + raymarch params into the shared 40-float UBO and dispatch the raymarcher into the demo's render target.
- `M.NAMES(name) -> {volume, rt, composite}` — engine-side resource names for the demo (used by the `.zvol` viewer to bypass the helpers and upload voxels via `compute.writeFloatsTexture3D` directly).
## Usage
```luau
-- From a demo scene at <pkg>/demo/<name>.scene/entrypoint.luau,
-- `...` climbs to the package root, then resolves volumeDemos.module:
local V = require("@builtin::systems.volumetrics.volumeDemos")
-- onLoad: spin up the pipeline.
V.spawn({
name = "smoke_plume",
size = { 96, 192, 96 },
fillShader = SMOKE_FBM_WGSL,
fillUniformBytes = 64,
})
-- update(dt): repaint the volume + raymarch into the RT.
V.dispatchFill("smoke_plume", { elapsed, density_amp, spread, ... })
V.dispatchFrame("smoke_plume", {
camera = cam.get(),
aabbMin = { -40, 0, -40 },
aabbMax = { 40, 160, 40 },
width = 1280, height = 720,
stepSize = 0.9, maxSteps = 240,
densityGain = 2.5, emissionGain = 1.0, alphaCutoff = 0.995,
})
-- onUnload: free GPU resources.
V.destroy("smoke_plume")
```
## Notes
- `dispatchFrame` packs 40 f32s into the raymarch UBO:
`[0..16)` inv_view_proj (mat4 row-major), `[16..19)` aabb_min,
`[19..22)` aabb_max, `[22..23)` step_size, `[23..24)` max_steps,
`[24..25)` alpha_cutoff, `[25..26)` density_gain, `[26..30)`
target_width/height + inv reciprocals, `[30..31)` emission_gain,
`[31..40)` padding.
- Pipeline state lives in module-locals keyed by `name` — the same
module instance can host many demos in parallel, but two scenes
must not pick the same `name`.
- Each demo scene supplies its own fill shader; the raymarcher is
shared (one shader name, one compiled pipeline). Replacing
`fillShader` between frames is supported; replacing the raymarcher
isn't (use the volumetrics package for that use case).
# volumeSequence
Playback for a run of `.zvol` frames, through a ring of resident volumes.
A sequence names its frames as VFS paths in playback order. It allocates
`resident` 3D textures — one frame each — and nothing else: the frames the
playhead is about to reach are read off the VFS on their own tasks and
uploaded into whichever ring slot the playhead has travelled furthest past.
So a 240-frame run and a 4-frame run cost the same on the GPU, and what a
sequence costs is decided when it is opened rather than by how long it is.
```luau
local Seq = require("@builtin::systems.volumetrics.volumeSequence")
local frames = {}
for i = 0, 239 do
frames[#frames + 1] = string.format("/source/sim/plume_%04d.zvol", i)
end
local seq = Seq.open({ name = "plume", frames = frames, resident = 3, fps = 24 })
local s = seq:stats()
print(s.residentBytes, s.allBytes) --> 3145728 251658240
```
Drive it from a component, which is also what makes it visible:
```luau
entity(id).component.add("VolumeSequence", { sequenceName = "plume", density = 6.0 })
```
or by hand, if the caller already has a tick of its own:
```luau
seq:tick(dt)
local frame = seq:current()
if frame then
frame:render({ entity = entity(id), density = 6.0 })
end
```
The playhead advances once per engine frame however many callers tick the
sequence — a component and a script, or two entities naming one run, share
one playhead rather than each moving it. Every call still promotes the frame
that has landed and starts the reads ahead of it.
## Residency
Every frame of a run has the dimensions frame 1 declares, so one ring serves
the whole run. `stats()` reports both what the ring holds (`residentBytes`)
and what holding every frame at once would cost (`allBytes`); `residency()`
says which frame each slot holds right now.
The ring needs at least two slots for a multi-frame run — one holds the
picture on screen while the other takes the read — and `open` raises the
count to two if it was asked for less. `prefetch` says how many frames ahead
of the playhead reads are started, and defaults to every slot the ring has
spare — a slot left empty is a frame that gets waited on later. It is bounded
by those same slots, so asking for more than fits reads as far ahead as it can.
## Keeping up
A tick that wants a frame no slot holds yet keeps showing the frame it had
and counts a `stall`. Playback is therefore never blank and never blocks, and
`stats().stalls` is the number that says whether the ring and the prefetch
distance are big enough for the rate: a run whose reads keep up shows a stall
count that stops rising once the ring has filled.
`errors` counts frames that failed to read or whose header disagreed with the
ring. Each one is counted and logged once, with the path and what was wrong
with it, and the run steps over it from then on rather than re-reading it
every tick — so `errors` answers how many frames of the run are broken, and a
mistyped path costs one line in the log rather than one per frame.
The rate and the playhead are the sequence's, not a caller's: `setFps` keeps
the frame the playhead stands on and the fraction of the way through it, and
`seek` lands on exactly the frame asked for.
# volumetricLighting
The settings behind the `VolumetricLighting` component, and the way to drive the
scene's participating medium from a script — fog that rolls in over a cutscene, a
quality slider that drops the step count, a room that fills with smoke.
```lua
local volumetricLighting = require("@builtin::systems.volumetrics.volumetricLighting")
volumetricLighting.set({ density = 0.06, anisotropy = 0.7 })
volumetricLighting.get() -- a copy of the settings in force
volumetricLighting.active() -- whether the march is running
volumetricLighting.clear() -- empty the air, keep the settings
```
`set` takes any subset of the fields; whatever is omitted keeps its current
value. A `density` of 0 releases the render feature rather than marching a medium
that scatters nothing, and `clear` is the named way to say the same thing.
`sunDirection` tracks the scene's sun on its own. Pin it through `set` when a
scene wants the medium lit from somewhere else.
The module packs the settings into the `vol_params` buffer the passes read, so a
change lands on the next frame without recompiling anything.
## Cost
The march is the expensive part, and its cost is `steps` × (`shadowSteps` + the
number of live scene lights) per marched pixel. Those are the two knobs to reach
for on a budget: `steps` controls how cleanly a beam's edge resolves,
and `shadowSteps` controls whether the sun's contribution is shadowed at all.
Setting `shadowSteps` to 0 removes the per-sample visibility work entirely, which
is the single largest saving available — at the cost of the shafts, since the
medium is then lit evenly wherever it reaches.
# volumetricSky
Orchestration backend for the `VolumetricSky.component`. Owns the
GPU resources the cloud layer needs (cloud volume, raymarch render
target, ping-pong TAA history pair, post-process composite) and
exposes a small public surface the component drives every frame.
## Exports
- `M.spawn(opts?) -> id` — spawn the singleton sky entity, or configure the one already standing, and return its id. Every weather parameter (`coverage`, `sunDir`, `sunColor`, `sunIntensity`, `windSpeed`, …) is settable at the top level or inside a `preset` sub-table; every `VolumetricSky` component field (`shaderVariant`, `cloudSize`, `taaBlendAlpha`, …) by its own name; `name` names the entity. Parameters left out keep their current values. An option that names neither raises, listing both sets, and leaves no entity behind. A weather parameter takes the shape its default carries — a number, or an array of that many numbers — and a value of another shape raises naming the parameter.
- `M.configure(opts) -> boolean` — apply those same weather parameters and component fields to the sky already standing. Returns `false` when none has been spawned.
- `M.defaultPreset() -> table` — a fresh copy of the weather configuration a sky spawned with no options carries. The copy is the caller's to edit.
- `M.destroy() -> ()` — release every resource `spawn` created.
- `M.fillClouds(noiseParams) -> boolean`: bake the cloud volume with the active `cloud_noise_*` shader when what it holds is not what `noiseParams` describe, and return whether this call baked. A change to a shaping parameter, the colour, the seed, the size, the variant or the render device bakes at once, and a write to the noise shader's `shader.wgsl` or `bindings.yaml` bakes once the program it compiles is resident; a change to the wind alone bakes at most `noiseParams.noiseRegenRate` times per second (default `M.DEFAULT_NOISE_REGEN_RATE`, `0` = every call the wind moved) on `noiseParams.clock` (default `getTime()`). `noiseParams` is the flat table produced by `VolumetricSky.component`'s `snapshotNoiseParams()`.
- `M.volumeBakes() -> number`: how many times the cloud volume has been baked since the module loaded.
- `M.dispatchRaymarch(renderParams, viewport) -> ()` — dispatch the active `cloud_raymarch_*` shader, blend with history via the TAA pass, and write into the composite target. `renderParams` is the flat table from `snapshotRenderParams()`.
- `M.find() -> id?` — return the id of the active VolumetricSky entity, or `nil` if none has been spawned by `M.spawn`.
- `M.despawn() -> ()` — despawn the entity `M.spawn` minted (does NOT free GPU resources — call `M.destroy()` for that).
- `M.setCoverage(c) / M.setDensity(g) / M.setSun(dir, color, intensity?) / M.setWind(v)` — convenience setters that route through the active component's `public` fields.
- `M.reprojectionDepthNdc(camPos, fwd, aabbMin, aabbMax, near, far) -> number` — the NDC z the TAA pass carries history through, taken from where the cloud stands along the centre view ray. A point at range `d` travels `translation / d` across the screen, so this is what keeps a translating camera's history landing on the part of the picture the cloud moved to.
## Usage
```luau
-- The component drives this module automatically. Direct M.* use is
-- rare; the canonical entry point is attaching VolumetricSky to an
-- entity (or loading a preset and passing it to component.add):
local sky = entity.spawn("sky")
sky.component.add("VolumetricSky", {})
-- Driving the layer by hand instead, a frame at a time. Each dispatch
-- creates the resources it needs at the size its own arguments name —
-- the cloud volume from `noiseParams.cloudSize / cloudHeight`, the
-- render target and composite from the viewport:
local Sky = require("@builtin::systems.volumetrics.volumetricSky")
Sky.fillClouds(noiseParams)
Sky.dispatchRaymarch(renderParams, { width = 1280, height = 720 })
```
## Notes
- `shaderVariant` selects which compute shader pair (`cloud_noise_v3 +
cloud_raymarch_v3` / `cloud_noise + cloud_raymarch_v4` /
`cloud_noise_v5 + cloud_raymarch_v5`). The module resolves the
variant strings to absolute identities — relative paths don't work
from a module body (the chunkname retains an `.init` segment that
throws off the rsplit-to-parent step in `resolve_relative_dot`).
- The cloud volume + history pair are RGBA16F — 256×128×256 ≈ 32 MiB
VRAM. Drop to 192/96 via `cloudSize` / `cloudHeight` if tight.
- The TAA pass is a single compute dispatch blending current sample
with the previous frame's RT through the history pair. Set
`taaBlendAlpha = 1.0` to disable while still going through the same
code path (useful for benchmarking).
- All public setters write through to `VolumetricSky.component`'s
`public` fields, which means they replicate as Sync if the
component is sync-enabled. Tweaks survive scene reloads as long as
the same entity is alive.
- `SkyPreset` and its defaults are declared here, and the component
declares its `preset` field from them. `M.spawn` / `M.configure` read
that same key set to tell a weather parameter from a component field, so
a parameter added to the preset reaches the schema and the option routing
in one edit.
·renderfeature · born here
❒asset # volumetricSky
The cloud layer's render passes.
The layer is raymarched for the camera being drawn, copied into the target the
composite reads, temporally accumulated where there is a previous frame of that
view to accumulate against, and laid over the frame behind whatever the depth
buffer holds.
Every pass but the accumulation runs for each view the frame draws, an
environment cube's six faces included — which is what puts the clouds into
reflections. The accumulation blends against the previous frame of the same
view, and a cube face is rendered once with no previous frame, so it declares
`environmentCapture = false` and the copy before it is what those faces read.
`VolumetricSky` creates this feature; it is not meant to be created by hand.
# 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.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) and the
packages it depends on (`dependencies`). **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 })
```
## `package.yaml`
```yaml
name: inkBlocks # optional; the folder stem when absent
version: "1.0.3"
description: "Ink and paper style blocks"
dependencies:
- { name: starterBlocks, version: "1.0.8" }
- styleKit # a bare name states no version
```
`dependencies` lists the packages this package needs. Each entry's `name` is
a reference the package records, the same kind of reference a `require` or an
asset name in a data file makes:
- **It resolves in the author's world.** Install the dependency here first
(`zeromind.installAsset`); the entry then names the copy this world holds.
A push refuses a package whose dependency resolves to nothing, naming it.
- **An install brings it along.** Installing the package installs every
dependency it lists, at the version this world holds when the package is
pushed, under `/source/deps/<owner world>/`, deduplicated against what the
installing world already holds. `zeromind.preview` lists each one before
anything is written, and the install's `deps` lists what it brought.
Uninstalling the package leaves a dependency that another installed
package still uses.
- **`version` is checked, not chosen.** A stated `version` has to equal the
`version` the dependency's own `package.yaml` states in this world;
`asset.validate` (and so the push) refuses the package naming both when
they differ. To depend on another version, install that version here.
## How it operates
1. **Registration.** Writing `package.yaml` indexes the package with
the package registry.
2. **Install.** A package is read before it is installed: `zm ls-tree
@<guid>` in the shell lists what it contains, and `zm show
@<guid>:<path>` and `zm grep <pattern> @<guid>` read it, so candidates
are compared by what they do. The one that fits is installed by guid,
the way every asset is: `zeromind_install` over the engine's MCP, or
`tools.use("zeromind", "installAsset", <guid>)` inside the engine. The
install writes the package's files (and the deps its `.refs`
record, the packages its `package.yaml` lists under `dependencies`
included) 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.
- **A dependency named only by a string the code builds.** A package
whose files reach another package only through a name assembled at
runtime records no reference to it, so an install arrives without it.
List that package under `dependencies` in `package.yaml`.
- **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.
·voxeltemplate · born here
❒asset # bookshelf
Voxel template `bookshelf` (demo asset). Live instances spawned via `Voxel.fromTemplate("bookshelf")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # bot_seated
Voxel template `bot_seated` (demo asset). Live instances spawned via `Voxel.fromTemplate("bot_seated")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # bot_standing
Voxel template `bot_standing` (demo asset). Live instances spawned via `Voxel.fromTemplate("bot_standing")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # cabinet
Voxel template `cabinet` (demo asset). Live instances spawned via `Voxel.fromTemplate("cabinet")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # chair
Voxel template `chair` (demo asset). Live instances spawned via `Voxel.fromTemplate("chair")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # clock
Voxel template `clock` (demo asset). Live instances spawned via `Voxel.fromTemplate("clock")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # desk
Voxel template `desk` (demo asset). Live instances spawned via `Voxel.fromTemplate("desk")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # keyboard
Voxel template `keyboard` (demo asset). Live instances spawned via `Voxel.fromTemplate("keyboard")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # lamp
Voxel template `lamp` (demo asset). Live instances spawned via `Voxel.fromTemplate("lamp")` reload automatically when this template's `template.voxbin` payload is rewritten.
·voxeltemplate · born here
❒asset # linked_workstation_baked
Voxel template `linked_workstation_baked` (demo asset). Live instances spawned via `Voxel.fromTemplate("linked_workstation_baked")` reload automatically when this template's `template.voxbin` payload is rewritten.