Builtin Library
readme.md
antiAliasing system
Geometry is rasterised at one sample per pixel, so a silhouette crossing a pixel either covers its sample or does not. A shallow-angle edge therefore comes out as a staircase of hard steps rather than as a line, and that staircase is the most recognisable artifact of an unfiltered render. In motion it gets worse: the step pattern shifts as the edge moves, a specular highlight flashes on and off as it crosses the sample point, and fine texture crawls.
Two corrections live here, and they work from different information.
Edge antialiasing (EdgeAntiAliasing, FXAA) works from the finished frame
alone. It finds each edge and shifts that pixel's sample across it. Fixed cost,
no dependencies, corrects the staircase in a still image — and knows nothing
about time, so what only appears in motion stays.
Temporal antialiasing (TemporalAntiAliasing) samples a different point
inside each pixel every frame and accumulates the frames, following the scene's
own motion to know where each pixel's surface was. That recovers detail no single
frame holds and settles exactly what edge antialiasing cannot see — the crawling
and flickering that exist only between frames. It needs the scene's velocity, and
costs a small amount of softening, which its sharpness puts back.
They compose: edge antialiasing cleans up within a frame, the temporal pass handles what happens between them.
Using them
local id = entity.spawn("antialiasing").id
entity(id).component.add("TemporalAntiAliasing", { historyWeight = 0.9 })
entity(id).component.add("EdgeAntiAliasing", { edgeThreshold = 0.125 })
Every field is live. For runtime control (a quality slider, or turning one off) drive the modules:
local antiAliasing = require("@builtin::systems.antiAliasing.antiAliasing")
local temporalAntiAliasing = require("@builtin::systems.antiAliasing.temporalAntiAliasing")
temporalAntiAliasing.enable({ historyWeight = 0.9, sharpness = 0.2 })
antiAliasing.enable({ edgeThreshold = 0.125 })
Each module has .enable / .get / .active / .disable.
To see temporal antialiasing as a player sees it, read the frame the screen
showed: capture { source = "presented" } (frames = n for a run of
consecutive frames, which is how convergence and flicker read), or
renderer.presentedFrames() for the pixels with each frame's jitter and
history reset. Every view keeps its own accumulation: a render-to-texture
camera accumulates over its own renders, and an offscreen capture draws a fresh
view whose single render has no history behind it, so it shows none of the
accumulation and leaves the viewport's untouched.
What the edge knobs do
| field | effect |
|---|---|
edgeThreshold | How much a pixel's luminance must differ from its neighbours', as a fraction of the brightest of them, before it counts as an edge. Lower catches fainter edges and costs more. |
edgeThresholdMin | The absolute floor under that test. It keeps the pass off dark areas, where a small relative step is still visually flat. |
subpixel | How strongly features finer than one pixel are corrected. |
searchSteps | How far the pass walks along an edge looking for its end. Longer reach resolves shallower edges, at linear cost. |
What the temporal knobs do
| field | effect |
|---|---|
historyWeight | How much of the accumulated result to keep each frame. Higher resolves more detail and settles smoother, at the cost of taking longer to let go of what a surface used to look like. |
neighbourhoodClamp | How far outside the neighbourhood's own colour spread an accumulated sample may sit before it is pulled back in. Lower rejects more, trading some of the settling for a result that follows change faster. |
sharpness | How much of the edge lost to averaging over sub-pixel samples is added back. |
motionRejection | How much fast motion shortens the memory, which is what stops a moving object trailing behind itself. |
How they render
fxaa.renderFeature runs one compute pass after lighting. The result is not a
blur. A blur softens everything; this moves each edge pixel's sample
perpendicular to the edge by a sub-pixel amount, so the step between two flat
regions becomes a gradient while both regions are left as they were. That is why
the pass has to find the edge's direction and extent rather than filtering a
neighbourhood — the offset depends on where along the edge the pixel falls, so a
pixel near the middle of a long edge barely moves and one near an end moves
nearly a full half-texel.
Luminance drives every decision there. The eye resolves luminance detail far more finely than chroma, so a step that reads as a jagged edge is a step in luminance, and an edge that is pure chroma needs no correction. A pixel whose neighbourhood varies less than the threshold is passed through untouched, which is what keeps flat regions and gentle gradients out of the filter entirely. Features finer than a pixel produce no long edge to walk, so the 3x3 luminance average carries their correction instead.
taa.renderFeature offsets where the projection samples each frame and
accumulates the frames that produces, reprojecting through @scene.motion and
bounding what it carries by the neighbourhood's own colour statistics. Its README
covers the sample sequence, the three tests that decide whether an accumulated
sample is still about the same surface, and why the bound is computed in YCoCg.
Files
antiAliasing.package/
EdgeAntiAliasing.component/ -- the scene's edge antialiasing, authored and persisted
antiAliasing.module/ -- .enable / .get / .disable / .active
fxaa.renderFeature/ -- the edge pass
TemporalAntiAliasing.component/ -- the scene's temporal antialiasing, authored and persisted
temporalAntiAliasing.module/ -- .enable / .get / .disable / .active
taa.renderFeature/ -- the accumulation pass + the per-frame sample offset