How AAA Games Light Indoor Scenes
DOOM, Call of Duty, Frostbite and Killzone light a room in remarkably similar ways: baked bounce light, probes for moving objects, and a cascade of reflection sources that fall through by coverage. This is a study of what shipped, and of the pipeline a web renderer can take from it.
Ben Houston • • 16 min read
This post looks at what shipped games do within limits like those, and at what they have in common.
A few terms first#
Lighting papers use several names for the same things. These are the ones this series uses:
- Direct lighting: light arriving straight from a light source.
- Indirect diffuse (irradiance): light bounced off other surfaces, arriving from all directions.
- Indirect specular (radiance): bounced light seen in the mirror direction; reflections.
- Ambient occlusion (AO): how much nearby geometry blocks indirect light.
- Specular occlusion (SO): the same, for the reflection direction.
- Source: one technique that can supply indirect light, such as a lightmap, SSR or the environment map; each step of a cascade is a source.
- Coverage: how much of a pixel a source could answer, from 0 to 1.
- SSR: screen-space reflections, found by tracing rays through the depth buffer.
- SSGI: screen-space global illumination; the same tracing, gathering bounced diffuse light instead of reflections.
Where to look#
If you are a graphics developer, reading about how games render is one of the great pleasures of the job. I started where I always start, with the granddaddy: Lagarde and de Rousiers' Moving Frostbite to Physically Based Rendering. It has been around the longest, and it is the paper we used to bring physically based materials to Three.js years ago, which was one of my previous quests to improve it. This time I am leaning on it again, for its sections on reflection probes and on combining indirect lighting.
Conference talks and papers matter too, and GDC and SIGGRAPH Advances are where the engine teams publish. But my main resource has been something else: frame breakdowns of shipped games written by people outside the teams, who capture a frame in a GPU debugger and walk through every pass. They are some of the best technical writing on the internet, and they show what actually ships, not just what was presented. A few blogs that do this regularly:
- Adrian Courrèges' graphics studies, including DOOM (2016), plus a compilation of everyone else's.
- Behind the Pretty Frames, long breakdowns of games such as Death Stranding and Elden Ring.
- Emilio López's The Code Corsair.
- Thomas Poulet's frame analyses.
- Simon Coenen's DOOM Eternal graphics study.
The most useful games to study are the ones that look great and do it the way a web renderer can: without real-time ray tracing. They split indoor lighting into two problems, global illumination and reflections, and their answers to each are remarkably consistent.
Global illumination#
For global illumination, these are the games that show the recipe most clearly.
| Game | Static geometry | Dynamic objects | On top |
|---|---|---|---|
| DOOM (2016) (talk, interview) | Baked indirect lighting | Irradiance volumes | Real-time direct lights, SSAO |
| DOOM Eternal | Path-traced lightmaps | Irradiance volumes | Real-time direct lights |
| Call of Duty: Infinite Warfare | Directional lightmaps | Light grid of probes | Real-time key lights |
| The Order: 1886 | Spherical Gaussian lightmaps | Spherical Gaussian probe grids | Real-time sun |
They share one recipe:
- Bake indirect diffuse into lightmaps for everything that does not move. Do it offline, with a slow, high-quality path tracer. Store irradiance only, without the surface color, and multiply the color in at runtime. Global illumination is low-frequency, so lightmaps can be low-resolution: the texture detail comes from the material.
- Light dynamic objects with probes: a grid or volume of points that store incoming light in every direction, sampled and interpolated wherever a character or prop happens to be.
- Keep the key lights real-time. They can still move, flicker and cast sharp shadows and highlights, while their bounce is baked.
- Add screen-space ambient occlusion for the small-scale contact shadows a lightmap is too coarse to hold, and for where dynamic objects meet static ones.
Put together, these form a cascade. Each stage answers what it can, measured as a coverage from 0 to 1, and the next stage fills in the rest, 1 − coverage. The lightmap comes first, with coverage 1 on a static mesh that has one and 0 on any other mesh. Everything without a lightmap falls through to the probe grid, but a probe grid only covers the volume it was placed in, so its coverage fades to zero near the volume's edges. Beyond the grid, the environment map serves as one global irradiance probe and always has an answer, so an object walking out of the grid changes lighting smoothly instead of popping. Some engines put a screen-space stage at the front of the cascade; that is SSGI, discussed below, and both cascades are drawn side by side at the end of the reflections section.
The trade-off is that baked bounce light is static. If you move a wall, or switch off a light, its bounce does not update. For most indoor scenes, from architecture to retail to most games, that is an excellent trade, and it is the one these games made.
In Three.js the line between baked and live is thinner than it was for those games, because the baker runs in the browser, in the renderer that draws the frame. Baking on load and updating while the scene runs are the same code. LightProbeGrid.bake() takes a range of probes, so a grid can be re-captured a few probes per frame after a light changes, as the Sponza probe example does. Cyberpunk 2077's non-ray-traced GI and the Lumen Lite tier in Unreal Engine 5.8 are built on probes refreshed this way. So the diagrams in this series say cached rather than baked: lighting computed ahead of the frame that uses it, whether that was at authoring time, on load, or a few frames ago.
What about SSGI? Screen-space global illumination is the diffuse counterpart of screen-space reflections: it traces rays through the depth buffer and gathers the bounced light it finds on screen. None of the four games above uses it, but other games and engines do, including on top of baked lighting:
| Game or engine | Screen-space stage | Where it has no answer | Relation to baked light |
|---|---|---|---|
| Quantum Break | Screen-space diffuse lighting, traced from the previous frame | Precomputed GI in irradiance volumes | Replaces it where the screen has an answer |
| Unreal Engine 5, Lumen | Screen traces, run first | Distance fields or hardware ray tracing, lit from a surface cache | None; Lumen does not bake |
| Unity HDRP | SSGI, optional | Reflection probes and sky | Replaces lightmaps and light probes |
| Godot 4 | SSIL, optional | LightmapGI, VoxelGI or SDFGI | Weights it by SSIL's coverage, then adds SSIL |
| Unreal Engine 4 | SSGI, optional | Baked lighting from Lightmass | Recommended as a supplement to it |
Simply added on top of a bake, SSGI has two problems. A lightmap already holds the static bounce, so SSGI counts it twice. And SSGI loses light the moment its source leaves the screen, which is far more noticeable in diffuse lighting than in a reflection. That is why Quantum Break and Godot do not add it: they put it in front of the baked GI as a coverage cascade. Quantum Break's notes put it plainly: "We prefer screen-space lighting when possible", falling back to the precomputed GI for geometry the screen cannot see. Where SSGI has an answer it replaces the baked bounce, the bake answers everywhere else, and SSGI contributes what a bake cannot know: light bounced off moving objects and from moving lights. So the full pipeline has it as the first stage of the diffuse cascade, the same position SSR holds for reflections.
Reflections#
For reflections, the shipped-game consensus is just as strong.
| Game / engine | First | Then | Last |
|---|---|---|---|
| DOOM (2016) | SSR, reading the previous frame | Pre-baked cubemap probes | |
| DOOM Eternal (SSR, probes) | SSR, in the forward pass | Hand-placed, box-projected cubemap probes | |
| Frostbite (SSR, fallbacks) | Stochastic SSR with spatial and temporal filtering | Local reflection probes | Distant probe |
| Killzone: Shadow Fall | SSR, reading the previous frame | Localized cubemaps | Background cubemap |
Every one of these is a cascade. Screen-space reflections are tried first, because they are sharp and correct, but they can only reflect what is already on screen. Wherever SSR has no answer, the renderer falls back to local reflection probes: cubemaps captured inside each room. Beyond the probes is the sky, the distant environment, which always has an answer.
The key idea that makes a cascade work is coverage. Each stage outputs not just a color but how much of the pixel it could answer, from 0 to 1. The next stage fills in the rest, 1 − coverage. SSR gives high coverage on a floor reflecting a wall that is on screen, low coverage near the screen edge, and none for a reflection pointing out of the window. The probes fill the gap.
The two cascades side by side: each source answers what it can, and the next fills in the rest.
This is the same cascade as the one for diffuse light above, which is why the chart draws them together. SSGI sits in front of the lightmap and probe grid exactly as SSR sits in front of the reflection probes.
Not every engine does it this way#
The games above were chosen because they agree. Widen the survey and the picture is more varied. Each row links to the talk, documentation or frame breakdown it comes from. A dash means that source does not cover that half, not that the game has nothing there.
| Game or engine | Indirect diffuse | Indirect specular |
|---|---|---|
| Unreal Engine, without Lumen | Baked lightmaps on static objects, a baked Volumetric Lightmap for dynamic ones | SSR, reflection captures, sky light |
| Call of Duty: Infinite Warfare | Lightmaps on large structural geometry, baked probes for everything else | — |
| DOOM Eternal | Path-traced lightmaps on static geometry, irradiance volumes for dynamic objects | SSR, then hand-placed, box-projected probes |
| DOOM (2016) | Baked lighting on static geometry, irradiance volumes for dynamic objects | SSR reading the previous frame, then static cubemap probes |
| Killzone: Shadow Fall | — | SSR, then localized cubemaps, then a global cubemap |
| Frostbite | — | SSR, then local probes from small to large, then a distant probe |
| Quantum Break | Screen-space diffuse lighting first, falling back to precomputed GI in irradiance volumes | SSR first, falling back to precomputed specular probes |
| Unreal Engine 5, Lumen | Screen traces first, then distance fields or hardware ray tracing, with lighting from a surface cache | Lumen reflections, traced the same way |
| Unity HDRP | SSGI in place of lightmaps and light probes, falling back to reflection probes and sky where rays miss | SSR, then reflection probes, then sky, evaluated in order until the weights reach 1 |
| GTA V | — | A 128² environment cubemap re-rendered every frame, plus a planar reflection for water |
| CRYENGINE, SVOGI | Voxel ray tracing at run time, no baking | Light probes |
| Godot 4 | A choice of LightmapGI, VoxelGI or SDFGI, with screen-space indirect lighting in front, weighting them by its coverage | Reflection probes blended with SSR; VoxelGI and SDFGI also provide reflections |
| Path of Exile 2 | Radiance cascades, traced in screen space | — |
Each of these is a sensible answer to a different question.
Unreal's baked path, Call of Duty and DOOM all bake. Unreal's documentation recommends it where lighting does not need to change, and the Call of Duty talk gives the other reason: the alternatives were too expensive for a 60 Hz game or not good enough. Even so, the three split static and dynamic objects differently. Call of Duty lightmaps only its largest surfaces and lights everything else from probes.
GTA V re-renders a small cubemap of the scene every frame. The breakdown points out what makes that affordable: the game already has a very low-polygon version of the city for distant detail. Cars and characters are left out of the cubemap, so your car reflects the street but not the car next to it.
CRYENGINE's SVOGI trades baking for a voxel structure built at run time. By default it supplies only diffuse light, and the documentation says light probes are still needed for specular. That is two different sources for the two slots, in one engine, by design.
Godot ships three diffuse systems and tells users to pick one: SDFGI as the starting point for desktop games, baked lightmaps and reflection probes as the only options on mobile.
Path of Exile 2 uses radiance cascades, a newer technique from Alexander Sannikov at Grinding Gear Games. His paper says the shipped version works in screen space, a choice motivated by the game's fixed camera.
Frostbite and Unity's HDRP describe the specular side most plainly. Frostbite's course notes give it as pseudocode: evaluate SSR, then local probes while coverage is below 1, then the distant probe. HDRP calls it a reflection hierarchy: SSR sets its own weight, each probe has a weight, the sky has a fixed weight of 1, and the renderer walks the list until the total reaches 1. Lumen and HDRP's SSGI apply the same shape to diffuse: trace the screen first, and hand whatever it misses to something slower or coarser. Quantum Break and Godot 4 do the same over baked GI.
No two rows are the same, and no row is wrong. A product viewer, an architectural walkthrough and a game with moving lights need three different stacks. What every row does share is the shape: an ordered list of sources for diffuse light and another for reflections, each source answering what it can and handing the rest to the next. That shape is what Three.js should adopt, with the list left for the application to set. Part 6 is that proposal.
The pipeline to build#
Of all those stacks, the one that fits the budget from Part 2 is the baked one that DOOM, Call of Duty and Unreal without Lumen share. It needs no ray tracing hardware, its per-frame cost is low and predictable, and its caches can be filled in the browser.
It has two parts. The setup fills three caches:
- Lightmaps for static meshes: unwrap, bake, then denoise and stitch seams.
- A light probe grid for moving objects: place the grid, capture, and project to spherical harmonics.
- Reflection probes for each room: place them with influence boxes, capture, and prefilter for roughness.
Each frame then reads those caches and adds the screen-space work on top:
- A G-buffer prepass: depth, normals, roughness and motion vectors.
- Ambient occlusion from the G-buffer.
- SSR and SSGI, traced against the previous frame's image and denoised.
- A beauty pass in which each material reads both cascades: SSGI, lightmap, probe grid and environment for diffuse; SSR, reflection probes and environment for specular.
- Temporal anti-aliasing or upscaling, whose output feeds the next frame's SSR and SSGI.
None of these pieces is new; every one is in the games above. What is new is this particular combination in Three.js, chosen because it looks excellent, runs in real time on ordinary hardware, and stays small in both code and download size.