Skip to content
Felucca Online

Docs

Global illumination (voxel cone tracing)

Decision #120. Code: crates/uo-client/src/gi/ (mod.rs, brick.rs, grid.rs, render.rs), shaders gi.wgsl, gi_bindings.wgsl, gi_compute.wgsl, gi_resolve.wgsl.

The client lights the world's ambient light with voxel cone tracing (VCT, Crassin et al. 2011) and caches the result in an irradiance volume that is traced again only where something changed. Every glowing texel (flames, lanterns, lit windows, lava, magic) is a light source; walls and floors stop light (no more torchlight through walls), windows let it out onto the street, interiors are lit by their windows and fires, the sky reaches only where it is seen, and light bounces off walls and ground. The sun keeps its sharp shadow-mapped light (#23); the GI replaces the sky hemisphere ambient and the point lights of static objects.

Why it is cheap here

  • The world barely changes. Statics move only when they break, open or are built; the sun moves slowly. So the expensive part (relighting voxels and tracing cones) runs only for the blocks that changed, and a static scene settles after a few passes and then costs nothing but the lookups.
  • The camera never turns. The voxel window is a clipmap around the camera, addressed toroidally: walking only voxelizes the block columns that enter the window, well off screen.
  • Voxelization is free. Every baked texel knows its solid interval along the view ray (height .. height_back, #25), so every sprite frame turns into a brick of voxels without rasterising anything.
  • Small window. The screen shows about 33 × 33 tiles at the default zoom; a 64-tile window at a quarter tile per voxel is 256 × 256 × 64 voxels.

Data

VolumeSize (High)FormatWritten by
voxels: albedo + opacity256 × 256 × 64RGBA8 sRGBCPU, per block
voxels: normalsameRGBA8CPU, per block
voxels: emissionsameRGBA8 (RGBM, range 16)CPU, per block
radiance, 6 directions128 × 128 × 32 × 6, 4 mipsRGBA16Frelight, downsample
probes: SH r, g, b, state128 × 128 × 32 × 4RGBA16Ftrace, invalidate

Voxel units are world units times voxels per tile; half units are two voxels: the cells of the radiance volume's level 0 and the probe grid (a probe at each half cell's centre). x and y wrap (slot = world mod size), z starts at the window's floor, which stays put until the ground under the camera leaves the window's lower half (then everything is rebuilt).

Bricks (brick.rs)

Each covered texel's column [height_back, height] is sampled along the view ray; a voxel's opacity is its solid share, scaled so that a slab 0.06 units thick (two-texel walls are 0.09) is opaque at every voxel size. Colour, normal and emission come from the texels' front surfaces; emission is the texels' radiance times the share of the voxel face they cover. Foliage (the debris vanish mask) blocks half as much and at most 0.35 per voxel; glowing texels don't block; sprites at most 0.4 units tall (ground cover) block nothing, except flat parts of buildings (floor_*, docks, steps), which are baked as open sheets and get a slab just thick enough to be opaque: an upper storey's floor is the ceiling of the room below. Mobiles are thin columns (half opaque, no preferred side). Bricks are built on two background threads from the sprite pack, per voxel size.

