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

Back to Blog Listing

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:

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.

GameStatic geometryDynamic objectsOn top
DOOM (2016) (talk, interview)Baked indirect lightingIrradiance volumesReal-time direct lights, SSAO
DOOM EternalPath-traced lightmapsIrradiance volumesReal-time direct lights
Call of Duty: Infinite WarfareDirectional lightmapsLight grid of probesReal-time key lights
The Order: 1886Spherical Gaussian lightmapsSpherical Gaussian probe gridsReal-time sun

They share one recipe:

  1. 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.
  2. 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.
  3. Keep the key lights real-time. They can still move, flicker and cast sharp shadows and highlights, while their bounce is baked.
  4. 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 engineScreen-space stageWhere it has no answerRelation to baked light
Quantum BreakScreen-space diffuse lighting, traced from the previous framePrecomputed GI in irradiance volumesReplaces it where the screen has an answer
Unreal Engine 5, LumenScreen traces, run firstDistance fields or hardware ray tracing, lit from a surface cacheNone; Lumen does not bake
Unity HDRPSSGI, optionalReflection probes and skyReplaces lightmaps and light probes
Godot 4SSIL, optionalLightmapGI, VoxelGI or SDFGIWeights it by SSIL's coverage, then adds SSIL
Unreal Engine 4SSGI, optionalBaked lighting from LightmassRecommended 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 / engineFirstThenLast
DOOM (2016)SSR, reading the previous framePre-baked cubemap probes
DOOM Eternal (SSR, probes)SSR, in the forward passHand-placed, box-projected cubemap probes
Frostbite (SSR, fallbacks)Stochastic SSR with spatial and temporal filteringLocal reflection probesDistant probe
Killzone: Shadow FallSSR, reading the previous frameLocalized cubemapsBackground 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.

Coverage cascades for indirect diffuse and indirect specular, side by side, with rows for screen space, surface cache, local probes and environment. Indirect diffuse: SSGI, then the lightmap, with coverage 1 on lightmapped meshes and 0 elsewhere, then the light probe grid, whose coverage fades at the volume's edges, then the environment map, which always answers. Indirect specular: SSR, then reflection probes, then the environment map. Each source passes on the 1 minus coverage it could not answer.

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 engineIndirect diffuseIndirect specular
Unreal Engine, without LumenBaked lightmaps on static objects, a baked Volumetric Lightmap for dynamic onesSSR, reflection captures, sky light
Call of Duty: Infinite WarfareLightmaps on large structural geometry, baked probes for everything else—
DOOM EternalPath-traced lightmaps on static geometry, irradiance volumes for dynamic objectsSSR, then hand-placed, box-projected probes
DOOM (2016)Baked lighting on static geometry, irradiance volumes for dynamic objectsSSR 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 BreakScreen-space diffuse lighting first, falling back to precomputed GI in irradiance volumesSSR first, falling back to precomputed specular probes
Unreal Engine 5, LumenScreen traces first, then distance fields or hardware ray tracing, with lighting from a surface cacheLumen reflections, traced the same way
Unity HDRPSSGI in place of lightmaps and light probes, falling back to reflection probes and sky where rays missSSR, 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, SVOGIVoxel ray tracing at run time, no bakingLight probes
Godot 4A choice of LightmapGI, VoxelGI or SDFGI, with screen-space indirect lighting in front, weighting them by its coverageReflection probes blended with SSR; VoxelGI and SDFGI also provide reflections
Path of Exile 2Radiance 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:

  1. A G-buffer prepass: depth, normals, roughness and motion vectors.
  2. Ambient occlusion from the G-buffer.
  3. SSR and SSGI, traced against the previous frame's image and denoised.
  4. 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.
  5. 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.