How global illumination (GI) works in this game: baked, light-probe-based bounce lighting stored in a sparse, adaptive 3D structure instead of a dense grid. This doc is for game devs comfortable with Three.js / WebGPU who aren't graphics specialists.
- We bake the bounced light of each world once (offline, in the browser) into a grid of light probes.
- Each probe stores one colour: the indirect light arriving at that point (orange near a canyon, green near grass, etc.).
- At runtime, every surface looks up the probes near it and adds that colour as soft fill light — so a black wall next to an orange canyon picks up some orange, grass tints the bottom of a tree trunk, and so on.
- The probes are stored in an adaptive brick volume (like Unity's Adaptive Probe Volumes / APV): tiny where colour changes fast (seams, foliage), coarse where it's flat (a big uniform wall). This makes it cost a few MB of VRAM instead of tens or hundreds.
- The whole lookup runs per-vertex in the shader, so it's nearly free on low-poly worlds.
For each probe we store incoming indirect irradiance — basically "what colour and how much bounced light reaches this point in space." It is independent of whatever surface ends up receiving it. At runtime a surface reconstructs its bounce lighting as:
bounceLight = probeColour × surfaceAlbedo
That's just the diffuse lighting equation (incoming light × surface colour). Because the probe stores the incoming light, the same probe correctly lights a grey rock, a red wall, or a moving character that walks through that spot — they each multiply it by their own colour.
Why a grid of probes and not one global ambient? Ambient light is the same everywhere. GI is local — the orange only shows up near the canyon. You need a value that varies with position, which means storing it across space.
The obvious approach is a uniform 3D grid: a Data3DTexture with one texel per probe, sampled by world
position with hardware trilinear filtering. It works and it's simple. The catch is memory scales with
volume — O(n³):
- Halve the probe spacing → 8× the texels.
- Most of those texels are empty air above the terrain or buried underground — wasted.
- The useful signal actually lives on a 2D-ish shell hugging the surfaces, but a dense grid pays for the whole 3D box anyway.
A fine grid (say 0.5 m spacing) over a 200×60×200 world is ~20M texels → 100+ MB of VRAM. Too much for a web game.
The adaptive brick volume keeps the nice property (sample by world position, hardware filtering) while only spending memory where detail actually exists.
Split the volume into bricks. Every brick holds the same small number of probes — here 4×4×4 — but a brick's level sets how much world space those 4³ probes cover:
| level | probe spacing | one brick spans |
|---|---|---|
| 0 | finest (e.g. 0.5 m) | 2 m |
| 1 | 2× | 4 m |
| 2 | 4× | 8 m |
| 3 | 8× | 16 m |
So a flat region (a uniform canyon wall) is covered by a few big level-3 bricks — cheap. A detailed region (a colour seam, the base of a tree, neon signage) uses small level-0 bricks — detailed. Detail goes exactly where the bounce colour changes, and nowhere else.
We keep only the bricks that touch a surface (plus a one-brick margin), and skip all the empty air.
The bricks live in two GPU textures:
Indirection texture (small, NEAREST) Atlas texture (LINEAR / trilinear)
one texel per "base brick" cell every kept brick, packed in a grid
┌──────────────────────────┐ ┌──────┬──────┬──────┬─────┐
│ rgb = atlas slot (x,y,z) │ ──points──▶ │brick0│brick1│brick2│ ... │ each brick = 6×6×6 texels
│ a = level + 1 (0=empty) │ ├──────┼──────┼──────┼─────┤ (4³ probes + 1 apron each side)
└──────────────────────────┘ │ ... │ │ │ │ rgba8, RGBM-encoded
└──────┴──────┴──────┴─────┘
- Indirection is a coarse 3D texture: for any world cell it tells you which brick covers it and at what level. Sampled NEAREST (it's an index, you can't blend indices).
- Atlas holds the actual probe colours for every kept brick, packed side by side. Sampled LINEAR so the GPU does trilinear interpolation between probes for free.
Each brick is stored as 6³ texels = 4³ probes + a 1-texel border ("apron") on every side. The apron is filled with the neighbouring bricks' edge values. Without it, trilinear filtering at a brick's edge would bleed into whatever unrelated brick sits next to it in the atlas. With it, a sample stays within its brick's own padded region and edges line up with neighbours. (This is the standard trick for sampling packed tiles/bricks.)
WebGPU's hardware-filterable colour formats are all ≥4 bytes, and bounce light is HDR-ish (can exceed 1.0).
We pack each probe into 4 bytes as RGBM: rgb (3 bytes) + a shared multiplier M (1 byte).
encode: M = max(r,g,b) / RANGE decode: colour = rgb × M × RANGE (RANGE = 8)
rgb = colour / (M × RANGE)
M is per-texel, so a dim probe still uses the full 8-bit precision (its M just shrinks with it). RGBM
also interpolates correctly enough under hardware trilinear, which an indexed/palette format would not
(you can't linearly blend palette indices) — that's why we didn't go further down the compression route.
The bake is a CPU pass (no GPU capture) that runs on a dev-only page and writes a file committed to the repo.
1. Cast rays per probe to get irradiance. For each probe we shoot 128 rays over a sphere using a BVH
(three-mesh-bvh) over the world surfaces. Each ray that hits a surface contributes surfaceAlbedo × light × falloff; sky misses contribute nothing. The average is the probe's colour.
// gi-irradiance-bake.ts (simplified)
for (const dir of sphereDirs) {
const hit = bvh.raycastFirst(ray, ..., RAY_DIST) // RAY_DIST = 40 m
if (!hit) continue
let received = AMBIENT_FILL + Math.max(0, normal.dot(SUN_DIR) /* if not shadowed */)
received /= 1 + (hit.distance / GI_FALLOFF_DIST) ** 2 // closer surfaces dominate
rgb += albedoOf(hit) * received
}
probe = rgb / RAY_COUNTThe distance falloff is important: without it, a big neighbour (the ground under a tree) tints the
whole receiver uniformly, because a large flat surface lights a point almost the same regardless of
distance. The 1 / (1 + (d/falloff)²) weight makes nearby surfaces dominate, so the bounce concentrates at
seams and the base of objects — which is what reads as "GI" rather than a flat colour wash.
2. Build the adaptive bricks. We compute a min/max pyramid of the bounce colour over the grid, then
walk it top-down: a block becomes a single coarse brick if its colour range ≤ eps (it's flat enough);
otherwise it subdivides into 8 and we recurse, down to the finest level.
// gi-bricks.ts (simplified)
const visit = (level, x, y, z) => {
if (!occupied(level, x, y, z)) return // empty air → no brick
if (level === 0 || colourRange(level, x, y, z) <= eps) {
leaves.push({ level, x, y, z }) // keep this brick at this size
return
}
for (const child of eightChildren) visit(level - 1, ...child) // too varied → subdivide
}Then we pack every leaf brick into the atlas (sampling the filled field at each probe + apron position, RGBM-encoding it) and fill the indirection texture so every base cell points at its brick + level.
eps is the main quality/size dial: higher = more aggressive coarsening = smaller atlas, less detail.
maxLevel caps how big a single brick may get in flat areas.
This is the whole sampler, written in Three.js's TSL (node-based shader graph). Given a world position it finds the brick, reads the probe colour, and decodes it:
// gi-volume.ts (the heart of volumeIrradiance, simplified)
const p = box.mul(giRes).sub(0.5) // continuous probe-space coords
const baseBrick = p.div(BRICK).floor() // which base cell
const ind = giIndNode.sample(baseBrick.add(0.5).div(giIndDims)) // NEAREST: slot + level
const level = ind.a.mul(255).sub(1).max(0)
const span = float(2).pow(level) // 2^level
const slot = ind.rgb.mul(255) // atlas slot xyz
const originCell = baseBrick.div(span).floor().mul(span).mul(BRICK)
const q = p.sub(originCell).add(0.5).div(span).sub(0.5) // local probe coords in this brick
const atlasTexel = slot.mul(BRICK_PADDED).add(BRICK_PAD).add(q) // skip the apron
const sample = giAtlasNode.sample(atlasTexel.add(0.5).div(giAtlasDims)) // LINEAR: trilinear
const irr = sample.rgb.mul(sample.a).mul(GI_RGBM_RANGE) // RGBM decodeTwo texture reads: one cheap NEAREST index lookup, one trilinear atlas read. That's it.
The entire lookup is wrapped in TSL's varying():
const gi = varying( irr.mul(intensity) /* ...etc */ )varying() pushes that whole subgraph — including the two texture samples — into the vertex shader, and
the GPU interpolates the result across each triangle. So instead of sampling the volume per pixel, we
sample it per vertex (a few times per triangle) and let the rasteriser blend.
The worlds are low-poly and the irradiance field is smooth, so this looks the same as per-pixel while doing a tiny fraction of the work. Compared to a dense grid this is the same technique — it just now does one extra (cheap) texture fetch and a little address math per vertex, against a few-MB atlas that sits happily in cache (versus a 100 MB grid that doesn't). Net runtime cost difference: negligible.
Caveat: because the lookup is per-vertex, effective GI detail is limited by mesh tessellation. A wall that's a single quad gets GI interpolated from 4 corners no matter how fine the bricks are there. If you ever want crisper detail on big flat faces, the small atlas makes per-fragment sampling affordable too (just don't wrap it in
varying()) — a dense fine grid couldn't afford that.
The decoded probe colour is added as indirect light in a custom Lambert lighting model:
indirectLight += giIrradiance × surfaceAlbedo // physically-correct diffuse bounce
A couple of stylisation knobs sit on top (all runtime uniforms, no re-bake needed):
- bleed — physically, a near-black surface reflects ~nothing, so it would ignore a bright neighbour's
colour even though the probe beside it is coloured.
bleedlifts the reflecting albedo toward white (mix(albedo, 1, bleed)) so dark surfaces visibly pick up neighbour colours. Stylised, not physical. - self-reinforcement — we subtract most of the bounce that's parallel to a surface's own colour, so an orange canyon doesn't pile orange onto itself; cross-colour bounce (orange onto green) is kept.
- intensity — overall strength.
These are split into two sets — one for world surfaces (terrain/structures) and one for characters/decorations — so the world can soak up strong colour bleed without characters turning lurid.
The baked file is dead simple: a tiny header, then the indirection bytes, then the atlas bytes. Those two byte arrays are the two GPU textures, uploaded verbatim. So:
VRAM ≈ on-disk (uncompressed) file size ≈ atlasTexels×4 + indirectionTexels×4
No mipmaps are generated (the filters don't use them), so there's no hidden ×1.33. A whole world is a few MB
(e.g. ~1–6 MB depending on size and eps), and only one world is resident at a time.
For repo and download size the files are committed gzip-compressed (the atlas is full of zeros and
smooth runs, so gzip ~3×s it) and decompressed natively in the browser via DecompressionStream('gzip') —
no dependency, no wasm. Note this is separate from CDN Content-Encoding: gzip here shrinks the source
bytes; a CDN may still negotiate brotli/zstd on the wire independently.
On zone changes the retired textures are disposed one load later (so they're out of any in-flight GPU submit), capping resident GI memory at ~2 worlds.
Bake-time (require re-baking, saved per world in gi-config.ts):
| knob | what it does |
|---|---|
spacing |
finest probe spacing — the most detail a brick can have (smaller = finer/denser) |
falloff |
distance-falloff half-distance — smaller = more local, sharper colour bleed |
eps |
brick coarsening tolerance — higher = smaller atlas, less detail |
maxLevel |
biggest a brick may get in flat areas |
Runtime (live sliders, no re-bake), per receiver type (world / character):
| knob | what it does |
|---|---|
intensity |
overall bounce strength |
self |
how much a colour reinforces itself (lower = more neighbour colour) |
bleed |
how much dark surfaces pick up colour regardless of their own albedo |
| file | role |
|---|---|
client/lib/gi-irradiance-bake.ts |
ray-traced 1-bounce irradiance bake |
client/lib/gi-bricks.ts |
adaptive brick builder (pyramid → octree → pack) |
client/lib/gi-format.ts |
binary format, RGBM, gzip helpers |
client/lib/gi-volume.ts |
runtime: textures, uniforms, the TSL sampler |
client/pages/bake-gi.tsx |
dev bake page + brick debug viewer |
client/scenes/GiBricksDebug.tsx |
wireframe brick boxes (by level / by colour) |
Same idea (sample baked probes by world position with trilinear filtering), but memory scales with surface detail (≈O(area)) instead of volume (O(n³)) — so you get fine 0.5 m detail at colour seams for a few MB, where a uniform fine grid would cost 10–100×.