renderFeature
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 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:
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:
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
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.What a pass with no
inputssamples. A fragment pass that declares noinputsreads@scene.color— the shape of an effect that filters the scene image. A pass that names@scene.coloramong its ownoutputssamples 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. Declareinputs = { 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:geometrynames a buffer created withusage = { "vertex" },storage.indexone created withusage = { "index" }, andselectthe one entity whose transform and material the draw lands through.params[0]is how many indices to submit — or name a buffer understorage.indirect(created withusage = { "indirect" }, holding aDrawIndexedIndirectArgsblock: 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_instancemust 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.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.
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)(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).