renderFeature
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:
| 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. |
ctx.settings | The 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:
| 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.
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:
| field | holds |
|---|---|
x | 1 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 |
y | how many renders in a row have carried the history |
zw | this 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:
| Phase | Scene colour there |
|---|---|
afterDepthPrepass | the 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) |
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.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:
| Name | Holds | Format | Valid from | Aliases |
|---|---|---|---|---|
@scene.color | the scene's lit colour; the display composite at a post-scene phase | the scene target's format at a scene phase, the display composite's at a post one | every phase | — |
@scene.depth | opaque depth, reversed (1.0 near, 0.0 far/empty sky) | Depth32Float — binds through a texture_depth compute slot, not texture2d | afterDepthPrepass on | — |
@scene.motion | screen-space NDC velocity since the previous frame | Rg16Float | afterOpaque on | — |
@scene.normal | world-space normal + the per-pixel surface-flag field | Rgb10a2Unorm | deferred path, afterOpaque on (previous frame's before — declare readsPrevious) | — |
@scene.shaded | what a surface SENT — its shaded colour, linear and scene-referred | Rgba16Float | deferred path, afterOpaque on (previous frame's before) | @scene.albedo |
@scene.material | what a surface IS — base colour (RGB565) + metallic + perceptual roughness | Rgba8Uint | deferred path, afterOpaque on (previous frame's before) | @scene.mr |
@scene.emissive | radiance a pass stamped on the surface, added at the composite | Rgba16Float | deferred path, afterOpaque on (previous frame's before) | — |
@scene.ao | scene occlusion a feature wrote, read back by shading | Rgba8Unorm | every scene phase (cleared at the top of the scene render) | — |
@scene.reflection | traced reflection radiance (.rgb) + confidence (.a) | Rgba16Float | every scene phase (cleared at the top of the scene render) | — |
@scene.colorHistory | the lit colour the target's previous render ended with | Rgba16Float | every phase | — |
@scene.lights | the 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 loop | storage buffer | every phase | — |
@scene.environment | the reflection-probe cube array | texture cube array | scene phases only | — |
@scene.environmentSampler | the filtering sampler for @scene.environment | sampler | scene phases only | — |
@scene.reflectionProbes | the environment buffer: the probe blend data naming which probe reaches a point, then the irradiance light-probe volumes | storage buffer | scene 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
capturetool'ssource = "position"/"entity"therefore run the pass queue too; using@frame.camerais 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 (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.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 = trueremains 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) isRgba8Uint, which binds asuint— 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'sinputsreads through the same fixed float slot, so naming@scene.materialthere is refused the same way; a compute pass reads that channel through asample: uintslot instead.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 /
@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)(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).
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.