Skip to content
Ben is currently available for contract work for 3D & web solutions — reach out.

Back to Blog Listing

Building the AAA Lighting Pipeline in Three.js

From the G-buffer to temporal upscaling, here is each stage of the lightmap, probe and screen-space reflection pipeline measured against Three.js: what games require of it, what Three.js has today, and the pull requests in review or still to write.

Ben Houston • • 34 min read

This post is the progress report: that pipeline, measured against Three.js one stage at a time.

It uses the lighting terms from Part 3: indirect diffuse and specular, source, coverage, SSR and SSGI. It adds five more:

  • BRDF: the material's shading function: how a surface turns incoming light into the light you see.
  • G-buffer: per-pixel images of the visible surface (depth, normal, roughness) that screen-space effects read.
  • TRAA: temporal anti-aliasing; each frame is blended with the reprojected frames before it.
  • TAAU: temporal anti-aliasing with upscaling; the same accumulation, but the scene is rendered below the output resolution and the history is kept at the output resolution.
  • PMREM: an environment map pre-blurred for every roughness level, which is how Three.js lights from a cubemap.

The pipeline#

The pipeline has two parts. The setup fills three caches: lightmaps for static meshes, a light probe grid for moving objects, and reflection probes for each room.

Pipeline setup, three caches filled on load or at authoring time. Lightmaps: UV2 unwrap, then bake with ray radiosity, then denoise and stitch seams. Light probe grid: place the grid, capture cubemaps, project to L2 spherical harmonics. Reflection probes: place probes with influence boxes, capture HDR cubemaps, GGX prefilter. Each step is marked as in Three.js today, in review or partly there, or still to build; lightmap clean-up and reflection probe placement are partly there, and the rest is in Three.js.

The setup: three caches, each filled in three steps. Solid steps are in Three.js today; tinted ones are in review or partly there.

Each frame then reads those caches and adds the screen-space work on top:

One frame of the pipeline: a G-buffer prepass, Hi-Z build, GTAO, an SSR and SSGI trace and their denoise, then a forward beauty pass that reads both coverage cascades and three cached inputs, lightmaps, the light probe grid and reflection probes, all at render resolution, then TRAA or TAAU at output resolution, with the resolved output fed back into the next frame's screen-space traces. The Hi-Z build and the beauty pass that reads both cascades are still to build; the SSR and SSGI trace and their denoise are partly there; the rest is in Three.js.

One frame of the pipeline. Solid stages are in Three.js today, tinted ones are in review or partly there, and outlined ones are still to build. The output of TRAA, or of TAAU when upscaling, feeds the next frame's SSR and SSGI.

Where things stand#

This work started in September. Two months in, here is how each stage compares with what the games require. Each row is explained in its own section below:

StageIn Three.jsIn review or draftedStill to build
G-bufferMRT prepassOctahedral normals
Baked GIlightMap, unwrapper, baker, ray radiosity, denoiser, HDR JPEG XLSeam stitching, KHR_lightmap (draft)glTF JPEG XL and UltraHDR extensions, runtime BC6H
Dynamic GILightProbeGrid, 8× faster bakes, multi-bounce, updates spread across framesDDGI visibility (draft)Cheaper visibility weighting
OcclusionGTAO, builtinAOContextBent normals and GTSO (draft), joint bilateral upsample (draft)
SSRStochastic GGX SSR, temporal accumulation, multi-bouncePrevious-frame tracingHi-Z tracing, coverage output, spatio-temporal SSR denoiser
SSGISSGINode, composited over the finished image—Coverage output, previous-frame tracing, a diffuse source for the BRDF
Reflection probesSingle box-projected env map, PMREMProbe system (draft), cube arraysProbe normalization, 128² prefiltering
BRDF integrationAO context, environment fallback—Pluggable indirect specular and diffuse, coverage cascade
TRAA and TAAUTRAA node, TAAU nodeCatmull-Rom history (draft), wider clip at rest and test harness (draft)TAAU in the full pipeline, texture mip bias, the TRAA refinements ported to TAAU

