Skip to content

Global illumination

Indirect diffuse light for the 3D renderer: a dynamic probe grid (DDGI style, software ray tracing in compute, infinite bounces), baked lightmaps for static scenes (UV2 charts, a GPU path tracer, denoise, directional encoding), a screen-space mode and an analytic ambient fallback for the low tiers. Code: crate sparklight-gi (CPU: settings, BVH, probe grid, lightmap data) and crates/sparklight-3d/src/gi/ (GPU passes, shaders gi_*.wgsl). Commands: gi.* (docs/agent/commands.md). Renderer reference: crates/sparklight-3d/docs/render-3d.md.

Terminal window
spark call space3d.load '{"path": "scenes/room.space3d.json"}'
spark call gi.tier '{"tier": "high"}' # probes, 64 rays per probe
spark call space3d.capture '{"width": 1280, "height": 720}' # renders with the session's GI settings
spark call gi.stats '{}' # probes, rays, traced instances and triangles, atlas bytes
spark call gi.set '{"settings": {"probes": {"spacing": 0.5, "rays_per_probe": 128}}}' # merge patch; unknown keys are errors
spark call gi.bake '{"settings": {"texels_per_meter": 16, "samples": 512}, "path": "lightmaps/room.slm"}'

In Rust: renderer.settings.gi = GiSettings::for_tier(GiTier::High) (sparklight_3d::sparklight_gi), then render frames; GI is off by default in every RenderSettings (the presets of the quality tiers do not turn it on: GI costs memory and needs a scene to trace, so it is opt-in).

Mode What it does Costs Sees
off the environment’s SH irradiance only (as before) nothing the sky, uniformly, also inside rooms
ambient the environment plus the sun’s light returned by a ground of one albedo (ambient.ground_albedo, bounce) and a minimum one ALU term per pixel no geometry
screen_space hemisphere rays marched through the depth prepass, lit by the previous frame, accumulated over time, depth-aware blur half resolution by default (screen_space.downscale) what is on screen; rays that leave it read the environment
probes a grid of irradiance probes traced against the whole scene, infinite bounces through the probes rays_per_probe x probes updated per frame everything in the traced scene, also behind the camera and through walls’ absence

Baked lightmaps apply on top of any mode (except screen_space, see Limits) to the placements that were baked: those pixels take their indirect diffuse light from the lightmap and nothing else (the baked light includes the sky).

Per frame (gi:trace, gi:update, gi:scatter compute passes, before the colour pass):

  1. The traced scene. Every draw item (not skinned, not see-through) is mirrored on the CPU into a two-level hierarchy: each mesh once as a bounding volume hierarchy of its triangles in a shared arena (binned surface-area-heuristic splits, sparklight_gi::bvh), and a hierarchy over the instances that is rebuilt (about a millisecond for a thousand instances) whenever a transform or a colour changes. Instances carry the albedo (base colour times the mean of the base colour texture; the base_color, albedo, color or tint parameter of a custom material, grey 0.5 without one) and the emission (emissive factor times the mean of the emissive texture, times emissive_scale). Mirrored (negative scale) instances are traced inside out like they are drawn.
  2. The grid. Probes sit on a lattice shifted by a fixed fraction of the spacing (so that they do not lie exactly on walls at whole metres). probes.volume.kind: auto (the bounds of the scene, spacing grown until max_probes fit; a huge ground plane makes the spacing coarse, use fixed or follow_camera then), fixed (a box), follow_camera (a box that moves with the camera in whole cells; probes that scroll out are reused on the other side and start from scratch).
  3. Rays. rays_per_probe rays per updated probe (a spherical Fibonacci set rotated differently every frame) find the closest hit; the hit is shaded with its emission, the sun and one random local light (each with a shadow ray) and the probes’ own previous result (infinite_bounces), a miss reads the environment. Back-face hits (a probe inside geometry) carry no light and a negative distance.
  4. Update. Per probe an 8x8 octahedral irradiance map (square-root encoded, a = fraction of the lobe that sees the sky) and a 16x16 visibility map (mean distance and mean squared distance, clamped to the visibility range) are blended into their history with hysteresis (faster when the change is large). Probes that sit in geometry or too close to a wall are relocated (up to 0.45 of the spacing), probes that see no surface near them are put to sleep (traced one frame in eight, skipped by shading). update_budget limits the probes updated per frame (the rest keep their result, oldest first; new ones first); never more than 4096 per frame (stats.update_capped).
  5. Shading. hook_env_irradiance replaces the SH irradiance by the grid: the eight probes around the pixel (normal_bias, view_bias offsets), weighted trilinearly, by whether they face the surface and by a Chebyshev test of their visibility map (no light through walls). Near the faces of the volume it fades to the environment (coverage). hook_specular: where nothing better is known (no reflection probe, no screen-space hit) the environment cube is darkened where the probes say the sky is hidden (specular_occlusion), and for rough surfaces (smoothstep(0.35, 0.85, roughness)) replaced by the probes’ irradiance towards the reflected ray: rough reflections fall back to the probes’ radiance.

