GI: baked irradiance volumes for diffuse GI
Closes #157 item 4 — indirect diffuse. The last item in docs/decisions/bistro-visual-fidelity.md (phase 7).
Design: docs/ADR/ADR-0018-diffuse-gi-irradiance-volumes.md.
Problem
The only diffuse indirect term in scene.frag.glsl is the distant environment:
vec3 irradiance = evalSH(N, ubo.env_sh) * intensity;
vec3 diffuse_ibl = irradiance * albedo / PI * (1.0 - metallic);env_sh is nine order-2 SH coefficients projected from the scene's one HDRI. It has no notion of position. A fragment under the Bistro's awning, a fragment inside the shop, and a fragment in open sunlight all receive the same hemisphere of sky. SSAO darkens that term but cannot colour it — no red spill from the awnings onto the stucco, no bounce from the storefront onto the pavement — and interior surfaces, which see almost no sky, get a full sky term SSAO can only attenuate.
Why irradiance volumes
Of #157 item 4's four options, three are unavailable or a poor fit:
- DDGI / RTXGI needs hardware ray tracing. SDL3 GPU exposes none on any backend — the same constraint that decided ADR-0017.
- Lightmaps need a second UV set and an atlas. Bistro ships no lightmap UVs and the importer has no unwrapper; building one is a larger project than the GI.
- Voxel cone tracing is expensive and aliasing-prone, and adds a voxelization pass to a renderer that is already submission-bound at this scale (#158).
Baked irradiance volumes are the only option that reuses machinery already in the repo, and they match the static/dynamic call ADR-0017 already made for this scene.
Design summary
IrradianceVolumeComponent— box volume,spacing,blend_distance,priority,intensity,AssetRef(.irradiance). Same field shape asReflectionProbeComponent/PostProcessVolumeComponent, so volume resolution reusespostprocess.resolveVolumes' existing box/blend/priority model..irradianceasset, magicTIRR, versioned from the first release per ADR-0012. Per probe: order-2 SH irradiance (9 RGB coefficients), mean distance and mean squared distance for a Chebyshev visibility test, and a validity flag.- Bake as a
TaskManagerjob (ADR-0016) with progress and cancel — not the per-frame trickle reflection probes use. A 16×8×16 grid is 2,048 probes × 6 faces. Extractreflection_probes.captureFaceinto a sharedsubsystems/render/cube_capture.zig; ADR-0017's bake becomes a second caller. Reuse the nine-term SH basis inassets.computeIrradianceSh, adding a cube-face integrator alongside the existing equirect one. Two or three iterations, each reading the previous one's irradiance, give multi-bounce. - Runtime: one fragment storage buffer, not 3D textures. Chebyshev weighting and backface rejection need per-corner weights, so hardware trilinear filtering is useless and the eight corners are fetched by hand anyway — at which point a storage buffer costs one binding regardless of coefficient count, where 3D textures would cost 9 samplers plus 2 for visibility on top of the 10 the scene shader already binds. Scene lights already set the precedent at
set=2, binding=10. - Blend then evaluate: weight the eight probes' coefficient sets into one (72 madds), then call the existing
evalSHonce. Exact for a linear basis, and one evaluation instead of eight. - The existing
ambient * occlusion * ssaomultiply stays — the volume supplies where the light comes from, SSAO the contact detail below the probe grid's resolution.
Scheduling
Scheduled after #158's performance phases: this adds frame cost to a scene that can't afford it yet, and it should land behind one of #160 (closed)'s feature toggles.
Acceptance
- A volume can be placed, sized and baked from the Inspector, with progress and cancel
- Interior surfaces are lit by the room, not by the sky — terrace and interior before/after pairs against the ORCA reference
- No visible light leak through the exterior/interior wall (the hardest case in this sample)
- Scenes with no volume render byte-identically to today
- Frame cost measured with the volume on and off (#159 (closed)'s harness)