---
title: "renderFeature"
description: "A render feature inserts custom GPU passes into the frame pipeline. The engine provides one generic capability — a per-frame render-pass queue and a set of pipeline-phase insertion points — and a…"
section: "Types"
slug: "types-renderfeature"
canonical: "https://origozero.ai/docs/types-renderfeature"
updated: "2026-08-22T07:41:56.190414715+00:00"
tags: ["asset-type", "reference"]
---

# renderFeature

## Authoring contract

A `.renderFeature` folder holds an `init.luau` that returns a feature module:

```luau
return {
    setup    = function(ctx) end,  -- optional; runs once when the feature goes live
    render   = function(ctx)       -- required; runs every frame
        -- enqueue one or more passes. The spec shape depends on `kind` (see
        -- "Pass kinds" below): this is a "fragment" pass (full-screen shader over
        -- input textures); a mesh effect (outline, x-ray) is a "geometry" pass
        -- with `material` + `select` (see "Geometry passes").
        ctx.enqueue {
            phase  = "afterTransparent",
            shader = "my.shader.guid",
            inputs = { src = "@scene.color" },
            output = myRenderTargetGuid,
        }
    end,
    teardown = function(ctx) end,  -- optional; runs once when the feature is destroyed
}
```

Optional `materials/` and `shaders/` subfolders hold shader source the feature
constructs GPU programs from. A feature constructs its GPU programs (it does not
reference `.material` surface assets).

## `ctx`

Built fresh each frame and passed to `setup` / `render` / `teardown`:

| Field | Meaning |
|-------|---------|
| `ctx.enqueue(spec)` | Queue one render pass for the current frame. |
| `ctx.viewport` | `{ w, h }` — the **main viewport's** size in pixels. |
| `ctx.time` | Seconds since boot. |
| `ctx.frame` | Monotonic frame index. |
| `ctx.renderPath` | `"forward"` / `"deferred"` / … — branch passes on the active path. |

`render` runs **once per frame**; the passes it enqueues run **once per render
target** drawn that frame — the live viewport, every offscreen capture, every
RTT camera — each at its own resolution. So `ctx.viewport` is the main
viewport's size, not the size of the target a pass ends up drawing into. Size a
full-screen scratch RT with `screen = true` and let the engine track the drawn
target (see "Screen-sized targets") rather than deriving it from `ctx.viewport`.

## Screen-sized targets

`renderer.texture.create { screen = true }` marks a feature RT as full-screen:
before each render target's feature passes, the engine resizes it to that
target's resolution. Create it once — the engine owns its size from then on:

```luau
myRt = renderer.texture.create {
    width = ctx.viewport.w, height = ctx.viewport.h,   -- initial size only
    screen = true, format = "rgba8", name = "my_effect",
}
```

`screenScale` (default `1.0`) takes a fraction of the drawn target's resolution,
for a scratch that runs below composite resolution (a half-res blur passes `0.5`).

A screen-sized RT is what makes a full-screen effect composite correctly at any
resolution. Deriving the size from `ctx.viewport` instead pins the RT to the main
viewport, and every differently-sized render target then composites a mismatched
scratch — the scene scaled into a corner with the rest black.

`screenSpace` names WHICH image a screen-sized RT follows:

| value | what it tracks |
|---|---|
| `"scene"` (default) | the scene render target, at whatever resolution the render scale asked for. Resized before EVERY render target's feature phases — the main frame and each offscreen one — and cleared to transparent before an offscreen render, so one sink never reads what another left behind |
| `"composite"` | the image the post-scene phases draw into. Resized on the presented frame only, and never cleared for an offscreen render, since none of those phases run there |

The two carry the same size until the render scale drops below 1. From there,
while the renderer presents the viewport itself, the scene reaches the display's
resolution through an upscale that runs before `afterScenePost` — so a
composite-space RT is the display's size while a scene-space one is the smaller
rasterized frame. Under a UI viewport panel the panel draws the scene target at
its own rect, nothing upscales beforehand, and both spaces carry the scene's
size.

