renderFeature
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…
Authoring contract
A .renderFeature folder holds an init.luau that returns a feature module:
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 render-target size in pixels. |
ctx.time | Seconds since boot. |
ctx.frame | Monotonic frame index. |
ctx.renderPath | "forward" / "deferred" / … — branch passes on the active path. |
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. Forward: lit opaque. Deferred: opaque is in the G-buffer, so the colour is still sky only until the composite (use afterLighting for lit opaque colour) |
afterTransparent | transparents drawn, before the 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 @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.
@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:
let inv_vp = mat4x4<f32>(cam[0], cam[1], cam[2], cam[3]);
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
capturetool'ssource = "position"/"entity"therefore run the pass queue too; using@frame.camerais 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 (orshader) the feature constructed;inputsbind live textures (@scene.*or RT guids),outputis the target RT (omit = the phase's default scene target). Post-process, blur, edge effects.Writing named G-buffer channels (
outputs). Instead of the singleoutputRT, a fragment pass may declare anoutputsarray — 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: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 = trueremains the shorthand for a single alpha-blended target. The shader's returned colour is written to every target in the set. -
"compute"— a workgroup dispatch (shader,storage,dispatch). -
"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.
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):
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.createtakes arenderblock —cull(back/front/none),depthWrite(bool),blend(opaque/alpha/additive) — so the pass can rasterize differently from the surface pass: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
.shaderis a surface shader: authorfn vertex(v: VertexData) -> VertexDatato move vertices (object-localv.position/v.normal) andfn fragment(input: FragmentData) -> vec4<f32>for the colour. - Its properties. A shader reads ONLY the uniforms IT declares in
properties.yaml, viamaterial.<yourProp>. Referencing a field it didn't declare (e.g. a StandardMaterialmaterial.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.
Lifecycle
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)(orrenderer.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 separateexecutecalls).