---
title: "Render features"
description: "Part of the rendering guide (guides { path = \"topics/rendering\" }), which lists every part."
section: "Topics"
slug: "topics-rendering-render-features"
canonical: "https://origozero.ai/docs/topics-rendering-render-features"
updated: "2026-10-15T11:08:40.253630575+00:00"
tags: ["documentation", "guide", "rendering"]
---

# Render features

## 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).

A scene keeps its features on one `RenderFeatures` entity — a list of
`.renderFeature` assets, each with the settings it runs under — which brings
every listed feature up, adopts one already live rather than stacking a second,
and takes them all down on disable and destroy. A feature declares its knobs as
`settings` and reads them from `ctx.settings`; `renderer.feature.schema(ref)`
lists them, `renderer.feature.configure(handle, { ... })` moves them at runtime,
and `renderer.feature.find(ref)` answers whether one is live. A compute pass can
read the scene's captured environment through `@scene.environment` and
`zero::environment` — the `renderFeature` type's README has the whole contract.

### 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.shaded", 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 screen-space pass that multiplies incoming light by a surface's own
reflectance — a bounce multiplier, a Fresnel tint, an ambient floor — reads
`@scene.material` rather than `@scene.shaded`: the first is what the surface
IS (base colour, metallic, roughness), the second is what it already SENT.
`@scene.material` packs all three into an `Rgba8Uint` channel, so a `.computeShader`
reading it declares its slot `sample: uint` and unpacks it through
`@builtin::shaderModules.gbuffer_material`. `renderFeature.assetType`'s README
carries the full `@scene.*` table (name, format, the phase each is valid from,
and its earlier spelling if it has one).

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.