All maps live in one Rgba16Float atlas texture (irradiance tiles, visibility tiles, offsets, then the lightmaps) so the PBR path binds one texture more (binding 21): a pipeline of the built-in PBR shader then holds 16 sampled textures, the default device limit.

  1. UV2: each distinct mesh is unwrapped into planar charts (triangles join a chart while their normal is within max_chart_angle of its average), each projected on its plane, sized by texels_per_meter, padding texels around, packed with a shelf packer. Seams duplicate vertices: a lightmapped draw uses the unwrapped copy of the mesh (positions, normals, UV copied; plus the second UV as a vertex stream, @location(5), shader variant LIGHTMAP). If the atlas would exceed max_atlas_size the density is lowered step by step and the report says so (texels_per_meter_used, skipped lists the note); if it cannot fit even at a tenth of a texel per metre the bake fails with gi.atlas_full.
  2. Instances of one mesh each get their own rectangle of the atlas; a placement is identified by the mesh’s geometry hash and its transform (rounded to 0.1 mm): an object that moves loses its lightmap, an unmoved one finds it again after a reload.
  3. Path tracing (GPU, the same ray tracer as the probes): per texel samples cosine-weighted paths of up to bounces bounces collect emission, the direct light of the sun and one random lamp at every surface they reach (shadow rays), and the environment; Russian roulette after two bounces. Only the indirect light is stored (the receiver’s own direct light stays real time, so nothing is counted twice).
  4. Post: edge-avoiding à-trous denoise over the atlas (denoise_iterations; weights by world distance, normal and brightness, so creases and shadow edges survive), dilation by padding texels, encoding into two images: A = irradiance / pi at the geometric normal and the factor k, B = dominant direction and its strength s. A shader with shading normal n reads A.rgb * ((1 - s) + s * k * max(0, n . m)): isotropic light is the same for every normal, one lamp follows Lambert, so normal-mapped surfaces still respond to where the light comes from.
  5. Data: LightmapSet (sparklight_gi::lightmap), saved as a .slm file: SLMB, version, a JSON header (unwrapped meshes, instances, settings), then the two images as little-endian half floats. gi.lightmaps.save/load/info/clear; in Rust renderer.gi_bake(&settings, &|index, item| ..), renderer.set_lightmaps(Some(set)).

Settings (gi.schema has the exact shape, gi.set the merge patch)

Section titled “Settings (gi.schema has the exact shape, gi.set the merge patch)”
Group Keys
top mode, intensity (scales the indirect diffuse light of the mode, not the lightmaps), emissive_scale, specular_occlusion, debug (none, indirect_only)
probes volume {kind, center, half_extents}, spacing (0 = fit max_probes), max_probes (at most 16384), rays_per_probe (16 to 512), update_budget, hysteresis, normal_bias, view_bias, visibility_sharpness, relocation, classification, infinite_bounces, max_ray_distance (0 = derived), energy_conservation
screen_space rays, steps, radius, thickness, downscale (1, 2, 4), history_weight
ambient ground_albedo, bounce, minimum
lightmaps enabled, intensity, directional