The biggest remaining gaps are in the SSR and BRDF rows: Hi-Z tracing and coverage for SSR, the same coverage and previous-frame work for SSGI, and above all the pluggable indirect lighting that ties both cascades into the BRDF. Everything else is in Three.js, in review or drafted.

It has moved this fast because of the Three.js maintainers. Michael Herzog (@Mugen87) has reviewed most of these PRs, and more than once has reworked one into a better design that fits how Three.js as a whole is built, adjusting the renderer's internals to make room for it. mrdoob has expressed strong support for many of them. I am very lucky to have both.

So far, though, these pieces have been proposed and reviewed one at a time, each on its own merits. Nobody has been asked to sign up to the whole. This series is my attempt to put them together into one coherent picture, so that each PR can be judged for where it fits as well as for what it does.

The pipeline, stage by stage#

The rest of this post walks through the frame one stage at a time. For each, it asks three questions: what do the games require, what does Three.js already have, and what is missing. The answers to the third question are where the work is.

The G-buffer#

What the games require. SSR, GTAO and the denoisers all run in screen space before lighting, so they need a per-pixel description of the visible surface: a G-buffer. A deferred engine stores everything lighting will need, including base color and emissive, because its lighting pass reads only the G-buffer. A forward renderer like Three.js has an advantage here: the beauty pass shades each material directly, so the G-buffer only needs what the screen-space passes read.

ValueMinimum bitsRead by
Depth24Everything; source of the Hi-Z pyramid
Normal2×12 (octahedral)SSR ray direction, GTAO, denoisers
Roughness8 (perceptual)SSR lobe and roughness fade, denoiser kernel size
Metalness1–8SSR fade on non-metals
Material flags2–8Masking SSR on sky, water, transparents
Motion vectors2×16TRAA, SSR hit reprojection, denoiser history

Base color, emissive and the material AO map are not on the list: the beauty pass reads them straight from the material.

What Three.js has. WebGPURenderer already renders a G-buffer prepass with multiple render targets, and the SSR examples use it today. Normals are packed into RGB8 with roughness in alpha, base color with metalness in a second RGBA8 target, and velocity in a third:

const scenePass = pass( scene, camera );
scenePass.setMRT( mrt( {
	output: output,
	diffuseColor: vec4( diffuseColor.rgb, materialMetalness ),
	normal: vec4( packNormalToRGB( normalView ).rgb, materialRoughness ),
	velocity: velocity
} ) );

So the mechanism exists. The gaps are the layout and the normal precision.

G-buffer structure and memory#

A layout that covers the table above, in three render targets plus depth:

TargetFormatBytes / pixelContents
DepthD32F, reversed-Z4Depth
NormalRG16 snorm (RG16F fallback)4Octahedral normal
MaterialRGBA84Roughness, metalness, flags, spare
MotionRG16F4Screen-space velocity
Total16

Sixteen bytes per pixel is lean by AAA standards. Deferred engines commonly spend 20–28, because they also store base color, emissive and baked lighting.

The G-buffer is not the only screen-sized memory, though. The rest of the frame adds a half-float beauty target (8 B), TRAA history and output (16 B), the previous depth for reprojection (4 B), a Hi-Z pyramid (about 5 B with its mips), and half-resolution SSR, denoiser history and GTAO buffers (about 7 B together). That is roughly 56 bytes per pixel for the whole screen-space pipeline. At a laptop's 1920×1080 that is 116 MB, and at 4K it is 465 MB. Part 2 has the table for each device and the argument that it fits, in both memory and memory bandwidth. The short version: capacity is not the problem, and bandwidth on phones is why SSR, its denoiser and GTAO run at half resolution. Rendering the whole frame below the output resolution and reconstructing it with TAAU cuts the budget further; that is covered under TRAA and TAAU below. Half resolution has one requirement of its own: the results must be upsampled with a depth- and normal-aware filter, or reflections and occlusion bleed across the edges of objects. I have drafted that filter as jointBilateralUpsample(); more on it under occlusion below.

Normal precision#

