---
title: "Rendering"
description: "Most of how a scene looks comes from its content — meshes, materials, and lights. This guide is about the scene-wide controls on top of that: lighting, post-process effects, and the renderer's global…"
section: "Topics"
slug: "topics-rendering"
canonical: "https://origozero.ai/docs/topics-rendering"
updated: "2026-09-05T16:41:46.809124085+00:00"
tags: ["documentation", "guide"]
---

# Rendering

## Lighting

The `lighting` toolbox is the scene's light environment — sun, ambient, sky, and point/spot lights, each with modify-or-spawn semantics. Reach for its tools when you are setting a scene up; a component or runtime script that adjusts lighting each frame uses the `modules.api.engine.lighting` capability directly, with the same options.

- **setSun** — aims the sun and sets its colour and intensity.
- **setAmbient** — the ambient fill.
- **setSky** — a preset word, a material, or `"none"`.
- **addLight** / **setLight** / **removeLight** — a named point or spot light, by
  position and intensity. `addLight` modifies one that already exists under that
  name rather than stacking a second.
- **setup** — sun, ambient, sky and clear colour in one call.
- **get** — the full lighting and sky state, in the shape **setup** takes back.

A component or runtime script that adjusts lighting each frame reaches the same light environment through the `modules.api.engine.lighting` capability, with no tool call. Setting and reading are separate calls there: a setter takes the toolbox's opts and answers with the entity id the light resolved to, and a reader takes no arguments and answers from the resolved lighting state. Both find the sun and the ambient through the light the renderer resolved, so a per-frame call is priced by the scene's lights rather than by everything else standing in it.

```lua
local lighting = require("@builtin::modules.api.engine.lighting")

-- writes — the same opts the tools above take:
lighting.setSun({ direction = { -0.5, -1, -0.3 }, intensity = 3, color = { 1, 0.95, 0.8 } })
lighting.setAmbient({ intensity = 0.2 })
-- sun, ambient, sky and clearColor in one call — only the sections passed change:
lighting.applySetup({ sun = { intensity = 3 }, ambient = { intensity = 0.2 }, clearColor = { 0.02, 0.02, 0.04 } })

-- reads — what the frame is shaded by:
local s = lighting.sun()        -- direction, color, intensity, castsShadows, entityId
local a = lighting.ambient()    -- color, intensity, entityId
local rows = lighting.lightRows()   -- every punctual light the renderer resolved
```

`lighting.sun()` reads and `lighting.setSun(opts)` writes, so an opts table handed to the reader is refused with the name of the call that applies it. The name-based lookup, the sky presets and the sky-material orchestration stay in the toolbox — the capability resolves each light by its component.

Lighting is real: with every source removed the scene goes dark. Lights also exist as a **component** (`entity.component.add("Light", { intensity = 5 })`) for ones that move with an entity — the lighting surface is the scene-environment one, the `Light` component is the per-entity one.

**Point and spot lights share one `intensity` scale.** They take a row of the same light buffer and are shaded by the same term — `color * intensity * falloff` — with the spot's cone as one further factor that is 1 inside its inner angle. So the same number at the same radius puts the same light on a surface either kind faces from the same place, and `addLight` hands `intensity` to both components unchanged. What the cone decides is how much of the room the light reaches: a spot lights the solid angle it opens on and leaves the rest of the room to whatever else is lit, so a frame-wide or room-wide average of a spot reads far below the same average of a point light carrying the same number. Balance a mixed rig by the brightness of the surfaces each light is aimed at, and read a spot on the surface its axis lands on. A spot's `angle` and `innerAngle` are HALF-angles from that axis, so `angle = 30` opens a 60-degree cone; widening the cone or raising `range` spreads the same light over more of the room, where raising `intensity` makes the spot's own pool brighter. The sun, a `directional` light and a `distant` light read on a scale of their own: a parallel light arrives from the same direction at every point in the world and no distance attenuates it, so the same number lands far more light on a surface than a point or spot carrying it across a room does. Balance those against the sun and against each other.

The **sun is a single field**: one direction, one colour, and the cascade shadow set. Setting it aims that light, so a second `Light { kind = "directional" }` moves the sun rather than joining it. A scene that wants more than one — a binary star, a moon opposite the sun, a cold fill from the far side of a set — adds `DirectionalLight` components, which are rows of the scene's light buffer beside its point, spot and area lights:

```lua
entity(moon).component.add("DirectionalLight", {
    direction = { 0.3, -0.5, 0.4 },   -- the direction the light TRAVELS
    color = { 0.7, 0.8, 1.0 },
    intensity = 0.6,
})
```

They shade through the same BRDF the sun does, so one at the sun's direction, colour and intensity renders the frame the sun would have. The cascade shadow set, the sky and the image-based lighting all follow the sun, so a distant light reaches every surface facing it. A GI bake gathers one the way it gathers the sun — its `mobility` field says how much of it to bake. `lighting.lightRows()` reports each of them with `kind = "distant"`, and the `lighting` toolbox's **get** tool lists them beside the scene's other lights.

The sky is an explicit component on an entity — `ProceduralSky` for the procedural day/night look, or `Skybox` for a material sky or `kind = "none"` to turn it off. A scene with no sky entity renders the engine's fallback sky and carries a missing-sky warning; the `lighting` toolbox's **setSky** tool creates (or reconfigures) the scene's sky entity and clears the warning.

## Global illumination — baked lightmaps & light probes

The lights above are direct light. **Indirect** light (bounce, color bleed, soft ambient) comes from the `@builtin::systems.globalIllumination` system, which path-traces global illumination offline so scenes get realistic multi-bounce lighting at no per-frame cost. The `Model.mobility` field (`"auto"` | `"static"` | `"movable"`) decides how each entity participates: **static** geometry receives lightmaps and occludes baked light, **movable** geometry (and every player) samples probe volumes. `"auto"` derives the answer from the entity's components (physics bodies, skinned meshes, animation, and movers all make it movable); set the field explicitly to override.

Lights carry their own `mobility` (`"auto"` | `"static"` | `"mixed"` | `"dynamic"`) answering a different question — how much of the light the bake takes. `"mixed"` is what `"auto"` picks: the bounce bakes, the direct light and shadows stay live, so a light still shines on whatever walks under it. `"static"` bakes the light whole and stops rendering it, which is the per-frame saving and the promise that nothing will move there.

Baking is a toolbox, so it answers on every tool surface. From the shell, `zero baking` lists the verbs and `zero baking <verb> --help` gives one verb's options:

  ```bash
  zero baking all        # lightmaps + auto-placed probe volumes + reflections
  zero baking lightmaps  # just the static-surface lightmaps
  zero baking probes     # just the irradiance probe volumes
  zero baking detect     # report what is baked vs stale vs missing
  zero baking clear      # remove baked lighting across a scope
  ```

The same verbs are the `baking` toolbox's tools, `all` among them, each taking an `args` table for its options (`scope`, `resolution`, `samples`, ...). Reach for them from code when what you are writing needs to bake, such as a panel offering a Bake button to a person.

Movable entities pick up the baked probes automatically: each baked volume publishes into the renderer's irradiance-volume set, and every standard-PBR fragment inside its bounds samples it — no per-entity setup. The full system (bake settings, UV requirements, mobility, staying current, the overlays) has its own guide: `guides { path = "systems/globalIllumination/documentation/baked-lighting" }`. `ReflectionProbe` (per-entity specular reflections) is a sibling component.

### Baked light in your own shader

A bake writes nothing to the material. Every receiver keeps the material it was authored with; the bake puts its texels in a region of one shared atlas and records *where* in the receiver's per-instance shader-data lanes. That means **a `surface()` shader, or any shader that `#include "@builtin::shaderModules.pbr_shading"`, already supports baked lightmaps — you write nothing.** The engine resolves the lanes and folds the irradiance into indirect light for you, and two instances of the SAME material resolve independently, so baked and unbaked geometry can share a material.

You only do this by hand in a **self-shading `fragment()` shader that computes its own lighting** (no PBR module). Read the two lanes and sample the atlas:

```wgsl
// lane 0 = (scale.u, scale.v, offset.u, offset.v) — this instance's rect in the page
// lane 1 = (layer, enabled, -, -)
let params = input.shader_data[1];
var baked = vec3<f32>(0.0);
if params.y > 0.5 {
    let rect = input.shader_data[0];
    let uv = input.lightmap_uv * rect.xy + rect.zw;
    baked = zero_feature_texture(uv, u32(params.x)).rgb;
}
```

`input.lightmap_uv` is the mesh's unique, atlas-packed uv1 — the bake's own UV set, not the texturing `uv`. The sample is **irradiance** (incoming diffuse light), so a surface turns it into outgoing light by multiplying by its own diffuse albedo: `baked * albedo`. Add that where your shader would otherwise apply ambient — a baked value REPLACES flat ambient and probe-volume irradiance rather than adding to them, since all three approximate the same quantity and summing double-counts the bake.

Gate on `params.y` and never on "is the texture bound": the atlas is always bound and always valid (black until something bakes), and the enable flag is what varies per instance.

## Post-process