Every structure is strict: an unknown key is E_GI_SETTINGS (at any depth), a value outside its range E_GI_RANGE naming the setting; nothing changes on error. Tiers (gi.tier, GiSettings::for_tier): low = ambient, medium = screen_space (1 ray, 12 steps, half resolution), high = probes with 64 rays and up to 2048 probes, ultra = probes with 128 rays and up to 8192 probes.

  • Traced scene: at most 8 000 000 triangles in the arena and 65 536 instances. A mesh over the limit is named in GiStats::rejected and logged once; the same for instances left out. A mesh with no triangle with an area, or an index outside its vertices, is refused by name.
  • Update budget per frame: 4096 probes; a bigger grid is updated over several frames (update_capped), nothing is dropped.
  • Bake: atlas size (above), skinned and see-through items are listed in BakeReport::skipped.
  • Static geometry only in the traced scene’s CPU mirror: meshes are read at upload; update_mesh_vertices (crumpling cars, cloth) does not update the traced copy. Skinned characters are not traced (a joint palette is not on the CPU); they are lit by the grid like any object.
  • Materials are reduced to a colour: albedo and emission per instance (texture means), no per-texel albedo, no specular bounce, no transparency: see-through materials are skipped (light passes them). Terrain and other custom shaders without a colour parameter count as grey 0.5.
  • The sun and one random local light are the lights of a bounce; further directional lights are not traced. The light of a bounce on a surface is Lambertian.
  • Probes are diffuse only; they do not replace reflection probes for polished surfaces.
  • Screen-space GI sees only the screen and the previous frame (it lags one frame and cannot light what is not visible); it is disabled inside reflection probe captures.
  • Lightmaps and screen-space GI exclude each other (both need the one extra texture binding): with mode = screen_space baked lightmaps are not used. Lightmaps need batched drawing (Renderer3D::set_instancing(true), the default where the device allows it).
  • The traced scene mirrors what Scene3D::items holds; an open world’s thousands of foliage instances are traced like any other (1772 instances and 258 760 triangles of the sample valley rebuilt in 1 to 4 ms), but no LOD: the probes see the meshes that are drawn this frame.
  • A lightmapped mesh is drawn from its unwrapped copy: do not deform it afterwards (update_mesh_vertices), and bake the scene at rest.
  • The first frames after a change are noisy: probes converge with the hysteresis (0.97 default is slow, about 100 frames to 95 %); gi_reset() recomputes everything.
  • tests/gi.rs (sparklight-3d): the furnace test (a closed room whose walls reflect 0.5 and emit 0.1 must give radiance 0.2 everywhere; probes reach 0.1992 after 80 frames, the baker 0.1999 at 512 paths), colour bleeding in the Cornell box with probes, screen-space GI and baked lightmaps, the interior lit through a window (the sky’s blue ambient is shut out, the sunlit floor lights walls and ceiling), a rough metal ball indoors (its reflection loses the sky: blue-ness 0.078 to -0.200, brightness 581 to 125), a roof that shades a sunlit floor moved away and back (the probe below goes 0.033, 0.267, 0.036: the scene mirror is rebuilt in the frame of the move), the baked room drawn with its lightmap (pixel value against the analytic one), the lightmap file round trip.
  • crates/sparklight-world/tests/gi_goldens.rs: the sample valley at noon and dusk with a camera-following grid.
  • crates/sparklight-gi unit tests: BVH against brute force, two-level tracing with mirrored and scaled instances, limits named, grid slots and scrolling, octahedral maps and borders, chart unwrapping and packing, texel rasterisation, denoiser (noise removed, shadow edge and crease kept), directional encoding (Lambert for one lamp, flat for isotropic light), settings strictness.
  • tests/hooks_shaders.rs: every built-in surface shader compiles through the hooks with LIGHTMAP.
  • crates/sparklight-control/tests/gi.rs: the commands, their strictness and codes, MCP tools, a bake and a capture.
  • Goldens: goldens/3d/gi_* (index in goldens/3d/README.md), all inspected.