The one field Three.js stores too coarsely today is the normal. SSR is unforgiving of normal error: a ray reflected off a slightly wrong normal lands somewhere visibly wrong at the end of a long trace. With RGB8 normals, smooth curved surfaces snap to a limited set of quantized directions, and their reflections show banding and clumping.

AAA G-buffers solve this with octahedral encoding: fold the sphere of directions onto an octahedron, unfold it into a square, and store two coordinates. It is the most accurate of the encodings surveyed by Cigolle et al. for its size. We have added packNormalToOctahedral() and unpackOctahedralToNormal() to Three.js in PR #35480:

Encoding (4 bytes / pixel)Mean errorMax errorAvailability
RGB8 (today)0.17°0.38°Everywhere
Octahedral RG16F0.011°0.040°Everywhere in WebGPU
Octahedral RG16 snorm0.0013°0.0037°93% of WebGPU devices

That is fifteen times more accurate with half floats, and over a hundred times with snorm, for the same four bytes. RG16 snorm is the target, with RG16F as the fallback.

Reflections on a glossy surface with RGB8 normals on the left, showing banding, and octahedral RG16F normals on the right, smooth

Reflections traced from RGB8 normals (left) show banding; octahedral RG16F normals (right) are smooth.

Angular normal error at 0.25° full scale: RGB8 on the left shows error everywhere, octahedral RG16F on the right is near zero

Angular error of the stored normal: RGB8 (left) and octahedral RG16F (right), at the same scale.

Global illumination#

What the games require. From the survey:

  • Bounce light baked into lightmaps for everything that does not move.
  • A probe volume to light dynamic objects, ideally one that does not leak light through walls.

Lightmaps. The runtime side has been in Three.js for years: every standard material takes a lightMap, read from a second UV set. What was missing was everything needed to make one: an unwrapper, a baker, a solver worth using, and a way to store and ship the result. Those now add up to a complete baking pipeline that runs in the browser. It is a large enough subject that it has its own post, Part 5, Adding Robust Light Baking to Three.js.

Light probes. For dynamic objects, LightProbeGrid places a grid of probes through a scene, each storing incoming light as L2 spherical harmonics, and lights each object from the probes around it (example). It worked well; the limit was how long a capture took.

Sponza lit by the Three.js light probe grid

Sponza lit by LightProbeGrid.

Probe capture speed. Batching probe captures made LightProbeGrid four times faster, and later changes doubled that again: it now bakes about eight times faster than it did at the start of September, and supports multi-bounce light. Profiling showed the bake was limited by the CPU submitting render passes, not by the GPU, so the batched capture renders eight probes per pass with one array camera: a resolution-12 grid dropped from 5.6 s to 1.2 s. Integrating the spherical harmonics exactly also cut their error from 2.4% to 0.08%. The second doubling came from culling each draw against its own sub-camera.

Leaking probes. A probe on one side of a wall can light an object on the other, so a dark room glows with the light of the bright room next door. DDGI solves this by storing, with each probe, the distance to the nearest surface in every direction, and ignoring probes that cannot see the point being lit. DDGI-style visibility weighting for LightProbeGrid works as a draft PR, but it is not yet cheap enough per pixel. Getting that cost down is the remaining work.

Two adjacent rooms lit by the light probe grid with visibility weighting, one warm and one blue, with no light leaking through the wall between them

Probe visibility weighting: the warm room and the blue room no longer light each other through the wall.

Ambient and specular occlusion#

What the games require. Lightmaps are too coarse for contact shadows, and they know nothing about dynamic objects. Games add screen-space occlusion on top, and Activision's GTAO is the standard. It also produces a bent normal, the average open direction, which gives specular occlusion (GTSO): a reflection pointing into a fold of the surface is occluded, one pointing out of the opening is not.

Which light occlusion applies to matters as much as how it is computed. In this pipeline occlusion is not a step of either cascade. It is a shared buffer, set with builtinAOContext as it is today, and the sources that need it read it and darken their own answer:

  • The lightmap, probe grid and environment apply AO to their indirect diffuse, combined with the material's AO map as min( materialAO, gtao ).
  • The reflection probes and environment apply GTSO to their indirect specular.
  • SSR and SSGI apply neither. A screen-space hit is something the ray actually found, so it is already correctly occluded.
  • Direct light is untouched, because it has shadows.