Full-screen effects run after the scene renders — bloom, tonemap, colour grading, your own. Each is a `post-process` `.shader` asset (the shaders guide) that runs once registered as a named pass:

```lua
postprocess.add("bloom", asset.resolve("@builtin::shaders.post.bloom", "shader"), opts)
postprocess.setEnabled("bloom", true)
postprocess.setProperty("bloom", "intensity", 0.6)
postprocess.list()            -- also setTexture / setSampler / remove
```

`add` is what puts the pass into the frame; authoring the shader asset defines the effect without rendering it. Editing a registered shader's body recompiles its pass in place, keeping the pass's enabled state, priority, layer and tuned properties.

`opts.layer` picks which composited image the pass grades. `"scene"` runs it before the UI is drawn, so it grades the rendered picture and leaves every widget on screen as authored; `"all"` — the default — runs it after the UI has landed, so the interface is graded along with the picture. A grade that belongs to the world's look wants `"scene"`, since on a world several authors share, an `"all"` pass grades everyone else's screens too. `tools.use("pp", "add", ...)` takes the same key and forwards it.

`postprocess.status()` is the chain as the renderer holds it — per effect: `enabled`, `priority`, `layer`, the shader's compile `error`, the texture each declared slot is bound to (`textures`), and the bindings whose texture has not reached the GPU yet (`pendingTextures`). A texture created in the same step as the `setTexture` that names it lands there first and moves into `textures` on the frame the upload completes.

`postprocess.describe(name)` is one effect of that chain read in full: the same record, plus `properties` — the schema the effect declared, entry by entry — and `values`, what each of those properties holds right now (the value the last `setProperty` wrote, or the schema default where nothing has written one). `setProperty` answers whether the uniform took the value; `describe` answers what the effect holds, which is the read-back a pass driving its properties every frame needs and the one that tells a mistyped property name from a pass that is not grading. Before an effect is registered, the same schema is on the `.shader` asset it will render: `asset.resolve("@builtin::shaders.post.bloom", "shader"):getProperties()`.

### The chain reaches an offscreen capture, at that camera's own matrices

A registered effect runs over the live viewport's frame and over every offscreen
one alike — `capture(source = "position")`, `capture(source = "entity")`, a
capture of a named camera, a render-to-texture camera. In each of those the
effect's `engine.view_proj` / `engine.prev_view_proj` / `engine.inv_view_proj`
are the camera THAT render was drawn from and `engine.resolution` is that
target's own size, so a pass reconstructing world space from
`zero_scene_depth(uv)` reconstructs against the station and lens the capture
asked for. An offscreen capture is therefore an oracle for an authored grade:
the frame it returns is the look as it stands at a chosen station, and driving
the on-screen camera somewhere else does not move it.

An offscreen render keeps no view history of its own, so `engine.prev_view_proj`
there holds that same matrix rather than the frame before it, and a pass taking
camera motion from the two reads none. On the viewport the pair is a frame
apart, so camera motion is the one reading a capture answers differently from
the screen.

`postProcessing = false` is the one control that takes the chain off a capture,
and it also takes off the film a render feature draws — the flare and grain, a
short focus's bokeh, the shutter's smear — so a graded frame and an ungraded one
are different pictures. A render feature's passes otherwise reach an offscreen
render the same way the chain does, against the same camera; the `renderFeature`
assetType README carries that half.

The `pp` toolbox's **add** tool is the shorter route to the shipped ones — `bloom`, `color_correction`, `colorGrade`, `fog`, `grayscale`, `invert`, `lut`, `sepia`, `tonemap`, `vignette`; the toolbox's **presets** tool lists them.

### Colour grading with a lookup table

A look is normally delivered as a **table** rather than as a chain of operations: for an N-entry colour cube, the answer to "what does this colour become" is stored for N³ inputs and everything between them is read by trilinear interpolation. A grade of any complexity then costs the same eight texel reads, and it is what a colourist's `.cube` export contains.

```lua
local lut = require("@builtin::modules.lut")

local ref = lut.install("warm_evening", lut.fromGrade({
    exposure = 0.3, temperature = 0.25, saturation = 1.1,
}))
-- the `pp` toolbox's `add` tool, with the preset name and its options:
--   preset "lut", { texture = ref.guid, amount = 1.0 }
```

`lut.fromCube(text)` imports what a grading tool exported, `lut.fromImage(bytes)` a strip PNG, `lut.build(size, fn)` bakes an arbitrary transform, and `lut.sample(table, r, g, b)` reads a table back the way the shader does, so a look can be checked without rendering a frame. `amount` cross-fades between the ungraded frame and the table's answer.

The cube is stored as a **strip**: N square tiles in one row, `N*N` wide by `N` tall, tile `b` holding the plane of constant blue. The shader reads N from the image's own height, so one effect serves a 16-, 32- or 64-entry cube; a texture that is not shaped like a strip leaves the frame untouched, which is what an unconfigured `lut` effect does. Where the effect sits in the chain decides which colours the table is indexed by — after the tonemap for a display-referred look, before it for scene-referred colour.

## Motion blur — the camera's shutter

A frame is not an instant. `motionBlur` holds the shutter open for part of the frame and reconstructs the path everything took in that time out of the velocity buffer the renderer already writes:

```lua
local motionBlur = require("@builtin::systems.motionBlur.motionBlur")
motionBlur.set({ shutterAngle = 180, samples = 16 })   -- 180 degrees is the film convention
motionBlur.get()                                       -- also active() / clear()
```

The same settings are authorable on an entity as the `MotionBlur` component. `shutterAngle` is the physical control — motion vectors span one frame, so a shutter open for half of it smears half the per-frame travel, and doubling the frame rate halves the blur on its own. `maxRadius` is the longest smear the filter will carry, in pixels, whatever the shutter asks for; it defaults to 32 and cannot be set past it, because 32 px is the tile size the neighbourhood search covers. A surface the velocity buffer leaves still receives the smear of whatever moved beside it: the filter reduces velocity to tiles and then to each tile's 3x3 neighbourhood, which is what carries a direction to pixels that have none of their own and lets a moving object blur past its own silhouette.

## The velocity buffer, and how far a moving surface reaches in it

Every temporal technique reads one buffer: the velocity buffer, which carries each pixel's screen-space travel between the previous frame and this one. Motion blur, temporal antialiasing, temporal upsampling and a denoiser's history term all sample it one texel per pixel, and `capture { pass = "motion_vectors" }` draws it.

A texel of it comes from whichever pass drew that pixel — a material's own fragment, the splat pass, or a render feature that names `@scene.motion` in its `outputs` — and the capture is taken at the end of the scene, once the scene's passes and its `afterTransparent` / `afterLighting` feature phases have written it. So it shows the field those readers will see rather than one contributor to it; a feature that writes `@scene.motion` at one of the post phases (`afterScenePost`, `afterPost`, `afterUI`) writes it after the picture was taken.

A camera holding the channel on a render target of its own draws the buffer over that target every frame its scene renders. A frame where the scene has nothing to draw is skipped whole, so the target keeps the last field drawn into it until the scene draws again.

A texel of it describes the surface drawn on it, and nothing else. At a moving object's edge that is a frame behind the truth: the background pixels the object is about to cover still report the background's velocity, so a reprojection reads history from where nothing moved and a blur stops dead at the silhouette.

`velocityDilation` changes what a texel answers. Above a radius of 0 each texel takes the velocity of the **closest** surface within that many texels, so a nearer moving object wins over the background it is passing in front of and its velocity reaches that far out past its own edge:

```lua
local velocityDilation = require("@builtin::systems.velocityDilation.velocityDilation")
velocityDilation.set({ radius = 2 })   -- velocity reaches 2 px past a silhouette
velocityDilation.get()                 -- also active() / clear() / maxRadius()
```

The same setting is authorable on an entity as the `VelocityDilation` component. It is off until something sets a radius, and every reader picks the wider field up with no change at its own call site. The rewrite happens after the last pass that writes velocity, so it costs two passes and one screen-sized target while armed, and `(2r+1)²` texture loads per pixel — which is what bounds the radius. Dilation is the per-pixel half of the same idea `motionBlur` carries at 32-pixel tile granularity; the two compose, and dilation is what a reader with no neighbourhood search of its own gets.

## Morph targets — a mesh blending toward other shapes

A mesh can carry morph targets: per-vertex position and normal deltas that
describe a shape it can blend toward. An entity carries how strongly each is
blended, as `ecs.MorphWeights`, and the vertex stage draws the base shape plus
the weighted sum of the targets. Weight `i` drives target `i`, in the order the
mesh declares them.

```lua
local mesh = renderer.mesh.create({
    positions = P, indices = I, normals = N,
    morphTargets = {
        { name = "smile", positions = smile },
        { name = "frown", positions = frown },
    },
})
ecs.insertSync(face, ecs.MorphWeights { weights = { 0.8, 0.0 } })
```

A target carries the name it was authored under, and an imported model keeps the
one its source file gave — an FBX blend-shape channel, a glTF target name. That
is what makes a shape addressable as itself: an ordinal is a property of import
order, so it moves when the model is re-exported with a shape inserted, while
`Eyes_Blink` stays `Eyes_Blink`. `renderer.mesh.morphTargets` reads the names in
target order, and `renderer.mesh.morphWeights` turns weights named by shape into
the ordered array the component takes:

