Real-time GI that just brightened the walls

webgpu, tsl, three-js, global-illumination, debugging

I built real-time global illumination for a church nave — light propagation volumes, hand-written in TSL on WebGPU compute. Order-2 spherical harmonics, three camera-centred cascades, energy-conserving propagation, a solve that re-runs live whenever the sun moves and amortizes to zero dispatches when it doesn't. The technique worked. Then it failed twice, in two different ways, and neither failure was in the compute passes.

The first complaint: you can't distinguish it from a baked scene. The second, once I could: the GI just brightens the wall textures. Both turned out to be design problems wearing a rendering-bug costume.

A correct solve looks baked

When the sun is static, a correct real-time solve and a correct baked one are identical by definition — that is what correct means. No rendering change fixes that. It is not a bug to hunt, and I wasted time treating it as one.

What settled it during development was one line of shader. The indirect term is added through each material's emissive node, so gating it per-pixel on screen position gives a split view of the same frame — same live solve, no second render pass:

material.emissiveNode = indirect
  .mul(albedo)
  .mul(step(wipeX, screenUV.x))

Park wipeX at -1 and step is 1 everywhere, so the gate costs nothing when it is off. Slide it across the nave and one side of a hairline has bounce light and the other doesn't, in the same frame. As a way to check your own work this is worth more than any amount of camera choreography — you see the contribution isolated, live, without rendering anything twice.

The volume had no first bounce

The harder complaint took measurement. With everything pinned, the volume's inputs turned out to be: light injected at the window glass, and a feedback term that re-injects what the volume held last frame. Nothing else.

Which means the brightest surfaces in the building — the window-shaped sun patches on the floor, the thing every path tracer bounces off first — injected nothing. Sun → floor → vault, the dominant indirect path in a real interior, did not exist. The room's fill was diffused window glow, and diffused window glow is exactly what a flat brightness lift looks like.

The fix reuses machinery the scene already had. The bounce surfels (8k samples across every solid surface) now test the sun's own shadow map: a surfel inside a patch re-emits albedo × sunlight × cookie tint as a cosine lobe along its normal, so the bounce carries each window's colour and moves when the sun does. The existing feedback term turns that into third-and-beyond bounces for free.

One WebGPU wrinkle worth recording: sampling a depth texture in a compute stage binds a comparison sampler, and WGSL has no textureSampleLevel overload for that pair — the pipeline fails to compile. A raw texel fetch needs no sampler at all:

const texel = ivec2(shadowUv.mul(vec2(textureSize(texture(shadowMap), int(0)))))
const depth = textureLoad(shadowMap, texel)

The volume is far too coarse to miss the filtering.

More bounce just made the walls brighter

With the first bounce in, turning it up still disappointed: the walls got brighter, not more lit. The gain dial scales energy, but what the eye wants from bounce light is shape — bright near the source, falling off, dark where nothing reaches — and the shape was fixed by things no gain dial touches.

Propagation is the culprit. Thirty steps of energy-conserving spreading across a 32³ grid flatten the field's near-to-far contrast to roughly 2:1. In Cycles, indirect light next to a sunlit patch is easily 20–50× a far corner, and every added unit of bounce energy arrives with its own visibility. Here, added energy diffuses into the same shallow gradient, the tonemapper's shoulder compresses the top of it, and the result reads as chalk. The information about where light can't reach was never in the field; amplifying the field cannot add it.

So the fix is not more energy, it is shape:

  • Kill the sourceless ambient. The injection had a floor for sun-less windows — 8% of full brightness, times an entire building of clear glass, is a giant constant lightbox. The injection floor is now 0.015, decoupled from what the panes render at (looking at glass, you see the sky; the room does not receive that number).
  • Put the falloff back at read time. A luminance-based exponent on the volume sample sends dim cells toward black and keeps cells near patch-brightness. It applies only where materials read the volume — the bounce feedback keeps reading linear values, so the loop's gain stays state-independent and stable.
  • Occlude at architectural scale. GTAO was tuned as a 6cm contact shadow; at half a metre it subtracts light in the aisles and vault pockets, which is the other half of "dark where light doesn't reach."

After that, the bounce dial finally behaves like intuition expects: turning it up deepens the glow around the patches, because the far field stays crushed no matter how much energy you add. And a surprise dividend — with the falloff curve owning the far field, half the propagation steps produce an identical image at half the solve cost.

The resolution trap

The obvious "more quality" knob made everything worse. Doubling the grid to 64³ is 8× the cells and 2× the propagation steps — 16× the solve, which during any sun motion took the frame rate from 120 to 17. And it looked worse: each surfel injects into exactly one cell, and at 32³ the coarse trilinear filtering blurs those single-cell deposits into smooth gradients. At 64³ they resolve as blotches. The low resolution was doing free denoising.

The lesson

Every dial in this system scales energy on some path, and no amount of energy scaling can add information a field doesn't contain. When a control disappoints, the useful question is not "how much" but "what shape" — and shape lives in the places gain never visits: what gets injected, how far it spreads, and what subtracts from it at the end.

The scene is live. The sun moves, and the bounce moves with it — which is the only proof that matters.