The cascades never see any of this; they combine colors by coverage and nothing else. The one rule a source must follow is that occlusion darkens its color, never its coverage. An occluded probe still answers the pixel, only darker; lowering its coverage instead would let the next source's unoccluded light leak into the corner.

Applying AO to lightmapped surfaces darkens them slightly twice, since the bake already includes static self-occlusion. We do it anyway, because dynamic objects still need to occlude static ones.

What Three.js has. GTAO is already there, and it already feeds the lighting rather than being multiplied over the final image. The AO example renders a prepass, runs GTAO on it, and hands the result to the beauty pass through a lighting context:

const aoPass = ao( prePassDepth, prePassNormal, camera );
scenePass.contextNode = builtinAOContext( aoPass.getTextureNode().sample( screenUV ).r );

This is exactly the pattern the rest of the pipeline needs, and it matters again below.

The gaps. There are two.

  • Specular occlusion. Today GTAO outputs a single visibility value, and the physical lighting model turns it into specular occlusion with a heuristic based on the view angle and roughness. It does not know which directions are blocked, so metal keeps reflecting the sky inside folds. Bent normals and GTSO are up as a draft PR. The bent normal comes out of the same horizon search with no extra depth samples; the cost is an AO target that grows from one byte per pixel to four.
  • Edge-aware upsampling. Half-resolution GTAO upsampled with a bilinear filter leaves dark outlines around objects, because occlusion from the surface behind bleeds across each silhouette. jointBilateralUpsample() upsamples using the full-resolution depth and normals as guides (Kopf et al. 2007), so the result stops at the edges. It is drafted and not yet submitted, with the AO example as its first user; half-resolution SSR needs the same filter.
A gold bust lit by an environment map: with GTAO and GTSO, folds and cavities stop reflecting the sky; with GTAO and a scalar specular occlusion, they still glow

With GTSO (left), folds and cavities stop reflecting the sky. With a scalar specular occlusion (right), they still glow.

Reflections: screen-space reflections#

What the games require. From DOOM, Frostbite and the others, SSR in a cascade has to:

  1. Trace efficiently over any distance, using a Hi-Z depth pyramid.
  2. Output coverage, so the probes can fill what it misses.
  3. Trace the previous frame's lit image, so its result can feed the current frame's lighting.
  4. Denoise spatially and temporally, so one ray per pixel is enough.

What Three.js had. In September, Three.js's SSR was built for a different job: adding reflections on top of a finished image, in a compact scene. It took few samples, so rough surfaces were noisy. It was composited over the image, so it did not go through the BRDF. And its rays stopped at a fixed distance, so reflections of anything far away were missing.

Much of that has changed since. SSRNode now importance-samples the full GGX lobe with correct Fresnel, validates and refines its hits, and uses Houston Stratified Noise, also known as HHW (Hierarchical Hashed Weyl) noise, in place of a blue noise texture. Its result is reprojected and accumulated over time, and fed back so reflections show reflections.

What it does not yet do is fit into a cascade. Here is how the SSR example combines it with the scene:

const litColor = scenePassColor.rgb.add( denoisedReflections.rgb );

The beauty pass renders first, SSR traces that same image, and the reflections are added on top. Each requirement above is a gap from there.

Hi-Z tracing. SSRNode currently marches the depth buffer in up to 64 steps out to a maxDistance, with a thickness tolerance for deciding a hit. Long rays are expensive, and both values need tuning per scene. A Hi-Z pyramid is a mip chain where each texel stores the minimum depth of the four below it. The ray takes large steps through empty space at coarse levels and fine steps only near surfaces, so cost grows with the log of the ray's length. Every ray can then go as far as the screen allows, with no maximum distance to tune. Storing both the minimum and maximum depth in the pyramid also helps the thickness test: a single depth layer cannot tell whether a ray passed behind an object or into it, and the extra bound narrows that ambiguity. The technique is from Uludag's Hi-Z Screen-Space Cone-Traced Reflections in GPU Pro 5, building on the screen-space ray marching of McGuire and Mara. Frostbite (for smooth surfaces), AMD's FidelityFX SSSR and Killing Floor 2 all use it. A first prototype matches linear marching bit for bit across 48 GPU test cases, but it is not yet a net win: the traversal saves up to about 7%, and building the pyramid costs more than that. On a GTX 1050, one test scene fell from 199 to 159 fps and another from 126 to 97. The next step is a cheaper pyramid build, in a single compute dispatch as in AMD's SPD, measured together with the traversal.

