Log inGet started

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:

FieldMeaning
ctx.enqueue(spec)Queue one render pass for the current frame.
ctx.viewport{ w, h } — the render-target size in pixels.
ctx.timeSeconds since boot.
ctx.frameMonotonic 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:

PhaseScene colour there
afterShadowsshadow maps populated; scene colour/depth not yet drawn
afterSkysky only; depth empty
afterOpaqueopaque 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)
afterTransparenttransparents drawn, before the composite
afterLightingdeferred-lighting + OIT composite done — fully lit, final geometry on both paths
afterScenePostscene-layer post (DoF/MB/…) done, before UI
afterUIscene + UI composited
afterPostall-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 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:

    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.

  • "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.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:
    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.

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) (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).
  • asset-type
  • reference