Log inGet started

Render features

Updated October 15, 2026

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 material uniform 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 any dpdx/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.create takes a render block — cull (back/front/none), depthWrite, blend — so the pass rasterizes differently from the surface pass. properties seeds the shader's declared uniforms (a flat { name = value } map, matching the names in properties.yaml; a colour is a {r,g,b,a} array). textures binds its declared texture slots. There is no colors/floats split — 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 .shader is a surface shader: fn vertex(v: VertexData) -> VertexData moves 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, and v.view_proj in vertex_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, via material.<yourProp> — and both vertex() and fragment() can read them (the generated material uniform 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 the EntityVisuals.outline field) 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.

  • documentation
  • guide
  • rendering