A 1D cross-section of Hi-Z ray marching: coarse mip levels let a reflected ray cross empty space in a few large steps, descending to finer levels near the surface it hits

Hi-Z ray marching: large steps across empty space, small steps near the surface.

Coverage. Today, a ray that misses samples an environment map itself, inside SSR: an equirectangular texture passed in as environmentNode, which cannot be the PMREM-filtered scene.environment, let alone a local probe. Adding SSR over the image also counts the environment reflection twice: once in the beauty pass, and once in SSR's own fallback. In a cascade, SSR should instead output its reflection and its coverage, lowered near the screen edge, at grazing angles, behind thin geometry and on very rough surfaces, and leave the rest to the probes. We have prototyped this; it lands together with the BRDF integration below.

Tracing the previous frame. SSR cannot be part of the lighting while it reads the lighting's output. DOOM's answer is to trace the current depth buffer but read colors from the previous frame: reproject each hit point into last frame's TRAA output and sample it there. Last frame's image already contained last frame's reflections, so a mirror facing a mirror gains a bounce each frame. Previous-frame tracing is in review.

Two rules keep this loop healthy. First, the image SSR reads must be linear HDR radiance, taken after TRAA but before tone mapping, bloom and other post-processing; otherwise reflections pick up tone-mapped colors and bloom halos. Second, the fed-back radiance must be clamped. Each frame's reflections become the next frame's input, so a single firefly or a slight energy gain in the denoiser compounds frame after frame instead of fading away.

Reflections only, with SSR multi-bounce feedback off and then restored: the restored version shows reflections inside reflections

Reflections only. With previous-frame feedback, reflections appear inside reflections.

Denoising. Rough reflections need more than one ray's worth of information per pixel, and Three.js does not yet have a spatio-temporal denoiser designed for SSR. Frostbite's is the model: reuse rays from neighbouring pixels, weighted by each pixel's own BRDF, then accumulate over time, reprojecting history by the reflected hit point's motion rather than the surface's, since reflections move differently from the surfaces that show them. zalo is working on this. His SVGF-based DenoiseNode has merged, but the SSR examples have gone back to the previous denoiser while it is reworked for reflections. The next attempt will be judged with a debug harness. One piece is already prototyped: demodulation, which divides the surface color out of the signal before filtering and multiplies it back afterwards, so the denoiser blurs lighting and not texture detail.

A split view of a checkered sphere on a checkered floor: on the left, denoising without demodulation blurs the texture away; on the right, with demodulation, the checker pattern stays sharp

The same noisy signal and the same filter, without demodulation (left) and with it (right).

Screen-space global illumination#

SSGI is the diffuse counterpart of SSR. It adds the bounce light a bake cannot know about, and it reuses almost everything SSR needs.

What the games require. Engines that use SSGI as part of their lighting, rather than as an effect laid over the image, treat it the way they treat SSR. Quantum Break is the plainest example: it prefers its screen-space diffuse lighting wherever the screen has an answer and falls back to precomputed GI everywhere else, and Godot 4 weights its baked lighting by SSIL's coverage the same way. In a cascade, that means SSGI has to:

  1. Output coverage: the fraction of its rays that found a surface on screen. The rest falls through to the lightmap or the probe grid.
  2. Trace the previous frame's lit image, so its result can feed the current frame's lighting, and so bounce light gains a bounce each frame.
  3. Denoise spatially and temporally, at half resolution, with the same edge-aware upsample as GTAO and SSR.