```lua
for i, name in renderer.mesh.morphTargets(meshRef) do print(i, name) end

ecs.set(face, ecs.MorphWeights {
    weights = renderer.mesh.morphWeights(meshRef, { Eyes_Blink = 1, Mouth_Smile = 0.4 }),
})
```

Every target the mesh carries gets a slot; the shapes left out sit at 0. A name
the mesh does not carry errors listing the ones it does, so a mistyped viseme
says so instead of quietly moving nothing.

A blend costs one entry per target its weights move — the mesh's own target
count is what it may address, and how many of those move at once is what the
frame pays for. So a facial rig carrying dozens of shapes costs the few it is
holding, and a mesh may declare as many targets as its delta block fits in
memory.

```lua
local m = renderer.morphStats()
print(("%d slots carrying %d blends over %d meshes (%d targets, %.1f KiB)")
    :format(m.instances, m.blends, m.meshes, m.targets, m.deltaBytes / 1024))
```

`instances` is how many render slots the stage deformed and `blends` how many
single-target blends they carried between them — a mesh with 40 targets whose
weights move 3 of them contributes 3. `meshes` / `targets` / `deltaBytes` are
the residency of the delta blocks themselves, which a mesh pays for once
however many entities blend it.

The deformation reaches the colour passes, the opaque depth pre-pass, every
shadow view and the velocity pass, so a morphed surface lays the depth of its
morphed shape, casts the shadow of it, and reports the motion its own change of
shape made. The box the instance is tested against carries the blend as well:
the mesh's stored bounds grown, per target, by the extreme its weighted delta
reaches, so a blend that carries a surface into the frustum keeps it in the
frame and the same instance at weight zero is rejected against the stored shape.

## Render features — custom GPU passes (outlines, x-ray, wireframe, decals)

A **render feature** inserts your own GPU passes into the frame, every frame — the
generic capability behind mesh **outlines**, x-ray, custom wireframe, decals, and
any effect the built-ins don't cover. A feature is a `.renderFeature` asset whose
`render(ctx)` hook calls `ctx.enqueue{...}` to queue passes; the engine runs them
at a named pipeline phase. Three pass kinds: `"fragment"` (full-screen shader),
`"compute"` (dispatch), and `"geometry"` (re-draw scene meshes with your pipeline).

### What a pass costs

A `render(ctx)` hook runs every frame, and a feature usually cannot tell how much
work it has: it runs in its own Luau VM, so module state and entity attributes
written on the other side of that boundary do not read back here. The normal
shape is therefore to enqueue the pass unconditionally and let the shader decide
per frame whether it draws anything — which is fine, on two conditions.

A `"fragment"` pass runs its entry once per pixel it covers, so a full-screen
pass with nothing to draw still pays for whatever every invocation sets up:

- **Return on a uniform first.** A value from the `material` uniform is the same
  for every pixel, so a test against it costs one branch for the whole pass and
  an idle pass costs nothing. Test it before sampling anything, and before any
  `dpdx`/`dpdy` — a derivative has to be reached by every pixel in a quad, so a
  per-pixel early-out placed above one leaves its neighbours reading differences
  nobody wrote.
- **Hold no array in a function-local `var`.** Scratch a function declares is
  reserved on every invocation, sized for the whole array, whether or not the
  pixel reaches the code that reads it — so an early-out does not save a pass
  that declared one, and the pass costs the same idle as it does full. Read each
  element straight from its uniform, or pass them to a function as arguments, so
  the values stay in registers. The engine warns at compile time when a
  post-process shader declares one; a batched effect holding eight slots that way
  cost 4.4ms a pass with nothing on screen.

The `profiler` toolbox's **gpu** tool gives the per-pass medians to check this against —
an idle full-screen pass should read as tens of microseconds, not milliseconds.

An early-out still costs the invocation that takes it, so a full-screen pass over
an effect that only touches part of the world pays for the screen. When the
feature knows the world-space box its effect stays inside — a decal projector, a
light volume, a filter over one room — it says so, and the pass shades only the
part of the screen that box lands on:

```lua
ctx.enqueue {
    kind = "fragment", shader = myEffect, phase = "afterOpaque",
    outputs = { { target = "@scene.albedo", blend = "alpha" } },
    bounds  = { min = { -4, 0, -4 }, max = { 4, 3, 4 } },  -- world space
}
```

The rectangle is worked out per camera, from the projection that camera drew
with, and it is conservative — it holds every pixel whose view ray meets the box,
so the image is the one the unconfined pass produced. A camera whose frustum the
box falls outside skips the pass entirely, so marks behind the camera cost
nothing. It applies to a pass whose `outputs` all name `@scene.*` channels and
all **blend**: a pixel the effect reaches nothing at composites as the target
already stood, which is why leaving it unshaded is the same picture. A pass that
**replaces** its target states every pixel outright and is left covering it.

`renderer.feature.shaded()` reads how many pixels each pass shaded on the last
drawn frame, which is what says whether a pass costs its effect or costs the
screen.

A **geometry pass** is the one for mesh effects — it redraws scene render-objects
with a material you construct, and can target **specific entities**:

```lua
ctx.enqueue {
    kind     = "geometry",
    material = myMatKey,          -- a material from renderer.material.create
    geometry = "@geometry.opaque", -- "@geometry.opaque" | "@geometry.transparent" | "@geometry.all"
    select   = { entityA, "ent_…" }, -- OPTIONAL: only these entities (refs or ids); omit = all
    phase    = "afterLighting",   -- lit scene + valid depth
}
```

`select` restricts the draw to the given entities — what turns a whole-scene pass
into a chosen-meshes effect. Each entry is an entity ref **or the id string
itself** — the pass matches `select` against each render-object's owner id, so
an id you already hold goes in as it stands and the selection follows the
renderer's own view of the scene. The **material** decides how those meshes are
drawn, and you control three things:

- **Render-state.** `renderer.material.create` takes a `render` block — `cull`
  (`back`/`front`/`none`), `depthWrite`, `blend` — so the pass rasterizes
  differently from the surface pass. `properties` seeds the shader's declared
  uniforms (a flat `{ name = value }` map, matching the names in
  `properties.yaml`; a colour is a `{r,g,b,a}` array). `textures` binds its
  declared texture slots. There is no `colors`/`floats` split — that is the
  mat.yaml authoring shape, not this runtime call:
  ```lua
  renderer.material.create({
      shader     = myShaderKey,               -- a .shader you authored (identity, not guid)
      render     = { cull = "none", depthWrite = false },
      properties = { my_color = { 1, 0.45, 0, 1 } }, -- seeds material.my_color
  }, "my_pass_mat")
  ```
- **The shader.** A geometry `.shader` is a surface shader: `fn vertex(v:
  VertexData) -> VertexData` moves vertices (`v.position`/`v.normal`, object
  space); `fn fragment(...) -> vec4<f32>` is the colour. A vertex hook is
  evaluated twice per vertex — once for this frame and once for the vertex as it
  stood one frame back, which is what the renderer's motion vectors are taken
  between. Read the frame off the argument (`v.time`, and `v.view_proj` in
  `vertex_clip()`) so the second evaluation lands where the surface actually was;
  the shader assetType README has the full contract.
- **Its properties.** A shader reads ONLY the uniforms it declares in
  `properties.yaml`, via `material.<yourProp>` — and **both `vertex()` and
  `fragment()` can read them** (the generated `material` uniform is bound for both
  stages). Referencing one it didn't declare (e.g. `material.base_color`) fails to
  compile and renders **magenta**; the compile error in the engine log names the
  field and the fix (declare it, or use a constant). (See the shaders guide.)

What effect that produces — outline, x-ray, wireframe, highlight — is yours to
design from those pieces; the engine names none of them.

> The legacy `visuals.setOutline` / `Model:setOutline` / `sc.outline` (and the
> `EntityVisuals.outline` field) are **deprecated** — they predate render features
> and draw nothing. A mesh outline is a render-feature geometry pass.

The full feature contract (`setup` / `render` / `teardown`, `ctx`, every phase,
the `@scene.*` / `@frame.camera` / `@geometry.*` names) is in the **`renderFeature`
assetType README**. Reserved inputs resolve per render target, so a screen-space
feature that reconstructs world space from depth via `@frame.camera` is correct in
the live viewport AND in offscreen captures (`capture(source = "position"`/`"entity")`)
from a different camera.

## Material graphs — a material as nodes

A surface shader is WGSL. `@builtin::systems.materialGraph.materialGraph` lets a
material be written as nodes and the links between them instead, and compiles it
to the same two files a hand-written `.shader` asset holds — the material a graph
produces is an ordinary material, drawn by an ordinary program.

