---
title: "Post-process"
description: "Part of the rendering guide (guides { path = \"topics/rendering\" }), which lists every part."
section: "Topics"
slug: "topics-rendering-post-process"
canonical: "https://origozero.ai/docs/topics-rendering-post-process"
updated: "2026-10-15T11:08:40.226165515+00:00"
tags: ["documentation", "guide", "rendering"]
---

# Post-process

## Post-process

The scene is drawn in light: every surface, the sky and every `afterLighting` feature write linear radiance into a float target, and effects registered on the `scene` layer see that light. One pass, `__display__`, then applies the exposure and the tone curve and encodes the result for the screen. The UI lands on that picture, and effects on the `all` layer grade the picture. `postprocess.describe("__display__")` reads its `curve` (0 linear, 1 Reinhard, 2 ACES, 3 Khronos PBR Neutral) and `exposure`; `postprocess.setProperty` writes them, and a `PostProcess` component's `display` carries them with a scene.

Full-screen effects run after the scene renders — bloom, colour grading, your own. Each is a `post-process` `.shader` asset (the shaders guide) that runs once registered as a named pass:

```lua
-- No layer: bloom reads light, so the chain runs it on `scene`.
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.

An effect declares in its `properties.yaml` whether its `fragment()` works on `light` (the default) or on `display` code values, and the chain converts at both ends, so a vignette grades either layer and a lookup table always sees code values.

An effect may also own several passes, each at its own scale, drawn in order through per-effect scratch targets before the next effect sees the frame — bloom, for instance, is a threshold pass, a pyramid of downsamples and a chain of tent upsamples, each level reading an earlier one at a coarser size. `asset.resolve("@builtin::shaders.post.bloom", "shader"):getPasses()` reads a shader's own pass list before registering it — for the bloom that is twelve entries, `bright`, `down1`…`down5`, `up4`…`up0` and `composite`, each with the `entry`, `scale` and `inputs` it was declared with; the shaders guide (`types/shader`) has the full `passes:` shape, the two indexed-read helpers, and what `engine.params` carries for a pass the chain draws.

`opts.layer` picks which composited image the pass grades. `"scene"` runs it before the UI is drawn, over the scene's own light in a float target, so it grades the rendered picture and leaves every widget on screen as authored; `"all"` runs it after the UI has landed, over the picture `__display__` fitted to the screen, so the interface is graded along with the picture.

A registration that names no layer takes the one its space belongs to: a `light` effect grades `scene` and a `display` effect grades `all`. That is what puts a bloom where highlights still stand above 1.0 and a lookup table where the code values it was built for are. Name `"all"` for a pass that must reach the interface — on a world several authors share, it 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. `__display__` still runs on that capture: what
`postProcessing = false` opts a camera out of is the registered effects, and
the exposure and tone curve that turn the scene's light into the picture a
capture returns stay on it. A diagnostic capture (`albedo`, `normal`,
`motion_vectors`, …) bypasses `__display__` itself, since it answers a reading
rather than a graded picture, and the environment-cube capture bypasses it for
the same reason — its faces carry radiance into the cube array, which is what
the PBR indirect term multiplies into its lighting. 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: `tools.use("pp", "add", name, shader, opts)` resolves `shader` — a shipped short name, an identity, a path, a guid, or a scene-authored `.shader` asset — and applies `opts` as named properties. The toolbox's **presets** tool lists every shipped short name.

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

```lua
local lut = require("@builtin::modules.lut")

local ref = lut.install("warm_evening", lut.fromGrade({
    exposure = 0.3, temperature = 0.25, saturation = 1.1,
}))
tools.use("pp", "add", "grade", "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. The effect works on the display's code values — the usual place for a look exported from a grading tool, since the chain applies it after `__display__`, the chain's own display transform, has already run.

## Verifying a post effect by numbers

Whether a tone curve, a bloom, an antialiasing resolve, a fog or a grade does what it should is read off the pixels a viewer sees and held against a reference by numbers. `@builtin::modules.postMetrics` is the kit:

```lua
local postMetrics = require("@builtin::modules.postMetrics")
local frames = renderer.presentedFrames({ frames = 16 })          -- what the player saw, frame by frame
postMetrics.temporalVariance(frames).mean                           -- flicker: 0 on a still, resolved view
postMetrics.banding(frames[1], { x = 0, y = 0, w = 256, h = 32 }) -- longestRun: a quantised gradient's bands
postMetrics.expectGolden(frames[1], asset.containing(__FILE__).path .. "/goldens/look.png")  -- FLIP against a golden
return postMetrics.picture(postMetrics.scopes.falseColor(frames[1]))
```

- **FLIP and goldens.** `postMetrics.flip(test, reference)` (and `cpu:flip(reference)` on any CPU picture) is NVIDIA FLIP: how different two pictures look, per pixel in 0..1, its `mean` the number the reference evaluator reports. `postMetrics.golden(frame, path)` holds a picture against a stored PNG and passes at a mean of 0.02 or under, the level of the noise a GPU or a platform puts on a picture, below any change a viewer sees; `{ record = true }` makes the golden.
- **Pixel metrics.** `banding`, `haloOvershoot`, `temporalVariance`, `ringEnergy` (the light a bloom spreads around a point), `meanColour` and `hueDrift` (a colour's hue across exposures).
- **Known light.** `postMetrics.chart.show(props)` draws `@builtin::shaders.post.hdrChart` in place of the scene, first in the chain: a ramp, a grey wedge over stops, the ColorChecker, or a saturated colour over its grey. `postMetrics.chart.reference` builds the picture the display should present for it under a curve, from `postMetrics.curves`, the CPU twins of what `__display__` draws.
- **Scopes.** `scopes.histogram` (with a metering window), `waveform`, `vectorscope`, `falseColor` (stops from middle grey), `zebra` (the contours of a quantised gradient) and `toneCurve`, each a picture `postMetrics.picture` returns to an agent.
- **Reference scenes.** `@builtin::scenes.postReference.exposureWedge`, `bloomEnergy`, `temporalTorture`, `banding`, `dofChart` and `hdrChart`: known content with a `ReferenceCamera` each.

The module's README (`@builtin::modules.postMetrics`) covers every call, and `post_verification.testSuite` shows each instrument moving on a known-bad control.
