Log inGet started

renderFeature

Updated October 16, 2026

Authoring contract

A .renderFeature folder holds an init.luau that returns a feature module:

return {
    setup    = function(ctx)       -- optional; runs once when the feature goes live
        -- A "fragment" pass's `shader` names a POST EFFECT, not a `.shader`
        -- guid directly — postprocess.add compiles the shader asset under
        -- that name once, here. Enqueueing a name never passed to
        -- postprocess.add runs the pass as a no-op every frame (the log
        -- reads "effect '<name>' not compiled — pass skipped").
        postprocess.add("my_effect", asset.resolve("my.shader.guid", "shader"), { enabled = false })
    end,
    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_effect",
            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.
ctx.settingsThe feature's knobs as they stand this frame — see "Settings".

Settings

A feature declares the knobs it takes as settings, each a Field descriptor with a default, the way a component declares its public fields:

local Fields = require("@builtin::modules.field")
local Field, NoSync = Fields.Field, Fields.NoSync

return {
    settings = {
        intensity = Field.number(1.0, NoSync, "How much of the effect reaches the image."),
        quality = Field.enum({ "low", "high" }, "high", NoSync, "How many samples the pass spends."),
    },
    render = function(ctx)
        local s = ctx.settings          -- { intensity = 1.0, quality = "high" }, or what a caller set
        ...
    end,
}

The engine holds one settings table per live feature — every declared default, overridden by what it was handed — and passes it as ctx.settings on every setup / render / teardown. A feature reads its knobs there and nowhere else, so any caller tunes any feature by name:

local h = renderer.feature.create(ref, { settings = { intensity = 0.5 } })
renderer.feature.configure(h, { quality = "low" })   -- lays over the rest
renderer.feature.settings(h)                          -- { intensity = 0.5, quality = "low" }
renderer.feature.find(ref)                            -- the live handle, or nil
renderer.feature.schema(ref)                          -- the declared knobs, unrun

A knob the feature does not declare, or a value of the wrong shape, is refused by name. A scene authors its features on one RenderFeatures entity — a list of features, each with its settings — which adopts a feature already live rather than stacking a second, and takes every one down on disable and destroy.

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 = "rgba16f", name = "my_effect",
}

A target that holds a picture of the scene is rgba16f, which is what the scene target itself carries: linear light with headroom above the display's white and no 256-step floor under a dark gradient. Omitting format on a screen-sized target asks for the same thing. format = "rgba8" is for a mask, a weight or an occlusion term, where 0..1 is the whole range — hand one of those back over @scene.color and the frame loses every highlight and bands every gradient, and the engine says so in the log.

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.

A scene-space target is scratch: whatever a pass does not rewrite this render is transparent black. A composite-space target survives an offscreen render untouched, but it follows the presented frame alone.

History targets

An accumulation across frames (temporal antialiasing, a temporal denoiser, any pass that reads what it wrote last time) lives in a history target:

history = renderer.texture.create {
    width = ctx.viewport.w, height = ctx.viewport.h,   -- initial size only
    history = true, format = "rgba16f", name = "my_accum",
}
-- in render(ctx):
ctx.enqueue {
    kind = "compute", program = myResolve,
    inputs  = { scene_color = "@scene.color", accum_previous = history.previous.guid },
    buffers = { accum_state = history.state },
    storage = { accum_out = history.guid },
    phase = "afterLighting",
}

The passes a feature enqueues run once for every view the frame draws (the viewport, each render-to-texture camera, each capture), so an accumulation has to be one per view. A history target keeps one pair of images per view, sized to that view: for the length of a view's render history.guid names the image the render writes and history.previous.guid the image the same view's previous render wrote. Another view never resizes, clears or overwrites them, and a view's images are freed when it stops drawing. It follows screenSpace and screenScale like any screen-sized target; a composite-space one is kept for the viewport, the one view that draws the post-scene phases.

Before each render of a view the engine writes history.state, one vec4<f32> a pass binds through buffers:

