---
title: "Debug camera views"
description: "Part of the rendering guide (guides { path = \"topics/rendering\" }), which lists every part."
section: "Topics"
slug: "topics-rendering-capture-passes"
canonical: "https://origozero.ai/docs/topics-rendering-capture-passes"
updated: "2026-10-18T05:39:24.012049383+00:00"
tags: ["documentation", "guide", "rendering"]
---

# 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:

```lua
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:

```lua
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:

```lua
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:

```lua
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:

```lua
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.
