All tutorials Mighty Professional
Build a Game Engine · Rendering: into 3D

Post-Processing & Anti-Aliasing

The lighting pass hands you an image whose bright values run far past 1.0, and a monitor shows 0 to 1. Without the passes on this page, lamps don't glow, driving out of a dark tunnel either blinds the player or leaves them in the dark, and every edge is a staircase that crawls as the camera moves. This is the back half of the frame: a chain of full-screen passes (bloom, exposure, grading) that ends at tone-map and present, and an anti-aliasing stage that fights the jaggies. Two parts take most of the care: building bloom from a multi-scale downsample/upsample pyramid rather than one wide blur, and temporal AA, which brings ghosting with it.

Time~55 min LevelSenior PrereqsHelpful, not required: the Deferred Rendering tutorial (full-screen passes, the HDR target, motion vectors) and PBR (HDR + tone mapping). StackGLSL · C++ & Rust
◂ Build a Game Engine Phase 7 · Rendering: into 3D Next · Vertex Animation Textures ▸
What you'll be able to do by the end

01The post chain

Bloom, exposure, grading and anti-aliasing each need to see the whole lit image, so they run after lighting, one full-screen pass at a time. A post-processing chain is an ordered sequence of full-screen passes over the HDR render target the lighting pass produced, a floating-point image that keeps values above 1.0. Each pass reads one texture and writes another, so you ping-pong between two HDR targets (A→B, then B→A): a pass never reads the image it's writing. The chain stays HDR and linear up to the tone-map, the curve that squeezes HDR values into the 0 to 1 a display shows; a few effects (FXAA, LDR grading, grain, vignette) run after it, on that low-dynamic-range (LDR) image.

Order matters, and you can't sample the target you're writing

The exact order varies by engine, but one rule holds across them: energy-linear effects (bloom, exposure) go before tone-map; display-space effects (FXAA, LDR grading) go after. Bloom added after tone-map no longer behaves like light energy: it can't brighten a highlight that the curve already compressed. In Vulkan, a pass can't sample arbitrary texels of the image it's rendering into; subpass input attachments and VK_KHR_dynamic_rendering_local_read expose only the current pixel's value, which is useless to an effect that gathers neighbors. The portable pattern is a separate pass plus an image barrier (COLOR_ATTACHMENT → SHADER_READ_ONLY) before the next pass samples it.

Worked example

A 1920 × 1080 target in RGBA16F (four 16-bit floats, 8 bytes per pixel) is 16.6 MB. A full-resolution pass reads one target and writes the other, so it moves at least 33 MB, and five such passes move 166 MB a frame, about 10 GB/s at 60 fps. That's one reason chains often run the wide effects (bloom, depth of field) at reduced resolution and merge cheap per-pixel steps such as exposure, tone-map and grading into one pass where the order allows.

02Bloom

A street lamp at night looks bright in a photo because the lens and the eye scatter some of its light into a glow around it; render it as a flat disc and it reads as a white sticker. is that glow around bright sources. Three stages: an optional bright-pass keeps only the pixels above a brightness threshold, a downsample/upsample pyramid spreads them across scales, and a composite adds the result back into the HDR image before tone-map. Physically based variants skip the threshold, blur the whole HDR image, and blend in only a few percent of it[2]. The design choice that matters is the spreading step: a pyramid of small filters rather than one wide blur.

Skip the single wide blur; use the pyramid, and tame fireflies