```lua
local materialGraph = require("@builtin::systems.materialGraph.materialGraph")

local material = materialGraph.material({
    name = "rusty_metal",
    properties = {
        { name = "tint",       type = "color",   default = { 0.8, 0.4, 0.2, 1 } },
        { name = "wear",       type = "range",   default = 0.5, min = 0, max = 1 },
        { name = "albedo_map", type = "texture", default = "white" },
    },
    nodes = {
        base  = { op = "sampleTexture", texture = "albedo_map" },
        tint  = { op = "property", property = "tint" },
        mixed = { op = "multiply", a = { node = "base", swizzle = "rgb" }, b = { node = "tint", swizzle = "rgb" } },
        wear  = { op = "property", property = "wear" },
    },
    surface = { albedo = "mixed", roughness = "wear", metallic = 1 },
}, { name = "rusty" })

entity.spawn("pillar").component.add("Model", { model = "cube", material = material })
```

A port takes a number, an array of 2-4 numbers, the name of another node,
`{ node = "<name>", swizzle = "rgb" }`, or a node written inline where it is
used. A node's type — `float`, `vec2`, `vec3`, `vec4` — comes from its inputs: in
an arithmetic node the widest input decides the result and a float is broadened
to it, while two different vector widths are refused, naming the node.

`compile(graph)` is the same work with nothing installed — it returns the
`shader.wgsl` and `properties.yaml` text, the nodes it emitted, and the nodes the
terminal could not reach. `check(graph)` hands an error back instead of raising.
`nodeTypes()` lists every operation with the inputs a node of it is written with
and which of those it cannot be written without; `surfaceChannels()` lists what
the `surface` terminal accepts. The package's own readme has the full reference.

Calling `material` again with the same material name is how a graph is iterated
on: the shader is rewritten from the graph, the material is brought onto that
shader's properties, and `opts.values` is applied over it. The shader itself
compiles at the next frame boundary, the way any shader edit does —
`asset.ref(name, "shader"):compileStatus()` reports that outcome.

## Debug camera views: `capture pass=<name>`

A debug camera view is a diagnostic render that content publishes and a capture selects by name. `capture(pass = "lightmap")` draws the scene the way that view defines instead of the final lit image, so you read a value straight off each surface. The built-in debug passes (`normal`, `depth`, `albedo`, ...) are engine-owned; a content view is one you register yourself, and it draws through the same render-feature machinery above.

A view is a render feature gated to a debug channel. A live viewport camera carries channel 0, so a view's pass is invisible on screen and runs only when a capture selects it. An always-registered view therefore costs nothing: its pass is skipped every frame until someone captures it.

Register the name, and give it a feature whose pass carries the view's channel:

```lua
local VIEW = "lightmap"

-- Register the name so `capture pass=lightmap` resolves. `ensure` runs when the
-- view is first selected, so the drawing feature is created on demand.
renderer.captureView.register(VIEW, {
    description = "Each surface's sampled baked irradiance.",
    ensure = ensureFeature,
})

-- The feature draws the opaque scene with a material that emits the value you
-- want to read, gated to this view's channel so it runs only for a capture that
-- selected the view.
return {
    render = function(ctx)
        ctx.enqueue {
            kind = "geometry",
            material = viewMaterial,          -- a surface material emitting the value
            geometry = "@geometry.opaque",
            phase = "afterLighting",
            debugChannel = renderer.captureView.channelId(VIEW),
        }
    end,
}
```

The material is an ordinary surface material (the shaders guide): its `fragment()` returns the number you want to see rather than a lit colour. Because the pass is gated to a content-view channel, the engine passes that fragment output straight through instead of substituting a built-in debug colour.

Several views ship this way, and `renderer.captureView.list()` enumerates whatever is registered — each with a description of what it draws.

`capture(pass = "lightmap")` shows each surface's sampled baked irradiance, so "did the bake reach this surface" becomes a question you answer by looking instead of by reasoning.

### A pass that is part of the film

A Camera's `postProcessing` says whether that camera runs the post-process
chain. Some render features draw what the chain draws — the camera's
photographic finish rather than the scene: the lens flare and film grain
`lensFx` composites, the bokeh `depthOfField` gathers, the shutter smear
`motionBlur` sweeps, the exposure a meter arrives at. A pass declares that, and
the camera property answers for it too:

```lua
ctx.enqueue {
    kind = "fragment", inputs = { src = outRt.guid },
    phase = "afterLighting", order = 406,
    postProcessing = true,   -- part of the camera's photographic finish
}
```

A camera whose `postProcessing` is off runs neither the chain nor that pass, so
`capture { postProcessing = false }` hands back the scene with the whole finish
off it — which is what makes an ungraded frame comparable against a graded one,
and what keeps one session's half-tuned lens out of another session's evidence.
A camera keeping its chain draws the pass, and every viewport keeps its chain by
default.

Declare it for what the CAMERA does to the picture. A feature drawing the scene
itself — ambient occlusion, reflections, global illumination, the atmosphere
between the camera and a surface — leaves it out, so an ungraded frame is still
the scene as it is lit. (`clearAir` is the option that reaches the air; the
render-textures guide covers it.)


`capture(pass = "overdraw")` shows how many fragments each pixel cost, counting every fragment the rasterizer produced including the ones the depth test later discards. Flat bands, so the count is read by naming a colour: black none, blue 1, cyan 2, green 3, yellow 4, orange 5-6, red 7-9, white 10+.

`capture(pass = "zfighting")` shows where two opaque surfaces own the same depth — dark grey for one surface, red for two, white for three or more. It is the view for a defect that has no still to look at: the depth buffer cannot separate coplanar surfaces, so which one wins flips per frame, and a capture shows whichever won, correctly shaded. The count comes from re-running the depth test the frame already resolved (`lessEqual`, writing no depth), so a pixel more than one surface reaches is a pixel that will shimmer in motion. Take it over a scene before calling the scene done.

`capture(pass = "physics")` shows the physics world as solid shaded colliders on black, coloured by what each one takes part in: blue static, orange dynamic, green kinematic, magenta and translucent for a sensor, with lightness separating the individual objects inside a family. The simulation collides with colliders rather than with what is drawn, and a collider that disagrees with its mesh is invisible in every other pass — so this is the view for something that behaves wrongly rather than looks wrongly. `capture(pass = "physics_context")` draws the same colliders over the scene's meshes in dim grey, which answers whether a collider sits where its model does.

Both are built on `@builtin::systems.renderDiagnostics.countingView`, which is the generic shape: count the fragments satisfying a depth predicate at each pixel, then colour the count. A diagnostic of your own is that call with a different predicate and a different `zero_count_color`.

A counting view fills, counts and reads back in three passes at `afterLighting`, and their order matters in a way worth knowing for any feature: **`afterLighting` runs every geometry pass before any of its fragment passes**, ordered by `order` within each kind. That is what lets a fragment pass sample what a geometry pass drew the same frame — and why a full-screen fill meant to go *under* a geometry pass has to be a geometry pass too. It is also the phase that draws geometry, draw and splat passes at all: one of those enqueued at another phase never runs.

### What a pass reads, and when it exists

A pass declares what it reads (`inputs`) and what it writes (`output` /
`outputs` / `storage`), and the frame runs the declarations in that order. So a
pass whose input is a render target the same frame produces **later** samples the
target as the frame found it — the previous frame's contents for a target that
persists, an empty target for one just created — and renders anyway, at full
cost, over data nobody put there this frame.

`renderer.passSchedule()` reads the frame's declarations back and names those
reads:

```lua
for _, v in renderer.passSchedule().violations do
    print(v.message)
end
-- pass 'blur_h' at afterLighting/order 40 reads render target
-- '4f2c…' on slot 'src', which is written by pass 'bright_pass' at
-- afterLighting/order 90 — later in the same frame …
```

Each finding also goes to the engine log the first time it appears, so a
mis-ordered feature says so without anyone asking.

The frame's own buffers are read on the same terms. Bind `@scene.motion`,
`@scene.color` or any of their siblings on a pass that runs ahead of the one
writing that buffer and the read is reported the same way, naming the buffer —
what it samples there is the scene draw's own output rather than the previous
frame's, and the pass that was going to contribute to it has not run yet.

A history buffer, a temporal accumulation and the far half of a ping-pong all
read a target one frame late on purpose. Declare the slot and the check leaves
it alone:

```lua
ctx.enqueue {
    kind = "compute", program = accumulate,
    inputs = { history = historyRt.guid },
    storage = { out = historyRt.guid },
    readsPrevious = { "history" },
    phase = "afterLighting", order = 200,
}
```

The declaration is per slot, so the pass's other inputs stay checked; a slot
named there that the pass binds no such resource to is reported in
`unboundPrevious`, since a renamed slot would otherwise quietly widen what the
declaration excuses. A resource no queued pass writes is never reported — a
camera rendering to texture and `compute.dispatch` both fill targets outside the
pass queue, and the scene draw fills the `@scene.*` buffers every frame.

A read the frame has only one order for is not reported either. Where the pass
that writes the resource consumes something the reading pass produces — a
compute pass reading `@scene.motion` into a target of its own, and a copy
reading that target back over `@scene.motion` — the reader runs first or the
writer has nothing to write. No reordering hands that reader the writer's
output, so the chain reads clean without a declaration.

The third list, `unreachable`, holds passes at a phase that does not run their
kind. Every phase drains its fragment and compute passes, while the geometry,
draw and splat drains are `afterLighting`'s alone — so a mask enqueued as a
geometry pass at `afterOpaque` sits in the queue and is never drawn, and
everything sampling it reads an empty target on every frame.