Because a composite-space target survives an offscreen render untouched, it is
the one that can hold an accumulation across frames. A scene-space target is
scratch: whatever a pass does not rewrite this render is transparent black.

## Pass phases

`enqueue` runs a pass at a named pipeline boundary — a real seam between the
scene's discrete passes — ordered within a phase by `order`. Each phase is the
point where the scene colour holds a specific, nameable state:

| Phase | Scene colour there |
|-------|--------------------|
| `afterShadows` | shadow maps populated; scene colour/depth not yet drawn |
| `afterSky` | sky only; depth empty |
| `afterOpaque` | opaque drawn, before transparents. `@scene.depth` (and the deferred G-buffer) are populated. Forward: lit opaque. Deferred: opaque is in the G-buffer, so the colour is still sky only until the composite (use `afterOpaqueLit`) |
| `afterOpaqueLit` | the lit opaque scene, still before transparents, on **both** paths — the forward opaque draw, or the deferred lighting composite. The background a refractive or transmissive surface samples: the one phase whose colour holds what is behind such a surface without holding the surface itself |
| `afterTransparent` | transparents drawn, before the OIT composite |
| `afterLighting` | deferred-lighting + OIT composite done — fully lit, final geometry on **both** paths |
| `afterScenePost` | scene-layer post (DoF/MB/…) done, before UI |
| `afterUI` | scene + UI composited |
| `afterPost` | all-layer post done — the final image, before present |

The last three are the post-scene phases, and they work on the COMPOSITED image:
`@scene.color` there is the target the UI lands on, not the scene render target.
`@scene.depth` and `@scene.motion` still bind — they are the scene's own buffers
for the frame just drawn, which is what lets a pass at these phases reproject the
composite it is handed — but they keep the SCENE's resolution. Whenever the
render scale is below 1 on the presented frame, that is smaller than
`@scene.color`, so a pass here maps between the two by uv rather than by texel
index, and takes its own output size from the target it writes.

The `@scene.*` inputs (`@scene.color`, `@scene.depth`, …) bind the live frame
buffers; the deferred g-buffer channels (`@scene.normal`, `@scene.albedo`, …)
resolve only on the deferred path.

`@scene.motion` carries screen-space velocity: the NDC displacement each pixel's
surface underwent since the previous frame, so a still surface under a still
camera reads `(0, 0)`. Opaque geometry contributes it on both render paths — the
forward opaque draw writes it alongside colour, and a velocity pass writes it for
the geometry the deferred G-buffer drew. A moving entity, a moving camera and an
animated pose all reach it in the viewport; transparents, particles and sky
carry none. A surface a material's `vertex()` hook displaces carries the
displacement's own velocity on the forward path; on the deferred path the
velocity pass draws the undisplaced shape, so what it writes describes the
entity's movement rather than the hook's. A render target's own camera and a
capture keep no view history, so velocity there is the entity's motion with no
camera term.

`@frame.camera` is a reserved **buffer** input carrying the camera CURRENTLY
being drawn: 5×`vec4<f32>` = the inverse view-projection (columns `[0..4)`,
column-major) + camera world position (`[4].xyz`). A compute pass declares a
`buffer` binding and binds it via `inputs = { name = "@frame.camera" }`, then
reconstructs a pixel's world position from `@scene.depth`:

```wgsl
let inv_vp = mat4x4<f32>(cam[0], cam[1], cam[2], cam[3]);
// ndc.z is the `@scene.depth` sample itself. Scene depth is reversed — 1.0 is
// the near plane, 0.0 the far plane and empty sky — and this matrix inverts
// that same range, so a sample goes in unmodified.
let wh = inv_vp * vec4<f32>(ndc, 1.0);
let world_pos = wh.xyz / wh.w;
```

Like `@scene.*`, the engine resolves it **per render target** — so the same pass
reconstructs against the live viewport's camera on screen and against an
offscreen capture's / RTT camera off screen. (Pack a camera matrix into your own
buffer instead and the offscreen render reconstructs against the wrong camera.)

