# Real-time GI that just brightened the walls — Tobi Moccagatta

> A light propagation volume can solve live and still read as a brightness slider: the missing sun-patch bounce, the dynamic range propagation destroys, and the read-side falloff that gives it back.

- Site: https://tobis.vision — Tobi Moccagatta, Creative Developer at basement.studio
- Canonical: https://tobis.vision/notes/real-time-gi-that-just-brightened-the-walls
- Contact: contact@tobis.vision

_Published 2026-09-01 · webgpu, tsl, three-js, global-illumination, debugging_

I built real-time global illumination for [a church nave](/experiments/lpv) —
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:

```js
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:

```js
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](/experiments/lpv). The sun moves, and the bounce moves with
it — which is the only proof that matters.

---

## Other pages

- [Creative Developer](https://tobis.vision) — hey, I'm tobi. creative developer at basement.studio, working on shaders and 3D on the web. peek into my vision. Markdown: https://tobis.vision/index.md
- [Experiments](https://tobis.vision/experiments) — A growing list of experiments across shaders, sound, scenes, and interaction. Markdown: https://tobis.vision/experiments.md
- [Works](https://tobis.vision/works) — Shaders and real-time 3D for Coinbase, Modal, Baseten, E2B and Rox at basement.studio, plus Shader Lab and Annex. Markdown: https://tobis.vision/works.md
- [About](https://tobis.vision/about) — Creative developer at basement.studio working on shaders, WebGL and WebGPU. Self-taught, in Buenos Aires, open to freelance. Markdown: https://tobis.vision/about.md
- [Contact](https://tobis.vision/contact) — How to reach Tobi Moccagatta: one email address, what to put in it, what he takes on, and current availability from Buenos Aires (UTC-3). Markdown: https://tobis.vision/contact.md
- [Privacy](https://tobis.vision/privacy) — What this site collects and what it does not: cookieless analytics, no forms, no trackers, and an in-memory rate-limit counter that expires with its window. Markdown: https://tobis.vision/privacy.md
- [Developer resources](https://tobis.vision/developers) — Machine-readable tobis.vision: llms.txt, the OpenAPI spec, the read-only content API, and the markdown representation of every page. Markdown: https://tobis.vision/developers.md
- [Notes](https://tobis.vision/notes) — Write-ups from building shaders and real-time 3D on the web: WebGPU, TSL and three.js, mostly the parts that went wrong. Markdown: https://tobis.vision/notes.md
- [Grass (2026)](https://tobis.vision/experiments/grass) — A walkable Val d'Orcia field: 790,000 instanced grass cards on a procedurally baked atlas, backlit through a translucency map, bent by a travelling wind field and parted by a displacement trail you leave behind you. Markdown: https://tobis.vision/experiments/grass.md
- [Critters](https://tobis.vision/experiments/critters) — Soft vinyl creatures raymarched from signed distance fields, morphing between forms as one continuous surface and reacting to your cursor. Markdown: https://tobis.vision/experiments/critters.md
- [LPV](https://tobis.vision/experiments/lpv) — Real-time global illumination via light propagation volumes, hand-written in TSL on WebGPU compute. Markdown: https://tobis.vision/experiments/lpv.md
- [BIP](https://tobis.vision/experiments/bip) — A holoprojector experiment with BIP, the robot influencer. Markdown: https://tobis.vision/experiments/bip.md
- [Fluid](https://tobis.vision/experiments/fluid) — A WebGPU fluid simulation ported from Pavel Dobryakov's classic to three.js TSL. Markdown: https://tobis.vision/experiments/fluid.md
- [Alpha-to-coverage, and the black edges it doesn't fix](https://tobis.vision/notes/alpha-to-coverage-foliage-atlases) — 790,000 grass cards you can walk inside cannot be depth-sorted. Alpha-to-coverage solves the ordering, a contrast curve solves mip alpha collapse, and neither touches the dark outlines. Markdown: https://tobis.vision/notes/alpha-to-coverage-foliage-atlases.md
- [An MCP server that lets an agent write WebGPU shaders](https://tobis.vision/notes/mcp-server-that-writes-webgpu-shaders) — You cannot render WebGPU in a Node process, so the server owns nothing and relays into a live editor tab. The compile feedback loop is what makes an agent able to write shaders at all. Markdown: https://tobis.vision/notes/mcp-server-that-writes-webgpu-shaders.md
- [MSAA on a RenderTarget breaks GTAO on WebGPU](https://tobis.vision/notes/msaa-breaks-gtao-on-webgpu) — Why three.js GTAO fails with `Invalid ShaderModule "fragment_GTAO"` the moment a RenderTarget asks for samples, and what to do instead. Markdown: https://tobis.vision/notes/msaa-breaks-gtao-on-webgpu.md
- [Making three.js bloom 9x faster with the Call of Duty blur](https://tobis.vision/notes/three-js-bloom-jimenez-dual-filter) — three.js renders a fixed 5-mip chain of separable Gaussians every frame and ignores your radius. The Advanced Warfare downsample/upsample took bloom from 8.2ms to 0.9ms. Markdown: https://tobis.vision/notes/three-js-bloom-jimenez-dual-filter.md

## Machine-readable

- [llms.txt](https://tobis.vision/llms.txt)
- [sitemap.xml](https://tobis.vision/sitemap.xml)
- [robots.txt](https://tobis.vision/robots.txt)
- [openapi.json](https://tobis.vision/openapi.json)
- [developer resources](https://tobis.vision/developers)