fieldholds
x1 when previous holds this view's previous image; 0 on the view's first render, its first after a camera cut or a change of size, or after a render of the view that ran without the target, and previous then reads transparent black
yhow many renders in a row have carried the history
zwthis render's sub-pixel projection offset, in pixels (renderer.setProjectionJitter)

renderer.setProjectionJitter is the matching per-view sample sequence: every view walks it by its own count of renders, so the sequence a temporal pass expects is the one each view's renders sampled.

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
afterDepthPrepassthe visibility raster written: @scene.depth holds this frame's complete opaque depth, and @scene.motion its opaque motion, before anything shades. Scene colour is not yet drawn; @scene.colorHistory holds the previous frame's lit colour, and the deferred g-buffer the previous frame's surfaces (readsPrevious)
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.shaded, …) resolve only on the deferred path. Before the pass that shades has drawn — at afterDepthPrepass — a g-buffer channel holds the previous frame's surfaces, and a pass reading it there names the slot in readsPrevious (readsPrevious = { "scene_normal" }); one that does not is held back rather than handed a stale normal. @scene.colorHistory is the lit colour the target's previous render ended with, kept by the engine per render target and never cleared by a frame: what a pass tracing at afterDepthPrepass, against this frame's depth, takes its light from — reprojected through the previous view-projection @frame.camera carries. A capture drawn once, or a camera drawn again after a pause, is drawn lit first when a pass reads the history, so its first frame holds one.

Every @scene.* channel, what it holds, its format, the phase it is first valid from, and the earlier spellings still bound to it:

