Debug camera views
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 (topics/rendering-render-features).
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.)
A pass that is part of the air
A Camera's environment = "isolated" says that camera is lit and seen by
nothing the scene holds: not its sky, not its sun, and not the air between the
camera and a surface. A render feature drawing that air (height fog, aerial
perspective, the light a medium scatters toward the viewer) declares it on
every pass it enqueues for it, the passes marching the medium as well as the one
compositing it:
ctx.enqueue {
kind = "fragment", inputs = { src = fogRt.guid },
phase = "afterLighting", order = 110,
atmospherics = true, -- part of the scene's air
}
A camera stating an isolated environment skips those passes, so an asset preview, an item icon or a portrait reads the same in a fogged world as in a clear one; every camera drawing the scene runs them. Declare it for what the SCENE's air does to the picture. A feature drawing something a camera photographs, such as an outline, a gameplay overlay or a placed volume, leaves it out.
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.