## Asking where the drawn world is along a ray

`renderer.raycast(origin, direction, maxDistance?, exclude?)` reports the
nearest surface the renderer DRAWS along a ray, and `renderer.raycastAll` every
surface along it, nearest first. Both answer for a mesh whether or not anything
gave it a rigid body, so a terrain, a procedurally generated mesh, or any plain
`Model` reports the surface under a point.

The answer is the nearest triangle of the mesh, so a sloped or terraced surface
reports its height where it was asked rather than the extent of its bounding
box:

```lua
local hit = renderer.raycast({ x, 100, z }, { 0, -1, 0 }, 200)
if hit then
    print(hit.entityId, hit.point.y, hit.normal.y, hit.distance)
end
```

A hit carries `entityId`, the world-space `point`, a unit `normal` turned to
face back along the ray, the `distance` from the origin, the `meshGuid`, and
`exact` — true where the answer came from a triangle, false where a mesh's
vertices live on the GPU alone and the object's bounds are the whole answer.
`distance` is measured in world units — the direction is normalised, at any
length you hand it in — so it is directly comparable to a `physics.raycast`
distance along the same ray. The triangles walked are the mesh's own, placed by
the entity's transform and by the mesh's bind pose, so a surface a skinning or
morph pass deforms on the GPU answers as the geometry the mesh holds.

Everything drawn is in scope — the ground you meant, and equally a character
standing on it, a prop, or a placeholder floor left in the scene. Read
`entityId` before trusting a height, step over what you did not mean with
`exclude`, or take the whole column with `raycastAll` and pick the surface
yourself:

```lua
-- the ground under a camera station, ignoring whoever is standing there
local hit = renderer.raycast({ x, 100, z }, { 0, -1, 0 }, 200, { playerId })

-- or: every surface in the column, choose the one you are after
for _, h in renderer.raycastAll({ x, 100, z }, { 0, -1, 0 }, 200) do
    if h.entityId == groundId then print(h.point.y) end
end
```

`physics.raycast` answers from colliders instead, which is the query for what a
body would collide with; this one is the query for what a camera would see.

## Scene-wide renderer settings

The global pipeline switches — shadows, deferred shading, order-independent transparency, culling — live in the world's **`.world_settings`** file under `[render]`, not in a runtime API. The renderer reads the file once a frame, so an edit reaches the next frame. Edit them there (the engine/development guides cover world settings):

```toml
[render]
shadows = true
deferred = true
oit = true
culling_mode = "gpu"
anisotropy = 16        # texture filtering quality: 1, 2, 4, 8 or 16

point_shadow_budget_mb = 48   # VRAM the point-light cube-shadow atlas may hold
point_shadow_resolution = 512 # texels per cube face

spot_shadow_budget_mb = 32    # VRAM the spot/area shadow atlas may hold
spot_shadow_resolution = 1024 # texels per atlas layer — the sharpest one light gets

shadow_caster_min_radius_px = 0  # smallest on-screen caster that still casts
shadow_caster_max_distance = 0   # how far from the camera casters keep casting

max_frames_in_flight = 2  # frames of GPU work that may be outstanding
present_mode = "mailbox"  # how a presented frame reaches the display
```

**Shadowed point lights are bought by the megabyte.** A point light with
`castsShadows` renders an omnidirectional cube map — six faces of depth — and
the atlas holding them is sized from `point_shadow_budget_mb`, not from a fixed
slot count. 48 MiB at 512² per face is eight simultaneously shadowed point
lights; a ninth caster is still lit but throws no shadow, and the engine log
says how many were turned away. The same budget is a runtime knob:

```lua
renderer.pointShadowBudget()                        --> { megabytes = 48, resolution = 512, slots = 8, bytes = ..., maxSlots = 16 }
renderer.setPointShadowBudget({ megabytes = 96 })   --> 16   what this budget buys
renderer.setPointShadowBudget({ resolution = 1024 })--> 4    sharper faces, fewer of them
```

The atlas is reallocated on the next frame, so `slots` reports the new pool one
frame after the call while the return value is immediate. Face resolution and
slot count trade against each other quadratically: doubling `resolution`
quarters how many lights the same budget shadows. Spot lights draw from their
own separate atlas, so a shadowed spot never costs a point light its cube.

**A spot's shadow is sized for the screen it covers.** The spot/area atlas is
`spot_shadow_budget_mb` of VRAM cut into square layers of
`spot_shadow_resolution` texels, and each frame every shadow-casting spot is
given the smallest *tile* of a layer that still resolves finer than the pixels
its light covers. A light filling the view takes a whole layer; one far enough
away to cover a few dozen pixels takes a sixteenth of one, and the layer it
leaves free goes to a light that can show it. Nothing is authored per light —
move the camera and the maps resize:

```lua
renderer.spotShadowBudget()                        --> { megabytes = 32, resolution = 1024, layers = 8, tiles = 128, minResolution = 256, slots = 64, bytes = ..., maxLayers = 32 }
renderer.setSpotShadowBudget({ resolution = 2048 })--> 2    sharper hero shadows, fewer layers
renderer.setSpotShadowBudget({ megabytes = 64 })   --> 16   more lights holding a large tile at once
```

`slots` is how many spot/area lights can cast at all — the atlas's own tiles,
or the 64 light rows a scene can hold, whichever runs out first — and `tiles` is
how many maps the atlas could hold if every one of them were small on screen.
The budget is what moves `slots`: at the shipped 32 MiB the atlas already holds
a tile for every light a scene can have, so the 64 light rows are what `slots`
reports there, and a smaller budget lowers it below that. Raising
`resolution` is what makes a close shadow sharper — it is the size of the map a
light gets when it takes a whole layer — while raising `megabytes` is what lets
several lights hold one at the same time. When more is asked for than the atlas
holds, every caster steps down together — one shared tile size for all of them,
and only what is left over is spent back on the largest askers — so a street of
equally demanding lights never splits into a sharp few and a coarse many. A
light that finds no tile at all is lit but throws no shadow, and the engine log
says how many were turned away and how many tiles the budget bought.

**One cube slot is always granted.** A budget that does not cover a single cube
still shadows one light, and then the atlas costs what that slot costs — not
what the budget said. `{ megabytes = 1, resolution = 4096 }` allocates 384 MiB
for its one slot. Read `pointShadowBudget().bytes` (the same figure
`shadowMemory().point` reports) back after setting a budget on a
memory-constrained device rather than assuming `megabytes` was a ceiling.

**A caster can stop casting before it stops drawing.** Every shadowed light
rasterizes a caster's whole triangle count into its own view, however little
shadow that ends up covering — six times over for a shadowed point light. The
caster cutoff is the two thresholds that say when it is not worth it, and both
are measured against the camera the frame draws from rather than against each
light, so one setting covers every cascade, spot and cube face:

```lua
renderer.shadowCasterCutoff()                              --> { minRadiusPx = 0, maxDistance = 0 }
renderer.setShadowCasterCutoff({ minRadiusPx = 2 })        -- drop casters under 2 px on screen
renderer.setShadowCasterCutoff({ maxDistance = 120 })      -- and casters past 120 units
```

`minRadiusPx` is the on-screen radius a caster must reach: an object the viewer
resolves a couple of pixels of casts a shadow of about that size, so dropping it
removes a depth pass for something nobody was going to read as a shadow.
`maxDistance` is measured to the near side of the caster's bounding sphere, so a
building keeps casting while any part of it is inside the range while the props
around it stop. Both are 0 — released — by default, which draws the casters the
frame drew before either was set, and setting one leaves the other alone.

The saving reads in `renderer.drawStats().draws`, which counts each shadow
view's draws alongside the camera's. Casters that stop casting keep drawing in
the camera's own passes, so the scene looks the same everywhere except in the
shadows the cutoff removed — walk the thresholds up until one of those shows.

**A caster can rasterize cheaper geometry than it draws.** A shadow is a
silhouette laid on other surfaces at the resolution of a shadow map, so the
triangles carrying a mesh's close-up detail write depth no reader can resolve. A
shadow proxy is the cheaper stand-in it rasterizes there instead — a decimated
version of the shape, a level of its own LOD chain, or a hand-built hull:

```lua
renderer.setShadowProxy(statueMesh, statueHullMesh)
local p = renderer.shadowProxies()
print(p.triangles, "shadow triangles, from", p.sourceTriangles)
```

The registration is keyed by mesh, so one call covers every instance of it —
entities and GPU-driven populations alike — and a crowd sharing that mesh stays
one draw. The proxy is placed by whatever places the caster, its instance's own
transforms, so it stands where the caster stands. Nothing else in the scene
draws a proxy, so the call is what brings it onto the GPU.
`triangles` and `sourceTriangles` are what the last frame's shadow passes
submitted and what those same draws would have cost from the source meshes, so
the pair is the before/after of every registration and they are equal while
nothing is proxied. They count what was rasterized, so a frame that drew no
shadow view at all — every one served from the depth it already held — reads 0
for both.

