Builtin Library
- ..
- denoise_atrous_rgba16f.computeShader
- denoise_atrous_rgba8.computeShader
- denoise_persist_rgba16f.computeShader
- denoise_persist_rgba8.computeShader
- denoise_temporal_rgba16f.computeShader
- denoise_temporal_rgba8.computeShader
- denoiser.module
- dn_persist.shaderModule
- dn_temporal.shaderModule
- package.yaml16 B
- readme.md6.1 KB
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.