Rendering
Lighting
The lighting toolbox is the scene's light environment — sun, ambient, sky, and point/spot lights, each with modify-or-spawn semantics. Reach for its tools when you are setting a scene up; a component or runtime script that adjusts lighting each frame uses the modules.api.engine.lighting capability directly, with the same options.
- setSun — aims the sun and sets its colour and intensity.
- setAmbient — the ambient fill.
- setSky — a preset word, a material, or
"none". - addLight / setLight / removeLight — a named point or spot light, by
position and intensity.
addLightmodifies one that already exists under that name rather than stacking a second. - setup — sun, ambient, sky and clear colour in one call.
- get — the full lighting and sky state, in the shape setup takes back.
A component or runtime script that adjusts lighting each frame reaches the same light environment through the modules.api.engine.lighting capability, with no tool call. Setting and reading are separate calls there: a setter takes the toolbox's opts and answers with the entity id the light resolved to, and a reader takes no arguments and answers from the resolved lighting state. Both find the sun and the ambient through the light the renderer resolved, so a per-frame call is priced by the scene's lights rather than by everything else standing in it.
local lighting = require("@builtin::modules.api.engine.lighting")
-- writes — the same opts the tools above take:
lighting.setSun({ direction = { -0.5, -1, -0.3 }, intensity = 3, color = { 1, 0.95, 0.8 } })
lighting.setAmbient({ intensity = 0.2 })
-- sun, ambient, sky and clearColor in one call — only the sections passed change:
lighting.applySetup({ sun = { intensity = 3 }, ambient = { intensity = 0.2 }, clearColor = { 0.02, 0.02, 0.04 } })
-- reads — what the frame is shaded by:
local s = lighting.sun() -- direction, color, intensity, castsShadows, entityId
local a = lighting.ambient() -- color, intensity, entityId
local rows = lighting.lightRows() -- every punctual light the renderer resolved
lighting.sun() reads and lighting.setSun(opts) writes, so an opts table handed to the reader is refused with the name of the call that applies it. The name-based lookup, the sky presets and the sky-material orchestration stay in the toolbox — the capability resolves each light by its component.
Lighting is real: with every source removed the scene goes dark. Lights also exist as a component (entity.component.add("Light", { intensity = 5 })) for ones that move with an entity — the lighting surface is the scene-environment one, the Light component is the per-entity one.
Point and spot lights share one intensity scale. They take a row of the same light buffer and are shaded by the same term — color * intensity * falloff — with the spot's cone as one further factor that is 1 inside its inner angle. So the same number at the same radius puts the same light on a surface either kind faces from the same place, and addLight hands intensity to both components unchanged. What the cone decides is how much of the room the light reaches: a spot lights the solid angle it opens on and leaves the rest of the room to whatever else is lit, so a frame-wide or room-wide average of a spot reads far below the same average of a point light carrying the same number. Balance a mixed rig by the brightness of the surfaces each light is aimed at, and read a spot on the surface its axis lands on. A spot's angle and innerAngle are HALF-angles from that axis, so angle = 30 opens a 60-degree cone; widening the cone or raising range spreads the same light over more of the room, where raising intensity makes the spot's own pool brighter. The sun, a directional light and a distant light read on a scale of their own: a parallel light arrives from the same direction at every point in the world and no distance attenuates it, so the same number lands far more light on a surface than a point or spot carrying it across a room does. Balance those against the sun and against each other.
The sun is a single field: one direction, one colour, and the cascade shadow set. Setting it aims that light, so a second Light { kind = "directional" } moves the sun rather than joining it. A scene that wants more than one — a binary star, a moon opposite the sun, a cold fill from the far side of a set — adds DirectionalLight components, which are rows of the scene's light buffer beside its point, spot and area lights:
entity(moon).component.add("DirectionalLight", {
direction = { 0.3, -0.5, 0.4 }, -- the direction the light TRAVELS
color = { 0.7, 0.8, 1.0 },
intensity = 0.6,
})
They shade through the same BRDF the sun does, so one at the sun's direction, colour and intensity renders the frame the sun would have. The cascade shadow set, the sky and the image-based lighting all follow the sun, so a distant light reaches every surface facing it. A GI bake gathers one the way it gathers the sun — its mobility field says how much of it to bake. lighting.lightRows() reports each of them with kind = "distant", and the lighting toolbox's get tool lists them beside the scene's other lights.
The sky is an explicit component on an entity — ProceduralSky for the procedural day/night look, or Skybox for a material sky or kind = "none" to turn it off. A scene with no sky entity renders the engine's fallback sky and carries a missing-sky warning; the lighting toolbox's setSky tool creates (or reconfigures) the scene's sky entity and clears the warning.
Global illumination — baked lightmaps & light probes
The lights above are direct light. Indirect light (bounce, color bleed, soft ambient) comes from the @builtin::systems.globalIllumination system, which path-traces global illumination offline so scenes get realistic multi-bounce lighting at no per-frame cost. The Model.mobility field ("auto" | "static" | "movable") decides how each entity participates: static geometry receives lightmaps and occludes baked light, movable geometry (and every player) samples probe volumes. "auto" derives the answer from the entity's components (physics bodies, skinned meshes, animation, and movers all make it movable); set the field explicitly to override.
Lights carry their own mobility ("auto" | "static" | "mixed" | "dynamic") answering a different question — how much of the light the bake takes. "mixed" is what "auto" picks: the bounce bakes, the direct light and shadows stay live, so a light still shines on whatever walks under it. "static" bakes the light whole and stops rendering it, which is the per-frame saving and the promise that nothing will move there.
Baking is a toolbox, so it answers on every tool surface. From the shell, zero baking lists the verbs and zero baking <verb> --help gives one verb's options:
zero baking all # lightmaps + auto-placed probe volumes + reflections
zero baking lightmaps # just the static-surface lightmaps
zero baking probes # just the irradiance probe volumes
zero baking detect # report what is baked vs stale vs missing
zero baking clear # remove baked lighting across a scope
The same verbs are the baking toolbox's tools, all among them, each taking an args table for its options (scope, resolution, samples, ...). Reach for them from code when what you are writing needs to bake, such as a panel offering a Bake button to a person.
Movable entities pick up the baked probes automatically: each baked volume publishes into the renderer's irradiance-volume set, and every standard-PBR fragment inside its bounds samples it — no per-entity setup. The full system (bake settings, UV requirements, mobility, staying current, the overlays) has its own guide: guides { path = "systems/globalIllumination/documentation/baked-lighting" }. ReflectionProbe (per-entity specular reflections) is a sibling component.
Baked light in your own shader
A bake writes nothing to the material. Every receiver keeps the material it was authored with; the bake puts its texels in a region of one shared atlas and records where in the receiver's per-instance shader-data lanes. That means a surface() shader, or any shader that #include "@builtin::shaderModules.pbr_shading", already supports baked lightmaps — you write nothing. The engine resolves the lanes and folds the irradiance into indirect light for you, and two instances of the SAME material resolve independently, so baked and unbaked geometry can share a material.
You only do this by hand in a self-shading fragment() shader that computes its own lighting (no PBR module). Read the two lanes and sample the atlas:
// lane 0 = (scale.u, scale.v, offset.u, offset.v) — this instance's rect in the page
// lane 1 = (layer, enabled, -, -)
let params = input.shader_data[1];
var baked = vec3<f32>(0.0);
if params.y > 0.5 {
let rect = input.shader_data[0];
let uv = input.lightmap_uv * rect.xy + rect.zw;
baked = zero_feature_texture(uv, u32(params.x)).rgb;
}
input.lightmap_uv is the mesh's unique, atlas-packed uv1 — the bake's own UV set, not the texturing uv. The sample is irradiance (incoming diffuse light), so a surface turns it into outgoing light by multiplying by its own diffuse albedo: baked * albedo. Add that where your shader would otherwise apply ambient — a baked value REPLACES flat ambient and probe-volume irradiance rather than adding to them, since all three approximate the same quantity and summing double-counts the bake.
Gate on params.y and never on "is the texture bound": the atlas is always bound and always valid (black until something bakes), and the enable flag is what varies per instance.
Post-process
Full-screen effects run after the scene renders — bloom, tonemap, colour grading, your own. Each is a post-process .shader asset (the shaders guide) that runs once registered as a named pass:
postprocess.add("bloom", asset.resolve("@builtin::shaders.post.bloom", "shader"), opts)
postprocess.setEnabled("bloom", true)
postprocess.setProperty("bloom", "intensity", 0.6)
postprocess.list() -- also setTexture / setSampler / remove
add is what puts the pass into the frame; authoring the shader asset defines the effect without rendering it. Editing a registered shader's body recompiles its pass in place, keeping the pass's enabled state, priority, layer and tuned properties.
opts.layer picks which composited image the pass grades. "scene" runs it before the UI is drawn, so it grades the rendered picture and leaves every widget on screen as authored; "all" — the default — runs it after the UI has landed, so the interface is graded along with the picture. A grade that belongs to the world's look wants "scene", since on a world several authors share, an "all" pass grades everyone else's screens too. tools.use("pp", "add", ...) takes the same key and forwards it.
postprocess.status() is the chain as the renderer holds it — per effect: enabled, priority, layer, the shader's compile error, the texture each declared slot is bound to (textures), and the bindings whose texture has not reached the GPU yet (pendingTextures). A texture created in the same step as the setTexture that names it lands there first and moves into textures on the frame the upload completes.
postprocess.describe(name) is one effect of that chain read in full: the same record, plus properties — the schema the effect declared, entry by entry — and values, what each of those properties holds right now (the value the last setProperty wrote, or the schema default where nothing has written one). setProperty answers whether the uniform took the value; describe answers what the effect holds, which is the read-back a pass driving its properties every frame needs and the one that tells a mistyped property name from a pass that is not grading. Before an effect is registered, the same schema is on the .shader asset it will render: asset.resolve("@builtin::shaders.post.bloom", "shader"):getProperties().
The chain reaches an offscreen capture, at that camera's own matrices
A registered effect runs over the live viewport's frame and over every offscreen
one alike — capture(source = "position"), capture(source = "entity"), a
capture of a named camera, a render-to-texture camera. In each of those the
effect's engine.view_proj / engine.prev_view_proj / engine.inv_view_proj
are the camera THAT render was drawn from and engine.resolution is that
target's own size, so a pass reconstructing world space from
zero_scene_depth(uv) reconstructs against the station and lens the capture
asked for. An offscreen capture is therefore an oracle for an authored grade:
the frame it returns is the look as it stands at a chosen station, and driving
the on-screen camera somewhere else does not move it.
An offscreen render keeps no view history of its own, so engine.prev_view_proj
there holds that same matrix rather than the frame before it, and a pass taking
camera motion from the two reads none. On the viewport the pair is a frame
apart, so camera motion is the one reading a capture answers differently from
the screen.
postProcessing = false is the one control that takes the chain off a capture,
and it also takes off the film a render feature draws — the flare and grain, a
short focus's bokeh, the shutter's smear — so a graded frame and an ungraded one
are different pictures. A render feature's passes otherwise reach an offscreen
render the same way the chain does, against the same camera; the renderFeature
assetType README carries that half.
The pp toolbox's add tool is the shorter route to the shipped ones — bloom, color_correction, colorGrade, fog, grayscale, invert, lut, sepia, tonemap, vignette; the toolbox's presets tool lists them.
Colour grading with a lookup table
A look is normally delivered as a table rather than as a chain of operations: for an N-entry colour cube, the answer to "what does this colour become" is stored for N³ inputs and everything between them is read by trilinear interpolation. A grade of any complexity then costs the same eight texel reads, and it is what a colourist's .cube export contains.
local lut = require("@builtin::modules.lut")
local ref = lut.install("warm_evening", lut.fromGrade({
exposure = 0.3, temperature = 0.25, saturation = 1.1,
}))
-- the `pp` toolbox's `add` tool, with the preset name and its options:
-- preset "lut", { texture = ref.guid, amount = 1.0 }
lut.fromCube(text) imports what a grading tool exported, lut.fromImage(bytes) a strip PNG, lut.build(size, fn) bakes an arbitrary transform, and lut.sample(table, r, g, b) reads a table back the way the shader does, so a look can be checked without rendering a frame. amount cross-fades between the ungraded frame and the table's answer.
The cube is stored as a strip: N square tiles in one row, N*N wide by N tall, tile b holding the plane of constant blue. The shader reads N from the image's own height, so one effect serves a 16-, 32- or 64-entry cube; a texture that is not shaped like a strip leaves the frame untouched, which is what an unconfigured lut effect does. Where the effect sits in the chain decides which colours the table is indexed by — after the tonemap for a display-referred look, before it for scene-referred colour.
Motion blur — the camera's shutter
A frame is not an instant. motionBlur holds the shutter open for part of the frame and reconstructs the path everything took in that time out of the velocity buffer the renderer already writes:
local motionBlur = require("@builtin::systems.motionBlur.motionBlur")
motionBlur.set({ shutterAngle = 180, samples = 16 }) -- 180 degrees is the film convention
motionBlur.get() -- also active() / clear()
The same settings are authorable on an entity as the MotionBlur component. shutterAngle is the physical control — motion vectors span one frame, so a shutter open for half of it smears half the per-frame travel, and doubling the frame rate halves the blur on its own. maxRadius is the longest smear the filter will carry, in pixels, whatever the shutter asks for; it defaults to 32 and cannot be set past it, because 32 px is the tile size the neighbourhood search covers. A surface the velocity buffer leaves still receives the smear of whatever moved beside it: the filter reduces velocity to tiles and then to each tile's 3x3 neighbourhood, which is what carries a direction to pixels that have none of their own and lets a moving object blur past its own silhouette.
The velocity buffer, and how far a moving surface reaches in it
Every temporal technique reads one buffer: the velocity buffer, which carries each pixel's screen-space travel between the previous frame and this one. Motion blur, temporal antialiasing, temporal upsampling and a denoiser's history term all sample it one texel per pixel, and capture { pass = "motion_vectors" } draws it.
A texel of it comes from whichever pass drew that pixel — a material's own fragment, the splat pass, or a render feature that names @scene.motion in its outputs — and the capture is taken at the end of the scene, once the scene's passes and its afterTransparent / afterLighting feature phases have written it. So it shows the field those readers will see rather than one contributor to it; a feature that writes @scene.motion at one of the post phases (afterScenePost, afterPost, afterUI) writes it after the picture was taken.
A camera holding the channel on a render target of its own draws the buffer over that target every frame its scene renders. A frame where the scene has nothing to draw is skipped whole, so the target keeps the last field drawn into it until the scene draws again.
A texel of it describes the surface drawn on it, and nothing else. At a moving object's edge that is a frame behind the truth: the background pixels the object is about to cover still report the background's velocity, so a reprojection reads history from where nothing moved and a blur stops dead at the silhouette.
velocityDilation changes what a texel answers. Above a radius of 0 each texel takes the velocity of the closest surface within that many texels, so a nearer moving object wins over the background it is passing in front of and its velocity reaches that far out past its own edge:
local velocityDilation = require("@builtin::systems.velocityDilation.velocityDilation")
velocityDilation.set({ radius = 2 }) -- velocity reaches 2 px past a silhouette
velocityDilation.get() -- also active() / clear() / maxRadius()
The same setting is authorable on an entity as the VelocityDilation component. It is off until something sets a radius, and every reader picks the wider field up with no change at its own call site. The rewrite happens after the last pass that writes velocity, so it costs two passes and one screen-sized target while armed, and (2r+1)² texture loads per pixel — which is what bounds the radius. Dilation is the per-pixel half of the same idea motionBlur carries at 32-pixel tile granularity; the two compose, and dilation is what a reader with no neighbourhood search of its own gets.
Morph targets — a mesh blending toward other shapes
A mesh can carry morph targets: per-vertex position and normal deltas that
describe a shape it can blend toward. An entity carries how strongly each is
blended, as ecs.MorphWeights, and the vertex stage draws the base shape plus
the weighted sum of the targets. Weight i drives target i, in the order the
mesh declares them.
local mesh = renderer.mesh.create({
positions = P, indices = I, normals = N,
morphTargets = {
{ name = "smile", positions = smile },
{ name = "frown", positions = frown },
},
})
ecs.insertSync(face, ecs.MorphWeights { weights = { 0.8, 0.0 } })
A target carries the name it was authored under, and an imported model keeps the
one its source file gave — an FBX blend-shape channel, a glTF target name. That
is what makes a shape addressable as itself: an ordinal is a property of import
order, so it moves when the model is re-exported with a shape inserted, while
Eyes_Blink stays Eyes_Blink. renderer.mesh.morphTargets reads the names in
target order, and renderer.mesh.morphWeights turns weights named by shape into
the ordered array the component takes:
for i, name in renderer.mesh.morphTargets(meshRef) do print(i, name) end
ecs.set(face, ecs.MorphWeights {
weights = renderer.mesh.morphWeights(meshRef, { Eyes_Blink = 1, Mouth_Smile = 0.4 }),
})
Every target the mesh carries gets a slot; the shapes left out sit at 0. A name the mesh does not carry errors listing the ones it does, so a mistyped viseme says so instead of quietly moving nothing.
A blend costs one entry per target its weights move — the mesh's own target count is what it may address, and how many of those move at once is what the frame pays for. So a facial rig carrying dozens of shapes costs the few it is holding, and a mesh may declare as many targets as its delta block fits in memory.
local m = renderer.morphStats()
print(("%d slots carrying %d blends over %d meshes (%d targets, %.1f KiB)")
:format(m.instances, m.blends, m.meshes, m.targets, m.deltaBytes / 1024))
instances is how many render slots the stage deformed and blends how many
single-target blends they carried between them — a mesh with 40 targets whose
weights move 3 of them contributes 3. meshes / targets / deltaBytes are
the residency of the delta blocks themselves, which a mesh pays for once
however many entities blend it.
The deformation reaches the colour passes, the opaque depth pre-pass, every shadow view and the velocity pass, so a morphed surface lays the depth of its morphed shape, casts the shadow of it, and reports the motion its own change of shape made. The box the instance is tested against carries the blend as well: the mesh's stored bounds grown, per target, by the extreme its weighted delta reaches, so a blend that carries a surface into the frustum keeps it in the frame and the same instance at weight zero is rejected against the stored shape.
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).
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.albedo", 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 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.
Material graphs — a material as nodes
A surface shader is WGSL. @builtin::systems.materialGraph.materialGraph lets a
material be written as nodes and the links between them instead, and compiles it
to the same two files a hand-written .shader asset holds — the material a graph
produces is an ordinary material, drawn by an ordinary program.
local materialGraph = require("@builtin::systems.materialGraph.materialGraph")
local material = materialGraph.material({
name = "rusty_metal",
properties = {
{ name = "tint", type = "color", default = { 0.8, 0.4, 0.2, 1 } },
{ name = "wear", type = "range", default = 0.5, min = 0, max = 1 },
{ name = "albedo_map", type = "texture", default = "white" },
},
nodes = {
base = { op = "sampleTexture", texture = "albedo_map" },
tint = { op = "property", property = "tint" },
mixed = { op = "multiply", a = { node = "base", swizzle = "rgb" }, b = { node = "tint", swizzle = "rgb" } },
wear = { op = "property", property = "wear" },
},
surface = { albedo = "mixed", roughness = "wear", metallic = 1 },
}, { name = "rusty" })
entity.spawn("pillar").component.add("Model", { model = "cube", material = material })
A port takes a number, an array of 2-4 numbers, the name of another node,
{ node = "<name>", swizzle = "rgb" }, or a node written inline where it is
used. A node's type — float, vec2, vec3, vec4 — comes from its inputs: in
an arithmetic node the widest input decides the result and a float is broadened
to it, while two different vector widths are refused, naming the node.
compile(graph) is the same work with nothing installed — it returns the
shader.wgsl and properties.yaml text, the nodes it emitted, and the nodes the
terminal could not reach. check(graph) hands an error back instead of raising.
nodeTypes() lists every operation with the inputs a node of it is written with
and which of those it cannot be written without; surfaceChannels() lists what
the surface terminal accepts. The package's own readme has the full reference.
Calling material again with the same material name is how a graph is iterated
on: the shader is rewritten from the graph, the material is brought onto that
shader's properties, and opts.values is applied over it. The shader itself
compiles at the next frame boundary, the way any shader edit does —
asset.ref(name, "shader"):compileStatus() reports that outcome.
Debug camera views: capture pass=<name>
A debug camera view is a diagnostic render that content publishes and a capture selects by name. capture(pass = "lightmap") draws the scene the way that view defines instead of the final lit image, so you read a value straight off each surface. The built-in debug passes (normal, depth, albedo, ...) are engine-owned; a content view is one you register yourself, and it draws through the same render-feature machinery above.
A view is a render feature gated to a debug channel. A live viewport camera carries channel 0, so a view's pass is invisible on screen and runs only when a capture selects it. An always-registered view therefore costs nothing: its pass is skipped every frame until someone captures it.
Register the name, and give it a feature whose pass carries the view's channel:
local VIEW = "lightmap"
-- Register the name so `capture pass=lightmap` resolves. `ensure` runs when the
-- view is first selected, so the drawing feature is created on demand.
renderer.captureView.register(VIEW, {
description = "Each surface's sampled baked irradiance.",
ensure = ensureFeature,
})
-- The feature draws the opaque scene with a material that emits the value you
-- want to read, gated to this view's channel so it runs only for a capture that
-- selected the view.
return {
render = function(ctx)
ctx.enqueue {
kind = "geometry",
material = viewMaterial, -- a surface material emitting the value
geometry = "@geometry.opaque",
phase = "afterLighting",
debugChannel = renderer.captureView.channelId(VIEW),
}
end,
}
The material is an ordinary surface material (the shaders guide): its fragment() returns the number you want to see rather than a lit colour. Because the pass is gated to a content-view channel, the engine passes that fragment output straight through instead of substituting a built-in debug colour.
Several views ship this way, and renderer.captureView.list() enumerates whatever is registered — each with a description of what it draws.
capture(pass = "lightmap") shows each surface's sampled baked irradiance, so "did the bake reach this surface" becomes a question you answer by looking instead of by reasoning.
A pass that is part of the film
A Camera's postProcessing says whether that camera runs the post-process
chain. Some render features draw what the chain draws — the camera's
photographic finish rather than the scene: the lens flare and film grain
lensFx composites, the bokeh depthOfField gathers, the shutter smear
motionBlur sweeps, the exposure a meter arrives at. A pass declares that, and
the camera property answers for it too:
ctx.enqueue {
kind = "fragment", inputs = { src = outRt.guid },
phase = "afterLighting", order = 406,
postProcessing = true, -- part of the camera's photographic finish
}
A camera whose postProcessing is off runs neither the chain nor that pass, so
capture { postProcessing = false } hands back the scene with the whole finish
off it — which is what makes an ungraded frame comparable against a graded one,
and what keeps one session's half-tuned lens out of another session's evidence.
A camera keeping its chain draws the pass, and every viewport keeps its chain by
default.
Declare it for what the CAMERA does to the picture. A feature drawing the scene
itself — ambient occlusion, reflections, global illumination, the atmosphere
between the camera and a surface — leaves it out, so an ungraded frame is still
the scene as it is lit. (clearAir is the option that reaches the air; the
render-textures guide covers it.)
capture(pass = "overdraw") shows how many fragments each pixel cost, counting every fragment the rasterizer produced including the ones the depth test later discards. Flat bands, so the count is read by naming a colour: black none, blue 1, cyan 2, green 3, yellow 4, orange 5-6, red 7-9, white 10+.
capture(pass = "zfighting") shows where two opaque surfaces own the same depth — dark grey for one surface, red for two, white for three or more. It is the view for a defect that has no still to look at: the depth buffer cannot separate coplanar surfaces, so which one wins flips per frame, and a capture shows whichever won, correctly shaded. The count comes from re-running the depth test the frame already resolved (lessEqual, writing no depth), so a pixel more than one surface reaches is a pixel that will shimmer in motion. Take it over a scene before calling the scene done.
capture(pass = "physics") shows the physics world as solid shaded colliders on black, coloured by what each one takes part in: blue static, orange dynamic, green kinematic, magenta and translucent for a sensor, with lightness separating the individual objects inside a family. The simulation collides with colliders rather than with what is drawn, and a collider that disagrees with its mesh is invisible in every other pass — so this is the view for something that behaves wrongly rather than looks wrongly. capture(pass = "physics_context") draws the same colliders over the scene's meshes in dim grey, which answers whether a collider sits where its model does.
Both are built on @builtin::systems.renderDiagnostics.countingView, which is the generic shape: count the fragments satisfying a depth predicate at each pixel, then colour the count. A diagnostic of your own is that call with a different predicate and a different zero_count_color.
A counting view fills, counts and reads back in three passes at afterLighting, and their order matters in a way worth knowing for any feature: afterLighting runs every geometry pass before any of its fragment passes, ordered by order within each kind. That is what lets a fragment pass sample what a geometry pass drew the same frame — and why a full-screen fill meant to go under a geometry pass has to be a geometry pass too. It is also the phase that draws geometry, draw and splat passes at all: one of those enqueued at another phase never runs.
What a pass reads, and when it exists
A pass declares what it reads (inputs) and what it writes (output /
outputs / storage), and the frame runs the declarations in that order. So a
pass whose input is a render target the same frame produces later samples the
target as the frame found it — the previous frame's contents for a target that
persists, an empty target for one just created — and renders anyway, at full
cost, over data nobody put there this frame.
renderer.passSchedule() reads the frame's declarations back and names those
reads:
for _, v in renderer.passSchedule().violations do
print(v.message)
end
-- pass 'blur_h' at afterLighting/order 40 reads render target
-- '4f2c…' on slot 'src', which is written by pass 'bright_pass' at
-- afterLighting/order 90 — later in the same frame …
Each finding also goes to the engine log the first time it appears, so a mis-ordered feature says so without anyone asking.
The frame's own buffers are read on the same terms. Bind @scene.motion,
@scene.color or any of their siblings on a pass that runs ahead of the one
writing that buffer and the read is reported the same way, naming the buffer —
what it samples there is the scene draw's own output rather than the previous
frame's, and the pass that was going to contribute to it has not run yet.
A history buffer, a temporal accumulation and the far half of a ping-pong all read a target one frame late on purpose. Declare the slot and the check leaves it alone:
ctx.enqueue {
kind = "compute", program = accumulate,
inputs = { history = historyRt.guid },
storage = { out = historyRt.guid },
readsPrevious = { "history" },
phase = "afterLighting", order = 200,
}
The declaration is per slot, so the pass's other inputs stay checked; a slot
named there that the pass binds no such resource to is reported in
unboundPrevious, since a renamed slot would otherwise quietly widen what the
declaration excuses. A resource no queued pass writes is never reported — a
camera rendering to texture and compute.dispatch both fill targets outside the
pass queue, and the scene draw fills the @scene.* buffers every frame.
A read the frame has only one order for is not reported either. Where the pass
that writes the resource consumes something the reading pass produces — a
compute pass reading @scene.motion into a target of its own, and a copy
reading that target back over @scene.motion — the reader runs first or the
writer has nothing to write. No reordering hands that reader the writer's
output, so the chain reads clean without a declaration.
The third list, unreachable, holds passes at a phase that does not run their
kind. Every phase drains its fragment and compute passes, while the geometry,
draw and splat drains are afterLighting's alone — so a mask enqueued as a
geometry pass at afterOpaque sits in the queue and is never drawn, and
everything sampling it reads an empty target on every frame.
Asking where the drawn world is along a ray
renderer.raycast(origin, direction, maxDistance?, exclude?) reports the
nearest surface the renderer DRAWS along a ray, and renderer.raycastAll every
surface along it, nearest first. Both answer for a mesh whether or not anything
gave it a rigid body, so a terrain, a procedurally generated mesh, or any plain
Model reports the surface under a point.
The answer is the nearest triangle of the mesh, so a sloped or terraced surface reports its height where it was asked rather than the extent of its bounding box:
local hit = renderer.raycast({ x, 100, z }, { 0, -1, 0 }, 200)
if hit then
print(hit.entityId, hit.point.y, hit.normal.y, hit.distance)
end
A hit carries entityId, the world-space point, a unit normal turned to
face back along the ray, the distance from the origin, the meshGuid, and
exact — true where the answer came from a triangle, false where a mesh's
vertices live on the GPU alone and the object's bounds are the whole answer.
distance is measured in world units — the direction is normalised, at any
length you hand it in — so it is directly comparable to a physics.raycast
distance along the same ray. The triangles walked are the mesh's own, placed by
the entity's transform and by the mesh's bind pose, so a surface a skinning or
morph pass deforms on the GPU answers as the geometry the mesh holds.
Everything drawn is in scope — the ground you meant, and equally a character
standing on it, a prop, or a placeholder floor left in the scene. Read
entityId before trusting a height, step over what you did not mean with
exclude, or take the whole column with raycastAll and pick the surface
yourself:
-- the ground under a camera station, ignoring whoever is standing there
local hit = renderer.raycast({ x, 100, z }, { 0, -1, 0 }, 200, { playerId })
-- or: every surface in the column, choose the one you are after
for _, h in renderer.raycastAll({ x, 100, z }, { 0, -1, 0 }, 200) do
if h.entityId == groundId then print(h.point.y) end
end
physics.raycast answers from colliders instead, which is the query for what a
body would collide with; this one is the query for what a camera would see.
Scene-wide renderer settings
The global pipeline switches — shadows, deferred shading, order-independent transparency, culling — live in the world's .world_settings file under [render], not in a runtime API. The renderer reads the file once a frame, so an edit reaches the next frame. Edit them there (the engine/development guides cover world settings):
[render]
shadows = true
deferred = true
oit = true
culling_mode = "gpu"
anisotropy = 16 # texture filtering quality: 1, 2, 4, 8 or 16
point_shadow_budget_mb = 48 # VRAM the point-light cube-shadow atlas may hold
point_shadow_resolution = 512 # texels per cube face
spot_shadow_budget_mb = 32 # VRAM the spot/area shadow atlas may hold
spot_shadow_resolution = 1024 # texels per atlas layer — the sharpest one light gets
shadow_caster_min_radius_px = 0 # smallest on-screen caster that still casts
shadow_caster_max_distance = 0 # how far from the camera casters keep casting
max_frames_in_flight = 2 # frames of GPU work that may be outstanding
present_mode = "mailbox" # how a presented frame reaches the display
Shadowed point lights are bought by the megabyte. A point light with
castsShadows renders an omnidirectional cube map — six faces of depth — and
the atlas holding them is sized from point_shadow_budget_mb, not from a fixed
slot count. 48 MiB at 512² per face is eight simultaneously shadowed point
lights; a ninth caster is still lit but throws no shadow, and the engine log
says how many were turned away. The same budget is a runtime knob:
renderer.pointShadowBudget() --> { megabytes = 48, resolution = 512, slots = 8, bytes = ..., maxSlots = 16 }
renderer.setPointShadowBudget({ megabytes = 96 }) --> 16 what this budget buys
renderer.setPointShadowBudget({ resolution = 1024 })--> 4 sharper faces, fewer of them
The atlas is reallocated on the next frame, so slots reports the new pool one
frame after the call while the return value is immediate. Face resolution and
slot count trade against each other quadratically: doubling resolution
quarters how many lights the same budget shadows. Spot lights draw from their
own separate atlas, so a shadowed spot never costs a point light its cube.
A spot's shadow is sized for the screen it covers. The spot/area atlas is
spot_shadow_budget_mb of VRAM cut into square layers of
spot_shadow_resolution texels, and each frame every shadow-casting spot is
given the smallest tile of a layer that still resolves finer than the pixels
its light covers. A light filling the view takes a whole layer; one far enough
away to cover a few dozen pixels takes a sixteenth of one, and the layer it
leaves free goes to a light that can show it. Nothing is authored per light —
move the camera and the maps resize:
renderer.spotShadowBudget() --> { megabytes = 32, resolution = 1024, layers = 8, tiles = 128, minResolution = 256, slots = 64, bytes = ..., maxLayers = 32 }
renderer.setSpotShadowBudget({ resolution = 2048 })--> 2 sharper hero shadows, fewer layers
renderer.setSpotShadowBudget({ megabytes = 64 }) --> 16 more lights holding a large tile at once
slots is how many spot/area lights can cast at all — the atlas's own tiles,
or the 64 light rows a scene can hold, whichever runs out first — and tiles is
how many maps the atlas could hold if every one of them were small on screen.
The budget is what moves slots: at the shipped 32 MiB the atlas already holds
a tile for every light a scene can have, so the 64 light rows are what slots
reports there, and a smaller budget lowers it below that. Raising
resolution is what makes a close shadow sharper — it is the size of the map a
light gets when it takes a whole layer — while raising megabytes is what lets
several lights hold one at the same time. When more is asked for than the atlas
holds, every caster steps down together — one shared tile size for all of them,
and only what is left over is spent back on the largest askers — so a street of
equally demanding lights never splits into a sharp few and a coarse many. A
light that finds no tile at all is lit but throws no shadow, and the engine log
says how many were turned away and how many tiles the budget bought.
One cube slot is always granted. A budget that does not cover a single cube
still shadows one light, and then the atlas costs what that slot costs — not
what the budget said. { megabytes = 1, resolution = 4096 } allocates 384 MiB
for its one slot. Read pointShadowBudget().bytes (the same figure
shadowMemory().point reports) back after setting a budget on a
memory-constrained device rather than assuming megabytes was a ceiling.
A caster can stop casting before it stops drawing. Every shadowed light rasterizes a caster's whole triangle count into its own view, however little shadow that ends up covering — six times over for a shadowed point light. The caster cutoff is the two thresholds that say when it is not worth it, and both are measured against the camera the frame draws from rather than against each light, so one setting covers every cascade, spot and cube face:
renderer.shadowCasterCutoff() --> { minRadiusPx = 0, maxDistance = 0 }
renderer.setShadowCasterCutoff({ minRadiusPx = 2 }) -- drop casters under 2 px on screen
renderer.setShadowCasterCutoff({ maxDistance = 120 }) -- and casters past 120 units
minRadiusPx is the on-screen radius a caster must reach: an object the viewer
resolves a couple of pixels of casts a shadow of about that size, so dropping it
removes a depth pass for something nobody was going to read as a shadow.
maxDistance is measured to the near side of the caster's bounding sphere, so a
building keeps casting while any part of it is inside the range while the props
around it stop. Both are 0 — released — by default, which draws the casters the
frame drew before either was set, and setting one leaves the other alone.
The saving reads in renderer.drawStats().draws, which counts each shadow
view's draws alongside the camera's. Casters that stop casting keep drawing in
the camera's own passes, so the scene looks the same everywhere except in the
shadows the cutoff removed — walk the thresholds up until one of those shows.
A caster can rasterize cheaper geometry than it draws. A shadow is a silhouette laid on other surfaces at the resolution of a shadow map, so the triangles carrying a mesh's close-up detail write depth no reader can resolve. A shadow proxy is the cheaper stand-in it rasterizes there instead — a decimated version of the shape, a level of its own LOD chain, or a hand-built hull:
renderer.setShadowProxy(statueMesh, statueHullMesh)
local p = renderer.shadowProxies()
print(p.triangles, "shadow triangles, from", p.sourceTriangles)
The registration is keyed by mesh, so one call covers every instance of it —
entities and GPU-driven populations alike — and a crowd sharing that mesh stays
one draw. The proxy is placed by whatever places the caster, its instance's own
transforms, so it stands where the caster stands. Nothing else in the scene
draws a proxy, so the call is what brings it onto the GPU.
triangles and sourceTriangles are what the last frame's shadow passes
submitted and what those same draws would have cost from the source meshes, so
the pair is the before/after of every registration and they are equal while
nothing is proxied. They count what was rasterized, so a frame that drew no
shadow view at all — every one served from the depth it already held — reads 0
for both.
A caster keeps its own geometry where a stand-in could not be placed or deformed
correctly: its entity is skinned (it rasterizes the post-skinned vertices written
for its own mesh), it blends morph targets (whose deltas describe its own mesh
and are read by vertex id), its proxy would be placed by a different node of its
model than the source mesh is, or the renderer holds no geometry under the
proxy's guid. Each of those is counted as skinned / morphed /
nodeMismatch / unresolved, so a
registration that is doing nothing says which of the four it is — read them the
way you read triangles, on a frame that drew a shadow view at all. On a
settled scene under shadow caching no view is drawn and every one of them is 0;
renderer.setShadowCaching(false) puts every view back on every frame, which is
also how the before/after triangle counts are read.
renderer.clearShadowProxy(mesh) puts one caster back on its own geometry, and
the bare call drops every registration.
How coarse a proxy can be is a property of the subject and the shadow map's resolution, and it is measurable: capture the shadow with the proxy released, capture it armed, and diff. A stand-in whose silhouette lands inside the map's own resolution changes nothing a viewer can see while removing most of the depth work.
One caster can have a shadow view of its own. A cascade covers the slab of world the camera sees, so its texels are spread over tens of metres and the character in the middle of it is resolved by a handful of them — which is why a distant subject's shadow reads as a smudge rather than as its silhouette. A hero view is the same light and the same depth range, zoomed onto one entity's world bounds:
renderer.setShadowHero(player.id)
local h = renderer.shadowHero()
print(("%.0f texels/unit, up from %.0f"):format(h.texelsPerUnit, h.cascadeTexelsPerUnit))
It renders beside the cascades, into a layer of the same texture allocated while
a hero is registered, and every surface inside it reads it in place of the
cascade, crossing back over a band at its edge. Nothing else about the shadow
changes: the same casters reach it, at the same depth range, through the same
filter — so the difference is resolution and only resolution, which is what makes
the before/after diffable. zoom is the factor its texel density gains,
worldExtent the world distance it covers across one axis of the map, and the
cascade* fields the same measures for the cascade it was fit from.
The second argument is padding, a multiple of the caster's bounds: room for a pose that leaves the bind-pose box and for the filter that samples just outside a silhouette. It costs density in proportion — 2.0 covers twice the world across the same map, so half the texels per unit.
A registered caster that gets no view says why in decline: notFound (no
renderable of it in the scene), noBounds, degenerate, outOfDepthRange (it
sits outside the depth range of the view it would be fit from), noGain (it
already fills that view, so a zoom would resolve nothing the cascade does not) or
noDirectionalShadow. The fit is over the renderables the entity itself carries,
so a subject assembled out of several entities is fit to whichever one is
registered. renderer.clearShadowHero() releases the caster and gives the layer
back.
A shadow map that nothing changed is not drawn again. Every shadow view — one directional cascade, one atlas layer of spot tiles, one face of a point light's cube — keeps the depth it already holds until something it draws from changes: its light moves, a caster it can see moves or appears or vanishes, the caster cutoff starts or stops dropping one of them, a shadow proxy is registered or cleared, the geometry a caster rasterizes is replaced under its own handle — its own mesh or the proxy standing in for it — a caster's material changes what it lets light through, a model's animation moves the nodes its parts are placed by, a skinned caster changes pose, or the map it writes into is reallocated by a budget change. A scene standing still therefore pays for its shadows on the frame that resolves them and on no frame after, and one where a single light moves pays for that light's views alone. The one case that cannot be read from the scene is a mesh whose vertices a compute pass writes — a population's per-instance transforms, or a mesh created straight from a compute buffer — so a view holding one of those re-renders every frame.
renderer.setShadowCaching(false) draws every view on every pass. A shadow
suspected of holding an image the scene has moved past is compared against that:
if the two agree, the map is current and the difference is elsewhere.
local s = renderer.shadowCacheStats()
print(s.rendered, "shadow views drawn this frame")
print(s.cached, "kept the depth they already had")
print(s.spotRendered, "of", s.spotRendered + s.spotCached, "spot atlas layers redrawn")
A cascade is fit around the camera, so moving the camera changes the cascade's
own matrix and redraws it — the saving is largest for the local lights, whose
views depend on the light and its surroundings rather than on where anyone is
looking. profiler.gpuFrame() shows the same thing in milliseconds: the
scene.shadow span is what stops being recorded on a still frame.
Which light is eating the shadow budget. The three readings above are totals
across every shadow view the frame drew. renderer.shadowViews() is the same
frame one view at a time: a row per directional cascade, per hero view, per
shadow-casting spot and per cube face of a shadow-casting point, each naming the
light that owns it and carrying what that view drew.
local report = renderer.shadowViews()
for _, view in report.views do
print(("%s %d — light %s, %d draws, %d instances, %s"):format(
view.role, view.index, view.light, view.draws, view.instances,
view.rendered and "drew" or "cached"))
end
Three things that took a destructive edit to measure before are reads here.
report.camera.submittedInstances is the main camera's own share of
renderer.drawStats().compactedDrawn, so the shadow views' share is the rest of
it rather than a number recovered by turning every light's shadow off and
looking again. Each row's span is the label its pass is timed under, so its GPU
time is a lookup in profiler.gpuFrame() — every one of them is a variant of
scene.shadow, which still carries their total, so the aggregate span
decomposes over the lights that made it. And castersTested / castersAdmitted
/ castersRejected / castersCutOff / castersNotCasting say what one view's
pass did with the frame's casters — admitted and drawn, outside the volume,
dropped by the shadow-caster cutoff, or held back by the object's own
Model.castsShadows — where renderer.cullStats() answers for the main camera
alone. A view serving the depth it already holds runs no pass, so its census
reads 0 the way its draws do.
A cascade row also carries the near and far of the split it covers and the
viewProj it rasterized with, which is the read side of
renderer.setShadowConfig — change cascades, distance or splitLambda and
the four ranges move with it.
The list is rebuilt every frame, so a view whose light stopped casting is gone
from the next report rather than standing at the numbers it last had, and a frame
that drew no shadow view answers a report whose views is empty. The frame names
its views only while something is reading them: the first call of a session arms
that and the frame after it answers, which the renderer.shadowViews() wrapper
waits out for you.
An object can stop drawing before it stops existing. The camera's own version of that threshold is a scene-wide screen-size cutoff: below it, an object is dropped from the frame's draws entirely rather than rasterized into the handful of pixels it covers.
renderer.setMinScreenSize(4) -- drop anything under 4 px of on-screen radius
renderer.minScreenSize() --> 4 what the camera draws down to now
renderer.setMinScreenSize(0) -- released: every object draws however small
It is measured from the object's own bounds against the camera's projection, so one threshold means the same apparent size at any distance or field of view, and it is released (0) by default. Walk it up until something you meant to keep disappears, then back off — a few pixels carry no detail a viewer resolves while still costing a full vertex pass and a submission.
The cutoff is answered on the GPU, alongside the frustum and occlusion tests,
and the objects that survive it are packed into draws whose instance count the
GPU decides. renderer.drawStats() reports that pair:
local d = renderer.drawStats()
print(d.compacted, "instances planned through compacted draws")
print(d.compactedDrawn, "of them the GPU asked for")
compacted is this frame's plan; compactedDrawn comes back from the buffer
the GPU wrote, so it describes a frame that has already finished and holds its
last value until another one arrives. In a scene standing still the gap between
the two is the work culling removed — arm the cutoff and watch compactedDrawn
fall while compacted does not.
What a frame spends on materials follows how many distinct materials it
draws with, not how many draws it submits. A material's parameters — its uniform
block, its textures, and any storage buffers its shader declares — are bound
state, so the batched opaque geometry is gathered into runs: the draws naming
one material are submitted next to each other, and the run costs one set of
binds however many draws it holds. renderer.drawStats() reports both halves:
local d = renderer.drawStats()
print(d.materialBinds, "material binds issued")
print(d.materialBindsElided, "draws spared the bind")
print(d.materialExtraBinds, d.materialExtraBindsElided) -- the shader's own storage group
Their sum is how many times a pass reached the bind decision — once per unit of
geometry it submitted, at or below draws, since a mesh of several primitives
draws once per primitive under one set of binds. materialBinds alone is what
the frame paid. A scene of many meshes over a few materials reads a small
materialBinds beside a large materialBindsElided; the two converging is a
scene whose draws each name a material of their own, which is the shape to look
for when a frame costs more than its geometry explains.
Only the batched opaque geometry is gathered into runs. Depth-sorted
transparents, skinned meshes and populations carry the same bound state through
the same pass but submit in their own order, so they are spared a bind only
where a material happens to repeat — a transparent-heavy or population-heavy
scene reads a low materialBindsElided for that reason rather than because
anything is wrong.
What a transparent crowd costs is how much of its depth order repeats. Blended geometry is submitted farthest-first, because that order is what makes the blend come out right, and a stretch of neighbours in it sharing a mesh, a material, a shader and a pose is submitted as ONE instanced draw over those neighbours — the same members, in the same order, out of a single submission.
renderer.setBlendedBatching(false) -- a draw per blended renderable
local before = renderer.drawStats().draws
renderer.setBlendedBatching(true) -- neighbours sharing a draw key together
local after = renderer.drawStats().draws
A run stops wherever a differently-drawn renderable sorts between two of its members, so a crowd interleaved with other transparent geometry batches into several runs rather than one, and a mesh of several primitives keeps a draw per renderable. Both would otherwise move fragments through each other, which is the one thing a back-to-front order exists to prevent. The image is identical either way — the switch is there so a frame suspected of being formed by the batching can be compared against one that is not.
What a skinned crowd costs is its distinct poses, not its head count. A
skinned instance is posed by a compute pass that writes its vertices into a
shared pool, and instances of one mesh whose joints hold the same pose are
posed once, into one slice of that pool that all of them draw from.
renderer.skinningStats() reports what the last frame's skinned instances
actually cost:
local s = renderer.skinningStats()
print(s.instances, "skinned instances") --> 21
print(s.poses, "distinct poses") --> 1 what they cost between them
print(s.dispatches, "skinning dispatches")--> 0 the pose arrived on an earlier frame
print(s.held, "poses read as they stood") --> 1
print(s.liveBytes, "of", s.unsharedBytes) --> 1351296 of 28377216
A pose is written once. The pass builds a slice out of the instance's joint
matrices, the node transforms its vertices address, its blend weight and its
blend model, so a slice already holding a pose holds exactly what running the
pass over the same inputs would write — and a frame that binds a pose whose
slice still holds it reads the slice and dispatches nothing. Skinning is paid
for by the poses that CHANGED: held counts the poses of the last frame that
cost nothing, dispatches the ones that were written, and the two add up to
poses.
local s = renderer.skinningStats()
print(s.dispatches, "poses written,", s.held, "read as they stood") --> 0 22
A cast standing in one pose — a paused clip, a graph holding a frame, a prop
wearing a skeleton nothing drives — costs compute the frame the pose arrives and
nothing after it, and the skinning span leaves profiler.gpuFrame() entirely
once the window it averages over has rolled past the last dispatch. Turning a
bone costs two frames of dispatch: the frame the new pose arrives, and the frame
after, whose pose is the same joints reached from the new ones rather than from
the old. renderer.setSkinningPoseHold(false) writes every pose a frame binds
instead, which is the comparison a frame suspected of reading a slice that no
longer holds its pose is made against; the image is the same either way.
A crowd moving together — one clip on one clock — reads one pose however many
members it has, so the pool holds a single character's worth of vertices for all
of them. Members at different animation times each hold their own pose and each
take a slice, which is what keeps them posed independently: an avatar spawned
into a crowd that has been idling reads as its own pose until the two clocks
line up again. liveBytes against unsharedBytes is the saving as it stands
this frame; poolBytes is what the pool holds, with a previous-position buffer
of the same size beside it feeding skinned motion vectors.
A pose no instance holds any more gives its slice back, and the next pose of the
same size is cut from it — so a crowd that changes pose every frame cycles
through the slices it already has rather than walking the pool forward.
reusedSlices counts the poses of the last frame that were handed one of those
slices instead of pool the engine had never used, which is what a scene in
motion reads while poolBytes stays where it was:
local s = renderer.skinningStats()
print(s.reusedSlices, "of", s.poses, "poses took a slice the pool already held")
Per-entity shader data — a per-instance property override, without a material each
A material per entity is what a dissolve, a hit flash or an effect at its own age
would otherwise cost: an authored material each or a runtime renderer.material.create
clone each, a separate pipeline binding and a separate row in the material table for
every one of them, and a setProperty call per entity per frame to drive it. The
per-instance channel is the way out: every drawn object carries a block of four
vec4 lanes of its own, a surface shader reads lane i as input.shader_data[i],
and the engine attaches no meaning to what is in them.
local DISSOLVE_LANE = 0
renderer.instanceData.laneCount() --> 4 lanes per entity
renderer.instanceData.set(subject, DISSOLVE_LANE, progress)
renderer.instanceData.clear(subject) -- back to zero lanes
fn fragment(input: FragmentData) -> vec4<f32> {
let progress = input.shader_data[0].x;
if progress > fract(sin(dot(input.uv, vec2<f32>(12.9898, 78.233))) * 43758.5) {
discard;
}
return material.base_color;
}
Twenty subjects sharing that one material each dissolve at their own rate, and a write reaches the frame after it. Reach for a material per entity when what differs is the LOOK — a different shader, texture or blend — and for a lane when what differs is a number the shader reads. A feature picks the lane indices it owns and names them where it writes them, so its writer and its shader read the block the same way — the baked-lightmap section above is that pattern in the engine's own code, holding lane 0 for the atlas rect and lane 1 for the layer and enable flag.
A particle emitter is one entity, so a lane it sets there is shared by every
particle it draws. What one PARTICLE carries of its own — its life, its own seed,
the size it is drawn at — arrives through
@builtin::systems.particles.particle_data, read as particle_sprite_data(input)
or particle_mesh_data(input) for the emitter kind the material draws.
A lane an entity was never given reads zero, so a shader tells "not set" from "set to zero" by keeping a flag in one of the components rather than by testing the value it wants.
Which material an instance is drawing with is a number, because a shader
cannot read a name. Every renderable's per-instance record carries a material
index, and renderer.materialIdentity() is the table those indices are drawn
from — assigned the first time the renderer draws with that material and stable
for the rest of the session.
local id = renderer.materialIdentity()
id.slots["@builtin::materials.plastic_red"] --> 4 the index that material took
id.renderables[1] --> { entity = "ent_…", slot = 4, index = 4, material = "…" }
id.populations[1] --> { slot = 96, count = 4096, index = 5, material = "…" }
renderer.materialIndex("@builtin::materials.plastic_red") --> 4
A surface shader reads its own instance's index as input.material_index, so a
shader drawn by several materials can branch on which one it is under, and a
compute pass that traces the scene resolves the same index through
zeroMaterial(). Two renderables that differ only in their material read
different indices; one renderable reads the same index frame after frame, and a
material swap moves it on the next frame. A GPU-driven population is one row in
populations rather than one per member: every slot of its reserved run carries
the single material renderer.mesh.drawInstanced was given, so a member reads
that index wherever the population moves.
A renderable draws with the material its entity references, so a renderable whose entity names none reads index 0. And because an index is an identity, it is never reassigned: the table keeps a row for every material name drawn since the engine started, whether or not anything still draws with it.
A population's members carry their own shader data. One material is what
keeps a population one draw, so what a member differs in has to arrive
per-instance. instanceDataBuffer is that channel: a GPU buffer holding one
block of four vec4 lanes per instance, which the renderer copies into those
instances' lanes every frame, and which a surface shader reads as
input.shader_data[lane] — the same lanes an entity gets from
renderer.instanceData.set, on slots no entity owns.
local lanes = substrate.createBuffer({ name = "crowd.lanes", type = "vec4", len = COUNT * 4, kind = "gpu" })
renderer.mesh.drawInstanced(mesh, {
transformBuffer = "crowd.xf",
instanceDataBuffer = lanes.name, -- the NAME, not the buffer
instanceCount = COUNT,
material = M,
})
The copy re-runs every frame, so a compute pass (or a :write) that rewrites
the buffer changes the next frame with no second registration. The buffer must
back the whole reservation — a short one is refused at the call rather than
letting the instances past its end read the next population's lanes. Omit it and
the lanes read zero, whatever the slots held before the registration took them.
Texture filtering is the one of these with a runtime API as well, because it is the setting worth scaling with a quality slider. anisotropy is how many samples a material texture is allowed to take along the axis of greatest compression — which is what keeps detail on a surface seen at a grazing angle. At 1 (plain trilinear) a road, a floor, or a long wall receding toward the horizon blurs to a flat average well before it gets there; at 16 the detail that is in the texture stays resolvable.
renderer.setAnisotropy(16) --> 16 the effective level, clamped to the device
renderer.anisotropy() --> 16 what material textures sample at now
renderer.maxAnisotropy() --> 16 what this device can do (16, or 1 without the feature)
setAnisotropy returns the level that was actually applied, so asking for more than the hardware offers reports the clamp rather than failing. It takes effect on the next frame for content already on screen — no reload, no re-upload. A level that is not a power of two in [1, 16] is an error rather than a silent round. The world setting seeds the level at boot and whenever the file changes; a runtime call overrides it until then.
Frame pacing is how far the CPU is allowed to run ahead of the GPU.
Submitting work returns before the GPU has done it, and everything a submission
holds — its staging allocations, its bind groups, its command buffer — stays
alive until it completes. A frame that asks for more work than the GPU finishes
in a frame's time therefore leaves that behind it, and unbounded that is memory
growth rather than a lower frame rate. max_frames_in_flight is the bound; the
runtime knob is the setting worth scaling with a latency slider.
renderer.framePacing() --> { framesInFlight = 2, maxFramesInFlight = 2, mechanism = "submission-wait", … }
renderer.setMaxFramesInFlight(1) --> 1 lowest latency, least CPU/GPU overlap
renderer.setPresentMode("mailbox")--> "mailbox"
mechanism names how the bound is enforced where the engine is running.
submission-wait waits for the frame that many frames back, so a paced frame
costs latency and still draws. submitted-work-done counts outstanding frames
off the queue's own completion signal and declines to start a frame while the
bound is met — the remedy on a platform that cannot block — counting those in
pacedFrames and leaving the last presented image up until the GPU catches up.
Either way framesInFlight is taken from the queue's completion signal and
stays at or under maxFramesInFlight, so it reads under the bound while the
device keeps up and at it while the device is behind — and the backlog a slow
frame can build is bounded by a number you set.
setPresentMode answers the canonical spelling of what you asked for; read
renderer.framePacing().presentMode for the mode the surface took. Each of the
two settings is seeded on its own, so editing one of them in the file leaves a
runtime override of the other in force.
present_mode decides how a finished frame reaches the display: fifo queues
every frame and shows it on a vertical blank (never tears, never drops one),
mailbox replaces the queued frame with the newest, immediate presents as
soon as the frame is ready and can tear, fifo_relaxed tears rather than stall
when a frame misses its blank, and auto_vsync / auto_no_vsync leave the
choice to the backend. The default is mailbox: a frame that runs past the
vblank budget then shows its real rate, where fifo would lock it to a divisor
of the refresh rate (a 7ms frame on a 144Hz display presents at a hard 72). A
surface that does not offer the mode presents fifo, so read
renderer.framePacing().presentMode for what took effect and .presentModes
for what the surface offers.
The pipeline cache is the driver's compiled code for the pipelines this renderer builds, kept across runs. A pipeline is machine code the GPU driver compiles from the shader bound into it, and every launch pays that compile before the first frame drawing with that pipeline can appear. The engine hands each build the store, and writes the store back once a burst of builds settles — and again as the engine closes, so a short run keeps what it compiled too. The next launch of an unchanged shader reads back what the last one compiled.
renderer.pipelineCache() --> { supported = true, pipelinesBuilt = 41, buildMs = 812.4, restoredBytes = 1_620_324, … }
pipelinesBuilt and buildMs are what this session built and what it cost —
the number the store lowers — and restoredBytes is what a previous run left
for this GPU. path names the file, which carries the GPU's identity, so a
machine with two adapters keeps two; the driver validates the blob it is handed
before trusting it, so one left by a driver that has since been updated is
rejected and that launch compiles from source. saves, savedBytes and
dirty describe writing it back — dirty stays true while the file is behind
what the driver holds, including after a write that failed, which lastError
then names — a failed write costs the stored compile and never the frame.
supported is false where the platform holds no store a program can carry — a
browser keeps its own and hands none out — and reason says which; the build
count and timing still read true there.
Per-camera overrides live on the Camera component — debugChannel, postProcessing (whether that camera runs the post-process chain, and the render-feature passes that declared themselves part of the film with it), and the renderLayers spec (a space-separated list of layer names, e.g. "all !ui", where ui / sky / debug / EditorUI are built-in layers) decide what that camera draws (the render-textures guide uses this for secondary views).
That spec is one half of the render-layer system; the other half is which
entities are on each layer, written with renderLayer.set — { tree = true }
puts an entity and every descendant on a layer in one call, and the reply counts
what it moved. The render-layers guide covers both sides.
What the render targets cost
renderer.renderTargets() measures every target the renderer owns from the
texture that is allocated: the scene depth, the G-buffer, each shadow map, the
reflection-probe cubes, the transparency buffers, the post-process chain's
ping-pong pair, the game viewport's own set, and the scratch the draws into a
render target have needed — those rows are named camera[<handle>] after the
texture drawn into: depth and motion vectors under any rasterized pass, and the
occlusion channel and G-buffer under a camera's scene render. That scratch is
built by the draw that needs it and released once no live camera names the
target and sixty frames have passed without a draw, so a target nothing draws
into holds its colour texture and nothing else.
local rt = renderer.renderTargets()
print(("%.1f MiB over %d targets"):format(rt.totalBytes / 1048576, rt.residentCount))
for _, t in rt.targets do
print(t.name, t.resident, t.bytes, t.width .. "x" .. t.height, t.layers)
end
A row marked onDemand exists only while something needs it, and reads
resident = false, bytes = 0 the rest of the time. The weighted-blended
transparency buffers hold nothing at all until a pass accumulates into them —
the clear and composite that bracket the accumulation are skipped for those
frames too, since a composite over a buffer nothing wrote reproduces the image
underneath it. The reflection-probe cube array holds one cube slot until probes
need more of it, so a scene with no probe pays 2.10 MB rather than the 18.87 MB
of eight probe slots plus the sky fallback's. A
camera rendering into a texture brings its own depth, motion vectors, occlusion
and G-buffer with it, and takes them away again with the camera.
So the total moves with what the scene does, and comparing two scenes' totals says what the second one's content costs in render-target memory. The colour image a render-texture camera draws into is the one thing absent from the report: it lives in the shared texture cache and is sampled by guid like any other texture, so the camera is one of its holders rather than its owner.
What the engine holds for content, and why an asset will not load
renderer.renderTargets() above measures the targets the renderer owns.
asset.observe() answers the other question — every texture and mesh the
device is holding for content, with the bytes each costs:
local r = asset.observe()
for _, t in r.textures do print(t.identity or t.key, t.bytes, t.width .. "x" .. t.height) end
for _, m in r.meshes do print(m.identity or m.guid, m.bytes) end
print(r.totals.textureBytes, r.totals.meshBytes, r.totals.cpuCount)
The three pools are named because they are different pools. textures and
meshes are the device's; cpu is the set a live script-component context
holds, which is what warms an asset's bytes into memory. An asset can be in one
and not the others. A row carrying an identity reached the device through that
asset; a row without one is held under a guid no asset claims — a camera's own
render target, a glyph atlas a script built.
totals carries the aggregates the rows sum to, so the listing reconciles
against renderer.textureMemory() and the meshes category of
renderer.gpuMemory() exactly. renderer.texture.list() and
renderer.mesh.list() report the same resources from the renderer's side, with
the script registry's origin and owner alongside.
renderer.references(kind, guid) answers the other question about one of those
rows — what is still holding it, named consumer by consumer — and
renderer.collect() releases every runtime texture, material, mesh and render
feature that nothing holds, which is also what a root scene load runs once the
new scene stands. The core/resource-model guide states the ownership rule both
of them read. devicePublished and
cpuPublished say whether the engine can answer at all, which reads differently
from an engine answering with nothing resident.
The same reading is served at /zero/runtime/residency for a reader outside
Luau, with textures, meshes, cpu and totals readable on their own.
Compute's own GPU resources are a separate pool again, and the compute
category of renderer.gpuMemory() is what they cost. compute.observe() lists
them one row per resource — every storage and uniform buffer, every 3D texture,
storage target, history pair and sampler — each with its bytes, the shader that
made it and the frame it was created on, summing to that category exactly.
compute.diagnose(key) answers the other half: when a resource key names
nothing, which of the states the inventory distinguishes it is in. Both are
covered at guides { path: "types/computeShader" }, and the reading is served
at /zero/runtime/compute.
Why one asset will not load — which file its type's declared primary
resolved to, whether the bytes decode, and the reason when it cannot be used —
is asset.diagnose(ref), covered in the asset-system guide.
Finding the rest
lsp.methods("lights") and lsp.methods("postprocess") list those surfaces in full; asset.inspect("@builtin::components.Camera") shows the camera's render fields. For rendering into a texture (mirrors, minimaps, in-UI 3D) see the render-textures guide; for the shaders these passes run, the shaders guide. The model to hold: content makes the look, lights/postprocess tune the scene at runtime, and .world_settings [render] sets the pipeline.