A caster keeps its own geometry where a stand-in could not be placed or deformed
correctly: its entity is skinned (it rasterizes the post-skinned vertices written
for its own mesh), it blends morph targets (whose deltas describe its own mesh
and are read by vertex id), its proxy would be placed by a different node of its
model than the source mesh is, or the renderer holds no geometry under the
proxy's guid. Each of those is counted as `skinned` / `morphed` /
`nodeMismatch` / `unresolved`, so a
registration that is doing nothing says which of the four it is — read them the
way you read `triangles`, on a frame that drew a shadow view at all. On a
settled scene under shadow caching no view is drawn and every one of them is 0;
`renderer.setShadowCaching(false)` puts every view back on every frame, which is
also how the before/after triangle counts are read.
`renderer.clearShadowProxy(mesh)` puts one caster back on its own geometry, and
the bare call drops every registration.

How coarse a proxy can be is a property of the subject and the shadow map's
resolution, and it is measurable: capture the shadow with the proxy released,
capture it armed, and diff. A stand-in whose silhouette lands inside the map's
own resolution changes nothing a viewer can see while removing most of the
depth work.

**One caster can have a shadow view of its own.** A cascade covers the slab of
world the camera sees, so its texels are spread over tens of metres and the
character in the middle of it is resolved by a handful of them — which is why a
distant subject's shadow reads as a smudge rather than as its silhouette. A hero
view is the same light and the same depth range, zoomed onto one entity's world
bounds:

```lua
renderer.setShadowHero(player.id)
local h = renderer.shadowHero()
print(("%.0f texels/unit, up from %.0f"):format(h.texelsPerUnit, h.cascadeTexelsPerUnit))
```

It renders beside the cascades, into a layer of the same texture allocated while
a hero is registered, and every surface inside it reads it in place of the
cascade, crossing back over a band at its edge. Nothing else about the shadow
changes: the same casters reach it, at the same depth range, through the same
filter — so the difference is resolution and only resolution, which is what makes
the before/after diffable. `zoom` is the factor its texel density gains,
`worldExtent` the world distance it covers across one axis of the map, and the
`cascade*` fields the same measures for the cascade it was fit from.

The second argument is padding, a multiple of the caster's bounds: room for a
pose that leaves the bind-pose box and for the filter that samples just outside a
silhouette. It costs density in proportion — 2.0 covers twice the world across
the same map, so half the texels per unit.

A registered caster that gets no view says why in `decline`: `notFound` (no
renderable of it in the scene), `noBounds`, `degenerate`, `outOfDepthRange` (it
sits outside the depth range of the view it would be fit from), `noGain` (it
already fills that view, so a zoom would resolve nothing the cascade does not) or
`noDirectionalShadow`. The fit is over the renderables the entity itself carries,
so a subject assembled out of several entities is fit to whichever one is
registered. `renderer.clearShadowHero()` releases the caster and gives the layer
back.

**A shadow map that nothing changed is not drawn again.** Every shadow view —
one directional cascade, one atlas layer of spot tiles, one face of a point
light's cube — keeps the depth it already holds until something it draws from
changes: its
light moves, a caster it can see moves or appears or vanishes, the caster cutoff
starts or stops dropping one of them, a shadow proxy is registered or cleared,
the geometry a caster rasterizes is replaced under its own handle — its own mesh
or the proxy standing in for it — a caster's material changes what it lets light
through, a model's
animation moves the nodes its parts are placed by, a skinned caster changes
pose, or the map it writes into is reallocated by a budget change. A scene
standing still therefore pays for its shadows on the frame that resolves them
and on no frame after, and one where a single light moves pays for that light's
views alone. The one case that cannot be read from the scene is a mesh whose
vertices a compute pass writes — a population's per-instance transforms, or a
mesh created straight from a compute buffer — so a view holding one of those
re-renders every frame.

`renderer.setShadowCaching(false)` draws every view on every pass. A shadow
suspected of holding an image the scene has moved past is compared against that:
if the two agree, the map is current and the difference is elsewhere.

```lua
local s = renderer.shadowCacheStats()
print(s.rendered, "shadow views drawn this frame")
print(s.cached, "kept the depth they already had")
print(s.spotRendered, "of", s.spotRendered + s.spotCached, "spot atlas layers redrawn")
```

A cascade is fit around the camera, so moving the camera changes the cascade's
own matrix and redraws it — the saving is largest for the local lights, whose
views depend on the light and its surroundings rather than on where anyone is
looking. `profiler.gpuFrame()` shows the same thing in milliseconds: the
`scene.shadow` span is what stops being recorded on a still frame.

**Which light is eating the shadow budget.** The three readings above are totals
across every shadow view the frame drew. `renderer.shadowViews()` is the same
frame one view at a time: a row per directional cascade, per hero view, per
shadow-casting spot and per cube face of a shadow-casting point, each naming the
light that owns it and carrying what that view drew.

```lua
local report = renderer.shadowViews()
for _, view in report.views do
    print(("%s %d — light %s, %d draws, %d instances, %s"):format(
        view.role, view.index, view.light, view.draws, view.instances,
        view.rendered and "drew" or "cached"))
end
```

Three things that took a destructive edit to measure before are reads here.
`report.camera.submittedInstances` is the main camera's own share of
`renderer.drawStats().compactedDrawn`, so the shadow views' share is the rest of
it rather than a number recovered by turning every light's shadow off and
looking again. Each row's `span` is the label its pass is timed under, so its GPU
time is a lookup in `profiler.gpuFrame()` — every one of them is a variant of
`scene.shadow`, which still carries their total, so the aggregate span
decomposes over the lights that made it. And `castersTested` / `castersAdmitted`
/ `castersRejected` / `castersCutOff` / `castersNotCasting` say what one view's
pass did with the frame's casters — admitted and drawn, outside the volume,
dropped by the shadow-caster cutoff, or held back by the object's own
`Model.castsShadows` — where `renderer.cullStats()` answers for the main camera
alone. A view serving the depth it already holds runs no pass, so its census
reads 0 the way its draws do.

A cascade row also carries the `near` and `far` of the split it covers and the
`viewProj` it rasterized with, which is the read side of
`renderer.setShadowConfig` — change `cascades`, `distance` or `splitLambda` and
the four ranges move with it.

The list is rebuilt every frame, so a view whose light stopped casting is gone
from the next report rather than standing at the numbers it last had, and a frame
that drew no shadow view answers a report whose `views` is empty. The frame names
its views only while something is reading them: the first call of a session arms
that and the frame after it answers, which the `renderer.shadowViews()` wrapper
waits out for you.

**An object can stop drawing before it stops existing.** The camera's own
version of that threshold is a scene-wide screen-size cutoff: below it, an
object is dropped from the frame's draws entirely rather than rasterized into
the handful of pixels it covers.

```lua
renderer.setMinScreenSize(4)   -- drop anything under 4 px of on-screen radius
renderer.minScreenSize()       --> 4   what the camera draws down to now
renderer.setMinScreenSize(0)   -- released: every object draws however small
```

It is measured from the object's own bounds against the camera's projection, so
one threshold means the same apparent size at any distance or field of view, and
it is released (0) by default. Walk it up until something you meant to keep
disappears, then back off — a few pixels carry no detail a viewer resolves while
still costing a full vertex pass and a submission.

The cutoff is answered on the GPU, alongside the frustum and occlusion tests,
and the objects that survive it are packed into draws whose instance count the
GPU decides. `renderer.drawStats()` reports that pair:

```lua
local d = renderer.drawStats()
print(d.compacted, "instances planned through compacted draws")
print(d.compactedDrawn, "of them the GPU asked for")
```

`compacted` is this frame's plan; `compactedDrawn` comes back from the buffer
the GPU wrote, so it describes a frame that has already finished and holds its
last value until another one arrives. In a scene standing still the gap between
the two is the work culling removed — arm the cutoff and watch `compactedDrawn`
fall while `compacted` does not.

**What a frame spends on materials** follows how many distinct materials it
draws with, not how many draws it submits. A material's parameters — its uniform
block, its textures, and any storage buffers its shader declares — are bound
state, so the batched opaque geometry is gathered into runs: the draws naming
one material are submitted next to each other, and the run costs one set of
binds however many draws it holds. `renderer.drawStats()` reports both halves:

```lua
local d = renderer.drawStats()
print(d.materialBinds, "material binds issued")
print(d.materialBindsElided, "draws spared the bind")
print(d.materialExtraBinds, d.materialExtraBindsElided)  -- the shader's own storage group
```

Their sum is how many times a pass reached the bind decision — once per unit of
geometry it submitted, at or below `draws`, since a mesh of several primitives
draws once per primitive under one set of binds. `materialBinds` alone is what
the frame paid. A scene of many meshes over a few materials reads a small
`materialBinds` beside a large `materialBindsElided`; the two converging is a
scene whose draws each name a material of their own, which is the shape to look
for when a frame costs more than its geometry explains.

Only the batched opaque geometry is gathered into runs. Depth-sorted
transparents, skinned meshes and populations carry the same bound state through
the same pass but submit in their own order, so they are spared a bind only
where a material happens to repeat — a transparent-heavy or population-heavy
scene reads a low `materialBindsElided` for that reason rather than because
anything is wrong.

