# Tool (asset type)
A tool is a single agent-callable function whose schema is its
`typed function` signature. Tools are the workflow surface — composed
Luau operations that bundle multiple engine APIs into one named call,
suitable for agents (and for use from `execute`). Each tool has a
declared input shape, return shape, and a strict result envelope
(`ZmToolResult`).
**Every exported tool function is declared `typed function`.** The
`typed` keyword is what makes the signature real: it compiles to a
runtime dispatch wrapper that checks each argument against its
declared type and raises on a mismatch. A plain `function` — even one
carrying Luau type annotations — is checked by nothing at call time,
so the tool accepts any input and reports success on garbage. See
"The typed signature is the schema" below.
## When to use one
- You're composing several engine APIs into a workflow that's worth
naming and discovering (`tools.list`, `search_tools`).
- You want an agent to be able to call this function by name without
reading source code — the typed signature is the API contract.
- The operation has clear success / failure modes that fit
`{ exitCode, data?, stdout?, message? }`.
If the function is just a Luau utility called from other code, use a
`.module`. If it's stateful background work, use a `.service`. If
it's pure GPU compute, use a compute `.shader`.
## Where it lives
Tools nest under a `.toolbox/` parent — toolboxes are the namespace.
- Source: `/zero/source/.../<Toolbox>.toolbox/<name>.tool/`
- Identity: `<Toolbox>.<name>` (dotted form — the toolbox name is
part of the tool identity).
- Folder shape:
- `init.luau` (or `init.lua`) — exports the function, and carries its
schema in `--!desc` / `--!arg` / `--!return` / `--!example`
annotations above it. The typed signature IS the tool's signature.
**Required.**
- `README.md` — instance documentation. **Required.**
- `.metadata` — tool-level tier and tags. **Required.**
## How to create one
Use the workflow tool that scaffolds the right `.tool/` shape under
an existing toolbox:
```luau
-- The `tools` global authors a tool inside an existing toolbox:
tools.create({
name = "<name>",
toolbox = "<Toolbox>",
description = "What this tool does, agent-facing.",
code = "return shared.ok(true)", -- the function body
})
-- Or scaffold the folder manually:
local box = asset.resolve("<Toolbox>.toolbox")
asset.create("tool", "<name>", { into = box })
```
Always nest under an existing toolbox — if you don't have one yet,
call `tools.createToolbox({ name = "<Name>", description = "..." })`
first.
## How it operates
1. **Registration.** Writing `init.luau` into a `.tool/` folder
registers the tool with the tools registry. It becomes callable
as `tools.use("<toolbox>", "<name>", ...)` from Luau,
`use_tool { toolbox, tool, args }` over MCP, and
`zero <toolbox> <name> [args]` from the engine shell.
2. **Schema validation.** The `typed function` signature declares the
parameter shape. Its runtime dispatch wrapper checks each argument
before the body runs and raises a message naming the argument, the
expected type, and what arrived.
3. **Return envelope.** Tools MUST return `ZmToolResult`:
```
{ exitCode = 0|n, data? = any, stdout? = string, message? = string }
```
`exitCode = 0` means success; non-zero means failure, with
`message` carrying the human-readable reason.
4. **Discovery.** The tool surfaces in `tools.list()`,
`search_tools { query: "..." }`, and `asset.inspect`'s
`exposes:` section (parsed from the typed signature and the
`--!` annotations).
5. **Hot reload.** Editing `init.luau` re-registers the tool.
Subsequent calls see the new behaviour / schema.
## Discovery
- `tools.list()` — every registered tool (any toolbox).
- `search_tools { query: "..." }` — MCP-side tool search.
- `asset.list("tool")` — same set, asset-style listing.
- `asset.inspect("<toolbox>.<name>")` — signature, parameters,
return shape, this type README.
- `ls /zero/source/<Toolbox>.toolbox/<name>.tool` lists the files the folder holds.
## Authoring conventions
- **Implementation lives in `init.luau`.** The tool's full function
body — argument validation, the call into engine APIs, the
return value — goes in `init.luau`. The toolbox's
`shared.module/` is for genuinely cross-tool helpers (`ok` /
`fail` constructors, shared parsers / validators), not per-tool
logic. Do NOT make `init.luau` a one-line forwarder onto a
function defined in `shared.module/`.
- Use `shared.module/` in the parent toolbox for `ok` / `fail`
result constructors. Don't hand-roll the envelope per tool. Pull
it in with `local shared = require(".shared")` from any tool
that needs the helpers.
- Annotate every public function with `--!desc` / `--!arg` /
`--!return` / `--!example` directly above the function — the LSP
and toolbox surfaces extract these.
- **Declare every exported function `typed function M.name(arg: T,
opt: U?): R`.** This is not conditional on the signature being
"clear" — a loose argument is the case that needs the checked
contract most. Give the argument the type it actually accepts
(a union, a named table type, a literal enum) instead of dropping
to an unchecked `function`.
- Keep tools focused. One tool, one named workflow. Split rather
than grow.
- Tag tools in the sibling `.metadata` (`{ "tags": [...], "tier":
"core|content|specialized" }`) so `search_tools` can find them by
domain (e.g. `entity`, `scene`, `assets`, `physics`).
## The typed signature is the schema
The `typed` keyword compiles the function into a dispatch wrapper
that validates arguments at call time. Without it there is no check
at all, so the tool runs its body on whatever arrived and returns a
success envelope built from garbage. Type each argument as narrowly
as the operation actually accepts:
```luau
export type Mode = "edit" | "play"
export type SpawnOpts = {
position: { number }?,
parent: EntityRef?,
count: number?,
}
--!desc Spawn a primitive and return the entities it created.
--!arg kind Primitive shape to spawn.
--!arg opts Placement and repetition options.
--!return The spawned entity refs.
--!example "cube", { count = 4 }
typed function M.spawn(kind: ("cube" | "sphere" | "plane"), opts: SpawnOpts?): { EntityRef }
```
- **A fixed set of accepted words is a literal union**, never
`string`. `("cube" | "sphere")` rejects `"Cube"` at the call and
lists the accepted values in the error; `kind: string` accepts
`"banana"` and fails somewhere deeper, or not at all.
- **A structured argument is a named `export type`**, never
`{ [string]: any }`. Naming it puts every field and its type on
the tool's page and checks them on entry.
- **A named type is only as strong as its fields.** `opts: DrawOpts`
where `DrawOpts = { value: any }` passes for a table and checks
nothing inside it — the same hole, one level down. The argument
checker walks nested fields, so type each one; a type name is not
a place to put the work off.
- **A dispatch table keyed by an `op` string is several tools.**
`{ op: string, [string]: any }` is one unchecked entry point
wearing N signatures — split it so each operation carries its own
checked arguments.
- **An entity or asset argument is `EntityRef` / `AssetRef<kind>`**,
never `any` and never a bare name string.
- `any` is correct only for a value the tool genuinely does not
constrain — a passthrough payload, arbitrary user data. It is not
a placeholder for a type not yet written down.
## Common pitfalls
- **A plain `function` export.** The tool registers and dispatches,
so nothing looks wrong — it just never rejects bad input, and
every call reports success. Declare it `typed function`.
- **Annotations without `typed`.** `function M.f(x: string)` reads
as typed and checks nothing at runtime; the annotation only
informs the LSP. The keyword is what enforces it.
- **Wrong return envelope.** Returning a raw value instead of
`{ exitCode = 0, ... }` will be flagged by callers expecting
`ZmToolResult`.
- **Tool identity.** The fully-qualified identity is
`<toolbox>.<name>`, NOT just `<name>`. Toolbox-less tools don't
exist by design.
- **Missing dependency.** Tools requiring another package /
service must declare it; otherwise the call fails at runtime
with a missing-module error.
## Related types
- `.toolbox` — the required parent namespace for tools.
- `.module` — for Luau libraries with no agent-facing schema.
- `.service` — for stateful background work, not callable
request/response operations.
## In the Inspector
Tool: how the tool is called (its signature), what it does, each argument, what it returns, and its examples, with **Open** for its source.
# tests.shared (module)
Internal helpers shared by the `tests` toolbox tools. Not bound to a
global — required by sibling tools via `require(".shared")`.
## Surface
- `M.DEFAULT_JSON` / `M.DEFAULT_MD` — canonical report paths the engine
test runner writes (`/source/tmp/test_results.json`, `/source/tmp/test_results.md`).
- `M.readReport(src)` — read a JSON report from a VFS path, or pass a
decoded table through unchanged.
- `M.diff(before, after)` — classify every test in `after` relative to the
`before` baseline: `{ newFailures, fixed, stillFailing, newTests, removedTests }`.
## Example
```lua
local shared = require(".shared")
local before = shared.readReport("/source/before.json")
local after = shared.readReport(shared.DEFAULT_JSON)
local d = shared.diff(before, after)
print(#d.newFailures .. " regressions")
```
# oceans
A sea and the ground under it, as one package: the wave simulation, the surface
that renders it, the seabed it is seen through, and the answer to "how high is
the water here" that gameplay needs to float things on it.
```lua
local Ocean = require("@builtin::systems.oceans.modules.ocean")
Ocean.create({ preset = "gale" })
```
That is a whole ocean — waves out to the horizon, foam on the breaking crests, a
seabed below, and a surface that follows whoever is looking at it. To float
something on it:
```lua
local ocean = Ocean.forEntity(oceanEntityId)
boat.position = { x, ocean:sampleHeight(x, z), z }
```
It is a building block. The sun the water glitters under, the ambient its foam
sits in and the sky it reflects all come from the scene, through the same
directional light and reflection probes every other material in the engine
reads — so an ocean drops into a world that already has lighting instead of
bringing a second set, and changing the scene's light changes the water with it.
## What it does
The simulation is **spectral**: instead of animating individual waves, it builds
the sea's energy across every wavelength and direction from an oceanographic
spectrum, then inverse-transforms that into a height field. A real sea is what
comes out — wind speed alone decides wave height, and it rises with wind the way
a fully developed sea does, following the Pierson–Moskowitz law.
It runs in **cascades** — independent wave fields over different patch sizes,
each band-limited to its own range of wavelengths. A tile of roughly two
kilometres carries the long waves and one of a few hundred metres the short
ones; they sum into a surface with detail at every scale, and because the bands
are disjoint no wavelength is synthesised twice. Above them a **Gerstner bank**
adds the swell analytically, which no cascade can hold: a wave longer than its
own patch has nowhere to live, and swell arrives from a distant storm on its own
heading anyway.
**Foam** is not painted on. It comes from the Jacobian of the wave displacement:
where the surface folds over itself, the wave is breaking. Whitecaps appear
where and when the geometry actually breaks, accumulate into a persistent
world-space field, and streak behind the crest as they decay.
**Colour and transparency are the same mechanism.** Per-channel Beer–Lambert
extinction over the distance light travelled through water gives both — red dies
within a metre, blue survives tens. That is why shallow water over sand reads
turquoise and the open sea reads near-black blue without switching materials.
**The seabed** is one noise field read twice: scaled it is the terrain's relief,
unscaled it picks between sand and rock, so outcrops stand on the high ground
and sand fills the hollows. The sun's caustic web moves over both, bent by the
horizontal displacement of the water directly above it. Bodies land on it: the
pass that raises the ground writes the same field out as a height patch that
travels with the viewer, so what a body stands on is what the frame drew.
Nothing here ships an image. Every map the surface and the seabed sample — the
foam patterns, the sand, the rock, the caustics — is grown on the GPU at
startup.
## The pieces
| Asset | What it is |
|---|---|
| `components/Ocean` | Puts a sea on an entity. Its fields are the sea state; the simulation is rebuilt from them, so an ocean survives a scene reload. |
| `modules/ocean` | The substrate: `attach`, `create`, `forEntity`, `sampleHeight`, `sampleDisplacement`. |
| `components/Buoyancy` | Floats a rigid body on the sea. Its fields are the hull: how dense it is, how steady, and what box displaces water. |
| `modules/buoyancy` | The forces: `attach`, `forEntity`, `columns`, `submergedFraction`, `restingY`. |
| `modules/oceanPresets` | Nine named sea states, from `calmClear` through `gale`. |
| `modules/oceanQuality` | The four tiers — how finely a sea is resolved, as distinct from what sea it is. |
| `modules/oceanClipmap` | The camera-following ring geometry both the surface and the seabed are built on. |
| `modules/oceanFloor` | The seabed: terrain, ground materials, caustics and the ground physics collides with. |
| `modules/oceanScene` | The seam between the scene capture and the materials that sample it. |
| `shaders/oceanSurface` | The water: cascade normals, absorption, exact dielectric Fresnel, subsurface scattering, sun glitter, foam, and the view from below. |
| `shaders/oceanFloor` | The seabed: sand and rock blended by terrain noise, under a caustic web. |
| `materials/oceanWater`, `materials/oceanSeabed` | The default looks. |
| `renderFeatures/oceanScene` | Copies the frame's colour and depth so the surface can refract what is behind it and measure its own water column. |
| `computeShaders/ocean_*` | The ten-stage GPU pipeline. |
## Wind is the control
Significant wave height goes as the **square** of wind speed, so that one field
does most of the work:
| Wind | Sea | Wave height |
|---|---|---|
| 3 m/s | glassy, a mirror | ~0.2 m |
| 11 m/s | working sea, occasional whitecap | ~2.5 m |
| 24 m/s | gale, breaking and streaked | ~12 m |
Everything else shapes character rather than size: `choppiness` pinches crests
into peaks, `standingWaveRatio` runs waves both ways instead of only downwind,
`jonswapGamma` sharpens a fetch-limited sea, and the foam layers decide how
readily a breaking crest whitens and how long it streaks.
Read `documentation/oceans.guide` for the full walkthrough, including the
seabed, buoyancy, reflections and what each tier costs.
# assetType (asset type)
The `assetType` is the **type of types** — the meta-type that every
`<typename>.assetType/` folder is an instance of, including itself. A
`<typename>.assetType/` folder declares a new asset type called
`<typename>`: its on-disk shape, its identity rule, and the structural
contract instances of the type must satisfy.
This type is self-hosting. `assetType.assetType/` conforms to its own
schema — a `type.yaml` plus a `README.md` — so the type system is
defined in terms of itself rather than a hidden engine special-case
(the same way a C compiler is written in C). Every asset in the engine,
including each `.assetType` definition, now resolves to a registered
assetType: a `.material` resolves to `material.assetType`, and
`material.assetType` resolves to `assetType.assetType`, which resolves
to itself.
## Why it exists
Assets used to be mapped to their type *implicitly*, by matching the
folder suffix against a name-keyed registry. That match carried no
stored link: nothing on a `Foo.material` recorded *which*
`material.assetType` it was written against, so two worlds shipping a
same-named type could silently disagree. Making `assetType` a real,
resolvable asset closes that gap — the link from an asset to its type is
now an explicit reference pinned in the asset's `.refs` sidecar
(`via: "asset_type"`), exactly like every other dependency.
## Where it lives
- Source: `/zero/source/.../<typename>.assetType/`
- Identity: `<typename>` (the `.assetType` suffix strips from the
identity; the folder retains the suffix on disk).
- Folder shape:
- `type.yaml` — the structural spec the validator reads. **Required.**
- `README.md` — type-level documentation. **Required.**
- `about.yaml` — three short plain-language texts saying what the type
is for (see "Say what your type is for" below). Every built-in type
ships one.
- `behavior.luau` — optional behavior/scaffolding module.
- `template/` — optional canonical placeholder body the templater
copies into a new asset of the type.
- `<name>.module/` — optional shared code the type ships to **every
instance** of it, conventionally `shared.module/` (see below).
## Shared code its instances reach (`asset.containing` + `.modules`)
A type ships code every instance of it uses by declaring the modules in
its own `behavior.luau`, as a map of tracked `require`s:
```luau
-- inside <typename>.assetType/behavior.luau
M.modules = { shared = require(".shared") } -- the sibling shared.module/
```
An instance reaches it with one location-independent line:
```luau
-- inside any foo.<typename>/init.luau
local api = asset.containing(__FILE__).modules.shared
```
`__FILE__` is the chunk's own VFS path; `asset.containing` resolves the
calling asset, and `.modules.<name>` follows the instance's pinned
`typeRef` (the type's **guid**, recorded in the instance's `.refs` as
`via = "asset_type"`) to the module its type declares. Because
resolution follows that link rather than a category name, the same line
in an instance of a different type resolves to that type's module, and
identically-named modules in two types never collide. `asset.typeRef(target)`
reads that guid for any target; `asset.resolve(asset.typeRef(target))`
gives the full `AssetRef<assetType>`. See `assetTypes/README.md`
§ "Shared code: type-owned modules" for the full mechanism.
## How to create one
Authoring a new asset type is just creating a `<typename>.assetType/`
folder under `/zero/source/`. The engine registers `<typename>` as a
category **the moment the folder is written** — no engine restart, no
Rust change, and it works for built-in (`@builtin/assetTypes/`) and
user-defined types identically. Once `<typename>` is registered,
`<name>.<typename>/` folders are recognised as assets of that type and
resolve their `asset_type` link back to this definition.
Creating a `<name>.<typename>/` folder *before* its
`<typename>.assetType/` exists fails fast: the suffix has no registered
type, so the folder can't be a valid asset. Author the type first.
## Say what your type is for (`about.yaml`)
Every type carries a small `about.yaml` beside its `type.yaml`, and the README
you write for the type documents how to author an asset of it. Both are
required, and both are meant to be written as you make the type, while you know
what it is for. `about.yaml` answers three questions in plain words, one key
each:
```yaml
intent: "Sets the typeface of the text in your game: UI, labels, signs and 3D lettering."
function: "A font, from a TTF, OTF or WOFF file, made available to all of Zero's text, including 3D text."
usage: "You choose it by name for your UI or your text, and the text appears in that typeface."
```
- **`intent`** is what the type is for in someone's game. Name the whole range
of things it covers, in the words a game maker would use for them.
- **`function`** is how it works today: what an asset of the type is made of and
what the engine does with it.
- **`usage`** is what a person or an agent does with one: how they pick it, place
it, call it, or hand it to something else.
A few habits keep it useful. Write about the type as a whole, so the text holds
for every asset of it, and leave out any one asset's name. Say what the type
does right now. Stay with words a game maker would say, and mention a file name
or a call only where a game maker would know it. The limits are in bytes:
`intent` 800, `function` 1500, `usage` 1200. Each is generous, and a few
sentences usually say it.
Why bother: agents working in a world read these three texts to learn what your
type is for, so they reach for it when it fits and use it the way you meant. People
searching find the type by what they want to make, and the three texts let them
tell at a glance whether it is the one they need.
`asset.create("assetType", name)` scaffolds the file with all three keys, and a
README whose first paragraph is a `[[describe]]` note. Each value starts with
`[[describe]]` followed by a note on what to write; replace the whole value with
your own words. A value or a README line that still starts with `[[describe]]`
counts as not written yet, which the push's check of each asset it carries against
its type reports, naming the file. A type you leave as it was is not asked, so
older types stand.
Many types at once, or one you would rather not write by hand: `services
describe` lists the types that lack these texts and what filling them costs, and
`{ mode = "all" }` writes them into the same files, where you edit them like
anything else you wrote.
Where the texts show up: `asset.inspect("<typename>", "assetType")` returns them
as `detail.about`, `assetTypeRef:getAbout()` returns them as a table with the
keys `intent`, `function` and `usage`, the type's `types/<typename>` guide page
ends with an "About this type" section holding them, and the Inspector's Type
section lists them at the top. The guide page is this README plus that section,
so a README that explains the type well and an `about.yaml` that says what it is
for read well together.
## Ask your assets to say what they are
A type can ask each of its assets to carry a description: a file, written by the
author or generated, that says what that asset is and what it is for. The people
and agents who find an asset read it to decide whether it fits, and the author
knows what it is for at the moment of making it.
**1. Declare the file with a `described` check.** This is the whole requirement:
```yaml
files:
required:
- path: "README.md" # any file name works
kind: "file"
content:
- { check: "described", message: "Say what this widget is for and where it goes." }
```
With that in place:
- An asset whose file is missing, empty, or still has a line starting with
`[[describe]]` does not meet the type's requirements. `asset.validate` names it,
`zm status` and `world.publishBlockers()` list it, and a push that carries it is
refused with `message`.
- `services describe` finds it (`mode = "find"` lists it and the price) and can
write the description for it (`mode = "all"`). It writes only this file.
For a YAML file that holds several texts, name the keys and their limits in bytes,
the way this type does for `about.yaml`:
```yaml
content:
- check: "described"
keys:
- { key: "intent", max_bytes: 800 }
- { key: "usage", max_bytes: 1200 }
message: "Write intent and usage."
```
`headings` names sections a Markdown file must have. `assetTypes/README.md` has the
full check.
**2. Optionally, steer what gets generated with a `describe:` block:**
```yaml
describe:
guidance: "Say what the widget shows, what input it reacts to, and where it fits in a UI."
examples:
- "<identity of an asset of this type whose description reads well>"
```
`guidance` says what a description of your type covers, and `examples` names assets
whose descriptions show the shape you want. Without the block, generation still
works from the general describing guide and the check's `message`.
An example is an asset of your type in the library whose description is a true,
written one, never a scaffold's placeholder, and never the asset being described.
When the library has none, leave `examples` empty rather than name one that is not.
A type whose assets the model cannot perceive (a sound it cannot hear, a motion it
cannot see) says so in `guidance`: the description comes only from what the asset's
own files state, such as its name and the `.metadata` where a generation service
records the prompt that made it, and when they state nothing the model answers
`cannot` with that reason. `services describe` then fails that one asset with the
reason and leaves its file as it was.
**3. Start the file unwritten in your template.** Put a heading and one line that
starts with `[[describe]]` and a short note on what to write, in
`template/<file>`, so a new asset starts unwritten rather than with text that only
looks written. Do not put example content in it: every new asset carries the
template verbatim, and the same text on many assets makes them impossible to tell
apart in search.
Types whose assets say what they are through their own content (a mesh, a texture,
a sound) leave this out: declare nothing and nothing is asked.
## The `behavior.luau` contract
`behavior.luau` is the type's code. It returns ONE table, and the keys below
are the ones the framework reads off it for EVERY type — the surface you get
by declaring them. Anything else on the table is your own: reachable by name
through `require("@builtin::assetTypes.assetType.shared.ref").loadTypeModule("<typename>")` for a
system that knows your type (`computeShader` publishes `dispatchKey` that
way), and never called by the framework.
```luau
local M = {}
M.ref = { ... } -- methods on every AssetRef of this type
M.modules = { ... } -- shared code the instances reach
M.global = {} -- reserved key; ship the empty table
function M.onCreate(name, opts) ... end
return M
```
### The tables
| Key | Shape | What the engine does with it |
|-----|-------|------------------------------|
| `M.ref` | `{ [name] = function(self, ...) }` | Every function becomes a method on every `AssetRef` of this type: `ref:name(...)`, with `self` the ref. This is how a type gives its instances an API. Two names in here are contracted — see below. |
| `M.modules` | `{ [name] = require(".sibling") }` | Shared code the type's instances reach through `asset.containing(__FILE__).modules.<name>`, resolved by the instance's pinned `typeRef` guid. See the section above. |
| `M.refShapes` | `{ [method] = "TypeName" }` | Declares the result type of a `M.ref` method so the LSP can check what a call site does with it. See the `M.refShapes` section below. |
| `M.events` | an events schema | The events an ASSET of this type fires, declared the way a component declares its own. Readers subscribe through `ref.events.<name>:connect(...)`; firing authority stays with the type. Keyed by the asset's guid, so every resolver of the same asset shares the signals and a re-resolved ref re-attaches to subscriptions already there. |
| `M.namePattern` | a Lua pattern string | The name shape `asset.create` enforces for instances of this type, replacing the default `^[A-Za-z][A-Za-z0-9_]*$`. |
| `M.global` | `{}` | Reserved. Ship the empty table. |
### The lifecycle hooks
Each is optional; a type that omits one costs nothing. The engine calls them:
| Hook | Signature | When it fires |
|------|-----------|---------------|
| `M.onCreate` | `(name, opts) -> { [filename] = contents }` | On `asset.create("<type>", name, opts)`. Returns the files that scaffold the new instance, as a map of relative path to contents; the framework writes them. The `opts` type annotation is the schema `asset.create` validates the caller's arguments against, so annotate it. |
| `M.onReplace` | `(dest, files) -> { [filename] = contents }` | On an `asset.create` that writes over an asset already at `dest`, after `onCreate`. Receives the files `onCreate` produced and returns the files to write, so a type that re-derives its content (a rig re-imported from its source file) can carry what an author added to the asset it replaces. Optional. |
| `M.onRegister` | `(self)` | Once per instance, the first time it registers: on `engine.onWorldLoaded` for instances already in the world, immediately on a live `asset.create`, and when an instance arrives after that sweep (installed, pulled, or synced from a peer). Guarded by the instance's path, so a double trigger never double-registers. An instance that leaves the runtime releases the guard, so one arriving at the same path again registers. This is what makes "write an asset into the world and it takes effect live" work for content that registers into a runtime registry. The `@builtin` library is excluded from the sweep. |
| `M.onChange` | `(ref, change)` | On every VFS write to a file INSIDE one of this type's instances. `change` is `{ path, asset, type, kind, origin }`: `kind` is `"edited"` or `"seeded"`, `origin` is `"local"` or `"remote"` (an importer runs on the originator only). **Filter on `change.path`**: the hook fires for any file under the folder, so act on the one you care about. It runs as its own task and may wait on what it reads: a `vfs.read` of bytes only the blob store holds (a binary file on the web build) fetches them. The writes to one asset reach the hook in the order they were made, each once the hook before it has finished. A re-entrancy guard suppresses writes back into the same asset routed inline from the hook, and a write the hook makes is routed on a later frame like any other, so the hook must be convergent on its own: diff the meaningful state and short-circuit while your own regeneration is in flight. |
| `M.onDelete` | `(ref)` | When an instance of this type leaves the runtime: it is deleted, or the engine swaps away from the world binding that held it. Its files are gone from `/zero/source` by then. A removal routed while the instance's `onChange` is waiting reaches `onDelete` once that hook, and every write queued behind it, has finished. On a swap the instance still exists in the world it belongs to, and a VFS write the hook makes reaches the world now bound. |
| `M.validate` | `(assetRef) -> { { code, message, severity? } }` | On `asset.validate`, after the structural `type.yaml` check, for the type's own SEMANTIC validation. `severity` defaults to `"error"`; error-severity problems flip `ok` to false, warnings do not. A hook that raises or returns a non-table is itself reported as a `validate.hook_failed` error. `world.push` runs this per user asset, so declaring it enforces your type's rules at publish time with no further wiring. |
### The two contracted `M.ref` names
Most `M.ref` methods are your type's own surface: name them what you like,
return what you like. Two are read by the engine and mean the same thing for
every type, so their shape is fixed:
- **`instantiate(self, target?, opts?) -> (root, idMap)`** — makes an instance
part of the scene. Defining it is the whole opt-in: `ref:canInstantiate()`
is true exactly when it exists. Its **return is a contract** — see the next
section.
- **`inspect(self) -> detail`** — the type-specific half of `asset.inspect`.
See its section below.
### Names a type cannot shadow
Some keys resolve on every `AssetRef` before per-type dispatch, so an `M.ref`
entry of the same name is never reached: `canInstantiate`, `getSource` /
`getBytes` / `getText`, `exists`, `deps`, `meta`, `runtime`, `events`,
`modules`, `typeRef`, and the residency flags `has_backing_asset`,
`has_runtime_changes`, `cpu_resident`, `gpu_resident`. These are the
behaviours every asset must expose identically, which is why they win.
### Reloading
A `behavior.luau` edit ripples to existing refs with no restart: dispatch
reads `mod.ref` fresh each time and `require`'s cache is re-run in place. A
type whose folder appears at runtime is picked up on the next dispatch —
negative results are not cached either.
## The names your type's assets hold (`references:`)
An asset that names another asset by a string depends on it: the named asset
travels with it into a pack or a pull, and a name nothing answers to is an
unresolved dependency `worldValidation` reports and the push gate refuses. The
engine reads `require(...)` and the asset lookups on its own; a name your type's
format or API carries anywhere else is one you declare, in `type.yaml`:
```yaml
references:
# A top-level key of this type's own files that holds a name (or a table of them).
keys:
- { key: "shader", type: "shader" }
# A call's argument, counting from 1, wherever the call is written.
args:
- { call: "g:node", position: 2, type: "procNode" }
# A field of the options table a call takes.
fields:
- { call: "fx.beam", field: "material", type: "material" }
```
`type` is the category the name resolves in, one of `asset.categories()`. An
argument declared once per category, at the same `call` and `position`, takes
the name from whichever category answers. A key or a field holds one category:
declared again with another `type`, it takes the later one. The block is
declared as the type registers, when the library or the world holding it is
laid into the VFS, so every reference table reads it whatever script has or has
not run. `asset.declareReferenceKey`, `asset.declareReferenceArg` and
`asset.declareReferenceField` declare the same things from a script, for a
surface that is no type's.
## What search does with your type (`indexing:`)
A type declares what search embeds for its instances. **This is not optional
and there is no useful default**: ZeroMind runs your declaration and adds
nothing of its own, so anything you do not point at is absent from the index —
silently, and for every instance of the type forever. Twenty types once shipped
with no `indexing:` block and every instance of them was unfindable.
A type whose instances are never searchable (a run record, a log) says so with
`indexing: none`; every other type declares a recipe.
A type without an `about.yaml` beside its `type.yaml` refuses every push that
carries it, whether or not the push edited the type. Texts in it that are not
written yet follow the rule every `described` file follows: they refuse the
push that edits, stages or commits the type.
Two words carry the whole thing, and they are the same two the search tool
exposes:
- **`identity`** — what an instance IS. For anything you can look at, that is
the picture (`content:` + `modality: image`, matched directly by a text
query). For everything else it is the authored text that says what it is.
- **`capability`** — what it DOES and how it is made. Code, settings, the
model-written summary of them.
A type with no behaviour has no `capability` entry. A type with nothing to look
at has no image. Leaving a slot empty is a statement; filling it with whatever
happens to be lying around is not.
The one you have to decide when authoring a type is: *for an instance of this,
what is the thing a person would recognise it by, and what is the thing it
does?* A texture answers "the image" and "its compression settings". A module
answers "its README" and "its code". Write those two answers into `indexing:`
and the rest follows.
Three specifics worth knowing before you write one:
- **`derive` is instructed by this type's own `README.md`.** A model reads an
instance's source and writes a description; what it embeds is the
description, not the source. It follows your README to know what it is
looking at, so a type whose README says what its instances are gets good
derivations for free. There is no prompt to name.
- **`derive` loses detail.** It keeps the main technique and drops secondary
ones. If the source is code you want searchable by its own vocabulary,
declare the same files a second time as `capability` with
`extractor: verbatim`. Two entries, same role, different failure modes.
- **Never `source: { field: name }`.** A filename is one or two words with no
usable embedding, and a corpus indexed that way answers "language runtime"
with `say_runtime_2.soundClip`.
`facets:` is the second stage: a `file:` embedded whole (`.metadata`, so keys
nobody declared still get indexed) plus computed keys that state a value for
every instance including `false` — the raw file can imply "not rigged" and can
never say it.
The full schema, every extractor and the facet sources are in
[`assetTypes/README.md`](../README.md); the scaffolded block with inline
guidance is in [`template/type.yaml`](template/type.yaml).
## A container whose contents work together (`system: true`)
A type that is a container can say its contents are one working whole by
declaring `system: true` at the top level of its `type.yaml`. `package` declares
it: the components, modules, scenes and tools a package ships are parts of one
system. The declaration is plain data, read from the decoded `type.yaml`
(`require("@builtin::assetTypes.assetType.shared.declaration").of("package").system`),
and a type that leaves it out is an ordinary type.
## What your type looks like (`preview:`)
A type declares how its instances are pictured, and the engine makes the
picture when something needs it: staging an instance whose `preview.png` is
missing or pictures content it no longer holds queues a render, a view that
shows the instance asks for one, and `ref:preview()` renders one on demand. An
edit renders nothing on its own. Each `preview.png` records the content it
pictured, which is how a stale one is told from a current one. The preview studio owns the light, the
backdrop, the framing and the camera, and nothing in the world reaches it, so
an instance pictures the same in every world. Declare one of four recipes:
| `kind` | The picture is | Your type provides |
|---|---|---|
| `instantiate` | the asset stood in the studio through its own `instantiate` | `ref.instantiate` |
| `image` | a file the instance holds, fitted to the frame, nothing rendered | `source.files`, the candidate files in order |
| `surface` | the asset painted on a builtin mesh | `mesh`, and `ref.previewMaterial(self) -> { material }` |
| `custom` | whatever `ref.preview(self, opts)` returns | `ref.preview` |
```yaml
preview:
kind: surface
mesh: sphere
```
A scene-instantiable type that declares nothing still answers `ref:preview()`
through its `instantiate`, but persists no picture: the declaration is what
makes the engine keep one. List `preview.png` among the type's files so an
instance may hold it.
The picture is findable only when `indexing.content` names it. The two are
separate statements, and neither is derived from the other:
```yaml
indexing:
content:
- role: identity
modality: image
source: { file: "preview.png" }
```
`@builtin::assetTypes.assetType.shared.previewRecipe` reads the declaration;
the scaffolded block with inline guidance is in
[`template/type.yaml`](template/type.yaml).
## Typing a result from the instance (`M.refShapes`)
Every method on `M.ref` is checked against the type its `--!return` names,
and every asset of the type gets the same one. That is right for most
methods and wrong for the ones whose result is shaped by the ASSET: a
method answering one entry per child folder, per row of a config, per
binding an input map declares. The widest true annotation for those is
`{ [string]: Thing }`, and an indexer accepts every key — so a caller's
typo reads as valid and nothing reports.
`M.refShapes` lets the type state the result per instance. Each entry is
`function(self) -> (typeExpression, source?)`: a Luau type expression for
THIS asset, and the module whose type vocabulary the expression uses.
Read the asset; never run it — stating a type must not take effect.
```lua
-- inputMap.assetType/behavior.luau — the shipped example.
-- `activate()` hands back one handle per control the map declares.
M.refShapes = {
activate = function(self): (string, string)
local names = {}
for _, control in ipairs(M.ref.controls(self)) do
table.insert(names, control.name .. ": Handle")
end
if #names == 0 then return "", "" end
return "{ " .. table.concat(names, ", ") .. " }",
"@builtin::modules.zinput.scheme"
end,
}
```
With it, `map.jump:onPressed(fn)` resolves and `map.noexisting` is
reported with the map's real controls. Without it, both pass silently.
### What the call site has to say
A per-instance type is matched by the asset's IDENTITY, so it applies only
where the reference the method is called on says WHICH asset. Two spellings
do:
```lua
-- a component field, whose declared default names the asset
public = { map = Field.assetRef("inputMap", "@builtin::inputMaps.default", Sync) }
local controls = public.map:activate() -- typed for THAT map
-- an annotation, naming category and identity
local m: AssetRef<"inputMap", "@builtin::inputMaps.default">
```
`asset.resolve("<identity>", "<category>")` carries the CATEGORY, so the
methods the category defines are checked on the result — but not which
asset it found, so a per-instance result keeps the category's declared
return. A reference that names no asset (`asset.resolve(someVariable)`, a
value passed in as a parameter) carries neither, and calls on it are
unchecked. That is the same rule everywhere: the checker states what the
code states, and a name computed at runtime states nothing.
### The result is a value, not a binding
The type belongs to the expression, so it travels the way any inferred type
travels. Indexing the call's result is checked; stashing it in a module
local and indexing it from another function is not, because the local's
declared type is what carries across — and `any` (or no annotation) carries
nothing:
```lua
-- checked: `d.greting` is reported
function awake()
local d = public.dialogue:lines()
d.greeting:say()
end
-- NOT checked: `lines` is a module local typed `any`
local lines: any = nil
function awake() lines = public.dialogue:lines() end
function start() lines.greting:say() end
```
Bind what you need at the call site and the names stay checked in both
places — the asset-derived type at the boundary, ordinary scoping after it:
```lua
local lineGreeting = nil
function awake()
local d = public.dialogue:lines()
lineGreeting = d.greeting -- `d.greting` is reported here
end
function start() lineGreeting:say() end
```
### When nothing is reported and you expected something
From a call site, a type that is correct and a type that was never published
look the same: no diagnostic either way.
`require("@builtin::assetTypes.assetType.shared.refShapes").published()` answers which it
is. It returns two maps — the category surfaces, and the per-asset results
keyed by identity and then by method:
```lua
local shapes = require("@builtin::assetTypes.assetType.shared.refShapes").published()
shapes.categories.dialogue
--> "{ lines: () -> any, lineNames: () -> { string } }"
shapes.returns.Shopkeeper.lines
--> "{ browsing: Line, farewell: Line, greeting: Line, wares: Line }"
```
An identity absent from `returns` was never published for, which is a
different thing from published-and-correct — and the reason to look here
rather than at the call site.
The answers are recomputed when an instance is written, so a control added
to a map reaches the checker with no restart. Only types that declare
`refShapes` are read per instance, and at most 64 assets of one type are —
past that the type keeps its declared return and the engine log names what
was dropped.
## The instantiate hook (`M.ref.instantiate`) — a contracted return
A type opts into becoming part of the scene by defining `instantiate` on its
`ref` table. That is the whole opt-in: `ref:canInstantiate()` is true exactly
when the hook exists, so consumers offer a scene path — an `Asset.source`
field, a viewport drop, a tool argument — by CAPABILITY rather than by a list
of type names.
Unlike `inspect`, this hook's **return is a contract**. A caller writes one
piece of code against every instantiable type, so what comes back cannot vary
by type:
```lua
-- behavior.luau
local Instantiable = require("@builtin::assetTypes.assetType.shared.instantiable")
local M = {}
M.ref = {
instantiate = function(self, target, opts)
local root = Instantiable.root(self, target, opts) -- IN: the base opts
-- ... compose whatever this type is, under `root`, NOW ...
return Instantiable.result(self, root, idMap) -- OUT: the contract
end,
}
return M
```
**IN.** `target` is an owning entity ref: the instance lands under (or, for a
hierarchy type, onto) that owner. With no target the type spawns a fresh root.
`position`, `rotation`, `scale`, `name` and `temporary` mean the same for every
type — `Instantiable.root` applies them to a root you mint, `Instantiable.place`
to one you adopt, so you never re-read the spec. Honour more opts of your own if
your type needs them, and document them in the hook's `--!arg` docs.
**OUT.** Return `(root, idMap)` through `Instantiable.result`:
- **`root`** — the composed root, as an `EntityRef`. Composition is
**synchronous**: everything your type builds is live when you return, so the
caller can parent to it and read its components in the same statement. Do not
defer the work to a component's `awake` and return an empty shell — a root
that is not live is refused.
- **`idMap`** — the `originalId -> runtimeId` map naming what you spawned, or
nil for a type with no addressable children (`result` normalises it to `{}`).
**A caller never receives nil.** A component that re-composes your asset on
every load keeps this map and passes it back in, which is how a cross-entity
reference into the composition survives a reload.
`AssetRef` runs every `instantiate` through `result` on the way out whether or
not your type called it, so returning something else fails at your own call
rather than handing a caller a nil root. Calling it yourself is still how you
say what you return, and running it twice changes nothing.
`@builtin::assetTypes.assetType.shared.instantiable`'s README is the full reference for both halves.
## The inspect hook (`M.ref.inspect`)
`asset.inspect(ref)` resolves `ref`, builds a common envelope shared by
every asset (`identity`, `name`, `guid`, `source`, `typeName`,
`typeDefinitionPath`, `scope`, `origin`, `description`, `tags`), then
calls the resolved type's own inspect hook — `M.ref.inspect(self)` on
`behavior.luau` — for the type-specific `detail`. The hook returns
*only* `detail`; the framework fills in everything else:
```lua
-- behavior.luau
local M = {}
M.ref = {
inspect = function(self)
-- self.path is the asset's own folder. Parse the asset's OWN
-- files — source text, a native payload header, its `.metadata`
-- — never runtime state (a live component instance, a GPU
-- upload, a compiled schema): inspect must work on an asset that
-- hasn't been compiled, uploaded, or registered yet.
return {
-- type-specific fields, whatever this type wants to surface
}
end,
}
return M
```
A type that omits `M.ref.inspect` yields the generic record: the
envelope alone, `detail = nil`. That's a valid, working state — not an
error — but it means agents calling `asset.inspect` /
`tools.use("assets","describe", ...)` on an instance of the type see
only identity and scope, none of what makes an instance of it
meaningful. Declaring the hook is what makes a type's instances
inspectable for what they actually are.
A hook that raises leaves `detail = nil` and sets `record.warning` to
the error text — `asset.inspect` itself never raises for a resolved
ref.
## Discovery
- `asset.list("assetType")` — every registered asset type.
- `asset.inspect("<typename>")` — the type's identity, source path, and
this README, plus its `about.yaml` texts as `detail.about`.
- `asset.resolve("<typename>", "assetType")` — the `<typename>.assetType`
asset itself (used by the validator to find the type's `type.yaml`).
## Related
- `assetTypes/README.md` — the full `type.yaml` schema and validator
rules.
- `@builtin::assetTypes.assetType.shared.instantiable` — the instantiation contract's shared
implementation, both halves.
- Every other `*.assetType/` here — the concrete types built on this
meta-type.
## The Inspector a type ships
Every type names the file its Inspector sections come from with `inspector: { type: "inspector.luau" }` in its type.yaml. `asset.create("assetType", name)` scaffolds the block and an `inspector.luau` whose Contents section lists an asset's files, built from `ctx.parts`. `asset.validate` on a type that names none reports `assetType.inspector_required` (an error for a `@builtin` type the engine's library authors; a warning for a world's own, and for one the library carries by reference, which the world that published it answers for), and one whose file does not load `assetType.inspector_invalid`.
## In the Inspector
Type: what the type.yaml declares (suffix, container, primary files, the settings and inspector blocks), whether the type ships a behavior, how many instances it has, and **Open** for its files. Schema: the settings every instance carries, as read-only typed controls at their defaults (a type whose schema is per instance says where it is declared). Instances: a link to each of the first 20 assets of the type, and how many there are when there are more.
# ScreenSpaceReflections
Reflections off the scene's own drawn frame, as a one-feature component. A
scene lists its features together, each with its settings, on `RenderFeatures`
(`{ feature = "@builtin::systems.reflections.ssr", settings = { ... } }`); this
component carries the same knobs as fields for one feature.
```lua
local id = entity.spawn("reflections").id
entity(id).component.add("ScreenSpaceReflections", {
intensity = 1.0,
maxDistance = 40,
quality = "high",
})
```
Polished floors start reflecting what stands on them, wet ground reflects what
is above it, and metal picks up its surroundings — all from geometry the camera
can already see, with nothing authored per surface.
Roughness decides who gets them. A screen-space ray traces one direction, which
is a mirror; `roughnessCutoff` is where that stops being a fair stand-in for a
surface's real lobe, and past it the pass leaves the surface alone.
Every field is live: edit one in the inspector and the reflections follow.
Setting `intensity` to 0 turns them off without losing the rest of the settings.
How much a reflection shows through is the surface's own Fresnel term, so metal
takes almost all of it and a dielectric floor takes a few percent head-on and
more at a grazing angle — the same way a wet road reads dry underfoot and
mirror-like down the street.
For code that adjusts reflections at runtime, reach for
`@builtin::systems.reflections.ssr` — same options, no component.
The full description of what each knob does and how the passes work is in the
package readme: `guides { path = "systems/reflections" }`.
# Polished floor
A polished stone floor with three coloured pillars standing on it, under an
afternoon sky, with screen-space reflections listed on the scene's
`RenderFeatures` entity — so the floor reflects the pillars, and a player
walking across it, rather than the sky alone.
Load it with `scene.load("@builtin::systems.reflections.demo.polished_floor")`.
Players arrive facing the pillars from floor height, which is the grazing
angle a polished dielectric reflects at most strongly.
## What it shows
- **The feature on one entity.** `render_features` carries a `RenderFeatures`
list with `@builtin::systems.reflections.ssr` at `intensity = 1`,
`maxDistance = 60`, `quality = "high"`. Delete the entity and the floor
mirrors the sky alone; put it back and the pillars stand in the floor.
- **Only what the camera sees.** A pillar's reflection comes from the frame
the camera drew, so turning away from a pillar takes its reflection with it,
and the reflection fades toward the screen edge rather than cutting off.
- **The environment underneath.** Where a ray finds nothing the floor keeps
the sky reflection the environment gives it, at the same mip and Fresnel
weight, so the seam between the two is a change of content and not of
brightness.
- **Roughness decides who reflects.** The floor sits at roughness 0.06, the
pillars at 0.55 — above the feature's `roughnessCutoff` — so the floor is the
one surface tracing reflections in the scene.
## Contents
- `floor` — a 60 m slab of `PolishedStone`, a near-black dielectric at
roughness 0.06, with a collider.
- `pillar_1..3` — 1.6 × 5 × 1.6 m boxes in `PillarRed`, `PillarGreen`,
`PillarBlue`, sunk into the slab.
- `sun`, `ambient`, `sky` — a side-lit afternoon under a `ProceduralSky`.
- `render_features` — the `RenderFeatures` entity this scene exists for.
- `PlayerSetups` / `Spawns` — the humanoid player on an orbital-follow
camera, spawning in front of the pillars. The scene's player intent is
`"spawns"`.
## Entry point
- `entrypoint.luau` — nothing to set up: the reflections run from the
`RenderFeatures` entity the moment the scene loads.
# ssr
Screen-space reflections — polished floors reflect what stands on them, from
the frame the engine has already drawn.
```lua
local ssr = require("@builtin::systems.reflections.ssr")
ssr.set({ intensity = 1, maxDistance = 40, quality = "high" })
ssr.get() --> the settings in force
ssr.active() --> are the reflection passes running
ssr.clear() --> off, settings kept for the next set()
```
`set` takes any subset — omitted fields keep their current value, so adjusting
one knob does not mean restating the rest. An `intensity` of 0 turns
reflections off and releases the passes rather than running them at zero
strength.
`roughnessCutoff` is the field that decides who gets reflections at all. A
screen-space ray traces one direction, which describes a mirror; past the
cutoff a real surface scatters over a lobe too wide for one ray to stand in
for, and the pass leaves those surfaces alone.
`maxDistance` and `quality` trade against each other. Quality sets how many
steps the march spends, so a long distance at a low quality samples coarsely —
reflections thin out at range rather than disappearing, because refinement
adapts to the step size on its own.
For reflections a scene should simply have, author the
`ScreenSpaceReflections` component instead — it carries the same settings,
persists with the scene, and edits live in the inspector. This module is for
code that changes reflections at runtime: a quality slider, a cutscene, weather
turning pavement wet.
The full description of what each knob does and how the passes work is in the
package readme: `guides { path = "systems/reflections" }`.
·renderfeature · born here
❒asset # ssr
The render feature behind `ScreenSpaceReflections` and
`@builtin::systems.reflections.ssr`. One compute pass runs at the
`afterDepthPrepass` seam, once the visibility raster has laid the frame's
depth and before anything shades:
| order | pass | output |
|---|---|---|
| 60 | `ssr_trace` | reflected colour plus a confidence weight, per pixel, into `@scene.reflection` |
That channel is what a material's environment term reads the same frame, at
its own pixel: the traced reflection stands in for the probes and the sky to
the degree the trace trusts it, and the material's own Fresnel, roughness and
tint weigh it into the pixel the way they weigh any environment. Nothing here
touches the lit picture.
The trace marches against this frame's depth, which the visibility raster
has just written, so the reflection stands where the camera is now. What it reads
about the surfaces — the reflecting surface's normal and roughness, the hit's
normal and its colour — comes from the previous frame: the G-buffer as that
frame left it (`readsPrevious`), and `@scene.colorHistory`, the light it
ended with, each reprojected through the previous view-projection
`@frame.camera` carries in its rows `[5..9)`. A point the previous frame did
not show reflects its environment alone.
## The march
`ssr_trace` reflects the view ray about the surface normal and walks it through
the depth buffer.
Steps are uniform in **screen** space, not world space. A ray heading toward
the horizon covers enormous world distance across a few pixels, so stepping in
world units there means either missing every thin occluder near the camera or
spending the whole budget on distance nothing can be seen at. The world
parameter is recovered from the screen parameter through `1/w`, which is what
interpolates linearly under perspective.
At each step the ray's world position is compared against the world position
reconstructed from the depth sample under it. Comparing in world space is what
lets `thickness` mean a distance in world units rather than a depth-buffer
epsilon whose meaning changes with range.
The march looks only for the **crossing** — where the ray first passes behind
the depth buffer. Thickness is tested after bisecting that crossing, not
during the march: one march step covers roughly as much depth as a surface is
thick, so a ray tested during the march can jump from in front of a surface to
well behind it without ever landing inside it.
Bisection runs for whichever is larger, the quality tier's budget or however
many halvings bring one march step under `thickness`. A coarse march that
stopped bisecting early would leave the crossing bracketed more loosely than a
surface is thick, and every hit would then fail the thickness test — long-range
reflections would vanish outright instead of thinning.
A hit is rejected when the surface it landed on faces along the ray, because
what is on screen there is that surface's far side.
## What the channel holds
`ssr_out` is `@scene.reflection`: linear radiance in `.rgb`, and in `.a` the
confidence — the edge fade, the range fade, how squarely the ray carried on
into the scene, and `intensity`, multiplied — so `intensity` is how much of the
trace reaches the picture and a miss is a zero the material reads as "the
environment alone". The channel is cleared at the top of every scene render
this feature does not run for, so such a frame reflects the environment alone
too. The trace reads the light the previous frame ended with, so it runs on
the lit channel alone (`debugChannel = 0`); a camera drawing a diagnostic pass
is drawn lit first, unpresented, so the trace runs for where that camera
stands, and the diagnostic reads the channel traced for it.
The cutoff exists because a single ray describes a mirror: past it the lobe a
rough surface gathers over is wider than one ray can stand in for, and the
material keeps the prefiltered environment it already has.
# ssr_common
The screen-space reflection passes' shared ground: the view-space reconstruction from depth, and the ray-to-screen projection the march steps along.
Included with `#include "@builtin::systems.reflections.ssr_common"`.
·computeshader · born here
❒asset # ssr_trace
Marches a reflection ray through the depth buffer and reports where it hit.
## Includes
- `ssr_common`
## Bindings
| name | kind |
|------|------|
| `scene_color` | `texture2d` |
| `scene_depth` | `texture_depth` |
| `scene_normal` | `texture2d` |
| `scene_material` | `texture2d` (`sample: uint`) |
| `frame_cam` | `buffer` |
| `ssr_params` | `buffer` |
| `ssr_out` | `storage2d (rgba16f)` |
# Package (asset type)
A package is an asset — a `<Name>.package/` folder that bundles
components, modules, scenes, presets, tools, and any other typed
sub-assets under one identity, installed into a world as one unit.
Packages are how authoring sets get distributed across worlds.
A **library** is the addressable root scope content resolves under
(`@builtin`, `@<name>`) — a whole world mounted into this one under a
namespace, via `zeromind.installLib` / `world.installLibrary`. A package is
an asset that lives inside such a root, and one library holds as many
packages as its author put there. A package is installed by guid the
way every asset is (`zeromind.install`, `zeromind.installAsset`).
## When to use one
- You're authoring a coherent feature suite (character controller
stack, voxel engine, UI kit, animation library) and want to ship
it as one install target.
- You want a stable identity worlds can install and resolve sub-assets
against (`@<lib>::pkg.sub_module`).
- You want versioning + a per-package metadata layer that travels with
the contents.
If you're authoring inside a single world and don't need a separate
install lifecycle, you don't need a package — drop your `.component`
/ `.module` / etc. directly in `/zero/source/`. Packages are for
cross-world distribution.
## Where it lives
- Source: `/zero/source/.../<Name>.package/` (or, for legacy
packages, a folder containing `package.yaml` at its root).
- Identity: `<Name>` (the `.package` suffix collapses in identity —
mirrors `.module`). A file at `<Name>.package/<sub>/init.luau`
resolves under the same identity it would at `<Name>/<sub>/init.luau`.
- Folder shape (container-style — open child set):
- `package.yaml` — metadata (name, version, description).
**Required.**
- `README.md` — instance documentation. **Required.**
- `.metadata` — agent-editable tags + free-form fields. **Required.**
- Sub-asset folders: any combination of `.component/`,
`.module/`, `.scene/`, `.preset/`, `.tool/`, `.toolbox/`,
`.material/`, `.shader/`, etc.
The validator permits unlisted children (`allow_unlisted: true`) by
design — packages are open containers.
## How to create one
```luau
asset.create("package", "<Name>")
-- Creates: /zero/source/<Name>.package/
-- package.yaml (metadata scaffold)
-- README.md (instance README template)
-- `folder` places it in a subfolder of /source instead of the root:
asset.create("package", "<Name>", { folder = "systems" })
```
Then scaffold sub-assets inside it:
```luau
local pkg = asset.resolve("<Name>.package")
asset.create("component", "Spinner", { into = pkg })
asset.create("module", "util", { into = pkg })
```
## How it operates
1. **Registration.** Writing `package.yaml` indexes the package with
the package registry.
2. **Install.** A package is installed by guid, the way every asset
is: `zeromind.install` from the shared library, or
`tools.use("zeromind", "installAsset", <guid>)` inside the engine. The
install writes the package's files (and the deps its `.refs`
record) into this world's source, so its sub-assets resolve here.
`tools.use("zeromind", "preview", <guid>)` reports what an install would
write before it writes anything.
3. **Sub-asset resolution.** Because `.package` collapses in
identity, sub-asset paths work whether the package folder is
suffixed (`<Name>.package/<sub>`) or not (`<Name>/<sub>`).
4. **Updates.** `tools.use("zeromind", "updates")` lists installed content
with upstream changes available; re-running the install pulls them.
5. **Metadata.** `package.yaml::name` overrides the folder stem if
present (rare); `version` + `description` are surfaced in the
package index.
6. **Hot reload.** Edits to sub-assets hot-reload as normal; edits to
`package.yaml` reload package metadata.
## Discovery
- `asset.list("package")` — every registered package.
- `asset.inspect("<name>")` — sub-asset catalog, source, this type
README.
- `ls /zero/source/<Name>.package` lists the files the folder holds.
- `library.list()` — the library assets registered in this world,
including the ones packages under an installed library contribute.
## Authoring conventions
- Name packages by their feature (`voxels`, `cameraRigs`,
`dialogSystem`), not by their owner — the host library is the
`@<lib>::` scope prefix.
- Cluster cohesive sub-assets together. A "character controller"
package should ship the controller component, its preset configs,
its supporting modules, and a demo scene — not just the component.
- Document the package's surface in `README.md` — what install
installs, what each sub-asset does, how they relate. Agents reading
`cat /zero/source/<Name>.package/README.md` get the full picture in one shot.
- Version conservatively — breaking a package means breaking every
world that installed it. Use semantic version bumps.
## Common pitfalls
- **Missing `package.yaml`.** Without it the folder isn't recognized
as a package even if it has a `.package/` suffix.
- **Dependency installs.** A package that depends on another package
must declare it (in `package.yaml::dependencies` once supported)
and the parent world must install dependencies in order.
- **Identity collapse.** Sub-asset paths skip the `.package` suffix.
Don't include `.package` in `require` strings.
## Referencing inside a package: the `~` rule
`~` means **this package**, resolved structurally from the referring file's own
location. It is the only package-specific syntax. It is rename-safe (no package
name is ever written) and library-agnostic (it never names the host library, so
it survives `@builtin` being renamed or the package being mounted elsewhere).
`~.tail` maps to `<pkg>.package/tail` from anywhere inside the package.
| Referencing from | Use | Example |
|---|---|---|
| An asset/module **inside this package** | `~.tail` | `require("~.modules.myThing")`, `shader: "~.shaders.myThingBlend"` |
| A **different package** under the same root | root-relative identity | `require("@root::systems.otherPkg.modules.x")` |
| A **different library's** public API (a deliberate external dependency) | `@otherlib::…` | `require("@physics::modules.rigidbody")` |
**Never write `@builtin::` (or any host-library name) to reference your own
package** — that couples the package to the library it currently lives in and
breaks if the library is renamed or the files move. Use `~.`. It works from
Luau `require()` and from asset-reference strings in data files (a material's
`mat.yaml` `shader:` field, a component's default asset ref); the engine
resolves it against the referring file's own package. `asset.resolve` takes a
`~.` name the same way, against the package of the file that calls it:
`asset.resolve("~.materials.myThingStandard", "material")`.
`~.` is the package-relative self-reference; `@root::` is the root-relative one
(the library root, or the world source tree for non-library content). The
`core/requiring-modules` guide covers the root model in full.
A package that ships substantial functionality should include a `.guide` asset
(a `<name>.guide/guide.md` folder anywhere under the package); the `guides` tool
discovers it live, so collaborators find the system the way they find built-in
guides. A package's test suite reaches the shared `Test` framework through its
enclosing asset: `local Test = asset.containing(__FILE__).modules.shared`
(`asset.containing` returns the deepest enclosing typed asset, the `.testSuite`,
not the outer `.package`).
## Related types
- Any of `.component`, `.module`, `.scene`, `.preset`, `.tool`,
`.toolbox`, `.material`, `.shader`, `.style`, `.service`,
`.importer` — packages bundle these.
## In the Inspector
Package: the name, version and description package.yaml declares, how many things the package holds and what they are, and **Open** for package.yaml. A package.yaml that does not decode is listed under Problems with the decoder's error.
# GaussianSplat
Renders a Gaussian splat capture on an entity — a photographic reconstruction
stored as a cloud of oriented 3D Gaussians. `.spz` (Niantic) and `.ply` (3DGS)
captures both load.
```luau
local id = entity.spawn("scan").id
entity(id).component.add("GaussianSplat", { source = "captures/room.gaussianSplat" })
```
The entity's Transform places, rotates and scales the cloud like any other
renderable: each Gaussian's covariance is conjugated by the transform, so a
non-uniform scale shears the cloud the way it shears a mesh.
## Fields
| Field | Default | Meaning |
|-------|---------|---------|
| `source` | `""` | VFS path of the capture. |
| `convention` | `"rightDownFront"` | Axis convention the capture was authored in. `"engineNative"` for a capture already in engine space. |
| `opacity` | `1.0` | Multiplies every splat's stored opacity. |
| `size` | `1.0` | Multiplies every splat's stored size. |
| `visible` | `true` | Whether the cloud draws. |
| `buckets` | `4096` | Depth buckets the per-frame far-to-near sort files splats into. |
| `near` / `far` | `0.05` / `500.0` | Depth range the sort resolves, in world units. Splats outside it are skipped. |
## Methods
- `setSource(source)` — point the cloud at a different capture.
- `setOpacity(scale)` — scale every splat's stored opacity.
- `setSize(scale)` — scale every splat's stored size.
## Notes
- The component only declares what to render. The `gaussianSplats` render
feature owns the GPU buffers and the per-frame depth sort, because only it
runs inside a frame with a live camera to sort against.
- `guides { path = "topics/gaussian-splats" }` covers capturing, axis
conventions, and tuning.
# gaussianSplats
The entity-id-keyed registry of live Gaussian splat clouds — the seam between
`GaussianSplat.component` (which declares what to render) and the
`gaussianSplats` render feature (which owns every GPU buffer and runs the
per-frame depth sort).
A plain table: no GPU state, no frame coupling.
## Exports
- `M.set(id, entry)` — register or update an entity's cloud. Changing `source`
or `convention` bumps the entry's generation, which makes the render feature
drop its buffers and decode again.
- `M.clear(id)` — drop an entity's cloud, along with the placement recorded for
it.
- `M.entries()` — the live registry, keyed by entity id.
- `M.get(id)` — one entity's entry, or nil.
- `M.setVelocity(enabled)` / `M.velocity()` — whether a moving cloud writes its
screen-space displacement into the frame's velocity buffer.
- `M.recordPlacement(id, matrix)` — the render feature records the placement it
culled and drew a cloud at, as 16 column-major numbers. The numbers are copied
in.
- `M.placement(id)` — that placement, translation in elements 13, 14, 15, as a
fresh table each call. Nil on a frame the feature did not draw the cloud, and
once the cloud has left the registry.
Types:
- `SplatEntry` — `{ source, convention, opacity, size, buckets, near, far, visible, generation }`.
## Usage
```luau
local registry = require("@builtin::systems.gaussianSplats.gaussianSplats")
for id, entry in pairs(registry.entries()) do
print(id, entry.source, entry.visible)
end
```
·computeshader · born here
❒asset # splat_cull
Stage 1 of the Gaussian splat per-frame depth sort. One thread per splat.
Transforms the splat's packed position to world space, frustum-culls it at three
sigma (the Gaussian's visible support), computes its view depth from the
clip-space `w`, and files the survivor into one of `bucket_count` depth buckets
ordered far to near. The bucket id lands in `keys[i]`; a culled splat gets
`bucket_count`, one past the last real bucket, so it sorts behind every survivor
and the scatter can recognise it.
Buckets are spaced logarithmically between `near` and `far`, so each spans a
constant ratio of depth: near geometry gets the same relative ordering precision
as far geometry.
`indices` is seeded to the identity permutation the radix passes start from.
## Bindings
| Slot | Access | Contents |
|------|--------|----------|
| `records` | read | Packed splat records, 6 u32 words each. |
| `view` | read | `CullUniforms` — view-projection, model, frustum planes, params. |
| `keys` | read_write | Per-splat depth bucket, or `bucket_count` when culled. |
| `indices` | read_write | The sort's input permutation, seeded to the identity. |
| `stats` | read_write | `[0]` = surviving splat count (atomic). |
A splat whose whole footprint projects to less than `screen.z` pixels of
radius is dropped as well. Distance shrinks that projection, so a cloud far from
the camera resolves to progressively fewer splats while a near one keeps every
splat it has. The radius is estimated by projecting the three world axes from
the splat's centre and keeping the widest, which holds under a sheared or
non-uniformly scaled cloud.
`params` is `(splat count, bucket count, near depth, far depth)` and `screen` is
`(viewport width, viewport height, min screen radius in pixels, 0)`.
Dispatch one workgroup of 64 per 64 splats.
# 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.
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()`.
Globals leak across reloads. 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**.
# tests
Run the engine test suite from inside a live engine and triage the results — the baseline-then-regression loop, without leaving the session.
List every registered test suite (optionally with test counts) to see exactly what the runner and CI will execute. Run everything, one suite, or a subset — each suite runs through its own cooperative coroutine so multi-frame tests yield correctly — and get back structured results plus Markdown and JSON reports written to the VFS. Compare two JSON reports to surface the failures that are new since the baseline, which is how you catch regressions a change introduced. The lower-level framework for authoring suites lives in the test module and the `testSuite` asset type.
# pixelTemplate (asset type)
A saved, reusable pixel-art sheet. A `<name>.pixelTemplate/` folder asset
carries a JSON payload `template.pixbin` (width, height, pixelSize, plane,
palette, the flat pixel array, and — for animated sheets — every frame) plus
the `.metadata` the structural spec requires.
Authoring paths: `PixelCanvas:saveAsTemplate(name)` /
`Pixel.saveAsTemplate(...)`. Instantiation: `Pixel.fromTemplate(name, opts)`
copies (bakes) the payload into a fresh canvas — there is no live link, so
editing an instance never touches the template.
## In the Inspector
Selecting a pixel template opens its **Sheet** section, decoded from `template.pixbin` once per version of the file: the sheet's size, pixel size and plane, how many frames it holds and, when it animates, its frame rate and whether it loops, followed by a swatch per palette entry. A template with no saved sheet says so.
# pixelArt
Create and animate pixel-art canvases — make sprites, draw on them, animate frames, manage palettes, and save them as templates.
Spawn a canvas blank, seeded, painted from text-art rows, cloned from an existing one, or instantiated from a saved template. Draw onto the active frame with raster ops — pixels, rectangles, lines, circles, ellipses, whole rows, or clear. Build multi-frame animation: add, duplicate, or remove frames, pick the active frame, set the frame rate, and play or stop. Replace the palette or update a single colour. Read back pixels, bounds, counts, frame count, and the template list. Save a canvas as a reusable template or delete one, and despawn a canvas when you're done. A target — a handle, an entity id, or an entity name — is accepted uniformly across the toolbox.
# Buoyancy
Floats the entity's rigid body on the sea.
```lua
entity.find("barrel").component.add("Buoyancy", { density = 0.4 })
```
The entity needs a dynamic `Physics` body. Position and orientation stay the
physics world's — this component only adds the forces water exerts, so a
floating body still collides, still takes impulses, and still falls when it is
lifted clear of the water.
| Field | What it does |
|---|---|
| `density` | Relative to water: the lower it is the higher the hull rides, and a body denser than water sinks. A hull settles a little deeper than the number alone asks for, so trim it until the waterline sits where it should. 0 releases the body. |
| `damping` | How hard water resists the hull moving through it, as a fraction of the damping that settles it without overshoot. 1 is that critical value, 0 bobs indefinitely. Applied per column, so it steadies rolling as well as heaving. |
| `angularDamping` | The same, for a hull turning. The rigid body carries it as its own angular damping while the hull is in the water, and it is the only thing that damps a spin about the hull's own vertical axis, which the per-column damping cannot see. |
| `columns` | Grid resolution per horizontal axis over the footprint. 1 floats but cannot right itself; 2 is the smallest grid that produces a righting moment. |
| `sizeX`/`sizeY`/`sizeZ` | The box that displaces water, in the hull's own space. All three at 0 measures it from what the entity draws. |
`barrel:submersion()` reads how much of the hull was under water on the last
step, and `barrel:waterline()` reads the world Y the surface stood at under it,
wave motion included.
Forces reach a rigid body only while the scene is playing, so a hull sits where
it was placed in edit mode and settles onto the water when play starts.
# Ocean
Puts a live sea on an entity. The component's fields ARE the sea state — wind
speed and direction, choppiness, swell, foam, extent and detail — and the wave
simulation, the GPU surface mesh and the material bound to the simulation are
all rebuilt from them whenever the component wakes.
That rebuild is what makes the ocean persistable. The simulation lives entirely
in GPU buffers and a runtime mesh, none of which survive an engine restart; the
component's settings do, and the whole sea is reconstructed from them. A scene
with an ocean in it reloads into the same sea.
The surface follows the viewer and covers `extent` metres around them, so one
ocean is the whole sea however far anyone sails. `sampleHeight(x, z)` and
`sampleDisplacement(x, z)` report where the water is — the same waves that are
drawn — which is what a boat, a buoy or a swimmer floats on.
Start from a `preset` (`calmClear`, `darkChop`, `darkRolling`, `heavySwell`,
`whitecapped`, `murkyGrey`, `paleSwell`, `glassyDark` and `gale`) and override
whatever the scene needs. Wind speed does most of the work:
significant wave height goes as its square.
·computeshader · born here
❒asset # ocean_assemble
Unpacks one cascade's two transformed fields into the `(dx, height, dz)`
displacement field the surface mesh reads, applying the `(-1)^(x+z)` sign
alternation that undoes the centred-on-zero wave-vector grid the spectrum was
built on. Height and horizontal displacement carry independent gains, so crest
pinching (`choppiness`) can be dialled without changing wave height.
·computeshader · born here
❒asset # ocean_displace
Displaces the ocean surface mesh by one cascade, once per cascade per frame. The
first cascade writes each vertex from the rest position stored in its UV and the
rest accumulate onto it, so the mesh is rebuilt from a clean grid every frame
instead of drifting. Cascades are sampled at the vertex's true world position —
the mesh follows the camera, so its rest grid sits at a moving origin. The vertex
normal accumulates each cascade's surface gradient (the ratio `n.xz / n.y`),
which is how independent wave fields actually combine; per-fragment normal detail
comes from the cascade normal maps in the surface shader, since the mesh is far
too coarse near the horizon to carry it.
·computeshader · born here
❒asset # ocean_evolve
Advances one cascade's static spectrum to the current time — `h(k,t) = h0(k)
e^{iwt} + conj(h0(-k)) e^{-iwt}` at the deep-water dispersion `w = sqrt(g|k|)` —
and emits the two complex fields the inverse FFT consumes. The frequency is
quantised to a multiple of `2*pi / loop_period` so the whole sea state repeats
seamlessly instead of drifting as elapsed time grows. Field A packs the height
and X-displacement spectra into one complex field (`H + i Dx`), which the
Hermitian symmetry of both lets a single inverse transform separate again — two
FFTs per cascade instead of three.
·computeshader · born here
❒asset # ocean_fft
Transforms one axis of an ocean cascade's complex field in place, as one
dispatch per axis: a workgroup per line runs every Stockham auto-sort radix-2
butterfly stage against workgroup-shared memory, so a 256-wide transform costs
one dispatch rather than eight butterfly passes and needs no bit-reversal
permutation. Supports grids up to 512 per side. The axis, direction and scale
arrive through the bound `plan` buffer rather than a `params:` uniform, because
same-frame dispatches are chained into one submission and a uniform rewritten
between them would not be seen per-dispatch.
·computeshader · born here
❒asset # ocean_floor_displace
Raises the seabed's flat grid into terrain: one gradient-noise field over world
XZ, written into each vertex's height, with the analytic slope written into its
normal.
Runs when the mesh recentres rather than every frame. The terrain is still in
the world; only the grid under it moves.
It is a compute pass rather than the surface shader's `vertex()` hook so that
every pass that draws the mesh, and everything that reads its vertex buffer,
meets the same displaced geometry.
The normal is the derivative of the noise rather than a difference of
neighbouring vertices, so it stays correct where the grid is coarse — which,
over a clipmap, is nearly everywhere.
## The collision patch
The threads past the mesh write a square patch of the same field into `heights`
as plain metres, row-major with the column along world X and the row along world
Z — the layout a heightfield collider indexes. `plan[2]` gives the patch its
world centre, its samples per side and its world span.
It is the same call to the same function that raised the vertices. A second
evaluation of the noise, in Luau or in a pass of its own, is a copy free to
drift from the one that is drawn, and ground stood on a metre from where it is
drawn is worse than ground with no collision at all — nothing reports it.
## Bindings
| Buffer | Access | Contents |
|--------|--------|----------|
| `plan` | read | lane 0: `xy` mesh world origin, `z` vertex count, `w` noise period (m). lane 1: amplitude, lacunarity, persistence. lane 2: `xy` patch centre, `z` samples per side, `w` patch span (m). |
| `vertices` | read_write | The mesh's vertex buffer, engine 23-word layout. |
| `heights` | read_write | The collision patch, `side * side` f32 metres. |
Dispatch `vertexCount + side * side` threads.
·computeshader · born here
❒asset # ocean_floor_texture
Grows the three tiling maps the seabed is built from: sand, rock, and the
caustic web the sun draws on both of them. Albedo goes in `rgb` and height in
`a`; the caustics map carries two independent phases in `r` and `g`.
Generated rather than shipped as images. A stored texture is a binary,
content-addressed asset, and a material blocked on a texture reference that
never resolves never prepares — which leaves its entire uniform block unwritten
and the surface black. Generating keeps the seabed on the same compute-texture
path the cascade maps and the foam patterns already use.
Run once per map at startup, each with its own seed. Every map tiles: each
octave hashes its lattice modulo a period that divides the texture size.
The caustics are constructed rather than painted. Sunlight refracting through a
wavy surface lands as the light a patch gathers divided by the area it spreads
over — the reciprocal Jacobian determinant of the refraction map. Writing the
surface as a height field makes that map `p + k grad h`, so the determinant is
that of `I + k H`; inverting it produces the sharp filaments and their crossing
cusps, which are the places the determinant approaches zero and a finite bundle
of light lands on almost nothing.
·computeshader · born here
❒asset # ocean_foam
The persistent whitecap field: a world-space region, following the viewer, in
which foam accumulates where the sea folds and drains everywhere at a fixed
half-life.
Shading foam directly from the instantaneous fold makes it blink with the wave
that caused it. This field is what gives a broken crest a wake of white behind
it, and what the surface's `waves` foam layer is actually gated on.
Run once per frame, after every cascade's `ocean_normals`. The two history
buffers swap roles each frame; the caller binds whichever holds last frame's
result as `previous`.
·computeshader · born here
❒asset # ocean_foam_texture
Grows the tiling greyscale pattern the three foam layers stamp on the water.
It is generated rather than shipped as an image. A stored texture is a binary,
content-addressed asset, and a material blocked on a texture reference that
never resolves never prepares — which leaves its entire uniform block unwritten
and the surface black, not merely un-foamed. Generating the pattern keeps foam
on the same compute-texture path the cascade maps already use.
Run once per layer at startup, each with its own seed. The noise tiles: every
octave hashes its lattice modulo a period that divides the texture size.
·computeshader · born here
❒asset # ocean_normals
Differentiates one cascade's displacement field into a shading normal and a foam
mask. Normals come from central differences of the displaced positions (so crest
pinching is accounted for, unlike a height-only gradient), with wrap-around
neighbours because the cascade tiles. Foam comes from the smaller eigenvalue of
the horizontal displacement Jacobian: where it goes negative the surface has
folded over itself, which is what a breaking wave is — so whitecaps appear from
the same displacement that raised the crest rather than from a painted-on noise
threshold. Fresh foam replaces the stored value immediately while old foam fades
at `foam_decay` per second, leaving streaks behind broken crests.
·computeshader · born here
❒asset # ocean_probe
Resamples every cascade onto one small grid that the CPU reads back, so gameplay
can ask where the water is without stalling a frame on the full simulation.
Dispatched once per cascade: the first overwrites the grid, the rest add to it.
`xyz` is the summed displacement in metres and `w` the strongest folding, so a
caller can also ask whether the water at a point is breaking.
The swell bank is deliberately absent — it is analytic and longer than the tile
this grid covers, so `ocean.module` evaluates it directly on the CPU instead.
·computeshader · born here
❒asset # ocean_spectrum
Builds one ocean cascade's time-independent complex wave amplitude `h0(k)` from
a JONSWAP directional spectrum: a Phillips energy core, Donelan–Banner
directional spreading around the wind, a JONSWAP peak enhancement, and a
`k_min`/`k_max` band limit that keeps each cascade to its own disjoint range of
wave numbers. The result is randomised into a complex Gaussian so the same sea
state produces a real random surface rather than a deterministic ripple. Runs
when the sea state changes, not per frame — `ocean_evolve` animates the field it
writes.
# islandLagoon
The island the `island_lagoon` demo scene stands in, as a terrain graph.
A flat lagoon bed at -14 m with a broad dome rising through sea level at
roughly 1:21, carrying two surfaces: sand from the lagoon floor up past the
waterline, grass over the dry crown. The gentle slope is the point — it puts a
continuous range of water depth in front of the camera, which is what makes
the ocean's shoaling, absorption and shoreline wash readable in one shot.
## Inputs
None. Every dimension is authored in the graph; edit `init.luau` to move the
waterline, widen the beach or change the relief.
## Outputs
- `land`: the landscape as one element carrying `Terrain`, which the graph
shows through `io.output`: the ground, its colliders and its water, standing in
the scene.
## Consumers
- `@builtin::systems.oceans.demo.island_lagoon` instantiates it as the demo's
terrain and binds the ocean's `ground` to the entity it produces.
# Island lagoon
The spectral ocean against a shore: an island rising out of a lagoon at a
beach's slope, so one view holds every water depth from the waterline to open
blue.
Load it with `scene.load("@builtin::systems.oceans.demo.island_lagoon")`, then
walk down to the water.
## What it shows
- **Depth as colour.** The water column's absorption is per-channel, so the
sea reads turquoise where the sand is a metre or two down and turns blue as
the bed falls away. The gradient is the ground's own slope seen through the
water, not a painted ramp.
- **The seabed through the surface.** The bed is refracted, attenuated over
the real path the light travelled, and lit by the caustic web the surface
focuses onto it.
- **Shoaling.** Chop dies as the bottom comes up — geometry and shading
together — so the shallows are glassy while the open water stays crisp.
- **The shoreline.** Wash along the waterline, foam that clings to breaking
crests, and a sea that meets the sand instead of ending at it.
- **A sun path over water.** The glitter track is the surface's own slope
statistics under this scene's directional light.
## Things to try
```luau
local Presets = require("@builtin::systems.oceans.modules.oceanPresets")
Presets.names() -- nine sea states, calm to gale
entity("lagoonSea").Ocean.preset = "gale" -- the same shore under a gale
entity("lagoonSea").Ocean.seaLevel = 2.0 -- flood the beach
```
A preset is a whole sea — spectrum, swell, colour, all three foam layers and
the seabed — so swapping one changes the water and leaves the island, the
light and the sky exactly where they are.
## What it is made of
- `@builtin::systems.oceans.demo.islandLagoon` — the terrain graph the island
is baked from.
- `Ocean` — the sea, bound to that landscape through its `ground` field.
- A directional sun, an ambient fill, a `ProceduralSky` and a
`PhysicalAtmosphere`. The ocean carries no light of its own: every
reflection, the glitter and the foam's shading come from these.
# oceans guide
The walkthrough for building a sea with the oceans package: putting an ocean in
a scene, choosing a sea state and understanding which control does what, floating
things on the waves, and what the simulation costs.
# oceanSeabed
The ground under the sea: one gradient-noise field mixed between sand and rock,
lit by the scene's own sun and ambient, with the caustics the waves above focus
onto it added on top. The simulation drives the cascade displacement map, the
sand and rock maps the compute pass generates, and the terrain's world origin on
this material as the clipmap recentres; the blend threshold, the tiling and the
caustic strength are authored here. Sea-state presets in
`~.modules.oceanPresets` override these values.
# oceanWater
The default open-ocean material the `Ocean` component applies: deep blue with
per-channel absorption tuned for clear seawater, a near-mirror surface, and
breaking-wave foam. The simulation drives the cascade normal maps, the cascade
patch sizes and the scene-depth map on this material every frame; everything
else is authored here. Sea-state presets in `~.modules.oceanPresets` override
these values for paleSwell, gale and the rest.
# buoyancy
Floats a rigid body on the sea the simulation is already drawing.
```lua
local buoyancy = require("@builtin::systems.oceans.modules.buoyancy")
buoyancy.attach(barrel, { density = 0.4 })
```
Or attach a `Buoyancy` component next to the entity's `Physics` body and author
`density` / `damping` / `angularDamping` / `columns` on it — the component calls
this module every frame.
## A hull is a bundle of columns
A grid is laid over the hull's footprint, and each cell stands for a vertical
prism spanning the hull's full height. Every column measures the water above its
own base, and carries the share of the displaced volume that implies, applied at
its own position.
That is what the grid is for. One force at the centre of mass can only push a
body up and down; forces spread over the footprint push harder on the side that
is deeper, which is what rights a hull that has been tipped over and what makes
it pitch as a wave rolls under it. `columns = 1` is a single central column: it
floats, but it cannot right itself.
## Density sets the waterline
`density` is relative to water: the lower it is, the higher the hull rides, and a
body denser than water sinks. `buoyancy.restingY(waterY, height, density)` gives
the height at which a hull of that density displaces its own weight.
A hull settles somewhat deeper than that height, and how much deeper moves with
how long the frames are — a force applied from a script acts over a frame, while
gravity is integrated by the physics step, so the two balance a little below
where the arithmetic puts them. Densities up to about a half float on every frame
time measured; above that a hull rides progressively lower and sinks outright on
a long frame. Trim the number until the waterline sits where it should.
The body's own mass comes from its rigid body, whether `Physics.mass` sets it or the solver derives it from the collider volume, and the buoyant force is scaled
against it, so a hull floats at the same waterline whatever mass it was authored
with — mass changes how briskly it gets there, not where it ends up. `mass` and
`gravityScale` may be passed to `attach` / `configure` for a body whose rigid
body says something else.
## Damping is measured against the hull's own spring
Buoyancy is a spring: push a hull under and the water pushes back in proportion
to how far, so it has a natural frequency of its own, set by how deep it has to
sink to displace its own weight. `damping` is a fraction of the damping that
would return it to its waterline without a single overshoot — 1 is that critical
value, lower values let it bob on the way, 0 bobs indefinitely.
Reading it as a ratio rather than an absolute rate is what keeps a dinghy and a
barge equally steady at the same setting. It is applied per column against that
column's own velocity, so it steadies rolling as well as heaving; a body spinning
about its own vertical axis moves every column but their forces cancel in pairs,
so `angularDamping` damps the hull's turning on top of it. That one is handed to
the rigid body as its own angular damping rate for as long as the hull is in the
water, so the physics step takes the spin down itself and can never overshoot
the rest it is bringing the body to.
Both scale with how much of the hull is actually in the water, so a body lifted
clear of the sea falls freely.
## The hull's box
The hull is measured from what the entity draws, in the entity's own space, so
it stays the same box however the body is turned. Where that box sits comes with
it, so a model drawn off its own pivot — a keel-pivoted boat, an offset child
mesh — displaces water where it draws and carries its columns over that
footprint. What an entity draws arrives over the frames after it is spawned, so
the measurement is retried until it lands and a body keeps its place while it
waits.
`sizeX`/`sizeY`/`sizeZ` name a box explicitly — for a body whose visible mesh is
not what displaces water, or one that draws nothing at all. That box is centred
on the entity's origin, and it belongs at the size of the body that is actually
there: the water pushes on the rigid body's own inertia, so a footprint much
wider than the collider rocks a hull the body has no weight to steady.
## What it reads
The water height comes from `ocean:sampleHeight`, the same wave field the
surface renders, so a hull rides the waves that are on screen. With no ocean in
the scene there is nothing to float on and the body is left to the physics
world; pass `ocean` to pick one when a scene has several.
# ocean
The oceans package's substrate. One `Ocean.create` call brings up a live sea: the
GPU wave simulation, the surface mesh that renders it, the material bound to the
simulation's output, and the coarse readback gameplay queries.
The simulation is spectral and runs in cascades — independent wave fields over
different patch sizes, each band-limited to its own range of wavelengths, summed
into one surface. That is what gives detail at every scale (swell, wind waves,
chop) while keeping each transform small.
The surface is one mesh whose vertices a compute pass rewrites every frame, so no
wave data crosses to the CPU for rendering. It is graded radially and follows the
viewer, snapped to a wave-patch multiple so the sea stays put in the world while
the mesh slides under it.
`sampleHeight` / `sampleDisplacement` answer where the water is, from a small
grid read back off the GPU a few times a second — enough to float a boat on the
same waves that are drawn without stalling a frame on a round-trip. Both invert
the horizontal wave displacement, so they return the surface point that actually
ended up above the position you asked about.
Use the `Ocean` component to place a sea in a scene; use this module directly to
drive one from code.
# oceanClipmap
Builds the ocean's geometry: concentric rings that double in extent and halve in
density outward, the stitch strips that remove the T-junctions between them, and
the skirt that carries the water out to the horizon.
Returns the vertex and index data as Luau buffers, ready to upload straight into
the compute buffers `ocean_displace` rewrites each frame.
# oceanFloor
The ground the water is seen through. It spans the same extent as the clipmap,
follows the viewer the way the surface does, and is shaded by
`~.shaders.oceanFloor`.
One noise field is the whole terrain: scaled it is the relief the seabed's
vertex stage raises the grid into, and read again unscaled it picks between sand
and rock. Over both moves the sun's caustic web, displaced by the horizontal
displacement of the water directly above it.
The seabed applies no absorption of its own. The water column belongs to the
surface shader, which knows the real distance light travelled through water;
attenuating here as well would darken the ground twice.
Its three tiling maps are grown once by `ocean_floor_texture` — the same
compute-texture path the cascade maps take — so the seabed carries no binary
asset and no content reference that could fail to resolve.
```luau
local Floor = require("@builtin::systems.oceans.modules.oceanFloor")
local floor = Floor.create({
config = cfg, -- a resolved ocean config
extent = 12800, -- the clipmap's own extent, metres
prefix = "ocean1_floor_",
name = "Seabed",
})
floor:bindWave(cascadeDisplacementTexture, cascadeTile)
floor:step(elapsed, viewerX, viewerZ)
floor:destroy()
```
`Ocean` creates and drives one of these itself whenever the sea state's `floor`
block is enabled, so a scene that uses the `Ocean` component gets a seabed
without naming this module at all.
# oceanPresets
Named sea states for the oceans package — `calmClear`, `darkChop`, `darkRolling`,
`heavySwell`, `whitecapped`, `murkyGrey`, `paleSwell`, `glassyDark` and `gale`.
Each name says what the water does, so a sea can be picked off the list without
reading its values. A state is an `OceanConfig` fragment applied underneath
whatever the caller passes to `Ocean.create`, so any single field can be
overridden without giving up the rest.
The states are laid out along the Beaufort scale, because wind speed is what
determines wave height in a spectral ocean — the remaining fields shape
character: how long the swell runs, how readily crests break, and what colour the
water body is. `materialProperties` returns the appearance half of a preset,
ready to apply to an ocean material.
# oceanQuality
The four quality tiers: how finely the sea is resolved, as distinct from what
sea it is.
A tier owns the FFT grid per cascade, the clipmap's ring density, the swell band
count, the whitecap field's resolution and whether screen-space reflections run.
It never touches wind, spectrum shape or colour — those come from the preset, so
the same named sea looks like itself on any machine.
# oceanScene
The seam between the `oceanScene` render feature, which captures the colour and
depth behind the water, and the ocean materials that sample them.
`renderer.feature.create` hands back a handle rather than the feature module, so
a shared module is how the half that fills the textures reaches the half that
reads them.
·computeshader · born here
❒asset # ocean_copy
Copies the ocean's rendered surface into the scene target.
## Bindings
| name | kind |
|------|------|
| `scene_depth` | `texture_depth` |
| `scene_color` | `texture2d` |
| `depth_out` | `storage2d (r32f)` |
| `color_out` | `storage2d (rgba16f)` |
# oceanCalmClear
The "Calm Clear" sea state, offered as `calmClear` in the ocean's preset list (slot 1 in the calm-to-gale progression). The lightest wind in the catalogue, at 4 m/s, over the brightest and clearest water it holds, above a shallow shoal. A complete `oceanPreset` configuration: spectrum and swell, colours, fresnel, all three foam layers, the seabed and its caustics, subsurface scattering, screen-space reflections and the sun sparkle.
# oceanDarkChop
The "Dark Chop" sea state, offered as `darkChop` in the ocean's preset list (slot 2 in the calm-to-gale progression). A brisk wind chopping the darkest water in the catalogue, with almost no swell running underneath it. A complete `oceanPreset` configuration: spectrum and swell, colours, fresnel, all three foam layers, the seabed and its caustics, subsurface scattering, screen-space reflections and the sun sparkle.
# oceanDarkRolling
The "Dark Rolling" sea state, offered as `darkRolling` in the ocean's preset list (slot 3 in the calm-to-gale progression). Dark water rolling under a long swell, on the least choppy surface in the catalogue. A complete `oceanPreset` configuration: spectrum and swell, colours, fresnel, all three foam layers, the seabed and its caustics, subsurface scattering, screen-space reflections and the sun sparkle.
# oceanGale
The "Gale" sea state, offered as `gale` in the ocean's preset list (slot 9 in the calm-to-gale progression). The strongest wind in the catalogue, at 38 m/s, raising a fully wind-driven sea under the heaviest surface foam. A complete `oceanPreset` configuration: spectrum and swell, colours, fresnel, all three foam layers, the seabed and its caustics, subsurface scattering, screen-space reflections and the sun sparkle.
# oceanGlassyDark
The "Glassy Dark" sea state, offered as `glassyDark` in the ocean's preset list (slot 8 in the calm-to-gale progression). No swell at all and the highest standing-wave ratio in the catalogue: a still, strongly refracting surface over near-black water. A complete `oceanPreset` configuration: spectrum and swell, colours, fresnel, all three foam layers, the seabed and its caustics, subsurface scattering, screen-space reflections and the sun sparkle.
# oceanHeavySwell
The "Heavy Swell" sea state, offered as `heavySwell` in the ocean's preset list (slot 4 in the calm-to-gale progression). The largest and longest swell in the catalogue, running through mid teal-green water. A complete `oceanPreset` configuration: spectrum and swell, colours, fresnel, all three foam layers, the seabed and its caustics, subsurface scattering, screen-space reflections and the sun sparkle.
# oceanMurkyGrey
The "Murky Grey" sea state, offered as `murkyGrey` in the ocean's preset list (slot 6 in the calm-to-gale progression). Grey, murky water whose reflections fall away close to the eye and which carries no glitter at all. A complete `oceanPreset` configuration: spectrum and swell, colours, fresnel, all three foam layers, the seabed and its caustics, subsurface scattering, screen-space reflections and the sun sparkle.
# oceanPaleSwell
The "Pale Swell" sea state, offered as `paleSwell` in the ocean's preset list (slot 7 in the calm-to-gale progression). A big swell under one of the lightest winds in the catalogue, through the palest blue subsurface transmission it holds. A complete `oceanPreset` configuration: spectrum and swell, colours, fresnel, all three foam layers, the seabed and its caustics, subsurface scattering, screen-space reflections and the sun sparkle.
# oceanPreset
A complete sea state for the ocean system: everything `ocean.create` needs to
stand up a specific sea, from the wave spectrum to the seabed under it. Each
`.data` instance is one named preset offered in the ocean's preset list.
## Fields
- `key` — the name the preset answers to in `ocean.create({ preset = ... })`
and the presets API. Stable; world content refers to it.
- `label` — the human-readable name shown in pickers.
- `order` — the preset's slot in the calm-to-gale progression the list is
offered in (1 = flattest).
- `seed` — the spectrum's random seed.
- `waves` — the FFT spectrum: wind speed and direction, choppiness, gravity,
JONSWAP gamma, spectral sharpness, standing-wave ratio, animation speed,
and a global amplitude on top.
- `cascades` — the three simulation bands: per-band enable, amplitude and
world-space tile scale in metres.
- `gerstner` — the analytic long swell layered under the FFT bands:
wavelength and its spread, amplitude, wave count, directional spread.
- `clipmap` — the surface mesh: base tile size in metres and ring count.
- `color` — the water column: absorption rates authored as a hex triple
(decoded to per-channel 1/m extinction), transmission and body colours,
and the depth assumed where nothing stands behind the water.
- `fresnel` — the mirror: distance fade start/power, index-of-refraction
ratio, normal strength, screen-space refraction strength.
- `foam` — the three layers: `surface` lace, `waves` whitecaps (with
persistence, optional `breaking` window and `placement` mask), and the
`shoreline` wash.
- `floor` — the procedural seabed and its `caustics` web.
- `shore` — how deep the bottom starts taking the waves (`shoalDepth`,
metres); omitted, the ocean's default shoaling band applies.
- `sss` — subsurface scattering through backlit crests.
- `ssr` — screen-space reflections.
- `sparkle` — the sun's specular glint.
Colour-valued fields are authored as `#rrggbb` strings and decoded to linear
RGB by `Presets.color` on the way to the GPU — including `absorptionColor`,
whose channels are extinction rates rather than a colour.
# oceanWhitecapped
The "Whitecapped" sea state, offered as `whitecapped` in the ocean's preset list (slot 5 in the calm-to-gale progression). A hard wind raising the steepest crests in the catalogue, whose foam is the heaviest and the slowest to fade. A complete `oceanPreset` configuration: spectrum and swell, colours, fresnel, all three foam layers, the seabed and its caustics, subsurface scattering, screen-space reflections and the sun sparkle.
·renderfeature · born here
❒asset # oceanScene
Copies the colour and depth of everything drawn behind the water into textures
the ocean surface material can sample.
A surface shader cannot bind the frame's own depth buffer or colour target while
drawing into them, and the water needs both: depth gives the length of the water
column, and colour is what refraction bends and what screen-space reflections
march through.
Both copies run at `afterOpaque` and are published through
`~.modules.oceanScene`, which is where the ocean materials pick them up.
# oceanFloor
The seabed under the spectral ocean: sand where the ground has settled, rock
where it has not, lit through the water by a moving web of focused sunlight.
One noise field decides the whole terrain. Scaled, it is the relief the vertex
stage raises the mesh into and the normal that shades it; unscaled, the same
field picks between sand and rock — so rock outcrops stand on the high ground
and sand fills the hollows, because the two are readings of one surface rather
than independent patterns laid over each other.
Both ground maps carry albedo in `rgb` and height in `a`. The height is
differenced into a detail normal that rides on the terrain's own slope as a
gradient, so relief accumulates rather than one normal replacing the other.
The caustics are two layers of the caustics map scrolling against each other at
slightly different scales and in opposite directions, displaced by the
horizontal displacement of the water directly overhead — which is what makes the
web ripple with the surface instead of sliding across the ground as one rigid
sheet. Each colour channel is read at its own small offset, giving the chromatic
fringe focused light through a dispersing medium has.
The seabed applies NO absorption of its own. The water column above it belongs
to the water surface, which knows the real distance the light travelled through
water; attenuating here as well would darken the ground twice.
The mesh follows the viewer, so `floor_center` carries where it currently sits
and every terrain and texture lookup is made in world space. That is what keeps
the ground still while the mesh slides beneath it.
# oceanSurface
The water surface of the spectral ocean. Geometry arrives already displaced by
`ocean_displace`, so this shader only decides what the surface looks like: the
normal summed across cascades as gradients (which is what adds when wave fields
overlap), a water column shaded by per-channel Beer–Lambert extinction over the
distance to whatever lies behind it, Fresnel reflection from the scene's own
reflection probes, subsurface scattering through backlit crests lit by the
scene's directional light, a sharp sun-glitter lobe, and foam
from the simulation's own breaking-wave mask plus a shoreline band. Opacity is
the extinction rather than an authored constant, so colour and transparency stay
consistent from a turquoise shallow to a near-black deep. Works from below as
well as above.
# StreamingSource
The entity world content streams around.
Put it on whatever should keep the world resident near it — the player, a chase
camera, a vehicle. Content the `spatialStreaming` store holds becomes resident
inside `loadRadius` of the nearest source and is released past `unloadRadius`.
Past that, out to `proxyRadius`, a released cell is drawn as whatever stand-in
it was given — so the far field is a silhouette rather than a hole.
```lua
entity.find("Player").component.add("StreamingSource", {
cellSize = 64, loadRadius = 120, unloadRadius = 180, budget = 32,
})
```
| Field | Meaning |
|---|---|
| `cellSize` | Edge length of a streaming cell in world units. Cells are square columns on the ground plane, so a tower and its foundation stream together. |
| `loadRadius` | Distance within which a cell is made resident, measured to the cell's nearest edge. |
| `unloadRadius` | Distance past which a resident cell is released. Must exceed `loadRadius`; the band between them is what keeps a source standing on a boundary from thrashing. |
| `proxyRadius` | Distance within which this source sees a released cell's proxy. 0 follows `unloadRadius`, so the far field starts where the content stops however wide this source streams; a number of its own must exceed `unloadRadius`, since inside that the content itself is standing. |
| `proxyUnloadRadius` | Distance past which the proxy is dropped too. 0 follows `proxyRadius`; a number of its own must exceed it, for the same reason `unloadRadius` must exceed `loadRadius`. |
| `budget` | Entity records one frame may spend loading and releasing. A group is atomic, so a group larger than this still moves in one piece. |
Several sources are fine — each contributes its position and cells stream around
the closest one. They share a single step per frame, so the budget is spent
once however many sources there are.
The component contributes a position and the per-frame step. The content itself
reaches the store through
`@builtin::systems.spatialStreaming.spatialStreaming`: `capture` hands it live
entities, `add` registers records for content that was never spawned.
Streaming runs in edit mode as well as in play, so an author sees the same
residency a player will.
# spatialStreaming
The store and the residency rule behind the `spatialStreaming` package.
## The rule
Three pure functions carry the whole policy, so it can be read and tested
without a scene:
- `cellKeyAt(x, z, cellSize)` — the cell a point stands in.
- `cellDistance(key, cellSize, x, z)` — distance to the cell's nearest **edge**,
zero inside it. Measuring to the centre would put a cell's near corner well
inside the load radius while the cell read as far, so a source walking a
boundary would watch the ground it stands on get released.
- `decide(distance, loadRadius, unloadRadius)` — `"resident"`, `"released"`, or
`"hold"`. The hold band between the radii is what stops a boundary from
thrashing.
- `proxyDecide(distance, proxyRadius, proxyUnloadRadius)` — the same rule over
the far-field radii, for the stand-in a released cell is drawn as.
Each source runs that verdict with its own radii — its own, else its layer's,
else the configuration's. A cell is resident while any source says resident and
released once every source says released. A cell the budget stopped mid-move is
the one case the band does not hold: it finishes the transition it was making,
because half a cell is not a state a source can idle in.
## The far field
Past the unload radius a cell can still be seen, so a cell carries an optional
**proxy**: its own records, which makes a merged mesh, a billboard impostor and
a bounding silhouette all expressible. `setProxy` / `clearProxy` give and take
one; `deriveProxies` builds the silhouette case from what the store already
holds, one box over a cell or one over each group. A record is measured as a box
of its own `scale` placed through the whole of its parent's transform — scale,
then rotation, then position — so a group standing rotated and scaled is
enclosed where it actually stands. A mesh that is not one unit at scale 1 is
measured as the unit box it scales from, which is what `padding` covers.
The proxy stands on its own pair of radii, `proxyRadius` / `proxyUnloadRadius`,
measured from the same cell-edge distance. Left alone the pair **follows the
content**: the far field starts a fixed multiple past `unloadRadius` and carries
its own band, so a world that streams over a wide radius gets a far field past
it without naming one. A number pins it instead, and a pin is measured — a proxy
radius inside the unload radius names ground the content is standing on, so it
is refused where it is authored. Passing 0 hands the pin back to the derivation.
Configuration, layer and source each follow that rule: a scope that names an
unload radius of its own gets a far field measured from it.
The exchange between the two representations is ordered. A cell's proxy is
raised **before** the first of its groups is released, and dropped once the
cell's own content has landed — so a cell in the proxy band is never represented
by nothing, and an opaque silhouette is never left standing over the content it
was covering for. A proxy whose entities left the scene by some other path is
taken down whole on the next step and raised again from its records.
## The store
`capture(targets, opts)` serializes a root and its descendants into a group and
files it in the cell the root stands in, leaving the entities standing. `add`
registers records for content that was never spawned. Either can write to a
file with `store = "vfs"`, in which case a released cell keeps only its
manifest — key, layer, byte count, entity count, path — in memory.
A group is released by re-serializing it from the live subtree first, so what
comes back is the state it was released in rather than the state it was
captured in — including the cell it belongs to, which is re-derived from where
its root now stands. A group whose root gameplay destroyed is dropped: the cell
forgets it rather than resurrecting deleted content on the next visit, and the
slot is closed so nothing later visits an index that names no content.
## The step
`tick(dt)` spends at most `budget` entity records per frame, releases before it
loads (so the peak stays at what the radius bought rather than at the sum of the
cells on both sides of a boundary), and takes the nearest pending cell first.
One step belongs to the frame rather than to the caller. A second `tick` inside
the same engine frame reports `stepped = false` and changes nothing, so a world
carrying several `StreamingSource` components spends one budget per frame rather
than one per source.
Parenting waits for the entities. `entity.instantiate` mints ids immediately but
the entities land over the following drains, a bounded number per frame — so a
group's links are applied at the top of each step and the ones whose child or
parent has yet to land are retried on the next. The cell counts as pending until
they are all applied, and a group with links outstanding is not released:
re-serializing it while its children were still queued would store the root
without them.
## Reporting
`stats()` gives the residency picture: `cellsResident` / `cellsPending`,
`entitiesResident` / `entitiesTotal`, and `bytesResident` / `bytesStored` —
the encoded size of the groups standing now against the size of everything the
store knows about. The far field is priced apart from the content it stands in
for: `proxiesTotal` / `proxiesResident`, `entitiesProxy` and `bytesProxy` are
what it costs right now, against the `entitiesTotal` / `bytesStored` it covers.
`cells()` is the per-cell view (`proxy`, `proxyResident`), `proxies()` the
per-cell view of the stand-ins — `records` is what one is made of, `entities`
how much of it is standing — and `residentIds()` / `proxyIds()` the entity ids
each half currently has standing.
# spatialStreaming test suite
Covers the `spatialStreaming` package on both halves of what it is.
**The rule, as pure functions** — `cellKeyAt` / `cellCoords` / `cellBounds`
agreeing on one square, `cellDistance` running to the nearest **edge** (the case
that separates it from a centre-based measure, which would release the ground a
source is standing on the corner of), `decide` returning resident / released /
hold across the hysteresis band, `proxyDecide` doing the same over the far-field
radii, and the refusal of a radius pair with no band between it.
**The store, against a live scene** — a capture taking roots, counting the
branches it was handed and refusing a root it already holds; content past the
unload radius leaving `entity.findAll` entirely and returning with the tint,
position and attribute written while it was resident; a subtree coming back
parented; a 201-entity group — more than one frame's drain admits — coming back
whole and releasing with every entity it started with; a group whose root
gameplay destroyed leaving the store instead of lingering as a slot that names
nothing, with the steps that follow completing; a root that moved streaming from
where it ended up; a step spending the budget it was given; a transition the
budget cut short finishing even inside the hold band; a cell standing while any
one source holds it; a disabled layer releasing its own content and nothing
else; a file-backed cell reporting zero resident bytes while it is away and
reading them back on a visit; and a second `tick` inside one frame changing
nothing.
**The far field** — a proxy pair left alone following the content radii, so a
configure and a setLayer that name only a wide unload radius are accepted and
report a far field past it, while a pinned radius inside the unload radius is
refused and changes nothing (the layer it was aimed at can still be switched
off) and a zero hands a pin back to the derivation; a derived silhouette
containing every record it stands for, including a group whose root stands
rotated and scaled, where the raw offsets name a place the content is not; a
walk over a store whose budget cannot move a cell in one step never taking a
cell in the proxy band from covered to uncovered, with both representations
exercised; a stand-in coming down once the content it covered has landed, so the
two overlap for the handover and no longer; a proxy costing a fraction of the
content it stands in for; clearing one taking down what it had standing and its
now-empty cell with it; a disabled layer holding no stand-in either; a proxy
despawned out from under the store being raised again from its records; and a
store that was never given a proxy streaming exactly as it did.
Each live case resets the store, restores the world-wide configuration and
despawns its own `__stream_*` entities on the way out; the file-backed case
writes under `Test.tmpPath` and removes the tree it wrote. The suite leaves the
engine as it found it.
# IKAim
Turns a bone chain until a named axis on its tip points at a target. Where
`IKLimb` drives a tip to a position, this drives it to an orientation — it is
what aims a weapon barrel, a turret, a sensor, or a spotlight.
```luau
turret.component.add("IKAim", { rootBone = "base", tipBone = "barrel", target = enemyRef, axisZ = 1 })
```
## Fields
- `role` or `rootBone` + `tipBone` — which chain turns.
- `target` — an `EntityRef`.
- `axisX` / `axisY` / `axisZ` — the tip's local aiming direction, default `+Z`.
A barrel modelled down `+Y` sets `axisY = 1` and `axisZ = 0`.
- `weight` — blends against the incoming animation; 0 is an exact pass-through.
- `iterations` — aiming is iterative because turning a bone also moves the tip,
which changes the direction to the target.
## Checking it
```luau
turret.component.get("IKAim"):aimState()
-- { ready, status, boneCount, aimErrorDegrees, weight, error }
```
`aimErrorDegrees` is the angle still between the tip's axis and the target. If
it stays large, check the axis before anything else.
# IKChain
Solves an arbitrary bone chain so its tip reaches a target. It names bones
directly instead of resolving canonical humanoid roles, so it works on any rig
at all — a crane boom, a robot arm, a tentacle, a lamp, a humanoid limb.
```luau
crane.component.add("IKChain", {
rootBone = "boom_base",
tipBone = "boom_tip",
target = hookRef,
solver = "ccd",
})
```
## Fields
- `rootBone`, `tipBone` — the chain's ends, named as the rig names them. The
tip must be a descendant of the root; the bones between come from the rig's
own parent links. Use `ik.bones` to see what the rig actually calls things.
- `target` — an `EntityRef`. Bones are strings, targets are refs.
- `solver` — `"ccd"`, `"fabrik"`, or `"twoBone"`. CCD holds joint limits well on
short runs; FABRIK converges faster on long floppy ones.
- `weight` — blends against the incoming animation. 0 is an exact pass-through
and skips the solve entirely.
- `iterations`, `tolerance` — the iterative solvers' budget and stopping error.
## Checking it
```luau
crane.component.get("IKChain"):chainState()
-- { ready, status, boneCount, reach, distanceToTarget, solver, weight, error }
```
`status` says in words what it is doing when `ready` is false — usually a body
whose rig has not arrived yet, which clears on its own unless the engine is
paused. `distanceToTarget` is the residual after solving, not the distance to
compare against `reach`; for that, use `ik.reach`.
# IKLimb
Drives a two-bone limb — an arm or a leg — so its tip lands on a target. Exact
and single-pass: the law of cosines answers outright, so there is nothing to
converge.
```luau
hero.component.add("IKLimb", { role = "rightarm", target = doorknobRef, pole = elbowHintRef })
```
## Fields
- `role` — `leftarm`, `rightarm`, `leftleg`, or `rightleg`, resolved through the
body's canonical role map so one setup works on any humanoid mesh. The
vocabulary is not the anatomical word: an upper arm is `leftarm` and the
forearm is `leftforearm`, while an upper leg is `leftupleg` and the shin is
`leftleg`.
- `rootBone`, `tipBone` — explicit bones instead, for a rig with no role map.
Only the ends are named; the joint between comes from the rig's parent links.
- `target` — an `EntityRef`.
- `pole` — an `EntityRef` steering which way the joint bends. Put one behind the
elbow or in front of the knee. Without a pole the bend plane the incoming
animation produced is preserved, which is stable but not always the plane you
want.
- `weight` — blends against the incoming animation; 0 is an exact pass-through.
## Checking it
```luau
hero.component.get("IKLimb"):limbState()
-- { ready, status, boneCount, reach, distanceToTarget, hasPole, weight, error }
```
A limb reaches as far as its bones are long. A target beyond that has no
solution — the arm extends straight toward it and stops, which looks exactly
like a broken solver. `ik.reach` answers whether a target is inside the band.
# IKLookAt
Turns a character's spine, neck, and head toward a target so it watches
something as it moves.
```luau
guard.component.add("IKLookAt", { target = playerRef, clampAngle = 80 })
```
## Fields
- `target` — an `EntityRef`. Nil leaves the pose alone.
- `rootBone`, `tipBone` — an explicit chain, for a rig with no canonical spine
roles. Absent, the spine-to-head run is resolved from roles.
- `axisX` / `axisY` / `axisZ` — the head's local forward, default `+Z`. Naming
the wrong axis is the classic way to get a mangled pose: the solver faithfully
points the axis you named, however strange the result.
- `bodyWeight` — the share of the turn taken by the bones below the head. 0
turns the head alone; higher brings the torso round with it.
- `clampAngle` — the largest total turn, in degrees. This is what stops a
character wringing its neck round to follow something behind it.
- `weight` — blends against the incoming animation; 0 is an exact pass-through.
## Checking it
```luau
guard.component.get("IKLookAt"):lookState()
-- { ready, status, boneCount, clamped, clampAngle, bodyWeight, weight, error }
```
`clamped` is the field worth watching: it distinguishes "watching the target"
from "turned as far as allowed and still facing away", which look identical in
a screenshot.
# IKRig
Whole-character IK on a humanoid: both legs, both arms, a look-at, and ground
adaptation, resolved through the body's canonical roles so one configuration
works on any humanoid mesh.
```luau
hero.component.add("IKRig", { grounding = true, rightHandTarget = ledgeRef, lookTarget = playerRef })
```
Everything registers on a single solve pass in a fixed order: grounding, then
legs, then spine and head, then arms. Grounding leads because it moves the
pelvis, and the pelvis is the root of every limb.
## Fields
- `leftFootTarget`, `rightFootTarget`, `leftHandTarget`, `rightHandTarget`, each
with a matching `…Weight`.
- `lookTarget`, `lookWeight`, `lookClampAngle`, `lookBodyWeight`.
- `grounding` — casts a ray under each foot, moves that foot's target to the
surface, tilts it to the normal, and drops the pelvis far enough that neither
leg over-reaches.
- `groundingMaxSlope` — how far a foot may tilt onto a steep face, in degrees.
- `groundingFootHeight` — how far the foot's origin sits above its sole.
- `groundingHalfLife` — smoothing on every value that comes off a raycast.
Without it a ray crossing a step edge pops the foot between two heights.
- `groundingRayUp`, `groundingRayDown` — how far above and below the foot to cast.
An explicit foot target wins over the grounder. The grounder still counts that
foot toward the pelvis drop, so planting one foot on a ledge while the other
follows the terrain stays coherent.
## Checking it
```luau
hero.component.get("IKRig"):rigState()
-- { ready, status, pelvisOffset, limbs, look, error }
```
`limbs` carries each resolved limb's weight, residual distance, ground contact
and lift. `pelvisOffset` is the current drop — roughly zero on flat ground
whether or not grounding is doing anything, so read the per-foot `grounded`
flags rather than the offset alone.
Climbing is a character controller's job. Grounding lowers the pelvis but never
raises it, so a tall step asks a leg to stretch past its limit and the solve
stops short.
# ik
The inverse kinematics library behind the `IKChain`, `IKLimb`, `IKLookAt`,
`IKAim`, and `IKRig` components. Require this module when you are driving a
chain from a script, or writing a solver of your own; the components are the
normal way in.
## Exports
- `chain` — resolve a root-to-tip bone run from a rig, run forward kinematics
over it, and move it between pose buffers. Everything else operates on a chain.
- `solvers` — `twoBone`, `ccd`, `fabrik`, `aim`, `lookAt`, plus `byName` and
`names` for looking one up from a component field. The five are also lifted
onto this module directly.
- `limits` — cone and hinge rotation limits, measured from a bone's rest pose.
- `grounding` — foot probes, slope-clamped surface alignment, pelvis drop, and
the framerate-independent smoothing that keeps a foot from popping at a step
edge.
- `bind` — the component-side plumbing: find a body, get an IK node onto the
graph driving it, and convert between world and model space.
- `ikNode`, `basePose` — the animation nodes IK runs through.
## Usage
```luau
local ik = require("systems.ik.ik")
local rig = ik.bind.rigOf(bodyId)
local c = ik.chain.fromRole(rig.bones, rig.roles, "rightarm")
ik.chain.forward(c)
ik.twoBone.solve(c, { target = modelSpaceTarget, pole = elbowHint, weight = 1 })
```
A chain solves in the rig's own model space. `bind.worldToModel` and
`bind.modelToWorld` convert a world-space point into and out of it, scale
included — a target handed to a solver in world space will be wrong by whatever
the body is scaled by.
## Notes
- `chain.forward` runs forward kinematics over the chain's own bones;
`chain.forwardBase` runs it over the bones above the chain's root, which is
what puts an arm at its shoulder rather than at the character's origin. A
solver needs both to have run.
- Solvers rewrite `chain.lRot` in place and leave translations alone. Call
`chain.forward` after a solve to see the result in `gPos` / `gRot`.
- `weight` 0 is an exact pass-through on every solver and short-circuits before
any work happens.
- Every solver tolerates a target it cannot reach: the chain extends toward it
rather than producing a degenerate pose or NaN.
# ik suite
Covers the pure-math core of the `ik` package — chain resolution, the five
solvers, and rotation limits. Everything here runs without a rendered frame, a
live skeleton, or an entity, so it executes in the offline shard.
What this locks in:
- **Chain resolution** — root-to-tip ordering, segment lengths and total reach,
forward kinematics, and the three ways resolution legitimately fails (tip not
a descendant of the root, an unknown bone name, a role the rig does not fill).
Also `rotateGlobal` pivoting a bone about its own joint rather than the chain
root, which is the property every solver depends on.
- **`twoBone`** — exact reach for targets in every direction around the root,
graceful extension toward an unreachable one, weight 0 as an exact
pass-through, partial weight landing between the incoming pose and the solve,
idempotence across repeated solves, and pole targets producing opposite,
deterministic bend planes.
- **`ccd` and `fabrik`** — convergence, early exit at tolerance, segment lengths
preserved through the solve, the root staying anchored, and rotation limits
holding while iterating.
- **`aim` and `lookAt`** — the tip axis landing on the target direction, the
total turn respecting `clampAngle`, and `bodyWeight` actually moving the turn
down the chain instead of leaving it all on the head.
- **Rotation limits** — clamping outside the range, leaving values inside it
untouched, stability under repeated application (a limit that drifted each
frame would slowly collapse a pose), and a hinge stripping off-axis swing.
Idempotence and repeat-application stability appear throughout because IK runs
every frame on the result of the previous frame: a solver that drifts by a
fraction of a degree per solve looks correct in a single-frame test and visibly
wrong after a second of playback.
# terrainData
A landscape's baked data, as one asset.
A terrain is never a single image. It is a heightfield, the control textures
saying which surface covers where, a mask of where there is no ground at all,
the water surface standing over it, and the table of what those surfaces are.
Every one of those is an ordinary `.texture` the renderer already knows how to
sample; this container holds them together and names their roles, so a `Terrain`
component references a **landscape** rather than a handful of loose images, and a
heightfield never turns up in the picker beside an albedo map.
```
dunes.terrainData/
terrain.yaml the manifest: where the ground stands, and role -> child
height.texture/ rgba32f: R metres, G/B gradient, A coverage
holes.texture/ rgba8: R non-zero where there IS ground
water.texture/ rgba32f: R the surface's world Y, G its coverage
water_shade.texture/ rgba8: R the water column over the deepest column, G coverage
control0.texture/ rgba8: four layer weights, one per channel
control1.texture/ rgba8: layers 5-8
slice_albedo_0.texture/ rgba8: one layer texture per distinct albedo
slice_normal_0.texture/ rgba8: one per distinct normal map
slice_mask_0.texture/ rgba8: one per distinct mask map
```
The children are written by the same `asset.create` every other texture goes
through and placed inside the container by `dest`, the way a bundle carries the
meshes it assembles. A container holds `holes`, `water` and the `slice_*` children
only when the landscape has them.
## The manifest
```yaml
version: 1
size: [4000, 4000] # metres of ground, along world X and Z
origin: [0, 0] # world centre of that rect
heightRange: [-8.1, 63.4] # the vertical reach the geometry declares
height: height.texture
holes: holes.texture
water: water.texture
waterShade: { file: water_shade.texture, depthMax: 12.5 }
controls: [control0.texture, control1.texture]
layers:
- { name: sand, tint: [0.62, 0.56, 0.42], tiling: 12, albedoSlice: 0, normalSlice: 0, maskSlice: -1 }
- { name: rock, tiling: 8, albedoSlice: 1, normalSlice: -1, maskSlice: -1 }
slices:
albedo: [slice_albedo_0.texture, slice_albedo_1.texture]
normal: [slice_normal_0.texture]
size: [1024, 1024]
```
Children are named **relative to the container**, so a landscape that is moved
or renamed carries its own textures with it.
## The payloads
**The heightfield** is `rgba32f`: R the height in **metres**, G and B its
gradient along world X and Z, A coverage. Metres and not a normalized 16-bit
ramp, because relief is often a few metres inside a range of hundreds and eight
or sixteen bits band that into terraces. The gradient is what a surface normal
comes out of without differencing the height four more times.
**The control textures** are `rgba8`, one channel per surface layer, four layers
a texture and another texture for every four after that. A texel holds how much
of each layer covers it, and the weights are **normalized to sum to one**: a
texel covered half by sand and half by rock reads 128/128, whatever the two
masks happened to evaluate to, so a shader blends by them without renormalizing
per pixel. Layer `n` is channel `((n - 1) % 4) + 1` of control texture
`ceil(n / 4)`; `:layerSlot(name)` answers that for a named layer.
**The holes mask** is `rgba8`, R non-zero where there **is** ground. Terrain a
cave mouth or a quarry has been cut out of has holes, and a hole is absence
rather than a height of zero.
**The water surface** is `rgba32f`: R the world Y the surface stands at, in
metres, G whether a surface covers that texel. A sea, a lake and a river are the
same thing here: a level and where it reaches. `water_shade` is its filterable
`rgba8` companion that the water's material reads per pixel, R the column over
`depthMax` and G coverage.
**The layer table** says what each control channel stands for: its tint, its
albedo, its normal map, its mask map (R metallic, G ambient occlusion, B height,
A smoothness), and the metres one repeat of them covers (`tiling`).
**The layer slices** are the layers' own textures, one child per distinct texture
per role (albedo, normal, mask), all resampled to one square edge (`slices.size`).
A surface binds each role's slices as one texture array, and each layer's
`albedoSlice`, `normalSlice` and `maskSlice` says which slice it reads (0-based,
-1 for none). A slot per layer per map would be twenty-four sampled textures in
one bind group, past what a fragment stage is guaranteed; three arrays cost three
slots whatever the layer count, and every slice wraps on its own.
## Filling one
`terrain.write` in a procgen graph writes the manifest and every child in one
step, and answers the reference to the container. It takes:
- `raster`: the ground; its `height` layer becomes the heightfield.
- `name`: the container's identity, a bare identifier (`dunes` writes
`dunes.terrainData`). `folder` or `dest` places it.
- `layers`: the names of the raster layers holding each surface's coverage, in
channel order, separated by commas (`"sand, rock"`). Coverage per surface is an
ordinary mask layer written by `terrain.mask`.
- `surfaces`: optional records of `{ name, tint?, albedo?, normal?, mask?, tiling? }`
matched to a layer by `name`, saying what each surface is shaded with.
- `holes` / `solid`: a mask layer marking where ground is cut out, or where it
exists.
- `height`, `water`, `waterMask`: the raster layers read for the heightfield and
the water surface.
- `resolution` and `layerResolution`: the texel size of the maps and the edge of
one layer slice.
A surface that names no texture is shaded by its `tint`. Writing the same name
again re-bakes in place and keeps the guid, so every reference already pointing
at that landscape stays resolved.
## Reading one
| Method | |
|---|---|
| `:placement()` | `{ size, origin, heightRange }`: where the ground stands and how far it reaches. |
| `:height()` | The heightfield texture. |
| `:holes()` | The holes mask, or nil when the ground is solid everywhere. |
| `:water()` | The water surface texture, or nil when the landscape is dry. |
| `:waterShade()` | `{ texture, depthMax }`, or nil for a landscape carrying no water. |
| `:control(i)` / `:controls()` | The weight textures, in channel order. |
| `:layers()` | The surface table. |
| `:slices()` | `{ albedo?, normal?, mask?, size }`: the layer textures per role, or nil when no layer carries a texture. |
| `:layerSlot(name)` | Which control texture and channel a named layer is in. |
| `:manifest()` | The whole thing, decoded. |
| `:instantiate(target?, opts?)` | An entity carrying a `Terrain` component bound to this asset. |
| `:setChild(role, file)` / `:setPlacement(p)` / `:setLayers(t)` / `:setSlices(s)` / `:setWaterShade(file, depthMax)` | What `terrain.write` fills it through. |
The placement travels with the landscape, so a `Terrain` component standing on
one does not re-state how big it is.
## Validation
`asset.validate` (and the publish gate) refuse a container whose manifest does
not decode, that does not say where its ground stands, that names a child which
is not there, or that declares more layers than its control textures can carry.
A landscape whose heightfield is missing is ground nothing can stand on, and
that is worth catching before it is published rather than when an entity tries
to load it.
## In the Inspector
Selecting a terrain opens its **Terrain** section: its size, origin and height range, the heightfield's resolution once one is written, a table of its material layers (name, tiling, whether it has an albedo), and a link to each child texture (heightfield, holes, water, control maps). A terrain not yet sculpted says it has no child textures.
# Terrain (component)
Ground, as scene content. Part of the
[terrainSystem package](../../readme.md) — every terrain entity in a scene
carries exactly one of these.
An entity carrying this component IS a landscape: a `.terrainData` standing over
a rect of world, drawn through a quadtree of LOD nodes that
refines under whichever camera is looking at it, with a collider the default
avatar can walk.
```luau
-- The whole of it: one reference. The landscape says how big it is.
entity(id).component.add("Terrain", { terrain = dunes })
```
## Properties
| Name | Type | Default | Description |
|------|------|---------|-------------|
| `terrain` | `AssetRef<terrainData>` | `nil` | The landscape this entity IS — the heightfield, the control textures, the holes mask and the layer table, held together as one asset. It states the rect it covers and the reach it declares. |
| `material` | `AssetRef<material>` | `nil` | What the ground is shaded with. Falls back to the package's `terrainStandard`. |
| `size` | `vec2` | `{ 0, 0 }` | Where the ground stands, when it should stand somewhere other than where the landscape says. Left at zero, the container's own placement is used. |
| `origin` | `vec2` | `{ 0, 0 }` | World centre of that rect, same rule. |
| `heightRange` | `vec2` | `{ 0, 0 }` | The vertical reach the geometry declares — `x` the lowest metre, `y` the highest. Left at zero, the landscape's own reach is used. |
| `collision` | `boolean` | `true` | A static heightfield collider over the whole rect. |
| `collisionResolution` | `number` | `257` | Collider samples along each axis. |
`heightRange` is the mesh's bounding box, so a range smaller than the ground
culls the terrain away at grazing angles and the symptom is nothing at all on
screen.
The collider is ONE grid over the whole rect, so `collisionResolution` is its
spacing: 257 over four kilometres is a sample every sixteen metres, and raising
it buys resolution at O(n²) cells to build.
The heightfield's own texel count sets how finely the ground is described inside
the rect; the landscape's `size` sets how much world that description covers.
## Where the landscape comes from
A graph. It opens a rect of ground with `terrain.source`, shapes it with the terrain
nodes (`fbm`, `cone`, `ridge`, `erode`, ...), writes the coverage of each surface as a
mask layer with `terrain.mask`, and ends in `terrain.write`, which bakes the manifest and
every child texture in one step and answers the reference to the `.terrainData`. That is
the same move every other procgen family makes to persist what it made. The reference is
what the component's `terrain` property holds:
`entity(id).component.add("Terrain", { terrain = ref })`.
A heightmap made anywhere else (an 8-bit image, a downloaded elevation tile) comes in
through `terrain.from_texture` and back out through `terrain.write`.
## Lifecycle
- `awake` — opens the landscape, builds the LOD pool, binds it to the entity,
adds the `Model` and the collider.
- `update` / `editorUpdate` — follows the on-screen camera with the cut, in edit
and in play alike.
- `onPropertyChanged` — a `material` change swaps the Model's material in place;
anything else describes the ground itself, so the ground is rebuilt.
- `onDestroy` — releases the mesh, the pool and the heightfield's pixels.
## Methods
| Method | |
|---|---|
| `:heightAt(x, z)` | Bilinear metres. |
| `:normalAt(x, z)` | Unit surface normal, three numbers. |
| `:slopeAt(x, z)` | Radians; 0 is flat. |
| `:field()` | The heightfield itself — the value every `terrainHeightfield` verb takes. |
| `:reload()` | Read `terrain` again and rebuild what the entity draws. |
The queries answer from the same texels the geometry is built out of, so what a
script is told and what a player walks on cannot disagree.
Re-baking the landscape and calling `:reload()` moves every terrain standing on
that asset, without a restart and without touching the scene graph —
the mesh keeps its guid, so the Model is never re-bound.
## One draw
Every node of the LOD cut is one range of one index buffer, so the whole
terrain — at every level, however large — is a single indexed draw on a single
entity. Nodes are never entities. `modules/terrainMesh` is the whole surface.
·computeshader · born here
❒asset # terrain_apply_delta
Folds an erosion pass's accumulated fixed-point deltas into a channel and
clears the accumulator.
An erosion pass never writes height. Every gram of material it moves
accumulates into an integer accumulator in units of 1/65536 m, because integer
addition commutes and float addition does not — the summed result of thousands
of droplets landing on one cell is the same whatever order the threads resolved
in. This pass is where that sum becomes terrain.
It runs once per erosion iteration, after the gather that brings in the
material neighbouring tiles moved across the seam. `atomicExchange` reads and
zeroes in one step, so the accumulator is empty for the next iteration without
a second clearing pass.
One thread per stored cell, apron included. An apron cell's own delta is
applied here as well: the following apron exchange overwrites it wherever a
neighbour owns the cell, and where the field ends and no neighbour does, the
applied value is what keeps the border apron a plausible continuation of the
terrain rather than a step.
·computeshader · born here
❒asset # terrain_apron
Copies a rectangular block of a neighbour tile's stored cells into this tile.
A tile's apron holds a second copy of cells its neighbour owns. An op that is a
pure function of world position writes both copies to the same value on its
own; an op whose result depends on what the tile already held — erosion above
all — leaves the two copies disagreeing, and every neighbourhood read at the
seam then sees a different surface depending on which tile it came through.
This pass makes the apron read what the neighbour computed.
The block is given in stored-cell coordinates (`dstX0, dstZ0`, `srcX0, srcZ0`,
`blockWidth`, `blockHeight`) rather than as an edge index, so one shader moves
an edge band and a corner square. The caller issues one dispatch per neighbour
that exists: eight of them tile a tile's apron ring exactly, with no cell
written twice.
A block facing off the edge of the field has no neighbour and no dispatch. Its
apron keeps whatever the generating op wrote there, which for a world-space op
is the correct extrapolation of the landscape past the field boundary.
Run it after any op whose result is not a pure function of world position, and
between erosion batches — a droplet may walk at most `apron` cells before the
neighbourhood it is reading is no longer the neighbourhood its neighbour has.
·computeshader · born here
❒asset # terrain_coast
Pulls the ground outside a landmass down to a seabed altitude, by a radial
falloff about a point.
The land weight is 1 inside `radius`, falls to 0 across `falloff` metres beyond
it, and the height is interpolated between the seabed and whatever the stack
already built:
```
value = mix(seabed, prev, landWeight)
```
The interior keeps its landscape untouched, the rim slopes off it, and
everything past the rim sits at the seabed. Put the seabed below the sea level
and the result is an island — the terrain ends before the field's rect does,
and the rect goes on carrying the sea.
`shapeMode` picks the metric: 0 is a disc, 1 is the Chebyshev square.
This op covers the **whole field**, not the disc it is centred on: the ground
it changes is the ground outside that disc, and an op that only touched its own
radius would leave the far corners standing above the water.
It is an ordinary op in the stack, so an erosion below it weathers the
coastline it drew, a flatten cuts a harbour into it, and a river carves a mouth
through it.
`terrain_coast_mask` is the same operation driven by a painted raster instead of
a radius, for a coastline whose outline is drawn rather than described.
Every op carries a mask. The op's weight at a cell is scaled by what the mask
reads there — multiplied in, or subtracted out — so the op applies only where
the mask lets it and a cell the mask holds out keeps exactly what it already
held, at every blend mode. An op that names no mask writes at full strength
everywhere.
·computeshader · born here
❒asset # terrain_coast_mask
Pulls the ground outside a landmass down to a seabed altitude, with the
landmass given by a painted raster rather than a radius.
The raster is a layer co-registered with the field — one texel is one cell — so
the outline an author paints is the outline the coast follows. The value is the
land weight directly:
```
value = mix(seabed, prev, clamp(mask * gain, 0, 1))
```
1 is land and keeps the landscape untouched, 0 is open water and sits at the
seabed, and the values between are the shoreline slope: the softness of the
brush is the softness of the coast. `gain` scales the raster before the clamp,
so a stroke painted at a fraction of full strength can still read as solid land.
Like the radial form, this covers the **whole field** — the ground it changes
is the ground the mask does not cover.
`terrain_coast` is the same operation driven by a radius, for a coastline
described rather than drawn.
Every op carries a mask. The op's weight at a cell is scaled by what the mask
reads there — multiplied in, or subtracted out — so the op applies only where
the mask lets it and a cell the mask holds out keeps exactly what it already
held, at every blend mode. An op that names no mask writes at full strength
everywhere.
·computeshader · born here
❒asset # terrain_delta_gather
Adds a neighbour tile's apron-ring erosion deltas into this tile's interior.
A droplet spawned in one tile's interior may walk into that tile's apron and
deposit there. Those apron cells belong to the neighbour — the neighbour's
interior is what carries the field's value for them — so the deposit has to
reach the neighbour's accumulator before either tile folds its accumulator into
height. Without this pass, every cell within a droplet's reach of a seam
collects less material than the same cell away from one, and the seam shows as
a band running through the eroded terrain.
The block is described in stored-cell coordinates, the same description
`terrain_apron` takes, so the two passes address the same geometry from
opposite ends: this one reads the neighbour's apron ring and adds into this
tile's interior band; `terrain_apron` reads the neighbour's interior and
overwrites this tile's apron ring.
The accumulation is a fixed-point integer add. Integer addition commutes, so a
corner cell reached from three neighbours at once sums to the same value
whichever dispatch lands first — which is what keeps a tiled erosion run
reproducible.
·computeshader · born here
❒asset # terrain_erode_droplet
Droplet hydraulic erosion over one tile, one batch at a time.
Each thread carries one droplet. It is placed by a hash of its index, the op's
seed, the batch number and the tile's field-wide base cell, then runs downhill:
it picks up material where it moves fast over steep ground and drops it where
the ground flattens or rises. That is what cuts channels into a ridge and lays
the fans out below them, and it is why the result looks like water shaped it
rather than like a filter ran over it.
## Reproducibility
The pass never writes height. Every change accumulates into `delta` as
fixed-point integers, 1/65536 m per unit, through `atomicAdd`. Integer addition
commutes; float addition does not. Thousands of droplets landing on one cell
therefore sum to the same total whatever order the threads resolve in, and the
same seed over the same stack gives the same terrain every run — on any GPU,
with any scheduling.
A float accumulator would be a few last bits different each run, and everything
downstream that assumes a seed names a landscape (storage, streaming, a shared
world) would drift.
## What one batch may move
A batch works out every droplet's change from the ground as it stood when the
batch began and adds them all at the end, so nothing in a batch can see what
the rest of it is doing to the cell it is standing on. Two bounds are what keep
that from running away.
`share` is the part of one cell's rainfall a droplet carries. A batch rains
`dropletCount` droplets over the interior and each walks up to `apron` cells, so
a cell is stepped on `dropletCount * apron / interiorCells` times a batch and
every one of those steps adds into the same accumulator. Scaling each droplet's
change by the reciprocal is what makes `rain` a sampling density: doubling it
resolves the flow better and moves the same material. Rain too sparse to step on
every cell gives a droplet one cell's rainfall and no more, because a droplet
carrying several cells' worth would put a whole batch's cut into a single step
and reopen the swing below. A run below that density — fewer than
`interiorCells / apron` droplets a batch — therefore samples the flow sparsely
and moves proportionally less; above it the result stops changing with `rain`.
`MAX_SHARE` is the most of the drop below a cell one batch may cut, and the most
of the rise above it one batch may fill. Without it a droplet cuts the whole
drop, which leaves the cell level with the one it drains into — and every other
droplet in the batch cuts that same drop from the same starting ground, so the
cell ends the batch far below its neighbour. The next batch finds the pair
reversed and cuts back further, and the swing grows every batch until the
fixed-point accumulator wraps. `terrain_erode_thermal` names the same bound; a
quarter rather than a half, because water gathers where the ground funnels it
and a cell along a channel is stepped on several times over what an average
cell gets.
## Tiling
A droplet takes at most `maxSteps` steps, and the caller keeps that at or below
the apron width. Past that it would read cells whose real owner is a
neighbouring tile and whose contents have not been exchanged since the batch
began, and the two tiles would disagree about the ground at their seam.
Droplets spawn in the interior only. A spawn in the apron would place a second
droplet on ground the neighbouring tile has already rained on, doubling the
rainfall in a band along every seam. Material a droplet carries out of the
interior and into the apron is not lost: `terrain_delta_gather` adds it into
the neighbour's interior before either tile folds its accumulator into height.
What a droplet is still carrying when its walk ends goes with it. A run
therefore lowers the ground it cuts: material leaves the uplands, and a field
has no basin outside itself for it to arrive in. The fall in the field's mean
is a small fraction of the depth the channels reach.
## Parameters
Lane 1 carries `seed, batch, dropletCount, maxSteps`; lane 2
`solubility, deposition, evaporation, inertia`; lane 4
`minSlope, capacity, gravity, apron`. Heights are metres and horizontal
distances are cells, so `capacity` and `minSlope` read as metres of fall per
cell of run.
Every op carries a mask, and for a simulation it bounds what a step MOVES. The
mask scales both what a step cuts and what it lays down, and the material a step
declines to move stays where it was — in the ground, or in the droplet — so a
droplet carries its load ACROSS ground the mask holds out rather than losing it
there.
What a droplet still holds when its walk ends lands where the walk ended,
whatever the mask reads there. The ground it walked is already lighter by
exactly that much, so scaling that last drop would leave the difference nowhere
and the pass would remove material instead of moving it. The mask therefore
bounds where a run cuts exactly, and where it deposits everywhere except the
cell a walk ends on. An op that names no mask erodes at full strength
everywhere.
·computeshader · born here
❒asset # terrain_erode_thermal
Talus-angle relaxation over one tile, one iteration per dispatch.
Loose material resting on a slope steeper than its angle of repose slides down.
Repeat that and cliffs grow scree fans at their feet while the crests a droplet
pass leaves knife-edged round off — which is why this is usually the finishing
pass over hydraulic erosion rather than a substitute for it.
The rest angle is a world angle. A cell may stand `tan(talus) * cellSize`
metres above a four-neighbour and still rest, so refining the grid resolves the
same slope rather than changing which slopes are stable.
Each iteration is one dispatch: a cell above the rest angle sheds a fraction of
its excess, split across the neighbours below the angle in proportion to how
far below each one is. Half the excess is the ceiling per iteration, above
which the two cells either side of a step trade places every step instead of
settling.
Changes accumulate through the same fixed-point integer path droplet erosion
uses, so the result is equally independent of the order threads land in.
One thread per INTERIOR cell. The apron is read and never written: an apron
cell belongs to the neighbouring tile, which relaxes it itself, and relaxing it
here as well would move its material twice. Material that slides out of the
interior lands in the apron, and `terrain_delta_gather` carries it into the
neighbour before either tile folds its accumulator into height.
Every op carries a mask, and for a relaxation the mask gates the cell that
SHEDS. A cell the mask holds out keeps its own material, and what it still
receives from an unmasked neighbour is the scree that slope throws past the
mask's edge — material moving downhill, which is what this pass does. An op that
names no mask relaxes at full rate everywhere.
·computeshader · born here
❒asset # terrain_fbm
Domain-warped fractal noise written into one scalar channel of one tile.
Gradient noise with quintic interpolation. Sample positions come from the
field-wide cell index, the field's minimum corner, and the seed, so a tile
evaluated on its own is identical to the same tile evaluated as part of the
whole field — and a cell reached as one tile's apron lands on exactly the
world position it has as its neighbour's interior.
`featureSize` is the world size in metres of the largest noise feature.
Halving `resolution` therefore refines the same landscape instead of
producing a different one.
`warp` is a world distance: how far the domain is displaced by two
lower-octave fields before the main fractal is sampled. Non-zero warp is what
turns isotropic lumps into ridgelines and basins.
`ridge` folds the fractal about zero and squares it, giving sharp crests and
rounded valleys.
`blend` decides how the contribution meets what the channel already holds:
`replace`, `add`, `max`, or `min`.
Every op carries a mask. The op's weight at a cell is scaled by what the mask
reads there — multiplied in, or subtracted out — so the op applies only where
the mask lets it and a cell the mask holds out keeps exactly what it already
held, at every blend mode. An op that names no mask writes at full strength
everywhere.
·computeshader · born here
❒asset # terrain_index
Writes one node's triangles into the terrain's shared index buffer. One thread
per quad, six indices each.
The node's vertices live at `vertexSlot` in the vertex pool and its triangles
land at `writeSlot` in the index buffer. Keeping the two apart is what makes a
cut change cheap: a node that survives a cut keeps its vertex slot, so a cut
that only adds, drops or reorders nodes rewrites indices without re-meshing a
vertex, and re-meshing a node for a moved camera rewrites vertices without
touching an index.
The active cut's indices are written as one contiguous run from index 0, so the
whole terrain — every node, at every level — draws as a single indexed draw.
Winding is CCW seen from +Y, the outward side of a heightfield.
·computeshader · born here
❒asset # terrain_layer
Blends a co-registered raster layer into a scalar channel.
A layer shares the field's grid exactly — same tile, same cell, same apron — so
there is no sampling and no transform involved, only a blend. Compare
`terrain_stamp`, which samples a texture through a world-space transform
because the source has a grid of its own.
That exactness is what lets freehand editing live inside a parametric stack.
Brush strokes accumulate into a layer, the layer is one `layer` op among the
others, and changing an op above it re-runs the stack with the strokes still in
place. Painting is an edit to the layer's raster rather than a bake over the
terrain the ops produced.
`opacity` scales the layer before the blend, so the same raster can be dialled
in and out without repainting it. `blendMode` follows the numbering every
terrain op shares: 0 replace, 1 add, 2 max, 3 min.
Every op carries a mask. The op's weight at a cell is scaled by what the mask
reads there — multiplied in, or subtracted out — so the op applies only where
the mask lets it and a cell the mask holds out keeps exactly what it already
held, at every blend mode. An op that names no mask writes at full strength
everywhere.
·computeshader · born here
❒asset # terrain_layer_weights
Evaluates a material layer stack's rules into two RGBA weight textures — layers
0-3 in one, 4-7 in the other, with the eight shares at a texel summing to one.
One thread per destination weight texel. The dispatch is per tile: a tile writes
the texels whose centres fall inside its interior, reading the field through its
own apron-inclusive buffer. Because every read is placed from the field minimum
and the texel size in world metres — never from the tile's own origin — the
texels one tile writes agree exactly with its neighbour's along the seam.
## The six inputs
| input | unit | where it comes from |
|---|---|---|
| `height` | metres, world Y | the `height` channel, bilinear |
| `slope` | degrees, 0 flat / 90 vertical | central difference of `height` over `gradientStep` metres |
| `curvature` | 1/metres, + gully / − ridge | Laplacian of `height`, magnitude ≈ 1/radius |
| `mask` | 0..1 | a bound `mask.<name>` channel, bilinear |
| `noise` | 0..1 | `terrain_fbm`'s gradient noise over world position |
| `channel` | the channel's own stored values | any bound field channel, bilinear |
`slope` and `curvature` are derived here rather than stored, so they cost no
channel and can never fall behind the ground they describe.
`mask` and `channel` both read a named channel through the same four slots and
the same bilinear tap; they differ in what the band is written against. A mask
channel holds 0..1, so its band is in that range. A `channel` band is in the
values the channel stores — cubic metres of deposited sediment, metres of water
depth, whatever the op that wrote it produces — with no rescaling on the way in.
That is what lets a layer put sand where the water actually dropped it and wet
shore where water actually stands.
## A rule is a band, whatever it reads
Every rule carries `min`, `max`, `softness` and `jitter` **in the unit of its own
input**. A layer covers the ground by its `strength` times the product of its
rules; a layer with no rules covers unconditionally. `invert` takes the
complement of the band, which is how "anywhere but here" is written.
`softness` is a feather half-width and `jitter` is how far noise is allowed to
move the value before it is banded — metres on a height rule, degrees on a slope
rule. Each rule draws its jitter from its own noise offset, so two rules on one
layer do not wander in step.
## The layers are a stack
Working from the top down, each layer takes its coverage share of whatever is
still showing. A cliff rule that fully passes therefore leaves nothing of the
grass under it, a layer at half coverage lets half the layer below through, and
whatever no layer claimed goes to layer 0 — the ground the stack stands on. The
eight shares add to one by construction, with nothing divided by a sum, and an
unclaimed texel is a surface rather than a hole.
Rules are numbered bottom-up across the whole stack and each layer reads its own
range of them, so covering downward moves no rule's noise offset.
## Bindings
`params` carries the whole stack: four head lanes, eight layer lanes, then three
lanes per rule. `height` is the field's height tile. `chan0`..`chan3` are the
field channels the stack's rules name, in sorted order — a slot no rule reads is
bound to the height tile and never sampled. Four is what the kernel declares; a
compute stage carries eight storage buffers and `params` and `height` take two,
so six is the ceiling a stack could be given.
`weightsLo` and `weightsHi` are `rgba8` storage targets, which is the widest
format a material slot can sample: a material reads every slot through a
filtering sampler, and the 32-bit float formats are not filterable.
·computeshader · born here
❒asset # terrain_mask
A soft-band rule over one feature of a raster, written into a named layer.
| input kind | value | unit |
|---|---|---|
| 0 height | the surface's own value | world metres |
| 1 slope | the angle of its gradient | degrees from flat |
| 2 curvature | its Laplacian — positive in a gully, negative on a ridge | 1/metre |
| 3 noise | gradient noise over world position | 0..1 |
| 4 curve | distance to a polyline's sampled control points | world metres |
A band is `min .. max`, feathered by `feather` — a soft half-width in the
input's own unit — on either edge; a side left open (`±1e29`) is not a bound
at all. `jitter` perturbs the sampled value before the band, by up to
`jitter` over a gradient-noise field sized `jitterSize`, so the boundary
wanders instead of tracing a contour. `invert` swaps what the band selects.
The `height`/`slope`/`curvature` inputs are derived from the raster's own
"height" layer, read through `op_source` at the same resolution as the layer
this op writes — one indexed read per cell, no resampling. `slope` and
`curvature` are central differences over `step` world metres.
The `curve` input reads a polyline's sampled control points off `op_payload`
— the same array `terrain.project` rides — and measures distance to it in
the ground plane; a control point's height plays no part in the distance.
`blend` decides how the band's weight meets what the layer already holds:
`replace`, `add`, `max`, or `min`. A layer written fresh is usually
`replace`; combining two masks into one accumulated layer is `add` or `max`.
The written value is a weight in 0..1 — a layer this op writes is what
`terrain.mask`'s `name` param names, and any other raster kernel op's own
`mask` param can reference it by that name, multiplied in or subtracted out,
through the generic `op_mask`/`op_common` contract every raster kernel op
already carries.
·computeshader · born here
❒asset # terrain_mesh
Builds one quadtree node's vertices from a height channel tile, in the engine's
23-word vertex layout, straight into the shared vertex pool the terrain draws
from. One thread per node vertex.
Positions are world space. Normals and tangents come from a central difference
over the field in the same pass, which is why the tile's apron has to hold real
data — a normal at a node edge reads a cell that belongs to the neighbouring
tile's interior.
Two edge behaviours make an LOD cut renderable:
- **Stitching.** `coarseMask` names the edges whose neighbour is one level
coarser. On such an edge the odd-indexed vertices snap onto the previous even
one, so the fine edge becomes the polyline through the coarse neighbour's own
vertices. The crack closes because the two edges are then the same line, not
because something covers it.
- **Geomorphing.** Over the far end of the distance band a node is a leaf
across, every vertex slides onto the position it would hold one level coarser.
By the time the cut merges the node into its parent, the node is already
drawing the parent's geometry, so the merge moves nothing. The morph runs off
each vertex's own distance to the camera rather than the node's, so a vertex
two nodes share is placed identically by both.
A node coarser than one field tile covers several of them, so it is dispatched
once per tile it overlaps and each dispatch writes only the vertices inside that
tile's half-open world rect. Every position the stitch and the morph can produce
stays inside the node, so the node's tiles cover its vertices exactly once.
·computeshader · born here
❒asset # terrain_project
A curve drives the ground: the points of the curve a geometry holds ride
`op_payload`, and every cell's height is banded by its ground-plane distance
to the resulting polyline — one dispatch, so the channel it cuts has no seam
where a chain of stamps would have met.
Inside `width`, falling to zero across `falloff` metres beyond it, a cell
blends toward a target height:
- `useCurveHeight` set: the curve's own y at the nearest point on the
polyline — a road or a riverbed that follows the curve's authored
elevation.
- otherwise: `fromHeight` .. `toHeight`, interpolated by how far along the
polyline (ground-plane arc length, 0 at the first control point, 1 at the
last) the nearest point falls — a ramp that runs the whole curve's length
in one dispatch instead of one `ramp` stamp per segment.
`blend` decides how the target height meets what the layer already holds:
`replace`, `add`, `max`, or `min`.
The polyline is open — segment `i` to `i+1` for every consecutive pair of the
curve's sampled points — and distance and arc length are both measured in
the ground plane; a point's height does not distort either.
·computeshader · born here
❒asset # terrain_scatter_density
Evaluates one scatter rule into a density channel: **instances per square
metre** at every cell of one tile.
The unit is what makes a rule portable. An author asks for one tree per fifty
square metres and gets that count whether the field is sampled at half a metre
or at four, and whether the terrain is 512 m across or 4 km.
## The rule inputs are the material stack's inputs
| Input | Where it comes from |
|---|---|
| height | the surface channel's own value, world metres |
| slope | the angle of its gradient, radians |
| curvature | its laplacian, 1/m — positive in gullies, negative on ridges and crests |
| mask | a co-registered raster, multiplied in or subtracted out |
| noise | gradient FBM over world position, so a forest edge is not a contour line |
Each band contributes a weight in `0..1` and the weights multiply. A rule that
sets one band and leaves the rest open is exactly that one band, so an author
who understands one understands all of them.
The density written is the peak density times the product of the weights.
## The region
`atX/atZ`, `radius` and `falloff` bound where the rule applies at all, in world
metres. A radius of **zero** covers the whole field — what a ground cover
wants. Outside the region the shader writes nothing, so a second rule's density
on that cell stands.
## The mask slot
`maskMode` picks what the mask raster does:
| mode | effect |
|---|---|
| 0 | multiply — the rule applies only where the mask is painted |
| 1 | subtract — the rule is kept OUT of where the mask is painted |
| 2 | none — the binding is not read |
Subtract is how an authored `place` op's `clearing` keeps a scattered forest
out of the ground a landmark stands on: the placement writes its footprint into
the `placement` channel and the scatter rule subtracts it.
A binding has to point somewhere, so at mode 2 the caller binds the height
channel into the mask slot and the shader never reads it.
One thread per cell, apron included. The cell's world position comes from the
field-wide cell index and the field's minimum corner, so a cell reached as one
tile's apron resolves to the same density as the same cell reached as its
neighbour's interior.
## The op contract's mask
This kernel carries no `op_mask` / `op_common`. Those bind the mask every op
that writes GROUND is scaled by, and this one writes a density channel — it has
the `mask` binding above, with modes and a gain of its own, which is the mask a
scatter rule reads. The two are separate surfaces on purpose.
·computeshader · born here
❒asset # terrain_scatter_place
Turns one tile's density channel into instance transforms in the shared pool
`renderer.mesh.drawInstanced` reads.
One thread per candidate **site**, not per cell.
## Sites are world-anchored
The sites are a grid of `spacing` world metres running from the field's minimum
corner. A site belongs to exactly one tile — the one its centre falls in — so
the same field produces the same sites whatever the tiling, whatever the
resolution, and however much of the field is being re-evaluated.
A site carries an instance with probability `density * spacing^2`. That is what
makes the density channel mean **instances per square metre**: summed over the
sites of an area, the expected count is the integral of the density over it.
One site carries at most one instance, so `1 / spacing^2` is the densest cover
a spacing can express. `terrainScatter` derives the spacing from the density
the rule actually reaches rather than letting a caller find that ceiling by
hitting it.
## Determinism
Acceptance, the jitter inside the site, the yaw and the scale are all hashes of
the site's integer grid indices and the seed. The instance a site carries is
therefore the same instance every run: a reload does not reshuffle a forest,
and re-evaluating one corner of the field leaves the rest of the population
exactly where it stood.
Slots in the pool are handed out by an atomic, so **which** slot an instance
lands in varies between runs. The set of instances and each one's matrix do
not.
## Overflow is a resize, not a short forest
When the accepted count runs past `capacity` the counter keeps counting and the
extra instances are not written. The total it reports is what the caller grows
the pool to before running the pass again, which is how a population that grew
past its reservation ends up complete rather than silently clipped.
## The matrix layout
Column-major 4x4, four `vec4` columns per instance, tightly packed — the layout
a vertex shader reads as `array<mat4x4<f32>>`. Columns 0..2 are the basis
scaled by the instance's scale; column 3 is the translation.
## The op contract's mask
This kernel carries no `op_mask` / `op_common`. Those bind the mask every op
that writes GROUND is scaled by, and this one writes instance transforms. Where
a population may grow is already decided by the density channel it reads, and
`terrain_scatter_density` is where a mask shapes that. The separation is on
purpose.
·computeshader · born here
❒asset # terrain_shape
Blends an analytic shape into one scalar channel of one tile.
One shader covers every placed primitive, selected by a mode lane:
| mode | shape | what it draws |
|---|---|---|
| 0 | disc | a circular pad held at one altitude |
| 1 | square | the same, measured in the Chebyshev metric |
| 2 | cone | a rounded hill, cosine profile, tangent to flat at the rim |
| 3 | ridge | that profile swept along a segment |
| 4 | ramp | an altitude interpolated along a segment |
| 5 | terrace | the existing height quantized into steps |
A negative height on a `ridge` cuts the same profile downward, which is how a
valley or a sunken road corridor is drawn.
The shape's coverage at a cell is a weight in 0..1 — full inside `radius`,
falling to zero across `falloff` metres beyond it — and that weight is what the
blend is applied through. A flatten therefore settles into the terrain rather
than stamping a cliff-edged plateau into it.
Every coordinate is world metres. A shape about a point puts that point in both
segment endpoints; a shape along a line puts the two ends in them. `radius` is
the half-width for the segment shapes. Because nothing here is in cells, the
same op covers the same ground at any resolution.
`blend` decides how the shape's value meets what the channel already holds:
`replace`, `add`, `max`, or `min`. The rounded shapes read best with `add`,
which lays a hill on top of the terrain instead of replacing it; the level
shapes read best with `replace`.
Every op carries a mask. The op's weight at a cell is scaled by what the mask
reads there — multiplied in, or subtracted out — so the op applies only where
the mask lets it and a cell the mask holds out keeps exactly what it already
held, at every blend mode. An op that names no mask writes at full strength
everywhere.
·computeshader · born here
❒asset # terrain_stamp
Blends a source texture into a scalar channel under a world-space transform.
The source is an ordinary `.texture`. That is the point: importing a real-world
elevation model and placing an authored shape are the same operation, so there
is one path for both and no separate importer.
Sampling runs in world space. Each destination cell's world position is carried
into the stamp's local square — translated to `at`, rotated by `rotation`,
scaled so the square spans `radius * 2` metres — and the texture is read there.
A cell outside that square is left untouched, so the op's declared extent is
exact and the evaluator dispatches only the tiles the stamp reaches.
`channelIndex` picks which of the texture's four channels carries the value;
`amplitude` scales it into metres and `bias` offsets it, so an 8-bit greyscale
heightmap in 0..1 becomes real elevation. `falloff` feathers the disc inscribed
in the square, which is what lets a stamp meet the surrounding terrain without
a seam.
Compare `terrain_layer`, which blends a raster that already shares the field's
grid and therefore needs no sampling at all.
Every op carries a mask. The op's weight at a cell is scaled by what the mask
reads there — multiplied in, or subtracted out — so the op applies only where
the mask lets it and a cell the mask holds out keeps exactly what it already
held, at every blend mode. An op that names no mask writes at full strength
everywhere.
·computeshader · born here
❒asset # terrain_water
Fills a water surface over a region of one tile, into the `water` and
`waterMask` channels.
One shader covers every water body, selected by a region-mode lane:
| mode | region | what fills |
|---|---|---|
| 0 | disc | a round body about a point — a lake |
| 1 | square | the same, measured in the Chebyshev metric |
| 2 | path | a body swept along a segment, its surface running from one end's level to the other's — a river |
| 3 | everywhere | the whole field — a sea |
A cell takes the surface only where the ground stands **below** it, so a body
fills the basins its region reaches and leaves ground that rises out of it dry.
That is the whole difference between a lake and a sea: the region an op is
applied to.
## The two channels are one thing
| channel | what it holds |
|---|---|
| `water` | the world-space Y of the surface covering that cell |
| `waterMask` | 1 where a surface covers the cell, 0 where none does |
A mask of 0 is what "no water here" IS — there is no height in the `water`
channel standing in for it, so no authored water level can ever be mistaken for
an absence. Everything that reads water reads the pair.
## Bodies meet through `blend`
Where two bodies reach one cell, the second meets the first through `blend` —
`replace`, `add`, `max` or `min`. The default `max` makes the meeting
independent of authoring order: the higher surface wins whichever op ran first,
so a lake in a coastal basin reads the same whether it was placed before or
after the sea.
Every coordinate and every level is world metres, so a body keeps its size, its
place and its altitude when the field's resolution changes.
Every op carries a mask. The op's weight at a cell is scaled by what the mask
reads there, and here the water mask channel is what carries that coverage: a
cell the mask holds out keeps both channels as they stand, and a partly covered
cell holds part of the surface it already had. An op that names no mask fills at
full strength everywhere.
·computeshader · born here
❒asset # terrain_water_kernel
Fills a water surface over a region of one tile, into the `water` and
`waterMask` layers of a `proc.raster` `Raster` — `terrainOps.water`'s
`lake`, `river` and `sea` share this one dispatch.
One shader covers every water body, selected by a region-mode lane:
| mode | region | what fills |
|---|---|---|
| 0 | disc | a round body about a point — a lake |
| 1 | square | the same, measured in the Chebyshev metric |
| 2 | path | a body swept along a segment, its surface running from one end's level to the other's — a river |
| 3 | everywhere | the whole raster — a sea |
A cell takes the surface only where the ground stands **below** it, so a body
fills the basins its region reaches and leaves ground that rises out of it dry.
That is the whole difference between a lake and a sea: the region an op is
applied to. The ground it reads is `op_source`, bound to the raster's
`height` layer.
## The two layers are one thing
| layer | what it holds |
|---|---|
| `water` | the world-space Y of the surface covering that cell |
| `waterMask` | 1 where a surface covers the cell, 0 where none does |
A mask of 0 is what "no water here" IS — there is no height in the `water`
layer standing in for it, so no authored water level can ever be mistaken for
an absence. Everything that reads water reads the pair.
## Bodies meet through `blend`
Where two bodies reach one cell, the second meets the first through `blend` —
`replace`, `add`, `max` or `min`. The default `max` makes the meeting
independent of authoring order: the higher surface wins whichever op ran
first, so a lake in a coastal basin reads the same whether it was placed
before or after the sea.
Every coordinate and every level is world metres, so a body keeps its size,
its place and its altitude when the raster's resolution changes.
Every op carries a mask. The op's weight at a cell is scaled by what the mask
reads there, and here the water mask layer is what carries that coverage: a
cell the mask holds out keeps both layers as they stand, and a partly covered
cell holds part of the surface it already had. An op that names no mask fills
at full strength everywhere.
·computeshader · born here
❒asset # terrain_water_mesh
Builds one node of the water surface from the `water` / `waterMask` channels,
into the same 23-word vertex layout and the same slot-addressed pool
`terrain_mesh` writes.
## The baked column
Alongside altitude, each vertex bakes the WATER COLUMN — how far the surface
stands above the bed — into the vertex colour's red channel (word 19):
`max(water - height, 0)` where covered, `0` where not, and a sentinel standing
for open ocean on a flat skirt vertex, which has no channel to measure
against. `terrainWater.shader` reads it back as an interpolated attribute
instead of reconstructing it from the scene depth buffer, which a plain
material draws with no depth copy behind it.
## Where a vertex's altitude comes from
| the cell under the vertex | y |
|---|---|
| covered by water | `water` — the surface itself |
| not covered | `min(water, height - sink)` |
The uncovered branch is what ends the surface at the shore. `height - sink` is
under the terrain by construction, so a vertex with no water above it is buried
whatever the water channel holds there; taking the lesser of the two keeps the
plane flat right up to the last covered vertex, so the waterline falls where
the plane and the ground cross rather than on a node boundary.
Coverage is read from the nearest cell rather than interpolated: a cell either
carries a surface or it does not, and half-covered is not a state the surface
has.
## The skirt
A node with `flat` set reads no channel and sits at `flatLevel`. That is the
outer skirt — the ring of nodes carrying the sea from the field's rect out to
the horizon, so an island has water all the way around it instead of stopping
at the grid. Skirt vertices meet the rect's edge vertices at exactly the sea
level, which is what makes the join seamless rather than merely close.
Because the skirt's edge pieces are as long as the field and as deep as the
horizon is far, a node here is a **rectangle**: `nodeSizeX` and `nodeSizeZ` are
separate.
## Stitching
Boundary stitching is the same rule as `terrain_mesh`: on an edge whose
neighbour is one level coarser, the odd boundary vertices snap onto the
previous even one. There is no geomorph — a level surface has nothing to pop.
# terrain guide
The terrain system's reference guide: a landscape composed in code as a procgen graph
— the `Raster` the `terrain.*` ops pass along, the source → shape → mask → realize
shape of a terrain graph, every op in the family, and a worked valley with a river.
Surfaced through `guides { query: "terrain" }` and
`guides { path: "systems/terrainSystem/documentation/terrain" }` (likewise `man`). The
content lives in `guide.md`; this README is the asset summary.
The hand-authoring face of the same system is the `terrain` toolbox
(`search_tools terrain`), which carries its own README.
# terrainSplat (material)
The eight-layer terrain surface. Blends three layer atlases — albedo, normal,
mask — by the two RGBA weight textures the landscape was baked with.
## You normally do not apply this material yourself
`terrainSurface.forLandscape(data)` instances it per landscape: the
`.terrainData`'s control textures bound as the weight maps, its rect as
`field_min` / `field_size`, its packed sheets as the three atlases, and its
layer table as the per-layer tints and tilings. What you apply is the handle
that call returns — and a `Terrain` component does even that for you.
This asset is the same shader with defaults, so it is inspectable and it is what
a hand-authored variant starts from. Applied by hand it has no weight textures
and no field rect, so it renders **layer 0 everywhere** — an unbound stack looks
unbound rather than looking broken.
## Properties
| Property | Unit | What it does |
|---|---|---|
| `field_min`, `field_size` | metres | the rect the weight textures cover; zero size means no weight map |
| `weights_lo_data`, `weights_hi_data` | — | layers 0-3 and 4-7, normalised to sum to one |
| `layer_atlas_albedo` / `_normal` / `_mask` | — | 4x2 atlases, layer N at (N mod 4, N div 4) |
| `atlas_size` | texels | atlas dimensions, for the half-texel inset between cells |
| `layerN_tint` | — | multiplies layer N's albedo sample |
| `layerN_size` | metres | world metres one repeat of layer N's texture covers |
| `weight_sharpen` | — | 1 is the stack as evaluated; higher narrows every transition without moving it |
| `height_blend` | — | 0 blends by weight alone; higher gives a contested pixel to whichever layer's mask height stands proudest |
| `normal_strength` | — | scales the blended tangent-space XY; 0 is flat |
| `smoothness`, `metallic` | — | scale the mask atlas's alpha and red |
## The mask atlas
Each cell is a mask MAP in the usual channel order: **R metallic, G ambient
occlusion, B height, A smoothness**. White is the neutral one — no occlusion, a
level height that leaves `height_blend` with nothing to reorder, and metallic
and smoothness left to the two scalars.
The height channel is what makes a transition look like two materials meeting
rather than a crossfade: where the weights call a tie, `height_blend` gives the
pixel to the layer standing proudest there, so sand settles into the gaps
between cobbles instead of fading evenly across their faces. Layers at equal
height are attenuated equally, so a white atlas comes through unchanged.
## Three atlases, not twenty-four slots
A slot per layer per map would put twenty-four sampled textures in one bind
group, which is past what WebGPU guarantees a shader stage. The atlas keeps the
cost at three whatever the layer count is. `terrain.write` packs the sheets when
it bakes a landscape — a layer names its own `albedo` / `normal` / `mask`
textures and they are resampled into that layer's cell — so the packing is not
something an author does by hand.
A stack with no atlas at all renders its eight `layerN_tint` colours, which is
enough to see every rule working.
# terrainStandard (material)
Built-in default material for the terrain system. Uses the
`~terrainSystem.shaders.terrainBlend` shader, which renders a four-layer
surface blended by altitude and slope, with a procedurally-detailed
cliff override for steep faces.
## Layers
| Slot | Source field | Default | Role |
|------|--------------|---------|------|
| 0 (valley) | `colors.base_color` | green | Low ground, grass, valley floor. |
| 1 (mid) | `colors.layer1_color` | tan | Mid-band: dry grass / scrub between grass and rock. |
| 2 (high) | `colors.attenuation_color` | grey | High band: rock, exposed cliff face. |
| 3 (peak) | `reflectance` (engine default ≈ white) | white | Peaks: snow, ice. |
## Tunables (re-purposed StandardMaterial slots)
| Material field | Shader role |
|----------------|-------------|
| `parallax_depth_scale` | `layer1_height` — Y where layer 1 mid-band peaks. |
| `max_parallax_layer_count` | `layer2_height` — Y where layer 2 rock takes over. |
| `lightmap_exposure` | `layer3_height` — Y where layer 3 snow takes over. |
| `normal_scale` | `blend_width` — width of each altitude transition. |
| `clearcoat` | `slope_start` — cos-θ where cliff blend begins. |
| `clearcoat_roughness` | `slope_end` — cos-θ where cliff fully overrides. |
| `anisotropy_strength` | `detail_scale` — boundary-breakup noise frequency. |
| `anisotropy_rotation` | `(detail_amp, macro_scale)` — boundary amp + macro tiling. |
Authors who want different colours / heights just override the
respective `floats.` / `colors.` blocks in their own material.
## Customizing per terrain
```lua
Material.Create("alpine_meadow", "~terrainSystem.shaders.terrainBlend", {
base_color = { 0.18, 0.45, 0.22, 1 }, -- meadow green
emissive = { 0.35, 0.42, 0.30, 1 }, -- scrub
attenuation_color = { 0.55, 0.55, 0.55, 1 }, -- granite
parallax_depth_scale = 12, -- layer1 (scrub) peak
max_parallax_layer_count = 24,-- layer2 (rock) onset
lightmap_exposure = 38, -- snow onset
clearcoat = 0.7, clearcoat_roughness = 0.3,
})
```
## Caveats
- This iteration paints **solid colours per layer**. Texture sampling
per layer is a planned extension — swap the `mix(albedo, layerN_color, wN)`
in the shader for a tiled `textureSample(layerN_texture, ...)` and
add the texture slots to the material schema. Tracking issue:
filed alongside the terrain system PR.
- Author-controlled splatmap (an RGBA texture that paints which layer
is at each pixel) is similarly planned. The current altitude/slope
blend is procedurally driven only.
# terrainWater
The material a terrain field's water surface draws with — lakes, rivers, the
sea, and the skirt that carries the sea past the field's rect.
It is a plain transparent PBR body of water with no simulation behind it, which
is what makes it correct at every scale the surface is used at: a pond, a river
reach and a horizon all read the same. `terrainSystem.spawn` applies it to a
terrain's water entity unless `waterMaterial` names another.
It draws in the transparent queue after the terrain, with both faces, so the
ground under the surface is already in the frame and the same material is the
underside once the camera goes below the waterline.
Reach for `@builtin::systems.oceans` instead when the water is the subject —
that package simulates a spectrum, whitecaps and shore foam, and wants an ocean
rather than a terrain's water table.
# terrainHeightfield
A `.texture` standing over a rect of world, read as ground.
The durable form of a landscape is an ordinary texture asset — one with a guid,
that a component references, that travels with a world, that the renderer
already knows how to sample. `terrain.to_texture` is what puts a graph's raster
into one and `texture.write` persists it. This module is the other end: what a
runtime holds while that ground is on screen.
```lua
local terrainHeightfield = require("~terrainSystem.modules.terrainHeightfield")
-- A `.terrainData` states the rect it covers, so opening one needs nothing else.
local hf = terrainHeightfield.open("/zero/source/terrain/dunes.terrainData")
-- The heightfield alone can be read too; then the caller says where it stands.
local bare = terrainHeightfield.open(tex, { size = { 4000, 4000 }, origin = { 0, 0 } })
local y = hf:heightAt(120, -60) -- metres
local nx, ny, nz = hf:normalAt(120, -60)
local slope = hf:slopeAt(120, -60) -- radians
hf:close()
```
## The texels
`rgba32f`, in the layout `terrain.to_texture` writes:
| Channel | Holds |
|---|---|
| R | the height, in METRES — it goes negative |
| G | the gradient along world X |
| B | the gradient along world Z |
| A | coverage |
A height's interesting relief is often a few metres inside a range of hundreds,
so eight bits band it into terraces; that is what the precision is for. The
gradient is what a surface normal comes out of without differencing the height
four more times, and it is why `terrain_mesh` costs four texel reads a vertex
rather than twenty.
A heightmap authored anywhere else — an 8-bit image, a downloaded elevation
tile, a raster an earlier graph baked — becomes one of these by going through a
graph: `terrain.from_texture` reads it as ground, and `terrain.to_texture`
writes the result back out at this precision.
## Two carriers, one set of texels
An open heightfield holds the texture in both forms the runtime reads it in:
- **`hf.texels`** — a GPU buffer of the texel bytes, which the
`terrain_mesh` compute kernel builds vertices out of.
- **`hf.cpu`** — the pixels, which `heightAt` / `normalAt` / `slopeAt` /
`sampleGrid` answer from.
Both are the same texels, uploaded from one read, so what the ground LOOKS like
and what a query SAYS about it cannot drift apart.
## The surface
| Verb | |
|---|---|
| `open(ground, opts?)` | Read a `.terrainData` — or the heightfield `.texture` alone — as ground. A container carries its own `{ size, origin, heightRange }`; a bare texture takes them from `opts`, and an explicit opt outranks the container either way. |
| `hf:heightAt(x, z)` | Bilinear metres. |
| `hf:normalAt(x, z)` | Unit normal, three numbers, from the carried gradient. |
| `hf:slopeAt(x, z)` | Radians; 0 is flat. |
| `hf:sampleGrid(cols, rows)` | Row-major heights over the whole rect — the shape a heightfield collider takes. |
| `hf:hasGroundAt(x, z)` | Whether there is ground at all — false over a hole. |
| `hf:refresh()` | Read the texture's pixels again and put them back on the GPU. Bumps `gen`. |
| `hf:close()` | Let go of the pixels and the GPU buffer. The asset is untouched. |
`heightRange` is the vertical reach the geometry declares, which becomes the
mesh's bounding box — a range smaller than the ground culls the terrain away at
grazing angles. Left out, it is read off the pixels, which costs one pass over
them.
## Holes
A landscape can be CUT OUT — a cave mouth, a quarry, a shaft — and its holes
mask is carried the same two ways the heightfield is: on the GPU for the index
kernel, which writes the quads it cuts out as degenerate triangles, and on the
CPU for `hasGroundAt`. Same pixels, so a place with no surface to draw is a
place nothing can be stood on either, and scatter that asks before it places
never drops a tree into a shaft.
A landscape with no mask is solid everywhere and answers `hasGroundAt` true
without reading anything. The edge of a hole follows the mask at the density the
node is meshed at, so a hole is quad-shaped at the LOD it is seen from.
## Generations
`hf.gen` counts the times the texels have been read. `terrainMesh` records the
generation each node was meshed at, so re-authoring the ground under a standing
terrain and calling `hf:refresh()` brings the geometry with it — nobody has to
say which nodes changed.
# terrainMesh
Puts a heightfield on screen as **one draw**.
## What it is
A vertex **pool** of `maxNodes` slots, each a fixed `nodeVerts` square grid
(33x33 by default). A quadtree **cut** decides which parts of the ground are
drawn at which density: near nodes are small and dense, far nodes large and
sparse, and every node meshes to the same grid, so a node costs the same
wherever it is and the cut stays a few hundred nodes at any camera distance.
`terrain_mesh` fills the slots the cut needs; `terrain_index` writes the index
run over them; `renderer.mesh.create { vertexBuffer, indexBuffer }` turns the
pair into one mesh. One entity carries one `Model` bound to it, so the whole
terrain — every node, at every level — is one indexed draw.
**Nodes are never entities.** Entity count is the binding constraint on this
renderer; a per-chunk entity would put a hundred draws where this puts one. A
step that seems to need one is a step that has the design backwards.
A cut change calls `renderer.mesh.update` under the **same guid**, so the Model
is never re-bound and the scene graph is never touched.
## Using it
```luau
local terrainHeightfield = require("~terrainSystem.modules.terrainHeightfield")
local terrainMesh = require("~terrainSystem.modules.terrainMesh")
local hf = terrainHeightfield.open(groundTexture, { size = { 2048, 2048 } })
local mesh = terrainMesh.create(hf, { camera = terrainMesh.cameraPosition() })
terrainMesh.updateCut(mesh, hf, { x = 900, y = 30, z = 900 })
```
The `Terrain` component does all of this from a `.texture` on an entity and
follows the on-screen camera with the cut every frame, in edit and in play
alike. Reach for the module directly only when the ground is not an entity's.
## The vertices
`terrain_mesh` writes the engine's 23-word vertex layout, one thread per node
vertex, positions in WORLD space — so the entity carrying the mesh stands at the
world origin with no transform of its own.
It reads the heightfield's texels as one array over the whole rect. The height
is bilinear across the four texels around a position, and the normal comes from
the gradient those same four texels already carry — four reads a vertex rather
than the twenty a central difference of the height would cost.
## Holes
`terrain_index` writes one node's triangles, and a quad whose ground the
landscape cuts out is written DEGENERATE — six copies of one index — rather than
left out. The run is a fixed stride per node, so a quad that vanished would
shift every quad after it; a triangle whose three corners are one point covers
no pixels and is discarded before it rasterises. A hole therefore costs the
index slots it already owned and nothing else.
The quad's own centre decides, so the edge of a hole follows the mask at the
density the node is meshed at. The run carries the heightfield's generation as
well as the slot order, so a mask re-read under a standing terrain moves the
hole with it.
## Cracks and popping
Two things happen at node edges, and both are in `terrain_mesh`:
- **Stitching.** The cut is *balanced* — no leaf borders a neighbour more than
one level coarser — and each node carries the mask of which of its four edges
do. On such an edge the fine node's odd-indexed vertices snap onto the
previous even one, so the fine edge becomes the polyline through the coarse
neighbour's own vertices. The two edges are then the same line.
- **Geomorphing.** Over the far end of the distance band a node is a leaf
across, its vertices slide onto the positions they would hold one level
coarser. By the time the cut merges the node into its parent, the node is
already drawing the parent's geometry. The morph runs off each vertex's own
distance to the camera, so a vertex two nodes share is placed identically by
both.
## What re-meshes, and when
A node is re-meshed when it is new to the cut, when its coarse-neighbour mask
changes, when the camera moves (the geomorph is camera-relative), or when the
heightfield's `gen` has moved past the generation that node was last built at.
Nothing else touches it — `updateCut` from a viewpoint the cut was already taken
from remeshes zero nodes and reports `changed = false`.
`terrainMesh.invalidate(state, rect)` marks the nodes overlapping a world rect,
for when something changed the ground in one place.
## Nodes that are not drawn
`create` takes a `keep` predicate — `(node) -> boolean` — and the cut then
covers whatever it keeps rather than the whole rect. That is what lets the
terrain END before the grid does: an island's seabed is ground nobody can see,
and ground nobody can see is ground nothing has to mesh.
The predicate is a parameter of selection rather than a filter applied
afterwards, because the node budget is spent on what is **drawn**: a cut that
coarsened to fit nodes it then threw away would be spending the budget on
seabed. It runs after the tree is balanced, so what it drops cannot unbalance
what remains, and a node whose coarser neighbour was dropped has nothing on
that side to crack against.
## Ground that is not square
The quadtree tiles the rect with square **root** nodes. A square rect is one
root; an oblong one is a row or grid of them, sized by `rootSize`, which
defaults to the shorter side. A rect whose sides are not a whole number of
`rootSize` squares is refused by name — pass a `rootSize` that divides both.
## The bounding box
The mesh's box is the heightfield's rect by its declared `heightRange`. It is
taken from the declaration rather than reduced from whatever is drawn, because a
box that is too small culls the terrain away at grazing camera angles and the
symptom is nothing at all on screen.
## Entity binding
`terrainMesh.bind(entityId, hf, state)` records that an entity's geometry IS this
heightfield drawn through this pool. `forEntity` reads it back, `isBound` asks,
and `unbind` releases the mesh, the pool and the heightfield's pixels together.
`followCamera(entityId)` is the per-frame entry the `Terrain` component calls.
# terrainOps
The `terrain.*` procgen op family: a whole landscape (a valley, a mountain
range, an island ending in a coastline, a plateau cut into terraces) built as
a procgen graph.
`M.ops` is the family, keyed by registry id. `procPacks/terrain.procPack`
returns it, and that asset's registration is what puts every op in the proc
registry, the same `.procPack` path any author outside this package uses to
contribute a family. The procgen toolbox's `searchNodes` lists them and `describeNode`
gives one's inputs, params and outputs. The nodes a graph calls as `g.terrain.<name>`
are this package's `terrain.nodebox`.
| Movement | Ops |
|---|---|
| source | `terrain.source`: the rect of ground, in metres, at a resolution |
| shape | `terrain.fbm` · `terrain.cone` · `terrain.ridge` · `terrain.ramp` · `terrain.flatten` · `terrain.terrace` · `terrain.stamp` · `terrain.layer` · `terrain.project` · `terrain.erode` · `terrain.coast` · `terrain.sea` · `terrain.lake` · `terrain.river` |
| mask | `terrain.mask` writes a named layer; every raster kernel op reads one through `mask` / `maskMode` / `maskGain` |
| bake | `terrain.write`: the whole landscape as a `.terrainData`: heightfield, control textures, holes mask, layer table |
| carry | `terrain.to_texture` · `terrain.from_texture`: one layer in and out of a `.texture` |
| stand | `terrain.scatter` · `terrain.drape`: points onto the surface, and anyone else's points back down onto it |
The value every op takes and returns is a `proc.raster` **`Raster`**: a tiled
GPU grid carrying named scalar layers (`height`, `water`, `waterMask`, and
whatever `terrain.mask` names).
## What is in here
| File | Role |
|---|---|
| `init.luau` | Assembles the family into `M.ops`. Owns `terrain.source`. |
| `shape.luau` | The placed shapes: `flatten`, `cone`, `ridge`, `ramp`, `terrace`. |
| `noise.luau` | `terrain.fbm`: multi-octave, optionally domain-warped fractal relief. |
| `mask.luau` | `terrain.mask`: a soft band over one feature, written to a named layer. |
| `project.luau` | `terrain.project`: a curve driving the ground by distance to its polyline. |
| `surface.luau` | `layer`, `stamp`, `coast`: an external source blended into height. |
| `water.luau` | `lake`, `sea`, `river`: one water body over three regions. |
| `erosion.luau` | `terrain.erode`: droplet and thermal erosion batched over the tiles. |
| `convert.luau` | `to_texture`, `from_texture`: a raster layer and a texture are the same field in two carriers. |
| `write.luau` | `terrain.write`: the whole landscape as one `.terrainData` container. |
| `scatter.luau` | `terrain.scatter`, `terrain.drape`: a point cloud off the ground, and points put back on it. |
| `scene.luau` | `terrain.ground`: the ground the scene's Terrain entities stand on, which `drape` sets points on. |
| `curve.luau` | A `Curve`'s control points flattened for the payload binding, shared by `project` and `mask`. |
| `lanes.luau` | The eight vec4 dispatch lanes a placed-shape kernel's numbers are packed into. |
| `modes.luau` | The shader numberings the ops declare. |
`M.packers` and `M.payloads` expose each kind's dispatch numbers with no GPU
dispatch and no readback, which is what a test verifies the maths through.
## Out of the graph
A landscape leaves as a `.terrainData`: the heightfield, the control textures
saying which surface covers where, the holes mask, the water surface and the
table of what those surfaces are, held together as one asset. `terrain.write`
takes the raster, a `name`, and the `layers` (the mask layer names holding each
surface's coverage, in channel order), and answers the reference to the container.
Its children are ordinary `.texture` assets written inside the container, so
nothing here is a private format and a material samples a control texture the
way it samples any texture. A `Terrain` component standing an entity on that
container is what puts the ground in a scene.
`terrain.to_texture` carries ONE layer out on its own, for a raster that is
feeding something other than a landscape; `terrain.from_texture` is the way back
in, and it is how a heightmap made anywhere else becomes ground these ops can
carve.
# terrain
Ground as nodes. `terrain.source` opens a rect of ground as a tiled raster with a `height` layer; every other node
takes that raster, changes it and hands it on.
| Part | Nodes |
|---|---|
| the ground | `source`, `from_texture`; a landscape asset comes in through `io.import` |
| landforms | `flatten`, `cone`, `ridge`, `ramp`, `terrace`, `coast`, `layer`, `stamp`, `project`, `fbm` |
| rules | `mask` writes a soft band over height, slope, curvature, noise or a curve into a named layer; any node's `mask` input names it |
| water | `lake` (floods the seed's basin until it is dry), `sea`, `river` |
| weather | `erode`: droplets walked on the device with integer sums, or thermal relaxation |
| standing on it | `scatter`, `drape` |
| out of the graph | `landscape` (one element standing as the ground, the `Terrain` component on it), `write` (the same landscape saved as a named `.terrainData`), `to_texture` |
The landforms are one pattern: a coverage weight (`footprint`), a profile of the distance (`dome`) or a value, and
`core.raster.blend`. Parts the nodes share: `footprint`, `dome`, `mask_weight`, `nearest` (the nearest point of a
curve), `surface` (height, normal and slope under a position), `bilinear` (a grid of samples read between the four
around a point), `texel` and `image` (a layer read per texel of an image, and the image's texels), `dimensions`, `wet`
(water into a region), `control` (normalized surface weights packed as 8-bit words).
The raster holds more ground than its rect: the tiles store the cells of a border tile past the rect and a ring of
cells around the tiles, and every landform and every blend writes them at their own world positions, as the tiles of
the ops did. A node that reads at an offset (`cell` with `dx`, `dz`, the slope and curvature of `mask`, thermal
`erode`, the walk of a droplet) reads those cells, so ground beside the edge slumps, drains and washes into them.
The water floods (`lake` fill) stay inside the rect.
A call answers a node's output directly when it has one (`g.terrain.cone({ ... })` is the raster) and a record when it
has several (`g.terrain.footprint({ ... }).weight`).
`nearest` holds the module that reads a curve's polyline. `landscape` and `write` name the landscape record kind
(`records/terrain.procRecord` of this package), whose module declares the surfaces and writes the container.
The noise terrain's relief and masks are made of is the noise prelude of `kernels/` (`zero_angle_fbm2` and
`zero_angle_warped2` for `fbm`, `zero_proc_gradient` for the noise mask), called through `field.call`. Erosion droplets are a step Field handed to the `walk` kernel,
their changes summed with the `scatter` kernel.
# Landscape (procNode)
The last node of a terrain graph: the ground as one element. It is
`terrain.landscape` under the name a graph ends a landscape with: the raster goes
in, and one point comes out carrying the `Terrain` component, its `terrain` a
landscape record holding the heightfield, the control textures, the holes mask,
the water surface and the table of what the surfaces are.
`io.output` stands the element as an entity with the ground, its water and its
colliders, and `io.export` saves it with its `.terrainData` inside the asset it
writes. A terrain among other elements is merged and parented like any element.
## Inputs
- `raster` (Raster): the ground.
- `name` (String, default `"landscape"`): the entity's name, the element's `name`.
- `height` (String, default `"height"`): the raster layer written as the
heightfield.
- `water` (String, default `"water"`) and `waterMask` (String, default
`"waterMask"`): the raster layers holding the water surface and where it covers.
- `layers` (String, default empty): the surfaces, in channel order, as the names
of the raster layers holding their coverage, separated by commas:
`"sand, grass"`.
- `surfaces` (List of records, optional): what each surface in `layers` is
shaded with, `{ name, tint?, albedo?, normal?, mask?, tiling? }`, matched to a
layer by `name`.
- `holes` and `solid` (String, default empty): a mask layer marking where ground is
cut out, or where it exists.
- `resolution` (Int, default 0): texels across the longer axis; 0 takes one texel
per raster cell.
- `layerResolution` (Int, default 0): the edge of one layer slice; 0 takes the
largest edge any layer map carries.
- `fields` (Record, optional): fields of the `Terrain` component beside the
ground: `material`, `waterMaterial`, `collision`, `collisionResolution`, `size`,
`origin`, `heightRange`, `water`.
## Output
- `terrain`: one element carrying `Terrain`, standing as a landscape.
# terrain
Ground, as procedural operations. `terrain.source` opens a rect of ground as a
tiled `Raster` with a `height` layer; every other op in the family takes that
`Raster`, changes it, and hands it on — so a valley, a mountain range, an
island and a coastline are all the same graph with different nodes in it.
`terrain.write` carries the result back out — into a `.terrainData` that a
`Terrain` component stands an entity on.
Every op is an ordinary procgen op, so any graph can reach them and they
compose with the rest of the registry: the shaping ops take a `Raster` and hand
one on, and the ops that stand things on the ground hand back points.
## Ops
**The ground**
- `terrain.source` — the rect a terrain graph starts from: a tiled raster with
a height layer, at a chosen resolution.
**Shapes and noise**
- `terrain.fbm` — fractal noise over the height layer; ridged or plain.
- `terrain.flatten` — level the ground inside a placed shape.
- `terrain.cone` — a cone or crater at a placed centre.
- `terrain.ridge` — a raised or cut ridge along a placed line.
- `terrain.ramp` — a graded slope between two heights.
- `terrain.terrace` — quantize height into steps.
**Rules and blending**
- `terrain.mask` — write a soft band over one feature (height, slope, or
distance to a curve) into a named layer.
- `terrain.project` — band height by distance to a curve.
- `terrain.layer` — blend a painted layer into height.
- `terrain.stamp` — blend a texture into height at a placement.
- `terrain.coast` — pull the ground outside a landmass down to the shore.
**Water**
- `terrain.lake` — fill a basin to a water level.
- `terrain.river` — carve and fill a channel along a curve.
- `terrain.sea` — flood everything below a level.
**Erosion**
- `terrain.erode` — hydraulic (droplet) or thermal erosion, batched over the
raster's tiles.
**Standing things on it**
- `terrain.scatter` — points across the surface, weighted by a mask layer and
banded by slope and height, each carrying the normal and slope under it.
- `terrain.drape` — anyone else's points put down onto the ground: a graph's
own `raster`, or the scene's `ground`.
- `terrain.ground` — the ground the scene's Terrain entities stand on, as a
graph value read afresh every frame. Wired into `terrain.drape`, points land
on it wherever the graph stands, each marked `grounded`, and a Generator sets
them again when the ground is rebuilt or the Generator moves.
**Out of the graph**
- `terrain.write` — the whole landscape as a `.terrainData`: the heightfield
(`rgba32f`, R metres, G/B gradient), the control textures saying which surface
covers where (`rgba8`, four layers a texture, weights normalized to sum to
one), the holes mask, and the table of what those surfaces are.
- `terrain.to_texture` — ONE layer as a Texture on its own, for a raster feeding
something other than a landscape.
- `terrain.from_texture` — the way back in, and how ground made anywhere else
becomes something these ops can carve.
```luau
g:node("bake", "terrain.write"):input("raster", ground)
:param("name", "dunes"):param("layers", { "sand", "rock" })
```
Then a `Terrain` component standing on that landscape is what puts it in a scene.
# landscape (procRecord)
The landscape record kind: the ground a raster becomes, with every texture it needs read back from the device (the
heightfield, the holes mask, the water surface and its shade, one control texture per four surface layers), the
surfaces it declares, and the rect it covers. `terrain.landscape` answers one, held on one element as
`Terrain = { terrain = <record>, ... }`.
Live, a record becomes a `.terrainData` written in the session's scratch folder, the asset the `Terrain` component's
`terrain` field stands on; it is removed once nothing stands on it. Saved, it is a `.terrainData` container with its
textures as `.texture` children inside it, written inside the folder of the asset being exported. `terrain.write`
saves one under the name and folder it is given.
`landscape.module` is the writer both use: `declare` states the surfaces a layer list names, and `writeLandscape`
writes the container.
# layeredMaterial
Compiles a stack of materials into one WGSL surface shader.
```luau
local layeredMaterial = require("@builtin::systems.layeredMaterial.layeredMaterial")
```
| Call | What it does |
|---|---|
| `read(materialRef)` | Reads a layer's inputs off a material that already exists: `{ material, shader, values, textures, unsupported }`. |
| `compile(stack, name?)` | Returns `{ name, wgsl, properties, layers, textureSlots, values, textures, unsupported }` — the two files a `.shader` asset holds, plus what a material wearing it has to be given. |
| `check(stack)` | `compile` with the error handed back: `(true, result)` or `(false, message)`. |
| `install(stack, opts?)` | Writes the compiled stack out as a real `.shader` asset. `opts.name` names it; `opts.into` places it as it is created. |
| `material(stack, opts?)` | Installs, then makes a material wearing the shader and writes every property the stack declares onto it, from the stack where it says one and from the declared default where it does not. `opts.values` is written last, over them. |
| `property(index, input)` | The property name a layer's input is declared under — `l<index>_<input>`, counting the bottom layer as 1. |
| `inputs()` | The vocabulary: `{ values, textures, coverage, mask, surface }`. |
| `limits()` | `{ maxLayers, textureSlots }`. |
## A stack
```luau
{
name = "worn_barrel",
layers = {
{ material = "@builtin::materials.default" },
{
material = "@builtin::materials.gold",
blend = 0.6,
mask = "@builtin::systems.terrainSystem.demo.textures.rock_diff",
maskChannel = "r", -- or a { x, y, z, w } selector
maskContrast = 2,
instanceLane = 0,
instanceComponent = 0,
uvScale = { 4, 4 },
uvOffset = { 0, 0 },
},
},
}
```
The bottom layer is first, and the coverage controls belong to a layer laid over
it: writing one on the bottom layer raises. A layer names a `material`, or writes
its inputs directly as `values` and `textures`, or does both — written values win
over the material's, which is how a stack lays a material over another and still
says "but rougher here". A layer read off a material takes the maps that material
binds; a slot it leaves on a built-in fallback (`default:white`) is a slot it
binds nothing to.
## Coverage
A layer past the first is revealed by the product of three terms:
| Term | Property |
|---|---|
| Mask texture, one channel of it, shaped by a contrast | `l<i>_mask_texture`, `l<i>_mask_channel`, `l<i>_mask_contrast` |
| Per-material amount | `l<i>_coverage` |
| Per-instance amount, one lane of the entity's shader data | `l<i>_instance_lane`, `l<i>_instance_component` |
`renderer.instanceData.set(entity, lane, amount)` writes that lane, one entity at
a time, so one stack over many entities still reveals its top layer by a
different amount on each.
A layer written without a mask declares neither mask property nor mask slot, so
the mask term is absent from the shader rather than neutral in it.
## Bounds
`limits().maxLayers` layers, `limits().textureSlots` texture slots between them.
Slots are spent on the maps the layers actually bind: a layer of uniform values
alone spends none. A stack past the budget is refused, naming what each layer
spent.
## Shading
The layers are blended as shading inputs and shaded once. A fully covered layer
therefore renders as that layer alone, and a half-covered surface as half of
each.
# materialGraph
Compiles a material node graph into the engine's WGSL surface DSL.
```luau
local materialGraph = require("@builtin::systems.materialGraph.materialGraph")
```
| Call | What it does |
|---|---|
| `compile(graph, name?)` | Pure. Returns `{ name, wgsl, properties, nodes, unused }` — the two files a `.shader` asset holds, the nodes emitted, and the nodes the terminal could not reach. |
| `check(graph)` | `compile` with the error handed back: `(true, result)` or `(false, message)`. |
| `install(graph, opts?)` | Writes the compiled graph out as a real `.shader` asset. `opts.name` names it; `opts.into` places it as it is created, and installing again writes wherever the asset already is. |
| `material(graph, opts?)` | Installs, then makes a material wearing the shader. `opts.values` sets the material's own property values — on the first call and on every later one, so calling it again is how a graph is iterated on. |
| `nodeTypes()` | Every operation, with the inputs a node of it is written with, which of those it cannot be written without, its result type and a line of prose. |
| `surfaceChannels()` | Every channel of the terminal, and the shading-model field it drives. |
## Inputs
A port takes any of:
| Written as | Means |
|---|---|
| `0.5` | A float constant. |
| `{ 1, 0.5, 0.25 }` | A vector constant, 2 to 4 components. |
| `"other"` | The output of the node named `other`. |
| `{ node = "other", swizzle = "rgb" }` | Part of that node's output. |
| `{ op = "fresnel", power = 3 }` | A node written where it is used. |
`swizzle` reads part of whatever the table it is written in resolves to — a node
reference, an inline node, or a constant vector. A table carrying both an `op`
and a `node` is refused: a port says where its value comes from once.
## Types
`float`, `vec2`, `vec3`, `vec4`. A node's type is worked out from its inputs. In
an arithmetic node the widest input decides the result and a
float input is broadened to it; two inputs of different vector widths are
refused, naming the node. At the terminal a float broadens into a colour
channel, and a wider value is refused with a note to swizzle it.
Every number a graph carries is written into the shader as an `f32`, so one past
what an `f32` holds is refused where it is written.
## Operations
| Group | Ops |
|---|---|
| Sources | `uv` `lightmapUv` `affineUv` `worldPosition` `worldNormal` `worldTangent` `viewDirection` `viewPosition` `vertexColor` `time` |
| Values | `constant` `property` |
| Textures | `sampleTexture` `normalMap` |
| Arithmetic | `add` `subtract` `multiply` `divide` `min` `max` `pow` `step` `mix` `clamp` `smoothstep` |
| Per-component | `abs` `floor` `ceil` `fract` `sign` `sin` `cos` `sqrt` `exp` `log` `saturate` `oneMinus` `negate` `normalize` |
| Vector | `length` `distance` `dot` `cross` `combine` |
| Shading | `fresnel` |
## Properties
`graph.properties` holds the same declarations a hand-written `properties.yaml`
carries: `float`, `range` (with `min` / `max`), `float2`, `float3`, `float4`,
`color`, `int`, `bool` and `texture`. A `property` node reads one, and
`sampleTexture` / `normalMap` sample a `texture` one through the texture and
sampler pair the engine generates for it. `int` and `bool` are converted where
they are read, so they compose with everything else.
A `texture` declaration becomes a global of its own name in the interface the
engine generates, so `s`, `input` and `material` — the names the generated body
already stands on — and anything starting with `mg_` are refused as texture
names.
## The terminal
`graph.surface` names channels of the shading model:
`albedo` `normal` `metallic` `roughness` `emissive` `occlusion` `alpha`
`alphaCutoff` `ior` `reflectance` `clearcoat` `clearcoatRoughness` `anisotropy`
`transmission` `thickness` `attenuationColor` `attenuationDistance` `sheenColor`
`sheenRoughness` `iridescence` `iridescenceThickness` `iridescenceIor`
`subsurfaceColor` `subsurfaceRadius` `fibreColor` `fibreSpecular` `fibreShift`
`fibreRoughness`
A channel left unset keeps the engine's own neutral default, so a graph names
only the channels it has something to say about.
# Generator
A live procedural generator: it binds a `.procGraph`, plays the graph's
timeline and stands each of the graph's outputs in the scene as children of
the entity it is on. Change a param and the output is made again in place; in
edit and in play alike, nothing it makes is written to the world's source.
```lua
local graph = asset.create("procGraph", "crate") -- a box with a `size` input; edit its init.luau
local e = entity.spawn("crate")
e.component.add("Generator", { graph = graph })
e.component.get("Generator").params = { size = 2 }
```
## Fields
| Field | What it does |
|---|---|
| `graph` | The `.procGraph` it plays. |
| `params` | Overrides for the graph's declared inputs, by name. |
| `output` | One declared output to stand; empty, the default, stands every output, one root each. |
| `playOn` | `"auto"` (default) plays a timeline with no end as soon as it binds and holds any other on its first frame; `"load"` plays on bind; `"manual"` waits for `play()`. |
| `ending` | `"graph"` (default) does what the timeline declares; `"loop"` plays the range round and round; `"hold"` plays it once and stays on its last frame. |
| `speed` | How fast its clock runs: 1 its own rate, 0 still, negative plays a timeline with an end backwards. |
| `diskCache` | Whether the outputs the graph caches load from, and are written to, the world's disk cache. On by default. |
| `cookBudgetMs` | Milliseconds of cook work a frame before a cook yields to the next frame. 5 by default. |
## Methods
`play()`, `pause()`, `seek(seconds)`, `seekFrame(frame)`, `loop()`, `hold()`,
`setFps(fps)`, `setRange(first, last)` and `setWallTime(on)` drive its clock,
on every peer. `getFrame()`, `getTime()`, `describeGraph()` and `playback()`
read it; `playback()` also says what running the graph costs and how each
output is drawn. `cancelCook()` stops a cook in flight and leaves the last
output standing. `apply()` swaps the generator for the asset it makes: the
frame being presented becomes a bundle, and an `Asset` component standing it
takes the Generator's place. The `onFinished` event fires when a range played
once reaches its end.
A timeline that runs in real time (`realTime = true`) has an end when its graph
states `frames` beside it (`g:timeline({ realTime = true, fps = 60, frames = 180 })`)
or when `setRange` gives it one; with neither it runs on and never finishes.
When the playhead of one with an end reaches its last frame, `ending` applies as
it does to any timeline: `"graph"` loops where the graph declares `loop` and
otherwise holds on the last frame and fires `onFinished` once; `"loop"` plays it
again from the start, a fresh simulation each time, without firing it; `"hold"`
stays on the last frame. `play()` plays a held one again from its start.
`playback().ends` is whether the timeline has an end, and `last` is its last
frame. A real-time timeline begins again, by `seek(0)`, a loop or `play()`, in
the runs its solvers hold on the device: a restart costs a rewrite like any
other present, and opens no cook.
## Under an effect
An effect's play runs each graph of its entry on a Generator, a child of the
play's entity. `effectRef:play(entry, opts)` and the `Effect` component make
them and drive their clocks together, so a shot, an impact or a cast is a play
of an effect (`guides { path = "topics/visual-effects" }`). The play's own
`playback()` adds its Generators' costs together, and `procgen.frames` and
`procgen.onPresent` take the play's entity as they take a Generator's.
Every play of one graph with the same params plays the frames the first one
cooked. A Generator of a graph with `playOn = "manual"` cooks every frame of the
timeline while it waits, and `playback().cookedThrough` reaching
`playback().last` says every frame is cooked.
## Related
- `guides { path = "systems/procgen/documentation/procgen" }`: graphs, ops,
timelines, effects and the live generator in full.
- The `procgen` toolbox: `spawn`, `adjust`, `apply`, `generators`, `cooks`,
`frames` and `onPresent` work on generators by entity.
# abi
The four ops every procgen node is built from. Each states where it runs in its `runs` field, and none calls another op.
## Ops
| Op | Runs | Does |
|---|---|---|
| `device.kernel` | `gpu` | Runs a `.computeShader` over a domain of elements and returns what it wrote as columns |
| `transfer.upload` | `cpu_to_gpu` | Turns CPU numbers, lists and tables into device columns |
| `transfer.readback` | `gpu_to_cpu` | Turns device columns and live counts into CPU values, a frame or more later |
| `cpu.call` | `cpu` | Calls a Luau function with declared return types and a time ceiling |
## device.kernel
`kernel` (param) is the shader, a reference resolved from a literal where the node is written
(`asset.resolve("<identity>", "computeShader")`; a name is refused); `specialize` (param) gives each of its `specialize`
parameters one of its options. `bind` (input) gives every binding its value and `domain` (input) says how many
elements run.
- A buffer binding takes a Column. A binding the shader writes and that is bound to a Column writes a copy, and the
copy comes back under the binding's name.
- A `columns` binding takes a table of Columns, packed on the device into one buffer in the order the binding's
`order` field states (`order: "shapeKind center extent"`; the table must hold exactly those columns), and in
sorted-name order where the binding states none. A kernel that reads its columns by slot number states `order`;
one that reads them by `NAME_col_<column>` needs none.
- `specialize` takes the table `fieldc.walkSpec(step, opts).specialize` states as it stands (the walk kernel's
`WALK_STEP`, `WALK_STEP_HI`, `WALK_DONE` and `WALK_PRELUDE`), and the resources the spec runs over come from
`fieldc.bindSpec(spec)`: its `cols` (a table of columns already packed on the device) binds to the kernel's `columns`
binding and its `u` (the numbers the chain carries, a device buffer) to its read `buffer` binding. The caller that made
them lets them go (`bound.release()`) once the kernel's outputs are no longer read.
- A Field bound to a binding is evaluated by the field compiler (`fieldc.evaluate`) in one fused dispatch over the
domain, and the column it lands in stands as long as the outputs of the kernel do.
- `bind.columns` is a domain (a record of Columns, or a class of a Geometry): every read binding the bind left
unnamed takes the column of the same name, and an unbound `columns` binding takes every column of it.
- An appended output bound to a column that an append returned writes a copy of it, and the copy keeps the live
count that column carries, so a later pass appends after what an earlier one appended.
- A param takes a Number or a Bool (1 or 0). A Vec spreads over `<param>_x` to `<param>_w`. `count`, `time` and
`frame` are names any node may give; a kernel that declares none of them leaves them out.
- A Texture binds its `texels` column, and sets `<name>_width` and `<name>_height` where the shader declares them.
- `domain` is a Number, a Column (its elements), or a live count: a count Column, an appended output, or the record
`{ count = <one-word Column>, capacity = <Number> }`. A live count dispatches over its capacity, and the shader reads
the count through a `buffer` binding named `domain_count`. A shader that declares `domain_count` over a plain number
gets the number in it. The live-count form is stated in "Over a live count" below.
- An output with `count` or `capacity` is allocated before the dispatch. An appended output comes back as a Column
(its `_capacity` and `_liveCount` set) and as `<name>_count`.
Outputs: `columns` (Record of Columns) and `info` (the kernel, the variant key, the elements, the determinism the
shader declares, the specialization).
Column elements are 32-bit words: an `Int` column holds `i32`, a `UInt` column `u32`, every other type `f32`
(`column:element()`). A kernel's `i32` and `u32` outputs are `Int` and `UInt` columns. The first run of a kernel
waits for its program to be built, so a readback asked for after the run sees what the dispatch wrote.
A column whose words are not the ones a binding reads is cast by value before the dispatch: a Float column meeting an
`i32` or `u32` binding (and an Int or UInt column meeting an `f32` one) is read as the numbers it holds, not as the bits
of its words. The cast covers read bindings and in-place bindings; what a kernel writes back through an in-place binding
is the column of the binding's type (`Int` for `i32`).
## Over a live count
A kernel runs over a plain number of elements (every slot live) or over a capacity whose live count sits in a
one-word device buffer. A node that deletes, welds, expands or pairs elements states the count it made on the device,
and the next kernel takes it as its domain, so the count never crosses to the CPU between them.
**What a node passes.** `domain` is the count and the capacity it is bounded by: `found.indices.count` (the count of an
appended output, whose capacity the output carries), an appended output Column itself, or the record
`{ count = <one-word Column>, capacity = <Number> }` for any other count held on the device (a scan's `total`, a
count made by `proc.device.count`; its words are read as u32). A Number is the plain form, as before.
**What a kernel declares.** In `bindings.yaml`, a count binding that exists only in the variant that reads a count, and
the specialization that picks the variant:
```yaml
bindings:
- { name: domain_count, kind: buffer, access: read, element: u32, when: "COUNT=device" }
specialize:
- { name: COUNT, options: [plain, device] }
```
`device.kernel` runs the `COUNT=device` variant when the domain is a live count, and the `plain` variant (the bindings
and the code the kernel always had) when it is a Number. A kernel that declares `domain_count` without `when` reads it
in every variant, and gets the number in it over a plain domain.
**What the shader does.** The dispatch covers the capacity (`zero_n()`), outputs sized by `n` hold the capacity, and
the live count is `min(domain_count_get(0u), zero_n())`. The code that reads it sits in `// @when COUNT device` ...
`// @end` blocks (`compute_abi` removes a block from the variants it does not name). A slot at or past the live count
reads nothing and writes the inert value of each output it owns:
| Kernel | Inert value of a slot past the live count |
|---|---|
| `gather` | `dst` is zeros |
| `compact` | `inverse` is -1; `indices` is -1 past the kept ones |
| `scan` | `dst` is the total of the last group (0 for an empty run) |
| `jump` (rank, min) | `parent_out` is the slot itself, `value_out` is 0 |
| `loops` | stage clear writes every slot of the dispatch; stage link: `next` is the slot itself, `head` is 0 |
| `loops_cut` | `head` is 0, `cut` is the slot itself |
| `loops_place` | `ring` is -1, `pos` is 0 |
| `edges_runs` | `flag` is 0; `a` and `b` are -1, `fcount` is 0 |
| `expand` | `owner` and `local_index` are -1 past the sum of the counts |
| `hash_gather` | `items` past the count are left as allocated (0) |
**A new count.** A kernel that makes a count writes it to an appended output's counter, which the op returns as
`<output>_count` and as the live count of the output (`output.count`); it carries its capacity and is a domain as it is.
`compact` writes the kept count, `expand` the sum of the counts, `hash_gather` the number of points listed, and
`scan` states its total in `total` (an `i32` scan's total is a count: `{ count = scanned.total, capacity = ... }`).
**Capacity.** When the node does not know a count it allocates for the bound it can state, and the kernel never writes
past it. `compact`, `gather`, `scan`, `jump`, `loops*` and `edges_runs` write at most as many slots as the capacity
they run over. `expand` holds at most `n` slots (`n * k` when no count exceeds `k`). `hash_gather` holds at most its
`total` param, which is the bound the node states (a pair list has no smaller bound than points times queries). In
those two the count buffer states the true count whatever the capacity: a count above `.capacity` is an overflow, the
first `capacity` slots are written and the count minus the capacity is how many did not fit. A node that states a
bound checks `count <= capacity`.
**Storage buffers.** A stage binds at most 8 on the web. A `COUNT=device` variant binds one more than the plain one,
and the table counts the variants that exist:
| Kernel | plain | device |
|---|---|---|
| `gather` | 3 | 4 |
| `compact` | 4 | 5 |
| `scan` | 4 | 5 |
| `jump` | 7 | 8 |
| `loops`, `loops_cut`, `loops_place` | 6 | 7 |
| `edges_runs` | 8 | 5 (heads), 8 (table) |
| `expand` | 5 | 6 |
| `hash_gather` | 6 | 8 |
`edges_runs` binds the buffers of its own stage in the device variant (`when: "COUNT=plain; COUNT=device STAGE=table"`),
which is how it stays at 8. `abi.directLimit` counts every `buffer` binding the schema declares, `domain_count`
included, so a kernel with a `columns` binding that adopts the form has one direct column fewer; none of these ten has
one.
## transfer.upload
`values` is a Record. A Number becomes a one-element Float column; a list of numbers a Float column; a list of
`{x,y,z}` a Vec column, a list of text (or `{ type = "String", values = {...} }`) a column of the values; `{ type = "Int", values = {...} }` a typed column (`Float`, `Int`, `UInt`, `Vec2`, `Vec3`,
`Vec4`, `Color`); a table of those a table of columns; a Column is held as the device run it has.
## transfer.readback
`columns` is a Record of Columns, live counts, plain values (numbers, booleans and text answer themselves) or tables
of them, a Field (evaluated over the elements of the column it reads, then read; a Field that reads no column has no
length and is refused naming the form to use), or a Geometry or a Part, which answers the copy holding what its columns
held. A live count answers a Number, a Float or Int
column a list of numbers, a vector column a list of `{x,y,z,w}`. The op waits across frames; `report` states
the names, elements, bytes and frames. Anything that waits on a readback value is a planning step.
## cpu.call
`fn` (input) is called with `args` (input). `returns` (param) lists the type name of each result (`Number`,
`String`, `Bool`, `Function`, `List`, `Column`, `BufferRef`, or a procgen type) and a call that returns anything
else is refused. `ceilingMs` (param) refuses a call that took longer. A device Column reaches the function as a
`BufferRef { buffer, type, count, lanes, element }`.
# abiBox
A node for the procgenAbi suite: it is called through the builder and written out in a graph.
·computeshader · born here
❒asset # abi_add
Adds the delta param to every element of src into dst.
·computeshader · born here
❒asset # abi_columns
Adds the first lane of the columns a and b of a table into dst.
·computeshader · born here
❒asset # abi_filter
Appends every element of src above the threshold param to hits.
·computeshader · born here
❒asset # abi_inout
Writes src doubled into dst, or adds src to the dst it is bound when mode is 1.
·computeshader · born here
❒asset # abi_inplace
Adds one to every element of data in place.
·computeshader · born here
❒asset # abi_len
Copies src into a dst the length of src.
·computeshader · born here
❒asset # abi_live
Doubles src into dst for the elements a live count holds and writes -1 past it.
·computeshader · born here
❒asset # abi_nothing
Reads src and writes nothing.
·computeshader · born here
❒asset # abi_ordered
Writes ten times the first lane of slot 0 plus the first lane of slot 1 of a table into dst. The binding states the slot
order, `tail` then `head`.
·computeshader · born here
❒asset # abi_pair
Adds two read bindings, a and b, element by element into dst.
·computeshader · born here
❒asset # abi_scale
Multiplies every element of src by the scale param into dst, one element per invocation.
·computeshader · born here
❒asset # abi_spec
Doubles src into dst as the element type T the specialization names.
# procgen guide
Making things from rules, as graphs of nodes: geometry and meshes, scattering and
terrain, and visual effects — particles, sparks, embers, magic, debris, swarms,
flocks, trails, cracks and lightning — simulated and drawn live every frame,
with your own compute and surface shaders as nodes.
The procgen package's reference guide. Surfaced through
`guides { query: "procgen" }` and `guides { path: "systems/procgen/documentation/procgen" }`
(likewise `man`), and reachable from the `procgen` toolbox via `search_tools procgen`.
The content lives in `guide.md`; this README is the asset summary.
# Scatter
Strews copies of one shape over the surface of another, each with its own turn, lean and size.
Both the surface and the thing scattered are inputs, so this covers rocks on a hillside, crates on a deck, bushes
along a bank or rubble across a floor: wire it a different `source` and it is a different field.
It makes points on the surface (`points.on_surface`, area-weighted), gives every point its own random yaw, lean and
scale (`instances.randomize_transform`), and places the source on those points (`instances.on_points`). The transform
is read from the same point cloud the instances sit on, so each copy takes the pose of its own point.
## Inputs
| Input | Type | Default | What it does |
|-------|------|---------|--------------|
| `surface` | Geometry | none | What the copies land on. Points are area-weighted, so a bigger face gets proportionally more. |
| `source` | Geometry | none | The shape being copied. |
| `count` | Int | 200 | How many copies. |
| `seed` | Int | 1 | Changes the arrangement. The same seed always gives the same field. |
| `scaleMin` | Float | 0.8 | Smallest per-copy size multiplier. |
| `scaleMax` | Float | 1.4 | Largest per-copy size multiplier. Equal to `scaleMin` means uniform. |
| `tilt` | Float | 0.15 | Radians of random lean off vertical. `0` stands every copy upright. |
## Output
`instances`: a population, the points each carrying the source as its `Mesh`. A Generator draws it as one
hardware-instanced draw call per source; `instances.realize` merges it into a single Geometry.
## Calling it
Another graph calls a `.procGraph` as it calls a node, by its path or identity, naming the inputs in a table; the
call answers what the graph outputs (`instances`). Its declared inputs are the call's inputs.
# Scatter (node)
A scattering node: its `init.luau` holds the graph that does the work, so the declared inputs are this node's inputs
and the declared output is this node's output. The node is its pipeline, in one self-contained asset.
It makes points on the surface (`points.on_surface`, area-weighted), gives every point its own random yaw, lean and
scale (`instances.randomize_transform`), and places the source on those points (`instances.on_points`). The
transform is read from the same point cloud the instances sit on, so each copy takes the pose of its own point.
A node is findable by what it does: the `procgen` toolbox's `ops` with the query `scatter` lists it beside
`points.on_surface` and `instances.on_points`. Editing this node's `init.luau` cooks again every graph that calls it.
## Inputs
- `surface` (Geometry): what the copies land on.
- `source` (Geometry): the shape being copied.
- `count` (Int, default 200): how many.
- `seed` (Int, default 1): changes the arrangement.
- `scaleMin` / `scaleMax` (Float, defaults 0.8 and 1.4): per-copy size range.
- `tilt` (Float, default 0.15): radians of random lean.
## Output
- `instances`: a population, the points each carrying the source as its `Mesh`.
## Authoring a node
A node is a graph returned from a `.procNode`'s `init.luau` (`proc.define`). `asset.create("procNode", name)`
scaffolds that shape, and `procNode.assetType/README.md` describes what a node states and how graphs call it.
# generator_inspector
The `Generator` component's card on an entity. It replaces the card's generic
`settings` section with one built from the bound graph: the graph it runs, as
an asset picker, and which of its outputs it realizes; then groups — Inputs,
one row per input the graph declares in the control its type takes, each able
to take an expression over the graph's own time; Timeline, the transport,
scrubber, range, frame rate, looping and wall time; Cook, what is cooking, what
cooking has cost and the frame budget — and the Flatten action. The
component's `inspector.luau` returns this module, so the editor's inspector
framework (`edui.inspector.componentSections`) shows it wherever the component
is on an entity. While the clock runs, or the range is still cooking, the card
asks the panel to follow it.
Editing an input row writes the value into the component's `params`, which
regenerates the live output in place. A write the input's type cannot hold is
refused, and the row says why.
`buildFieldSpecs(graph, params)` is the pure row mapping: one spec per declared
graph input, sorted by name, carrying its kind, its current value (the param
override when set, else the declared default), its declared range, the asset
kind a reference input picks from, and whether the value changes over time.
# grid
How a compute shader finds the points near a point: the walk over a spatial
hash grid `proc.device.gridInto` builds on the device. `zero_grid_cell`,
`zero_grid_bucket` (from `grid_cell`) and `zero_grid_range`.
A shader run by `shader.compute` or `sim.shader` gets a grid over its domain's
`P` by declaring three read bindings — `gcfg` (f32), `grange` (u32) and
`gorder` (u32) — and giving the node a `neighborRadius`:
```wgsl
#include "@builtin::systems.procgen.grid"
let c = zero_grid_cell(p);
for (var dz = -1; dz <= 1; dz = dz + 1) {
for (var dy = -1; dy <= 1; dy = dy + 1) {
for (var dx = -1; dx <= 1; dx = dx + 1) {
let cc = c + vec3<i32>(dx, dy, dz);
let span = zero_grid_range(cc);
for (var s = span.x; s < span.y; s = s + 1u) {
let j = gorder[s];
// skip j == i, and any j whose own cell is not cc
}
}
}
}
```
# grid_cell
The cell a point stands in (`zero_grid_cell`) and the bucket of a spatial hash
grid that cell hashes to (`zero_grid_bucket`), read against the grid settings
bound as `gcfg`. What a pass that only keys points by cell includes; a pass that
walks the grid includes `grid`, which includes this.
```wgsl
#include "@builtin::systems.procgen.grid_cell"
```
·computeshader · born here
❒asset # bvh
The tree of a bounding volume hierarchy over the primitives of a geometry (an
LBVH): a leaf per primitive in Morton order with its bounds, and the internal
nodes linked by the longest common prefix of the sorted codes, equal codes broken
by place. `bvh_pyramid` and `bvh_internal` then fill in the internal bounds, and
the `ray` kernel traverses the finished table. `bindings.yaml` states the node
layout and the order the kernels run in. Exact: the same geometry gives the same
tree.
# bvh_box
The boxes the bvh kernels fold: `ZeroBox`, the empty box and `zero_grow`. The
module binds nothing, so any shader includes it.