> Render-feature passes run in the main viewport render **and** in offscreen
> captures / render-to-texture, each against its own camera. The `capture` tool's
> `source = "position"` / `"entity"` therefore run the pass queue too; using
> `@frame.camera` is what keeps those captures matching the live viewport.

## Pass kinds

`enqueue` runs one of three kinds (the `kind` field; default `"fragment"`):

- **`"fragment"`** — a full-screen shader over input textures. The program is a
  GPU **material** (or `shader`) the feature constructed; `inputs` bind live
  textures (`@scene.*` or RT guids), `output` is the target RT (omit = the
  phase's default scene target). Post-process, blur, edge effects.

  **Writing named G-buffer channels (`outputs`).** Instead of the single `output`
  RT, a fragment pass may declare an `outputs` array — one or more targets, each a
  named engine channel (`@scene.albedo` / `@scene.normal` / `@scene.mr` /
  `@scene.emissive` / `@scene.color`) or an RT guid — each with its own blend mode:

  ```luau
  ctx.enqueue {
      kind    = "fragment",
      shader  = myShaderKey,
      outputs = {
          { target = "@scene.albedo",   blend = "under" },      -- premultiplied dst-over-src
          { target = "@scene.emissive", blend = "additive" },   -- one fullscreen pass, two targets (MRT)
      },
      phase   = "afterOpaque",   -- deferred: G-buffer filled, before the lighting composite
  }
  ```

  Blend modes: `"replace"` (overwrite), `"alpha"` (source-over), `"additive"`
  (sum), `"under"` (premultiplied dst-over-src — accumulates coverage-weighted
  contributions). A `@scene.*` channel resolves only where it exists (the deferred
  G-buffer at a pre-composite phase); a pass whose targets are all unavailable at
  its phase is skipped (logged). `output` + `blend = true` remains the shorthand
  for a single alpha-blended target. The shader's returned colour is written to
  every target in the set.

  **What a pass with no `inputs` samples.** A fragment pass that declares no
  `inputs` reads `@scene.color` — the shape of an effect that filters the scene
  image. A pass that names `@scene.color` among its own `outputs` samples a 1x1
  transparent texel instead, because the scene colour is that pass's render
  target and one texture is a colour attachment or a sampled resource in a render
  pass, never both. Declare `inputs = { src = "@scene.color" }` and write an RT
  to read the scene image and write somewhere else in one pass.
- **`"compute"`** — a workgroup dispatch (`shader`, `storage`, `buffers`, `dispatch`;
  see "Compute passes").
- **`"geometry"`** — draw scene geometry with a constructed pipeline. The program
  is a **material** the feature built (`renderer.material.create`); the engine
  draws the scene's render-objects with that material's pipeline + uniforms.
- **`"draw"`** — rasterize the feature's OWN geometry: `geometry` names a buffer
  created with `usage = { "vertex" }`, `storage.index` one created with
  `usage = { "index" }`, and `select` the one entity whose transform and
  material the draw lands through. `params[0]` is how many indices to submit —
  or name a buffer under `storage.indirect` (created with
  `usage = { "indirect" }`, holding a `DrawIndexedIndirectArgs` block: index
  count, instance count, first index, base vertex, first instance) and the count
  comes from the GPU instead, so a compute pass that compacts geometry pays only
  for what it kept and nothing waits on a read-back. `first_instance` must be 0;
  the engine resolves the entity's per-instance data another way, because a
  non-zero one needs a device feature WebGPU does not guarantee.
- **`"splat"`** — rasterize a compute buffer of particle records as instanced
  camera-facing billboards into feature RTs (see "Splat passes").

### Geometry passes

A geometry pass re-draws the scene's meshes with a material you supply — the
generic primitive for mesh-space effects (the engine names none of them):

```luau
ctx.enqueue {
    kind     = "geometry",
    material  = myMaterial,             -- the HANDLE renderer.material.create
                                        -- returned (or its registry-key string)
    geometry = "@geometry.opaque",      -- which drawables: "@geometry.opaque" |
                                        -- "@geometry.transparent" | "@geometry.all"
    select   = { entityA, entityC },    -- OPTIONAL: restrict to these entities
                                        -- (entity refs); omit = every matching mesh
    phase    = "afterLighting",         -- afterLighting = lit scene + valid depth
}
```

`select` restricts the draw to the given entities — the per-instance signal that
turns a whole-scene pass into a chosen-meshes effect. The **material** decides how
those meshes are drawn; you control three things:

- **Pipeline render-state.** `renderer.material.create` takes a `render` block —
  `cull` (`back`/`front`/`none`), `depthWrite` (bool), `blend`
  (`opaque`/`alpha`/`additive`) — so the pass can rasterize differently from the
  surface pass:
  ```luau
  local myMaterial = renderer.material.create({
      shader = myShaderKey,                         -- a .shader you authored (identity, not guid)
      render = { cull = "none", depthWrite = false },
      properties = { outline_color = { 1, 0.4, 0, 1 } }, -- seed the shader's uniforms
  }, "my_pass_mat")                                 -- pass `myMaterial` (or "my_pass_mat") as the pass `material`
  ```
- **The shader.** A geometry `.shader` is a surface shader: author `fn vertex(v:
  VertexData) -> VertexData` to move vertices (object-local `v.position` /
  `v.normal`) and `fn fragment(input: FragmentData) -> vec4<f32>` for the colour.
- **Its properties.** A shader reads ONLY the uniforms IT declares in
  `properties.yaml`, via `material.<yourProp>`. Referencing a field it didn't
  declare (e.g. a StandardMaterial `material.base_color`) fails to compile and the
  mesh renders **magenta** — declare the property or use a constant.

What effect those three produce (outline, x-ray, wireframe, highlight, …) is
yours to design — the engine just draws the selected geometry with your pipeline.

### Compute passes

A compute pass dispatches a `.shader` compute program over a workgroup grid:

```luau
ctx.enqueue {
    kind     = "compute",
    shader   = myComputeShaderKey,
    storage  = { outTex = myStorageRtGuid },     -- slot -> storage-texture RT guid
    buffers  = { particles = "my_particle_buf" },-- slot -> named compute buffer
    inputs   = { camera = "@frame.camera" },      -- slot -> @frame.*/@scene.* buffer
    dispatch = { x = 64, y = 1, z = 1 },
}
```

**`dispatch` is optional.** Omit it and the pass is dispatched over what it
WRITES: a grid covering its first `storage` texture, at the `@workgroup_size` the
shader's entry point declares. With a screen-sized storage target that grid
tracks the render target being drawn, so a screen-space effect covers the live
viewport, an offscreen capture and an RTT camera without the feature knowing any
of their resolutions:

```luau
ctx.enqueue {
    kind    = "compute",
    shader  = myComputeShaderKey,
    inputs  = { scene_color = "@scene.color", cam = "@frame.camera" },
    storage = { outTex = myScreenSizedRt.guid },   -- screen = true
    phase   = "afterLighting",
}
```

State an explicit `dispatch` for a grid that is not one thread per output texel
(a reduction, a particle sim), or when the shader's `@workgroup_size` is an
override/const expression the engine cannot read.

Each WGSL storage-buffer binding in the shader resolves its bound slot in this
order: an explicit `buffers` mapping (the handle a buffer's owner holds, or a
name), then `inputs` (`@frame.camera` / `@scene.lights`), then — when a slot has
neither — a buffer whose engine-side name equals the slot's own name. `buffers`
is the seam a pass binds through: the buffers a shader works over are the ones
handed to it, whatever each is called. `storage` binds storage TEXTURES (RT
guids) the same way, one map per resource kind.

### Splat passes

A splat pass draws every record of an f32 buffer as a camera-facing billboard —
the raster primitive behind screen-space particle surfaces (the `fluidSurface`
system is built entirely on it):

```luau
ctx.enqueue {
    kind   = "splat",
    phase  = "afterLighting",
    output = depthRt.guid,           -- or `outputs` for the thickness+color MRT
    splat  = {
        buffer    = "my_particles",  -- compute-buffer name
        stride    = 12,              -- floats per record
        posOffset = 0,               -- float offset of the position
        count     = 200000,          -- records to draw
        radius    = 0.3,             -- world particle radius
        channel   = "depth",         -- "depth" | "thickness" | "sprite" | "gaussian"
        velOffset = 4,               -- optional: velocity → screen-space stretch
        stretch   = 0.04,            -- seconds of motion elongating the billboard
        colorOffset  = 8,            -- optional (thickness): + per-particle RGB → MRT
        weightOffset = 7,            -- optional (sprite): per-particle intensity
        minY      = -60.0,           -- records below this y collapse (parked/dead)
        maxRadiusPx = 120,           -- optional: cap on the projected pixel radius
    },
}
```

The `"gaussian"` channel draws the engine's packed splat records — what
`renderer.splat.components` decodes a `.spz` or `.ply` capture into — as
elliptical-weighted-average Gaussians, and takes fields of its own:

```luau
splat = {
    buffer      = "cloud_records",
    stride      = 6,                 -- the packed record is six u32 words
    posOffset   = 0,
    count       = 1200000,
    radius      = 1.0,               -- multiplies each splat's stored scale
    channel     = "gaussian",
    orderBuffer = "cloud_order",     -- back-to-front permutation; "over" needs one
    shBuffer    = "cloud_sh",        -- optional: quantized higher-order SH
    shStride    = 24,                -- u32 words of SH per record
    shDegree    = 3,
    transform   = model,             -- object -> world, column-major, 16 floats
    prevTransform = prevModel,       -- where `transform` stood last frame
    opacityScale = 1.0,
}
```

`transform` places the cloud: each Gaussian's covariance is conjugated by its
upper-left 3×3, so a non-uniform scale shears the cloud the way it shears a mesh.
`prevTransform` plus a second entry in `outputs` makes the channel write an MRT
pair — the colour into the first target, and the screen-space displacement each
splat underwent between the two placements into the second, premultiplied by the
same alpha the colour composites under. That second target therefore resolves to
`(coverage-weighted mean displacement, coverage)`, which is what folds a moving
cloud into `@scene.motion`. Naming one target, or leaving `prevTransform` out,
renders the colour alone.

The other three channels write different fragments: `"depth"` writes nearest-surface
linear view depth (R32Float, hardware-depth-tested on the target's own depth
buffer), `"thickness"` accumulates additive optical thickness (with
`colorOffset` and a two-entry `outputs` list it also accumulates
thickness-weighted color into the second target), and `"sprite"` accumulates a
soft additive disc scaled by `weightOffset`. Every channel discards fragments
behind opaque scene geometry by sampling the scene depth at the fragment's own
NDC. `maxRadiusPx` scales down billboards whose projected major radius would
exceed that many pixels, so particles crossing the near plane stay
droplet-sized instead of filling the screen. Targets clear at the pass;
`blend = true` on the spec loads them instead to accumulate over a prior
splat pass.

`renderer.feature.create(featureRef)` instantiates a feature and returns a live
handle (`featureRef` may be an `AssetRef<renderFeature>` or its identity string).
The handle appears under `/zero/runtime/generated_features/<guid>/`. Re-creating
the SAME feature asset replaces the live one (hot-reload after editing
`init.luau`) rather than stacking a duplicate.

- `renderer.feature.list()` → `{ {guid, identity} }` for every live feature.
- `renderer.feature.destroy(handleOrGuid)` (or `renderer.destroy(handle)`) tears
  one down — by handle OR by guid string, so you can stop a feature even after
  losing its handle (e.g. across separate `execute` calls).
