Log inGet started

renderFeature

Updated 22 August 2026

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 main viewport's size in pixels.
ctx.timeSeconds since boot.
ctx.frameMonotonic 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:

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:

valuewhat 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:

PhaseScene colour there
afterShadowsshadow maps populated; scene colour/depth not yet drawn
afterSkysky only; depth empty
afterOpaqueopaque 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)
afterOpaqueLitthe 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
afterTransparenttransparents drawn, before the OIT 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 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:

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:

    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):

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.

Compute passes

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

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:

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):

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:

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