Calibrated glow. A small lamp's few glowing texels emit very little light physically; the point lights they used to be were bright on purpose (#51: 2500 √(texels × strength) lumens, 5 k – 90 k). Each frame's emission is scaled so that its glowing texels together emit what that point light did (radiance × image area = lumens × exposure / 4π; statics::glow_lumens), the glass of lit windows (wall pieces) as much, and desaturated like the point lights (look.light_saturation, #95). On top of that the emission gain (UO_GI emission) is 2 by default (user feedback: more light from torches and windows; 3 already floods lamp-lit cobbles orange). The calibration keeps the balance the same at every voxel size.

Grid (grid.rs)

Instances (a brick at an anchor voxel) are listed in every 8³ block they touch. A changed block is rebuilt from scratch: the ground columns under it (height, terrain colour from the ground tiles, lava glow), then every listed brick, composited (opacity 1 − Π(1 − a), colour weighted by opacity, emission added). Only the upload is kept; the GPU volumes are the one copy. A hash of every slot's contents catches rebuilds that come out the same (an online terrain window re-centred over the same world, an object replaced by an identical one): those are not uploaded, relit or traced.

Passes (once per frame, before the views render)

  1. invalidate — probes of blocks that changed hands (the window moved) are marked untraced.
  2. relight — for each queued block, every voxel gets its light: sun (marched through the voxels towards the sun, from its front and from its back), bounce (the probe in front of each side, times the gain), and emission. Each 2³ voxels fold into one texel per direction of travel, composited front to back along it; a cone sees a voxel's front only when it comes at it from the front, so a wall lit outside stays dark inside.
  3. downsample — the three coarser mips, the same way, over the whole volume (cheap).
  4. trace — for each queued block, its 64 probes cast cones cones (spherical Fibonacci, equal solid angles) through the radiance volume, footprint level from the cone's width, opacity corrected to the step; what is left past reach or the window is the sky hemisphere. The result is stored as L1 spherical harmonics scaled to irradiance / π. A probe whose cell is mostly solid (inside a thick roof, a rock) traces from its emptiest voxel instead; one with no free voxel is marked buried and never used (DDGI-style relocation).
  5. resolve (per view, between the depth/normal prepass and the main pass, gi_resolve.wgsl) — every render pixel's surface is rebuilt from the depth (orthographic camera) and the prepass normal, and looks its probes up once into a screen texture (irradiance / π, confidence).

Shading (gi_bindings.wgsl)

The impostor, terrain and mesh shaders take their ambient light from the GI instead of the sky hemisphere: gi_ambient_at reads the resolved texel of their pixel (impostors write their own depth, so early depth testing is off for them and a per-fragment lookup was paid by every overlapping sprite: ≈ 2–3 ms; the resolve costs about one lookup per pixel). The lookup itself (gi::gi_irradiance) reads the eight probes around the point with trilinear weights, times how much each probe is in front of the surface (((cos + 1) / 2)² + 0.05), times the transmittance of 2–4 voxel samples between the point and the probe (so the far side of a wall never lights the near side). The test starts off the surface along the normal and towards the camera: the view ray reached the texel through air, so that side is free even where a normal-mapped texel points along the surface. Where no traced probe sees the point it falls back to the hemisphere. The ambient never drops below a share of the hemisphere (floor, 0.2), nor below 1.5 times the toon bands' black threshold (TOON_T0): the whole moonlit night sky is only about 2.5 times that threshold, so anything the sky can't fully reach (under a crown, in the moon's shadow) fell to black, and normal-mapped texels around the threshold flickered between black and the darkest band as dots. Black is only a guard (#95); shade stays in the darkest, moonlit-blue band. UO_GI_RESOLVE=0 looks the probes up per fragment instead (comparisons).

Cache and scheduling (mod.rs, schedule.rs)

  • A changed block (a static spawned, broken, opened; a mobile crossing a voxel; the ground window rebuilt) is voxelized again (CPU time budget 2.5 ms a frame, 12 ms until the first settle, so a faster CPU does more), its 3³ neighbourhood is queued for relighting and the 5 × 5 × 3 blocks around it for tracing in the urgent queues (a mobile: 3³ for both).
  • A change of light (sun direction ≈ 3°, sun, sky or ground colour ≈ 7 %) fills the background queues: every occupied block relit, every block with a surface nearby traced; then bounces more passes, each adding a bounce from the probes of the pass before, only within BOUNCE_RADIUS (24 tiles) of the player. The passes wait until the scene has loaded (no sprite or brick pending), so they aren't spent on a scene still filling in.
  • Nearest the player first, on their storey. Both queues (and the voxelization) are ordered by schedule::priority: Chebyshev distance from the local player's block across, blocks above their feet (upper storeys, roofs) three times and below one and a half times as far. A player in a house sees their own room and street settle first.
  • Relighting and tracing side by side: urgent before background, and within each a third of the units for relighting while both have work, so the probes near the player follow new light at once instead of waiting until the whole window is relit.
  • A budget in GPU time (schedule::Budget). The GI passes carry timestamp queries (before the first, after the last), read back with map_buffer_on_submit a few frames later (render.rs, Timer). Work is counted in units (1 per block relit, 0.5 + 0.2 × cones per block traced); the budget is the quality's target time over the smoothed cost per unit, at most doubling from one frame to the next, and a frame 1.5 times over the target corrects the estimate at once. A fast GPU therefore updates several times faster at the same cost to the frame; without timestamps the quality's fixed budget is used.
  • The coarser radiance levels are rebuilt only over the blocks relit this frame; sun marches stop after 8 world units.
  • Static lights (StaticLight, the point lights of glowing sprites) are hidden while the GI is on; dynamic lights (spells, explosions, carried torches) stay Bevy point lights.
  • Captures and recordings wait until the GI has settled (SpritesLoaded).

Measured in the demo village at night on the M1 (UO_GI_STATS=10): High fills the first view in ≈ 35 frames (≈ 80 with fixed budgets), runs both bounce passes in ≈ 20 frames (≈ 130), and handles a wave of arriving sprites in under 10 frames, at 0.6–2.3 ms of GPU time; the budget settles at 1 700–2 900 units. Ultra fills in ≈ 90 frames, mostly CPU voxelization of 25 000 blocks (with fixed budgets its probes had not started after 400 frames), at 1.3–3.3 ms.

Quality (user setting gi)

voxelwindow (tiles)conesreachbouncesGPU target / frameunits without timestamps
off——————point lights + sky hemisphere
low½ tile64 × 64 × 166411.0 ms260
medium¼64 × 64 × 166821.5 ms260
high¼64 × 64 × 16121222.0 ms400
ultra⅛48 × 48 × 12161643.0 ms850

Presets: low → low, medium → medium, high → high; ultra only by hand. A change rebuilds everything (a second or two to settle).

Tuning and debugging

  • UO_GI="emission=2,bounce=1,floor=0.2,strength=1" overrides the gains for a run (emission: gain over the calibrated glow; bounce; floor: share of the sky hemisphere the ambient never drops below; strength: GI ambient multiplier).
  • I cycles the debug views, also --debug-view gi-light|gi-voxels|gi-probes|gi-age: the GI light alone on grey (magenta: no probe), the voxel at every surface (with its grid), the nearest probe without the visibility test, the probes' age (white: traced this frame, red-yellow: within a second, blue: older, black: never).
  • UO_CAMERA_PAN=dx,dy drifts the free camera that many tiles a second (recordings of a moving view, e.g. checking that shadows stay put).
  • UO_GI_STATS=1 (or =N for every N frames) logs blocks voxelized, relit, traced and rebuilt unchanged, the queues, the work units and GPU time, bounce passes left, instances, the camera focus and the window.

What the cache does

UO_GI_STATS=20, demo village at night, a blast (--destruction-test, UO_DESTRUCTION_BLAST=25,64,2.5) breaking the candles of a house:

  • frames 0–80: the first view fills (≈ 550 blocks voxelized in the first frames at 12 ms, then relit and traced at the per-frame budgets);
  • frames 100–380: sprites keep arriving from the loader threads; each wave voxelizes and traces only its blocks (85, 107, 42 blocks);
  • frames 400–530: the scene is in; two bounce passes relight and trace everything once more;
  • after that no GI work at all (0 voxelized, relit, traced per frame) until something changes; the blast cost 42 blocks voxelized, 80 relit and ≈ 300 probe blocks traced over about ten frames, and the house went dark where its candles had been.

Limits

  • Flames don't flicker in the GI (their emission is constant); the sprite still flickers.
  • Light from small sources is soft at the voxel scale: pools are smooth, then stepped by the toon bands; no sharp window shapes.
  • The voxels are quantised to the grid (an object's height to ±½ voxel).
  • Upper storeys and roofs are in the voxels even when hidden (they still shade, like their shadow shells).