**What a transparent crowd costs** is how much of its depth order repeats.
Blended geometry is submitted farthest-first, because that order is what makes
the blend come out right, and a stretch of neighbours in it sharing a mesh, a
material, a shader and a pose is submitted as ONE instanced draw over those
neighbours — the same members, in the same order, out of a single submission.

```lua
renderer.setBlendedBatching(false)   -- a draw per blended renderable
local before = renderer.drawStats().draws
renderer.setBlendedBatching(true)    -- neighbours sharing a draw key together
local after = renderer.drawStats().draws
```

A run stops wherever a differently-drawn renderable sorts between two of its
members, so a crowd interleaved with other transparent geometry batches into
several runs rather than one, and a mesh of several primitives keeps a draw per
renderable. Both would otherwise move fragments through each other, which is the
one thing a back-to-front order exists to prevent. The image is identical either
way — the switch is there so a frame suspected of being formed by the batching
can be compared against one that is not.

**What a skinned crowd costs** is its distinct poses, not its head count. A
skinned instance is posed by a compute pass that writes its vertices into a
shared pool, and instances of one mesh whose joints hold the same pose are
posed once, into one slice of that pool that all of them draw from.
`renderer.skinningStats()` reports what the last frame's skinned instances
actually cost:

```lua
local s = renderer.skinningStats()
print(s.instances, "skinned instances")   --> 21
print(s.poses, "distinct poses")          --> 1    what they cost between them
print(s.dispatches, "skinning dispatches")--> 0    the pose arrived on an earlier frame
print(s.held, "poses read as they stood") --> 1
print(s.liveBytes, "of", s.unsharedBytes) --> 1351296  of  28377216
```

A pose is written once. The pass builds a slice out of the instance's joint
matrices, the node transforms its vertices address, its blend weight and its
blend model, so a slice already holding a pose holds exactly what running the
pass over the same inputs would write — and a frame that binds a pose whose
slice still holds it reads the slice and dispatches nothing. Skinning is paid
for by the poses that CHANGED: `held` counts the poses of the last frame that
cost nothing, `dispatches` the ones that were written, and the two add up to
`poses`.

```lua
local s = renderer.skinningStats()
print(s.dispatches, "poses written,", s.held, "read as they stood")  --> 0  22
```

A cast standing in one pose — a paused clip, a graph holding a frame, a prop
wearing a skeleton nothing drives — costs compute the frame the pose arrives and
nothing after it, and the `skinning` span leaves `profiler.gpuFrame()` entirely
once the window it averages over has rolled past the last dispatch. Turning a
bone costs two frames of dispatch: the frame the new pose arrives, and the frame
after, whose pose is the same joints reached from the new ones rather than from
the old. `renderer.setSkinningPoseHold(false)` writes every pose a frame binds
instead, which is the comparison a frame suspected of reading a slice that no
longer holds its pose is made against; the image is the same either way.

A crowd moving together — one clip on one clock — reads one pose however many
members it has, so the pool holds a single character's worth of vertices for all
of them. Members at different animation times each hold their own pose and each
take a slice, which is what keeps them posed independently: an avatar spawned
into a crowd that has been idling reads as its own pose until the two clocks
line up again. `liveBytes` against `unsharedBytes` is the saving as it stands
this frame; `poolBytes` is what the pool holds, with a previous-position buffer
of the same size beside it feeding skinned motion vectors.

A pose no instance holds any more gives its slice back, and the next pose of the
same size is cut from it — so a crowd that changes pose every frame cycles
through the slices it already has rather than walking the pool forward.
`reusedSlices` counts the poses of the last frame that were handed one of those
slices instead of pool the engine had never used, which is what a scene in
motion reads while `poolBytes` stays where it was:

```lua
local s = renderer.skinningStats()
print(s.reusedSlices, "of", s.poses, "poses took a slice the pool already held")
```

### Per-entity shader data — a per-instance property override, without a material each

A material per entity is what a dissolve, a hit flash or an effect at its own age
would otherwise cost: an authored material each or a runtime `renderer.material.create`
clone each, a separate pipeline binding and a separate row in the material table for
every one of them, and a `setProperty` call per entity per frame to drive it. The
per-instance channel is the way out: every drawn object carries a block of four
`vec4` lanes of its own, a surface shader reads lane `i` as `input.shader_data[i]`,
and the engine attaches no meaning to what is in them.

```lua
local DISSOLVE_LANE = 0
renderer.instanceData.laneCount()                    --> 4   lanes per entity
renderer.instanceData.set(subject, DISSOLVE_LANE, progress)
renderer.instanceData.clear(subject)                 -- back to zero lanes
```

```wgsl
fn fragment(input: FragmentData) -> vec4<f32> {
    let progress = input.shader_data[0].x;
    if progress > fract(sin(dot(input.uv, vec2<f32>(12.9898, 78.233))) * 43758.5) {
        discard;
    }
    return material.base_color;
}
```

Twenty subjects sharing that one material each dissolve at their own rate, and a
write reaches the frame after it. Reach for a material per entity when what
differs is the LOOK — a different shader, texture or blend — and for a lane when
what differs is a number the shader reads. A feature picks the lane indices it owns and
names them where it writes them, so its writer and its shader read the block the
same way — the baked-lightmap section above is that pattern in the engine's own
code, holding lane 0 for the atlas rect and lane 1 for the layer and enable flag.

A particle emitter is one entity, so a lane it sets there is shared by every
particle it draws. What one PARTICLE carries of its own — its life, its own seed,
the size it is drawn at — arrives through
`@builtin::systems.particles.particle_data`, read as `particle_sprite_data(input)`
or `particle_mesh_data(input)` for the emitter kind the material draws.

A lane an entity was never given reads zero, so a shader tells "not set" from
"set to zero" by keeping a flag in one of the components rather than by testing
the value it wants.

**Which material an instance is drawing with** is a number, because a shader
cannot read a name. Every renderable's per-instance record carries a material
index, and `renderer.materialIdentity()` is the table those indices are drawn
from — assigned the first time the renderer draws with that material and stable
for the rest of the session.

```lua
local id = renderer.materialIdentity()
id.slots["@builtin::materials.plastic_red"]  --> 4     the index that material took
id.renderables[1]                            --> { entity = "ent_…", slot = 4, index = 4, material = "…" }
id.populations[1]                            --> { slot = 96, count = 4096, index = 5, material = "…" }
renderer.materialIndex("@builtin::materials.plastic_red")  --> 4
```

A surface shader reads its own instance's index as `input.material_index`, so a
shader drawn by several materials can branch on which one it is under, and a
compute pass that traces the scene resolves the same index through
`zeroMaterial()`. Two renderables that differ only in their material read
different indices; one renderable reads the same index frame after frame, and a
material swap moves it on the next frame. A GPU-driven population is one row in
`populations` rather than one per member: every slot of its reserved run carries
the single material `renderer.mesh.drawInstanced` was given, so a member reads
that index wherever the population moves.

A renderable draws with the material its entity references, so a renderable
whose entity names none reads index 0. And because an index is an identity, it
is never reassigned: the table keeps a row for every material name drawn since
the engine started, whether or not anything still draws with it.

**A population's members carry their own shader data.** One material is what
keeps a population one draw, so what a member differs in has to arrive
per-instance. `instanceDataBuffer` is that channel: a GPU buffer holding one
block of four `vec4` lanes per instance, which the renderer copies into those
instances' lanes every frame, and which a surface shader reads as
`input.shader_data[lane]` — the same lanes an entity gets from
`renderer.instanceData.set`, on slots no entity owns.

```lua
local lanes = substrate.createBuffer({ name = "crowd.lanes", type = "vec4", len = COUNT * 4, kind = "gpu" })
renderer.mesh.drawInstanced(mesh, {
    transformBuffer = "crowd.xf",
    instanceDataBuffer = lanes.name,   -- the NAME, not the buffer
    instanceCount = COUNT,
    material = M,
})
```

The copy re-runs every frame, so a compute pass (or a `:write`) that rewrites
the buffer changes the next frame with no second registration. The buffer must
back the whole reservation — a short one is refused at the call rather than
letting the instances past its end read the next population's lanes. Omit it and
the lanes read zero, whatever the slots held before the registration took them.

**Texture filtering** is the one of these with a runtime API as well, because it is the setting worth scaling with a quality slider. `anisotropy` is how many samples a material texture is allowed to take along the axis of greatest compression — which is what keeps detail on a surface seen at a grazing angle. At 1 (plain trilinear) a road, a floor, or a long wall receding toward the horizon blurs to a flat average well before it gets there; at 16 the detail that is in the texture stays resolvable.

```lua
renderer.setAnisotropy(16)   --> 16   the effective level, clamped to the device
renderer.anisotropy()        --> 16   what material textures sample at now
renderer.maxAnisotropy()     --> 16   what this device can do (16, or 1 without the feature)
```

`setAnisotropy` returns the level that was actually applied, so asking for more than the hardware offers reports the clamp rather than failing. It takes effect on the next frame for content already on screen — no reload, no re-upload. A level that is not a power of two in `[1, 16]` is an error rather than a silent round. The world setting seeds the level at boot and whenever the file changes; a runtime call overrides it until then.