Measured with cargo run -p sparklight-3d --example gi_bench (the dev profile, which builds the renderer optimised) on an Apple M5 Max (Metal) at 1280x720 and 1920x1080, 2026-10-21, on a machine shared with other agents’ jobs (load average 19 to 23 during the run): every number is a median over seven pairs of 40 profiled frames (GPU timestamps), so read them as upper bounds with a noise of about one millisecond (the ambient rows show it: they cost nothing and read -1.2 to +0.9 ms). “Extra” is the GPU time of the same frame with GI on minus GI off, High render tier with cascaded shadows; “GI passes” is the sum of the gi:* passes of the profiled frames. Scenes: bench = 300 objects + ground + 32 lights, sky environment, probe volume following the camera (80 x 32 x 80 m, 1792 probes at 64 rays / 7436 at 128 rays: the spacing was grown to fit max_probes); cornell = the Cornell box (8 instances, 24 triangles, 0.5 m probes, 1872 / 2744 probes); window = the room lit through a window (11 instances, 30 triangles, 0.5 m probes, 1890 / 3564 probes).

scene resolution ambient (low), extra screen space (medium), extra / GI passes probes 64 rays (high), extra / GI passes probes 128 rays (ultra), extra / GI passes
bench 1280x720 +0.22 +0.77 / 2.31 +2.01 / 1.55 +1.62 / 2.48
bench 1920x1080 -1.17 (noise) +0.45 / 3.46 +1.79 / 1.25 +1.33 / 2.37
cornell 1280x720 -0.34 (noise) +0.70 / 2.62 +1.86 / 0.45 +1.37 / 1.06
cornell 1920x1080 -0.73 (noise) +1.31 / 5.28 +1.64 / 0.76 +2.36 / 1.82
window 1280x720 +0.85 +1.35 / 1.27 +1.56 / 1.77 +2.90 / 2.91
window 1920x1080 +0.61 +0.35 / 5.56 +1.10 / 1.39 +2.23 / 2.76
  • The probe passes cost the same at every resolution (they trace rays from the probes, not pixels): 0.45 to 1.8 ms at 64 rays, 1.1 to 2.9 ms at 128 rays for 1800 to 7400 probes. Per frame all probes are updated; update_budget trades that against latency (a quarter of the probes per frame costs about a quarter). What the pixels pay is the hook (eight probe taps, two texture reads each, per pixel and reflection): inside the “extra” column.
  • Screen-space GI scales with the resolution (half-resolution buffer by default) and is the most expensive per pixel; its passes read 1.3 to 5.6 ms here.
  • The CPU side of a probe frame is the scene mirror: building the hierarchy over the instances took 0.9 ms for 1772 instances (258 760 triangles) of the sample valley when something changed (3.6 ms in the frame that rebuilt it for the first time), 0.003 ms when nothing changed (GiStats::scene_ms).
  • Memory: the probe atlas is 6.2 MB for 1452 probes, 15 MB for 3600; lightmaps 16 bytes per texel (two RGBA16F images); the stage and ray buffers of an update are 5 KB and 2 KB per probe updated in a frame (at most 4096 probes a frame).
  • Bake of the Cornell box (gi.bake, GPU path tracer): 24 882 texels (16 texels per metre), 256 paths of 3 bounces: GPU 394 ms (including the pipeline’s first use), total 1.2 s; 97 362 texels (32 per metre), 1024 paths of 4 bounces: GPU 516 ms, total 4.1 s (the CPU part, unwrapping, denoising, dilating, takes the rest).