Log inGet started

Debug camera views

Updated October 18, 2026

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.

  • documentation
  • guide
  • rendering