A wide Gaussian at full resolution is expensive, and the cheap version, a small blur of one heavily downsampled copy, comes out blocky when bilinearly upscaled and pulses as a moving highlight crosses its coarse texels. The widely used alternative (Jimenez, Call of Duty: Advanced Warfare; LearnOpenGL's "Physically Based Bloom") is a progressive downsample then progressive upsample with a small kernel per level; the sum of ever-wider levels gives the long-tailed falloff of real lens glare and stays stable in motion[1][2]. And a naive HDR bright-pass lets one super-bright sub-pixel flicker (a firefly); fix it with a Karis average (weight each 2×2 group by 1/(1+luma)) on the first downsample only.

Worked example

From 1920 × 1080, five halvings give mips of 960 × 540 down to 60 × 34: 690,600 pixels in all, a third of the full frame. A 13-tap downsample of each costs 9.0 million fetches and the tent upsamples 6.2 million, and the coarsest mip's texels are 32 screen pixels wide, so the glow reaches dozens of pixels. A separable Gaussian with a 64-pixel radius at full resolution takes 129 taps in each of two passes: 535 million fetches. For the Karis average, a 2 × 2 group holding a glint at 300 next to three pixels at 0.04 averages 75, so its weight is 1 ÷ (1 + 75) = 0.013, against 0.95 for a dark group at 0.05: the glint counts about 72 times less than in a plain average.

The widget runs the real passes on a 128×72 HDR image:

A dark scene with a moving lamp, a lit window, and a one-pixel glint at the top right that blinks the way specular aliasing does, with the downsampled mips beside it. Untick the Karis average and watch the glint, switch to the single wide blur and watch the lamp, then drag Threshold. The pyramid gives the lamp a smooth glow that falls off over a wide range. The glint is the firefly case: with the Karis average off, each blink floods the mips and a big halo flashes on and off. With it on, the first downsample weights that group by 1/(1+luma), and in the widget's own passes the glow 4 pixels from the glint drops to about 28% of its Karis-off brightness and the halo covers about 40% of the area. Switch to the single wide blur (one 1/16-resolution copy, blurred, bilinearly upscaled) and the glow turns blocky and steps along as the lamp moves. The threshold is a soft bright-pass on luminance from 0 to 3: the lit window (luminance 2.2) stops glowing above 73, and at 0 the whole image feeds the blur, the physically based variant.
The progressive downsample / tent upsample (GLSL)
// 13-tap downsample: 13 bilinear fetches cover 36 source texels. e = center; a..m are the taps.
vec3 down = e*0.125
          + (a+c+g+i)*0.03125
          + (b+d+f+h)*0.0625
          + (j+k+l+m)*0.125;          // weights sum to 1
// On the FIRST downsample only, Karis-average the five 2x2 groups to kill fireflies:
//   weight(group) = 1.0 / (1.0 + dot(group, vec3(0.2126,0.7152,0.0722)));  then normalize by the weight sum

// 3x3 tent upsample, added back into the next-larger mip (additive).
vec3 up = (e*4.0 + (b+d+f+h)*2.0 + (a+c+g+i)) * (1.0/16.0);

03Exposure

A tunnel interior is dozens of times darker than the daylight outside, and one fixed brightness setting can't show both. Set it for the tunnel and the exit is a white blur. Set it for daylight and the tunnel is black. (the HDR→LDR operator, Reinhard or ACES) was covered in PBR: a curve that compresses highlights rather than a clamp[3]. What's new here is , a single multiplier on the whole HDR image before the operator, like a camera's exposure setting, and auto-exposure (eye adaptation): measure the scene's luminance, pick a target exposure, and ease toward it.

Use a log-average, and smooth it, or it pumps

Meter with a log-average / geometric mean of luminance rather than an arithmetic mean, so a few bright pixels don't dominate; a luminance histogram in a compute shader goes further and can drop the darkest and brightest percentiles before averaging[4]. The adaptation must be temporally smoothed (ease toward the target by 1 − exp(−dt · speed), usually in EV), or the exposure pumps: every brief flash in view darkens the whole image, and it snaps back when the flash ends. Real eyes adapt to light faster than to darkness, and Narkowicz recommends separate speeds for the two[4].

Worked example

These are the widget's numbers. The tunnel's luminance is 0.04 and the daylight's 1.5, a 37.5× gap, or 5.2 EV (one EV is a doubling). Aiming the average at a key of 0.5 gives exposure 0.5 ÷ 0.04 = 12.5 inside and 0.5 ÷ 1.5 = 0.33 outside. Now a sign at luminance 40 lights up across a tenth of the view. The arithmetic mean jumps to 0.9 × 0.04 + 0.1 × 40 = 4.04, about 100× the tunnel, so exposure collapses by the same factor. The log-average moves by 40 ÷ 0.04 = 1000 raised to the 0.1 power, a factor of 2. With the widget's default speed, the smoothing closes about 2.6% of the gap each 60 Hz frame, so a 0.12-second flash moves a log-averaged exposure by only 0.17 EV.

The widget drives out of that tunnel and back, on a 12-second loop, while the sign flashes every 2.3 seconds:

The left panel is the exposed view, with its background brightness as a percentage; the strip chart traces the metered luminance (blue), the exposure (yellow) and the displayed background (white). Untick smoothing and switch between the two meters, then turn smoothing back on and drag Adapt speed. Smoothing off: exposure tracks the meter every frame, so each flash of the sign darkens the whole view for its duration, the pump. In the tunnel the background reads 60%; with the arithmetic mean one bright sign covering a tenth of the view cuts the exposure by about a factor of 100 and the background drops to 9%, and with the log-average it cuts the exposure by about 2 and the background reads 48%. Smoothing on: with the log-average a 0.12-second flash barely moves the eased exposure (the background dips to about 58%), while the arithmetic mean still pulls it down to about 46%. The tunnel exit plays out as a short adaptation instead of a jump.

04Color grading

An art director wants the desert level warm and washed out and the night level cold and blue, without repainting a texture. Color grading applies that final artistic color transform, usually baked into a 3D LUT (lookup table, commonly 16³ or 32³): a small 3D texture indexed by the input color that returns the graded color in one fetch. The one decision: where in the chain it runs.

LUT placement changes the result

The classic pipeline grades in LDR after tone-map; Unreal's color-grading controls instead work in scene-referred linear space before the tone-map, so one grade holds for LDR and HDR displays, while its legacy LUTs still apply in LDR sRGB after it[13]. The two give different results: grading after tone-map can't recover highlights the curve already compressed, and grading before can. Pick one placement deliberately and document it.

Worked example

A 32³ LUT holds 32,768 colors: 128 KiB in RGBA8, small enough to swap per level or blend between two at a time. Its entries sit 1/31 apart along each axis, and the texture filter interpolates between the eight nearest entries, so a grade that changes smoothly needs no more. A 16³ LUT is 16 KiB, with entries 1/15 apart.

05The AA problem

A fence's top rail shows as a staircase, a shiny armor plate sparkles like glitter, and a far-off railing shimmers whenever the camera drifts. All three are aliasing: the GPU samples a continuous scene at one point per pixel, and detail finer than a pixel turns into steps and flicker. Aliasing has three distinct causes, and they need different fixes[6]:

Don't blame one cause

No single cause covers all three. Geometric aliasing is fixed by coverage supersampling (MSAA); specular aliasing is a shading-frequency problem fixed by normal/roughness prefiltering (Toksvig, LEAN), not by MSAA; temporal crawl is fixed by accumulating over time (TAA). One technique rarely solves all three.

Worked example

The plank in the §8 widget has a top edge that rises one pixel every 1 ÷ 0.18 = 5.6 pixels, so with one sample per pixel it shows as steps 5 or 6 pixels long. Shading aliasing is different: a highlight 0.3 pixels wide drifting across a pixel is caught by the pixel-center sample only while it sits over the center, so the pixel flashes fully lit for about 30% of the highlight's path and dark for the rest. More coverage samples change the first case and not the second.

06MSAA

Rendering every pixel four times (supersampling) fixes edges but quadruples the shading cost, and most of that cost buys nothing away from edges. Multisample anti-aliasing (MSAA) tests coverage and depth at N sample positions per pixel, but runs the pixel shader once per pixel (per covered triangle) and writes that result to the covered samples; a resolve averages the samples down[5]. So MSAA supersamples coverage at edges cheaply.

Geometry edges only, and expensive with deferred

Because shading is per-pixel, MSAA antialiases geometric edges only, not shader/specular aliasing, and not alpha-test cutouts (those need alpha-to-coverage). In forward the extra shading work lands only on edge pixels, though memory and bandwidth still scale with the sample count. In deferred it needs a per-sample G-buffer (N× the fattest bandwidth in the pipeline) and per-sample lighting at edges, a major reason deferred engines moved to post and temporal AA (see the Deferred costs). MSAA is not SSAA: SSAA shades every sample (fixing shading aliasing too) at full cost.

Worked example

At 4× MSAA, a pixel on a triangle's edge with 3 of its 4 samples covered gets the triangle's shaded color in those 3 and the background in the 4th, and resolves to a 75/25 blend: a smooth edge from one shader run per triangle. The storage still grows fourfold: RGBA8 color plus 32-bit depth is 8 bytes per sample, so a 1080p target goes from 16.6 MB to 66 MB.

07FXAA & SMAA

A deferred renderer that can't afford a 4× G-buffer still needs smooth edges, and it already has the finished image. Post-process AA works on that rendered image instead of geometry. FXAA (Lottes, NVIDIA) is a single pass on the final LDR image (after tone-map and grading): convert to luma, detect edges from local luma contrast, and blur along the edge[7]. SMAA (Jimenez et al.) is smarter morphological AA: contrast edge detection, pattern classification (straight/diagonal/corner), then a targeted blend, sharper than FXAA, with spatial and temporal tiers[8].

Cheap, and lossy

FXAA has no geometry or temporal data, so it blurs texture detail (it can't tell a real edge from a high-contrast texture feature) and can't fix temporal crawl: it only smooths each frame on its own. It makes a cheap fallback with visible costs. SMAA 1x is sharper but still spatial-only; its T2x tier adds temporal reprojection to catch crawl.

Worked example

A pixel at luma 0.1 beside one at 0.9 has a local contrast of 0.8, far above a threshold like the widget's 0.1, so FXAA blends it with its neighbors across the edge. A soft texture gradient from 0.40 to 0.45 has a contrast of 0.05 and is left alone. White text on a black sign has a contrast near 1.0, and FXAA blurs it exactly like a geometric edge, because it can't tell the two apart.

08TAA

MSAA leaves the sparkle and FXAA leaves the crawl, because each frame still shades every pixel at the same single spot. But at 60 fps you render 60 frames a second, and if each one samples a slightly different point inside every pixel, you can average them. (TAA) is the default AA in many current engines. Jitter the projection sub-pixel each frame (a different sample inside the pixel), then accumulate over time: reproject the previous frame via motion vectors (a per-pixel record of how far each pixel moved since last frame) and blend (an exponential moving average, ~5 to 10% current, the rest history)[9]. Over several frames you get many sub-pixel samples, supersampling for one sample per frame.

The hard part is the history

Reprojected history is stale on motion and disocclusion, so you rectify it: clamp or clip the history color into the AABB of the current 3×3 neighborhood (often in YCoCg) before blending[9]. Even so, TAA ghosts on disocclusion, transparency, and particles, and it softens the image (history resampling), which is why it's often paired with a sharpening pass. Two correctness rules: the jitter should follow a low-discrepancy sequence such as Halton[10], and the jitter must be kept out of the motion vectors or reconstruction blurs.

Worked example

The listing's Halton jitter starts (0, −0.17), (−0.25, 0.17), (0.25, −0.39), (−0.38, −0.06) pixels: each frame lands somewhere new in the pixel, spread evenly rather than clumped. Blending 10% of each new frame, the last 10 frames carry 65% of the result and the last 22 carry 90%. That's also how long a ghost lasts without the clamp: a bright object at 0.9 that moves off a background at 0.1 still reads 0.38 ten frames later and drops below 0.2 only after 20 frames, a third of a second at 60 fps. Clamp the history to the current 3×3 neighborhood, which spans 0.1 to 0.3 once the object has gone, and the ghost is cut to 0.3 on the first frame.

A TAA resolve (GLSL) and the Halton jitter (CPU)
vec3 current = texture(currentColor, uv).rgb;
vec2 histUv = uv - texture(motionVectors, uv).rg;   // reproject current -> previous
vec3 history = sampleCatmullRom(historyColor, histUv);  // sharper than bilinear

// Rectify: clamp history into the current 3x3 neighborhood AABB (ghost suppression).
// (textureOffset needs a compile-time constant offset, so loop over texelFetch instead;
//  clamp pixel + offset to the image bounds so border pixels don't fetch out of range.)
ivec2 pixel = ivec2(gl_FragCoord.xy);
vec3 boxMin = current, boxMax = current;
for (int y = -1; y <= 1; ++y) for (int x = -1; x <= 1; ++x) {
    vec3 neighbor = texelFetch(currentColor, pixel + ivec2(x, y), 0).rgb;   // YCoCg in production
    boxMin = min(boxMin, neighbor); boxMax = max(boxMax, neighbor);
}
history = clamp(history, boxMin, boxMax);                  // reject stale history
float alpha = 0.1;                                     // ~5-10% current
if (histUv != clamp(histUv, 0.0, 1.0)) alpha = 1.0;       // off-screen: no history
outColor = mix(history, current, alpha);
// Halton radical inverse: a low-discrepancy sub-pixel jitter sequence.
fn halton(mut index: u32, base: u32) -> f32 {
    let inv_base = 1.0 / base as f32;
    let (mut result, mut digit_scale) = (0.0_f32, inv_base);
    while index > 0 {                                   // mirror the base-b digits across the point
        result += (index % base) as f32 * digit_scale;
        index /= base;
        digit_scale *= inv_base;
    }
    result
}
let sequence_index = frame_index % 8 + 1;               // skip index 0, which is (0, 0)
let jitter_x = halton(sequence_index, 2) - 0.5;   // base 2, in pixels
let jitter_y = halton(sequence_index, 3) - 0.5;   // base 3
// Offset in NDC; the translation multiplies by w, so it survives the perspective divide:
// proj = Mat4::from_translation(Vec3::new(2.0 * jitter_x / width, 2.0 * jitter_y / height, 0.0)) * proj;
// CRITICAL: build motion vectors WITHOUT this jitter, or reconstruction blurs.

The comparison is simulated on a 72×40 pixel grid, enlarged:

A shiny plank with a shallow top edge and a pattern of tiny glints drifts slowly right. Switch between the four modes and watch the edge's steps and the glints. Every mode shades once per pixel at the pixel center, so the glints are undersampled. No AA: one coverage sample, so the steps crawl and the glints flicker. MSAA 4x: four coverage samples smooth the edge, but shading still runs once per pixel, so the glints flicker exactly as before (shading aliasing). FXAA-style: a contrast-driven blend softens the steps but also smears the glints, and each frame is still aliased on its own, so the crawl remains. TAA: a Halton-jittered sample each frame, blended 10% into clamped history, averages both edge and glints over about ten frames; the cost is a slightly softer image, and more ghosting than this simple motion shows once objects cross each other.

09Upscaling & more

At 4K a frame has 8.3 million pixels, four times 1080p, and every pass on this page scales with the pixel count. Temporal upscalers (DLSS, FSR2, XeSS, TAAU) are TAA that also upscales: render at lower resolution and reconstruct a higher-res image from jittered history plus motion vectors, the same machinery as §8[11][12]. DLSS 2+ reconstructs with a neural network; FSR2 is analytic; both take color, depth, motion vectors, and exposure (FSR2 adds a reactive mask for alpha and particles).

Not free resolution

Temporal upscalers reconstruct, so they ghost, shimmer, and smear like TAA when the history is wrong, especially on transparency and disocclusion. They trade those artifacts for a large quality-per-cost gain. DLSS (ML) and FSR2 (analytic) are different algorithms, so a claim about one doesn't transfer to the other. The same jitter and motion-vector rules from TAA apply.

Worked example

FSR2's Quality mode renders at 1/1.5 of the output resolution on each axis and Performance mode at 1/2[11]. For 4K output (8,294,400 pixels), Quality shades 2560 × 1440 = 3,686,400 pixels, 44% of the output, and Performance shades 1920 × 1080, 25%. The rest of each output pixel's detail has to come from history, which is why every TAA artifact applies.

The rest of the post chain imitates a real camera. Motion blur and depth of field usually run in HDR before the tone-map, so bright highlights smear and spread with their real energy. Motion blur gathers along the per-pixel velocity, with silhouette artifacts at object edges. Depth of field computes each pixel's circle of confusion, the disc an out-of-focus point blurs into, from its depth with the thin-lens model (a one-lens camera model), then approximates the blur with a gather. Vignette, chromatic aberration (per-channel UV offset), and film grain usually come after the tone-map.

Wrong answers, and why: more MSAA samples can't fix specular (shading) aliasing; and TAA ghosting is fixed by history rectification (clamp + jitter-free motion vectors), not by leaning harder on history or dropping to FXAA.

10Pitfalls

Bloom as one wide blurBlurring one low-res copy is blocky and pulses. Use the progressive downsample/upsample pyramid.
Firefly flicker in bloomUnstable HDR bright-pass. Karis-average the first downsample.
Bloom after tone-mapIt's light energy; add it in HDR before the operator.
Exposure pumpingTemporally smooth auto-exposure; use a log-average, not arithmetic.
MSAA for specular sparkleMSAA is geometry edges only. Prefilter normals/roughness or use TAA.
Jitter left in motion vectorsBreaks TAA reconstruction. Remove the jitter from the velocity buffer.
More history to fix ghostingMakes it worse. Rectify history with a neighborhood clamp.
"Upscalers are free resolution"They reconstruct and can ghost/smear. A quality-per-cost win, not free.

11What's next

That completes the rendering track: from a triangle in Vulkan to a tone-mapped, anti-aliased, post-processed image. The series now turns from how things look to how they move: Vertex Animation Textures first, then Skeletal Animation & Skinning and blending, before simulation, gameplay, AI, networking, tooling and the 3D-game capstone. The full path is on the series hub.

  1. Jorge Jimenez. "Next Generation Post Processing in Call of Duty: Advanced Warfare." SIGGRAPH 2014. iryoku.com. The modern post chain and the progressive downsample/upsample bloom with the Karis average.
  2. Alexander Christensen. "Physically Based Bloom." LearnOpenGL guest article, 2022. learnopengl.com. The 13-tap downsample (36 texels) and 3×3 tent upsample GLSL, no threshold with a small mix (~0.04) into the HDR image, and the Karis average on the first downsample.
  3. Erik Reinhard et al. "Photographic Tone Reproduction for Digital Images." SIGGRAPH 2002. cs.utah.edu. The tone-map operator (recap; covered in PBR).
  4. Krzysztof Narkowicz. "Automatic Exposure." knarkowicz.wordpress.com. Log-average luminance and temporally smoothed eye adaptation.
  5. Matt Pettineo. "A Quick Overview of MSAA." therealmjp.github.io. Coverage at the sample rate, shading once per pixel, per-sample storage, the resolve.
  6. Tomas Akenine-Möller, Eric Haines, Naty Hoffman, et al. Real-Time Rendering, 4th ed., ch. 5 & 12. realtimerendering.com. The antialiasing and image-space/post surveys.
  7. Timothy Lottes. "FXAA" (NVIDIA whitepaper). developer.nvidia.com. Single-pass image-space luma-edge AA on the final LDR image.
  8. Jorge Jimenez et al. "SMAA: Enhanced Subpixel Morphological Antialiasing." Eurographics 2012. iryoku.com/smaa. Morphological edge AA and the 1x/S2x/T2x/4x tiers.
  9. Brian Karis. "High Quality Temporal Supersampling." SIGGRAPH 2014 (Unreal Engine 4). cloudfront.net. The canonical TAA: jitter, motion-vector reprojection, and neighborhood clamping.
  10. Matt Pharr, Wenzel Jakob, Greg Humphreys. Physically Based Rendering, "The Halton Sampler." pbr-book.org. Why a low-discrepancy sequence beats grid or pseudo-random jitter.
  11. AMD GPUOpen. FidelityFX Super Resolution 2 (FSR2). gpuopen.com. Temporal upscaling: Halton jitter, motion vectors without jitter, the reactive mask, and the inputs.
  12. NVIDIA. "DLSS 2.0: A Big Leap in AI Rendering." developer.nvidia.com. DLSS 2+ as a temporal upscaler reconstructing from jittered low-res input, motion vectors, and depth.
  13. Epic Games. "Color Grading and the Filmic Tonemapper in Unreal Engine." dev.epicgames.com. Color-grading controls work in scene-referred linear space before tone mapping; legacy LUTs apply in LDR sRGB after it.

See also