Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

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

fieldeffect
edgeThresholdHow 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.
edgeThresholdMinThe absolute floor under that test. It keeps the pass off dark areas, where a small relative step is still visually flat.
subpixelHow strongly features finer than one pixel are corrected.
searchStepsHow far the pass walks along an edge looking for its end. Longer reach resolves shallower edges, at linear cost.

What the temporal knobs do

fieldeffect
historyWeightHow 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.
neighbourhoodClampHow 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.
sharpnessHow much of the edge lost to averaging over sub-pixel samples is added back.
motionRejectionHow 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