Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit61a33237e0

readme.md

denoising

Edge-aware filtering for render features that shade from a few stochastic samples per pixel.

A feature that traces a handful of rays per pixel produces a result carrying the sampling noise of that estimate. The expensive way to quieten it is to trace more rays; the cheap way is to filter the result, spending the samples the neighbourhood already has instead of taking new ones. That filter is the same for every such feature, so it lives here rather than being written again per effect.

What it does

denoiser.create(name, opts) gives you a filter that owns its own targets and compiled passes. Call run from a render feature's render with the guid of the noisy texture and it enqueues its passes and answers the guid of the filtered result.

local denoiser = require("@builtin::systems.denoising.denoiser")

local filter = denoiser.create("my_effect", { iterations = 4, worldSigma = 0.5 })

-- inside render(ctx), after the pass that wrote `noisy`
local clean = filter:run(ctx, noisy.guid, { phase = "afterLighting", order = 51 })

The name keys the filter's resources, so two features filtering in the same frame each pass their own and never share state.

How it filters

An à-trous wavelet: successive 5x5 passes with a doubling tap stride, so four passes reach a 16-pixel radius for 100 taps rather than the 1089 a single blur of that width would cost.

Each tap is weighted by how much the surface under it resembles the surface under the centre pixel — world position reconstructed from @scene.depth, normal from @scene.normal. A tap on a different plane or across a depth edge weighs approximately zero, so the smoothing follows geometry rather than bleeding one object's result onto whatever is behind it.

normalSharpness sets how fast a tap is rejected as its normal turns away; worldSigma is the world-space distance over which a tap's contribution falls to 1/e, and wants sizing to the scale of the geometry being filtered.

Filter a signal, not a frame

Route the effect's own term through the filter and apply it afterwards, rather than filtering an image the effect has already composited. Filtering a composited frame smooths the scene's own detail along with the noise.

rt_ao.renderFeature is the worked example: it writes the occlusion term alone, filters that, and multiplies the scene colour by the filtered result.

Accumulating across frames

temporal = true puts a reprojection pass in front of the à-trous ones, so the filter carries the previous frame's estimate onto this one before it smooths anything. A pixel's surface is found in the previous frame through @scene.motion, and what that frame decided there is mixed with this frame's sample — so the standing estimate holds the samples of every frame still in it, which is what lets the feature feeding it trace fewer rays.

local filter = denoiser.create("my_effect", {
    temporal = true,
    historyFrames = 12,   -- how many frames of samples the estimate may hold
    planeTolerance = 0.05,
    clampSigma = 4,
})

Alongside the accumulation the pass keeps a guide texture recording, per pixel, the world position of the surface it shaded and how long its estimate has been running. That is what the next frame's reprojection is checked against: a remembered point is admitted when it lies on the plane of the surface being shaded now, so a camera sliding along a wall keeps its estimate while a pixel newly uncovered behind a moving object starts again from its own sample. The four bilinear taps are admitted one at a time, so an edge does not drag one surface's estimate onto another.

clampSigma covers what the geometry cannot say: shading that changed where the surface did not — a light that moved, a material that was re-tinted — leaves the carried estimate outside the range this frame's own neighbourhood spans, and it is pulled back to that range. Whatever the tests decide, a pixel's sample count stops at historyFrames and the blend weight is one over it, so nothing survives in the accumulation past that many frames.

filter:reset() drops the accumulation outright, for a camera cut, where nothing on screen was on the previous frame. filter:stats() reports whether an accumulation is being held and how many times it has been dropped; a viewport that changes size drops it too, since what it holds was written for another resolution.

One accumulation, many render targets

A render feature's render runs once a frame, and the passes it enqueues run once for every render target drawn that frame — the viewport, an offscreen capture, a camera rendering into a texture. Each of those is a different view, so each gets its own resolved answer at its own resolution, and the à-trous passes smooth that answer.

The accumulation itself stands for one of those views: the camera that was live when the filter ran. A second pass carries the resolved frame into it, and that pass compares the camera being drawn against the owner's before it writes — so a capture or a texture camera resolves against the accumulation and leaves it exactly as it stands. That comparison is also why the accumulation is held apart from the scratch targets the engine re-derives for every render target: an estimate standing over frames is the one thing that cannot be re-derived.

Costs

The à-trous filter owns two screen-sized targets in the signal's own format. Accumulating adds four more: this frame's resolved estimate and its guide, both screen-sized, and the pair the accumulation is carried across frames in. The guide is rgba16f regardless of the signal's format — it holds a world position, and the plane test is only ever as tight as that storage can represent, so a scene sitting far from the world origin gets a looser test than one near it.

What one filter is

run occupies passes() consecutive order slots from the one it is given — the à-trous iterations, plus the two the accumulation takes when the filter has it. Ask the filter rather than counting, and a caller's own pass stays behind the filter's however it is configured.