Render features
Render features — custom GPU passes (outlines, x-ray, wireframe, decals)
A render feature inserts your own GPU passes into the frame, every frame — the
generic capability behind mesh outlines, x-ray, custom wireframe, decals, and
any effect the built-ins don't cover. A feature is a .renderFeature asset whose
render(ctx) hook calls ctx.enqueue{...} to queue passes; the engine runs them
at a named pipeline phase. Three pass kinds: "fragment" (full-screen shader),
"compute" (dispatch), and "geometry" (re-draw scene meshes with your pipeline).
A scene keeps its features on one RenderFeatures entity — a list of
.renderFeature assets, each with the settings it runs under — which brings
every listed feature up, adopts one already live rather than stacking a second,
and takes them all down on disable and destroy. A feature declares its knobs as
settings and reads them from ctx.settings; renderer.feature.schema(ref)
lists them, renderer.feature.configure(handle, { ... }) moves them at runtime,
and renderer.feature.find(ref) answers whether one is live. A compute pass can
read the scene's captured environment through @scene.environment and
zero::environment — the renderFeature type's README has the whole contract.
What a pass costs
A render(ctx) hook runs every frame, and a feature usually cannot tell how much
work it has: it runs in its own Luau VM, so module state and entity attributes
written on the other side of that boundary do not read back here. The normal
shape is therefore to enqueue the pass unconditionally and let the shader decide
per frame whether it draws anything — which is fine, on two conditions.
A "fragment" pass runs its entry once per pixel it covers, so a full-screen
pass with nothing to draw still pays for whatever every invocation sets up:
- Return on a uniform first. A value from the
materialuniform is the same for every pixel, so a test against it costs one branch for the whole pass and an idle pass costs nothing. Test it before sampling anything, and before anydpdx/dpdy— a derivative has to be reached by every pixel in a quad, so a per-pixel early-out placed above one leaves its neighbours reading differences nobody wrote. - Hold no array in a function-local
var. Scratch a function declares is reserved on every invocation, sized for the whole array, whether or not the pixel reaches the code that reads it — so an early-out does not save a pass that declared one, and the pass costs the same idle as it does full. Read each element straight from its uniform, or pass them to a function as arguments, so the values stay in registers. The engine warns at compile time when a post-process shader declares one; a batched effect holding eight slots that way cost 4.4ms a pass with nothing on screen.
The profiler toolbox's gpu tool gives the per-pass medians to check this against —
an idle full-screen pass should read as tens of microseconds, not milliseconds.
An early-out still costs the invocation that takes it, so a full-screen pass over an effect that only touches part of the world pays for the screen. When the feature knows the world-space box its effect stays inside — a decal projector, a light volume, a filter over one room — it says so, and the pass shades only the part of the screen that box lands on:
ctx.enqueue {
kind = "fragment", shader = myEffect, phase = "afterOpaque",
outputs = { { target = "@scene.shaded", blend = "alpha" } },
bounds = { min = { -4, 0, -4 }, max = { 4, 3, 4 } }, -- world space
}
The rectangle is worked out per camera, from the projection that camera drew
with, and it is conservative — it holds every pixel whose view ray meets the box,
so the image is the one the unconfined pass produced. A camera whose frustum the
box falls outside skips the pass entirely, so marks behind the camera cost
nothing. It applies to a pass whose outputs all name @scene.* channels and
all blend: a pixel the effect reaches nothing at composites as the target
already stood, which is why leaving it unshaded is the same picture. A pass that
replaces its target states every pixel outright and is left covering it.
renderer.feature.shaded() reads how many pixels each pass shaded on the last
drawn frame, which is what says whether a pass costs its effect or costs the
screen.
A screen-space pass that multiplies incoming light by a surface's own
reflectance — a bounce multiplier, a Fresnel tint, an ambient floor — reads
@scene.material rather than @scene.shaded: the first is what the surface
IS (base colour, metallic, roughness), the second is what it already SENT.
@scene.material packs all three into an Rgba8Uint channel, so a .computeShader
reading it declares its slot sample: uint and unpacks it through
@builtin::shaderModules.gbuffer_material. renderFeature.assetType's README
carries the full @scene.* table (name, format, the phase each is valid from,
and its earlier spelling if it has one).
A geometry pass is the one for mesh effects — it redraws scene render-objects with a material you construct, and can target specific entities:
ctx.enqueue {
kind = "geometry",
material = myMatKey, -- a material from renderer.material.create
geometry = "@geometry.opaque", -- "@geometry.opaque" | "@geometry.transparent" | "@geometry.all"
select = { entityA, "ent_…" }, -- OPTIONAL: only these entities (refs or ids); omit = all
phase = "afterLighting", -- lit scene + valid depth
}
select restricts the draw to the given entities — what turns a whole-scene pass
into a chosen-meshes effect. Each entry is an entity ref or the id string
itself — the pass matches select against each render-object's owner id, so
an id you already hold goes in as it stands and the selection follows the
renderer's own view of the scene. The material decides how those meshes are
drawn, and you control three things:
- Render-state.
renderer.material.createtakes arenderblock —cull(back/front/none),depthWrite,blend— so the pass rasterizes differently from the surface pass.propertiesseeds the shader's declared uniforms (a flat{ name = value }map, matching the names inproperties.yaml; a colour is a{r,g,b,a}array).texturesbinds its declared texture slots. There is nocolors/floatssplit — that is the mat.yaml authoring shape, not this runtime call:renderer.material.create({ shader = myShaderKey, -- a .shader you authored (identity, not guid) render = { cull = "none", depthWrite = false }, properties = { my_color = { 1, 0.45, 0, 1 } }, -- seeds material.my_color }, "my_pass_mat") - The shader. A geometry
.shaderis a surface shader:fn vertex(v: VertexData) -> VertexDatamoves vertices (v.position/v.normal, object space);fn fragment(...) -> vec4<f32>is the colour. A vertex hook is evaluated twice per vertex — once for this frame and once for the vertex as it stood one frame back, which is what the renderer's motion vectors are taken between. Read the frame off the argument (v.time, andv.view_projinvertex_clip()) so the second evaluation lands where the surface actually was; the shader assetType README has the full contract. - Its properties. A shader reads ONLY the uniforms it declares in
properties.yaml, viamaterial.<yourProp>— and bothvertex()andfragment()can read them (the generatedmaterialuniform is bound for both stages). Referencing one it didn't declare (e.g.material.base_color) fails to compile and renders magenta; the compile error in the engine log names the field and the fix (declare it, or use a constant). (See the shaders guide.)
What effect that produces — outline, x-ray, wireframe, highlight — is yours to design from those pieces; the engine names none of them.
The legacy
visuals.setOutline/Model:setOutline/sc.outline(and theEntityVisuals.outlinefield) are deprecated — they predate render features and draw nothing. A mesh outline is a render-feature geometry pass.
The full feature contract (setup / render / teardown, ctx, every phase,
the @scene.* / @frame.camera / @geometry.* names) is in the renderFeature
assetType README. Reserved inputs resolve per render target, so a screen-space
feature that reconstructs world space from depth via @frame.camera is correct in
the live viewport AND in offscreen captures (capture(source = "position"/"entity"))
from a different camera.