Log inGet started

Post-process

Updated October 15, 2026

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:

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

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:

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.

  • documentation
  • guide
  • rendering