What Three.js has. SSGINode already exists, built on the visibility-bitmask approach of SSRT3, which extends GTAO's horizon search to gather light as well as occlusion. It outputs a GI texture and an AO texture. The SSGI example traces the current frame's beauty pass and composites over it:

const composite = vec4( scenePassColor.rgb.mul( ao ).add( scenePassDiffuse.rgb.mul( gi.rgb ) ), scenePassColor.a );

The gaps. The same as SSR's, and for the same reason.

  • Coverage output. SSGINode reports how much light it found, but not how much of the hemisphere it could answer. Without coverage it can only be added on top, and on a lightmapped surface that counts the static bounce twice: once from the bake, once from SSGI reading the image the bake lit. With coverage it replaces the baked bounce where it has an answer, so the two agree on static light and SSGI contributes only what changed: light bounced off moving objects, and from moving lights.
  • Previous-frame tracing. Reading the current frame's beauty pass forces SSGI to run after it, as a composite. The previous-frame tracing being added for SSR applies here unchanged, with the same rules: linear HDR before tone mapping, and clamped.
  • A diffuse source. Once it has both, SSGINode becomes the first entry in the diffuse list described below, with no change to the materials.

Reflections: local probes and the cascade#

What the games require. Three.js grew up rendering a single object floating in space, lit by one environment map, and for that it is superb. Indoors, one environment map is simply wrong: walk from a red room into a blue corridor and the floor should reflect red, then blue, not the sky.