**Frame pacing** is how far the CPU is allowed to run ahead of the GPU.
Submitting work returns before the GPU has done it, and everything a submission
holds — its staging allocations, its bind groups, its command buffer — stays
alive until it completes. A frame that asks for more work than the GPU finishes
in a frame's time therefore leaves that behind it, and unbounded that is memory
growth rather than a lower frame rate. `max_frames_in_flight` is the bound; the
runtime knob is the setting worth scaling with a latency slider.

```lua
renderer.framePacing()            --> { framesInFlight = 2, maxFramesInFlight = 2, mechanism = "submission-wait", … }
renderer.setMaxFramesInFlight(1)  --> 1    lowest latency, least CPU/GPU overlap
renderer.setPresentMode("mailbox")--> "mailbox"
```

`mechanism` names how the bound is enforced where the engine is running.
`submission-wait` waits for the frame that many frames back, so a paced frame
costs latency and still draws. `submitted-work-done` counts outstanding frames
off the queue's own completion signal and declines to start a frame while the
bound is met — the remedy on a platform that cannot block — counting those in
`pacedFrames` and leaving the last presented image up until the GPU catches up.
Either way `framesInFlight` is taken from the queue's completion signal and
stays at or under `maxFramesInFlight`, so it reads under the bound while the
device keeps up and at it while the device is behind — and the backlog a slow
frame can build is bounded by a number you set.

`setPresentMode` answers the canonical spelling of what you asked for; read
`renderer.framePacing().presentMode` for the mode the surface took. Each of the
two settings is seeded on its own, so editing one of them in the file leaves a
runtime override of the other in force.

`present_mode` decides how a finished frame reaches the display: `fifo` queues
every frame and shows it on a vertical blank (never tears, never drops one),
`mailbox` replaces the queued frame with the newest, `immediate` presents as
soon as the frame is ready and can tear, `fifo_relaxed` tears rather than stall
when a frame misses its blank, and `auto_vsync` / `auto_no_vsync` leave the
choice to the backend. The default is `mailbox`: a frame that runs past the
vblank budget then shows its real rate, where `fifo` would lock it to a divisor
of the refresh rate (a 7ms frame on a 144Hz display presents at a hard 72). A
surface that does not offer the mode presents `fifo`, so read
`renderer.framePacing().presentMode` for what took effect and `.presentModes`
for what the surface offers.

**The pipeline cache** is the driver's compiled code for the pipelines this
renderer builds, kept across runs. A pipeline is machine code the GPU driver
compiles from the shader bound into it, and every launch pays that compile
before the first frame drawing with that pipeline can appear. The engine hands
each build the store, and writes the store back once a burst of builds settles —
and again as the engine closes, so a short run keeps what it compiled too. The
next launch of an unchanged shader reads back what the last one compiled.

```lua
renderer.pipelineCache()  --> { supported = true, pipelinesBuilt = 41, buildMs = 812.4, restoredBytes = 1_620_324, … }
```

`pipelinesBuilt` and `buildMs` are what this session built and what it cost —
the number the store lowers — and `restoredBytes` is what a previous run left
for this GPU. `path` names the file, which carries the GPU's identity, so a
machine with two adapters keeps two; the driver validates the blob it is handed
before trusting it, so one left by a driver that has since been updated is
rejected and that launch compiles from source. `saves`, `savedBytes` and
`dirty` describe writing it back — `dirty` stays true while the file is behind
what the driver holds, including after a write that failed, which `lastError`
then names — a failed write costs the stored compile and never the frame.
`supported` is false where the platform holds no store a program can carry — a
browser keeps its own and hands none out — and `reason` says which; the build
count and timing still read true there.

Per-camera overrides live on the **Camera component** — `debugChannel`, `postProcessing` (whether that camera runs the post-process chain, and the render-feature passes that declared themselves part of the film with it), and the `renderLayers` spec (a space-separated list of layer names, e.g. `"all !ui"`, where `ui` / `sky` / `debug` / `EditorUI` are built-in layers) decide what that camera draws (the render-textures guide uses this for secondary views).

That spec is one half of the render-layer system; the other half is which
entities are on each layer, written with `renderLayer.set` — `{ tree = true }`
puts an entity and every descendant on a layer in one call, and the reply counts
what it moved. The **render-layers guide** covers both sides.

## What the render targets cost

`renderer.renderTargets()` measures every target the renderer owns from the
texture that is allocated: the scene depth, the G-buffer, each shadow map, the
reflection-probe cubes, the transparency buffers, the post-process chain's
ping-pong pair, the game viewport's own set, and the scratch the draws into a
render target have needed — those rows are named `camera[<handle>]` after the
texture drawn into: depth and motion vectors under any rasterized pass, and the
occlusion channel and G-buffer under a camera's scene render. That scratch is
built by the draw that needs it and released once no live camera names the
target and sixty frames have passed without a draw, so a target nothing draws
into holds its colour texture and nothing else.

```lua
local rt = renderer.renderTargets()
print(("%.1f MiB over %d targets"):format(rt.totalBytes / 1048576, rt.residentCount))
for _, t in rt.targets do
    print(t.name, t.resident, t.bytes, t.width .. "x" .. t.height, t.layers)
end
```

A row marked `onDemand` exists only while something needs it, and reads
`resident = false`, `bytes = 0` the rest of the time. The weighted-blended
transparency buffers hold nothing at all until a pass accumulates into them —
the clear and composite that bracket the accumulation are skipped for those
frames too, since a composite over a buffer nothing wrote reproduces the image
underneath it. The reflection-probe cube array holds one cube slot until probes
need more of it, so a scene with no probe pays 2.10 MB rather than the 18.87 MB
of eight probe slots plus the sky fallback's. A
camera rendering into a texture brings its own depth, motion vectors, occlusion
and G-buffer with it, and takes them away again with the camera.

So the total moves with what the scene does, and comparing two scenes' totals
says what the second one's content costs in render-target memory. The colour
image a render-texture camera draws into is the one thing absent from the
report: it lives in the shared texture cache and is sampled by guid like any
other texture, so the camera is one of its holders rather than its owner.

## What the engine holds for content, and why an asset will not load

`renderer.renderTargets()` above measures the targets the renderer owns.
`asset.observe()` answers the other question — every texture and mesh the
device is holding *for content*, with the bytes each costs:

```lua
local r = asset.observe()
for _, t in r.textures do print(t.identity or t.key, t.bytes, t.width .. "x" .. t.height) end
for _, m in r.meshes do print(m.identity or m.guid, m.bytes) end
print(r.totals.textureBytes, r.totals.meshBytes, r.totals.cpuCount)
```

The three pools are named because they are different pools. `textures` and
`meshes` are the device's; `cpu` is the set a live script-component context
holds, which is what warms an asset's bytes into memory. An asset can be in one
and not the others. A row carrying an `identity` reached the device through that
asset; a row without one is held under a guid no asset claims — a camera's own
render target, a glyph atlas a script built.

`totals` carries the aggregates the rows sum to, so the listing reconciles
against `renderer.textureMemory()` and the `meshes` category of
`renderer.gpuMemory()` exactly. `renderer.texture.list()` and
`renderer.mesh.list()` report the same resources from the renderer's side, with
the script registry's `origin` and `owner` alongside.
`renderer.references(kind, guid)` answers the other question about one of those
rows — what is still holding it, named consumer by consumer — and
`renderer.collect()` releases every runtime texture, material, mesh and render
feature that nothing holds, which is also what a root scene load runs once the
new scene stands. The `core/resource-model` guide states the ownership rule both
of them read. `devicePublished` and
`cpuPublished` say whether the engine can answer at all, which reads differently
from an engine answering with nothing resident.

The same reading is served at `/zero/runtime/residency` for a reader outside
Luau, with `textures`, `meshes`, `cpu` and `totals` readable on their own.

Compute's own GPU resources are a separate pool again, and the `compute`
category of `renderer.gpuMemory()` is what they cost. `compute.observe()` lists
them one row per resource — every storage and uniform buffer, every 3D texture,
storage target, history pair and sampler — each with its bytes, the shader that
made it and the frame it was created on, summing to that category exactly.
`compute.diagnose(key)` answers the other half: when a resource key names
nothing, which of the states the inventory distinguishes it is in. Both are
covered at `guides { path: "types/computeShader" }`, and the reading is served
at `/zero/runtime/compute`.

**Why one asset will not load** — which file its type's declared `primary`
resolved to, whether the bytes decode, and the reason when it cannot be used —
is `asset.diagnose(ref)`, covered in the asset-system guide.

## Finding the rest

`lsp.methods("lights")` and `lsp.methods("postprocess")` list those surfaces in full; `asset.inspect("@builtin::components.Camera")` shows the camera's render fields. For rendering into a texture (mirrors, minimaps, in-UI 3D) see the render-textures guide; for the shaders these passes run, the shaders guide. The model to hold: content makes the look, `lights`/`postprocess` tune the scene at runtime, and `.world_settings [render]` sets the pipeline.