NameHoldsFormatValid fromAliases
@scene.colorthe scene's lit colour; the display composite at a post-scene phasethe scene target's format at a scene phase, the display composite's at a post oneevery phase—
@scene.depthopaque depth, reversed (1.0 near, 0.0 far/empty sky)Depth32Float — binds through a texture_depth compute slot, not texture2dafterDepthPrepass on—
@scene.motionscreen-space NDC velocity since the previous frameRg16FloatafterOpaque on—
@scene.normalworld-space normal + the per-pixel surface-flag fieldRgb10a2Unormdeferred path, afterOpaque on (previous frame's before — declare readsPrevious)—
@scene.shadedwhat a surface SENT — its shaded colour, linear and scene-referredRgba16Floatdeferred path, afterOpaque on (previous frame's before)@scene.albedo
@scene.materialwhat a surface IS — base colour (RGB565) + metallic + perceptual roughnessRgba8Uintdeferred path, afterOpaque on (previous frame's before)@scene.mr
@scene.emissiveradiance a pass stamped on the surface, added at the compositeRgba16Floatdeferred path, afterOpaque on (previous frame's before)—
@scene.aoscene occlusion a feature wrote, read back by shadingRgba8Unormevery scene phase (cleared at the top of the scene render)—
@scene.reflectiontraced reflection radiance (.rgb) + confidence (.a)Rgba16Floatevery scene phase (cleared at the top of the scene render)—
@scene.colorHistorythe lit colour the target's previous render ended withRgba16Floatevery phase—
@scene.lightsthe punctual-light storage buffer: 64 light entries of twelve vec4s each (the light, then its punctual shadow record), followed by the virtual shadow map's region. A loop runs over the first @frame.camera[4].w entries, the lights in use; the buffer's length counts the shadow region too, so arrayLength does not size a light loopstorage bufferevery phase—
@scene.environmentthe reflection-probe cube arraytexture cube arrayscene phases only—
@scene.environmentSamplerthe filtering sampler for @scene.environmentsamplerscene phases only—
@scene.reflectionProbesthe environment buffer: the probe blend data naming which probe reaches a point, then the irradiance light-probe volumesstorage bufferscene phases only—

docs/topics/rendering.guide/guide.md and docs/topics/raytracing.guide/guide.md both point here rather than repeating this table.

Two g-buffer channels describe the surface, and they answer different questions. @scene.shaded is what a surface SENT — the colour the geometry pass shaded it to, linear and scene-referred, so a surface lit above the display's peak reads above 1 there. @scene.material is what the surface IS: the base colour it reflects, its metallic and its perceptual roughness, each in [0, 1] and each independent of how brightly the surface happens to be lit. A pass that multiplies incoming light by a reflectance — a bounce multiplier, a Fresnel tint, an ambient floor — reads @scene.material; a pass that filters the picture reads @scene.shaded.

@scene.material is an Rgba8Uint channel packing all three into 32 bits, so its slot declares sample: uint and the module unpacks it:

# bindings.yaml
  - { name: scene_material, kind: texture2d, sample: uint }
#include "@builtin::shaderModules.gbuffer_material"

let m = zero_gbuffer_material(scene_material, coord);
let bounce = incoming * m.base_color;

The earlier spellings still bind: @scene.albedo resolves to @scene.shaded (the SHADED lit colour) and @scene.mr resolves to @scene.material (the material channel), so a feature written against either keeps resolving. The engine logs the substitution once per spelling, naming the canonical one to write going forward.

@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 visibility raster writes it for every surface it records, and an opaque surface whose depth state the raster cannot express writes its own where it draws. 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. 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: 10×vec4<f32> = the inverse view-projection (columns [0..4), column-major) + camera world position ([4].xyz) + the previous render's view-projection (columns [5..9)), which projects a point into the picture that render left. 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.

The environment

A compute pass reads the scene's captured surroundings — the reflection-probe cube array, its sampler and the environment buffer (the probe blend data, then the irradiance light-probe volumes) — through three reserved inputs, bound to bindings of the shapes group(0) binds them in:

bindings:
  - { name: environment_map, kind: textureCubeArray }
  - { name: environment_sampler, kind: sampler }
  - { name: zero_environment, kind: buffer, access: read, element: ZeroEnvironment, array: false }
inputs = {
    environment_map = "@scene.environment",
    environment_sampler = "@scene.environmentSampler",
    zero_environment = "@scene.reflectionProbes",
}

Named like that, #include "zero::environment" in the shader resolves the environment at a world point along a direction — zpbr_sample_environment(world_pos, dir, lod), the probes whose radius reaches the point blended by rank and proximity, each through its proxy volume, and the sky under them — exactly as a material's environment term does, so a reflection a pass draws and one a material draws agree about the room they are in. The three bind at the scene phases; a post or UI phase carries none. A textureCubeArray binding takes @scene.environment alone, and a fragment pass has no slot of that shape.

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.shaded / @scene.normal / @scene.emissive / @scene.color) or an RT guid — each with its own blend mode:

    ctx.enqueue {
        kind    = "fragment",
        shader  = myShaderKey,
        outputs = {
            { target = "@scene.shaded",   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.

    A fragment shader returns vec4<f32>, so the channels it can target are the float ones. @scene.material (and its earlier spelling @scene.mr) is Rgba8Uint, which binds as uint — naming it as an output is refused by name, with the channel's format and class in the log, and the rest of the frame draws. A fragment pass's inputs reads through the same fixed float slot, so naming @scene.material there is refused the same way; a compute pass reads that channel through a sample: uint slot instead.

    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 / @scene.reflectionProbes), 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).

In the Inspector

Selecting a render feature opens its Feature section: whether the feature runs in this session, an Enable or Disable action, and its settings as typed controls. While it runs, the controls hold its live values and an edit reconfigures the running feature (renderer.feature.configure checks it against the schema first). Those edits are session state: they change the running feature only, they are not undoable (Ctrl+Z never reaches them), and Disable or a restart ends them; the feature's declared defaults are what the next Enable starts from. While it does not run, the controls show the declared defaults, read-only, and a write reaching it then is refused. A feature enabled, disabled or reconfigured from a script shows in the section as it happens. A feature that declares no settings says so.

  • asset-type
  • reference