# Chase Camera
Sticky low-pass-filtered follow cam. Stays behind the target's facing direction at a fixed `distance` + `height`, no user-driven orbit, no mouse-look. Designed for vehicles + fast platformers where `orbital_follow`'s orbit feels too floaty.
## Vs. `orbital_follow`
| | `orbital_follow` | `chase` |
|---|---|---|
| Orbit | RMB-drag (yaw + pitch) | none — derives from target facing |
| Mouse-look | yes | no |
| Position lag | n/a (camera is at fixed orbit) | per-second exponential lerp |
| Rotation lag | n/a | separate per-second exponential lerp |
| Best for | character action, RPG | vehicles, platformers, ski/snowboard |
The split between `positionSmoothing` and `rotationSmoothing` lets you tune "position snaps, rotation drifts" (or vice versa) for the cinematic feel you want.
## Fields
| Field | Default | Notes |
|---|---|---|
| `target` | `""` | Entity ID. Falls back to `Camera.follow` when empty. |
| `distance` | `6.0` | Horizontal trail distance behind target. |
| `height` | `2.5` | Vertical offset above target origin. |
| `minDistance`, `maxDistance` | `1.5`, `25.0` | Scroll-zoom bounds. |
| `zoomSpeed` | `0.12` | `exp(-scroll * zoomSpeed)` per tick. |
| `positionSmoothing` | `0.05` | 0 = instantly stuck, 1 = static. Per-second blend. |
| `rotationSmoothing` | `0.15` | Same shape — higher = more "looks where it's been". |
| `lookAhead` | `0.6` | How far in front of target the camera aims. |
## Use as a camera behavior
```lua
world.camera_default = asset.ref("@builtin::controller.chase")
-- Or per-scene
layers.active.camera.behavior = asset.ref("@builtin::controller.chase")
layers.active.camera.follow = layers.active.players.localPlayer
```
The component carries the `cameraBehavior` tag in `.metadata`.
## Controls
- **Scroll** — zoom in/out (distance only; height ratio stays constant)
- No user-driven rotation — the camera always trails the target's facing.
## Pair with
- `standard_controller` — the controller already rotates the player to face the input direction; chase locks behind that facing.
- `topdown_controller` — works, but `birds_eye` is usually a better fit for top-down gameplay.
- A custom vehicle controller that rotates with steering — chase will track the steering arc.
# controller.orbit
Orbit camera controller. Orbits around a target entity or position. Mouse drag to rotate, scroll to zoom, optional auto-rotate.
Public fields: `inputMap` (the camera-rig scheme, `@builtin::inputMaps.cameraRig`, by default; point it at a world's own map to rebind this camera), `target` (empty = use targetX/Y/Z), `targetX`, `targetY`, `targetZ`, `distance`, `minDistance`, `maxDistance`, `sensitivity`, `zoomSpeed`, `autoRotate`, `autoRotateSpeed`.
Controls: RMB drag to rotate, scroll to zoom.
```luau
entity(id).component.add("controller.orbit", { target = "player", distance = 10 })
```
# editorPanel
A dock tab in the editor, as an asset. A `<name>.editorPanel/` folder carries an
`init.luau` that returns the tab's spec:
```luau
local edui = require("@builtin::~edui.framework")
return {
title = "My Tab", -- the tab's label (`label` is accepted too)
order = 60, -- sort position in the menu
dock = "bottom", -- seeds the FIRST arrangement: left | right | bottom | center
menu = "scene", -- which top-bar menu lists it; an unknown name creates that menu
startClosed = true, -- registered closed; opened from the menu
build = function() -- the tab's body, called again whenever it should change
return edui.panel{ body = edui.text{ text = "hello" } }
end,
}
```
`build` is the only required field. The tab id defaults to the folder stem
(`myTab.editorPanel` → `myTab`); an explicit `id` overrides it, and a duplicate
id replaces the prior registration. An init may return a LIST of specs when one
asset carries several tabs — a spec this registration no longer declares has its
tab taken down.
The spec is forwarded whole to `edui.shell.addPanel`, so anything the shell
accepts (`badge`, `badgeColor`, …) works here; `man edui.shell` is its contract.
`build` may return `nil` to mean "nothing changed, keep what is drawn" — the
shell rebuilds open tabs on a timer, and a panel that re-publishes an identical
tree pays for it. Only the ACTIVE tab of a dock leaf is rebuilt at all.
An `init.luau` that fails to load does not break the editor: its tab comes up
carrying the failure — the path and the error, selectable — so a panel that will
not compile says so where you would look for it.
Every instance of this type becomes a tab: the ones in the open world, and the
ones the built-in library ships, on the same terms.
Adding a tab to a project is creating a new `editorPanel` asset: it comes from
the template above and registers its tab at once, wherever under `/source` it
was made (`editorPanels/` is the type's conventional folder). Editing the
`init.luau` re-registers the tab, deleting the folder removes it. Nothing
registers panels by name anywhere.
`guides { path = "editor/edui/edui" }` — the guide beside this type, inside
the edui package — covers the build model, where panel state lives, and the
`edui.*` surface a body draws with.
## In the Inspector
Panels: each panel id this file registers (`panelRef:panelIds()`), whether it is open, an **Open** action for it, and **Open** for the source. A file whose init.luau does not load says why.
# volumetrics
Volumetric (3D-texture) rendering, end to end, in one package. Built
entirely on the engine's generic `compute.*` GPU primitives — no
Rust changes are needed to author new volumetric content.
## Layout
| Asset | Identity | Role |
|---|---|---|
| `Volume.component` | `@builtin::systems.volumetrics.Volume` | Attach a 3D voxel texture to an entity; raymarches it through the entity's transform AABB. |
| `VolumetricSky.component` | `@builtin::systems.volumetrics.VolumetricSky` | Singleton procedural cloud layer with TAA. |
| `ParticipatingMedium.component` | `@builtin::systems.volumetrics.ParticipatingMedium` | Mark an entity as fog/smoke/dust that attenuates the light passing through it. |
| `mediaTransmittance.module` | `@builtin::systems.volumetrics.mediaTransmittance` | Light-space optical-depth map: the registry, the settings, and the basis the map is built in. |
| `mediaShadow.renderFeature` | `@builtin::systems.volumetrics.mediaShadow` | Builds the optical-depth map and attenuates each surface by the density in front of it. |
| `volume.module` | `@builtin::systems.volumetrics.volume` | Builders (`noise`/`sphere`/`box`/`wrap`) + the `.zvol` header reader + occupancy + brick paging + the per-entity raymarch dispatch. |
| `VolumeSequence.component` | `@builtin::systems.volumetrics.VolumeSequence` | Play an open volume sequence through an entity's transform AABB. |
| `volumeSequence.module` | `@builtin::systems.volumetrics.volumeSequence` | A run of `.zvol` frames streamed through a fixed ring of resident volumes. |
| `volumeDemos.module` | `@builtin::systems.volumetrics.volumeDemos` | Shared pipeline for the showcase scenes (fill + emissive raymarch + composite). |
| `volumetricSky.module` | `@builtin::systems.volumetrics.volumetricSky` | Cloud volume + raymarch + TAA + composite orchestration. |
| `shaders/*.shader` | `@builtin::systems.volumetrics.shaders.*` | `volume_raymarch`, `volume_pages`, `volume_pack`, `cloud_noise[_v3/_v5]`, `cloud_raymarch[_v3/_v4/_v5]`, `cloud_taa`. |
| `importers/vdb_to_zvol.importer` | (auto-discovered) | `.vdb` → dense `.zvol` via the `openvdb` WASM plugin (procedural fallback when the plugin isn't loaded). |
| `presets/cumulus_default.preset` | `@builtin::systems.volumetrics.cumulus_default` | Canonical full `VolumetricSky` weather preset. |
| `demo/*.scene` | `@builtin::systems.volumetrics.demo.*` | `sdf_solids`, `smoke_plume`, `nebula`, `godrays`, `zvol_viewer`, `sky`. |
## Path convention — fully-qualified builtin identities
Every intra-package reference uses the asset's fully-qualified builtin
identity, the same convention every other builtin package follows:
```luau
local Volume = require("@builtin::systems.volumetrics.volume")
local Sky = require("@builtin::systems.volumetrics.volumetricSky")
local V = require("@builtin::systems.volumetrics.volumeDemos")
-- shader identities, e.g. asset.resolve("@builtin::systems.volumetrics.shaders.volume_raymarch")
```
Qualified identities are caller-independent: they resolve identically
from a module, a scene entrypoint, or a component. Caller-relative
(`.sibling`) requires are deliberately avoided here because the
component runtime caller identity is `@builtin::components.<Name>`, so a
`.sibling` require from a component resolves against the wrong root.
(An earlier revision of this package used a `~volumetrics` package-root
alias for relocatability; that alias is not resolvable on current main,
so the package uses qualified identities throughout.)
## Usage
```luau
-- Per-entity volume. The component resolves the name through `Volume.get`,
-- so a volume paged into a brick pool renders from the pool under the same
-- name:
local Volume = require("@builtin::systems.volumetrics.volume")
local cloud = Volume.noise({ name = "cloud", size = 64, scale = 4 })
cloud:buildSparse({ brickSize = 8 })
cloud:releaseDense()
entity(id).component.add("Volume", { volumeName = "cloud", density = 6.0 })
-- Procedural sky:
local sky = entity.spawn("sky")
sky.component.add("VolumetricSky", {})
preset.apply("cumulus_default", { entity = sky.id, component = "VolumetricSky" })
-- VDB import: drop a `.vdb` into the VFS; the importer writes a `.zvol`
-- alongside it. Load it with the zvol_viewer demo scene.
-- Animated run: one `.zvol` per frame, played through a bounded ring.
local Seq = require("@builtin::systems.volumetrics.volumeSequence")
Seq.open({ name = "plume", frames = framePaths, resident = 3, fps = 24 })
entity(id).component.add("VolumeSequence", { sequenceName = "plume", density = 6.0 })
```
## Plugin dependency
The VDB importer delegates real OpenVDB parsing to the `openvdb` plugin
in the builtin library, `@builtin::plugins.openvdb`. Without the plugin
the importer falls back to a deterministic procedural torus so the
pipeline stays functional.
# DecalProjector
Turns its entity into an oriented **box decal projector** — the deferred-decal
equivalent of Unreal's Decal Actor or Unity's Decal Projector. The entity's
Transform is the projection volume: position and rotation orient the box, scale
sizes it, and the projector stamps its `material` along the box's local −Z onto
every surface inside the box, reconstructing each covered pixel's world position
from scene depth and fading on faces turned away from the projection axis so the
decal wraps the surfaces facing it.
## Surface
| Field | Type | Meaning |
|-------|------|---------|
| `material` | `AssetRef<material>` | What the projector stamps. Its base-colour texture + tint drive the decal — a textured material projects its texture, a flat-colour material projects its colour. Defaults to `@builtin::materials.decal_default`. |
| `opacity` | `number` | Master fade, `0..1`. |
Registers into the `@builtin::modules.decals` registry on `awake`/`onEnable`
(and re-registers on property change / in edit mode), which the
`@builtin::renderFeatures.decals` render feature reads every frame.
## Example
```lua
-- A downward projector that stamps a texture onto whatever is under it.
local p = entity.spawn("floorDecal")
p.localPosition = { 0, 3, 0 }
p.localScale = { 6, 6, 4 }
p.localRotation = { 0.70710678, 0, 0, 0.70710678 } -- project down (-Y)
p.component.add("DecalProjector", {
material = "@builtin::materials.decal_default",
opacity = 1.0,
})
```
# edui (module)
The editor UI framework, built on the CSS-parity `ui.*` surface. `edui` is
**editor-only** — gameplay and normal UI author raw `ui.*` widget trees
(`{ type, style, props, children }`) and need no framework. `edui` provides the
one thing a framework earns its keep for: editor chrome — a reactive app, a
dock shell, and chrome primitives (panels, trees, toolbars, inspector rows,
context menus, drag-and-drop). It replaces the deprecated `zui` for the editor.
## The reactive core (`edui.app`)
`ui.*` dispatches interaction through **string callback ids** — a widget
carries `props.onClick = "<id>"`, and the screen's registered env receives
`onCallback(id, data)`. Closures can't cross the FFI boundary, so raw `ui.*`
makes you manage those ids by hand.
An `edui` **app** hides that. During a build it allocates an id per closure and
records id→closure; its single `onCallback` looks the id up, calls the closure,
and rebuilds:
```lua
local edui = require("@builtin::modules.edui")
local count = 0
local app = edui.createApp("counter", { layer = editorMask })
app:mount(function(a)
return {
type = "vertical",
children = {
{ type = "label", props = { text = "count: " .. count } },
{ type = "button", props = { text = "+1",
onClick = a:cb(function() count += 1 end, "inc") } },
},
}
end)
```
- `app:cb(closure, key?)` — allocate a callback id for `closure`. Pass a stable
`key` so the id survives rebuilds (required for focus / drag / `ui.widgetState`).
- `app:dispatch(id, data)` — what the broadcast calls; routes id→closure and
rebuilds. Returns whether this app owned the id.
- `app:markDirty()` — request a rebuild (coalesced during an in-progress build).
- `app:rebuild()` / `app:mount(fn)` / `app:unmount()` — lifecycle.
- `edui.current()` — the app whose builder is running (the primitives use it so
you don't thread the app through every call).
Builder and handler errors are logged, never thrown — one bad build never
wedges the editor.
# SphereCollider (component)
A spherical collision shape. Works with or without a `Physics` component: on its
own it is static collision geometry, and alongside one it moves with the body.
## Sizing
`radius` is in the entity's local space, multiplied by the entity's world scale.
Leave it unset and the sphere derives from the entity's model bounds.
```lua
entity.find("ball").component.add("SphereCollider", { radius = 0.5 })
entity.find("zone").component.add("SphereCollider", { radius = 5, isTrigger = true })
```
## Fields
| Field | Meaning |
|-------|---------|
| `radius` | Sphere radius. Unset derives from model bounds. |
| `isTrigger` | Report overlaps without blocking. |
| `friction` | Surface friction. |
| `restitution` | Bounciness. |
## Composing
A collider on a child entity attaches to the nearest ancestor carrying a
`Physics` component. A character is a `CapsuleCollider` for the body and a
`SphereCollider` trigger on a head entity; queries report whichever shape they
hit, so the head stays distinguishable from the torso.
# DirectionalLight
A directional light in addition to the scene's sun: parallel light arriving from
one direction everywhere in the world, the way a body far enough away delivers
it. A second star over an alien planet, a moon hanging opposite the sun, a cold
fill from the far side of a set.
The sun is `Light { kind = "directional" }` — one direction, one colour and the
cascade set, held as a single field, so a second `Light` of that kind moves the
sun rather than joining it. This component adds a light instead. Several can
coexist; they share the scene's light buffer with point, spot and area lights,
so the cap is the combined count (64).
Public fields: `directionX/Y/Z` (the direction the light TRAVELS, normalised by
the engine), `colorR/G/B`, `intensity`, `mobility` (`auto` / `static` / `mixed`
/ `dynamic` — how a GI bake treats it).
Input aliases accepted by `component.add`: `direction`, `color`, `brightness`.
Methods: `:resolveMobility()`, `:lightMobility()`.
`lighting.lightRows()` reports one of these with `kind = "distant"`; the row's
`direction`, `color` and `intensity` are what the shading reads, and its
`position` follows the carrying entity. A GI bake gathers it the way it gathers
the sun, so a `mobility = "static"` light is baked whole and its live
contribution withheld.
```luau
entity(id).component.add("DirectionalLight", {
direction = { 0.3, -0.5, 0.4 },
color = { 0.7, 0.8, 1.0 },
intensity = 0.6,
})
```
# Model
Loads and displays a 3D mesh on an entity. Disabling the component hides the mesh. Supports procedural meshes (`cube`, `sphere`, ...), model files, and remote URLs. Tint and outline are shader effects applied to the mesh.
**Material lives on this component.** There is no standalone `Material` component — a material only renders where there is a mesh to render it on, so the `material` field is part of Model (and SkinnedModel). Set it at add time or assign the field later; properties are registry-wide.
Public fields: `model`, `material` (`AssetRef<material>`), `tintR/G/B`, `tintBlend`, `outlineR/G/B`, `outlineIntensity`.
Methods: `:setTint(color, blend?)` (color: `{r, g, b}` array or `{r=, g=, b=}` map), `:clearTint()`, `:setOutline(color, intensity?)`, `:clearOutline()`, `:setMaterialProperty(prop, value)`, `:getMaterialProperty(prop)`, `:getMaterialPropertyNames()`.
```luau
entity(id).component.add("Model", { model = "cube", material = "gold" })
entity(id).component.get("Model").material = "checkerboard" -- swap material
entity(id).component.get("Model"):setTint({1, 0, 0}, 0.5)
entity(id).component.get("Model"):setMaterialProperty("roughness", 0.2)
```
# lightCookies
Projects an authored image through a spot light's cone — a gobo, window blinds,
a headlight cutoff, a projector pattern.
## How it divides up
The renderer already projects a texture through a spot's frustum. At each shaded
point it reads `zero_feature_texture(uv, cookieLayer)`, with `uv` from that
point's position in the light's own clip space, and multiplies the cone by what
it finds. `SpotLight.cookieLayer = -1` means no cookie.
What the renderer does not decide is what goes in the layer. That is this
package: resample an authored image into one full layer of the shared
feature-texture array, and point a spot at that layer.
## Using it
```luau
local lightCookies = require("@builtin::systems/lightCookies.package/lightCookies")
-- Size the array once for every layer the scene will use.
lightCookies.configure({ resolution = 256, layers = 4 })
lightCookies.project(lampId, "/textures/cookies/window_blinds.png", { layer = 0 })
```
Or attach a `LightCookie` component next to the `SpotLight` and author
`texture` / `layer` / `gain` / `tint` on it.
`lightCookies.clear(lampId)` stops the projection; the layer keeps its contents.
## When a fill lands
`fill` and `project` queue the fill and return its layer at once. The module's
own loop writes it over the frames that follow: the rasterize is issued on the
next frame and the copy into the layer on the frame after, and the fill is
written once `renderer.featureTexture.state().fills` reports the copy landed in
its layer, one fill at a time in the order they were asked for. The loop belongs to the module, so a fill
lands even when the code that asked for it (a one-shot script, a task scope, a
component that is destroyed) has ended.
`lightCookies.status(layer)` reports where the last fill of a layer stands:
```luau
local s = lightCookies.status(0)
-- s.state: "queued" | "rasterizing" | "copying" | "written" | "refused"
-- s.reason: why a refused fill was refused, in the engine's words
-- s.filled: whether renderer.featureTexture.state().filled lists the layer
```
`status` reads a fill `"written"` from the first reading of the array that
reports its copy landed, so `s.state` and `s.filled` agree whichever of the
module's loop and the reader runs first in a frame.
A fill names a layer below the count `configure` sized the array to; one past
it is refused at the call, which returns nil and the reason. A fill that cannot
land once it is on its way (the GPU refuses its rasterize because its image was
given back first, a later `configure` left the array without its layer, or its
copy never reached the array) reads `"refused"` with the reason, and is also
logged as a warning.
A `LightCookie` component whose fill is refused at the call, such as one
authored before the scene's `configure` has sized the array, asks again each
time `configure` sizes it. `lightCookies.observe().configurations` counts
those calls for code of its own that does the same.
`lightCookies.observe()` reports how many fills are waiting, the one in
progress, and the loop's own counters.
## An empty layer puts the light out
The cone is multiplied by what it reads from its layer, so a spot pointed at a
layer nothing has filled renders black rather than plain. `project` fills before
it points the light, which is the order that keeps the cone lit; if you set
`SpotLight.cookieLayer` by hand, fill that layer first.
## The array is shared, and sizing it is explicit
The feature-texture array is one resource that every feature using
`zero_feature_texture` draws layers from, and it has no allocator: sizing it
zeroes every layer, including layers another feature owns. So this package never
sizes it as a side effect of setting a cookie, and `layer` is authored rather
than assigned. `lightCookies.configure` is the one call that resizes, made once
by whoever knows what else in the scene wants layers.
A scene that also bakes global illumination shares this array with the lightmap
atlas. Size it for both, and give cookies layers above the ones the bake fills.
## A cookie fills a whole layer
The cone's lookup runs over the layer's entire `uv` range, so an image is
resampled to the layer's extent rather than placed in a corner of it. One layer
carries one cookie.
## Alpha
The source's alpha is how much of the pattern applies. At alpha 0 the cone
passes through unmodulated, so a fully transparent image is a no-op rather than
a black cone.
# restirLighting
Direct light from many small sources at a cost that does not grow with how many
there are.
A shading pass that loops every light pays for every light at every pixel, which
is why the number of lights has to be capped. Resampled importance sampling
breaks that link: each pixel draws a few candidates, keeps ONE of them with a
probability proportional to what it would contribute, and carries a weight that
makes the survivor stand for the whole set. What it keeps is then reused — by its
neighbours this frame, and by itself on the next one — so the set a pixel
effectively samples keeps growing while the number of candidates it draws stays
where you put it.
A cave of glowing crystals, a street of practicals, a control room of indicator
lamps: hundreds of sources, and each pixel looks at about a dozen.
## Using it
Put an `EmissiveSource` on the geometry that glows:
```lua
local id = entity.spawn("crystal", { position = { 2, 1, 0 } }).id
entity(id).component.add("EmissiveSource", {
color = { 0.3, 0.8, 1.0 },
intensity = 8.0,
radius = 6.0,
size = 0.4,
})
```
Or register sources from a script, which is how a generated scene does it:
```lua
local restir = require("@builtin::systems.restirLighting.restirLighting")
for i = 1, 400 do
restir.set(`lamp{i}`, {
position = { math.random() * 60 - 30, 1.5, math.random() * 60 - 30 },
color = { math.random(), math.random(), math.random() },
intensity = 4.0,
radius = 5.0,
})
end
```
The pass starts with the first source and stops with the last. Settings are on
`restir.configure`, or on a `ResampledLighting` component for a scene to author:
```lua
restir.configure({ candidates = 16, spatialSamples = 6 })
```
## What a pixel pays
`candidates + spatialSamples + 1` evaluations, reported by
`restir.perPixelCost()`. None of those terms is the source count.
The estimate is formed on a grid of one cell per 2x2 pixels (`resolution =
"full"` gives every pixel its own cell), across four passes:
| Pass | Runs over | Does |
|---|---|---|
| `restir_sample` | grid cells | draws `candidates` sources at random, resamples to one, merges the cell's own history |
| `restir_spatial` | grid cells | borrows from `spatialSamples` neighbours, each evaluated against this cell's surface |
| `restir_resolve` | pixels | shades the chosen source with the pixel's own normal, roughness and albedo |
| `restir_denoise` | pixels | averages that light over the cells around the pixel and composites it |
Which source to look at is a low-frequency decision a cell of pixels can share.
What the surface does with it is not, and is computed per pixel.
One source per cell is correct on average and different from its neighbour,
which is the grain a single sample carries. `restir_denoise` averages across the
cells around a pixel, weighing each by how nearly its surface is this one, so the
estimate keeps its value and loses its variance.
`mode = "reference"` replaces the reservoir lookup with a loop over every
registered source at every pixel. That is the picture the resampled mode is
estimating and the cost it is measured against — the same shading, the same
filter and the same composite, so the two are comparable directly.
## Temporal reuse
A cell keeps its reservoir across frames and merges it with the fresh
candidates, so a good find keeps paying after the frame that made it. The stored
world position is what says the cell is still looking at the same surface; a
camera move or an occluder passing in front fails that test and the history is
dropped rather than smeared.
`historyCap` bounds how many candidates the history is allowed to stand for.
Without it a converged cell would outvote every new candidate forever and a
lighting change would never work its way out. At the default of 20 a change is
absorbed over roughly twenty frames.
Removing a source, or turning its `intensity` down to 0, releases it; the pass
stops with the last one.
## Where it sits
Sources registered here are additional to the lights the engine holds rows for.
The passes run in `afterLighting` at orders 80-84 and add to the scene as it was
drawn, reading position, normal, albedo and metallic-roughness back out of the
G-buffer.
A source lights every surface within `radius` that faces it, along the straight
line between them.
## One frame, several targets
A render feature's `render` runs once a frame, and the passes it enqueues run
once per render target — the window, an offscreen capture, a camera rendering to
a texture. Each pass therefore reads the frame size out of the textures it is
handed and works out its own cell grid, so every target resamples at its own
resolution. A reservoir carries the grid width it was written under, which is
what stops one target's history being read as another's.
The reservoir buffers are sized to the widest grid any target has asked for. A
target wider than they have yet been sized for reports the grid it needed, and
the frame after that covers it.
## Reading what it did
`restir.stats()` returns the counters the passes keep on the GPU for the most
recent frame a read-back has landed for, carrying that frame's number so a
repeated read is distinguishable from a fresh one. `meanWeight` is counted in
sixteenths with each cell capped at 16 so the total fits a 32-bit counter;
`peakWeight` alongside it is exact to a thousandth, and is where an estimator
that has come loose shows first.
# PlayerPrototype
Marks the root of a player-setup subtree — the entity and children that define what a joining user becomes (avatar slot, spawn behaviour, controller). On awake it flips the entity to PrototypeOnly participation, so the prototype lives in the editor and drives spawning without being saved as a world entity itself; `kind` and `label` describe it for authoring surfaces. In edit mode it draws a cyan silhouette outline so the prototype root is easy to pick out, and surfaces its own setup-validation messages (missing camera, avatar slot, or wrong participation) on the component's error channel.
Location: `src/lua/lib/components/PlayerPrototype.component`
# Skybox
The scene's sky as a single **material**. Add it to an entity and the engine
renders that material across the sky behind everything else:
```lua
entity.spawn("sky").component.add("Skybox", { material = "sky_cubemap" })
entity.spawn("sky").component.add("Skybox", { material = "my_custom_sky" })
entity.spawn("sky").component.add("Skybox", { kind = "none" }) -- explicit no sky
```
`Skybox` is the generic, material-driven sky: it points at **any** sky material
— a builtin (`sky_procedural`, `sky_solid`, `sky_cubemap`, `sky_equirect`) or
your own authored sky-domain material — and makes it the active sky. For the
editable procedural atmosphere (day/night, sun, stars, moon), use the
[`ProceduralSky`](../ProceduralSky.component/README.md) component instead — it is
a `Skybox` over the `sky_procedural` material plus typed parameter controls.
A Skybox **materialises its material's GPU handle** when it binds it. The sky's
point-of-use is the sky pass, which never binds the material the way a `Model`
does, so the component performs the Disk→CPU→GPU upload (`:handle()`) itself. The
renderer binds the sky by the material's **identity** (the key it is resident
under once materialised), so a material-mode sky renders correctly after boot and
mode flips — no manual `:handle()` needed.
Like the directional light, the sky is conceptually **singular per scene**: the
engine copies the most recently authored sky into the scene-wide sky config the
renderer reads each frame. A sky belongs to the scene that spawned it; removing
the component reverts the scene to the engine fallback sky. State is bridged
through the native `Sky` ECS component (sky type `material` / `none`), so there
is no global sky singleton — two scenes can never clobber each other.
## Fields
| Field | Type | Default | Meaning |
|---|---|---|---|
| `material` | string | `sky_procedural` | The sky material to render. Any registered material whose shader is a sky-domain shader. Defaults to the procedural sky so an empty `Skybox{}` is never a black void. |
| `kind` | string | `material` | `"material"` renders `material`; `"none"` turns the sky pass off — the explicit authored form of "this scene has no sky". |
## Methods
| Method | Description |
|---|---|
| `skybox:setMaterial(name)` | Point the sky at a different material (materialises its handle). |
| `skybox:setNone()` | Turn the sky off explicitly. |
# AreaLight
A rectangular light source on an entity. The rect is centred on the entity's
world position, faces along the entity's world rotation applied to `face`
(default "Front", the entity-local -Z that `transform.forward` reports and that
`entity:lookAt` aims), and spans `width` by `height`. A rect under a carrier moves
and turns with it. Its light arrives from a surface rather than
a point, so shading falls off gradually across a wall, highlights stretch to the
shape of the source, and shadows carry a penumbra that widens with the rect's
size and the caster's distance.
# Text3D
Renders text in 3D world space. The quad auto-sizes to fit the text content and never clips. Positioning works like TextMeshPro: `offsetX/Y/Z` is a local offset from the entity, `pivotX/pivotY` is the anchor within the quad, and `billboard` makes it face the camera. With `billboard = false` the text reads from the entity's own forward, the local -Z that `transform.forward` reports and that `entity:lookAt` aims.
Sizing — three fields, two jobs:
- `worldHeight` — the quad's height in **world units** (default `1.0`). This is the one field that resizes a label, and it holds that height under every transform between the label and the world: a label hung off a shrunken detail box on a model comes out the size it asked for, and so does one whose own entity carries a `localScale`. Width follows the rasterised text's aspect. Measure the result with `getWorldSize()`, which reports the extent the quad is drawn at.
- `fontSize` — the **raster resolution** in texels (default `32`). Higher values sharpen the texture and change wrapping against `maxWidth`; the world-space size stays `worldHeight`.
- `scale` — rasterisation scale multiplier applied at raster time. Resolution only, like `fontSize`.
`maxWidth` is the width the text wraps at, in pixels of the raster `fontSize` states; `0` keeps it on one line. A label already drawn is laid out again when the field is written, so a caller re-wraps one label to line after line rather than making a label per line.
`alphaCutoff` decides whether the letters are a surface. At `0` (the default) the text is pure alpha blending: it draws over what is behind it and leaves the depth buffer alone, so a screen-space effect that reads scene depth — volumetric fog, screen-space shadows — integrates the whole distance behind the letters and the text sits inside it. Above `0`, coverage at or over the threshold is drawn opaque and written to depth, so those effects stop at the glyph shape instead. `0.5` reads well for most fonts; higher thins the letters, lower keeps more of the antialiased edge.
Public fields: `content`, `fontSize`, `color`, `alignment`, `richText`, `maxWidth`, `outline`, `outlineColor`, `background`, `scale`, `worldHeight`, `alphaCutoff`, `shadowX/Y`, `fontFamily`, `weight`, `slant`, `offsetX/Y/Z`, `pivotX/Y`, `billboard`. `weight` and `slant` are strings (`"regular"` / `"bold"`, `"normal"` / `"italic"`).
Methods: `setText(content)`, `setStyle(options)`, `getText()`, `getSize()`, `getWorldSize()`, `refresh()`.
```luau
entity(id).component.add("Text3D", { content = "Hello World" })
entity(id).component.add("Text3D", { content = "HP: 100", worldHeight = 0.5, fontSize = 96, color = "red", offsetY = 2.0, pivotY = 0 })
-- A title that keeps its letters crisp through volumetric fog.
entity(id).component.add("Text3D", { content = "RAISING", worldHeight = 3.0, alphaCutoff = 0.5 })
```
A label that is not showing, or came out in a face you did not ask for, reads
back out of the text system: `text.observe()` lists every live text object with
the entity that owns it and the texture its raster is in, and `text.face(h)`
names the font face the shaper actually used against the `fontFamily` that was
requested. `topics/text` walks both.
# Transform
Position, rotation, and scale for an entity, as the authoring face of the transform the
entity holds. Add it to an entity to reach these fields and methods.
Public fields: `positionX/Y/Z`, `rotationX/Y/Z/W` (quaternion), `scaleX/Y/Z`. They hold
the entity's LOCAL transform — where it stands relative to its parent — and an entity
with no parent stands at those coordinates in the world.
The entity's transform is what the component reports, and the fields mirror it. Adding
the component to an entity that already stands somewhere reads that placement into the
fields and leaves the entity where it is. A field the init table names is written onto the
entity instead, so `component.add("Transform", { positionX = 1, positionY = 2, positionZ =
3 })` moves the entity to `(1, 2, 3)`; every axis the table says nothing about keeps the
value the entity already holds, so adding it with `{ scaleY = 2 }` alone stretches the
entity on Y and leaves it standing where it stood.
Writing one field moves the entity on the axis that field names. The other axes of the
same vector are read off the entity first, so a position written after something else
moved the entity keeps the move on the axes it says nothing about.
Methods take one table each and are called with a colon:
| Method | Argument |
|---|---|
| `t:setPosition({x, y, z})` | position table, `{x, y, z}` or `{x =, y =, z =}` |
| `t:addPosition({dx, dy, dz})` | offset table, added to where the entity stands now |
| `t:setRotation({x, y, z, w})` | quaternion table, `{x, y, z, w}` or `{x =, y =, z =, w =}` |
| `t:rotateAxisAngle({ax, ay, az}, angle)` | axis table, and an angle in radians |
| `t:setScale(2)` or `t:setScale({x, y, z})` | one number for a uniform scale, or a table |
| `t:lookAt(target)` | a position table, or an entity id / name / proxy |
| `t:getPosition()` / `t:getRotation()` / `t:getScale()` | none |
`setPosition`, `addPosition`, `setRotation`, `rotateAxisAngle` and `setScale` write the
entity's local transform. `rotateAxisAngle` composes onto the rotation the entity
already holds, and reads its axis in the frame that rotation is relative to — the
parent's, and the world's for an entity with no parent.
The three getters report what the entity's transform holds at the moment they are
called, so a rotation written by `lookAt` — or a position written by another script —
reads back through them.
`lookAt` is the one method on a WORLD position. It takes one target: a position table
(`{x, y, z}` or `{x =, y =, z =}`), or an entity — an id string, an entity name, or an
entity proxy — whose world position is aimed at. It answers whether the rotation was
written, and the reason when it was not. The `Camera` component's `lookAt` reads the
same slot.
```luau
local t = entity(id).component.get("Transform")
t:setPosition({ 0, 5, 0 })
t:setScale({ 2, 2, 2 })
t:rotateAxisAngle({ 0, 1, 0 }, math.pi / 2)
local pos = t:getPosition()
```
# BoxCollider (component)
A box collision shape. Works with or without a `Physics` component: on its own
it is static collision geometry, and alongside one it moves with the body.
## Sizing
`half` is the box's half-extents in the entity's local space, multiplied by the
entity's world scale. Leave it unset and the box derives from the entity's model
bounds, so a bare `BoxCollider` matches what is rendered at any scale.
```lua
entity.find("crate").component.add("BoxCollider", {}) -- fits the mesh
entity.find("wall").component.add("BoxCollider", { half = { 2, 4, 0.2 } }) -- authored
```
## Fields
| Field | Meaning |
|-------|---------|
| `half` | Half-extents. Unset derives from model bounds. |
| `isTrigger` | Report overlaps without blocking. |
| `friction` | Surface friction. |
| `restitution` | Bounciness. |
## Composing
A collider on a child entity attaches to the nearest ancestor carrying a
`Physics` component, so a body made of several shapes puts each on its own
entity and each keeps its own material and trigger flag.
Past a few dozen boxes sharing one material, `CompoundCollider` is cheaper: it
is a single broadphase volume where separate components are one each.
# VehicleController
Drives a car, truck, kart or buggy built from `WheelCollider`s. Put it on the
chassis, the dynamic rigid body the wheels sit under, and the player's
controls reach the wheels on keyboard, gamepad and touch alike: the controls
come from `@builtin::inputMaps.vehicle` through `Zin.controllers.vehicle`.
| Control | Keyboard | Gamepad | Touch |
|---|---|---|---|
| throttle | `W` / `S` | right / left trigger | stick, vertical |
| steer | `D` / `A` | left stick | stick, horizontal |
| brake | `Space` | south | button |
| handbrake | `Ctrl` | east | button |
| boost | `Shift` | north | button |
| recover | `R` | select | button |
| zoom | scroll | shoulders | pinch |
What each one does to the vehicle:
- **throttle** drives the driven wheels (`drive`: `all`, `front` or `rear`),
the torque fading to nothing at `topSpeed`. Pulled back while the vehicle
rolls forward it brakes; at a stop it reverses, up to `reverseTopSpeed`.
Pushed forward while it rolls backward it brakes too.
- **steer** turns the steered wheels, the pair nearest the chassis' forward
(its local -Z), at up to `maxSteerAngle` at rest, narrowing to
`steerAtTopSpeed` at `topSpeed`, turning at `steerRate`.
- **brake** brakes every wheel with `brakeTorque`; **handbrake** locks the
rear pair with `handbrakeTorque`.
- **boost** multiplies the drive torque and the top speed by `boost`.
- **in the air**, with no wheel on the ground, the chassis turns toward
level, its nose raised to `airPitch` by throttle and lowered by reverse or
brake, spinning at `airSpin` where it is steered; `airControl` is how fast
it gets there (0 lets it fly as it left the ground).
- **recover**, or lying on its side or roof slower than 1 m/s for
`recoverAfter` seconds, sets the chassis back on its wheels where it lay,
`recoverLift` above it, facing the way it faced.
- **zoom** changes how far the `camera`'s chase controller trails.
The component writes `motorTorque`, `brakeTorque` and `steerAngle` on the
wheels, each only when its value changes. Only the peer that owns the chassis
reads its controls.
## Fields
| Field | Default | Unit | Meaning |
|---|---|---|---|
| `inputMap` | `@builtin::inputMaps.vehicle` | | The controls it reads. |
| `takeControlOnAwake` | `true` | | Take the controls as it wakes; `false` waits for `takeControl()`. |
| `drive` | `all` | | Which wheels the throttle drives. |
| `motorTorque` | 1500 | N·m | Drive torque per driven wheel at full throttle. |
| `reverseTorque` | 800 | N·m | Drive torque per driven wheel at full reverse. |
| `brakeTorque` | 3000 | N·m | Brake torque per wheel at full brake. |
| `handbrakeTorque` | 6000 | N·m | Brake torque per rear wheel under the handbrake. |
| `topSpeed` | 25 | m/s | Forward speed where the drive has faded to nothing. |
| `reverseTopSpeed` | 8 | m/s | Reverse speed where the drive has faded to nothing. |
| `boost` | 1.6 | × | Drive and top speed multiplier while boosting. |
| `maxSteerAngle` | 30 | degrees | Full lock at rest. |
| `steerAtTopSpeed` | 8 | degrees | Full lock at `topSpeed`. |
| `steerRate` | 150 | degrees/s | How fast the wheels turn toward the lock asked for. |
| `airControl` | 4 | rad/s² | How fast the chassis turns in the air toward the attitude asked for. |
| `airPitch` | 20 | degrees | Nose angle held in the air at full throttle or full reverse. |
| `airSpin` | 90 | degrees/s | Spin about the up axis at full steer in the air. |
| `recoverAfter` | 3 | s | Time overturned before it rights itself; 0 waits for the control. |
| `recoverLift` | 1 | m | Height above where it lay that a righted vehicle is set down. |
| `camera` | | | A camera with `@builtin::controller.chase` that zoom moves. |
## Methods
- `takeControl()`: the controls go live; the walking controls stand down.
- `releaseControl()`: the controls go back; the wheels coast straight.
- `hasControl() -> boolean`
- `driveState()`: `{ throttle, steer, brake, handbrake, boost, speed,
groundedWheels, wheels, steerAngle, overturned, recoveries, lastRecover }`.
## Example
A monster truck: a heavy chassis on four big wheels with long travel, the
suspension force raised to carry the landings.
```luau
local truck = entity.spawn("truck")
truck.position = { 0, 2.2, 0 }
truck.component.add("Physics", { kind = "dynamic", mass = 2000 })
truck.component.add("BoxCollider", { half = { 1.3, 0.4, 2.2 } })
for _, at in { { -1.5, -1.7 }, { 1.5, -1.7 }, { -1.5, 1.7 }, { 1.5, 1.7 } } do
local wheel = entity.spawn("wheel", { parent = truck.id })
wheel.localPosition = { at[1], -0.2, at[2] }
local tyre = entity.spawn("tyre", { parent = wheel.id })
tyre.component.add("Model", { model = "cylinder" })
tyre.localRotation = { 0, 0, 0.7071068, 0.7071068 }
tyre.localScale = { 1.6, 0.6, 1.6 }
wheel.component.add("WheelCollider", {
radius = 0.8, width = 0.6,
suspensionDistance = 0.8, springRate = 25000, damperRate = 5000,
maxSuspensionForce = 40000,
visual = tyre,
})
end
truck.component.add("VehicleController", { motorTorque = 4000, brakeTorque = 8000, topSpeed = 25 })
```
A car the player gets into takes the controls when the driver enters:
```luau
car.component.add("VehicleController", { takeControlOnAwake = false })
car.component.get("VehicleController"):takeControl() -- driver in
car.component.get("VehicleController"):releaseControl() -- driver out
```
# controller.character
Character-oriented controller component for builtin movement-driven actors.
# Video
Plays an MP4 video onto a plane mesh attached to this entity. Creates a hidden child entity with a `Model` + `Material` that displays the video render target — same pattern as `UiPanel`'s 3D mode. The video renders to a standard render target (`rt:video_N`), so it can also be used in any material via `material.setTexture(...)`.
Public fields: `url`, `loop`, `autoplay`, `rate`, `width`, `height` (0 = auto aspect), `maxResolution`, `showOverlay`.
Methods: `play()`, `pause()`, `stop()`, `seek(time)`, `getInfo()`, `getHandle()`.
```luau
entity(id).component.add("Video", {
url = "@builtin/videos/test_bars.mp4",
loop = true,
autoplay = true,
})
```
# Birds Eye Camera
Strict overhead camera — looks straight down (-Y world) and follows the target's X/Z position. No yaw, no pitch, no orbit; only `height` (zoom).
## Vs. the other top-down behaviours
| Behavior | What it does | When to pick it |
|---|---|---|
| `birds_eye` | locked overhead, follows target XZ only | tactical / map views, navigation overlays |
| `rts` | overhead with yaw + WASD pan + RMB rotate | strategy games where the user drives the view |
| `isometric` | fixed angle (not straight down) + WASD pan | classic isometric game-feel |
## Fields
| Field | Default | Notes |
|---|---|---|
| `inputMap` | `@builtin::inputMaps.cameraRig` | The map whose `zoom` control adjusts the height. Point it at a world's own map to rebind this camera. |
| `target` | `""` | Entity ID. Falls back to `Camera.follow` when empty. |
| `height` | `25.0` | World-Y distance above target. |
| `minHeight` | `5.0` | Scroll-zoom lower bound. |
| `maxHeight` | `120.0` | Scroll-zoom upper bound. |
| `zoomSpeed` | `0.15` | Multiplicative per scroll tick (`exp(-scroll * zoomSpeed)`). |
| `smoothing` | `0.18` | Per-second blend. `0` = instantly stuck to target. `1` = never moves. |
| `offsetX`, `offsetZ` | `0.0` | Constant XZ offset added to the target's position. |
## Use as a camera behavior
```lua
-- World-default
world.camera_default = asset.ref("@builtin::controller.birds_eye")
-- Per-scene swap
layers.active.camera.behavior = asset.ref("@builtin::controller.birds_eye")
layers.active.camera.follow = layers.active.players.localPlayer
```
The component carries the `cameraBehavior` tag in `.metadata`, so the slot validation in `Camera.behavior = ref` will accept it.
## Controls
- **Scroll** — adjust height (zoom in/out)
- No rotation, no panning — the target moves; the camera just follows.
# CompoundCollider (component)
One collision shape built from many box children, sharing a single broadphase
volume and a single material.
## When to reach for it
| Situation | Use |
|-----------|-----|
| A handful of shapes, each wanting its own material or trigger flag | Separate collider components on child entities |
| Many boxes sharing one material, such as a voxel chunk or a destructible wall | `CompoundCollider` |
The difference is cost. Separate collider components are one broadphase entry
each; a compound is one entry for the whole set, with contacts still resolved
per child. That crossover is what makes a compound the right answer for a voxel
chunk and the wrong one for a character's head.
## Children
Each child is `{ offset = vec3, half = vec3 }` in the entity's local space. The
whole set scales with the entity's transform.
```lua
entity.find("chunk").component.add("CompoundCollider", {
children = {
{ offset = { 0, 0, 0 }, half = { 0.5, 0.5, 0.5 } },
{ offset = { 1, 0, 0 }, half = { 0.5, 0.5, 0.5 } },
},
})
```
## Fields
| Field | Meaning |
|-------|---------|
| `children` | The box set. |
| `isTrigger` | Report overlaps without blocking, for every child. |
| `friction` | Surface friction, shared by every child. |
| `restitution` | Bounciness, shared by every child. |
# third_person
Third-person camera controller. Orbits behind/above a target entity. Right-click drag to rotate around the target, scroll to zoom. Falls back to a static center if no target.
Public fields: `target`, `centerX`, `centerY`, `centerZ`, `height` (vertical offset above target origin), `distance`, `minDistance`, `maxDistance`, `sensitivity`, `zoomSpeed`, `minPitch`, `maxPitch`, `yaw` (exposed for parent scripts).
Controls: RMB drag to orbit while nothing else in the world binds the right
button, scroll to zoom.
```luau
entity(id).component.add("third_person", { target = "player", distance = 5 })
```
# Physics
Adds rigid body dynamics to an entity. Body kind is `"dynamic"` (affected by forces), `"static"` (immovable), or `"kinematic"` (moved by script, affects other bodies).
Public fields: `kind`, `gravityScale`, `mass`, `linearDamping`, `angularDamping`, `ccd`, `interpolation`, `lockRotationX/Y/Z`, `lockTranslationX/Y/Z`.
`ccd` (continuous collision detection) sweeps the body along its motion so it cannot pass through thin geometry between two steps. It is off by default: the sweep runs on every body that travels further than its own thickness in one step — most of a collapsing structure or a debris burst — and it dominates the physics budget when a scene has many such bodies. Turn it on for the individual bodies that would visibly tunnel: bullets, thrown props, a wrecking ball.
```luau
entity(id).component.add("Physics", { kind = "dynamic", ccd = true })
```
`mass` is the body's total mass in kilograms. At its default of 0 the mass comes from the collider volume at 1 kg per cubic metre, so a bigger collider makes a heavier body; a value greater than zero sets the total mass exactly.
Methods: `:applyForce(x, y, z)`, `:applyImpulse(x, y, z)`, `:applyTorque(x, y, z)`, `:setVelocity(x, y, z)`, `:addVelocity(dx, dy, dz)`.
```luau
entity(id).component.add("Physics", { kind = "dynamic" })
entity(id).component.get("Physics"):applyImpulse(5, 0, 0)
```
# Collider
Gives an entity a collision shape. Can exist without a `Physics` component (static collision geometry like walls/floors). For dynamic bodies, `"mesh"` is automatically promoted to `"convex"` since concave trimesh colliders only work with static bodies in Rapier3D.
Shapes: `"box"`, `"sphere"`, `"capsule"`, `"convex"`, `"mesh"`, `"compound"`.
Sizing: dimensions are half-extents in the entity's local space, and the collider is multiplied by the entity's world scale — it scales with the entity, the same way its mesh does. When dimensions are omitted, the shape derives from the entity's model bounds (still scaled), so a bare `Collider` matches the rendered geometry. Providing dimensions (`size`/`sizeX`/`sizeY`/`sizeZ`, `radius`, `height`) makes them authoritative over the model bounds — but the world-scale multiply still applies. So a box with `sizeX = 0.5` on an entity scaled `[50, 0.2, 50]` is a `25 x 0.1 x 25` half-extent collider, not `0.5`; pass local half-extents and let scale do the rest (or leave the entity at scale 1 to size in world units). The rule is the same with or without a `Physics` component.
Public fields: `shape`, `sizeX`, `sizeY`, `sizeZ` (box half-extents, local — scaled by the entity), `radius` (sphere/capsule), `height` (capsule), `isTrigger`, `friction`, `restitution`.
Methods: `:setShape(shape)`, `:setSize(size)` (accepts `{x,y,z}` array or `{x=,y=,z=}` map), `:setRadius(r)`, `:setTrigger(bool)`.
```luau
entity(id).component.add("Collider", { shape = "box" }) -- box fitted to the entity's model
entity(id).component.add("Collider", { shape = "box", sizeX = 2, sizeY = 4, sizeZ = 0.2 })
entity(id).component.add("Collider", { shape = "sphere", radius = 0.5 })
entity(id).component.add("Collider", { shape = "capsule", radius = 0.3, height = 2 })
```
# Light
Adds a light source to an entity. The entity's world transform determines the light's position (point) or direction (directional). Point light positions are auto-synced from that world transform, so a light on a child entity burns where the entity stands — the position `entity.position` reports. A point light's `intensity` is on one scale with the `SpotLight` component's `intensity`/`brightness`: the same number at the same `radius`/`range` puts the same light on a surface either kind faces from the same place, and a spot spends it on the cone it opens on rather than all around itself.
Kinds: `"point"`, `"spot"`, `"directional"` (the scene's sun), `"ambient"`, `"distant"` (a parallel light beside the sun).
Public fields: `kind`, `colorR/G/B`, `intensity`, `radius`, `directionX/Y/Z`, `castsShadows`, `lightChannels`, `mobility`. `range`, `color` and `direction` are aliases accepting the composite/renamed forms.
Methods: `:setColor(color)` (color: `{r, g, b}` array or `{r=, g=, b=}` map), `:setIntensity(i)`, `:setRadius(r)`, `:setDirection(dir)`, `:setKind(lightKind)`.
`kind = "spot"` opens a cone along the entity's forward axis, at the engine's default 30° outer and 20° inner half-angles; the `SpotLight` component is the one that carries the cone angles and the `face` axis as fields. `"directional"` and `"distant"` are parallel lights: they arrive from the same direction at every point in the world and no distance attenuates them, so their `intensity` reads against the sun's rather than against a point light's. The `SpotLight` README carries the rest of how to balance a mixed point-and-spot rig.
```luau
entity(id).component.add("Light", { kind = "point", intensity = 2, radius = 10 })
entity(id).component.add("Light", { kind = "directional", direction = {-0.5, -1, -0.3} })
```
# shadowSystem
Cascaded shadow map (CSM) **policy** for crisp directional shadows across a
large view distance. The rendering *mechanism* lives in the engine; this
package is the per-frame policy that drives it.
## Layout
- `shadowSystem.module` — the policy: PSSM frustum splitting, per-cascade
bounding-sphere fit + texel-snap stabilization, and the matrix math. Pushes
per-cascade light-space matrices + split distances to the renderer via
`__rendering.setShadowCascades` each frame.
- `ShadowCascadeDriver.component` — attach ONE to a scene entity to drive the
module every frame from the active camera + sun.
- `depthBounds.renderFeature` — reduces the frame's depth target to the
nearest and farthest view depth any surface occupies, and hands the result to
the module.
## Flow
1. Add a `ShadowCascadeDriver` component to any entity in the scene.
2. Each frame it slices the camera frustum into N cascades (N from
`render.shadow_cascades`), fits a stable light-space ortho to each, and
pushes the matrices + split distances to the renderer.
3. The renderer renders one shadow depth pass per cascade; the fragment shaders
pick a cascade by the fragment's distance from the camera.
## Fitting splits to the depth that is there
Splits are placed over a range. Taken from the camera's near and far planes
that range is whatever the scene *could* occupy; taken from the depth buffer it
is what the scene *does* occupy. A courtyard that stops at 40 m under a 200 m
shadow distance spends most of its cascade texels on empty space.
```lua
entity.find("scene").component.add("ShadowCascadeDriver", {
fitToDepth = true, -- place splits over the measured depth
depthMargin = 0.1, -- room past the far end for a frame of movement
})
```
The **far** end is what moves. The near end stays where the camera puts it: the
renderer picks a cascade from a fragment's distance and cascade 0 is fitted to
the range's start, so raising that start would leave anything nearer sampling a
box it does not sit inside.
The measurement is one frame old, because a GPU read-back cannot land in the
frame that produced it. `depthMargin` is the room that gives something to move
away in before it falls outside the last cascade.
With `fitToDepth` off, no reduction is dispatched and splits are placed exactly
as before.
Purely additive: with no driver the renderer falls back to its legacy single
camera-fit directional shadow. Quality is controlled by the
`render.shadow_resolution` / `render.shadow_cascades` / `render.shadow_distance`
/ `render.shadow_split_lambda` world settings. `render.shadow_distance` is a cap
on the range the splits run over, and its default of 0 leaves that range to the
frame — the depth its own renderables reach.
## Documentation
See `shadowSystem.module/README.md` and `ShadowCascadeDriver.component/README.md`.
# areaLights system
Lights that emit from a surface rather than from a point.
Every screen, neon sign, glowing window, ceiling panel and lantern face is an
area emitter. Standing a point light in the middle of one gets the brightness
roughly right and the character completely wrong: a point puts a pinpoint
reflection on the floor and a radial falloff on the wall, where the real thing
puts the shape of the emitter and a falloff that follows its extent.
## Using it
An emitter that should follow an entity's face is a component:
```lua
local id = entity.spawn("tv").id
entity(id).component.add("SurfaceLight", {
width = 2.0,
height = 1.2,
face = "front", -- front | back | up | down | left | right
intensity = 6.0,
range = 12,
})
```
The rectangle turns with the entity, so a tilted screen lights the direction it
actually points. Every field is live, and an `intensity` of 0 removes the light.
For code placing emitters directly:
```lua
local surfaceLight = require("@builtin::systems.areaLights.surfaceLight")
surfaceLight.set("strip", {
position = { 0, 5, -12 },
right = { 1, 0, 0 }, up = { 0, 1, 0 },
width = 0.6, height = 7,
color = { 1, 0.72, 0.35 }, intensity = 5,
})
surfaceLight.remove("strip")
surfaceLight.configure({ diffuse = 1, specular = 0.5 })
surfaceLight.clear()
```
## What the knobs do
| field | effect |
|---|---|
| `width` / `height` | Size of the emitting rectangle, world units. The shape of the light is the shape of this. |
| `face` | Which way the entity emits. |
| `range` | How far the light carries, with a smooth cutoff at the end. |
| `intensity` | Brightness. 0 removes the light. |
| `colorR` / `colorG` / `colorB` | Emitter colour. |
| `offset` | Distance the rectangle sits proud of the entity's origin, along the face's normal. |
| `diffuse` / `specular` | Scales on the two terms — the wash across a surface, and the reflected shape of the emitter. |
Shape is the point. A wide panel puts a broad pool on the floor; a narrow strip
puts a narrow elongated one. That difference is not reachable with a point
light at any brightness.
At most 32 emitters are held at once, because the shader loops every light for
every pixel.
## How it renders
`areaLighting.renderFeature` runs one compute pass after lighting. Diffuse is
the exact irradiance of the quad — Lambert's formula over its four edges — which
matters where a point approximation fails hardest: a wide emitter close to a
wall. Specular uses the representative point on the rectangle nearest the
reflection ray, with the lobe widened to cover the emitter's solid angle and its
peak scaled down to match, so a large emitter reads broader rather than
brighter.
## Files
```
areaLights.package/
SurfaceLight.component/ -- an emitter on an entity's face
surfaceLight.module/ -- the emitter registry and scales
areaLighting.renderFeature/ -- the compute pass
```
# controller.editor
The viewport camera you author from. It is the behavior on the editor scene's `EditorCamera` — a world's players never meet it, which is why it reads the editor's controls rather than a game's.
Rotates the pivot child exposed by a sibling `Player` component if present, otherwise rotates self.
Public fields: `moveSpeed`, `sensitivity`, `speedMin`, `speedMax`.
Controls come from the map on `inputMap`, `@builtin::inputMaps.editor` by default: `move` to fly, `flyVertical` to rise and drop, `look` to aim while flying, `zoom` to adjust move speed. Rebind them by pointing `inputMap` at another map.
```luau
entity(id).component.add("controller.editor", { moveSpeed = 10 })
```
# WASDInput
Action-to-input-bus driver. Reads the `move` axis and `jump` action
each frame, remaps `move` to the active viewport camera's
forward/right basis (via `camera.main()` + `entity(camId).transform.forward`),
and writes the resulting world-space horizontal velocity to a target
component's input fields:
- `target.inputX`, `target.inputZ` — world-space velocity × speed
- `target.inputJump` — boolean, true while the jump action is held
Defaults to writing to `CharacterController` on the same entity, but
the field names are the contract — any component that exposes
`inputX/inputZ/inputJump` is a valid sink (vehicles, hovercraft,
custom CCs).
## Why generic
Independent of which camera is active and independent of the visual
layer:
- Works with any camera (third-person, top-down, FPS, fixed, vehicle).
- Works with no camera (falls back to world-axis WASD).
- Works without `Locomotion` or any animation system — CC moves,
visualization is opt-in.
- Works without a follow rig — `camera.main()` is the seam, not a
specific camera component.
## Public fields
| Field | Default | Purpose |
|---|---|---|
| `speed` | `5.0` | Multiplier on the unit-length input vector |
| `targetComponent` | `"CharacterController"` | Component to write `inputX/inputZ/inputJump` onto |
A custom control scheme is authored as an inputMap that rebinds the
`move` axis and `jump` action — not per-instance key fields on this
component.
## Example
```lua
-- Player with CC + WASDInput + any camera
entity(playerId).component.add("CharacterController")
entity(playerId).component.add("WASDInput")
-- Active viewport camera (any kind)
local cam = entity.spawn("cam", { parent = playerId })
cam.component.add("Camera", { fov = 60, priority = 0 })
cam.component.add("orbital_follow", { follow = playerId })
-- Press W → CC.inputX/inputZ are written camera-relative each frame
-- → CC translates the body → MovementState reports velocity →
-- (optional) Locomotion rotates the visible mesh + plays anims.
```
# Cinematic Camera
Wide-FOV, slow-lerp follow with a configurable over-the-shoulder offset. Intended for cutscenes, dialogues, and walking moments where you want a more composed framing than `orbital_follow` and softer motion than `chase`.
The camera holds a right-shoulder offset relative to the target's facing and slow-lerps both position and look-at point so quick player movements feel cinematic rather than jittery.
## Vs. the other follow cams
| | `orbital_follow` | `chase` | `cinematic` |
|---|---|---|---|
| Orbit (mouse) | yes | no | no |
| Position lag | n/a | low (sticky) | high (cinematic) |
| Look-at lag | n/a | low | even higher than position |
| Composition | centered behind | locked behind | shoulder-framed |
| Best for | character action | vehicles, fast | cutscenes, walking, dialogue |
## Fields
| Field | Default | Notes |
|---|---|---|
| `target` | `""` | Entity ID. Falls back to `Camera.follow`. |
| `distance` | `4.5` | Trail distance behind target. |
| `height` | `1.8` | Vertical offset above target origin. |
| `shoulderX` | `0.6` | Right-shoulder offset (negative = left shoulder). |
| `fov` | `60.0` | Pushed once to the sibling `Camera` component on `awake`. |
| `positionSmoothing` | `0.35` | High = slow, cinematic. Per-second blend. |
| `lookAtSmoothing` | `0.45` | Higher than position so framing reads as "anticipating". |
## Use as a camera behavior
```lua
world.camera_default = asset.ref("@builtin::controller.cinematic")
-- Or per-scene
layers.active.camera.behavior = asset.ref("@builtin::controller.cinematic")
layers.active.camera.follow = layers.active.players.localPlayer
```
The component carries the `cameraBehavior` tag in `.metadata`.
## Notes
- No user-driven rotation, no scroll-zoom — `cinematic` is set-it-and-let-it-frame.
- Swap `shoulderX` for a left-shoulder shot. Sign-flip in script for an interactive switch.
- Pairs especially well with `humanoid_visual` + `standard_controller` for narrative gameplay.
# edui.inspector
The Inspector as an ordered list of **sections**. A subject — an asset, or a
component on an entity — gets generic sections, merged with the sections its
declarer's `inspector.luau` adds or replaces, in the order that file names.
```luau
local Inspector = require("@builtin::editor.edui.inspector")
local sections, ctx = Inspector.assetSections(ref, asset.inspect(ref))
local nodes = Inspector.render(sections, ctx) -- section cards
local nodes = Inspector.renderAsset(guid) -- the same, from an asset id
local sections, ctx = Inspector.componentSections(entityId, "Camera")
local nodes = Inspector.render(sections, ctx, { nested = true }) -- a component card's body
```
## Sections
A section is `{ id, title, icon?, open?, build(ctx) -> { node } }`. The generic
asset sections, in default order, are `identity`, `problems` (present only
when `asset.validate` reports any), `tags`, `move`, `settings`, `preview` and
`operations` (see `assetSections.module`). A type declares its
own in the file its `type.yaml` names:
```yaml
inspector: { type: "inspector.luau" }
```
That file returns `{ sections?, replace?, order? }`: sections it adds (placed
before `operations`), generic sections it replaces by id, and the ids to put
first. `material.assetType/inspector.luau` is a worked example, and
`@builtin::assetTypes.assetType.shared.inspector` documents the declaration.
A component on an entity has one generic section, `settings`: its public
fields as typed controls over the live component, three-number colour and
vector aliases as one grouped control, each edit checked against the schema
and written as one undoable operation. A component that ships an
`inspector.luau` beside its `init.luau` (named by `component.assetType`'s
`inspector: { instance: "inspector.luau" }`) adds, replaces and orders
sections of its card the same way; its sections read `ctx.component` and
`ctx.selection.id`. `SceneModule.component/inspector.luau` is a worked example.
A declaration that cannot be read, or that names a section that is not there,
shows its problem as a first `inspector-problem` section while every other
section still renders. Every build runs through
`Inspector.buildSection(section, ctx) -> (rows, problem?)`: a build that raises,
or returns anything but a list, shows its error inside that section alone.
## The Context
Every `build(ctx)` receives one Context, made by `Inspector.valuesContext`:
| Field | What it is |
|---|---|
| `ref` | the inspected asset (on an entity, the component asset) |
| `info` | `asset.inspect(ref)` |
| `schema` | `asset.schema(ref)`, `settingsOf` resolved |
| `schemaProblem` | why a declared schema did not resolve |
| `settings` | the current values |
| `setSettings(patch)` | checks the patch with `Field.checkSchema`, then writes it as one undoable operation named for the field; answers `(ok, violations)` |
| `rejected` | field path -> the message of its last rejected edit, for this subject only |
| `selection` | `{ scope = "asset" \| "entity", id, component?, name? }`; on an entity, `component` is the component's identity and `name` the name the entity lists it by |
| `component` | the live component proxy (entity scope) |
| `nameOf(id)` | the name an entity-reference field shows for an entity id (entity scope) |
| `widgets` | the edui framework table (`field`, `schemaField`, `section`, `button`, `text`, `vbox`, `hbox`, ...) |
| `keyPrefix` | the callback-key namespace for this subject |
| `refresh()` | rebuild the Inspector after a write that moves no revision |
| `actions` | when set, the ids of the Settings actions offered (`{ reset = true }`); nil offers every action that applies |
| `parts` | the building blocks a section composes: facts, lists, tables, asset and entity links, actions, a file opener, problems, swatches, a content-versioned cache, an lsp check and typed values (`parts.module`) |
`Inspector.valuesContext{ subject, schema?, schemaProblem?, values?, write, ref?,
keyPrefix?, info?, selection, component?, nameOf?, actions? }` is the one way to make a Context: `write(patch)`
performs a patch already checked against the schema.
## The rest of the API
- `Inspector.mergeSections(generic, decl, problem, file)` — the merge and order.
- `Inspector.componentSections(entityId, componentName)` — a component card's
sections and Context; `Inspector.componentSettingsRows(ctx)` is its generic
`settings` section's body.
- `Inspector.componentValuesSignature(entityId, componentName)` — text that
moves whenever one of the component's declared fields holds a different
value (an edit, an undo, a script's write); a panel folds it into its build
signature.
- `Inspector.typeSections(ref)` — the sections `ref`'s type declares, plus
Settings when it has values; what the inline editor under an asset chip shows.
No other generic section is built for it, so it runs no validation. Nil when
the type declares no `type:` inspector.
- `Inspector.render(sections, ctx, { nested = true })` — titled groups inside a
card, for the inline editor.
- `Inspector.signature(assetId)` — text that moves whenever what the asset
view shows can have moved: the asset folder's content version (the change
dispatcher moves it after a write inside the asset reaches its type's hook —
an edit, an undo or redo, a peer's write) and the settings values file's. A
panel folds it into its build signature; `edui.widgets.openEditorsSignature()`
is the same for every open inline editor.
- `Inspector.buildSection(section, ctx)` — runs one section's build and judges
it: `(rows, nil)`, or one error row and the problem text.
- `settingsSection.fields(ctx)` — the field rows of the whole schema without
the actions beneath them.
- `settingsSection.fieldRows(ctx, fields)` / `settingsSection.actionRow(ctx)`
— the two halves of `settingsSection.build`, for a section that shows part of
a schema and acts on all of it.
- The Context, Section and Declaration shapes are exported as types:
`Inspector.Context`, `Inspector.Section`, `Inspector.Declaration`.
- `Inspector.release(keyPrefix)` — forget the rejections, section
disclosures and parts state (action failures, file checks, and cached values
no other open Context read) kept under a Context's key prefix and the
prefixes nested in it; a closed inline editor calls it for its own.
- `Inspector.epoch()` — moves on every disclosure, rejection and write the
Inspector records; a panel folds it into its build signature.
- `Inspector.settingsSection` — the Settings section (`settingsSection.module`).
- `@builtin::editor.edui.inspector.presetActions` — the Settings section's
Save as preset / Apply preset… actions, and `presetsFor(declarer)`, the
presets whose target is a declarer. `preset.assetType/inspector.luau` is
the preset's own view: its target and its values edited through the
target's schema.
# controller.side_scroller
Side-scroller camera controller. Fixed-offset camera that follows a target from the side (classic 2.5D perspective). Maintains a constant position relative to the target and looks slightly ahead.
Public fields: `target`, `centerX`, `centerY`, `centerZ` (fallback), `offsetX`, `offsetY`, `offsetZ` (camera offset from target), `lookAheadX`, `lookAheadY`, `lookAheadZ` (look-at offset from target).
Controls: none — follows the target automatically.
```luau
entity(id).component.add("controller.side_scroller", { target = "player", offsetX = -8, offsetY = 3 })
```
# controller.first_person
First-person camera controller. Positions the camera at an entity's location (+ eye-height offset) and rotates with mouse look.
It resolves its follow target in this order: its own `target` field, then `Camera.follow` when `target` is empty, then the static `posX/posY/posZ` when neither is set. `Camera.follow` is the standard slot every behavior reads, so a first-person camera selected through `Camera.behavior` tracks whatever `Camera.follow` points at — the correct source for a player camera, whose body id is minted fresh per spawned clone and cannot be authored onto the behavior ahead of time.
Public fields: `target` (entity id to follow, empty = use `Camera.follow`, then `posX/Y/Z`), `posX`, `posY`, `posZ`, `offsetX`, `offsetY` (eye height above target origin), `offsetZ`, `sensitivity`, `minPitch`, `maxPitch`.
Controls: pointer lock to rotate (yaw + pitch), which the standard scheme
takes while the world runs. With the cursor handed back, an RMB drag rotates
too — for as long as nothing else in the world binds the right button.
```luau
-- Track the Camera's follow target (set Camera.follow, no id authored here):
entity(id).component.add("controller.first_person", { offsetY = 1.6 })
-- Or a static camera at a fixed position:
entity(id).component.add("controller.first_person", { posX = 0, posY = 1.6, posZ = 3 })
```
# ParentConstraint
Copies the full transform (position + rotation + scale) from a target entity — like parenting but without hierarchy. The entity stays independent in the scene tree.
Public fields: `targetEntityId`, `axisX`, `axisY`, `axisZ`, `weight`.
Methods: `setTarget(entityId)`, `setWeight(w)`.
```luau
entity(id).component.add("ParentConstraint", {
targetEntityId = playerId,
weight = 1.0,
})
```
# 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.
# effect
A set of visuals packaged as one thing: every impact a weapon makes, every stage
of an explosion, the muzzle flash, impact and tracer of a gun. Each visual is an
entry the effect plays by name. The graphs, the parts they are built from, and
the shaders, materials, compute shaders and textures they use all live inside
the effect.
## The shape on disk
<name>.effect/
effect.yaml what it plays: entry name -> one graph, or a list of graphs played together
graphs/ the graphs its entries play
parts/ graphs those graphs use as nodes, each one whole piece of the look
shaders/ shaders and shader modules its materials draw with
materials/
compute/ compute shaders its graphs run
textures/
README.md what this effect shows, what each entry plays, what each input changes
.metadata
settings.yaml its values for the settings below
preview.png its default entry at a representative moment
`effect.yaml`, `graphs/`, `README.md` and `.metadata` are required.
```yaml
plays:
flare: "~.graphs.flare"
impact:
- "~.graphs.impactFlash"
- "~.graphs.impactDebris"
default: flare
```
Inside an effect `~.x` names the effect's own `x`: `~.graphs.flare`,
`~.parts.crack`, `~.shaders.lava`, wherever the effect sits.
## Graphs and parts
A graph under `graphs/` composes what one entry shows. A piece worth building
and looking at on its own (the crack of a fissure, the spray, the smoke) is a
graph under `parts/`, called as a node:
```lua
local crack = g["~/parts/crack"]({ length = length })
```
A part is a whole graph, so it previews and plays alone: the crack without the
smoke in front of it. An operation several effects use (a streak look, a smoke
puff) is a `.procNode` outside the effect, used the way a module is required.
## Playing one
```lua
local beacons = asset.resolve("beacons", "effect")
local shown = beacons:play("flare", { root = mast, offset = { 0, 0.9, 0 }, inputs = { size = 0.4 } })
shown:set("size", 0.6)
shown:onFinished(function() print("done") end)
```
`root` is the entity it plays on and follows; `inputs` go to every graph of the
entry that declares them; the handle answers `set`, `pause`, `resume`, `seek`,
`getTime`, `stop`, `isFinished` and `onFinished`. A play finishes when every
graph of its entry reaches the end of its timeline; a graph that states
`realTime` alone has no end, so a play of it runs until `stop()`, and a
simulation that runs once states its frames
(`g:timeline({ realTime = true, frames = 120 })`). Plays are kept ready, built
ahead of time: `beacons:warm("flare", 6)` keeps six ready for a burst. An
`Effect` component, or a drop into the viewport, places an entry in a scene.
`entries()` and `entry(name)` read what it plays; `setEntry`, `removeEntry`,
`renameEntry`, `setDefault` and `newGraph` change it, keeping every line of
`effect.yaml` they do not touch.
## Settings
`pool`: how many plays of each entry are kept ready (default 2).
## In search
Each asset inside an effect is found on its own and names the effect it
belongs to. The effect itself is found by its preview, its README and its
entries.
# PlayerNameLabel
Drives this entity's `Text3D` with the owning player's name, read from the nearest ancestor `PlayerAvatar`'s synced `displayName` so every peer renders the same name without a local registry lookup. The label re-rasterises when the display name changes, and in play it hides itself on the local player's own avatar — name labels exist so other players are identifiable. An unowned avatar reads a placeholder.
Location: `src/lua/lib/components/PlayerNameLabel.component`
# Joint
Connects two bodies with a joint constraint. Each end is a rigid body (an entity with `Physics`) or a soft body (an entity with `SoftBody`).
Joint kinds: `"fixed"`, `"hinge"`, `"ball"`, `"prismatic"`, `"spring"`, `"rope"`.
Public fields: `kind`, `connected`, `localAnchorX/Y/Z`, `remoteAnchorX/Y/Z`, `axisX/Y/Z`, `stiffness`, `damping`, `restLength`, `maxDistance`, `breakForce`, `breakTorque`, `region`, `remoteRegion`.
Methods: `:setMotor(targetVelocity, maxForce)`.
```luau
entity(id).component.add("Joint", {
connected = frameEntityId,
kind = "hinge",
axis = {0, 1, 0},
})
```
A `"rope"` joint is a hard maximum-distance limit solved inside the physics step: the bodies move freely while the anchors are closer than `maxDistance` (slack rope) and are stopped from separating beyond it (taut rope). It requires `maxDistance > 0`, and `maxDistance` is reactive — writing it on a live joint retunes the limit in place, so a winch can reel a rope in or out without recreating the joint.
```luau
entity(ballId).component.add("Joint", {
connected = hookEntityId,
kind = "rope",
maxDistance = 8,
})
```
A soft body end is anchored on the body as a whole (its frame, the rigid motion that best fits its particles), or on a region of its particles: `region` for this entity, `remoteRegion` for the connected one, each `{ box = { min, max } }` (every particle between the corners) or `{ nearest = point }` (the one particle nearest the point), in the entity's unit box (-0.5 to 0.5 on each axis) unless `space = "world"`. A region end's anchor is measured from the centre of its particles. Motors, `breakForce` / `breakTorque` and `Physics.jointReaction` work the same at either kind of end, and the joint is removed with either body.
```luau
-- a soft punching bag hung by its top particle from a fixed hook
bag.component.add("Joint", { connected = hook.id, kind = "ball", region = { nearest = { 0, 0.5, 0 } } })
-- two jellies held face to face, breaking under 200 N
a.component.add("Joint", {
connected = b.id, kind = "fixed", breakForce = 200,
region = { box = { min = { 0.4, -0.5, -0.5 }, max = { 0.5, 0.5, 0.5 } } },
remoteRegion = { box = { min = { -0.5, -0.5, -0.5 }, max = { -0.4, 0.5, 0.5 } } },
})
```
# ProceduralSky
The editable atmospheric sky: a day/night gradient with a sun disc, glow, stars
and a moon, all derived procedurally from the directional light's elevation. Add
it to an entity like a light:
```lua
entity.spawn("sky").component.add("ProceduralSky", { timeOfDay = 18.5 })
entity.spawn("sky").component.add("ProceduralSky", { preset = "sunset" })
entity.spawn("sky").component.add("ProceduralSky", {
zenithColor = { r = 0.05, g = 0.1, b = 0.3 },
horizonColor = { r = 0.9, g = 0.4, b = 0.2 },
})
```
A `ProceduralSky` owns its material outright: it registers a **runtime GPU
material** of its own over the builtin procedural-sky shader and pushes every
visual parameter (colours, sun, stars, moon, turbidity, exposure) straight to
that record. The component's fields **are** the material definition — no
`.material` asset backs it, so retuning a scene's sky changes that scene's sky
and nothing else. The renderer reads the values from the material's group(1)
uniform like any other material — there is no sky-specific render path.
Those values also travel the native `Sky` bridge into the scene sky config,
alongside the time-of-day / auto-cycle / sun-sync controls. That config is what
`sky.get()` reports and what a saved scene records, so both name the sky being
drawn.
With `syncSunToLight` on (the default), the sky's `timeOfDay` is what the scene
is lit by: the scene's directional light takes its direction, colour and
intensity from the sun's position, so the sun in the sky and the sun the scene
is lit by are the same sun through a whole day/night cycle. Turn it off for a
light the sky leaves alone.
The intensity that arrives is the day/night curve between night and noon, and
`sunPeakIntensity` is the noon end of it — `1.0`, the sky's own daylight, unless
a scene asks for more. A harder sun on water or snow is stated here; setting the
light itself does not survive, because the sky writes over it every time the
hour moves.
Like the directional light, the sky is conceptually **singular per scene**. A
`ProceduralSky` fully defines its material on `awake` (every parameter from its
own fields), so switching scenes never inherits a previous scene's overrides.
Day/night comes from the sun's elevation, so evening presets render dark —
drive the look by `timeOfDay` (which orients the sun).
## Fields
| Field | Type | Default | Meaning |
|---|---|---|---|
| `preset` | string | `""` | Named look applied before explicit fields: `clear_day`, `sunset`, `sunrise`, `overcast`, `night`. |
| `timeOfDay` | number | `14.0` | 0..24 hours; orients the sun and the day/night gradient. |
| `autoCycle` | bool | `false` | Advance `timeOfDay` each frame. |
| `cycleSpeed` | number | `60.0` | Game-seconds per real-second for the auto cycle. |
| `syncSunToLight` | bool | `true` | Drive the directional light from the sun. |
| `sunPeakIntensity` | number | `1.0` | What the sun's light measures at noon; the day/night curve runs from night up to this. |
| `zenithColor` / `horizonColor` / `groundColor` | color | — | Sky gradient colours. |
| `sunSize` / `sunIntensity` | number | `0.02` / `20.0` | Sun disc size + brightness. |
| `starsIntensity` | number | `1.0` | Night star-field intensity. |
| `moonSize` | number | `0.03` | Moon disc size. |
| `turbidity` | number | `4.0` | Atmospheric haziness. |
| `exposure` | number | `1.0` | Sky exposure multiplier. |
## Methods
| Method | Description |
|---|---|
| `sky:setTimeOfDay(hours)` | Set the time of day (0..24). |
| `sky:applyPreset(name)` | Apply a named look. |
| `sky:setZenithColor(c)` / `setHorizonColor(c)` / `setGroundColor(c)` | Set a gradient colour (`{r,g,b}` array or map; >1 auto-scales /255). |
# editorSelection
Named selection scopes for the editor. A scope is an independently
tracked, ordered set of typed refs plus a primary (the last ref added
or clicked). Distinct scopes never clobber each other, so viewport,
outliner, inspector and asset browser share one answer to "what is
selected" per kind.
A ref is `{ kind: string, id: string }` — a stable id, never a display
string, so rename/move never invalidates a selection.
## Exports
- `M.scope(name) -> Scope` — get/create a named scope handle.
- `M.set(scope, refs)` — replace refs in order; primary becomes the last.
- `M.get(scope) -> { Ref }` — refs in click order (fresh array).
- `M.primary(scope) -> Ref?` — last-clicked ref, or nil.
- `M.clear(scope)` — empty a scope.
- `M.toggle(scope, ref)` — add if absent, remove if present.
- `M.add(scope, refs)` — add each ref not already present; primary becomes the last added.
- `M.remove(scope, refs)` — remove each of `refs`; primary becomes the last remaining or nil.
- `M.contains(scope, ref) -> boolean` — membership by `(kind, id)`.
- `M.count(scope) -> number` — the scope's selection size.
- `M.subscribe(scope, fn) -> handle` / `M.unsubscribe(scope, handle) -> boolean`.
- `M.context() -> { scope, refs, primary }` — last-focused scope, for command ctx.
## Usage
```luau
local Selection = require("@builtin::modules.api.editor.selection")
local scope = Selection.scope("entity")
Selection.set(scope, { { kind = "entity", id = "player_1" } })
Selection.subscribe(scope, function() refreshInspector() end)
```
## Notes
- State lives in a fixed `_G` slot, seeded pre-seal by the boot chain
(`prelude.luau`); it survives hot-reload and edit↔play flips. Mutations
after boot write into nested tables only.
- `set` / `toggle` / `clear` mark their scope as last-focused, which drives
`context()` and therefore which scope commands act on.
- The service holds the editor's selection state in Luau. Refs handed back by
`get` / `primary` / `context` are copies, so a caller can hold or mutate them
without touching internal state.
# UiPanel
A UI panel component that renders a widget tree in one of three modes:
- `"2d"` — egui viewport overlay (HUDs, menus, debug panels).
- `"projected"` — screen-space UI at the entity's world position via `worldToScreen`. Always on top, no occlusion or lighting.
- `"3d"` — world-space billboard using egui render-to-texture. Respects depth/occlusion.
Public fields: `mode`, `layer`, `visible`, `source` (inline or `"lib::<path>"` / `"@..."` library module), `offsetX/Y/Z`, `maxDistance`, `fontSize`, `textColor`, `background`, `billboard3dScale`, `theme`, `rasterizeWidth`, `rasterizeHeight`.
Methods: `setTree(tree)`, `show()`, `hide()`, `toggle()`, `setMode(newMode)`, `getScreenName()`.
A library module named by `source` is mounted with `mount(ctx)`. When `mount` registers its own screen, the panel registers that screen again from its own code, so the screen is the panel's: its callbacks reach the panel's `onCallback`, which hands them to the `onCallback` the mount returned, and the screen is unregistered when the panel ends.
```luau
entity(id).component.add("UiPanel", {
mode = "2d",
layer = 10,
tree = { type = "label", props = { text = "HP: 100" } },
})
```
# Population
Draws a `.population` asset — one mesh at every transform the asset carries,
as ONE draw call per variant at any instance count.
```luau
entity.spawn("forest").component.add("Population", { source = "forest" })
```
The component owns what the draw costs: one GPU transform buffer and one
instanced-draw registration per variant. It builds them on awake, rebuilds
them when `source` changes or the asset's contents change on disk, and
releases them on destroy — so a scene reload replaces the draws instead of
stacking a second set, and despawning the entity takes them with it.
## Fields
| Field | Meaning |
|---|---|
| `source` | The `.population` asset to draw. |
| `castsShadows` | `false` renders the instances while blocking no light. |
## Reading what it draws
Both answers come back from the renderer, across every live registration:
```luau
local pop = entity.find("forest").component.get("Population")
pop:instanceCount() -- instances the engine reports drawing
pop:drawCalls() -- registrations — one per variant, at any instance count
```
## Where it comes from
`populationRef:instantiate(target?, opts?)` spawns exactly this: one entity
carrying this component. That is also what `Asset { source = <a population> }`
drives, which is how a baked procedural population reaches a scene — a root
record plus one child holding the reference, at any instance count.
The matrices a population carries are taken in this entity's space, so its
instances stand where the recipe placed them relative to the entity and move,
turn and scale with it.
# RenderFeatures
The scene's render features, on one entity: a list of `.renderFeature` assets,
each with the settings it runs under.
```lua
entity(id).component.add("RenderFeatures", {
features = {
{ feature = "@builtin::systems.reflections.ssr", settings = { intensity = 0.8, quality = "high" } },
{ feature = "@builtin::renderFeatures.outline" },
},
})
```
While the component is enabled every listed feature is live. A feature
something already runs — a script's `renderer.feature.create`, another entity's
list — is adopted and configured rather than stacked a second time. Each
feature runs its passes at the phase and order its own `render(ctx)` declares.
Disabling or destroying the component tears every one of them down.
Editing the list re-syncs it: an entry removed goes, one added comes up, and a
changed `settings` table reaches the live feature on its next frame.
`settings` are the knobs the feature's asset declares —
`renderer.feature.schema("@builtin::systems.reflections.ssr")` lists them with
their defaults. A knob the feature does not declare, or a value of the wrong
shape, is refused by name in the log.
Methods: `:getHandles()` — the live handles this component holds, keyed by the
feature asset's guid.
For code that tunes a feature at runtime — a quality slider, a cutscene —
`renderer.feature.find(ref)` and `renderer.feature.configure(handle, settings)`
reach the same knobs without an entity.
# MeshCollider (component)
Collision against mesh geometry. There are no size fields: the collider is the
mesh, scaled by the entity's transform.
## The two fits
| `fit` | Shape | Bodies | Concavity |
|-------|-------|--------|-----------|
| `"hull"` (default) | Convex hull of the mesh's points | Static and dynamic | Filled in, so a doorway becomes a slab |
| `"exact"` | The mesh's own triangles | Static | Followed exactly |
`"exact"` is for static bodies because a triangle mesh is a surface with no
interior: rapier cannot derive a mass tensor from one, and a dynamic body built
from it would fall through everything it touched. Setting `fit = "exact"` on a
dynamic body raises an error naming `"hull"` rather than quietly substituting it.
## Choosing the geometry
`mesh` names the asset to collide against. Left unset, the collider tracks
whatever mesh the entity renders, including a later swap. Set it to collide
against a cheaper proxy than the one on screen, which is the usual move for
detailed scenery where the visual mesh carries far more triangles than collision
needs.
```lua
entity.find("rock").component.add("MeshCollider", {}) -- what you see
entity.find("level").component.add("MeshCollider", { fit = "exact" }) -- concave, static
entity.find("rock").component.add("MeshCollider", { mesh = "rock_collision" }) -- cheap proxy
```
Because `mesh` is an asset reference, a collision proxy is visible to the asset
graph and survives a rename.
## Cost
Built shapes are shared: every collider asking for the same mesh, fit and scale
gets the one built shape, so a field of a hundred identical rocks pays for one
hull rather than a hundred.
## Fields
| Field | Meaning |
|-------|---------|
| `mesh` | Mesh asset to collide against. Unset tracks the entity's renderable. |
| `fit` | `"hull"` or `"exact"`. |
| `isTrigger` | Report overlaps without blocking. |
| `friction` | Surface friction. |
| `restitution` | Bounciness. |
# Camera
Manages viewport priority, render-to-texture, and capture. State is stored in the native `Camera` ECS component; the Rust camera system handles render scheduling and render targets.
Public fields: `fov`, `near`, `far`, `priority`, `textureHandle` (the guid of the texture the camera renders into; empty = main viewport), `renderLayers` (which render layers this camera draws — a space-separated spec of names, e.g. `"all"`, `"all !ui"`, or `"default sky"`; `ui`/`sky`/`debug`/`EditorUI` are built-in layers), `postProcessing` (whether this camera runs the post-process chain), `debugChannel`, plus the behavior slot below.
Methods: `:lookAt(target)` (entity id string OR `{x, y, z}` table), `:render()`, `:capture()`, `:setTargetTexture(tex?)` (a `renderer.texture.create` handle to render into, or nil for the viewport).
```luau
entity(id).component.add("Camera", { fov = 90, priority = 10 })
entity(id).component.get("Camera"):capture()
```
The scene's play-mode camera is reached as `layers.active.camera`, a handle that
reads and writes the fields above on whichever entity currently carries them.
`layers.active.camera.entity` is the entity ref for that camera and
`layers.active.camera.entityId` its id string, so a script that needs to attach
something to the camera — an `AudioListener`, a child entity — goes through the
ref:
```luau
local cam = layers.active.camera
cam.fov = 70 -- the camera's settings
cam.entity.component.add("AudioListener") -- the entity carrying them
```
## How the camera moves: `behavior` and `follow`
A `Camera` does not move itself. `behavior` names a component that does, and setting it attaches that component to this entity. Clearing it detaches whatever was attached.
```luau
local cam = entity(id).component.get("Camera")
cam.behavior = asset.ref("@builtin::controller.orbital_follow", "component")
cam.follow = playerBody
```
The attached behavior is an ordinary component on the camera's entity, so it is tuned the way any component is: read it with `component.get` and set its fields there. A `component.add` of the same type configures the instance the slot attached instead of adding a second one: the values it passes are written onto that instance and its proxy is returned. That add is the form a scene `build.luau` tunes the behavior with, because its values are saved with the scene and the loaded Camera's behavior takes them back. A key the component reads only as it is created (one of its `initKeys`, a private field) refuses the add and names the key.
```luau
cam.behavior = asset.ref("@builtin::controller.third_person_follow", "component")
local rig = entity(id).component.get("third_person_follow")
rig.cameraDistance = 6
-- the same, as an add of the type the slot attached
entity(id).component.add("third_person_follow", { cameraDistance = 6 })
```
`follow` is the standard slot every shipped behavior reads. Set the follow target on the **Camera**, not on the behavior, so swapping behaviors keeps it. A behavior that finds its own `follow` field empty falls back to this one, which is what lets a rig keep tracking the player across a behavior swap.
`followResolves` answers whether that slot names an entity that is live — `true` while it names a live one or names nothing at all, `false` once the target is despawned or the id names no entity. A rig whose target does not resolve holds its last pose, and this is the field that tells it from a rig posed correctly on a subject that has not moved. `camera.get` carries the same value beside `follow`, and a write naming an id with no entity behind it draws a warning where it lands.
```luau
cam.followResolves -- false once the followed entity is gone
tools.use("camera", "get", id).followResolves -- the same answer off the tool
```
The shipped behaviors live under `@builtin::controller.*`: `orbital_follow`, `third_person_follow`, `first_person`, `free`, `orbit`, `chase`, `isometric`, `rts`, `birds_eye`, `side_scroller`, `cinematic`, `menu`.
## Writing your own
Any component can be a camera behavior. Write one that moves its own entity and attach it the same way:
```luau
cam.behavior = asset.ref("MyChaseCam", "component")
```
`follow` lives on the Camera, so a behavior gets no property notification of its own when the target changes. Declare `onFollowChanged(newFollow, oldFollow)` to be told the moment it does — the Camera calls it on the component it attached, which is what lets a rig re-pose on the new subject at once. A behavior that reads `Camera.follow` on its own schedule declares nothing and is attached the same way.
```luau
typed function public:onFollowChanged(newFollow: any, oldFollow: any)
-- pose this entity against the new target
end
```
To make it appear in the discovery catalog alongside the shipped ones, declare the `cameraBehavior` tag in the component's `.metadata`:
```json
{ "tags": ["cameraBehavior"] }
```
The tag governs **discovery**, not attachment. A tagged component is listed by `layers.active.camera.behaviors` and is what tooling offers when something asks "which camera behaviors exist"; an untagged component attaches just as well and simply stays out of that list. Tag the ones you want other people (and agents) to find.
```luau
for name, ref in pairs(layers.active.camera.behaviors) do
print(name, ref.identity)
end
```
## Texture colour space
`textureColorSpace = "display"` applies the display transform when rendering into a texture. `"linear"` writes scene-linear values instead. `postProcessing` independently controls the effects chain in either mode. Floating-point targets retain values above one; normalized targets clamp to their representable range. The main viewport uses display encoding.
# CapsuleCollider (component)
A capsule collision shape: a cylinder along the entity's local Y axis capped by
hemispheres. This is the shape characters use, because a rounded bottom slides
over small steps instead of catching on them.
## Sizing
`radius` and `height` are in the entity's local space, multiplied by the
entity's world scale. `height` is the full height of the cylindrical section,
excluding the caps. Leave both unset and the capsule derives from the entity's
model bounds.
```lua
entity.find("player").component.add("CapsuleCollider", { radius = 0.3, height = 1.6 })
entity.find("pillar").component.add("CapsuleCollider", {})
```
## Fields
| Field | Meaning |
|-------|---------|
| `radius` | Capsule radius. Unset derives from model bounds. |
| `height` | Cylindrical section height. Unset derives from model bounds. |
| `isTrigger` | Report overlaps without blocking. |
| `friction` | Surface friction. |
| `restitution` | Bounciness. |
# Tessellation
Compute-shader mesh subdivision with material-driven displacement.
`Tessellation` sits next to `Model` on a renderable. The engine's
`tessellation` system reads the entity's current mesh geometry,
subdivides each triangle into `4^level` micro-triangles, displaces the
result along the interpolated normal from the entity material's
displacement map, and swaps the entity's `MeshInstance.mesh` for the
displaced derivative. The material is bound per-entity, so the
tessellated surface renders with the same PBR parameters and normal maps
as the source.
```luau
entity.find("floor").component.add("Tessellation", {
level = 4,
displacementScale = 0.05,
})
```
## Fields
| Field | Type | Purpose |
| --- | --- | --- |
| `level` | number | Subdivision level (0..=6). Clamped when out of range, and `getStatus()` answers `"clamped"`. `4^level` micro-tris per source tri. Under `mode = "adaptive"` this is the ceiling. |
| `displacementScale` | number | World-units of displacement per unit of sampled height. Sign matters — negative inverts inward. With no displacement map on the material, the surface is subdivided but left flat. |
| `mode` | string | `"static"` or `"adaptive"`; any other value makes `getStatus()` answer `"rejected_mode"` and leaves the entity rendering the mesh it already had. |
| `screenError` | number | Target micro-triangle edge in pixels, read under `mode = "adaptive"`. Smaller buys more subdivision at a given distance; zero or less asks for level 0. Default 8. |
| `stitch` | boolean | Place this surface's boundary on the coarsest lattice any patch sharing that boundary uses. Default `true`. |
## Methods
The `tessellation` system writes what each bake did onto the entity's
native `ecs.Tessellation` component, and these methods read it back.
`ecs.get(id, ecs.Tessellation)` reads the same values as its `status`,
`activeLevel`, `outputTriangles`, `outputVertices` and `stitchedEdges`
fields.
| Method | Purpose |
| --- | --- |
| `tess:getStatus()` | Read the latest status string: `""` before the first dispatch, then `"pending"`, `"ok"`, `"clamped"`, `"too_large"` or `"rejected_mode"`. |
| `tess:getActiveLevel()` | Read the level the installed mesh was baked at. |
| `tess:getTriangleCount()` | Read the installed mesh's triangle count: source triangles × `4^activeLevel`. |
| `tess:getVertexCount()` | Read the installed mesh's vertex count: source triangles × the lattice a patch carries at `activeLevel`. |
| `tess:getStitchedEdges()` | Read how many boundary edges the last bake placed on a coarser neighbour's lattice. |
## Adaptive level
```luau
entity.find("cliff").component.add("Tessellation", {
mode = "adaptive",
level = 5, -- ceiling, reached up close
screenError = 8, -- micro-triangles ~8 px on an edge
})
```
Under `mode = "adaptive"` the level is re-derived each frame from how
much screen the surface covers: the entity's bounding sphere is projected
under the viewport camera's own projection (perspective or orthographic),
and the level is the one whose `4^level` micro-triangles land near
`screenError` pixels on an edge — clamped to `level`. A surface therefore
gains detail as the camera approaches and sheds it on retreat. A sphere
the view frustum does not reach covers no pixels, so a surface behind or
outside the frame drops to level 0 until it comes back into view.
Sample `getTriangleCount()` across a dolly to watch it move. Two rules
bound the re-baking: a half-level dead band around the active level, so a
camera parked on a boundary holds one level, and a minimum spacing
between re-bakes, so a fast sweep through several levels re-bakes at a
bounded rate rather than every frame.
A whole entity holds one level, so the micro-triangles along every shared
edge inside it match by construction. Where two entities meet, see
*Shared boundaries* below.
## Shared boundaries
A surface's outline — the edges only one of its triangles uses — is the
only place another surface can meet it. Two surfaces whose outlines share
an edge and subdivide it to different levels place their points along it
on different curves: the finer one follows the displacement between the
coarser one's samples, and the space between the two curves is a crack
the background shows through.
Every frame, each tessellated surface's outline is placed in world space
and matched against the others'. Where a shared edge is found, the finer
surface bakes its points along that edge onto the coarser one's lattice,
so both trace one boundary. The rest of the finer surface keeps its own
level. `getStitchedEdges()` reports how many of its boundary edges were
placed this way, and a neighbour that changes level, moves away, or
appears re-bakes the surfaces that share an edge with it.
```luau
-- Two tiles meeting at x = 0, subdivided differently.
entity.find("tile_west").component.add("Tessellation", { level = 5, displacementScale = 1.5 })
entity.find("tile_east").component.add("Tessellation", { level = 2, displacementScale = 1.5 })
-- tile_west:getStitchedEdges() == 1 — its east edge is on tile_east's lattice.
```
Two outlines count as shared when both endpoints land on the same point
of a 0.1 mm world-space grid, which is what lets tiles authored to meet
match however each one is placed — the rounding each entity's own
transform leaves behind is orders of magnitude finer than one cell.
`stitch = false` leaves a surface subdivided uniformly to its own
level everywhere; its level is still what neighbours that do stitch clamp
themselves to.
## Output size
Each source triangle's micro-vertices are the grid points of one shared
barycentric lattice: `(n + 1)(n + 2) / 2` of them for `n = 2^activeLevel`,
carrying `n^2` micro-triangles. Micro-triangles meeting along an edge
inside a patch therefore name the same vertex, and the index buffer names
lattice addresses — three per micro-triangle. At level 6 a patch is 2145
vertices for 4096 triangles. Read the two counts with `getVertexCount()`
and `getTriangleCount()`.
## Displacement
Displacement height comes from the entity material's **displacement map**
(a grayscale height texture). A material without one still tessellates —
the geometry is subdivided for a smoother silhouette — but stays flat.
`displacementScale` scales the sampled height into world units along each
micro-vertex's interpolated normal.
## Limits
- Maximum subdivision level: 6 (= 4096 micro-triangles per source
triangle). Higher values are clamped, and `getStatus()` answers
`"clamped"`.
- Projected output cap: 64 MiB, counting the vertex buffer and the index
buffer together. A dispatch that exceeds this is skipped, and
`getStatus()` answers `"too_large"`: lower the level or use a
lower-poly source mesh.
# SpotLight
Adds a cone-shaped light source to an entity. Position comes from the entity's world transform; the cone axis is the entity's world rotation applied to the `face` direction, so a spot under a carrier turns with it. `face` defaults to `"Front"`, the entity-local -Z that `transform.forward` reports and that `entity:lookAt` aims, so aiming the carrier at something is what puts the cone on it. Shares the 64-light scene cap with `Light` (point lights), and shares its intensity scale: the same `intensity` at the same range puts the same light on a surface either kind faces from the same place, and the cone decides how much of the room that light reaches rather than how bright it is where it lands. `angle` and `innerAngle` are half-angles from the cone axis, so `angle = 30` opens a 60-degree cone.
Public fields: `colorR/G/B`, `brightness` (→ intensity), `range` (→ radius), `angle` (outer half-angle, deg), `innerAngle` (inner half-angle, deg), `face` (`Front`/`Back`/`Left`/`Right`/`Top`/`Bottom`), `shadows`, `cookieLayer`, `mobility`. `intensity` and `radius` are aliases of `brightness` and `range`, spelled the way the `Light` component spells them.
Methods: `:setColor(color)`, `:setBrightness(b)`, `:setRange(r)`, `:setAngle(deg)`, `:setInnerAngle(deg)`, `:setFace(face)`.
Falloff: 0..innerAngle = full bright, innerAngle..angle = smoothstep to 0, > angle = 0. Range falloff is UE4-style smooth windowed inverse-square clamped at `range`.
The cone is one further factor on that shared scale, and it is 1 inside `innerAngle`. So the reading that separates the two kinds is a room-wide or frame-wide average: a spot lifts it far less than a point light carrying the same number, because it reaches less of the room. Balance a mixed rig by the brightness of the surfaces each light is aimed at, not by how much of the frame each one lifts — and widen `angle` or `range` to cover more of the room where raising `brightness` would only deepen the spot's own pool.
A spot casts shadows by default; `shadows = false` keeps it purely additive. `cookieLayer` names a layer of the shared feature texture to project through the cone as a gobo, and `-1` (the default) leaves the cone uniform — fill the layer with `renderer.featureTexture.setLayer` before pointing a spot at it.
```luau
entity(id).component.add("SpotLight", {
color = {1, 0.95, 0.8},
brightness = 3.0,
range = 30,
angle = 30,
innerAngle = 20,
face = "Front",
shadows = true,
cookieLayer = 0,
})
```
# Player
Identity tag for a player entity. Attaches the Rust `PlayerOwned` marker via `__native` in `awake` and removes it in `onDestroy`. No public state, no update loop, no visuals.
```luau
entity(id).component.add("UserIdentity")
entity(id).component.has("UserIdentity") -- true
```
Future rules (not yet enforced):
- **Non-serialising.** Scene and world saves omit this component. Players are attached at runtime per peer, not inherited from the save file.
- **Non-removable.** Once an entity is a player, removing this component is a no-op. A player stays a player for its lifetime outside an explicit identity swap.
Visuals, cameras, input, and movement live in separate components (e.g. `PlayerController`, `PlayerTemplate` bundle content) so adding or removing them never affects identity.
# Asset
Promote the bundle's spawned children from temporary to permanent scene state and remove the Asset component, "baking" the bundle's contents directly into the owning scene. Subsequent saves persist the baked entities verbatim and stop replaying the bundle template.
Location: `src/lua/lib/components/Asset.component`
# AudioListener
The "ears" of the scene. Spatial audio is heard relative to the entity carrying this component — its transform sets the listener position and orientation. Attach it to whatever should hear: a camera, a character, a dedicated listener rig.
Public fields: `active` (`boolean`, default `true`).
Methods: `setActive(on)`.
```luau
entity.find("camera").component.add("AudioListener")
entity.find("player").component.add("AudioListener", { active = true })
```
A scene needs an active listener for its `Audio` sources to be heard positionally. Sources with no active listener are reported at load and play centered until a listener is added.
`active` is live: writing it takes effect on the next frame, so clearing it on the listener holding the ears hands them to another active listener, and clearing it everywhere leaves the scene with no ears. When several are active one of them drives the ears — the same one for as long as those entities live, whatever else changes about them — and the engine warns once, naming it. Recreating the entities, as a scene reload or entering play does, chooses again. `audio.observe().listener` reports the winner as `entity`, alongside `present`, `count` (how many are active) and the pose. Which one it picks is not something to author against: deactivate or remove the extras when it matters.
# ambientOcclusion system
Screen-space ambient occlusion — the contact darkening in corners, creases and
where objects meet.
Without it, every concave region is lit exactly like a flat one: a box resting
on a floor has no seam, a corner is as bright as the wall, and an untextured
scene reads as plastic. Occlusion is the cheapest shading cue there is per
millisecond, because it needs no extra geometry pass — only the depth and normal
the frame already produced.
Three techniques are shipped. All three answer the same question — what fraction
of the hemisphere above this pixel is visible — and all three cost the same taps
to answer it.
## Using it
The scene's occlusion is a component — author it once and it persists with the
scene:
```lua
local id = entity.spawn("ambient_occlusion").id
entity(id).component.add("AmbientOcclusion", {
technique = "gtao", -- ssao | hbao | gtao
intensity = 0.9, -- how far the darkening goes, 0..1
radius = 1.5, -- world-space reach
quality = "high", -- low | medium | high | ultra
})
```
Every field is live: edit one in the inspector and the shading follows. Setting
`intensity` to 0 turns it off without losing the rest.
For code that adjusts occlusion at runtime — a quality slider, a cutscene —
reach for the module directly (same options, no component):
```lua
local ao = require("@builtin::systems.ambientOcclusion.ao")
ao.set({ technique = "hbao", intensity = 0.9 }) -- any subset; the rest is kept
ao.get() -- the settings in force
ao.techniques() -- the three, each with what it does
ao.qualityLevels() -- the sampling shape each level costs
ao.active() -- is the pass running
ao.clear() -- off, settings kept
```
## The three techniques
| technique | what it measures |
|---|---|
| `ssao` | Taps scattered over a disk, each weighed on its own: how far the neighbour rises above the tangent plane, over how far away it is. |
| `hbao` | One horizon per direction — a ray marched outward until it finds the steepest thing along it — averaged as sines. |
| `gtao` | The cosine-weighted arc between the two horizons of a slice, in closed form. This is the visibility the other two estimate. |
They are scaled to agree. A point in a right-angle corner sees exactly half the
hemisphere, and each technique is normalised so it reads that corner as half:
| technique | flat plane | right-angle corner | occlusion |
|---|---|---|---|
| `ssao` | 1.000 | 0.566 | 0.434 |
| `hbao` | 1.000 | 0.592 | 0.408 |
| `gtao` | 1.000 | 0.594 | 0.406 |
That is what lets `intensity` mean the same thing whichever technique is
selected, so switching one for another changes how the estimate is made rather
than how dark the scene gets.
## What the knobs do
| field | effect |
|---|---|
| `technique` | Which estimator runs: `ssao` \| `hbao` \| `gtao`. |
| `intensity` | How far the darkening goes, in [0, 1]. 0 turns it off and releases the pass. |
| `radius` | World-space reach — roughly "how big is a crease worth darkening". Constant in world space, so it does not swell as the camera approaches. |
| `power` | Contrast curve on the visibility term. Above 1 deepens the dark end. |
| `thickness` | World-space thickness assumed for an occluder, so a thin foreground object stops occluding everything behind it out to the full radius. |
| `bias` | How far a neighbour must rise above the surface before it counts as an occluder rather than as the surface itself, in [0, 1]. Raise it if a flat surface shades itself in patches; lower it if fine creases disappear. |
| `quality` | Sampling shape. See the table below. |
| quality | slices × steps | taps per pixel | resolution |
|---|---|---|---|
| `low` | 2 × 3 | 12 | half |
| `medium` | 3 × 4 | 24 | half |
| `high` | 4 × 6 | 48 | full |
| `ultra` | 6 × 8 | 96 | full |
Every technique spends the same budget: `slices × steps × 2` taps. `gtao` walks
each slice both ways, `hbao` treats each of the two directions as its own ray,
and `ssao` takes that many independent taps of the disk.
The lower two levels compute occlusion on a half-resolution grid — a quarter of
the pixels, each still costing its level's taps. Occlusion is low-frequency
almost everywhere, and the filter that removes the sampling noise lifts the
coarse grid back to the frame in the same pass, weighting each texel by the
depth where it sits so silhouettes survive the upsample. What a half-resolution
grid loses is the innermost pixel of a sharp crease; everything wider than a
couple of pixels comes back the same.
## How it renders
One compute pass after lighting writes the per-pixel visibility, a second
filters it and multiplies it into the frame, and a blit puts the result on
screen. The filter is depth-aware: a plain blur would bleed occlusion across
silhouettes, so each tap is weighted by how close its depth is to the centre
pixel's.
Every technique jitters its sampling by a per-pixel offset, which trades banding
for noise; the filter is the half of that trade that removes the noise.
Depth, normal and camera come from the render target being drawn (`@scene.depth`,
`@scene.normal`, `@frame.camera`), so the result is correct in offscreen
captures and render-to-texture cameras at their own resolutions, not only the
live viewport. Sky pixels are passed through — there is no surface to occlude.
The normal buffer is the deferred G-buffer, so this needs the deferred path
(the default).
## Seeing the occlusion on its own
While occlusion is running, `ambientOcclusion` is a capture view: the filtered
visibility with no scene colour, white where the hemisphere is open and dark
where it is closed.
```
capture { pass = "ambientOcclusion" }
```
It is the fastest way to tell a shading problem from an occlusion one, and to
compare two techniques or two `radius` values without the lighting in the way.
## Files
```
ambientOcclusion.package/
AmbientOcclusion.component/ -- the scene's occlusion, authored and persisted
ao.module/ -- ao.set / .get / .techniques / .qualityLevels / .active / .clear
aoShared.module/ -- the prelude, filter and passes the techniques share
ssao.renderFeature/ -- taps weighed on their own
hbao.renderFeature/ -- one horizon per direction
gtao.renderFeature/ -- the arc between two horizons
```
# CollisionGroup
Sets collision group membership and filter masks on an entity's physics colliders using a bitmask. Two colliders collide only when `(a.membership & b.filter) ~= 0` AND `(b.membership & a.filter) ~= 0`.
Public fields: `membership` (default `0xFFFFFFFF`), `filter` (default `0xFFFFFFFF`).
Methods: `setGroups(membership, filter)`, `ignoreEntity(entityId, ignore?)`.
```luau
entity(id).component.add("CollisionGroup", { membership = 1, filter = 3 })
```
# third_person_follow
Over-the-shoulder camera. The rig hangs off a shoulder pivot beside the follow target and sits back along the heading it looks down, so framing is authored as offsets in space. The offset is measured in the camera's own frame, so the subject holds the same spot on screen whichever way it turns.
The rig owns that heading. Binding a follow target aims it along the subject's facing, and look input turns it from there — the subject's facing is never read again. That is what makes the camera steerable for a character who turns to face where it is walking: hold "forward" and the character walks the line the camera looks down, instead of chasing a heading that moves as it turns.
The facing a bind reads comes from the body's own declaration of which way it faces (`Locomotion.forward`), so the rig lands behind a `+Z`-forward Synty body and a `-Z`-forward glTF body alike. A target that declares nothing falls back to its transform forward.
Public fields: `follow`, `shoulderOffset` (x across the frame, y up it, z along the heading), `verticalArmLength`, `cameraSide` (0 = left shoulder, 1 = right), `cameraDistance`, `minDistance`, `maxDistance`, `aimYaw` (the world heading the camera looks along, in radians about +Y from world +Z) and `aimPitch` (how far it tilts off level), `damping` (per-axis time constants in seconds), `cameraRadius` (radius of the pull-in sweep; `0` turns the pull-in off), `cameraIgnore` (entities whose colliders the pull-in passes through), `sensitivity`, `invertY` (off by default: pushing the mouse forward looks UP; on pitches the other way), `zoomSpeed`, `minPitch`, `maxPitch`.
Writing any framing field re-poses the rig immediately with damping bypassed, so edit mode shows the framing play mode will produce.
`follow` falls back to the `Camera.follow` slot when it is empty, so a rig selected through `Camera.behavior` tracks whatever that camera already follows.
Controls: mouse to look (play mode requests pointer lock, so no button is held), scroll to adjust `cameraDistance`.
## The control scheme
`inputMap` is the scheme the rig answers to. It reads two controls from it, each on its own:
- `look` — turns `aimYaw` and `aimPitch`.
- `zoom` — steps `cameraDistance`.
A map declaring only one of the two gives the rig that one and keeps the other where it is; the rig logs which control the map left out, and names the map. A field naming no map leaves the rig answering nothing, and logs that too. Point `inputMap` at a world's own map to rebind looking and zooming without touching this component.
**Repointing it on a live rig takes on the next frame.** The map the rig was holding is stood down — its controls leave the live set and its touch buttons leave the screen — and the map named here is taken up in its place:
```luau
entity(rigId).component.get("third_person_follow").inputMap = asset.resolve("mygame.input.cinema", "inputMap")
```
Assigning the map the rig already holds changes nothing, so re-stating the scheme leaves the live set where it is. While gameplay is paused the rig holds no map at all — the editor's camera has the controls — and it takes whatever `inputMap` names when play resumes.
```luau
local cam = entity(id).component.get("Camera")
cam.behavior = asset.ref("@builtin::controller.third_person_follow", "component")
entity(id).component.get("controller.third_person_follow").cameraDistance = 3
```
## The camera pulls in when a collider is in the way
Each frame the rig sweeps a sphere of `cameraRadius` from the point it aims at out to where its framing puts the camera, and stands the camera where the sphere first meets a collider. Every collider in the world stops that sweep, whether or not anything draws it: an invisible walk bound (a `BoxCollider` with no `Model`), a trigger volume and a solid wall all pull the camera in alike. The follow subject is the one entity the sweep passes by default.
So a camera that snaps in close to the player, or collapses onto it, while nothing visible stands behind the player is meeting a collider nobody can see. Two fields settle it:
- `cameraIgnore` lists the entities whose colliders the camera sees through. Name the walk bounds and gameplay volumes there and the sweep passes them as it passes the subject, while walls the player can see still pull the camera in. Each entry is an entity (or its id), and it is the collider's own entity that is listed.
- `cameraRadius = 0` turns the pull-in off: the camera stands where its framing puts it, through every collider.
```luau
local rig = entity(camId).component.get("controller.third_person_follow")
rig.cameraIgnore = { entity.find("NorthBound"), entity.find("SouthBound") }
-- or drop the pull-in entirely
rig.cameraRadius = 0
```
## Attach through `Camera.behavior`
The Camera owns the behavior slot, so assigning the rig there is what lets the Camera tell it when `follow` changes. A rig added straight onto the entity with `component.add` still poses, but the Camera does not know about it and a `follow` assigned afterwards will not reach it.
Assigning the slot attaches the rig to the camera's entity, so it is tuned on that instance: `component.get("third_person_follow")` and set its fields. A `component.add("third_person_follow", { ... })` after the assignment, in a scene `build.luau` or anywhere else, writes its values onto the instance the slot attached and returns it, so the Camera keeps the one rig it knows about. In a `build.luau` that add is the way to tune the rig: its values are saved with the scene and the loaded rig holds them.
## Player cameras: leave `follow` unset
Entering play drops and respawns `PlayerPrototype` subtrees, so the body is recreated with a NEW entity id. A `follow` authored on the rig keeps pointing at the original, which is no longer live, and the camera freezes at its last pose instead of tracking the player.
For a player camera, set the target on the **Camera** and leave the rig's own `follow` empty:
```luau
entity(rigId).component.get("Camera").follow = body -- PlayerPrototype rebinds this on every spawn
```
`PlayerPrototype` refreshes `Camera.follow` to the live body each time it clones, and the rig reads that slot whenever its own `follow` is empty. Set the rig's `follow` only for a camera that watches a fixed, non-respawning subject.
# ReflectionProbe
A reflection probe: bakes the scene into its own cube slot from its position, so reflective surfaces within its `radius` mirror the captured surroundings. Multiple probes in a scene are blended by proximity and rank (the renderer's per-fragment probe blend); a surface outside every probe's radius reflects the sky fallback, or the scene's ambient when no sky is captured. The probe renders a small editor-only gizmo marker (cyan octahedron) in edit mode and is invisible in play mode.
Every field is live: editing `radius`, `priority`, the proxy or `skyFallback` in the inspector re-pushes the probe, and `reflectionProbe.list()` reports what the component holds.
A probe is an **authoring-time** bake: it loads its persisted baked cube on awake (`reflectionProbe.loadBaked`) rather than re-baking on every load. A probe reflects once it holds a cube: until it is baked (or has a persisted bake to load) it adds nothing to the frame, arms no sky fallback, reads `baked = false` in `reflectionProbe.list()` and in `lighting.get`'s `environment.probes`, and the engine log names it with the call that bakes it. Set `probeId` on authored probes so the baked `probe_<probeId>.texture` asset keeps the same identity across reloads (the runtime entity id changes; the probeId does not).
Public fields:
- `radius` (number, default `12`) — influence radius in world units. A surface within it reflects this probe at a weight falling from full at the probe to zero at the radius.
- `bakeOnAwake` (bool, default `false`) — opt-in first-time bake on awake (deferred a few frames so geometry is GPU-resident; baking before the scene renders captures black). Only fires when no persisted baked cube exists yet.
- `probeId` (string, default `""`) — stable asset identity for the persisted baked cube. Empty → falls back to the entity id.
- `skyFallback` (bool, default `true`) - once the probe holds a cube, capture the scene's sky into the fallback slot so a surface outside every probe's radius reflects the sky.
Methods: `:bake()` — (re)bake this probe from its current position into its cube slot and persist it (runs off the calling frame).
```luau
-- One-liner (spawns an entity carrying this component):
reflectionProbe.add(0, 3, 0, { radius = 15, probeId = "lobby" })
-- Or attach directly + bake everything once the scene is built:
entity.find("zone").component.add("ReflectionProbe", { radius = 12 })
reflectionProbe.bakeAll()
```
Max 8 probes (cube slots). Registry policy lives in `@builtin::api.engine.reflectionProbe`; the generic per-slot capture primitive is the `environment` module.
# controller.rts
RTS camera controller. Top-down strategy camera. WASD to pan (aligned to camera yaw), scroll to zoom, right-click drag to rotate. Optionally lock to a target entity.
Public fields: `inputMap` (the camera-rig scheme, `@builtin::inputMaps.cameraRig`, by default; point it at a world's own map to rebind this camera), `target` (empty = free pan), `centerX`, `centerY`, `centerZ`, `distance`, `minDistance`, `maxDistance`, `moveSpeed`, `sensitivity`, `zoomSpeed`, `minPitch`, `maxPitch`.
Controls: WASD to pan, scroll to zoom, RMB drag to rotate.
```luau
entity(id).component.add("controller.rts", { distance = 15, moveSpeed = 12 })
```
# SceneModule
A placement of a `.sceneModule`: the module this entity is built from, and the
params this placement sets. In edit mode it runs the module's builder and
reconciles the result under this entity; what lands is permanent scene content —
saved in `scene.json`, replicated, selectable and inspectable like anything
placed by hand.
Public fields:
- `source` — the module to build. Gated by capability, so it accepts any asset
whose type defines `instantiate`. `module` is an input alias for
it.
- `params` — this placement's input overrides, keyed by the names the module
declares in its `inputs`. A name the module declares overrides that input's
default; a name it does not declare is left alone.
Those two are the whole record. A placement in `scene.json` reads:
```json
{
"id": "ent_ee2d09b7c4ee5a28",
"name": "orchard_0",
"components": [
{
"type": "@builtin::components.SceneModule",
"data": {
"enabled": true,
"params": { "rows": 2 },
"source": { "name": "Orchard", "type": "sceneModule", "guid": "49ba97ac-…" }
}
}
]
}
```
The entities the build placed sit beside it as records of their own, each
naming this entity as the build that placed it and its own chain of names from
the build root down to it:
```
buildOwner=ent_ee2d09b7c4ee5a28 buildKey=orchard
buildOwner=ent_ee2d09b7c4ee5a28 buildKey=orchard/tree_r1
buildOwner=ent_ee2d09b7c4ee5a28 buildKey=orchard/tree_r1/canopy
```
```luau
local pusher = entity.spawn("pusher_0")
pusher.component.add("SceneModule", {
source = asset.resolve("CoinPusher", "sceneModule"),
params = { seed = 0, coinCount = 40 },
})
pusher.localPosition = { 0, 0, 0 }
```
Placing the same module twice with different params yields two different
results from one asset:
```luau
for i = 0, 2 do
local p = entity.spawn("pusher_" .. i)
p.component.add("SceneModule", { source = coinPusher, params = { seed = i } })
p.localPosition = { i * 4, 0, 0 }
end
```
## Rebuilds
A write to the module's `init.luau`, or a change to `params`, re-runs the
builder and **reconciles** the result: an entity the previous build placed is
updated in place, one the build no longer emits is removed, and a new one is
spawned. Ids hold, so a reference to a built entity survives the rebuild.
A rebuild recognises its own entities by computing those ids again: the same
placement and the same place in the hierarchy give the same string. The scene
save writes them and the load brings them back, so a rebuild in a later session
lands on exactly the entities the first build placed with nothing carried
between the two.
A CHANGE is what rebuilds, and the changes are `source`, `params`, and the
module's code on disk. Loading and mode flips are not changes: the entities are
in the scene as authored content, the last bake is what they hold, and the
placement runs nothing. A rebuild whose module, params and module source are
all unchanged is skipped too. `:rebuild()` forces one anyway — for a change
nothing else reports, such as an asset the builder resolves changing underneath
it, or a module edited while the world was closed.
A build that raises leaves the previous one in the scene and says so, rather
than emptying the placement over a typo.
## Who states the params
A placement an author made — dragged in, spawned by a tool, written by a script
— holds its own params. Nothing else sets them.
A placement a **build** made is stated by that build. A scene's `build.luau`
that places one names its `source` and its `params`, and every run of that build
states them again — on load, on a write to the file, on `:build()`. So a param
set on such a placement is a preview: it rebuilds at once and holds until the
next run of the build, which states the params its own source gives.
Both moments say so. Setting a param on a build-placed placement names the file
that states it:
```
[warn] a build placed this entity and states its SceneModule params — a param
set on the placement holds until that build runs again and states them from its
own source { placement="tent_ring_placement", set="count = 9",
statedIn="/zero/source/scenes/main.scene/build.luau (content)" }
```
and the run that states them again names what it replaced:
```
[warn] a build ran and stated this placement's SceneModule params over the ones
set on the placement — the file the build is written in is where they are stated
{ placement="tent_ring_placement", replaced="count: 9 → 7",
statedIn="/zero/source/scenes/main.scene/build.luau (content)" }
```
A run that states what the placement already holds says nothing. To change the
value for good, change it where the build states it.
A build that stops stating a placement takes what the placement built with it.
When a run no longer adds the `SceneModule` to an entity it places, because it
dropped the module or now places it there through `instantiate`, the entities
the placement built under that entity go with the component, so the entity
holds only what the build states now. A placement an author
added to an entity a build places is the author's: a run of that build leaves
it and what it built in place.
## Play
Nothing runs and nothing instantiates. The entities are already in `scene.json`
and load as authored content, so play is deterministic, costs no Luau
construction, works on a runtime-profile boot with no edit session, and every
multiplayer peer loads the same records. This component is inert there.
## Methods
- `:rebuild()` — re-run the builder and reconcile, ignoring the signature.
Returns false in play.
- `:declaredInputs()` — the bound module's declared inputs, sorted:
`{ name, kind, default, values? }`. The set `params` can address.
# PositionConstraint
Constrains an entity's transform (position, optionally rotation and scale) to follow a target entity, with optional offset. Use `weight` to control smoothing.
Public fields: `targetEntityId`, `targetPositionX/Y/Z`, `offsetX/Y/Z`, `axisX`, `axisY`, `axisZ`, `weight`, `constrainRotation`, `constrainScale`.
Methods: `setTarget(entityId)`, `setOffset(x, y, z)`, `setWeight(w)`.
```luau
entity(id).component.add("PositionConstraint", {
targetEntityId = carId,
offset = {0, 5, 10},
weight = 0.15,
})
```
# PlayerSpawn
A world entity that spawns players from a prototype. It carries the fields describing how spawning happens: which `prototype` to instantiate, the `team` and `role` assigned to spawned players, a `maxPlayers` cap (0 = unbounded), the `spawnPolicy` (how many players spawn from this point), and the `placement` (where a spawned player is positioned). The entity's own transform is the spawn point when `placement` is `at_spawn_transform`. In edit mode it draws an orange silhouette outline at the spawn point and surfaces its own setup-validation messages (missing or mis-targeted prototype) on the component's error channel.
Location: `src/lua/lib/components/PlayerSpawn.component`
# components.FreeMover
WASD + mouse free-fly mover. Attach to an entity to make it move under
WASD input with mouse look, independent of any character controller or
physics rig. Used by the editor's free camera and any debug "fly-around"
viewpoint.
Public fields: `speed`, `sensitivity`, `sprintMultiplier`.
# Mover
`Mover.component` — see `init.luau` for the component surface.
Location: `src/lua/lib/components/Mover.component`
# HeightfieldCollider (component)
A regular grid of heights, which is the shape terrain collides with. Testing
against it costs a cell lookup rather than a walk down a triangle tree, so a
large landscape collides at a cost that stays flat as it grows.
## The grid
`rows` and `cols` are the grid's dimensions, `extent` its world span as
`{ x = width, y = heightScale, z = depth }`, and `heights` the row-major grid
itself, indexed `heights[r * cols + c + 1]`. The grid is centred on the entity.
`rows` maps to the entity's local Z and `cols` to its local X.
```lua
entity.find("terrain").component.add("HeightfieldCollider", {
rows = 64, cols = 64,
extent = { x = 300, y = 1, z = 300 },
heights = grid,
})
```
Use `setGrid(rows, cols, heights)` to replace the grid and the dimensions
describing it together, so the two are never out of step.
## Replication
`heights` is `NoSync`. It is large, and every peer can rebuild it from its own
copy of the terrain, so replicating it would send a whole landscape over the
wire to reproduce something each peer already has.
## Fields
| Field | Meaning |
|-------|---------|
| `rows` / `cols` | Grid dimensions. |
| `extent` | World span, with `y` scaling the heights. |
| `heights` | Row-major height grid. Not replicated. |
| `isTrigger` | Report overlaps without blocking. |
| `friction` | Surface friction. |
| `restitution` | Bounciness. |
# Humanoid
Marker component for entities carrying a skeleton conforming to the
standard humanoid bone layout (Synty / Unreal-Mannequin convention).
Locomotion, LocomotionFull, future IK, equipment slots, and ragdoll all
read bones through this component instead of re-walking the descendant
tree.
## Bone naming convention
```
Hips
Spine_01 → Spine_02 → Spine_03 → Neck → Head
└ Eyes, Eyebrows, Jaw
UpperLeg_R / UpperLeg_L
LowerLeg_R / LowerLeg_L
Ankle_R / Ankle_L
Ball_R / Ball_L → Toes_R / Toes_L
Clavicle_R / Clavicle_L
Shoulder_R / Shoulder_L
Elbow_R / Elbow_L
Hand_R / Hand_L
IndexFinger_01..04 / Thumb_01..04 / Finger_01..04
```
Mixamo / Unity-Humanoid imports go through a bone-rename pass at import
time (tracked separately).
## Fields
| Field | Type | Notes |
|---|---|---|
| `height` | number | Foot-to-head height in T-pose, metres. Default 1.8. |
| `eye_height` | number | Foot-to-eyes height, metres. Default 1.65. |
| `bones` | `{ [string]: entity }` | Computed; bone-name → entity-proxy map. Re-walks on read. |
## Methods
| Method | Returns | Notes |
|---|---|---|
| `:bone(name)` | `entity \| nil` | Nil-safe single-bone lookup; same as `self.bones[name]`. |
## Usage
```luau
-- Read a specific bone
local h = entity(playerId).component.get("Humanoid")
local head = h.bones.Head -- entity proxy
local handR = h:bone("Hand_R") -- nil-safe lookup
-- Mount something on the head
local hat = entity.spawn("hat", { parent = head.id })
-- Read height for camera mounting
local cam = entity.spawn("eye_cam", { parent = playerId })
cam.localPosition = { 0, h.eye_height, 0 }
```
## Tag convention
Bundles carrying a humanoid skeleton tag themselves with both
`playerVisual` AND `humanoid` in their `.metadata`. Tooling can filter
to humanoid-conforming visuals via
`asset.list("bundle", nil, { fields = { tags = { "playerVisual", "humanoid" } } })`.
# controller.isometric
Isometric camera controller. Fixed-angle overhead camera (classic isometric view). WASD to pan on the XZ plane, scroll to zoom. The viewing angle is fixed (no rotation).
Public fields: `inputMap` (the camera-rig scheme, `@builtin::inputMaps.cameraRig`, by default; point it at a world's own map to rebind this camera), `target` (empty = free pan), `centerX`, `centerY`, `centerZ`, `distance`, `minDistance`, `maxDistance`, `moveSpeed`, `zoomSpeed`, `yaw` (default pi/4), `pitch` (default ~35.26 degrees).
Controls: WASD to pan (world-axis-aligned), scroll to zoom.
```luau
entity(id).component.add("controller.isometric", { distance = 12, moveSpeed = 8 })
```
# LookAtConstraint
Rotates an entity to face a target entity or a fixed world position. Use `weight` for partial/smoothed orientation.
Public fields: `targetEntityId`, `targetPositionX/Y/Z`, `axisX`, `axisY`, `axisZ`, `weight`.
Methods: `setTarget(entityId)`, `setWeight(w)`.
```luau
entity(id).component.add("LookAtConstraint", {
targetEntityId = playerId,
weight = 0.5,
})
```
# Audio
Adds audio playback to an entity, driven by a `.soundClip` asset. Supports sound effects, music, and spatial/3D audio tied to entity position.
Public fields: `clip` (`AssetRef<soundClip>`), `sourceType` (`"SoundEffect"` / `"Music"` / `"Ambient"`), `volume`, `pitch`, `looping`, `spatial`, `maxDistance`, `rolloff`, `autoplay`, `channel`.
A spatial source plays at full volume within one unit of the listener and reaches silence at `maxDistance` (default 50). Across that window its amplitude is `(1 - t)^rolloff` for a source `t` of the way through, so the default `rolloff` of `1` is a linear fade — half the distance, half the amplitude — a larger exponent drops away sooner, and `0` holds full volume to the edge.
Methods: `play()`, `pause()`, `stop()`, `setVolume(v)`, `setPitch(p)`.
```luau
entity(id).component.add("Audio", { clip = explosionClip })
entity(id).component.add("Audio", { clip = fireClip, spatial = true, looping = true })
```
A loose audio file (ogg/mp3/wav/flac) needs to be imported into a `.soundClip` asset before it can be referenced here — the importer creates the container and encodes its playable payload.
Whether the clip streams its compressed payload or decodes fully on load follows the clip's own `loadType` setting: `"decompressOnLoad"` and `"streaming"` apply directly, and `"auto"` (the clip's default) picks by the clip's duration — short one-shots decode up front, longer tracks stream. The clip decides: each source this component builds asks `clipRef:streams(bytes)`.
# CutsceneTrigger
Plays a cutscene when something happens, without writing the wiring: the scene
entering play, the player reaching this entity, or a call from your own code.
```luau
-- the game opens on a cutscene
entity.spawn("Intro").component.add("CutsceneTrigger", {
cutscene = "mygame.scenes.intro",
trigger = "onPlay",
})
-- a cutscene when the player reaches the gate
entity.find("GateArea").component.add("CutsceneTrigger", {
cutscene = "mygame.scenes.gate_opens",
trigger = "onEnter",
radius = 4,
})
```
The cutscene itself is a module returning a definition — see the `cutscene`
module for its shape, and the `topics/cutscenes` guide for the whole picture.
## Fields
| Field | Default | Notes |
|---|---|---|
| `cutscene` | `""` | The module path a cutscene definition is returned from. |
| `trigger` | `"onPlay"` | `onPlay` as the scene enters play · `onEnter` when the local player comes within `radius` · `manual` only on a `play()` call. |
| `radius` | `3` | How close the local player has to get, for `onEnter`. |
| `delay` | `0` | Seconds to wait after the trigger before it starts. |
| `once` | `true` | Whether it plays only the first time it is triggered. |
| `skippable` | `true` | Whether the player can skip it, overriding the definition. |
| `played` | `false` | Whether it has already played. Local to this peer. |
## Methods
- `play()` — start the cutscene now, whatever `trigger` says. This is what
`trigger = "manual"` is for.
- `isPlaying()` — whether the cutscene this trigger started is still running.
## Notes
- `played` is `NoSync` on purpose: a cutscene is something each player watches for
themselves, so one player having seen it is not a reason another player does
not.
- Playing goes through `cutscene.play`, so only one cutscene is ever on screen —
a trigger that fires while another cutscene runs ends that one first. The
`onEnter` trigger holds off while any cutscene is playing rather than cutting
across it.
- A trigger destroyed while its own cutscene is on screen ends the cutscene, so
the player is never left looking through a camera nothing moves.
# SoftBody
Makes an entity a deformable body: a jelly, a cushion, a balloon, a cloth, a soft version of the entity's own model, or a shell that crumples and keeps the dent, like a car body after a crash. The body is a set of particles held together by elastic elements, simulated in the same step as rigid bodies, so it rests on and pushes rigid bodies, rigid bodies rest on, bounce off and dent it, and soft bodies collide with each other.
A soft body is its own body and its own collision surface: it goes on an entity with no `Physics` and no collider component, and whichever is added second is refused with an error naming the conflict (logged, and readable as the refused component's `disabledReason`). The body fills the entity's unit box (-0.5 to 0.5 on each axis) times its world scale, so it sits where a default cube or sphere primitive of that scale would. `shape = "mesh"` instead fills every closed part of the entity's model with tetrahedra of `cellSize`, and the model is drawn carried by them; a model with no closed part, or a cell size past the 60000-cell cap, is refused with the reason and the fields that change it. While the simulation runs, the entity's position and rotation follow the body as a whole, and a `Model` on the entity moves with it; the deformed surface is drawn by the physics view (`capture` with `pass = "physics"`) and read by `Physics.softBodyState`.
Public fields: `shape` (`"box"`, `"sphere"`, `"balloon"`, `"cloth"`, `"mesh"`), `cellSize` (for `mesh`: the cells' edge in metres, 0 takes the model's longest extent divided by `resolution`), `collideWith` (for `mesh`: `"cage"`, the cells' boundary, or `"surface"`, the model's own surface), `resolution` (particles along the longest edge, 2 to 24), `mass` (kg; the default 0 weighs it at 1 kg per cubic metre of its shape, like a rigid body), `stiffness` (Young's modulus in pascals for a box or sphere, default 300; spring stiffness for a balloon or cloth), `damping` (0 rings on, 1 settles without overshoot), `model` (a solid's cells: `"corotational"`, the default, `"neoHookean"` or the cheap `"volume"`), `poisson` (Poisson's ratio, 0 up to 0.5), `plasticYield` (the strain past which a dent stays; 0, the default, is fully elastic), `plasticCreep` (how fast per second the strain past the yield sets, default 10), `plasticMax` (the most permanent strain a part takes, default 0.5), `inflation` (the share of its built volume a closed shape is held at), `shapeMatching` (hertz of the pull back toward the body's rest shape as a whole, 0 for none), `selfContact` (whether the surface collides with itself), `repairs`, `preserveVolume`, `friction`, `restitution`, `collisionMemberships`, `collisionFilter`, `canSleep`, `attachments`. The material (`stiffness`, `damping`, `poisson`, the plastic fields, `inflation`, `shapeMatching`, `preserveVolume`, `friction`, `restitution`, the collision groups) and `repairs` take effect on the running body, waking it. Writing `shape`, `resolution`, `cellSize`, `collideWith`, `mass`, `model`, `selfContact` or `canSleep` rebuilds the body; in play mode a rebuild of the same particles (a new `model`, `mass` or `selfContact`, or the entity moved while the clock is paused) keeps the deformation that stayed, and one while the simulation runs keeps every particle where it is and how it moves. A reload of this component's code keeps the body as it stands: the reloaded component takes it over, dents, motion and pins included.
**Permanent deformation.** With `plasticYield` above 0, a part of the body strained further than the yield flows toward the shape it is held in, so a dent, a crumple or a crease stays once the load is gone: a car shell that keeps its crash, clay that keeps a print, a sheet that keeps a fold. `Physics.softBodyState(id).plastic` reads how deep it is, in metres; `:repair()` (or `Physics.repairSoftBody(id)`) restores the shape the body was built in, the body put back in it where it stands and moving on as it moved. Leaving play for edit mode puts the body back in that shape too.
`attachments` holds regions of the body to rigid bodies, two-way: a list of `{ target, region }`, `target` the entity id of a body with `Physics`, `region` `{ box = { min, max } }` (every particle between the corners) or `{ nearest = point }` (the one particle nearest the point) in the entity's unit box, `space = "world"` for world coordinates, empty for the whole body. Each region follows its rigid body and the rigid body carries its weight and pull. Changing the list re-ties only the entries that changed; a rebuilt body is tied again, and removing either side releases the hold. `Physics.softBodyState(id).attachments` reads each entry back with the force it pulls with, or why it holds nothing. To join soft bodies to each other, or to anchor a joint on a soft body, use a `Joint`, whose ends each take a region.
Methods: `:state(opts?)` returns the same report as `Physics.softBodyState(entityId, opts)`. `:repair()` restores the shape the body was built in and returns the new `repairs` count. `:attach(target, region?)` adds an attachment and returns its index (the same as `Physics.attachSoftRegion`); `:detach(target?)` releases the regions held to `target`, or all of them, and returns how many it removed.
The body is built at the start of the frame after the component is added. Until then `Physics.softBodyState(id).status` is `"pending"` (with `exists = false`, and `waitingFor` naming what the build waits on); once built it is `"built"`, and a component that was not built reads `"refused"` with the reason in `refusal`.
`Physics.softBodyState(id).cage` reports a `mesh` body's fill: `cellSize`, `cached` (taken from the cage cache, as a second instance of a model is), `buildMs`, `generateMs`, `filledParts`, `strayParts`, `openParts`, `seams` (springs sewing touching parts together), `pieces`, `enclosedVolume` and `digest`.
```luau
local jelly = entity.spawn("jelly")
jelly.position = { 0, 2, 0 }
jelly.component.add("SoftBody", { shape = "box", resolution = 6 })
print(Physics.softBodyState(jelly).status) -- "pending" in the call that added it
-- on a later frame:
local s = Physics.softBodyState(jelly)
print(s.status, s.particleCount, s.volumeRatio, s.sleeping) -- "built", 216, ...
```
```luau
-- a cloth whose left edge hangs from a pole
flag.component.add("SoftBody", { shape = "cloth" })
flag.component.get("SoftBody"):attach(pole, { box = { min = { -0.5, -1, -0.5 }, max = { -0.45, 1, 0.5 } } })
print(Physics.softBodyState(flag).attachments[1].forceMagnitude) -- on a later frame
```
```luau
-- a slab of clay that keeps the print of whatever lands on it
clay.component.add("SoftBody", { shape = "box", stiffness = 3000, plasticYield = 0.02 })
-- on a later frame, after something landed on it and left:
print(Physics.softBodyState(clay).plastic) -- metres of dent that stayed
clay.component.get("SoftBody"):repair() -- and gone again
```
# ClipPlayer
Plays one `.animation` clip on the skinned body it is attached to, through the
canonical AnimGraph. The graph retargets the clip from its source rig onto the
body's rig and drives the body's bone entities each frame, so any humanoid clip
plays on any humanoid body. It is the single-clip case of the animation
contract: build a one-clip graph on start, then advance and apply it each frame.
Attach it to the same entity as the skinned mesh. The body's rig comes from its
`Skeleton`; the clip's source rig from the clip itself (or `sourceRig`). All the
retarget and pose machinery lives in the graph — this component only configures
the clip and ticks it.
```luau
entity.find("hero").component.add("ClipPlayer", { clip = "A_Walk_F_Masc" })
```
Public fields: `clip` (the `.animation` to play), `sourceRig` (the clip's source
rig; empty resolves from the clip), `looping`, `speed` and `playing`.
# PostProcess Component
Applies a **stack of post-process effects** to the scene, declaratively, from a
single entity. Each effect references a `.shader` asset (`@domain: post-process`)
and carries its own **configurable property values** — tuned like material
properties, with no shader edit required.
This is the load-time **reproducer** for post-processing: where an agent's live
`postprocess.add(...)` + `postprocess.setProperty(...)` calls produce effects
that vanish on reload, a `PostProcess` component re-registers the exact same
stack — with the exact same tuning — every time the scene loads. The persist
freeze converts live execute-origin effects into `.shader` assets + one entity
carrying this component, so **what you see is what you get** across save/reload.
## Properties
| Field | Type | Description |
|---|---|---|
| `effects` | array | The effect stack. Each entry: `{ shader, priority?, enabled?, layer?, values?, textures? }`. |
| `shape` | `"global"` \| `"box"` \| `"sphere"` | Where the stack applies. Default `"global"` (unbounded). |
| `extents` | vec3 | Box region half-extents, world units. Default `{5, 5, 5}`. |
| `radius` | number | Sphere region radius, world units. Default `5`. |
| `falloff` | number | Soft-edge distance outside the volume the stack fades over. Default `0`. |
| `priority` | number | Order of this region against other `PostProcess` regions, added to each effect's own priority. Default `0`. |
| `display` | struct `{ curve, exposure }` | The display transform this region sets while it is live — `curve` (0 linear, 1 Reinhard, 2 ACES, 3 Khronos PBR Neutral, default `3`) and `exposure` (multiplier on the scene's light before the curve, default `1`). Takes effect only when `setsDisplay` is `true` and `shape` is `"global"`. |
| `setsDisplay` | boolean | Whether this region writes `display` to the chain's `__display__` transform while it is live. Default `false`. |
Each effect entry:
| Key | Type | Description |
|---|---|---|
| `shader` | asset reference | The `.shader` asset (a `@domain: post-process` shader) this effect runs. Builtin shaders (`@builtin::shaders.post.vignette`, …) or scene-authored custom shaders both work. |
| `priority` | number | Chain order within this region — lower runs earlier. Default `100`. |
| `enabled` | boolean | Per-effect toggle. Default `true`. |
| `layer` | `"all"` \| `"scene"` | Which composited image this effect grades: `"scene"` is the rendered picture before the UI reaches it, in the scene's own light; `"all"` is the picture fitted to the screen with every widget on it. Left empty, the entry follows its shader's `space` — a `light` shader grades `"scene"`, a `display` shader grades `"all"`. |
| `values` | table | Named property overrides `{ [prop] = value }`, applied via `postprocess.setProperty` after the effect is added — the configurable tuning. |
| `textures` | table | Texture bindings `{ [prop] = path }`, applied via `postprocess.setTexture` after the effect is added. |
The component-level `enabled` flag (engine-managed, not declared here) toggles the
**whole stack**: disabling the component removes every effect (and restores the
chain's own display transform if this region set one); enabling it re-registers
them (`onEnable` / `onDisable`).
## Usage
```lua
entity.find("fx").component.add("PostProcess", {
effects = {
{ shader = "@builtin::shaders.post.vignette", priority = 0,
values = { intensity = 0.6 } },
{ shader = "@builtin::shaders.post.grayscale", priority = 10,
values = { intensity = 1.0 } },
},
})
```
Setting the display transform from a `global` region:
```lua
entity.find("look").component.add("PostProcess", {
setsDisplay = true,
display = { curve = 2, exposure = 1.5 }, -- ACES
})
```
Two `global` regions with `setsDisplay = true` both write `__display__` — whichever
registers last wins the frame. The regions setting it share one ordered record of
who is live, so they come off in any order: removing the newer of two leaves the
older one's display standing, removing the older leaves the newer one's, and
removing the last leaves whatever stood before any of them wrote.
The exposure package drives `__display__.exposure` on its own, from a GPU readback
each tick it meters. A region arriving or leaving sets the exposure and the
package's next readback sets it again, so with auto-exposure running the exposure
a region asks for holds for a frame while its curve holds for as long as the region
does.
## Methods
- `pp:getEffectNames()` — the live effect names currently registered, in stack order.
- `pp:setProperty(index, prop, value)` — tune a property of one effect at runtime
(forwards to `postprocess.setProperty`), like editing a material property.
# orbital_follow
Three-ring orbit camera. Framing is authored as three rings around the follow target — a bottom, a centre and a top, each with a `height` above the target's origin and a `radius` out from its axis. Vertical look slides the camera along the spline through those rings; horizontal look orbits around them. The shape of the orbit is geometry you can see and edit rather than a pair of pitch limits you cannot.
Public fields: `follow`, `ringBottom` / `ringCenter` / `ringTop` (each `{ height, radius }`), `verticalAxis` (0 at the bottom ring, 1 at the top), `horizontalAxis` (the world heading the camera looks along, in radians about +Y from world +Z), `radiusScale`, `aimHeight` (the aim point above the target's origin), `damping` (per-axis time constants in seconds), `cameraRadius` (radius of the pull-in sweep; `0` turns the pull-in off), `cameraIgnore` (entities whose colliders the pull-in passes through), `sensitivity`, `invertY` (off by default: pushing the mouse forward looks UP, drawing the camera toward the bottom ring; on pitches the other way), `zoomSpeed`, `minRadiusScale`, `maxRadiusScale`.
The rig owns that heading. Binding a follow target aims it along the subject's facing, so the shot opens from behind whoever the camera is told to follow, and look input turns it from there — the subject's facing is never read again. That is what makes the camera steerable for a character who turns to face where it is walking: hold "forward" and the character walks the line the camera looks down, instead of chasing a heading that moves as it turns.
The default rings frame a roughly 1.8 m humanoid standing at its origin: the centre ring sits above head height at the widest radius for a resting shot from behind and slightly above, the bottom ring drops to knee height and pulls in to look up, and the top ring climbs and tightens for a near-overhead look down.
Writing any framing field re-poses the rig immediately with damping bypassed, so editing a ring moves the camera to the shot that ring describes.
`follow` falls back to the `Camera.follow` slot when it is empty, so a rig selected through `Camera.behavior` tracks whatever that camera already follows.
Controls: mouse to orbit and to slide along the rings (play mode requests pointer lock, so no button is held), scroll to scale the ring radii.
```luau
local cam = entity(id).component.get("Camera")
cam.behavior = asset.ref("@builtin::controller.orbital_follow", "component")
entity(id).component.get("controller.orbital_follow").ringCenter = { height = 2.6, radius = 6 }
```
## The camera pulls in when a collider is in the way
Each frame the rig sweeps a sphere of `cameraRadius` from the point it aims at out to where its framing puts the camera, and stands the camera where the sphere first meets a collider. Every collider in the world stops that sweep, whether or not anything draws it: an invisible walk bound (a `BoxCollider` with no `Model`), a trigger volume and a solid wall all pull the camera in alike. The follow subject is the one entity the sweep passes by default.
So a camera that snaps in close to the player, or collapses onto it, while nothing visible stands behind the player is meeting a collider nobody can see. Two fields settle it:
- `cameraIgnore` lists the entities whose colliders the camera sees through. Name the walk bounds and gameplay volumes there and the sweep passes them as it passes the subject, while walls the player can see still pull the camera in. Each entry is an entity (or its id), and it is the collider's own entity that is listed.
- `cameraRadius = 0` turns the pull-in off: the camera stands where its framing puts it, through every collider.
```luau
local rig = entity(camId).component.get("controller.orbital_follow")
rig.cameraIgnore = { entity.find("NorthBound"), entity.find("SouthBound") }
-- or drop the pull-in entirely
rig.cameraRadius = 0
```
## Attach through `Camera.behavior`
The Camera owns the behavior slot, so assigning the rig there is what lets the Camera tell it when `follow` changes. A rig added straight onto the entity with `component.add` still poses, but the Camera does not know about it and a `follow` assigned afterwards will not reach it.
## Player cameras: leave `follow` unset
Entering play drops and respawns `PlayerPrototype` subtrees, so the body is recreated with a NEW entity id. A `follow` authored on the rig keeps pointing at the original, which is no longer live, and the camera freezes at its last pose instead of tracking the player.
For a player camera, set the target on the **Camera** and leave the rig's own `follow` empty:
```luau
entity(rigId).component.get("Camera").follow = body -- PlayerPrototype rebinds this on every spawn
```
`PlayerPrototype` refreshes `Camera.follow` to the live body each time it clones, and the rig reads that slot whenever its own `follow` is empty. Set the rig's `follow` only for a camera that watches a fixed, non-respawning subject.
# SkinnedModel
Component that drives skinned model playback and animation graph integration.
Like `Model`, it carries the `material` field (`AssetRef<material>`) — material lives on the model that renders it, not as a standalone component. Set it at add time or assign `entity(id).component.get("SkinnedModel").material = "..."` later; `:setMaterialProperty` / `:getMaterialProperty` / `:getMaterialPropertyNames` operate on the assigned material (registry-wide).
## Blend model
`skinning` chooses how the pass combines a vertex's joint influences.
- `"linear"` (default) averages the joint matrices themselves. The average of
two rotations is not a rotation, so a strongly twisted joint loses volume
toward its axis — at a 180° twist a limb collapses onto the axis entirely.
- `"dualQuaternion"` averages the joints' rigid motions as unit dual
quaternions and renormalises, so the blend is a rotation at every weight and
the cross-section is carried through the twist. Non-uniform joint scale rides
along as a separately blended stretch. It costs a quaternion extraction per
influence, so it is worth setting on characters whose wrists, shoulders or
spine twist far, and leaving off elsewhere.
```lua
entity.find("hero").component.get("SkinnedModel").skinning = "dualQuaternion"
```
A rig at rest renders identically under either model.
## Render self-check
A few frames after its mesh attaches, a SkinnedModel checks that the mesh
names something the engine can draw (on the GPU, or held off the device until
this display puts it back) and that the pose it is drawn in has not collapsed it: the
posed vertices the skinning pass wrote are measured against the mesh's own
bind-pose extent, so a 1 cm part of a prop is judged by its own size. A mesh
that fails is swapped for the builtin ERROR marker, drawn un-skinned with the
error material, and the reason is logged. Setting `model` again takes the
marker down and checks the new mesh.
`renderCheck()` answers the verdict: `{ state = "pending" | "passed" |
"failed" | "skipped", reason }`.
```lua
local v = entity.find("prop_part").component.get("SkinnedModel"):renderCheck()
if v.state == "failed" then print(v.reason) end
```
# deprecatedParticles (deprecated)
> **Deprecated.** This belongs to the earlier particle substrate. It stays loadable by identity and guid for content that already uses it; nothing new should use it, and search does not offer it. Particles are procgen sim nodes (`sim.spawn`, `sim.particles`, `sim.shader`) played through an effect: use `@builtin::assetTypes.effect`.
The package holds the GPU particle engine module (`engine`), the `deprecatedParticles` module over it, the `DeprecatedParticleEmitter` and `DeprecatedSimpleParticleEmitter` components, the shaders, compute shaders and materials they draw with, and a demo scene. The package's `.metadata` carries the deprecation marker, so every asset inside is deprecated.
# WheelCollider
A wheel of a ray-cast vehicle (Unity-style). Add it to a child entity of a rigid body: every wheel under one body is one wheel of that body's vehicle, simulated by rapier's ray-cast vehicle controller (`DynamicRayCastVehicleController`). The system finds the nearest ancestor with a `Physics` component and drives it.
Each physics tick a wheel casts its suspension ray straight down the chassis' local **-Y** from its own position, which is the suspension **mount**. The ray passes through triggers and through the chassis' own colliders, so a chassis `BoxCollider` around the mounts is fine. The controller then applies the suspension, drive, brake and tyre grip to the chassis as impulses.
Drive conventions: positive `motorTorque` drives toward the parent body's local **-Z** (the engine's forward); positive `steerAngle` steers right.
## Fields
| Field | Meaning |
|---|---|
| `suspensionDistance` | Suspension travel (m): the wheel hangs at most this far below the mount, and the spring compresses by at most this much from its rest, where the bump stop holds the wheel. |
| `targetPosition` | Where the spring rests, as a share of `suspensionDistance` below the mount. The spring's rest length is `suspensionDistance * targetPosition`, and the wheel reaches the ground within that length plus its radius. 1 rests at full extension. The spring compresses from its rest by up to `suspensionDistance`, where the bump stop is. |
| `springRate`, `damperRate` | Spring stiffness (N/m) and damping (N·s/m) per wheel. |
| `maxSuspensionForce` | The most force (N) one wheel's suspension pushes with (default 6000). Raise it for a vehicle heavier than about 2 t on four wheels. |
| `radius`, `width` | Wheel size (m). An unset radius comes from the wheel entity's model bounds, or else from its visual's. The ray is cast from the point across the tread where the ground is nearest, so a kerb under one shoulder holds the wheel up. |
| `motorTorque` | Drive torque (N·m). The wheel drives with `motorTorque / radius` newtons, up to `forwardFriction` times the load it carries. |
| `brakeTorque` | Brake torque (N·m), braking with up to `forwardFriction` times the load. It acts while `motorTorque` is 0, and so does `rollingResistance`. A braked car stands still on any slope its brakes' grip holds, gravity included. |
| `steerAngle` | Steering angle (degrees), positive steers right. At light throttle the car turns on the radius its geometry gives, the wheelbase over `tan(steerAngle)`; driving the steered wheels hard tightens the turn and driving only the others widens it. |
| `frictionSlip` | The tyre's combined grip limit (default 10.5): the drive, brake and side grip together reach at most the suspension load times this before the tyre slides sideways. Lower it (1 or less) for ice, a drift car or skids. |
| `sideFrictionStiffness` | How strongly the tyre resists sliding sideways below its grip limit (default 1). |
| `forwardFriction` | Longitudinal grip coefficient (default 1): drive and brake force are held to this times the load the wheel carries. |
| `sidewaysFriction` | Multiplier on `sideFrictionStiffness`. |
| `rollingResistance` | Drag on a freely rolling wheel, times the load it carries. |
| `is2D` | The wheel applies no sideways grip. |
| `visual` | The wheel's visual: an entity, or its id (see below). |
The suspension ends at a **bump stop**: once the spring has compressed by `suspensionDistance` from its rest, the wheel holds the chassis off the ground there like a solid contact. It stops the chassis closing on the ground within the tick, so a landing faster than the springs can take, or a chassis heavier than its springs carry, ends on the stops with the tyre on the ground rather than in it, and the chassis is not thrown back up. A chassis that stands past its stops (put there by a script, say) is eased back out at no more than 1 m/s.
The spring is applied as an impulse worked out from the state at the start of each tick. So that it cannot feed on itself, its rate is held, per tick, to what the chassis' mass **and its rotational inertia** integrate stably: a light chassis, or one whose pitch or roll inertia is small next to its wheelbase, gets less of a very stiff spring than authored. The damper is applied to the velocity the chassis ends the tick with, which is stable at any rate, so the authored `damperRate` always acts whole. A damper pulls a wheel down no harder than its spring pushes it.
## Runtime state
Read-only, updated every physics tick, and readable through `Physics.getWheelState(wheelId)`:
- `isGrounded`: the tyre touches the ground anywhere within the suspension's full travel, `suspensionDistance` below the mount. Past the spring's rest it carries no load.
- `compression` (0 with the spring at or past its rest length, up to `suspensionDistance`, where the bump stop is).
- `angularVelocity` (rad/s) and `rotation` (rad): the wheel's spin and its angle about the axle, which grow as it rolls forward by the distance rolled over the radius.
- `skid` (m/s): how fast the tyre slides sideways while its grip is used up; 0 while the tyre holds.
- `sideImpulse`, `forwardImpulse` (N·s): what the tyre applied to the chassis this tick. `suspensionForce` (N). `stopImpulse` (N·s): what the bump stop applied this tick, the part of a landing or a load the suspension could not take.
- `center`, `hitPoint`, `hitNormal`: the wheel centre and the ground contact, in world space. A wheel with no ground within its travel hangs at full travel.
A driver for the wheels is built in: `VehicleController` on the chassis reads the vehicle input map and writes `motorTorque`, `brakeTorque` and `steerAngle`.
`Physics.vehicleState(chassisId)` reads the whole vehicle: the chassis' forward speed (m/s, negative in reverse), how many wheels are grounded, and each wheel's state.
## Seeing the suspension
`capture { pass = "physics" }` and the edit-mode collider overlay draw every wheel over the colliders: its suspension ray over its whole reach, `suspensionDistance` and the radius below the mount (green on ground, red in the air, as `isGrounded` reads), the travel from the mount to the tyre's centre coloured by how much of it is taken up (blue extended, yellow half way, red bottomed out), the tyre's radius as a white circle across its steered axle, and where the tyre meets the ground (yellow) with the ground's normal (cyan). `Physics.debugLines({ layers = { "wheels" }, entities = { chassis } })` returns those lines with each wheel's `grounded` and `compression` (the same values as `isGrounded` and `compression` here), `travel`, `suspensionLength`, `restLength`, `radius`, `mount` and `contactPoint` / `contactNormal`. In edit mode the vehicle stands at rest, so its wheels draw where they are mounted and follow their authored fields as they change.
## Visuals
The wheel entity's transform is the suspension mount, so it stays where it is authored. Put the tyre mesh on another entity under the chassis (a child of the wheel is the usual place) and name it in `visual`, as the entity or its id (a prefab or player clone of the car points it at its own copy of the tyre): every tick the wheel places that entity at the wheel centre, turns it by the steering angle and spins it about the axle, starting from the rotation it had when the wheel first posed it.
Methods: `setMotor(torque)`, `setBrake(torque)`, `setSteer(angle)`.
```luau
local car = entity.spawn("car")
car.position = { 0, 1, 0 }
car.component.add("Physics", { kind = "dynamic", mass = 600 })
car.component.add("BoxCollider", { half = { 0.9, 0.3, 2.0 } })
for _, at in { { -0.85, -1.3 }, { 0.85, -1.3 }, { -0.85, 1.3 }, { 0.85, 1.3 } } do
local wheel = entity.spawn("wheel", { parent = car.id })
wheel.localPosition = { at[1], -0.3, at[2] }
local tyre = entity.spawn("tyre", { parent = wheel.id })
tyre.component.add("Model", { model = "cylinder" })
tyre.localRotation = { 0, 0, 0.7071068, 0.7071068 } -- the cylinder's axis along X
tyre.localScale = { 0.7, 0.25, 0.7 }
wheel.component.add("WheelCollider", {
suspensionDistance = 0.3,
springRate = 20000,
damperRate = 2000,
visual = tyre,
})
end
```
# RenderFeature
Runs a `.renderFeature` render pass for the lifetime of the entity carrying it.
A scene's features are listed together, each with its settings, on
`RenderFeatures`; this is the one-feature form. A feature already live on the
same asset is adopted rather than stacked a second time.
A render feature is authored content — a `.renderFeature` asset whose `init.luau`
returns `{ setup?, render, teardown? }` and enqueues GPU passes every frame. This
component is the declarative way to keep one running: on awake / enable it calls
`renderer.feature.create(feature)`, and on disable / destroy it tears the feature
down. A render feature is a **global** pass, so a single component on a single
entity drives it for the whole scene.
## Fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `feature` | `assetRef("renderFeature")` | none | The render feature asset to run. |
## Methods
- `:getHandle()` — the live `RenderFeatureHandle` this component created (its
`guid` is the feature's stable id), or `nil` when disabled / before awake.
## Persist
This component is the persist-side mirror of a live `renderer.feature.create(…)`
call. When you enable a render feature ad-hoc during play, the scene-authoring
freeze (`tools.sceneAuthoring.acceptChanges`) promotes the feature's source into a
`.renderFeature` asset and records it on one entity carrying this component — so
loading the scene re-runs the exact same render pass.
## Example
```lua
local e = entity.spawn("acrylic_pass", {})
e.component.add("@builtin::components.RenderFeature", {
feature = asset.resolve("acrylic", "renderFeature"),
})
```
# 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.
# edui
The editor UI framework, on the CSS-parity `ui.*` surface. An editor-only
toolkit: panels author closures instead of callback ids, a dock shell over
`ui.*` native docking, and typed editor-chrome primitives. Gameplay and normal
world UI author raw `ui.*` trees and need none of this.
**`edui.guide` beside this file is how to use it** — creating a panel, the spec
it returns, how a panel is drawn and redrawn, the whole widget vocabulary, and
how a panel reaches the selection and the dock.
## What installs
- **`framework`** — everything under one table. From outside the package,
`require("@builtin::~edui.framework")`; inside it, `require("~.framework")`.
It carries `app`, `widgets`, `shell`, `topbar`, `query`, `fileDialog`,
`worldDialog`, `stats`, and the chrome primitives flat (`panel`, `tree`,
`field`, `section`, `text`, `button`, `menu`, …).
- **`app`** — maps a panel's closures to `ui.*` callback ids, dispatches to
them, and coalesces redraws.
- **`widgets`** — the primitives, plus the typed inspector layer
(`widgets.tree`, `widgets.fields`, `widgets.controls`).
- **`inspector`** — the Inspector as an ordered list of sections: the generic
asset sections, the Settings section and its actions, and the merge with
the sections a type's `inspector.luau` declares (`inspector.assetSections`,
`inspector.settingsSection`), and the building blocks those sections
compose (`inspector.parts`, reached as `ctx.parts`).
- **`shell`** — the dock: one `ui.*` `dockArea` holding a `dockPanel` per
registered panel, and the panel registry behind it.
## Referencing
- Inside this package, modules reference siblings with `~.` — e.g.
`require("~.app")`, `require("~.widgets.fields")`.
- Editor content built on the framework references it by package identity —
`require("~edui.framework")`, `require("~edui.shell")`.
# CachedIndirect
The scene's cached indirect light, as a component. The fields are the effect —
editing one re-pushes it, and the values persist with the scene.
# cachedIndirect
The settings for the scene's cached indirect light, and the lifetime of the
passes that draw it. Owns the cache the render feature drives.
`set` / `get` / `clear` / `active` / `invalidate` / `cache`.
·renderfeature · born here
❒asset # cachedIndirect
The scene-wide user of the world-space radiance cache. Runs the cache's
allocate, gather and resolve passes and composites the result against the
scene's albedo, so indirect diffuse light is gathered once per patch of world
and reused by every later frame and every pixel standing on that patch.
Driven through `@builtin::systems.radianceCache.cachedIndirect`.
# radianceCache
A world-space store of lighting results that survives the frame that produced
it. `create(name, opts)` hands back a cache that owns its own fixed table of
slots, addressed by hashing a world-space cell, so it covers an unbounded world
in bounded memory.
A technique either drives the built-in screen-space producer with `run`, or
enqueues its own producing pass over `bindings()` and lets the cache own the
table, the addressing and the eviction.
# radiance_cache
The addressing of the world-space radiance cache, shared by every pass that
touches it: which cell a point belongs to, how coarse that cell is at its
distance from the camera, which of the six facings it is subdivided by, and the
hash that turns all of that into a slot in a fixed table.
Also the lookup — the interpolation over the eight cells surrounding a point,
rejecting the ones on another surface — and the reconstruction of a pixel's
world position from the depth target.
A shader that includes it binds `rc_params` and `rc_cells`. The passes that
insert also bind `rc_keys` as `atomic<u32>`, which this does not touch.
·computeshader · born here
❒asset # rc_alloc
Claims a cache slot for every patch of world the frame is looking at, walking
the frame at a coarse stride. A patch already in the table is stamped with this
frame and with the pixel that can see it; one that is not takes the first slot
that is empty or whose occupant has aged out, through an atomic exchange so two
pixels racing for the same slot produce one winner.
Stamping is half the eviction policy: what no pixel asks for stops being
stamped, and `rc_gather` releases it.
·computeshader · born here
❒asset # rc_apply
Composites the cached bounce into the image: the light arriving at each surface
times the surface's own albedo, added to the drawn colour.
·computeshader · born here
❒asset # rc_gather
Fills the cache, one slot per thread and a fraction of the table per frame.
Runs over cache slots rather than pixels, which is what the cache buys: a
thousand pixels looking at one wall share one gather, and that gather is not
repeated next frame. Each frame visits the slots whose index falls on this
frame's residue, so the table turns over every `stride` frames.
What a slot gathers is a form-factor sum over the surrounding pixels, averaged
into what the slot already holds. Also releases slots nothing has asked for in
`maxAge` frames.
·computeshader · born here
❒asset # rc_resolve
Reads the cache back out per pixel: works out which patch the pixel stands on
and interpolates over the eight cells surrounding it.
The only per-pixel cost the cache carries, and it gathers nothing. Alpha carries
how much of the lookup the cache could cover, so a pixel over a patch the cache
has not filled yet is left alone rather than darkened.
# Package (asset type)
A package is an asset — a `<Name>.package/` folder that bundles
components, modules, scenes, presets, tools, and any other typed
sub-assets under one identity, installed into a world as one unit.
Packages are how authoring sets get distributed across worlds.
A **library** is the addressable root scope content resolves under
(`@builtin`, `@<name>`) — a whole world mounted into this one under a
namespace, via `zeromind.installLib` / `world.installLibrary`. A package is
an asset that lives inside such a root, and one library holds as many
packages as its author put there. A package is installed by guid the
way every asset is (`zeromind.install`, `zeromind.installAsset`).
## When to use one
- You're authoring a coherent feature suite (character controller
stack, voxel engine, UI kit, animation library) and want to ship
it as one install target.
- You want a stable identity worlds can install and resolve sub-assets
against (`@<lib>::pkg.sub_module`).
- You want versioning + a per-package metadata layer that travels with
the contents.
If you're authoring inside a single world and don't need a separate
install lifecycle, you don't need a package — drop your `.component`
/ `.module` / etc. directly in `/zero/source/`. Packages are for
cross-world distribution.
## Where it lives
- Source: `/zero/source/.../<Name>.package/` (or, for legacy
packages, a folder containing `package.yaml` at its root).
- Identity: `<Name>` (the `.package` suffix collapses in identity —
mirrors `.module`). A file at `<Name>.package/<sub>/init.luau`
resolves under the same identity it would at `<Name>/<sub>/init.luau`.
- Folder shape (container-style — open child set):
- `package.yaml` — metadata (name, version, description).
**Required.**
- `README.md` — instance documentation. **Required.**
- `.metadata` — agent-editable tags + free-form fields. **Required.**
- Sub-asset folders: any combination of `.component/`,
`.module/`, `.scene/`, `.preset/`, `.tool/`, `.toolbox/`,
`.material/`, `.shader/`, etc.
The validator permits unlisted children (`allow_unlisted: true`) by
design — packages are open containers.
## How to create one
```luau
asset.create("package", "<Name>")
-- Creates: /zero/source/<Name>.package/
-- package.yaml (metadata scaffold)
-- README.md (instance README template)
-- `folder` places it in a subfolder of /source instead of the root:
asset.create("package", "<Name>", { folder = "systems" })
```
Then scaffold sub-assets inside it:
```luau
local pkg = asset.resolve("<Name>.package")
asset.create("component", "Spinner", { into = pkg })
asset.create("module", "util", { into = pkg })
```
## How it operates
1. **Registration.** Writing `package.yaml` indexes the package with
the package registry.
2. **Install.** A package is installed by guid, the way every asset
is: `zeromind.install` from the shared library, or
`tools.use("zeromind", "installAsset", <guid>)` inside the engine. The
install writes the package's files (and the deps its `.refs`
record) into this world's source, so its sub-assets resolve here.
`tools.use("zeromind", "preview", <guid>)` reports what an install would
write before it writes anything.
3. **Sub-asset resolution.** Because `.package` collapses in
identity, sub-asset paths work whether the package folder is
suffixed (`<Name>.package/<sub>`) or not (`<Name>/<sub>`).
4. **Updates.** `tools.use("zeromind", "updates")` lists installed content
with upstream changes available; re-running the install pulls them.
5. **Metadata.** `package.yaml::name` overrides the folder stem if
present (rare); `version` + `description` are surfaced in the
package index.
6. **Hot reload.** Edits to sub-assets hot-reload as normal; edits to
`package.yaml` reload package metadata.
## Discovery
- `asset.list("package")` — every registered package.
- `asset.inspect("<name>")` — sub-asset catalog, source, this type
README.
- `ls /zero/source/<Name>.package` lists the files the folder holds.
- `library.list()` — the library assets registered in this world,
including the ones packages under an installed library contribute.
## Authoring conventions
- Name packages by their feature (`voxels`, `cameraRigs`,
`dialogSystem`), not by their owner — the host library is the
`@<lib>::` scope prefix.
- Cluster cohesive sub-assets together. A "character controller"
package should ship the controller component, its preset configs,
its supporting modules, and a demo scene — not just the component.
- Document the package's surface in `README.md` — what install
installs, what each sub-asset does, how they relate. Agents reading
`cat /zero/source/<Name>.package/README.md` get the full picture in one shot.
- Version conservatively — breaking a package means breaking every
world that installed it. Use semantic version bumps.
## Common pitfalls
- **Missing `package.yaml`.** Without it the folder isn't recognized
as a package even if it has a `.package/` suffix.
- **Dependency installs.** A package that depends on another package
must declare it (in `package.yaml::dependencies` once supported)
and the parent world must install dependencies in order.
- **Identity collapse.** Sub-asset paths skip the `.package` suffix.
Don't include `.package` in `require` strings.
## Referencing inside a package: the `~` rule
`~` means **this package**, resolved structurally from the referring file's own
location. It is the only package-specific syntax. It is rename-safe (no package
name is ever written) and library-agnostic (it never names the host library, so
it survives `@builtin` being renamed or the package being mounted elsewhere).
`~.tail` maps to `<pkg>.package/tail` from anywhere inside the package.
| Referencing from | Use | Example |
|---|---|---|
| An asset/module **inside this package** | `~.tail` | `require("~.modules.myThing")`, `shader: "~.shaders.myThingBlend"` |
| A **different package** under the same root | root-relative identity | `require("@root::systems.otherPkg.modules.x")` |
| A **different library's** public API (a deliberate external dependency) | `@otherlib::…` | `require("@physics::modules.rigidbody")` |
**Never write `@builtin::` (or any host-library name) to reference your own
package** — that couples the package to the library it currently lives in and
breaks if the library is renamed or the files move. Use `~.`. It works from
Luau `require()` and from asset-reference strings in data files (a material's
`mat.yaml` `shader:` field, a component's default asset ref); the engine
resolves it against the referring file's own package. `asset.resolve` takes a
`~.` name the same way, against the package of the file that calls it:
`asset.resolve("~.materials.myThingStandard", "material")`.
`~.` is the package-relative self-reference; `@root::` is the root-relative one
(the library root, or the world source tree for non-library content). The
`core/requiring-modules` guide covers the root model in full.
A package that ships substantial functionality should include a `.guide` asset
(a `<name>.guide/guide.md` folder anywhere under the package); the `guides` tool
discovers it live, so collaborators find the system the way they find built-in
guides. A package's test suite reaches the shared `Test` framework through its
enclosing asset: `local Test = asset.containing(__FILE__).modules.shared`
(`asset.containing` returns the deepest enclosing typed asset, the `.testSuite`,
not the outer `.package`).
## Related types
- Any of `.component`, `.module`, `.scene`, `.preset`, `.tool`,
`.toolbox`, `.material`, `.shader`, `.style`, `.service`,
`.importer` — packages bundle these.
## In the Inspector
Package: the name, version and description package.yaml declares, how many things the package holds and what they are, and **Open** for package.yaml. A package.yaml that does not decode is listed under Problems with the decoder's error.
# SubsurfaceScattering
Marks an entity's meshes as carrying light beneath the surface. The terminator
softens and warms instead of ending on a line, and light spreads through a thin
edge instead of stopping at it.
```lua
entity(headId).component.add("SubsurfaceScattering", {
color = { 1.0, 0.3, 0.16 },
radius = 0.02,
})
```
Every field is live — editing one in the inspector moves the shading on the next
frame, and the values persist with the scene. Removing the component stops the
entity scattering, and the pass is released with the last marked entity.
| field | effect |
|---|---|
| `color` | What light becomes beneath the surface. Its channels also set how far each one travels, so a tint weighted to red spreads red furthest. |
| `radius` | How far light travels beneath the surface before leaving, in world units over [0, 1]. Skin at human scale sits near 0.02. |
| `strength` | How much of the spread is mixed back into the frame, in [0, 1]. 0 leaves the shading exactly as it was lit. |
A radius below zero is reported as a warning and the last good setting stays
live, so a typo in the inspector does not stop the component.
Code that marks meshes at runtime can drive the same settings through
`@builtin::systems.subsurfaceScattering.subsurface`.
·renderfeature · born here
❒asset # sssDiffusion
The render pass behind the `SubsurfaceScattering` component and the `subsurface`
module.
The transport it draws is a spread of exitant light across a surface, so it runs
on the frame that was just drawn. Each marked mesh is drawn into a mask carrying
its tint and radius — one draw per group of meshes sharing both. Two passes then
spread the lit colour along that mask, once across and once down; two lines
rather than a disc is what makes the reach cost twice the taps instead of their
square. A final pass mixes the result back by `strength`.
Weights are a Gaussian in units of each channel's own reach, and the reach is
the tint: a channel the surface passes freely is the one that travels furthest,
which is what carries red past the terminator while green and blue stop at it.
Taps are confined to the mask and to a depth tolerance, so light spreads across
one surface and stops at its silhouette rather than dragging the background into
it. The radius is world-space, converted to a pixel span from each pixel's own
depth, so a surface keeps the same softness as the camera moves toward it.
Depth and camera come from the render target being drawn, so the pass is correct
in offscreen captures and RTT cameras at their own resolutions.
# sssMask
Fills a marked mesh's silhouette with the scattering it was authored with: the
tint in RGB, and in alpha the world-space distance light travels beneath the
surface before it leaves, over [0, 1]. Alpha above zero is what marks a pixel as
scattering at all, so a target cleared to zero reads as "no subsurface here".
A geometry pass draws through a material pipeline built for the frame's own
colour format, which is why the mask is 8-bit rather than float. One group holds
one radius, so that quantisation lands on the authored value rather than varying
across a surface.
One material carries one group's values and `sssDiffusion.renderFeature` issues
one draw per group, so meshes scattering differently keep their own numbers.
| property | meaning |
|---|---|
| `scatter_color` | The tint, and per channel how far that channel travels. |
| `scatter_radius` | The distance light travels beneath the surface, in world units. |
·computeshader · born here
❒asset # sss_across
Blurs subsurface diffusion across the image — one half of the separable pair.
The body is `sss_diffuse`; this shader supplies the axis.
## Includes
- `sss_diffuse`
## Bindings
| name | kind |
|------|------|
| `sss_src` | `texture2d` |
| `sss_mask` | `texture2d` |
| `scene_depth` | `texture_depth` |
| `frame_cam` | `buffer` |
| `sss_params` | `buffer` |
| `sss_out` | `storage2d (rgba16f)` |
·computeshader · born here
❒asset # sss_composite
Composites the diffused subsurface term back over the lit scene.
## Bindings
| name | kind |
|------|------|
| `scene_color` | `texture2d` |
| `sss_diffused` | `texture2d` |
| `sss_mask` | `texture2d` |
| `sss_params` | `buffer` |
| `composite_out` | `storage2d (rgba16f)` |
# sss_diffuse
One separable subsurface-diffusion blur. The including shader defines `AXIS` —
the direction this pass blurs along — so the same body serves the horizontal and
the vertical halves of the separable pair.
```wgsl
const AXIS: vec2<f32> = vec2<f32>(1.0, 0.0);
#include "@builtin::systems.subsurfaceScattering.sss_diffuse"
```
·computeshader · born here
❒asset # sss_down
Blurs subsurface diffusion down the image — one half of the separable pair.
The body is `sss_diffuse`; this shader supplies the axis.
## Includes
- `sss_diffuse`
## Bindings
| name | kind |
|------|------|
| `sss_src` | `texture2d` |
| `sss_mask` | `texture2d` |
| `scene_depth` | `texture_depth` |
| `frame_cam` | `buffer` |
| `sss_params` | `buffer` |
| `sss_out` | `storage2d (rgba16f)` |
# subsurface
Light that enters a surface at one point and leaves at another — the transport
that softens and warms the terminator on skin.
```lua
local subsurface = require("@builtin::systems.subsurfaceScattering.subsurface")
subsurface.mark(headId, { color = { 1, 0.3, 0.16 }, radius = 0.02 })
subsurface.unmark(headId) --> true when it had been marked
subsurface.marked(headId) --> { color, radius }, or nil
subsurface.list() --> every entity scattering, sorted
subsurface.groups() --> the marked set gathered by what it scatters
subsurface.setStrength(0.7) --> how much of the spread reaches the frame
subsurface.active() --> is the pass running
subsurface.clear() --> unmark everything, release the pass
```
`mark` on an entity that is already marked replaces what it scatters. A radius
below zero is an error rather than a silent clamp, and an entity that does not
exist is an error rather than a mark that never draws. The pass is created with
the first mark and released with the last.
`groups` is what the render feature draws from: meshes sharing a tint and a
radius are one draw, so per-entity scattering costs one pass per distinct
setting rather than one per mesh. It also drops entities that have since been
despawned, so a removed character does not keep its group alive.
For scattering a scene should simply have, author the `SubsurfaceScattering`
component instead — same settings, persists with the scene, edits live in the
inspector.
The full description of the knobs and how the passes work is in the package
readme: `guides { path = "systems/subsurfaceScattering" }`.
# 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)` |
# ParticipatingMedium
Marks its entity as a volume of fog, smoke, dust or steam — something light has
to travel through. What is behind the volume comes out dimmer than what is
beside it, and overlapping volumes stack.
```luau
local id = entity.spawn("fog_bank").id
entity(id).component.add("ParticipatingMedium", {
sizeX = 30, sizeY = 8, sizeZ = 30,
density = 0.15,
})
```
## Fields
| Field | Meaning |
|---|---|
| `shape` | `"box"` (default) or `"sphere"` |
| `density` | Extinction per world unit. `0` releases the medium. |
| `sizeX` / `sizeY` / `sizeZ` | Half-extents, for a box |
| `radius` | Radius, for a sphere |
`density` is the physical knob: a ray crossing `d` world units of a medium of
density `k` leaves with `exp(-k * d)` of the light it entered with. A 20-unit
bank at `0.15` therefore passes about 5% of the light straight through it.
The entity's position places the volume; move the entity and the shadow moves
with it.
## Notes
- The light-space map is sized to the bounding sphere of everything registered.
A small bank sharing a scene with a very large one lands on few texels — set
`extent` and `centre` through `mediaTransmittance.set` to bias the map toward
the region you care about.
- Attenuation applies to surfaces. The sky keeps its own colour.
- The attenuated light is the sun's: `sunDirection` sets which way the map's
rays travel.
See `mediaTransmittance` for the scene-wide settings and the direct API.
# Volume
Per-entity attachment that renders a 3D voxel texture (cloud, smoke,
nebula, imported VDB grid) into the scene as a volumetric body. The
component does NOT own the volume's lifetime — producers (the
`volume` module, a custom compute fill, or `compute.writeTexture3D`)
build the texture and pass its name in `volumeName`. The entity's
world transform defines the AABB the raymarcher samples.
`volumeName` is resolved through `Volume.get`, so a volume the `volume`
module built is rendered as it stands — at its own dimensions, from its own
density channel, through its occupancy grid, and out of its brick pool if it
is paged. A name no volume answers to is taken as a bare 3D texture some
other system owns, and `occupancyName` / `isRgba` describe it.
## Usage
```luau
local Volume = require("@builtin::systems.volumetrics.volume")
local cloud = Volume.noise({ name = "cloud", size = 64, scale = 4 })
cloud:buildOccupancy({ brickSize = 8 })
local e = entity.spawn("cloud_entity")
e.localScale.set(8, 4, 8)
e.component.add("Volume", {
volumeName = "cloud",
occupancyName = cloud.occupancyName,
density = 6.0,
})
```
A paged volume costs what it holds and is named the same way:
```luau
local cloud = Volume.noise({ name = "cloud", size = 128, threshold = 0.6 })
cloud:buildSparse({ brickSize = 8, threshold = 0.01 })
cloud:releaseDense() -- the brick pool is now the only copy
local e = entity.spawn("cloud_entity")
entity(e.id).localScale = { 8, 4, 8 }
e.component.add("Volume", { volumeName = "cloud", density = 6.0 })
```
## Public fields
| Field | Type | Sync | Default | Meaning |
|---|---|---|---|---|
| `volumeName` | string | Sync | `""` | Required. Name of the volume, or of a bare 3D texture, to render. |
| `occupancyName` | string | Sync | `""` | R8 occupancy texture for a bare texture's empty-space skipping. Empty = no occupancy. A volume the module built carries its own, and a name set against one is reported as left aside. |
| `density` | number | Sync | `1.0` | Density gain multiplier. |
| `absorption` | number | Sync | `1.0` | Beer-Lambert absorption coefficient. |
| `emission` | number | Sync | `1.0` | Emission gain. |
| `color` | color | Sync | `{1,1,1,0}` | RGB tint + density floor in alpha. |
| `stepSize` | number | NoSync | `0` | World units per ray step. `0` = auto-derive from volume extent. |
| `maxSteps` | number | NoSync | `256` | Ray-march step cap. |
| `target` | string | Sync | `"scene_volume_rt"` | Output render target name (composite key). |
| `targetWidth` | number | NoSync | `1280` | Output width (px). |
| `targetHeight` | number | NoSync | `720` | Output height (px). |
| `useOccupancy` | boolean | NoSync | `true` | Toggle empty-space skipping. |
| `isRgba` | boolean | Sync | `true` | `false` reads density from the red channel. Left `true`, a volume the module built is read from the channel it was built with, and a bare texture as RGBA. |
All public fields use the `Field.<kind>(default, Sync|NoSync)` schema
(PR #3019). Visual params (look, tint, density) propagate Sync;
quality knobs (stepSize, maxSteps, target resolution, useOccupancy)
are NoSync — each client picks its own perf/quality point.
## Lifecycle
- **`awake`** — no-op. The volume texture is owned by the producer
and looked up lazily on the first `update`.
- **`update(dt)`** — resolves `volumeName` through `Volume.get`, falling
back to a wrapper over a bare texture of that name, and dispatches the
compute raymarcher into `target`. The resolution runs every frame, so a
volume built, paged or destroyed after this component attached is picked
up on the next one.
- **`onPropertyChanged(key, value, old)`** — drops the resolved volume
when `volumeName`, `occupancyName`, or `isRgba` changes, forcing a
re-resolve on the next frame.
## Related
- `volume` module — texture builders and name lookup (`Volume.noise`,
`Volume.get`, `Volume.wrap`, `:buildSparse`, `:render`).
- `vdb_to_zvol.importer` — VDB → dense voxel pipeline that produces
textures consumable by this component.
- `volumeDemos` / `volumetricSky` packages — worked examples.
# VolumeSequence
Plays an open volume sequence through an entity's transform AABB.
The sequence is opened by a producer and named here — the same split the
`Volume` component makes between "what data to render" and "where it appears
in the world". Each tick the component advances the playhead and raymarches
the frame that is resident into the shared `scene_volume_rt` target, in both
play and edit mode, so the run animates while it is being authored.
```luau
local Seq = require("@builtin::systems.volumetrics.volumeSequence")
Seq.open({ name = "plume", frames = paths, resident = 3, fps = 24 })
local id = entity.spawn("plume_entity").id
entity(id).localScale = { 8, 8, 8 }
entity(id).component.add("VolumeSequence", { sequenceName = "plume", density = 6.0 })
```
`fps` and `playing` are pushed into the sequence when they change, so a
script calling `seq:setFps()` / `seq:pause()` between two ticks is left
standing until the authored value itself moves.
The component does not own the sequence: despawning the entity leaves the
ring allocated, and `volumeSequence.close(name)` is what frees it.
## Sharing
The playhead, the rate and the ring belong to the sequence, so several
entities naming one run play the same frame from the same three textures:
the playhead advances once per engine frame however many components tick it,
and the rate is whichever component's `fps` moved last. Give each entity its
own run when they should play independently.
`target` is a render target, and a raymarch writes the whole of it — two
volumes visible at once (a sequence beside a `Volume`, or two sequences) need
a `target` name each, or the later write replaces the earlier one and only
one of them appears. Composite each target the way any volume render is
composited — see `volume.compositeShaderSource()`.
# VolumetricLighting
The air between the camera and the scene, as a component. Add it to an entity
and light travelling through that air scatters toward the viewer, so a lamp
becomes a glow rather than an invisible point and the sun cuts shafts where
something crosses it.
```lua
local id = entity.spawn("air").id
entity(id).component.add("VolumetricLighting", {
density = 0.06,
anisotropy = 0.7,
})
```
Every field is live — editing one in the inspector changes the air on the next
frame, and the values persist with the scene. Setting `density` to 0 empties the
air and releases the passes while keeping the rest authored; removing the
component clears it.
| field | effect |
|---|---|
| `density` | How much medium there is per world unit at the reference altitude. 0 is off. |
| `heightFalloff` / `heightRef` | How fast density thins with altitude, and the altitude `density` is quoted at. 0 falloff fills the scene evenly. |
| `steps` | Samples along each view ray. More resolves a beam's edge more cleanly. |
| `colorR/G/B` | What the medium scatters. Warm dust and cold mist differ here. |
| `anisotropy` | How forward-biased the scattering is, in (-1, 1). Positive values throw light onward, so haze blooms around a source you look toward. |
| `sunIntensity` / `sunColorR/G/B` | The sun's contribution to the medium. |
| `lightScale` | The scene lights' contribution to the medium. |
| `shadowSteps` / `shadowDistance` | Samples taken back toward the sun to decide whether a point in the air is lit, and how far they reach. **0 leaves the sun's contribution unshadowed**, so the haze glows evenly and no shafts form. |
| `maxDistance` | How far along the view ray the march runs. A ray that escapes to sky stops here. |
The lights are the scene's own, each scattering on the terms its kind emits on:
a point fills the air around it, a spot fills its cone, a rect reaches the air on
the side its face emits along, and a parallel light lays an even glow across the
whole medium. One added, moved, re-aimed or recoloured at runtime changes the
glow it throws with nothing to keep in step here.
One body of air fills the scene, so one of these per scene is enough — the last
one to push wins.
The work runs in `lightScattering.renderFeature`; the settings live in
`@builtin::systems.volumetrics.volumetricLighting`, which is also how to drive
them from a script.
# VolumetricSky
Singleton component that drives the procedural cloud layer. Attach
to one entity (usually a sky placeholder), and the component owns
the cloud volume + render target lifecycle, bakes the noise field
when what it is baked from changes, and dispatches the compute
raymarcher every frame into `scene_volumetric_sky`.
A composite (post-process or material) blends `scene_volumetric_sky`
over the scene — see `demo/sky.scene/` for the full wiring.
## Usage
```luau
-- Defaults (the v4-tuned cumulus look — `Sky.defaultPreset()` hands out a
-- copy of the same table this field defaults to)
local sky = entity.spawn("sky")
sky.component.add("VolumetricSky", {})
-- Or apply a captured preset:
preset.apply("cumulus_default", { entity = sky.id, component = "VolumetricSky" })
-- Inline tweak. `component.add` writes the `preset` field as it is given,
-- so a table handed here carries the whole configuration:
local p = require("@builtin::systems.volumetrics.volumetricSky").defaultPreset()
p.noiseScale, p.octaves, p.coverage = 9.0, 5, 0.20
p.densityGain = 0.04 -- v5 σ_t
sky.component.add("VolumetricSky", { shaderVariant = "v5", preset = p })
```
`volumetricSky.module`'s `M.spawn` / `M.configure` take the same parameters
by name, folding each into the current configuration and leaving the rest
where they stand:
```luau
local Sky = require("@builtin::systems.volumetrics.volumetricSky")
Sky.spawn({ shaderVariant = "v5", noiseScale = 9.0, coverage = 0.20 })
```
## Schema (Field<T> / Sync / NoSync)
The 28 shaping / weather / lighting params travel as ONE Sync field
— `preset = Field.table(DEFAULT_PRESET, Sync)` — so replicating a
sky change is **one mutation, not 28**, and copying a sky between
scenes is **copying one field, not 28**. Per-client quality knobs
sit outside the preset as NoSync fields so each client picks its
own tier without disturbing peers.
`SkyPreset` and `DEFAULT_PRESET` are declared by `volumetricSky.module`,
which this component reads its default from and which routes an option by
that same key set — so the shape below and the module's `M.spawn` options
are one declaration.
| Field | Type | Sync | Default | Meaning |
|---|---|---|---|---|
| `preset` | `SkyPreset` table | Sync | `DEFAULT_PRESET` (v4 cumulus) | All shaping / weather / lighting (see below). |
| `shaderVariant` | string | Sync | `"v4"` | `"v3" \| "v4" (default) \| "v5"`. |
| `wind` | vec3 | Sync | `{0,0,0}` | Animated drift offset (advanced per frame from `preset.windSpeed`). |
| `cloudSize` | number | NoSync | `256` | Volume W,D resolution. |
| `cloudHeight` | number | NoSync | `128` | Volume H resolution. |
| `stepSize` | number | NoSync | `4.0` | World units per ray step. |
| `maxSteps` | number | NoSync | `224` | Ray-march step cap. |
| `shadowSteps` | number | NoSync | `8` | Shadow ray step count. |
| `alphaCutoff` | number | NoSync | `0.99` | Early-exit α threshold. |
| `jitter` | number | NoSync | `1.0` | Ray-origin jitter strength. |
| `taaBlendAlpha` | number | NoSync | `0.1` | TAA current-sample weight. |
| `targetWidth` | number | NoSync | `0` | Render target width (`0` = viewport). |
| `targetHeight` | number | NoSync | `0` | Render target height. |
| `noiseRegenRate` | number | NoSync | `10` | Times per second wind drift re-bakes the cloud volume (`0` = every frame the wind moved). |
### Preset (`SkyPreset`) shape
Noise / shaping: `noiseScale, octaves, coverage, densityGain,
altitudeLow, altitudeHigh, anvilBias, detailScale, detailOctaves,
detailStrength, detailThreshold, baseLowRemap, baseHighRemap, color,
windSpeed, seed`.
Volume placement: `horizExtent, cloudBaseAltitude, cloudTopAltitude`.
Look (visual, not perf): `phaseG`.
Sun / sky: `sunDir, sunColor, sunIntensity, sunDiscSize,
horizonColor, zenithColor, groundColor, ambient`.
v5-only: `cloudType, worleyBlend`.
See `presets/cumulus_default.preset/preset.yaml` for the canonical
full preset shape, and the `init.luau` `DEFAULT_PRESET` table for
the runtime defaults.
## Where the frame time goes
Two passes carry the cost, and different fields reach each:
- **The bake** (`cloud_noise_*`) fills every voxel of the
`cloudSize × cloudHeight × cloudSize` volume with layered noise. It is
the most expensive pass the layer has (around 10 ms at 256×128×256 on a
desktop GPU), and it runs only when the volume's inputs move: at once
for a change to `preset`, `cloudSize`, `cloudHeight`, `shaderVariant`
or the render device, once the program it compiles is resident for an
edit to the noise shader's own source, and at most `noiseRegenRate`
times per second for wind drift. A sky whose `preset.windSpeed` is zero
bakes once. `cloudSize` / `cloudHeight` set what one bake costs;
`noiseRegenRate` and `windSpeed` set how often it is paid.
- **The raymarch** (`cloud_raymarch_*`) runs for every view of every
frame. `stepSize`, `maxSteps`, `shadowSteps`, `alphaCutoff` and
`targetWidth` / `targetHeight` set its cost.
`profiler.gpuFrame()` reports both as `compute.<shader>` spans; a span's
`frames` count is how many frames of its window the pass ran in, and
`volumetricSky.volumeBakes()` counts the bakes themselves.
## Field-mutation gotcha (and why `update()` reassigns the whole vec3)
Under the new Field<T> / Sync / NoSync schema, `public` is not a
plain Lua table — it's wrapped in a proxy whose `__newindex`
metamethod performs type-checks, marks the field dirty, and queues
Sync replication. Writing `public.wind = {x,y,z}` triggers all four
steps. Writing `public.wind[1] = x` reads `public.wind` (getter),
mutates the returned table directly, and **bypasses the setter** —
no Sync replication, no dirty flag, no `onPropertyChanged`. Always
assign the whole vec3 / preset / color table; never index-mutate.
The same caveat applies to `public.preset`: `public.preset.coverage = 0.2`
won't replicate. To change one preset field, copy the table, mutate,
reassign:
```luau
local p = public.preset
local next = {}
for k, v in pairs(p) do next[k] = v end
next.coverage = 0.2
public.preset = next
```
## Lifecycle
- **`awake`**: starts the sky's clock and creates the render feature
that runs the layer's passes.
- **`update(dt)`** — advances `wind` (reading `preset.windSpeed`),
hands the noise inputs to `volumetricSky.fillClouds`, which bakes the
volume when they moved (see "Where the frame time goes"), then queues
the raymarch dispatch for the frame's deferred phase.
- **`onDespawn`** — releases the volume, the render target and the
composite effect via `M.destroy`.
### Why the raymarch is dispatched from `task.defer`
The raymarch produces ONE full-screen layer for ONE view, and the frame
composites it over everything that frame drew — so it has to be rendered
from the view the frame ends up drawn with. Which view that is settles
only once the frame's script work is finished: a cutscene walking the
camera from a task loop, a camera controller, a component ordered after
this one, an editor tool — each writes the camera after `update` has
run, while the renderer reads the camera once all of them are done.
Taking the camera snapshot in the deferred phase reads what every one of
them has already written, and still queues the dispatch ahead of the
frame's render. Snapshotting in `update` instead renders the layer for
whatever the camera was midway through the frame; on a hard cut that is
a different place entirely, and the frame the cut lands on composites a
cloud layer belonging to the shot it just left.
A caller driving `volumetricSky.dispatchRaymarch` directly owes the same
discipline.
## Related
- `volumetricSky.module` — orchestration backend.
- `presets/cumulus_default.preset` — canonical full preset; copy
to author named weather (storm, overcast, dawn, …).
- `Volume.component` — per-entity flow for non-cloud volumetric content.
- `demo/sky.scene` — worked example.
·computeshader · born here
❒asset # godrays_fill
Fills a volume with shafted light. One invocation per voxel.
## Bindings
| name | kind | what it is |
|------|------|------------|
| `vol` | `storage3d` (rgba16f) | the volume written — RGB emission, A density |
| `U` | `buffer` (read, f32) | the demo's fill parameters |
·computeshader · born here
❒asset # nebula_fill
Fills a volume with a spiral nebula: RGB carries emission, A carries density.
One invocation per voxel at workgroup size 4x4x4.
## Bindings
| name | kind | what it is |
|------|------|------------|
| `vol` | `storage3d` (rgba16f) | the volume written, read-write so a pass can accumulate |
| `U` | `buffer` (read, f32) | the demo's fill parameters, in the order the shader reads them |
`U` holds: elapsed time, spiral frequency, arm thickness, core brightness, edge
brightness, dust density, noise strength.
·computeshader · born here
❒asset # sdf_solids_fill
Fills a volume with signed-distance solids. One invocation per voxel.
## Bindings
| name | kind | what it is |
|------|------|------------|
| `vol` | `storage3d` (rgba16f) | the volume written — RGB emission, A density |
| `U` | `buffer` (read, f32) | the demo's fill parameters |
·computeshader · born here
❒asset # smoke_plume_fill
Fills a volume with a rising smoke plume. One invocation per voxel.
## Bindings
| name | kind | what it is |
|------|------|------------|
| `vol` | `storage3d` (rgba16f) | the volume written — RGB emission, A density |
| `U` | `buffer` (read, f32) | the demo's fill parameters |
·computeshader · born here
❒asset # zvol_viewer_fill
Leaves the volume as it is. The ZVOL viewer uploads voxels straight into the
volume's 3D texture from the CPU, so the fill stage this shader occupies has
nothing to compute — it reads one value from each binding so both stay live.
## Bindings
| name | kind | what it is |
|------|------|------------|
| `vol` | `storage3d` (rgba16f) | the volume the viewer writes into from the CPU |
| `U` | `buffer` (read, f32) | the fill parameters the spawn API supplies |
·computeshader · born here
❒asset # emission_raymarch
Marches a camera ray through an emissive volume and writes the result to a 2D
target. Emission accumulates along the ray and density attenuates what is behind
it, so a volume reads as light rather than as a surface.
## Bindings
| name | kind | what it is |
|------|------|------------|
| `vol` | `texture3d` | the volume marched — RGB emission, A density |
| `smp` | `sampler` | how `vol` is filtered along the ray |
| `out_tex` | `storage2d` (rgba16f) | the image the march writes |
| `U` | `buffer` (read, f32) | camera, volume placement and march settings |
`U` carries the inverse view-projection, the volume's world transform, the step
count, density and emission gains, and the target's inverse size.
# VDB → ZVOL importer
Claims `.vdb` (OpenVDB) writes and produces a sibling `.zvol` — the
engine-native dense RGBA8 volumetric format consumed by the volumetrics
system.
This is a standard `importer` asset: `init.luau` returns a
`{ canImport, import }` module that the importer dispatcher
(`@builtin::assetTypes.importer`) loads lazily and runs only when a matching
file is written. It does **not** execute anything at load time.
## Pipeline
1. **Real parse** — if the `openvdb` plugin in the builtin library
(`@builtin::plugins.openvdb`) is loaded, the VDB B-tree is parsed into a
dense RGBA8 voxel buffer and wrapped in a 64-byte ZVOL header.
2. **Procedural fallback** — without the plugin, a deterministic
noise-modulated torus is synthesised (FNV-1a hash of the first 256 source
bytes seeds it) so the importer + demo wiring stays functional. The
`produced_by` field in the result distinguishes the two paths.
## ZVOL header (64 bytes)
| offset | size | field |
|--------|------|-------|
| 0 | 4 | magic `ZVOL` |
| 4 | 4 | u32 version (1) |
| 8 | 4 | u32 width |
| 12 | 4 | u32 height |
| 16 | 4 | u32 depth |
| 20 | 4 | u32 format (1 = rgba8) |
| 24 | 12 | f32[3] aabb_min |
| 36 | 12 | f32[3] aabb_max |
| 48 | 16 | reserved |
| 64 | … | voxel data, `w*h*d*4` bytes, XY-major Z-slowest |
·renderfeature · born here
❒asset # lightScattering
The render passes behind the `VolumetricLighting` component and the
`volumetricLighting` module.
Two compute passes after lighting.
**`vol_scatter`** marches each view ray at half resolution, from the camera to
the surface the ray hits — or to the medium's reach when it escapes to sky, since
an unbounded march would integrate the whole horizon into every sky pixel. At
each step it evaluates the medium's density, gathers the light arriving there,
and accumulates the fraction sent toward the camera, attenuating by everything
already crossed.
A surface-only renderer draws the pool of light a lamp throws on the floor but
not the beam that carried it there, because nothing between the lamp and the
floor is ever shaded. Marching the ray shades that space.
The lights are the scene's own. `@scene.lights` is the same buffer the lit passes
read, so a light added, moved or recoloured at runtime changes the beam it throws
with no bookkeeping here. It holds **twelve vec4s per light** — the light, then
its punctual shadow record — read as raw words so no struct declaration has to
mirror the engine's layout. A spot's cone gate is
evaluated at the sample itself rather than at a surface, which is what makes the
cone visible as a volume instead of only where it lands. Attenuation matches the
lit passes' windowed falloff, so a beam and the pool of light it lands in agree
about how far the source reaches. Area lights are skipped — their contribution to
a medium is an integral over the rect rather than a single direction.
The sun's contribution is gated by a short march back toward it, tested against
the depth buffer. That is what turns uniform haze into shafts: the medium is lit
only where the path back to the sun is clear, so an occluder carves a dark volume
out of the beam rather than only shading the surface behind it. Because the test
reads the depth buffer, an occluder outside the view frustum does not cast into
the medium.
Scattering follows Henyey-Greenstein, so the medium brightens toward a source you
are looking at and stays dim when it is behind you — the difference between haze
that reads as air and haze that reads as a grey wash.
Each ray's first sample is offset by a per-pixel fraction of a step, trading the
banding a fixed step pattern produces for noise.
**`vol_composite`** upsamples that result and composites it. The taps are weighted
by how closely their depth matches the full-resolution pixel's, which keeps a beam
from bleeding across a silhouette — the halo a plain bilinear upsample leaves
behind.
The camera comes from `@frame.camera`, the camera of the render target being
drawn, and the forward matrix is derived by inverting it in the shader rather than
passed in from script — so an offscreen capture or an RTT camera marches through
its own matrix at its own resolution.
Parameters arrive through the `vol_params` buffer, packed by the
`volumetricLighting` module.
·renderfeature · born here
❒asset # mediaShadow
The shadow a fog bank casts on what is behind it.
Two compute passes run after lighting:
| Order | Pass | Work |
|---|---|---|
| 36 | `media_tau_map` | One ray per texel of a light-space map; accumulate optical depth |
| 37 | `media_shadow_apply` | Attenuate each surface by the density in front of it |
| 38 | composite | Blit the result |
## Why not a shadow map
A depth-based shadow map answers one question per texel: is this point nearer
the light than the first thing the light hit? Density has no first thing. What
a medium does to light is continuous — a ray crossing `d` units of extinction
`k` keeps `exp(-k*d)` of its energy — so what a receiver needs is the optical
depth between it and the light, not a depth to compare against.
The first pass therefore stores optical depth rather than distance, at four
points down each ray. Two consequences fall out of that choice:
- **Overlapping media stack.** Optical depth is additive, so two volumes that
intersect contribute to the same running total with nothing to resolve.
- **Geometry inside a volume is lit correctly.** A receiver looks up its own
position along the ray and reads the depth accumulated *up to that point*, so
a post standing in the middle of a fog bank is dimmed by the half in front of
it rather than by the whole column.
## Inputs
`@scene.color`, `@scene.depth`, `@frame.camera`, and the `media_params` /
`media_volumes` buffers packed by
`@builtin::systems.volumetrics.mediaTransmittance`.
Sky pixels are passed through untouched.
# mediaTransmittance
Light attenuation through participating media, in a form both media and
surfaces can sample.
A shadow map stores one depth per texel and answers a yes/no question, so
nothing made of density can cast with it. This module owns the other
representation: a light-space map holding the **optical depth** accumulated
along each light ray, recorded at four points down its length.
Optical depth is additive. Every medium a ray crosses contributes to the same
texel, so overlapping volumes stack with nothing to resolve between them. And
because four points are stored rather than one total, a receiver standing
halfway through a fog bank is attenuated by the half in front of it, not by the
whole column.
## Direct use
```luau
local Media = require("@builtin::systems.volumetrics.mediaTransmittance")
Media.set({ sunDirection = { 0.55, 0.55, 0.55 }, steps = 32 })
Media.setMedium("bank", {
position = { 0, 8, 0 },
size = { 20, 6, 20 },
density = 0.15,
})
```
Most scenes attach `ParticipatingMedium` to an entity instead and let it drive
this module.
## API
| Call | Effect |
|---|---|
| `set(opts)` | Scene-wide settings; omitted fields keep their value |
| `setMedium(key, opts)` | Register or move a medium under a stable key |
| `removeMedium(key)` | Drop one medium |
| `get()` | A copy of the settings in force |
| `clear()` | Drop everything and release the passes |
| `active()` | Whether the passes are running |
### Settings
| Field | Meaning |
|---|---|
| `sunDirection` | Points **toward** the sun; light travels the other way |
| `strength` | How much of the computed attenuation is applied, `[0, 1]` |
| `steps` | Samples along each light ray, `[4, 128]` |
| `extent` | Half-width of the map in world units; `0` sizes it to the media |
| `centre` | Point the map is centred on; omit to centre it on the media |
### Media
| Field | Meaning |
|---|---|
| `position` | World-space centre |
| `shape` | `"box"` (default) or `"sphere"` |
| `size` | Half-extents, for a box |
| `radius` | Radius, for a sphere |
| `density` | Extinction per world unit |
Up to 32 media are packed into the records buffer.
## Cost and limits
The map is 512×512 and is rebuilt each frame; `steps` sets the march length.
With nothing registered the passes are released entirely, so a scene with no
media pays nothing.
The map is sized to the bounding sphere of everything registered, so a small
bank sharing a scene with a much larger one lands on few texels — set `extent`
and `centre` explicitly to bias the map toward the region that matters.
Attenuation applies to surfaces. The sky keeps its own colour.
# media_common
The media-shadow passes' shared ground: the volume record layout, the transmittance
accumulation along a ray, and the cap on how many volumes one frame carries
(`MAX_VOLUMES` = 32). That cap sizes a fixed array, so it is a constant here
rather than a param.
·computeshader · born here
❒asset # media_shadow_apply
Applies the media's transmittance to the lit scene.
## Bindings
| name | kind |
|------|------|
| `source_map` | `texture2d` |
| `scene_depth` | `texture_depth` |
| `tau_map` | `texture2d` |
| `frame_cam` | `buffer` |
| `media_params` | `buffer` |
| `media_out` | `storage2d (rgba16f)` |
·computeshader · born here
❒asset # media_tau_map
Builds the transmittance map through the media, so a later pass reads how much light survives to a point instead of marching for it.
## Includes
- `media_common`
## Bindings
| name | kind |
|------|------|
| `media_params` | `buffer` |
| `media_volumes` | `buffer` |
| `media_tau_out` | `storage2d (rgba16f)` |
# cumulus_default
Default weather preset for `VolumetricSky.component`. Captures the
v4-tuned cumulus look: visible discrete clouds, warm-afternoon sun,
moderate coverage.
## Use
```luau
local sky = entity.spawn("sky")
sky.component.add("VolumetricSky", {})
preset.apply("cumulus_default", { entity = sky.id, component = "VolumetricSky" })
```
The values set the component's `preset` Field.table as a unit — one
Sync replication carries every shaping/lighting parameter to peers. Tweak
by reading the values and changing them before the component is added:
```luau
local values = preset.values("cumulus_default")
values.shaderVariant = "v5" -- swap raymarcher
values.preset.coverage = 0.6 -- sparser sky
sky.component.add("VolumetricSky", values)
```
## Authoring more presets
Capture a sky you have tuned with
`preset.capture({ entity = sky.id, component = "VolumetricSky" }, "<name>")`,
or drop another `<name>.preset/` folder next to this one (or anywhere under
`/zero/source/`); the asset indexer registers it and `preset.values("<name>")`
resolves it. Common alternatives:
- `stratocumulus.preset` — broad overcast layer (`coverage ~= 0.15`,
`cloudType = 0.5`, `altitudeHigh ~= 0.65`).
- `clear.preset` — barely any cloud (`coverage ~= 0.7`,
`densityGain ~= 0.2`).
- `storm.preset` — dense black anvils (`coverage ~= 0.1`,
`densityGain ~= 0.85`, `sunColor ~= [0.6, 0.55, 0.5]`).
`preset` is one `Field.table`: applying a preset replaces it whole, so
provide every field.
# scattering_common
The light-scattering passes' shared ground: the phase function, the shadow lookup along a ray, and the froxel-to-world mapping.
Included with `#include "@builtin::systems.volumetrics.scattering_common"`.
·computeshader · born here
❒asset # cloud_noise
Procedural volumetric-cloud noise fill (Schneider/Hillaire style). A low-frequency
FBM gives the base blob silhouette, a high-frequency FBM erodes it near the surface
for the fluffy cumulus look, plus altitude shaping (round bottom, full middle, anvil
top). Writes the 3D cloud density texture.
Bindings (see bindings.yaml): `clouds` (storage3d rgba16f — the cloud volume), `U`
(read `array<f32>` uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_noise", ...)` (storage texture requires resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_noise_v3
Volumetric-cloud noise fill (Schneider/Hillaire style), v3 variant. Low-frequency
FBM base silhouette + high-frequency FBM surface erosion for the billowed cumulus
look, with altitude shaping (round bottom, full middle, anvil top). Writes the 3D
cloud density texture.
Bindings (see bindings.yaml): `clouds` (storage3d rgba16f — the cloud volume), `U`
(read `array<f32>` uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_noise_v3", ...)` (storage texture requires resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_noise_v5
Volumetric-cloud noise generator, v5 (Nubis/Schneider style). Builds two
foundational noises — Perlin-Worley FBM for the lumpy "cauliflower" base shape and
Worley FBM for high-frequency detail erosion — under weather-map control. Writes
the 3D cloud density texture.
Bindings (see bindings.yaml): `clouds` (storage3d rgba16f — the cloud volume), `U`
(read `array<f32>` uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_noise_v5", ...)` (storage texture requires resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_raymarch
Volumetric-sky cloud raymarcher with Frostbite-style lighting: Wrenninge/Hillaire
multi-scatter approximation (3 octaves), a 5-sample cone-traced soft self-shadow,
and combined Beer-Lambert + Powder so cloud edges glow toward the sun. Reads the
cloud volume + occupancy textures and writes the lit raymarch result.
Bindings (see bindings.yaml): `clouds` (texture3d), `occupancy` (texture3d),
`cloud_smp` (sampler), `out_tex` (storage2d rgba16f), `U` (read `array<f32>`
uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_raymarch", ...)` (textures/sampler/storage texture require resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_raymarch_v3
Volumetric-sky cloud raymarcher (Frostbite-style lighting), v3 variant:
Wrenninge/Hillaire multi-scatter (3 octaves), 5-sample cone-traced soft shadow, and
combined Beer-Lambert + Powder edge glow. Reads the cloud volume + occupancy
textures and writes the lit raymarch result.
Bindings (see bindings.yaml): `clouds` (texture3d), `occupancy` (texture3d),
`cloud_smp` (sampler), `out_tex` (storage2d rgba16f), `U` (read `array<f32>`
uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_raymarch_v3", ...)` (textures/sampler/storage texture require resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_raymarch_v4
Volumetric-sky cloud raymarcher, v4. Adds procedural detail noise computed
in-shader on top of the large-scale texture silhouette (removes v3's voxel-grid
blockiness) and uses multi-distance shadow sampling (4 geometrically spaced strides)
instead of a cone trace. Reads the cloud volume + occupancy textures and writes the
lit raymarch result.
Bindings (see bindings.yaml): `clouds` (texture3d), `occupancy` (texture3d),
`cloud_smp` (sampler), `out_tex` (storage2d rgba16f), `U` (read `array<f32>`
uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_raymarch_v4", ...)` (textures/sampler/storage texture require resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_raymarch_v5
Production volumetric-cloud raymarcher, v5 (Nubis/Hillaire style): Hillaire
multi-scatter octaves (fixes black cloud cores), energy-conserving Beer-Powder-MS
lit-edge glow, a 6-offset Wrenninge cone-trace shadow, and a dual-lobe
Henyey-Greenstein phase. Reads the cloud volume + occupancy textures and writes the
lit raymarch result.
Bindings (see bindings.yaml): `clouds` (texture3d), `occupancy` (texture3d),
`cloud_smp` (sampler), `out_tex` (storage2d rgba16f), `U` (read `array<f32>`
uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_raymarch_v5", ...)` (textures/sampler/storage texture require resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # cloud_taa
Cloud TAA composite — temporal anti-aliasing for the raymarch output. Reprojects
last frame's blended output through the previous view-projection, color-clamps the
history to the current sample's neighborhood to suppress ghosting, and exponentially
blends the two. Writes both the next-frame history slot and the externally-sampled
`scene_volumetric_sky` texture the composite path reads.
Reprojection runs through the world point named by uniform `[37]`, the NDC z of the
range the cloud stands at along the view ray — `volumetricSky.reprojectionDepthNdc`
computes it. The weight the history carries falls to zero across the last
`REPROJECT_BORDER_TEXELS` of the reprojected frame, so a pixel whose history lies
off the previous frame reaches the raymarch's own output down a gradient rather
than across a line.
Bindings (see bindings.yaml — slot order must match the Luau dispatch): `cur_tex`
(texture2d), `hist_prev` (texture2d), `smp` (sampler), `out_hist` (storage2d
rgba16f), `out_scene` (storage2d rgba16f), `U` (read `array<f32>` uniforms).
Usage: dispatch by identity with `compute.dispatchEx("@builtin::systems.volumetrics.shaders.cloud_taa", ...)` (textures/sampler/storage textures require resource binding, not positional buffers) —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
·computeshader · born here
❒asset # volume_pack
Fills the brick pool from the volume `volume_pages` has just paged.
One invocation per pool voxel. The pool is a grid of blocks, one block per
slot; a block holds a brick plus a one-voxel apron taken from the neighbouring
bricks, so a trilinear sample inside a brick reads what the dense volume would
have given it rather than whatever brick the pool stores next door.
## Bindings
| # | name | what |
|---|---|---|
| 0 | `source` | the dense volume being paged (sampled 3D texture) |
| 1 | `atlas` | the brick pool (`rgba16f` storage) |
| 2 | `page_slots` | four words per slot, written by `volume_pages` |
| 3 | `K` | `PackParams` — volume dimensions and brick edge |
## Why the pool is always `rgba16f`
A pool is written as a storage texture and sampled with filtering. `rgba16f` is
the format that is both: the one-channel formats cannot be written as storage
textures, and `r32f` cannot be filtered without a device feature the engine
does not require. So a scalar volume's density rides in the pool's red channel
— which is where the raymarcher reads density from in density mode — and pays
`rgba16f` per resident voxel. `Volume:sparseStats()` reports the dense and
resident sizes side by side rather than assuming paging is the smaller of the
two.
The pass writes every voxel of the pool, including blocks whose slot was never
handed out: those hold whatever brick their stale slot entry names, and the
page table never points at them.