Every game in the survey, and Unreal's reflection captures, solves this with local reflection probes: cubemaps captured inside each room, each with a box describing the volume it influences. Inside its box a probe is box-projected: the reflection ray is intersected with the room's box first, and the cubemap is sampled toward that hit point instead of along the raw ray. That one step removes most of a cubemap's parallax error, so the far wall's reflection stays put on the floor as you walk (Lagarde's write-up is the classic explanation). Where boxes overlap, probes blend, and the sky beyond them is just the outermost probe: one with an infinite box and no box projection.

What Three.js has. The ingredients are there. CubeCamera captures a cubemap, PMREMGenerator prefilters it for roughness, and getParallaxCorrectNormal() box-projects it, as the box-projected environment map example shows. But that is one probe per material, set by hand. There is no notion of a set of probes in a scene, of influence volumes, or of blending between them.

The gaps, and how we are filling them.

  • A probe system. Box-projected reflection probes for WebGPURenderer adds scene-level probes with influence boxes and blending, and is up as a draft PR.
  • Fast access to many probes. Binding one cubemap per probe does not scale. Cube array textures put every probe in one texture; sampling them is merged, the texture class is in review.
  • Probe normalization. A probe is captured at one point in a room, so it sees that room's bright, open view everywhere it is used. A dark corner or the inside of a cupboard then reflects a brightly lit room. Call of Duty's fix, introduced in Black Ops II and carried into Infinite Warfare, is to scale the probe's reflection by the ratio of the diffuse light at the pixel to the diffuse light at the probe. The lightmaps and probe grid already provide both, so it costs almost nothing. It does mean a reflection probe needs to see the diffuse result at the pixel, which the pluggable design allows for. It is still to do.
  • Small probes. PMREMGenerator has a minimum size of 256². A 128² probe is plenty for blurry room reflections, since SSR in front of it handles the sharp ones, and it takes a quarter of the memory. Prefiltering at 128² is still to do.
Two connected rooms in Three.js lit by three box-projected reflection probes

Two connected rooms, each reflecting its own walls, from three box-projected probes.

Indirect lighting in the BRDF#

What the games require. The cascades only work if the bounced light goes through the material's BRDF, so Fresnel, roughness and metalness apply to it exactly as they do to direct light. In a forward renderer, that means the beauty pass needs two inputs:

  • Indirect diffuse: SSGI where it has coverage; then the lightmap on static meshes; on everything else, the probe grid, falling back by coverage to the environment map outside the grid's volume.
  • Indirect specular: SSR, falling back by coverage to the box-projected probes, falling back to the environment map.

The sources that need occlusion apply it themselves, as described above.

Both then go through the usual split-sum approximation (Karis 2013) alongside the direct lights.

Engines differ in where this happens. Deferred engines like Unreal sample lightmaps while filling the G-buffer and composite reflections in a separate full-screen pass. Three.js renders forward, so the natural fit is to compute everything screen-space, SSR, SSGI and AO, before the beauty pass and let each material read it while it shades. Tracing the previous frame is what makes that order possible.

What Three.js has. Indirect diffuse is mostly in place: lightMap, LightProbeGrid and scene.environment all feed it, and AO already reaches it through builtinAOContext. Indirect specular comes only from scene.environment, or a material's own envMap.

The gap. This is the biggest missing piece. There is no way to plug a screen-space result, or a cascade of sources, into a material's indirect specular, the way AO is plugged into its diffuse. What we want is the AO pattern, extended to both terms. Something like this, where the names are proposals and not an API that exists yet:

const ao = aoPass.getTextureNode().sample( screenUV ).r;   // GTAO, as today

const lighting = indirectLightingContext( {
	diffuse: [
		ssgiPass.getDiffuseSource(),                 // what the screen can see
		lightMapDiffuse(),                           // then static meshes
		lightProbeGridDiffuse( lightProbeGrid )      // then dynamic ones
	],
	specular: [
		ssrPass.getSpecularSource(),                 // what the screen can see
		reflectionProbesSpecular( reflectionProbes ) // then the room
	]
} );                                                 // the environment map is the last fallback in both

scenePass.contextNode = builtinAOContext( ao, lighting );   // AO, as today, for the sources that read it

builtinAOContext is the same as today, wrapping the new context instead of nothing; the sources that want occlusion read it, and the cascade itself never sees it. With the default being today's behaviour, the environment map for both, nothing changes for the single-object scenes Three.js already does well. Indoor scenes opt in to the full cascade. The full design is in Pluggable Indirect Lighting for Three.js.

TRAA and TAAU close the loop#

What the games require. Temporal anti-aliasing is standard in every modern engine. Here it does double duty: its output is next frame's input to SSR, so it has to stay sharp under motion and must not feed ghosts or fireflies back into the reflections.

What Three.js has. A good TRAA node with Halton jitter, motion-vector reprojection and variance clipping of the history.

TRAA is in good shape, and no major gap stands in the way. Two refinements remain:

  • Sharper history. History sampled with a bilinear filter blurs a little more every frame the camera moves. Catmull-Rom history sampling, a draft PR, keeps it sharp.
  • Wider clipping at rest. Variance clipping is what stops ghosting: it rejects history that no longer matches the current frame. But it also rejects good history. TRAA already tightens its clip box to 0.5σ under fast motion, but at rest the box was 1σ, tight enough to clip converged sub-pixel detail, so a still image never fully resolved and longer histories actually scored worse. A drafted change, not yet submitted, widens the box to 2σ at rest and adds a historyLength control. Measured with the TRAA harness, a static view gains 2.15 dB of PSNR against the supersampled reference and a still camera with moving objects gains 1.3 dB, while fast camera motion is unchanged to within 0.06 dB.

Upscaling with TAAU#

Every engine this series measures itself against renders below the output resolution and reconstructs the difference over time: TSR in Unreal, FSR 2 in Godot, DLSS and FSR in Cyberpunk 2077. It is the largest single performance lever in a modern frame, because every pass before the resolve gets cheaper at once.

What Three.js has. TAAUNode already does this, and the TAAU example shows it at a quarter of the pixels. It follows Karis's temporal supersampling and FSR 2. The scene pass renders at a reduced resolution, set with setResolutionScale(). The node jitters the camera, reconstructs each output pixel from the 3×3 jittered input samples around it, and blends that with a history kept at the output resolution, using the same depth rejection and variance clipping as TRAA plus a lock that protects thin features:

scenePass.setResolutionScale( 0.5 );

const taauNode = taau( scenePassColor, scenePassDepth, scenePassVelocity, camera );

How it fits. TAAU takes TRAA's place as the last stage of the frame, and nothing else in the frame changes shape:

  • Everything before it runs at the render resolution. The G-buffer prepass, Hi-Z, GTAO, SSR, SSGI and the beauty pass all take the same resolution scale. The node applies its jitter to the camera for the whole render pipeline, so the prepass and the beauty pass stay aligned.
  • Its output is the image the next frame's SSR and SSGI read. That image is linear HDR at the output resolution, so reflections are traced at the render resolution but read colors that are sharper than the frame that traced them. The two rules from the SSR section still apply: read it before tone mapping, and clamp it.
  • Bloom, tone mapping and the rest of post run after it, at the output resolution.

It also changes the memory budget above. Of the 56 bytes per pixel, about 40 scale with the render resolution; the TAAU history and resolve targets, about 24 bytes, are per output pixel. At a resolution scale of 0.5 that is 34 bytes per output pixel instead of 56: a 4K frame drops from 465 MB to about 280 MB, and shades a quarter of the pixels.

TAAU and the joint bilateral upsample are not alternatives. They upscale different things. jointBilateralUpsample() is spatial and works within one frame: it takes a half-resolution intermediate buffer, AO or SSR or SSGI, up to the render resolution before the beauty pass reads it, guided by depth and normals. Those buffers are smooth and noisy, with no detail to recover, only edges to respect. TAAU is temporal: it takes the finished image from the render resolution to the output resolution, and recovers real sub-pixel detail from jittered frames. A frame can use both. With both at 0.5, though, SSR and GTAO run at a quarter of the output resolution on each axis, so with TAAU on it may be better to run them at the full render resolution and skip the bilateral step.

The gaps.

  • Wiring it into the full pipeline. The example upscales a single scene pass. The pipeline here has a prepass, half-resolution effects and a beauty pass that all have to agree on resolution and jitter, and SSR's previous-frame read has to take TAAU's output. None of that has been tried together yet.
  • Texture mip bias. Rendering at half resolution makes the GPU pick blurrier texture mips than the output deserves. Every temporal upscaler pairs with a negative mip bias of about log2 of the resolution scale. Three.js does not apply one yet.
  • The TRAA refinements. TAAU samples and clips its history the way TRAA does today, so Catmull-Rom history and the wider clip at rest apply to it as well, and the TRAA harness should measure it against the same supersampled reference.
  • A cheaper lock. The lock is one value per pixel but is stored in a second half-float RGBA history target. A single 8-bit channel would save 7 bytes per output pixel.

Future work#

A few threads are worth pulling once the pipeline is complete.

  • Cheaper noise. Houston Stratified Noise gives SSR excellent sampling without downloading a texture, but it costs about 400 operations per sample, and every pixel pays that every frame. That is too much for something so basic. The promising trade is to shift some of the work into a small precomputed map: a few hundred kilobytes, generated at startup in 50 ms or less, that keeps HSN's properties while making each sample a lookup and a few adds.
  • Auto-exposure. Walking from a dark room into a bright courtyard needs eye adaptation, as every game has. Three.js does not have it yet.
  • A physically based sun and sky. The Three.js sky is built for artistic control, not physical light levels. A room lit through its windows needs a sun and sky that agree with each other and with the exposure: atmospheric scattering computed on the GPU, with the sun's color and intensity derived from the atmosphere in real units. A prototype does single scattering; multiple scattering and clouds are still to do.
  • Reflection probes in glTF. KHR_lightmap carries lightmaps, but there is no standard way to ship reflection probes. The minimum is each probe's position and influence box, so a loader can capture the probes on load; a fuller version would also carry the prefiltered cubemaps, so nothing needs capturing at all.
  • Directional lightmaps. The lightmaps here store a single irradiance value per texel, so normal maps cannot respond to baked light. Directional lightmaps restore that response for a few more bytes per texel: an ambient term plus one dominant direction as in The Last of Us and Call of Duty, L1 spherical harmonics as in Frostbite, or spherical Gaussians as in The Order: 1886.

What is left#

The biggest gap is the last one a frame needs: a way to plug both cascades into the material's BRDF. It changes more of Three.js than any other stage here, so it has its own post.

How these changes get tested and reviewed, with a debug harness for each screen-space stage, is in the bonus post, Making Effective Three.js Improvements.