Taos Engine · Chapter 7 Lighting

Chapter 7 · Part II · Rendering

Physically Based Lighting

How much light reaches this point, and how much of it bounces toward the camera? A microfacet BRDF answers the first half; punctual lights, area lights, image-based lighting and a family of screen-space and voxel techniques supply the light.

Interactive · The BRDFOpen ↗
Interactive demo · loads when scrolled into view
Drag to orbit; move the sliders. One sphere shaded by the engine's Cook-Torrance BRDF. Roughness widens and dims the specular lobe (the readout shows the GGX α = roughness²); metallic moves F0 from 4% toward the base color and fades the diffuse term out. Light it with the sun alone to see only the direct term, or with the environment alone to see only the IBL term.

Lighting is the heart of any renderer. Taos Engine implements a full physically-based shading pipeline supporting directional (sun), point, and spot lights, plus image-based lighting from environment maps.

§ 7.1

Physically-Based Rendering Theory#

Taos Engine uses the Cook-Torrance microfacet BRDF (bidirectional reflectance distribution function), which models a surface as a collection of microscopic facets. Every PBR shading calculation revolves around four vectors at the surface point — the surface normal, the directions to the light and the viewer, and the half-vector between them:

BRDF vectors at a surface point
BRDF vectors at a surface point

The BRDF has three terms, each evaluated from dot products of these vectors:

Normal distribution function (GGX/Trowbridge-Reitz) — describes the statistical orientation of microfacets relative to the surface normal:

textbook form (shipped as distribution_ggx, §7.7)WGSL
fn D_GGX(NdotH: f32, roughness: f32) -> f32 {
  let a = roughness * roughness;
  let a2 = a * a;
  let denom = (NdotH * NdotH * (a2 - 1.0) + 1.0);
  return a2 / (PI * denom * denom);
}

Geometry function (Smith GGX with Schlick-GGX) — describes microfacet self-shadowing:

textbook form (the engine ships a height-correlated variant, §7.7)WGSL
fn G_GGX(NdotX: f32, a: f32) -> f32 {
  let k = a * 0.5;
  return NdotX / (NdotX * (1.0 - k) + k);
}

fn G_SmithGGX(NdotV: f32, NdotL: f32, roughness: f32) -> f32 {
  let a = roughness * roughness;
  let GGX = G_GGX(NdotV, a) * G_GGX(NdotL, a);
  return GGX;
}

Fresnel function (Schlick approximation) — describes how reflectance varies with viewing angle:

textbook form (shipped as fresnel_schlick, §7.7)WGSL
fn F_Schlick(cosTheta: f32, F0: vec3f) -> vec3f {
  return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}

The full textbook BRDF for a single light is (§7.7 shows the form the engine actually evaluates):

textbook Cook-Torrance assemblyWGSL
let NdotL = max(dot(N, L), 0.0);
let NdotV = max(dot(N, V), 0.0);
let H = normalize(L + V);
let NdotH = max(dot(N, H), 0.0);
let HdotV = max(dot(H, V), 0.0);

let D = D_GGX(NdotH, roughness);
let G = G_SmithGGX(NdotV, NdotL, roughness);
let F = F_Schlick(HdotV, F0);

let specular = (D * G * F) / (4.0 * NdotV * NdotL + 0.0001);
let diffuse = (1.0 - F) * (1.0 - metallic) * albedo / PI;
return (diffuse + specular) * radiance * NdotL;

The metallic parameter blends between dielectric behavior (specular highlights on a diffuse base) and metallic behavior (no diffuse, colored specular). F0 is 0.04 for dielectrics and albedo for metals, interpolated by metallic:

the PBR lighting shaderWGSL
let F0 = mix(vec3f(0.04), albedo, metallic);
§ 7.2

The Directional Light (Sun)#

Taos Engine supports three light types — directional, point, and spot — each with a distinct geometry and falloff model:

Directional, point, and spot lights
Directional, point, and spot lights

The directional light represents the sun — an infinitely distant light source with parallel rays. It is defined by the DirectionalLight interface (src/renderer/directional_light.ts):

export interface DirectionalLight {
  direction: Vec3;
  intensity: number;
  color: Vec3;
  castShadows: boolean;
  lightViewProj?: Mat4;
  shadowMap?: GPUTextureView;
}

The direction is a unit vector pointing toward the light (from the surface toward the sun). intensity controls the overall brightness, and color tints the light.

Directional Light in the Lighting Pass#

The deferred lighting pass evaluates the directional light in a fullscreen shader. The light direction and cascade data are uploaded per frame:

lighting_pass.ts updateLight()TypeScript
updateLight(
  ctx: RenderContext,
  light: DirectionalLight,
  cascadeData: CascadeData[],
  cascadeCount: number,
  shadowTexView: GPUTextureView | null,
  debugCascades: boolean,
  shadowSoftness: number,
): void {
  // Pack direction, intensity, color, cascade data into lightBuffer
  const data = this._lightScratch;
  data.set(light.direction.toArray(), 0);
  data[3] = light.intensity;
  data.set(light.color.toArray(), 4);
  data[7] = cascadeCount;
  // ... cascade view-proj matrices, split depths, texel sizes ...
  ctx.queue.writeBuffer(this.lightBuffer, 0, data.buffer as ArrayBuffer);
}

The Sun as a Disk#

A directional light is a delta: every surface sees it from exactly one direction. For rough surfaces that is indistinguishable from the real sun, but a mirror-smooth one (a wet puddle, polished metal) turns the GGX peak into a highlight a pixel or two wide and blindingly bright — it aliases into sparkle the moment anything moves. The sun is actually a disk (the sky draws it at ~1.6° radius), and the specular lobe should see that disk.

The base lobe therefore aims at the point on the disk closest to the reflection ray instead of the disk center: if the reflection R falls inside the disk it is used directly, otherwise the direction is pulled toward it by the disk's angular radius. Widening the lobe that way would add energy, so the GGX term is normalized by (α / α')² with α' = α + sin(radius) / 2. For a mirror that works out to roughly the sun disk's own radiance — the physically expected reflection:

let R     = reflect(-V, N);
let LdotR = dot(L, R);
var L_s   = R;
if (LdotR < SUN_DISK_COS) {
  L_s = normalize(L * SUN_DISK_COS + normalize(R - LdotR * L) * SUN_DISK_SIN);
}
let a_s  = min(a + 0.5 * SUN_DISK_SIN, 1.0);
D = distribution_ggx(dot(N, normalize(L_s + V)), roughness) * (a / a_s) * (a / a_s);

Diffuse, clearcoat and sheen keep the disk center — at their roughness the difference is invisible.

§ 7.3

Point Lights#

A point light emits light equally in all directions from a position in space. It is defined by the PointLight interface (src/renderer/point_light.ts):

export interface PointLight {
  position: Vec3;
  range: number;
  color: Vec3;
  intensity: number;
  castShadows?: boolean;
}

The range field limits the light's influence. The attenuation is a windowed inverse-square — pow(saturate(1 − (d/range)⁴), 2) / d² — which follows the physical 1/d² law but ramps smoothly to exactly zero at the range boundary so a light never leaves a hard disc on the floor:

fn point_attenuation(dist: f32, radius: f32) -> f32 {
  let r = dist / radius;
  return pow(saturate(1.0 - r * r * r * r), 2.0) / max(dist * dist, 0.0001);
}

Point lights are processed in the PointSpotLightPass, which renders additive lighting (srcFactor: 'one', dstFactor: 'one') over the HDR target produced by the deferred sun pass, for all active point and spot lights:

point_spot_light_pass.ts updateLights()TypeScript
interface PackedPointLight {
  position: [number, number, number, 0];     // vec4 (w unused)
  color: [number, number, number, number];   // rgb + intensity
  range: number;
}

Up to MAX_POINT_LIGHTS = 32 point and MAX_SPOT_LIGHTS = 32 spot lights can be active per frame (point_spot_shadow_pass.ts:11-12). The deferred pass does not frustum- or tile-cull these lights — its fragment shader loops over every uploaded light at every pixel, rejecting each only by the cheap per-pixel range test if (dist >= range) { continue; }. That is affordable for a few dozen lights; the scalable path for hundreds of lights is the tiled Forward+ culling described in §7.9, which trims each tile's light list on the GPU before shading.

Point and spot light shadows — including how the PointSpotShadowPass bakes them into VSM moment arrays — are covered in Chapter 8 (§8.3–§8.5).

§ 7.4

Spot Lights#

A spot light emits light in a cone from a position in a specific direction. Taos Engine's SpotLight class (src/renderer/spot_light.ts) includes a lazy view-projection matrix computation:

export class SpotLight {
  position: Vec3;
  range: number;
  direction: Vec3;
  innerAngle: number;  // Full brightness cone half-angle (degrees)
  color: Vec3;
  outerAngle: number;  // Falloff cone half-angle (degrees)
  intensity: number;
  castShadows?: boolean;

  // Lazily computed from position + direction + outerAngle + range
  get lightViewProj(): Mat4;
  computeLightViewProj(near?: number): Mat4;
  markDirty(): void;
}

The view-projection matrix is computed from the light's parameters:

private _compute(near = 0.1): void {
  // Build a lookAt view from the light's position and direction
  const up = Math.abs(this.direction.y) > 0.99
    ? new Vec3(1, 0, 0)    // Avoid gimbal lock when pointing straight up/down
    : new Vec3(0, 1, 0);
  const view = Mat4.lookAt(this.position, this.position.add(this.direction), up);
  // Perspective projection matching the spot's cone angle
  const proj = Mat4.perspective(this.outerAngle * 2 * Math.PI / 180, 1.0, near, this.range);
  this._cachedLvp = proj.multiply(view);
  this._dirty = false;
}

A dirty flag avoids recomputation when the light's parameters haven't changed. Call markDirty() after mutating position, direction, outerAngle, or range.

Spot Light Attenuation#

The GPU evaluates spot light falloff using the inner and outer angles. A smoothstep between cos(outer) and cos(inner) gives a soft transition from the bright inner cone to the dark exterior:

Spot light cone and smoothstep falloff
Spot light cone and smoothstep falloff
the spot light shaderWGSL
// Spot cone attenuation
let cosAngle = dot(normalize(lightDirection), -toLightDir);
let cosInner = cos(spotLight.innerAngle * PI / 180);
let cosOuter = cos(spotLight.outerAngle * PI / 180);
let spotFactor = smoothstep(cosOuter, cosInner, cosAngle);
// Full point-light attenuation * spot cone factor
let attenuation = spotFactor / (distSq + 0.01);
Interactive · Light TypesOpen ↗
Interactive demo · loads when scrolled into view
Pick a light type. The set is lit by one punctual light at a time with no ambient, so everything you see comes from that light. Move the sun to watch its parallel shadows swing; shrink the point light's range to see the windowed falloff cut it off; pull the spot's inner angle toward the outer angle to sharpen the smoothstep edge. The readout converts each intensity to the photometric units of §7.5.

Light Cookies (Projection Textures)#

A spot light can carry an optional projection texture — a "cookie" or gobo — that is projected down the cone, tinting and masking the light the way a stained-glass window, a leaf canopy, or a film projector's gate would. The texture lives on the component (SpotLight.projectionTexture) and is uploaded into a shared rgba8unorm 2D array (PROJ_TEX_SIZE = 256, slot projTexIdx).

The cookie reuses the same light-space projection as the shadow map: the surface point is transformed by lightViewProj, the resulting clip coordinates become a [0,1]² UV, and the cookie is sampled there and multiplied into the light's radiance. Because the same UV drives both, a single in-frustum test guards the cookie and the shadow, and anything outside the cone's projected rectangle contributes nothing:

let ls = sl.lightViewProj * vec4<f32>(world_pos, 1.0);
let sc = ls.xyz / ls.w;
let uv = vec2<f32>(sc.x * 0.5 + 0.5, -sc.y * 0.5 + 0.5);
let in_frustum = all(uv >= vec2f(0.0)) && all(uv <= vec2f(1.0)) && sc.z >= 0.0 && sc.z <= 1.0;

var modulator = vec3<f32>(1.0);
if (in_frustum) {
  if (sl.shadowIdx >= 0) {                                       // VSM shadow
    let m = textureSampleLevel(vsm_spot, vsm_sampler, uv, sl.shadowIdx, 0.0).rg;
    modulator *= vec3f(vsm_shadow(m, sc.z));
  }
  if (sl.projTexIdx >= 0) {                                      // RGB cookie
    modulator *= textureSampleLevel(proj_tex, proj_sampler, uv, sl.projTexIdx, 0.0).rgb;
  }
} else {
  modulator = vec3<f32>(0.0);                                    // outside the cone gate
}
let radiance = sl.color * sl.intensity * att * cone * modulator;

Because the cookie is an RGB multiplier it can both shape (alpha-like masking in any channel) and colorize the beam — a single white spot light becomes a rose window by swapping its texture, with no extra draw cost. Spot shadows themselves use the same VSM scheme as point lights (Chapter 8, §8.3), but stored in a 2D array (VSM_SPOT_SIZE = 512) rather than a cube, since a spot only ever looks down one direction.

§ 7.5

Area Lights#

Directional, point, and spot lights are all punctual — they emit from a single point (or, for the sun, a single direction), so their specular highlight is a pinpoint no matter how large the real light fixture is. A fluorescent tube, a softbox, or a glowing sphere produces a soft, shape-aware highlight that stretches and spreads with the source. Taos Engine approximates these with representative-point area lights — sphere, tube (capsule), and rectangle emitters — implemented in src/shaders/modules/area_lights.wgsl and shared by the deferred, forward, and forward+ paths.

Sphere, tube, and rect area lights and the representative-point trick
Sphere, tube, and rect area lights and the representative-point trick

A light is described by the AreaLight interface (src/renderer/area_light.ts); the same 64-byte struct serves all three shapes, with fields reinterpreted by kind:

export interface AreaLight {
  kind: 'sphere' | 'tube' | 'rect';
  position: Vec3;   // world-space center
  color: Vec3;
  intensity: number;
  range: number;    // windowed inverse-square cutoff
  radius?: number;  // sphere / tube radius
  axisHalf?: Vec3;  // tube: half the end-to-end vector; rect: half-width edge
  halfUp?: Vec3;    // rect: half-height edge
}
export const MAX_AREA_LIGHTS = 8;

The Representative-Point Approximation#

The key idea is cheap and table-free: integrating the BRDF over the whole surface of the light is expensive, so instead the shader picks one point on the source — the representative point — that best stands in for the highlight, and shades a single punctual sample there. The trick is choosing that point per-lobe:

// Closest point on a sphere (center L, radius `radius`) to the reflection ray R.
fn closest_point_on_sphere(L: vec3f, R: vec3f, radius: f32) -> vec3f {
  let center_to_ray = dot(L, R) * R - L;
  return L + center_to_ray * clamp(radius / max(length(center_to_ray), 1e-4), 0.0, 1.0);
}

Pointing the reflection ray at the representative point effectively widens the specular lobe, which would brighten the highlight unphysically as the source grows. To compensate, the GGX distribution is renormalized by (α / α′)², where α′ is the roughness widened by the source's angular size — so total reflected energy stays roughly constant as the sphere or tube gets bigger:

fn area_ndf_normalization(roughness: f32, radius: f32, dist: f32) -> f32 {
  let a  = roughness * roughness;
  let aP = clamp(a + radius / (2.0 * max(dist, 1e-4)), 0.0, 1.0);
  let n  = a / max(aP, 1e-4);
  return n * n;
}

The Three Shapes#

A single dispatcher routes each light to its evaluator, so every lighting path shares one loop body:

fn shade_area_light(al: AreaLight, P: vec3f, N: vec3f, V: vec3f,
                    F0: vec3f, albedo: vec3f, roughness: f32, metallic: f32) -> vec3f {
  if (al.kind < 0.5) {        // sphere
    return area_light_sphere(/* center, radius, ... */);
  } else if (al.kind < 1.5) { // tube — endpoints = position ± axisHalf
    return area_light_tube(/* p0, p1, radius, ... */);
  }
  return area_light_rect(/* center, halfRight, halfUp, ... */);  // one-sided
}

By default area lights are evaluated with the base Cook-Torrance lobe only — the glTF extension lobes (clearcoat, sheen, iridescence, transmission) from §6.6 are skipped for them — and the representative-point path casts no shadows. The opt-in LTC path for rectangles and disks lifts the shadow limit: set castShadow (and optionally shadowSoftness) and the light renders a center-drawn omnidirectional cube shadow map, sampled with cube-space PCSS for a soft, size-driven penumbra (the first MAX_SHADOW_AREA_LIGHTS shadow-casters get a map). The sphere/tube/rect above are the table-free representative-point approximations. For rectangles (and disks) the reference-quality alternative — LTC, linearly-transformed cosines — is described next.

Reference-Quality Rect & Disk: Linearly Transformed Cosines (LTC)#

The representative-point rect is cheap and reads well as a soft panel, but its specular is a single-point approximation: on rough surfaces and at grazing angles the highlight's shape, intensity, and the diffuse form factor drift from ground truth, and a true polygonal "the light's outline reflected in the surface" never appears. Linearly Transformed Cosines (Heitz, Dupuy, Hill & Neubelt, SIGGRAPH 2016) is the reference-quality, widely shipped fix for rect lights, and Taos Engine offers it as an opt-in path for rect and disk area lights.

The idea: a GGX specular lobe is approximated by a clamped-cosine distribution warped by a 3×3 matrix M that depends on (roughness, NdotV). A cosine distribution has a closed-form integral over a polygon (a sum of per-edge terms), so you can integrate the BRDF over the actual light polygon by (1) transforming the polygon's corner directions by M⁻¹ into the cosine's space, (2) evaluating the analytic clamped-cosine polygon irradiance there, and (3) scaling by a precomputed magnitude/Fresnel term. Diffuse is the same integral with M = identity (Lambert is a clamped cosine). The result is the correctly-shaped reflection of the quad at any roughness — a crisp rectangle on a mirror, a smoothly blurred one on a rough surface — with a correct diffuse form factor.

The matrix M⁻¹ and the magnitude/Fresnel terms cannot be computed at runtime or hand-authored; they are the output of an offline per-texel fitting and must be shipped as data. Taos Engine vendors the well-known reference tables (Hill/Heitz, MIT-licensed — the same fit most real-time LTC implementations ship) as two 64×64 lookup textures in src/assets/ltc_tables.ts, base64-packed and uploaded as rgba16float (half-float is filterable in core WebGPU, so the linear table sampling needs no optional feature). The shading math lives in the standalone module src/shaders/modules/ltc.wgsl:

WGSL
// ── from src/shaders/modules/ltc.wgsl ── (rect; disk approximates the ellipse as an octagon)
let uv  = ltc_uv(NdotV, roughness);              // (roughness, sqrt(1−NdotV))
let t1  = textureSampleLevel(ltc1, samp, uv, 0.0);
let t2  = textureSampleLevel(ltc2, samp, uv, 0.0);
let mInv = mat3x3<f32>(vec3(t1.x, 0.0, t1.y),    // sparse inverse transform
                       vec3(0.0,  1.0, 0.0),
                       vec3(t1.z, 0.0, t1.w));
let spec = (F0 * t2.x + (vec3(1.0) - F0) * t2.y) // Hill's LTC Fresnel approximation
         * ltc_integrate(N, V, P, mInv, c0, c1, c2, c3);
let diff = albedo * (1.0 - metallic)
         * ltc_integrate(N, V, P, /*identity*/ I, c0, c1, c2, c3);

It is opt-in per lighting path because it adds two table textures plus a sampler to the lighting bind group — pass areaLightLtc: true to ForwardLitFeature, ForwardPlusFeature, or PointSpotLightFeature (deferred). Enabling it compiles a LTC_AREA_LIGHTS shader variant (the same conditional-binding pattern as the SH-diffuse variant in §7.17) and routes rect/disk lights through ltc_shade_area while sphere/tube stay representative-point. A runtime flag in each pass's lighting uniform then lets you toggle LTC on and off live (via feature.setAreaLightLtc(...)) without rebuilding the pass — handy for A/B comparison, which area_light_test exposes on the L key. The disk shape is a fourth AreaLight.kind: its halfRight/halfUp are the ellipse's half-axes, and the integrator approximates it as an octagon (visually indistinguishable here while reusing the same edge integral); without LTC a disk falls back to its bounding rect.

One unit caveat: LTC integrates the source's solid angle directly, so there is no inverse-square term — intensity is interpreted as the panel's radiance (and range is unused for LTC rect/disk), the usual convention for LTC rect lights. The representative-point rect, by contrast, applies the windowed 1/d² falloff, so the same intensity reads brighter or dimmer between the two models. That is expected: they are different lighting models, not a bug to reconcile. AreaLight.twoSided makes an LTC panel emit from both faces (the representative-point rect is always one-sided).

Interactive · Area LightsOpen ↗
Interactive demo · loads when scrolled into view
Switch shapes and resize the emitter. A sphere, tube, rect or disk light over a roughness sweep of dark dielectric (front) and gold metal (back) spheres. The highlights take the emitter's shape and spread as the surface gets rougher. On the rect and disk, toggle LTC shading to compare the representative-point approximation with the exact LTC integral.

Physical light units (lux · candela · lumens)#

The four light types above all carry an intensity number. By default it is an artist-chosen "looks right" value, but it can equally be a real-world photometric quantity — because the engine's BRDF is already radiometrically consistent. The diffuse term is Lambert (albedo/π), and the punctual falloff attenuate_inverse_square is a true 1/d² whose reference sits at d = 1 unit (§7.3). So a directional intensity already behaves as illuminance and a point/spot intensity already behaves as luminous intensity — there is no separate "physical mode" and no shader change. Authoring in real units is just a matter of using the right numbers (and a physical exposure to map them, §11.1).

Three panels showing the unit convention: a directional light's parallel rays give illuminance in lux (direct sun ≈ 100 000); a point light radiating over a sphere converts total flux to candela as cd = lm / 4π (800 lm ≈ 64 cd); a spotlight spreads flux over its cone as cd = lm / (2π(1 − cos θ)). A footer notes emissive surfaces are luminance in cd·m⁻², distances are meters, and gives the authoring API: sun.lux, bulb.lumens, spot.lumens.

The convention (world distances treated as meters, so candela = lux at 1 m):

Lightintensity is…Author it as
Directional / sunilluminance, luxlight.lux = 100000 (direct sun)
Pointluminous intensity, candelapoint.lumens = 800 → cd = lm/4π
Spotluminous intensity, candelaset outerAngle, then spot.lumens → cd = lm/(2π(1−cosθ))
Emissive / arealuminance, cd·m⁻²material.emissiveFactor

Because bulbs are rated in lumens (total flux) but the inverse-square law works in candela (intensity), the lumens accessors on PointLight/SpotLight do the conversion (src/renderer/photometric_units.ts); lux on DirectionalLight is just an alias, since a directional light's intensity already is illuminance. The physical_lighting_test sample authors four bulbs by lumen rating, and because each lights an equal patch of wall from an equal distance, the wall brightness reads the lumen ratio directly. Real values only look right once exposure is physical too — covered in §11.1.

Light Layers (Per-Object Light Exclusion)#

Sometimes a light should illuminate the scene but spare one object. The motivating case is a rectangular area light at a window pouring warm "sunlight" into a room: because the emitter plane sits centimeters from the wooden window frame, the frame's reveals blow out to white under the near-field inverse-square term. Moving or dimming the light fixes the frame at the cost of the effect everywhere else. What you actually want is this light, but not that object.

Taos Engine handles this with light-exclusion bitmasks — an 8-bit "layer" tag carried by both surfaces and lights, with exclusion semantics so the default is always "affects everything":

Because both default to 0 and 0 & x == 0, every light affects every surface until something opts in — there is no "layer 0 is the default layer" gotcha and nothing to initialize. You only set a bit on the handful of objects and lights that need to diverge, and any geometry path that never writes the mask is automatically unaffected.

The mask is a deferred-path mechanism. The fullscreen lighting pass sees only pixels, not objects, so the surface tag has to be baked into a pixel. Taos Engine stores it in the otherwise-unused alpha channel of the emissive G-Buffer target (rgba16float, so all 256 mask values resolve cleanly) — the geometry pass writes emissive.a = lightExcludeMask / 255, and nothing samples emissive alpha as light:

// emissive.a carries the 8-bit light-exclusion mask; .rgb is the emission.
out.emissive = vec4<f32>(emission, model.misc.x / 255.0);

The per-light mask rides the existing light buffers at no extra cost: for area lights it is packed into the spare high bits of the kind slot (code = kind + mask·4, exact in f32), so the 64-byte AreaLight struct is unchanged. The deferred PointSpotLightPass reads the surface mask once per pixel and skips any area light it shares a bit with:

let obj_exclude = u32(textureLoad(emissiveTex, coord, 0).a * 255.0 + 0.5);
for (var i = 0u; i < lightCounts.numArea && i < MAX_AREA_LIGHTS; i++) {
  if ((obj_exclude & area_light_exclude_mask(areaLights[i])) != 0u) {
    continue;   // this light excludes this surface
  }
  accum += shade_area_light(areaLights[i], world_pos, N, V, F0, albedo, roughness, metallic);
}

Application code is just two assignments — tag the object and the light with the same bit:

TypeScript
frameRenderer.lightExcludeMask = 1;   // the window frame opts into layer 1
windowLight.excludeMask        = 1;   // the window "sun" skips layer 1

The excluded object still receives every other light, plus IBL and reflection probes — only the tagged light is withheld, so the frame reads as normal lit wood while the rest of the room still catches the window light. The mask is honored today by the deferred area-light loop (the point and spot loops are a one-line extension of the same test); the forward paths ignore it. With eight independent bits a scene can carve out eight overlapping exclusion groups.

§ 7.6

Image-Based Lighting (IBL)#

Image-based lighting uses an HDR environment map to illuminate surfaces with distant light. This provides ambient lighting that matches the sky and surrounding environment.

IBL requires three textures derived from the HDR sky cubemap — one for diffuse, one for specular at varying roughness, and a 2D table that captures the Fresnel integral:

IBL: irradiance map, prefilter mip chain, BRDF LUT
IBL: irradiance map, prefilter mip chain, BRDF LUT
interface IblTextures {
  irradianceMap: GPUTexture;      // Diffuse irradiance (low-frequency)
  prefilterMap: GPUTexture;       // Specular prefilter (mipmapped)
  brdfLut: GPUTexture;            // BRDF integration lookup table (2D)
}

Irradiance map. A heavily blurred version of the sky cubemap (lowest mip). Sampled by the surface normal to give diffuse ambient light:

the lighting shaderWGSL
let irradiance = textureSample(irradianceMap, sampler, normal).rgb;
let diffuseIBL = irradiance * albedo;

Prefilter map. A mipmapped cubemap where each mip level represents a different roughness. Sampled by the reflection direction and roughness level:

the lighting shaderWGSL
let roughnessLevel = roughness * MAX_PREFILTER_MIP_LEVEL;
let prefiltered = textureSampleLevel(prefilterMap, sampler, reflection, roughnessLevel);

BRDF LUT. A 2D lookup table encoding the Fresnel-integral term of the split-sum approximation. Sampled by NdotV and roughness:

the lighting shaderWGSL
let brdf = textureSample(brdfLut, sampler, vec2f(NdotV, roughness)).rg;
let specularIBL = prefiltered * (F0 * brdf.r + brdf.g);

The complete IBL contribution is:

the lighting shaderWGSL
let ibl = (1.0 - metallic) * diffuseIBL + specularIBL;

Refining the Specular Reflection#

The reflection sampled by the raw R = reflect(−V, N) is an idealization in two ways the deferred shader corrects before the result reaches the screen.

Anisotropic bending. A brushed-metal or hair surface (§6.6, Anisotropy) reflects the environment as a streak, not a point — the same directional highlight the direct lobe produces, but for the whole sky. Sampling the prefilter cube along the plain mirror direction R would give a round reflection that contradicts the stretched direct highlight. So when a pixel is anisotropic, the reflection vector is bent toward the bitangent (Filament's approximation) before the cube is sampled:

var R_spec = R;
if (aniso_active) {
  R_spec = reflect(-V, anisotropic_bent_normal(N, aniso_B, V, anisotropy));
}
let global_pf = textureSampleLevel(prefilter_cube, ibl_samp, R_spec, roughness * (IBL_MIP_LEVELS - 1.0)).rgb;

The same bent R_spec is reused for the reflection probes (§7.11), so a brushed surface streaks consistently against both the global sky and a local probe. Only the base specular lobe is bent — the clearcoat lobe keeps the un-bent R, because a clearcoat is an isotropic film over the anisotropic base.

Specular horizon occlusion. Normal-mapped and curved surfaces can produce a reflection vector that points below the real geometric surface — physically impossible, but the cubemap will happily return sky for it, leaking light through the object. The fix fades the reflection as R dips beneath the geometric horizon. The G-Buffer stores only the perturbed shading normal, so the shader reconstructs a geometric normal from neighboring depth taps and uses it as the horizon reference:

// Geometric normal from screen-space depth neighbors (derivatives are illegal
// here — this runs after the sky `discard`, i.e. non-uniform control flow).
let wR = reconstruct_world_pos(/* coord + (1,0) */);
let wU = reconstruct_world_pos(/* coord + (0,1) */);
var Ng = normalize(cross(wU - world_pos, wR - world_pos));
if (dot(Ng, V) < 0.0) { Ng = -Ng; }
let spec_horizon = specular_horizon_occlusion(R_spec, Ng);   // 1 = open, → 0 below horizon

The neighbor taps are skipped at silhouette edges (where a neighbor lands on the far plane), so a depth discontinuity can't fabricate a bad geometric normal and darken the rim. spec_horizon then multiplies into specular_ibl — and, like AO, it occludes only the environment specular, never the direct lobe.

Screen-space reflections. IBL — even with probes — only reflects the baked environment; it cannot show a nearby object reflected in a wet floor or a polished table, because that object was never in the cubemap. For those high-frequency, scene-dependent reflections Taos Engine adds a separate screen-space reflection (SSR) pass that ray-marches the depth buffer and composites the hit color over the IBL specular. SSR is part of the water/reflective-surface pipeline rather than the core lighting pass — see Crafty §2.11 for its ray march, fade, and fallback-to-IBL logic.

§ 7.7

The BRDF#

§7.1 presents the textbook Cook-Torrance form. The shipped implementation lives in src/shaders/modules/pbr_brdf.wgsl — a single module that every lit shader pulls in with #import "pbr_brdf.wgsl" rather than re-deriving the terms. It is a metallic-roughness Cook-Torrance model with two modern upgrades and a few supporting helpers:

FunctionRole
distribution_ggx(NdotH, roughness)GGX/Trowbridge-Reitz NDF; alpha = roughness² remap lives here.
visibility_smith_ggx(NdotV, NdotL, roughness)Height-correlated Smith visibility — returns V = G / (4·NdotV·NdotL) with the specular denominator folded in.
fresnel_schlick(cosTheta, F0)Schlick Fresnel for direct lighting.
fresnel_schlick_roughness(cosTheta, F0, roughness)Roughness-clamped Fresnel for IBL — kills energy gain at grazing angles on rough surfaces.
energy_compensation(F0, env_brdf)Restores energy lost to un-modeled multiple scattering inside the microsurface (1 + F0·(1/Ess − 1)).
specular_horizon_occlusion(R, Ng)Fades IBL specular as the reflection vector dips below the geometric horizon (normal-mapped surfaces).
attenuate_inverse_square(dist, range)Windowed 1/d² falloff for punctual lights with a hard range cutoff.
ambient_floor(albedo, metallic)A tiny non-IBL diffuse floor so surfaces never crush to pure black.

The key convention: because visibility_smith_ggx already folds in the 1/(4·NdotV·NdotL) denominator, callers combine the terms directly as D * Vis * F — and reuse F for the diffuse kD = (1 − F)(1 − metallic) weight. A single direct light therefore evaluates as:

the shipped form (pbr_brdf.wgsl terms, assembled at the call site)WGSL
let Ecomp = energy_compensation(F0, env_brdf);
let F     = fresnel_schlick(VdotH, F0);
let D     = distribution_ggx(NdotH, roughness);
let Vis   = visibility_smith_ggx(NdotV, NdotL, roughness);
let spec  = D * Vis * F * Ecomp;
let kD    = (vec3f(1.0) - F) * (1.0 - metallic);
let diff  = kD * albedo / PI;
return (diff + spec) * radiance * NdotL;

The module owns PI and forbids importers from redeclaring it — a guard against the duplicate-symbol traps the textual #import system would otherwise allow.

The Extension Lobes#

The base lobe above is only the default surface. Taos Engine's PBR materials add a stack of secondary BRDF lobes from the glTF KHR_materials_* set — clear-coat, sheen, iridescence, anisotropy, and the non-refractive translucency lobes (diffuse transmission and subsurface). Each one is a material feature: what it models physically, and how it is parsed from glTF, encoded, stored, decoded, and evaluated in the shader, is covered end-to-end in Chapter 6, §6.6, which owns the per-lobe code. This section covers only what belongs to lighting — where the lobe math lives and how a single light's radiance feeds it.

How the extension lobes stack on top of the base Cook-Torrance lobe: clearcoat over the top, sheen at grazing angles, iridescence shifting F0, anisotropy stretching the highlight, and transmission/subsurface gathering light from behind.

The direct-light lobes live in src/shaders/modules/pbr_extensions.wgsl (function shade_direct_ext), the thin-film math in iridescence.wgsl, and the anisotropic NDF/visibility in pbr_brdf.wgsl. The forward, forward+, and point/spot paths all #import that module so they match bit-for-bit; the deferred pass keeps a synced inline copy interleaved with its IBL code. Each lobe is gated on its parameter — reading per-material constants from the MaterialParams SSBO (indexed by the G-Buffer materialId) and per-pixel multipliers/direction from the params2 / params3 attachments (§6.1) — so a default material pays for none of them.

Composing the Lobes#

shade_direct_ext evaluates the full lobe stack (§6.6) for one light and returns its outgoing radiance. The order matters: the base lobe (anisotropic when applicable) and clear-coat are computed first into diff + spec and scaled by radiance · NdotL; sheen, diffuse transmission, and subsurface are then added on top.

fn shade_direct_ext(
  N: vec3f, V: vec3f, L: vec3f, NdotV: f32, roughness: f32, metallic: f32,
  F0: vec3f, albedo: vec3f, env_brdf: vec2f,
  radiance: vec3f, radiance_trans: vec3f, mp: MaterialParams,
  aniso_dir: vec3f, anisotropy: f32,
) -> vec3f { /* base + clearcoat + sheen + transmission + subsurface */ }

The two radiance inputs are the subtle part. radiance folds in light color · intensity · attenuation · shadow for the reflective lobes. radiance_trans is the same quantity for the transmission lobes, but callers pass it un-shadowed for the directional sun — a front-face shadow must not extinguish back-lit translucency, or a leaf would go dark exactly when the sun is behind it. For punctual lights radiance_trans equals radiance, because their visibility is geometric (cone, cookie, range) and does gate the transmitted light. This split is what makes a backlit leaf glow correctly under the sun while still respecting a spotlight's cone.

§ 7.8

The Deferred Lighting Pass#

The DeferredLightingPass (src/renderer/render_graph/passes/deferred_lighting_pass.ts) is the core of the deferred renderer. It renders a fullscreen triangle that samples the G-Buffer and all shadow/lighting inputs, producing an HDR result and exposing persistent camera + light uniform buffers as handles that downstream passes (composite, godrays) can sample:

export class DeferredLightingPass extends Pass<DeferredLightingDeps, DeferredLightingOutputs> {
  readonly name = 'DeferredLightingPass';
  // Pipelines, BGLs, and samplers — created once in static create(ctx).
}

export interface DeferredLightingOutputs {
  hdr: ResourceHandle;          // direct lighting composited over input HDR
  cameraBuffer: ResourceHandle; // persistent uniform buffer handle
  lightBuffer: ResourceHandle;  // persistent uniform buffer handle
}

The fullscreen triangle approach avoids a vertex buffer — three vertices cover the entire clip space:

@vertex
fn vs_main(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
  // Fullscreen triangle: covers NDC without a vertex buffer
  let uv = vec2f(f32((vi << 1) & 2), f32(vi & 2));
  return vec4f(uv * 2.0 - 1.0, 0.0, 1.0);
}

Each fragment samples the G-Buffer, reconstructs the world position from depth, evaluates the directional light with cascade shadow maps, adds AO/SSGI, adds IBL, and writes the HDR result:

the deferred lighting shaderTypeScript
// Lighting pass fragment shader (conceptual flow):
// 1. Sample G-Buffer: albedo/roughness, normal/metallic, emissive, depth,
//    plus the material params (specular, occlusion, materialId) and the
//    extension channels (params2 multipliers, params3 anisotropy tangent)
// 2. Reconstruct world position from depth + inverse view-proj
// 3. Look up extended params in the MaterialParams SSBO via materialId
// 4. Evaluate directional light (sun) with shadow cascade — base lobe plus
//    any active extension lobes (clear-coat, sheen, iridescence, anisotropy,
//    diffuse transmission, subsurface; see §6.6)
// 5. Add ambient occlusion (from SSAO texture) and baked occlusion (params.b)
// 6. Add indirect light (from SSGI texture)
// 7. Add IBL diffuse + specular
// 8. Add emissive; write HDR color

Refractive transmission is not handled here — see-through glass needs the lit scene as input, so it renders in a separate forward TransmissionPass after this pass completes (Chapter 6, §6.7).

§ 7.9

The Forward Lighting Path#

Transparent objects cannot use deferred shading (the G-Buffer stores only one surface per pixel). Taos Engine's ForwardPass evaluates the same PBR lighting model but in a forward rendering path.

The forward pass handles:

The forward pass uses the same camera and light uniform buffers as the deferred passes, ensuring consistent lighting between opaque and transparent objects. Refractive transmission is a further specialization of the forward path — TransmissionPass (§6.7) — which samples the lit scene to bend it through glass rather than blending over it.

Forward+ (Tiled Light Culling)#

The plain forward pass loops over every point light at every fragment — fine for a handful of lights, but it scales as pixels × lights and collapses under a scene lit by hundreds of small lights (torches, embers, windows). The deferred path (§7.8) has the same problem: its PointSpotLightPass also tests all lights per pixel (§7.3). Forward+ (tiled forward) fixes this by culling the light list per screen tile on the GPU before any shading happens, so each fragment only iterates the few lights that actually reach its tile.

ForwardPlusPass (forward_plus_pass.ts) runs three graph passes per frame:

  1. Depth pre-pass (render) — rasterizes geometry depth only, giving the cull step tight per-tile depth bounds. (When a deferred preset hands its G-Buffer depth in as externalDepth, this pre-pass is skipped entirely.)
  2. Light culling (compute) — one workgroup per 16×16 tile builds the tile's view-space frustum and tests every point light's bounding sphere against it, writing a per-tile index list.
  3. Shading (render) — re-renders the geometry with full PBR; each fragment reads only its tile's light list.
Forward+ tiled light culling: depth bounds → tile frustum → sphere test → per-tile list
Forward+ tiled light culling: depth bounds → tile frustum → sphere test → per-tile list

The culling shader (light_culling.wgsl) is the heart of it. Each tile's 256 threads cooperatively reduce the tile's min/max depth (skipping background pixels so the depth slab hugs real geometry), unproject the tile's four screen-space corners into view-space rays to form four side planes through the eye, and clamp the slab to the reduced depth range:

// Side planes pass through the eye; inward-pointing normals from corner rays.
planes[0] = normalize(cross(c0, c1));
planes[1] = normalize(cross(c1, c2));
planes[2] = normalize(cross(c2, c3));
planes[3] = normalize(cross(c3, c0));
// Depth slab from the tile's actual geometry (view-space z is negative down -Z).
let zNear = viewFromNdc(vec3f(0.0, 0.0, minD)).z;   // closest geometry
let zFar  = viewFromNdc(vec3f(0.0, 0.0, maxD)).z;   // farthest geometry

Threads then stride across the light list, accepting a light when its bounding sphere (center lp, radius = range) is inside all four planes and overlaps the depth slab — a sphere-vs-frustum test that is just four dot products plus a slab check:

var inside = true;
for (var p = 0u; p < 4u; p = p + 1u) {
  if (dot(planes[p], lp) < -r) { inside = false; }     // outside a side plane
}
if (inside && hasGeometry) {
  if (lp.z - r > zNear || lp.z + r < zFar) { inside = false; }  // outside the slab
}
if (inside) {
  let slot = atomicAdd(&wgCount, 1u);                   // append to the tile list
  if (slot < MAX_LIGHTS_PER_TILE) { wgIndices[slot] = i; }
}

The surviving indices are written as numTiles blocks of MAX_LIGHTS_PER_TILE + 1 u32 — slot 0 is the count, the rest are light indices. The shading pass then loops only over tileLights[tile], so a fragment in an unlit tile does zero light work no matter how many lights exist globally.

A few constants bound the cost (forward_plus_pass.ts:24-28):

ConstantValueRole
TILE_SIZE16Tile edge in pixels; one compute workgroup (256 threads) per tile
MAX_LIGHTS256Total culled point-light pool the storage buffer is sized for
MAX_LIGHTS_PER_TILE64Per-tile list capacity — a fragment touches at most this many lights

Only the sun (with cascaded shadows, §8) and IBL ambient are evaluated globally; just the point lights are tile-culled. This is what lets "many hundreds of point lights stay cheap" — the per-tile cap, not the global count, governs the worst-case fragment cost. Skinned transparents take a parallel route through the SkinnedForward / SkinnedForwardPlus material pass types (Chapter 6), the latter sharing this same culling machinery.

§ 7.10

GPU-Based IBL Pre-Computation#

The three IBL textures — irradiance map, GGX prefiltered environment map, and BRDF LUT — could be pre-computed offline and shipped as assets, but Taos Engine computes them at runtime on the GPU. This allows the IBL to adapt to the current sky (procedural or HDR) without managing additional texture assets per environment.

IBL baking pipeline: equirectangular sky → compute shaders → irradiance cube + prefiltered cube + BRDF LUT
IBL baking pipeline: equirectangular sky → compute shaders → irradiance cube + prefiltered cube + BRDF LUT

BRDF LUT (CPU)#

The split-sum BRDF lookup table is view-independent (depends only on NdotV and roughness), so it is computed once on the CPU and cached per device. For each texel (NdotV, roughness), the function importance-samples the GGX distribution using a Hammersley low-discrepancy sequence and integrates the Smith G₂ visibility term weighted by the Fresnel coefficient:

function computeBrdfLutData(outW: number, outH: number, samples: number): Float32Array {
  for (let py = 0; py < outH; py++) {
    for (let px = 0; px < outW; px++) {
      const NdotV = (px + 0.5) / outW;
      const roughness = (py + 0.5) / outH;
      // Importance-sample GGX, accumulate scale (A) and bias (B)
      A += G_vis * (1 - Fc);
      B += G_vis * Fc;
    }
  }
}

The result is a 64×64 rgba16float texture (A in R, B in G), built from 512 importance samples per texel. Because it depends only on the BRDF model and not on the environment, it is computed exactly once per GPUDevice. The cache is a WeakMap<GPUDevice, GPUTexture>: multiple renderers sharing a device share the one LUT, it is never explicitly destroyed (the IblTextures.destroy() releases the irradiance and prefilter cubes but deliberately leaves the LUT), and when the device is garbage-collected the WeakMap entry goes with it. Across the many IBL rebuilds a day/night cycle triggers (§10.7), this LUT is touched exactly zero extra times.

Irradiance Map (GPU Compute)#

The diffuse irradiance map is a heavily blurred version of the HDR sky that stores the cosine-weighted hemisphere integral at every direction. The cs_irradiance compute shader (src/shaders/ibl_baking.wgsl) dispatches once per cube face (6 dispatches), each thread computing one output texel:

@compute @workgroup_size(8, 8, 1)
fn cs_irradiance(@builtin(global_invocation_id) id: vec3u) {
  let uv = (vec2f(id.xy) + 0.5) / f32(IRR_SIZE);
  let dir = cube_face_dir(u32(params.face), uv * 2.0 - 1.0);
  var irradiance = vec3f(0.0);
  for (var i = 0u; i < SAMPLES; i++) {
    let xi = hammersley(i, SAMPLES);
    let local_dir = cosine_sample_hemisphere(xi);
    let world_dir = tangent_frame(dir) * local_dir;
    irradiance += textureSampleLevel(sky_tex, sky_samp, equirect_uv(world_dir), 0).rgb;
  }
  textureStore(out_tex, id.xy, vec4f(irradiance / f32(SAMPLES) * params.exposure, 1.0));
}

Each output direction dir is the center of a cube face texel transformed to a unit vector. A tangent frame is built around that vector and 256 cosine-weighted hemisphere samples are taken from the equirectangular sky texture. The result is a 32×32 rgba16float cube map — low resolution since irradiance is very low-frequency.

GGX Prefiltered Environment Map (GPU Compute)#

The specular prefiltered cube follows the same pattern but uses importance sampling of the GGX distribution. Each mip level corresponds to a different roughness value — [0.04, 0.25, 0.5, 0.75, 1.0] — allowing the lighting shader to sample a mip level matching the surface roughness:

@compute @workgroup_size(8, 8, 1)
fn cs_prefilter(@builtin(global_invocation_id) id: vec3u) {
  let uv = (vec2f(id.xy) + 0.5) / f32(mipSize);
  let dir = cube_face_dir(u32(params.face), uv * 2.0 - 1.0);
  var color = vec3f(0.0); var weight = 0.0;
  for (var i = 0u; i < SAMPLES; i++) {
    let xi = hammersley(i, SAMPLES);
    let h  = ggx_importance_sample(xi, params.roughness);
    let l  = reflect(-dir, h);
    let ndotl = max(dot(dir, l), 0.0);
    if (ndotl > 0.0) {
      color += textureSampleLevel(sky_tex, sky_samp, equirect_uv(l), 0).rgb * ndotl;
      weight += ndotl;
    }
  }
  textureStore(out_tex, id.xy, vec4f(color / weight * params.exposure, 1.0));
}

The dispatch is 6 faces × 5 roughness levels = 30 workgroups, each sampling 256 GGX-importance-weighted directions per texel. The base mip is 128×128 per face, halving at each roughness level down to 8×8 at roughness 1.0.

Note that mip 0 uses roughness 0.04, not 0. At α² = 0 the GGX PDF collapses to a Dirac delta and D = α²/(π·denom²) becomes 0/0; the filtered-importance-sampling step (below) would then select a 1×1 source mip and the entire cube face would collapse to a single color. 0.04 is the engine's minimum useful roughness — at this prefilter resolution a true perfect mirror is visually indistinguishable from it, and the genuinely sharp end of reflection is anyway better served by screen-space reflections (§7.6) than by a 128×128 cube.

Firefly Suppression#

A 256-sample Monte Carlo integral is vulnerable to fireflies: bright sparkle artifacts that appear when one of the few sample directions happens to land on a very high-luminance HDR texel — the sun disc, a light fixture, a specular highlight reflected in the sky. The unlucky pixel inherits that texel's full brightness; its neighbor, sampling a direction one texel over, gets a dim sky reading. The result is per-texel sparkle in the prefiltered cube — most visible on smooth metals, where the lighting shader samples the low-roughness mips that are most likely to contain the artifact.

The baking shader (src/shaders/ibl_baking.wgsl) applies two complementary filters to suppress them. Both run inside each sample loop, on top of the naïve estimators shown above.

1. Filtered importance sampling (FIS). Rather than always sampling source mip 0, each sample picks a mip level whose texel footprint roughly matches the sample's solid angle. The PDF of the sample (cosθ/π for the cosine hemisphere, D/4 for GGX) gives a per-sample solid angle Ωs = 1/(N·pdf); comparing that against the source's per-texel solid angle Ωp = 4π/(W·H) yields the right mip. Each +1 of mip averages 4× more source pixels, trading negligible blur on smooth surfaces for far less variance in the integral:

let omegaP = 4.0 * PI / (srcDim.x * srcDim.y);   // per source-texel solid angle
let pdf    = max(D * 0.25, 1e-6);                // GGX sampling pdf: D/4 when V=N
let omegaS = 1.0 / (f32(SAMPLES) * pdf);         // per-sample solid angle
let mip    = clamp(0.5 * log2(omegaS / omegaP) + FIS_MIP_BIAS, 0.0, mipMax);

let sample = textureSampleLevel(sky_tex, sky_samp, equirect_uv(L), mip).rgb;

FIS_MIP_BIAS = 2.0 adds two extra mip levels on top of the analytic choice — the belt to FIS's braces — so even a narrow GGX lobe at low roughness still averages a small patch of source pixels rather than a single texel. The irradiance shader does the same calculation with pdf = cosθ/π.

2. Per-sample luminance clamp. Even with FIS, a single very bright HDR texel can still dominate the average when it survives the mip pre-filter. The shader caps each sample's luminance to FIREFLY_LUM_CLAMP = 10.0 while preserving its chroma:

fn clamp_firefly(rgb: vec3<f32>, maxLum: f32) -> vec3<f32> {
  let L = luminance(rgb);
  return select(rgb, rgb * (maxLum / max(L, 1e-6)), L > maxLum);
}

This is biased — it darkens the brightest lights — but the bias is far less perceptually objectionable than the sparkle it removes. A similar threshold around 10 is common; higher values preserve punchy suns at the cost of reintroducing sparkles on high-contrast HDRs.

The two filters target different failure modes. FIS reduces variance by averaging more source area per sample; the luminance clamp caps the magnitude of any single contribution that survives FIS. Together they keep the irradiance and prefilter cubes clean at 256 samples — without them the same dispatch would need an order of magnitude more samples to look acceptable on smooth metals.

These compute dispatches run whenever the sky changes. src/assets/ibl.ts exposes two drivers for them. computeIblGpu() is the one-shot path — it allocates fresh textures, submits the work, and awaits onSubmittedWorkDone() before returning the ready-to-use textures; it suits a sky baked once at load. Taos Engine's game instead uses IblBaker, which allocates its cubes and bind groups once and then re-records the convolution into a caller-supplied command encoder on demand — with no per-bake allocation and no CPU/GPU sync — so the IBL can be rebaked continuously as the procedural sky tracks the time of day (see §10.7).

The diffuse half of this output — the irradiance cube — is so low-frequency that it can be stored far more compactly as nine spherical-harmonic coefficients instead of a 32×32×6 texture; §7.17 covers that representation and how it slots into the same bake and shading code.

§ 7.11

Reflection Probes#

Global IBL (§7.6 / §7.10) lights everything in the world with the same environment — fine for outdoor scenes where the sky is the only meaningful indirect light, but unconvincing indoors or inside any enclosed volume. A mirror inside a stone room shouldn't reflect the open sky; the inside of a portal arena shouldn't pick up the same horizon as the open plain it sits in. Reflection probes solve this by capturing the environment from a chosen point in the world into a cubemap, convolving that cube into a localized IBL pair, and blending it over the global IBL within a bounded influence volume.

The implementation uses a per-probe oriented bounding box that doubles as both the capture-culling volume and the spatial weighting volume, and reflections are parallax-corrected by intersecting against that box before sampling the cube.

Reflection probe pipeline: probe → 6 cube faces → irradiance + prefiltered cube-arrays → sampled in lighting
Reflection probe pipeline: probe → 6 cube faces → irradiance + prefiltered cube-arrays → sampled in lighting

Component and Manager#

The user-facing surface is the ReflectionProbe component (src/engine/components/reflection_probe.ts). It is added to a GameObject whose world transform locates the probe and orients its box:

export class ReflectionProbe extends Component {
  captureContent: ReflectionProbeContent = 'sky-and-clouds';  // 'sky' | 'sky-and-clouds' | 'scene'
  updateMode: ReflectionProbeUpdateMode = 'once';             // 'once' | 'every-frame' | 'on-demand'
  extentHalfSize: Vec3 = new Vec3(8, 4, 8);                   // local-space AABB half-extents
  boxProjection = true;                                       // parallax-correct reflections
  influenceFalloff = 0.25;                                    // linear fade band (fraction of half-extent)
  requestUpdate(): void { this.needsBake = true; }            // force re-bake on next frame
}

The three capture modes trade fidelity for cost. 'sky' evaluates the procedural sky shader per direction — by far the cheapest, since no geometry is involved. 'sky-and-clouds' adds a stylized 2D cloud layer projected onto the upper hemisphere. 'scene' adds a forward render of every MeshRenderer whose world AABB intersects the probe's capture extent — the most expensive option, but the only one that captures nearby walls, props, or characters.

Update modes match those tradeoffs. 'once' bakes on the first frame the probe is rendered and never again — the right choice for static scenes. 'every-frame' bakes every frame, paying the cost for moving probes or animated geometry. 'on-demand' bakes only after requestUpdate() returns, useful when the lighting changes occasionally (a door opens, a fixture is destroyed) but not every frame.

The ReflectionProbeFeature (src/renderer/features/reflection_probe_feature.ts) discovers probes in the scene each frame, assigns each one a slot in a fixed-size cube-array (MAX_PROBES = 8), determines which slots need re-baking, and uploads the per-probe uniform data the lighting shader consumes:

// Three persistent cube-array textures shared across all probes
this.captureArray     = ctx.device.createTexture({ format: CAPTURE_FORMAT, size: [256, 256, 6 * MAX_PROBES], /* ... */ });  // raw env capture
this.irradianceArray  = ctx.device.createTexture({ format: 'rgba16float',  size: [ 32,  32, 6 * MAX_PROBES], /* ... */ });  // diffuse
this.prefilteredArray = ctx.device.createTexture({ format: 'rgba16float',  size: [128, 128, 6 * MAX_PROBES],
                                                   mipLevelCount: IBL_LEVELS, /* ... */ });                                  // specular (5 mips)

Sharing one cube-array per texture type — rather than allocating individual cubes per probe — avoids per-bake texture churn and lets the lighting shader index into a single binding by slotIndex rather than juggling N bind groups.

Capture Pass#

For each probe that needs baking, the ReflectionProbeCapturePass (src/renderer/render_graph/passes/reflection_probe_capture_pass.ts) renders six 256×256 faces around the probe's world position. The cube-face view-projection matrices are built by cubeCaptureViewProjs(position) — six lookAt matrices oriented along ±X, ±Y, ±Z, each with a 90° perspective.

Two pipelines render into every face. The sky pipeline runs first as a fullscreen triangle; its fragment shader reconstructs a view ray from NDC via the inverse view-projection and evaluates the procedural sky — a three-stop gradient (ground → horizon → zenith) with a soft sun disc and, in 'sky-and-clouds' mode, a sine-based fbm cloud layer. The sky pass writes depth as well, effectively clearing the face. The mesh pipeline runs second when captureContent === 'scene': a standard forward shader with simple Lambert + hemispheric ambient draws every mesh whose world AABB survives a per-probe cull, with normal depth testing.

The procedural sky used here is intentionally cheaper than the full atmosphere shader (§10.2) — IBL convolution is so bandlimited that the simplified gradient is indistinguishable from the real sky after blurring, and the savings let probes rebake every frame on modest GPUs.

IBL Convolution#

The capture cube on its own is not useful for shading: the lighting shader needs an irradiance map (diffuse, cos-weighted hemisphere integral) and a prefiltered map (GGX specular at varying roughness). The ReflectionProbeIblPass (src/renderer/render_graph/passes/reflection_probe_ibl_pass.ts) runs two compute shaders (src/shaders/reflection_probe_ibl.wgsl) immediately after capture.

The math is identical to the global IBL bake (§7.10) — 256 cosine-weighted samples for irradiance, 256 GGX-importance samples per roughness level for the prefilter mip chain — but reads from a cube-array slice rather than a single cubemap. The output is written to the matching slot in the persistent irradiance and prefiltered arrays. Firefly suppression (§7.10) is not run here: the capture cube has no mips for filtered importance sampling to step through, and the procedural sky avoids the extreme HDR luminance peaks that cause fireflies in the first place.

Box Projection#

Sampling a cubemap with the raw reflection direction R = reflect(-V, N) assumes the environment is infinitely far away — fine for the sky, wrong for an enclosed room. The mirror should reflect the actual wall, not what is straight along R at infinity. Box-projected reflections fix this by intersecting the reflection ray with the probe's bounding box, then sampling the cube along the direction from the probe center to the hit point:

With and without box projection: the reflection direction is bent to "stick" the cube sample to the box geometry
With and without box projection: the reflection direction is bent to "stick" the cube sample to the box geometry

The shader does the intersection in the probe's local space, so the box is always axis-aligned regardless of how the GameObject is rotated:

fn box_project_direction(p: ProbeData, worldPos: vec3<f32>, worldR: vec3<f32>) -> vec3<f32> {
  if ((p.flags & 1u) == 0u) { return worldR; }            // disabled — return original ray
  let localPos = (p.worldToLocal * vec4<f32>(worldPos, 1.0)).xyz;
  let localR   = (p.worldToLocal * vec4<f32>(worldR,  0.0)).xyz;
  let invR = vec3<f32>(
    select(1.0 / localR.x, 1e20, abs(localR.x) < 1e-5),   // guard division by zero
    select(1.0 / localR.y, 1e20, abs(localR.y) < 1e-5),
    select(1.0 / localR.z, 1e20, abs(localR.z) < 1e-5),
  );
  let tMax = ( p.halfExtentLS - localPos) * invR;          // t to +halfExtent planes
  let tMin = (-p.halfExtentLS - localPos) * invR;          // t to -halfExtent planes
  let tPos = max(tMax, tMin);                              // forward t per axis
  let t    = max(min(min(tPos.x, tPos.y), tPos.z), 0.0);   // nearest forward hit
  let hitWorld = worldPos + worldR * t;
  return normalize(hitWorld - p.positionWS);               // re-aim from probe center
}

The closed-form slab test takes the nearest positive t across all three axis pairs — that is the first face the reflected ray exits through. Re-aiming from the probe center to the hit point gives a direction the cubemap was actually captured along, so the resulting sample lines up with the room geometry. Toggling boxProjection to false skips the intersection and falls back to the as-if-infinite assumption; the raw capture is unmodified either way, so the cost is paid only at sampling time.

Influence Falloff#

A probe's effect is bounded by its AABB. Points well inside the box should be lit entirely by the probe; points outside should fall back to the global IBL; points in a thin band near the surface should blend smoothly between the two to avoid a visible seam where the probe's domain ends:

fn probe_weight(p: ProbeData, worldPos: vec3<f32>) -> f32 {
  let local = (p.worldToLocal * vec4<f32>(worldPos, 1.0)).xyz;
  let d = abs(local) - p.halfExtentLS;
  if (d.x > 0.0 || d.y > 0.0 || d.z > 0.0) { return 0.0; }    // outside the box
  let interior = -max(max(d.x, d.y), d.z);                    // distance to nearest face
  let minHalf  = min(min(p.halfExtentLS.x, p.halfExtentLS.y), p.halfExtentLS.z);
  let fade     = max(p.influenceFalloff, 0.0) * minHalf;      // fade band width
  if (fade <= 0.0) { return 1.0; }
  return clamp(interior / fade, 0.0, 1.0);                    // linear blend
}

influenceFalloff (default 0.25) is expressed as a fraction of the smallest half-extent — keying off the smallest axis prevents a very thin box from having a fade band wider than the box itself. Set the falloff to zero for hard-edged probes (matching cubes in a level-of-detail grid, say); set it higher when overlapping probes need to cross-fade smoothly.

Multi-Probe Blending#

The deferred lighting pass loops over every active probe and accumulates a weighted average:

var probe_irr_sum = vec3<f32>(0.0);
var probe_pf_sum  = vec3<f32>(0.0);
var probe_w_sum   = 0.0;
let pf_mip = roughness * (IBL_MIP_LEVELS - 1.0);
for (var pi = 0u; pi < probes.count && pi < MAX_PROBES; pi = pi + 1u) {
  let p  = probes.probes[pi];
  let w  = probe_weight(p, world_pos);
  if (w <= 0.0) { continue; }
  let sampleR = box_project_direction(p, world_pos, R);
  let irr_s = textureSampleLevel(probe_irr_cube, ibl_samp, N,       i32(pi), 0.0).rgb;
  let pf_s  = textureSampleLevel(probe_pf_cube,  ibl_samp, sampleR, i32(pi), pf_mip).rgb;
  probe_irr_sum = probe_irr_sum + irr_s * w;
  probe_pf_sum  = probe_pf_sum  + pf_s  * w;
  probe_w_sum   = probe_w_sum   + w;
}

Irradiance is sampled along the surface normal N (diffuse — direction-independent of view), while the prefiltered cube is sampled along the box-projected reflection direction with a mip level chosen by roughness — exactly the split-sum sampling pattern as the global IBL, just per-probe. After the loop the normalized probe contribution is blended over the global IBL with t = clamp(probe_w_sum, 0..1): full probe lighting where coverage saturates, full global lighting outside any probe, smooth crossfade in between.

Because the cube-array binding is fixed size and probes.count controls the loop length, disabling all reflection probes costs nothing in the shader — the loop simply runs zero iterations and the global IBL path wins.

Pipeline Position#

The two probe passes are inserted into the render graph before DeferredLightingPass, so the freshly-baked IBL arrays are visible by the time the lighting shader binds them:

// 1. Capture probes that need baking → write into captureArray
ReflectionProbeCapturePass.addToGraph(graph, { captureArray, requests, /* ... */ });
// 2. Convolve captureArray → irradianceArray + prefilteredArray (compute)
ReflectionProbeIblPass.addToGraph(graph, { captureArray, irradianceArray, prefilteredArray, slots });
// 3. (later) DeferredLightingPass samples both arrays

All three cube-array textures and the probe UBO are persistent across frames — graph.importPersistentTexture() ensures the same physical resource is reused so 'once'-mode probes survive between frames without rebaking. When no probe needs a bake this frame, the capture and IBL passes still run but loop over an empty request list, which the render graph then culls.

Usage Example#

samples/reflection_probe_test.ts is the reference setup — a single scene-mode probe centered on a room, rebaked every frame so interactive geometry changes show up in the reflections:

const probeGO = new GameObject({ name: 'ReflectionProbe' });
probeGO.setPosition(0, 1.4, 0);
const probe = probeGO.addComponent(new ReflectionProbe());
probe.extentHalfSize = new Vec3(8, 4, 6);
probe.captureContent = 'scene';        // capture nearby meshes
probe.updateMode = 'every-frame';      // rebake every frame
probe.boxProjection = true;            // parallax-correct
probe.influenceFalloff = 0.2;          // 20% fade band near the box surface
engine.scene.add(probeGO);

The sample's controls (1/2/3 cycle capture content, M cycles update mode, R forces re-bake, B toggles parallax) make the visual effect of each parameter directly observable — the easiest way to build intuition before placing probes in a real level.

Reflection Probe Settings#

FieldDefaultDescription
captureContent'sky-and-clouds'What to render into the cube: sky only, sky+clouds, or sky+scene meshes
updateMode'once'When to rebake: first frame, every frame, or only after requestUpdate()
extentHalfSize(8, 4, 8)Local-space AABB half-extents — culls scene-mode meshes and defines influence volume
boxProjectiontrueIf false, sample cube with raw R (infinite-distance assumption)
influenceFalloff0.25Width of the linear fade band, as a fraction of the smallest half-extent

MAX_PROBES = 8 is a shader-side constant; raising it requires bumping the cube-array layer count, the ProbesUniform array size, and the loop bound in src/shaders/deferred_lighting.wgsl.

§ 7.12

Ambient Occlusion and Screen-Space Techniques#

The next three sections cover Taos Engine's three interchangeable ambient occlusion algorithms — SSAO, HBAO+, and GTAO. They share so much machinery that it's worth establishing the common ground first; the per-technique sections then focus on what actually differs between them.

Screen-space techniques#

All three are screen-space techniques: they run as full-screen passes after the geometry has been rasterized, reading only the G-Buffer — the per-pixel depth and world-space normal produced by the geometry pass (Chapter 4). They never touch the scene graph, meshes, or materials. This buys two things and costs one:

What ambient occlusion approximates#

The IBL ambient term (§7.6) lights every surface as if the whole hemisphere of sky were visible to it. That's wrong in any crevice: the inside corner of a step, the gap under a rock, the seam where two walls meet all receive less ambient light because nearby geometry blocks part of the sky. Ambient occlusion estimates that fraction — a scalar in [0, 1], where 1 is fully open and 0 is fully enclosed — by examining how much surrounding geometry crowds the hemisphere above each pixel.

AO multiplies the ambient/indirect term only. Direct lighting is not scaled by it — that's the shadow map's job, and darkening contact regions twice would double-count the occlusion. The result is the soft contact darkening in corners and cavities that makes a scene read as grounded rather than floating.

"Ambient term" here means the whole environment contribution — both the IBL diffuse irradiance and the IBL specular reflection. It is a common simplification to occlude only the diffuse half, but Taos Engine applies the AO factor to both, because a crevice that can't see the sky can't see the sky's reflection either. In the deferred shader the two are summed and multiplied together, alongside two further occlusion factors:

let ambient = (diffuse_ibl + specular_ibl) * ao * horizon_fade
            * light.iblIntensity * mat_occlusion
            + ambient_floor(albedo, metallic);

Naïvely occluding specular has a failure mode — a mirror-smooth metal would get dark blotches where AO is strong, since a perfect reflection has no business being dimmed by a diffuse occlusion estimate. The split-sum specular is already roughness-aware, so in practice the visible effect is confined to rough/curved surfaces where it reads as correct contact shading; the ambient_floor term keeps even fully-occluded pixels from crushing to pure black.

The three algorithms#

Taos Engine implements three, all consuming the identical { normal, depth } G-Buffer pair, all writing a half-resolution r8unorm AO target, all cleaned up by the same bilateral/box blur shader, and all selected at runtime through a single aoMethod flag (§7.15). They differ only in the integral each uses to turn nearby depth into an occlusion factor:

Three ways to estimate ambient occlusion from the G-Buffer
Three ways to estimate ambient occlusion from the G-Buffer
TechniqueCore ideaStrengthsWeaknesses
SSAO (§7.13)Scatter a hemisphere of sample points around the pixel; count how many fall inside geometrySimplest and cheapest; well understood; few parametersBinary in/out test is noisy; prone to silhouette haloing; least physically grounded
HBAO+ (§7.14)March a few screen-space directions; keep the steepest horizon angle in eachSharper contact shadows; far less noise than SSAO; moderate costApproximate integral (no cosine weighting, no projected-normal term) → mild directional banding at grazing angles
GTAO (§7.15, default)Analytically integrate the cosine-weighted visible sky between two horizons per sliceMost physically accurate; smooth falloff; no haloingMost trig per pixel; needs careful thickness handling to avoid phantom halos

The progression is roughly cheapest-to-most-accurate: SSAO is the textbook baseline, HBAO+ trades a little fidelity for horizon-based sharpness at moderate cost, and GTAO is the ground-truth reference Taos Engine defaults to. Because the inputs, output, and blur are identical, switching between them is a one-line change — the rest of this discussion treats them as drop-in alternatives.

Interactive · Ambient OcclusionOpen ↗
Interactive demo · loads when scrolled into view
Compare the three algorithms. A corner, a staircase and some resting spheres lit mostly by the environment, since AO only darkens indirect light. Switch the view to AO buffer to see the raw occlusion term the lighting pass multiplies in, then raise the radius to watch occlusion spread from tight creases to whole corners.
§ 7.13

Screen-Space Ambient Occlusion (SSAO)#

SSAO estimates ambient light occlusion by sampling the depth buffer around each pixel and counting how many samples fall inside nearby geometry. The SSAOPass (src/renderer/render_graph/passes/ssao_pass.ts) implements classic view-space hemisphere SSAO — the normal-oriented refinement of the screen-space ambient occlusion Mittring introduced in Finding Next Gen (SIGGRAPH 2007), which sampled a full sphere. It's the simplest of the three (§7.12): there's no horizon traversal, no analytical occlusion integral, and no temporal accumulation. Just a per-pixel hemisphere of samples, an in/out test, and a bilateral cleanup blur.

SSAO: hemisphere of samples around the surface
SSAO: hemisphere of samples around the surface

The pass runs at half resolution to keep the per-pixel cost manageable — the AO factor varies smoothly enough that the blur recovers most of the detail loss.

Per-Frame Setup: Kernel and Noise#

Two static inputs are built once at pass creation and uploaded as uniforms:

1. The hemisphere kernel — 16 random points in the unit upper hemisphere (z >= 0):

function generateKernel(): Float32Array {
  const k = new Float32Array(KERNEL_SIZE * 4);
  for (let i = 0; i < KERNEL_SIZE; i++) {
    const cosT = Math.random();
    const phi = Math.random() * Math.PI * 2;
    const sinT = Math.sqrt(1 - cosT * cosT);
    const scale = 0.1 + 0.9 * (i / KERNEL_SIZE) ** 2;
    k[i * 4 + 0] = sinT * Math.cos(phi) * scale;
    k[i * 4 + 1] = sinT * Math.sin(phi) * scale;
    k[i * 4 + 2] = cosT * scale;
    k[i * 4 + 3] = 0;
  }
  return k;
}

Each sample is roughly cosine-weighted (cosT = U(0, 1) so points cluster near the pole), then radially scaled by 0.1 + 0.9 * (i/N)² so most samples sit close to the origin, where they contribute most to AO. Far samples still exist for crevice detection but they're a minority of the kernel.

2. The rotation noise — a 4×4 rgba8unorm texture of random 2D unit vectors (cos θ, sin θ) encoded as 0.5 + 0.5 * v. The shader tiles this across the screen via uv % 4 and uses the decoded vector to rotate the kernel per pixel. Without per-pixel rotation, every pixel would sample the same 16 directions and the result would show visible kernel banding; the 4×4 dither breaks that up at the cost of high-frequency noise the blur then smooths out.

The AO Pass#

For each half-resolution pixel (src/shaders/ssao.wgsl, fs_ssao):

1. Reconstruct view-space position and normal. Read the G-Buffer depth and world-space normal at the matching full-res texel, transform the normal into view space, and reconstruct view-space P from depth via the inverse projection.

2. Build a per-pixel TBN frame. Use Gram-Schmidt to construct an orthonormal basis from the tiled noise vector and the surface normal:

let noise_coord = vec2<u32>(half_coord) % vec2<u32>(4u);
let rnd         = textureLoad(noise_tex, noise_coord, 0).rg * 2.0 - 1.0;

let rand_vec = vec3<f32>(rnd, 0.0);
let T = normalize(rand_vec - N * dot(rand_vec, N));
let B = cross(N, T);
let tbn = mat3x3<f32>(T, B, N);  // tangent space → view space

This re-orients the hemisphere kernel so its +z axis aligns with the surface normal — every kernel sample is now guaranteed to be on the visible side of the surface.

3. Test 16 samples. For each kernel sample, transform it into view space, project it back to screen UV, read the depth there, and reconstruct that point's view-space Z:

for (var i = 0; i < KERNEL_SIZE; i++) {
  // View-space sample: kernel.z aligns with N (hemisphere above surface)
  let sample_vs = P + (tbn * ssao.kernel[i].xyz) * ssao.radius;

  // Project sample to screen UV, sample depth there
  let clip       = ssao.proj * vec4<f32>(sample_vs, 1.0);
  let ndc_xy     = clip.xy / clip.w;
  let sample_uv  = vec2<f32>(ndc_xy.x * 0.5 + 0.5, -ndc_xy.y * 0.5 + 0.5);
  let ref_depth  = textureLoad(depth_tex, /* ... */, 0);
  let ref_z      = view_pos(sample_uv, ref_depth).z;

  // Range falloff: ignore hits on geometry far from this surface
  let range_check = 1.0 - smoothstep(0.0, ssao.radius, abs(P.z - ref_z));

  // Occluded when the real surface is closer to the camera than the sample
  occlusion += select(0.0, 1.0, ref_z > sample_vs.z + ssao.bias) * range_check;
}

let ao = clamp(1.0 - (occlusion / f32(KERNEL_SIZE)) * ssao.strength, 0.0, 1.0);

Two details worth calling out:

The final AO factor is written to a half-res r8unorm target.

Bilateral Blur#

The raw output is noisy (16 samples × per-pixel rotation = high-frequency dither). The blur pass (src/shaders/ssao_blur.wgsl) supports two quality modes selectable at construction time:

Quality (default) — two-pass separable bilateral Gaussian:

const GAUSS: array<f32, 4> = array<f32, 4>(
  0.19638062, 0.17469900, 0.12161760, 0.06706740,
);

fn blur(uv: vec2<f32>, step: vec2<f32>) -> vec4<f32> {
  let depth0 = depth_load(uv);
  var accum  = 0.0;
  var weight = 0.0;
  for (var i: i32 = -3; i <= 3; i++) {
    let uv_off  = uv + step * f32(i);
    let depth_s = depth_load(uv_off);
    let ao_s    = textureSampleLevel(ao_tex, samp, uv_off, 0.0).r;
    let d_wt    = exp(-abs(depth_s - depth0) * 1000.0);
    let w       = GAUSS[abs(i)] * d_wt;
    accum  += w * ao_s;
    weight += w;
  }
  return vec4<f32>(accum / max(weight, 1e-5), 0.0, 0.0, 1.0);
}

Two passes (horizontal then vertical), 7 taps each, with the spatial Gaussian (GAUSS[]) multiplied by a depth-aware term exp(-|Δdepth| * 1000). The depth term collapses to zero across silhouettes, so AO from a near surface doesn't bleed onto a distant one — a classic bilateral edge stop.

Performance — a single-pass unweighted 4×4 box average. Cheaper but ignores depth, so it can mush AO across edges.

Pipeline Position#

SSAOPass.addToGraph(graph, { normal, depth }) takes the two G-Buffer handles already produced by the geometry passes and returns a half-res r8unorm AO handle:

const ssao = ssaoPass.addToGraph(graph, { normal: gbuf.normal, depth: gbuf.depth });
// ... later, the lighting pass consumes the AO:
const lit = lightingPass.addToGraph(graph, { gbuffer: gbuf, ao: ssao.ao, /* ... */ });

Downstream, two passes read the AO:

Both intermediate (raw) and final (blurred) AO targets are transient per-frame resources; only the kernel uniform buffer and the noise texture persist on the pass instance.

§ 7.14

Horizon-Based Ambient Occlusion (HBAO+)#

HBAO+ sits between SSAO and GTAO, both historically and in behavior. It descends from NVIDIA's Image-Space Horizon-Based Ambient Occlusion (Bavoil et al., 2008); the "+" denotes the later production variant that adds per-pixel jitter and an angle bias. Like GTAO it tracks horizons rather than counting in/out samples — but it keeps the original, cheaper integral: for each of several screen-space directions it finds the single steepest horizon angle above the surface's tangent plane and sums max(sin h − sin bias, 0). The HBAOPlusPass (src/renderer/render_graph/passes/hbao_plus_pass.ts) implements it, running at half resolution like the other two.

HBAO+: eight jittered directions per pixel
HBAO+: eight jittered directions per pixel

Per-Frame Setup: Jitter Noise#

HBAO+ needs only one static input, and it's simpler than SSAO's: a 4×4 rgba8unorm tile of uniform-random values in the red and green channels (blue/alpha are constant). There is no sample kernel — the directions are generated analytically in the shader. The two noise channels are tiled across the screen and used to jitter two things per pixel:

function generateNoise(): Uint8Array {
  const noise = new Uint8Array(16 * 4);
  for (let i = 0; i < 16; i++) {
    noise[i * 4 + 0] = Math.round(Math.random() * 255);  // rnd.x → start angle
    noise[i * 4 + 1] = Math.round(Math.random() * 255);  // rnd.y → march offset
    noise[i * 4 + 2] = 128;
    noise[i * 4 + 3] = 255;
  }
  return noise;
}

Without the jitter, every pixel would probe the same eight directions at the same step distances, and the AO would show coarse directional banding; the 4×4 dither breaks that into high-frequency noise the blur removes.

The AO Pass#

For each half-resolution pixel (src/shaders/hbao_plus.wgsl, fs_hbao):

1. Reconstruct view-space position and normal. Identical to SSAO and GTAO — read depth and the world normal at the matching full-res texel (coord = half_coord * 2), reconstruct view-space P via the inverse projection, and rotate the normal into view space.

2. Convert the world-space radius to a screen-space march length. Same projection-scale trick GTAO uses, so the sampling footprint stays a fixed world size regardless of distance:

let proj_scale = u.proj[1][1] * 0.5 * tex_size.y;
let radius_px  = clamp(u.radius * proj_scale / max(-P.z, 0.01), 4.0, 256.0);
let step_px    = max(radius_px / f32(STEPS_PER_DIR), 1.0);

3. March each direction and track its steepest horizon. Eight directions (NUM_DIRS = 8), each jittered by rnd.x; within each, four steps (STEPS_PER_DIR = 4), each offset jittered by rnd.y. For every sample the shader measures the sine of the angle above the tangent plane — that's exactly (D · N) / |D| for the offset vector D = sample − P — and keeps the maximum:

for (var d: i32 = 0; d < NUM_DIRS; d++) {
  let phi   = (f32(d) + rnd.x) * (TWO_PI / f32(NUM_DIRS));
  let omega = vec2<f32>(cos(phi), sin(phi));

  var sin_h: f32 = -1.0;                        // steepest horizon along this direction
  for (var step_i: i32 = 1; step_i <= STEPS_PER_DIR; step_i++) {
    let off  = (f32(step_i) - 0.5 + rnd.y) * step_px;
    let uv_s = clamp(in.uv + omega * off / tex_size, vec2<f32>(0.0), vec2<f32>(1.0));
    let ps   = view_pos(uv_s, depth_load_uv(uv_s));
    let D    = ps - P;
    let len  = length(D);
    if (len > u.radius * 2.0 || len < 1e-5) { continue; }   // ignore far / coincident hits

    let s    = dot(D, N) / len;                 // sin(angle above tangent plane)
    let attn = 1.0 - smoothstep(0.0, u.radius * 2.0, len);  // soften with distance
    sin_h = max(sin_h, s * attn);
  }

  // Visible extent above the tangent plane, minus a bias; clamp negatives to zero.
  ao_accum += max(sin_h - sin_bias, 0.0);
}

let occlusion = (ao_accum / f32(NUM_DIRS)) * u.strength;
let ao_factor = clamp(1.0 - occlusion, 0.0, 1.0);
HBAO+: the steepest horizon along one direction
HBAO+: the steepest horizon along one direction

Three details distinguish HBAO+ from its neighbors:

Blur and Pipeline Position#

The raw output is noisy (few directions × per-pixel jitter), so it goes through the exact same blur shader as SSAO and GTAO (src/shaders/ssao_blur.wgsl). HBAOPlusPass builds both blur variants at construction and picks one per the AOBlurQuality flag: 'quality' runs the two-pass separable bilateral Gaussian, 'performance' runs the single-pass box blur (see §7.13's Bilateral Blur).

Because the pass emits the same half-res r8unorm handle as the other two, the host selects it with the same runtime ternary shown in §7.15 (aoMethod === 'hbao+'), and the AO is consumed downstream exactly as the SSAO section described — the DeferredLightingPass multiplies the ambient term by it, and the CompositePass darkens fogged crevices with it.

HBAO+ Parameters#

NameValueRole
NUM_DIRS8Screen-space directions per pixel (shader constant)
STEPS_PER_DIR4March steps per direction (shader constant)
radius1.0World-space sampling radius
bias0.1Tangent angle bias in radians — horizons nearer than this count as open
strength2.0Multiplier on averaged occlusion before subtraction from 1 (higher → darker)

NUM_DIRS and STEPS_PER_DIR are compile-time constants in src/shaders/hbao_plus.wgsl; radius, bias, and strength are runtime uniforms set via HBAOPlusPass.updateParams().

§ 7.15

Ground-Truth Ambient Occlusion (GTAO)#

SSAO answers a binary question per sample — is this point inside geometry? — and averages the yes/no votes. GTAO (Jiménez et al., "Practical Realtime Strategies for Accurate Indirect Occlusion", SIGGRAPH 2016) asks a sharper one: along this direction, at what angle does the sky get blocked? Like HBAO+ (§7.14) it traces horizons instead of probing in/out, but where HBAO+ keeps a single steepest angle per direction and sums it in the sine domain, GTAO traces both horizons of each slice and evaluates the visible cone of sky between them with a closed-form, cosine-weighted integral. The result has a smoother falloff, no light-bleed haloing at silhouettes, and a more physically meaningful occlusion term.

Slices and Horizons#

GTAO splits the hemisphere integral into slices. A slice is a plane that contains the view vector V and a screen-space direction ω; the occlusion is integrated within each slice and averaged across them. Taos Engine traces NUM_SLICES = 2 slices per pixel, each rotated by a per-pixel jittered angle so that the 4×4 noise dither plus the cleanup blur recover the slices that weren't traced:

for (var s: i32 = 0; s < NUM_SLICES; s++) {
  // Slice angle in [0, π) jittered per pixel.
  let phi   = (f32(s) + rnd.x) * (PI / f32(NUM_SLICES));
  let omega = vec2<f32>(cos(phi), sin(phi));

  // View-space slice tangent: reconstruct a same-depth neighbor along omega.
  let near_uv = clamp(in.uv + omega / tex_size, vec2<f32>(0.0), vec2<f32>(1.0));
  let slice_t = normalize(view_pos(near_uv, depth) - P);
  let axis    = normalize(cross(V, slice_t));
  // ...

Within one slice the shader marches STEPS_PER_DIR = 4 steps in each direction (+ω and −ω), reconstructing each depth sample's view-space position. For every sample it tracks the horizon — the steepest direction in which nearby geometry rises toward V. The horizon is stored as the largest cosine of the angle from V: a higher cosine means a direction closer to straight-up, i.e. a tighter (more occluding) horizon.

GTAO traces a horizon on each side of a slice
GTAO traces a horizon on each side of a slice
for (var step_i: i32 = 1; step_i <= STEPS_PER_DIR; step_i++) {
  let off  = (f32(step_i) - 0.5 + rnd.y) * step_px;
  let uv_p = clamp(in.uv + omega * off / tex_size, vec2<f32>(0.0), vec2<f32>(1.0));
  let uv_n = clamp(in.uv - omega * off / tex_size, vec2<f32>(0.0), vec2<f32>(1.0));

  let pp = view_pos(uv_p, depth_load_uv(uv_p));   // +omega sample
  let pn = view_pos(uv_n, depth_load_uv(uv_n));   // -omega sample
  let dp = pp - P;  let lp = length(dp);
  let dn = pn - P;  let ln = length(dn);

  // Thickness heuristic: samples farther than max_thick don't lower the
  // horizon, so distant background geometry can't "haunt" foreground AO.
  if (lp < max_thick && lp > 1e-5) { cos_h_pos = max(cos_h_pos, dot(dp, V) / lp); }
  if (ln < max_thick && ln > 1e-5) { cos_h_neg = max(cos_h_neg, dot(dn, V) / ln); }
}

The two rnd channels from the tiled 4×4 noise are used distinctly: rnd.x jitters the slice angle phi, and rnd.y jitters the step offset off so neighboring pixels don't probe identical depths.

The Projected Normal#

Classic SSAO orients its kernel around the surface normal so every sample lands on the visible side. GTAO does the equivalent analytically: it projects the surface normal N onto the slice plane and measures that projected normal's signed angle from V.

// Project N onto the slice plane (remove the component along the slice axis).
let proj_N     = N - axis * dot(N, axis);
let proj_N_len = length(proj_N);
if (proj_N_len < 1e-4) { continue; }

// Signed angle of the projected normal from V toward slice_t.
let n_angle = atan2(dot(proj_N, slice_t), dot(proj_N, V));

n_angle defines where the surface's true visible hemisphere sits within the slice — it spans [n_angle − π/2, n_angle + π/2]. proj_N_len (how much of N lies in this slice plane) becomes the slice's weight: a slice nearly perpendicular to N contributes almost nothing and is rightly down-weighted.

The Analytical Slice Integral#

With both horizons traced and the projected normal known, the slice's visibility is an integral, not a sample count. The horizon cosines are turned into signed angles, clamped to the projected normal's visible hemisphere, and the cosine-weighted visibility between them is evaluated in closed form:

Clamping horizons and evaluating the cosine-weighted integral
Clamping horizons and evaluating the cosine-weighted integral
// Convert horizon cosines to signed angles measured from V.
let h_pos = acos(clamp(cos_h_pos, -1.0, 1.0));   //  0..π
let h_neg = -acos(clamp(cos_h_neg, -1.0, 1.0));  // -π..0

// Clamp to the visible hemisphere of the projected normal.
let h_pos_c = min(h_pos, n_angle + HALF_PI);
let h_neg_c = max(h_neg, n_angle - HALF_PI);

// Cosine-weighted slice integral, normalized so an open slice yields 1.
let v_slice = 0.5 * (sin(h_pos_c - n_angle) - sin(h_neg_c - n_angle));
visibility += v_slice * proj_N_len;

The integral ∫ cos(α − n_angle) dα / 2 over the clamped horizon span has the exact antiderivative (sin(h_pos_c − n_angle) − sin(h_neg_c − n_angle)) / 2 — one sin() per horizon, no Monte-Carlo loop. This closed form is what makes the technique "ground truth": given the traced horizons, the per-slice visibility is exact, not estimated.

After all slices are accumulated, the average is raised to the strength exponent to control AO contrast:

visibility = visibility / f32(NUM_SLICES);
let ao = clamp(pow(max(visibility, 0.0), u.strength), 0.0, 1.0);

Radius, Thickness, and Half-Res Stepping#

Like SSAO, GTAO runs at half resolution — the raw pass writes a half-res target, and addresses the full-res G-Buffer by doubling its coordinate (coord = half_coord * 2). The world-space radius is converted to a screen-space march length once per pixel, using the vertical projection scale and the pixel's view-space depth, then clamped so very near or very far surfaces stay sane:

let proj_scale = u.proj[1][1] * 0.5 * tex_size.y;
let radius_px  = clamp(u.radius * proj_scale / max(-P.z, 0.01), 4.0, 256.0);
let step_px    = max(radius_px / f32(STEPS_PER_DIR), 1.0);
let max_thick  = u.radius * (1.0 + u.bias * 10.0);

max_thick is the thickness cutoff used by the marching loop above: a depth sample farther than radius·(1 + bias·10) from the surface is treated as unrelated background and is not allowed to pull the horizon down. Without it, a distant wall behind a thin railing would cast a phantom occlusion halo onto the foreground.

Blur and Pipeline Position#

The raw GTAO output is noisy for the same reason SSAO's is — few samples, per-pixel jitter — so it is cleaned up by the exact same blur shader (src/shaders/ssao_blur.wgsl). GTAOPass builds both blur variants at construction and picks one per the AOBlurQuality flag: 'quality' runs the two-pass separable bilateral Gaussian, 'performance' runs the single-pass box blur (see §7.13's Bilateral Blur).

Per-frame state is uploaded through updateCamera() (view / projection / inverse-projection matrices) and updateParams():

updateParams(ctx: RenderContext, radius = 1.0, bias = 0.1, strength = 2.0): void {
  this._paramsScratch[0] = radius;
  this._paramsScratch[1] = bias;
  this._paramsScratch[2] = strength;
  ctx.device.queue.writeBuffer(this._uniformBuffer, 192, this._paramsScratch.buffer as ArrayBuffer);
}

The host selects the active algorithm when building the graph. Because every AO pass produces an identical handle, the choice collapses to a single ternary, and the AO is disabled (without removing the pass) by passing strength = 0:

const aoInputs = { normal: gbuf.normal, depth: gbuf.depth };
const ao = aoMethod === 'gtao'
  ? gtaoPass.addToGraph(graph, aoInputs).ao
  : aoMethod === 'hbao+'
    ? hbaoPass.addToGraph(graph, aoInputs).ao
    : ssaoPass.addToGraph(graph, aoInputs).ao;

Downstream the resulting AO factor is consumed exactly as the SSAO section described — the DeferredLightingPass multiplies the ambient term by it, and the CompositePass darkens fogged crevices with it.

GTAO Parameters#

NameValueRole
NUM_SLICES2Slices traced per pixel (shader constant)
STEPS_PER_DIR4March steps per direction, per slice (shader constant)
radius1.0World-space sampling radius
bias0.1Thickness bias — max_thick = radius·(1 + bias·10)
strength2.0Exponent applied to visibility (higher → more contrast)

NUM_SLICES and STEPS_PER_DIR are compile-time constants in src/shaders/gtao.wgsl; radius, bias, and strength are runtime uniforms set via GTAOPass.updateParams().

§ 7.16

Screen-Space Global Illumination (SSGI)#

Screen-space global illumination approximates one-bounce indirect light from the scene itself rather than from a pre-computed environment map. The SSGIPass (src/renderer/render_graph/passes/ssgi_pass.ts) casts stochastic rays in screen space against the previous frame's lit radiance, then accumulates the result temporally using a reprojected history:

SSGI pipeline: ray march → temporal accumulation → copy to history
SSGI pipeline: ray march → temporal accumulation → copy to history

Ray March Pass#

For each visible pixel, the shader reconstructs the world-space normal and view-space position from the G-Buffer, then casts several rays in a cosine-weighted hemisphere oriented around the surface normal. Each ray steps through view space; at each step the current position is projected back to screen space and compared against the stored depth buffer:

SSGI ray sampling in screen space
SSGI ray sampling in screen space
let vp = view_pos_at(in.uv, depth);
let N_vs = normalize((u.view * vec4<f32>(N_world, 0.0)).xyz);

for (var i = 0u; i < u.numRays; i++) {
  let phi = 6.28318530 * fract(f32(i) / f32(u.numRays) + f32(u.frameIndex) * 0.618033988);
  let ur = fract(f32(u.frameIndex * u.numRays + i) * 0.381966011);
  let cos_theta = sqrt(ur);
  let sin_theta = sqrt(max(0.0, 1.0 - cos_theta * cos_theta));
  let ray_local = vec3<f32>(sin_theta * cos(phi), sin_theta * sin(phi), cos_theta);

  for (var s = 0u; s < u.numSteps; s++) {
    let t = (f32(s) + 1.0) / f32(u.numSteps);
    let p = vp + ray_vs * (u.radius * t);
    // Project to screen UV, compare with stored depth
    let clip = u.proj * vec4<f32>(p, 1.0);
    let inv_w = 1.0 / clip.w;
    let ray_uv = vec2<f32>(clip.x * inv_w * 0.5 + 0.5, -clip.y * inv_w * 0.5 + 0.5);
    // ... read depth at ray_uv, test if ray passed behind surface ...
    if (p.z < stored_z && stored_z - p.z < u.thickness) {
      accum += textureSampleLevel(prev_radiance, lin_samp, ray_uv, 0.0).rgb;
      break;
    }
  }
}

Key details of the ray march:

Cosine-weighted distribution. Rays are distributed according to a cosine-weighted hemisphere, which matches the Lambertian diffuse BRDF — directions near the surface normal contribute more energy, while grazing-angle directions contribute less. The weighting is embedded in the ray distribution itself so every hit contributes equally and no explicit weight factor is needed.

Golden-ratio temporal jitter. The azimuth angle phi is offset each frame by the golden ratio φ ≈ 0.618 of a full turn. Over successive frames the ray pattern fills the hemisphere without ever repeating the same direction, so that temporal accumulation converges to a dense sampling pattern:

Frame 0: phi = 2π × fract(0 + 0 × 0.618)
Frame 1: phi = 2π × fract(0 + 1 × 0.618)
Frame 2: phi = 2π × fract(0 + 2 × 0.618)  →  ~124° rotation each frame

Tangent frame rotation. A 4×4 random noise texture provides a per-pixel rotation angle for the tangent frame, decorrelating rays between adjacent pixels. Without this, nearby pixels would cast rays in nearly identical directions, producing visible banding in the raw output:

let noise_val = textureLoad(noise_tex, coord % 4, 0).rg;
let cos_a = noise_val.x * 2.0 - 1.0;
let sin_a = noise_val.y * 2.0 - 1.0;
let T = cos_a * T_raw - sin_a * B_raw;
let B = sin_a * T_raw + cos_a * B_raw;

Hit test. An intersection is recorded when the ray's view-space Z has stepped past the stored depth at the projected screen position and the distance behind the surface is within the thickness threshold (default 0.5 view-space units). The thickness parameter prevents rays from self-intersecting on thin geometry such as leaves or wires.

Temporal Accumulation Pass#

The raw SSGI output from a single frame is extremely noisy — only 4 rays per pixel. The temporal pass accumulates samples over many frames by reprojecting each pixel's SSGI into the previous frame and blending:

// Reproject to previous frame
let world_pos = reconstructWorld(in.uv, depth);
let prev_clip = u.prevViewProj * vec4<f32>(world_pos, 1.0);
let prev_uv = vec2<f32>(
  prev_clip.x / prev_clip.w * 0.5 + 0.5,
  -prev_clip.y / prev_clip.w * 0.5 + 0.5,
);

// AABB clamp: 3×3 neighborhood of raw SSGI
var nb_min = vec3<f32>(1e9);
var nb_max = vec3<f32>(-1e9);
for (var dy = -1; dy <= 1; dy++) {
  for (var dx = -1; dx <= 1; dx++) {
    let s = textureLoad(raw_ssgi, clamp(coord + vec2<i32>(dx, dy), ...), 0).rgb;
    nb_min = min(nb_min, s);
    nb_max = max(nb_max, s);
  }
}
let history_clamped = clamp(history, nb_min, nb_max);
let result = mix(history_clamped, current, 0.1);

Reprojection. Each pixel is converted from screen-space UV and depth back to world position using the inverse view-projection matrix, then transformed into the previous frame's clip space using the stored prevViewProj matrix. If the previous UV falls outside the screen boundaries (disocclusion), the pixel trusts only the current frame.

Neighborhood clamping. The raw SSGI has high variance — a few bright rays can cause temporal ghosting when the camera moves. A 3×3 neighborhood AABB around the current pixel's raw SSGI clamps the history sample to prevent stale bright values from persisting after a geometry change.

Blend factor. The 10% blend (mix(history_clamped, current, 0.1)) means each frame contributes one tenth of the new raw estimate. With 4 rays per frame, the effective sample count after N frames is 4 × (1 − 0.9^N) / 0.1, reaching ~36 effective rays after 20 frames and ~40 rays at convergence.

Pass Wiring in the Render Graph#

SSGIPass.addToGraph() declares three sub-passes in the graph for one logical SSGI step. The first two are render passes; the third is a 'transfer' pass that copies the resolved frame into a persistent history texture for the next frame to read:

addToGraph(graph: RenderGraph, deps: SSGIDeps): SSGIOutputs {
  const { ctx } = graph;
  const fullDesc: TextureDesc = { format: HDR_FORMAT, width: ctx.width, height: ctx.height };

  const history = graph.importPersistentTexture(SSGI_HISTORY_KEY, {
    ...fullDesc, label: 'SSGIHistory',
  });

  let raw!: ResourceHandle;
  let result!: ResourceHandle;

  // 1. Ray march → raw
  graph.addPass('SSGIPass.rayMarch', 'render', (b) => {
    raw = b.createTexture({ label: 'SSGIRaw', ...fullDesc });
    raw = b.write(raw, 'attachment', { loadOp: 'clear', /* ... */ });
    b.read(deps.depth,        'sampled');
    b.read(deps.normal,       'sampled');
    b.read(deps.prevRadiance, 'sampled');   // ← TAA history, see below
    b.setExecute(/* ... draws fullscreen triangle with ssgi.wgsl ... */);
  });

  // 2. Temporal accumulate (raw + history → result)
  graph.addPass('SSGIPass.temporal', 'render', (b) => {
    result = b.createTexture({
      label: 'SSGIResult', ...fullDesc,
      extraUsage: GPUTextureUsage.RENDER_ATTACHMENT
                | GPUTextureUsage.TEXTURE_BINDING
                | GPUTextureUsage.COPY_SRC,
    });
    result = b.write(result, 'attachment', { loadOp: 'clear', /* ... */ });
    b.read(raw,       'sampled');
    b.read(history,   'sampled');
    b.read(deps.depth, 'sampled');
    b.setExecute(/* ... draws fullscreen triangle with ssgi_temporal.wgsl ... */);
  });

  // 3. Copy result → history for next frame
  graph.addPass('SSGIPass.copyHistory', 'transfer', (b) => {
    b.read(result,   'copy-src');
    b.write(history, 'copy-dst');
    b.setExecute((pctx, res) => {
      pctx.commandEncoder.copyTextureToTexture(
        { texture: res.getTexture(result) },
        { texture: res.getTexture(history) },
        { width: ctx.width, height: ctx.height },
      );
    });
  });

  return { result };
}

A handful of details from this layout are worth pulling out:

Pipeline Position#

updateCamera() uploads the per-frame uniforms — current matrices, plus the previous frame's view-projection for reprojection and the rolling frame counter that drives the golden-ratio jitter:

updateCamera(ctx: RenderContext): void {
  const camera = ctx.activeCamera;
  // ...
  data.set(camera.viewMatrix().data, 0);
  data.set(camera.projectionMatrix().data, 16);
  data.set(camera.inverseProjectionMatrix().data, 32);
  data.set(camera.inverseViewProjectionMatrix().data, 48);
  data.set(camera.previousViewProjectionMatrix().data, 64);  // ← from Camera
  // ... settings, frame counter ...
  u32[88] = this._frameIndex++;
  ctx.queue.writeBuffer(this._uniformBuffer, 0, data.buffer as ArrayBuffer);
}

camera.previousViewProjectionMatrix() is snapshotted at the top of Camera.updateRender() each frame and falls back to the current VP on the first frame (so the very first reproject sees zero motion rather than a singularity). See §11.3 Camera.jitteredViewProj and Camera.prevViewProj for the full story.

The host wires updateCamera() conditionally on the ssgi setting, then adds the pass to the graph. The interesting line is the prevRadiance argument:

// 6. SSGI uses last frame's TAA history as previous-radiance source.
// The TAA pass owns the persistent key; we import it here so SSGI reads
// the v=0 (previous frame's) contents before TAA bumps it later this frame.
let ssgi: ResourceHandle | undefined;
if (effects.ssgi) {
  const taaHistory = graph.importPersistentTexture('taa:history', {
    label: 'TAAHistory', format: 'rgba16float',
    width: ctxArg.width, height: ctxArg.height,
  });
  ssgi = ssgiPass.addToGraph(graph, {
    prevRadiance: taaHistory,
    normal: gbuf.normal,
    depth: gbuf.depth,
  }).result;
}

This is the trick that makes SSGI work cheaply in Taos Engine:

The host frame loop also threads the updateCamera calls in the order their consumers need them:

crafty/main.tsTypeScript
passes.ssaoPass!.updateCamera(ctx);
passes.ssaoPass!.updateParams(ctx, 1.0, 0.005, effects.ssao ? 2.0 : 0.0);
passes.ssgiPass!.updateSettings({ strength: effects.ssgi ? 1.0 : 0.0 });
if (effects.ssgi) {
  passes.ssgiPass!.updateCamera(ctx);
}

Disabling SSGI at runtime is two things together: setting strength = 0 (so the shader output is multiplied to zero even if it runs) and not adding the pass to the graph. Skipping addToGraph is what actually keeps the ray-march cost off the GPU; setting strength to zero is the belt-and-braces path used by samples that don't conditionally rebuild the graph.

Downstream, the DeferredLightingPass consumes the resolved SSGI as the indirect-diffuse contribution to the ambient term:

const lit = lightingPass.addToGraph(graph, {
  gbuffer: gbuf,
  shadowMap,
  ao: ssao.ao,
  hdr: skyHdr,
  ssgi,                  // ← SSGI result
  iblTextures: frame.iblTextures,
});

When ssgi is undefined (the effect is disabled or the pass wasn't added), the lighting pass falls back to its IBL-only ambient term. When present, the SSGI radiance is added on top of the IBL diffuse, scaled by AO so contact crevices stay dark.

SSGI Settings#

ParameterDefaultDescription
numRays4Stochastic rays per pixel per frame
numSteps16March steps along each ray
radius3.0Maximum march distance (view-space units)
thickness0.5Hit acceptance depth tolerance
strength1.0Output intensity multiplier

These are exposed through the SSGISettings interface and can be adjusted at runtime via SSGIPass.updateSettings().

§ 7.17

Spherical-Harmonic Diffuse IBL#

The irradiance cube from §7.10 stores the diffuse half of the IBL split-sum as a 32×32×6 rgba16float cubemap — about 98 KB per environment. But look at what that texture actually holds: a cosine-convolved hemisphere integral. Convolving any environment against the broad cos θ Lambert lobe annihilates almost all of its angular detail; the result is one of the smoothest signals in the whole renderer. Storing something that low-frequency in thousands of texels is wasteful. Spherical harmonics are the standard compact alternative: project the environment onto the first three frequency bands and keep just nine RGB coefficients — 144 bytes — that reconstruct the same diffuse irradiance to within a rounding error.

Spherical-harmonic diffuse IBL: project the environment to 9 coefficients, evaluate per-normal; 144 bytes vs the 98 KB irradiance cube
Spherical-harmonic diffuse IBL: project the environment to 9 coefficients, evaluate per-normal; 144 bytes vs the 98 KB irradiance cube

This is the standard representation production real-time renderers use for diffuse IBL, and Taos Engine implements it as an opt-in alternative to the irradiance cube (src/math/spherical_harmonics.ts, src/shaders/modules/sh.wgsl, src/shaders/sh_project.wgsl).

The basis and the math#

Real spherical harmonics are an orthonormal basis for functions on the sphere, organized by frequency band l. Order-3 means bands l = 0, 1, 2 — one constant function, three linear "dipole" lobes, and five quadratic "quadrupole" lobes, nine in total. Any spherical function is approximated as a weighted sum of them; the more bands you keep, the higher the frequencies you can represent.

The nine order-3 SH basis functions arranged by band, with the Lambertian cosine-transfer weight for each band
The nine order-3 SH basis functions arranged by band, with the Lambertian cosine-transfer weight for each band

Two operations matter. Projection integrates the environment radiance L(ω) against each basis function to get that coefficient:

c_lm = ∫  L(ω) · Y_lm(ω) dω          (one RGB integral per basis function)

Reconstruction of the diffuse irradiance for a surface normal N̂ is the weighted sum of the basis evaluated at N̂. The trick that makes SH the right tool for diffuse lighting is the per-band weight Â_l: convolving with the cosine lobe is, in the SH domain, just a multiply per band (Ramamoorthi & Hanrahan 2001). The factors fall off fast — Â₀/π = 1, Â₁/π = 2⁄3, Â₂/π = 1⁄4, and band 3 onward contribute almost nothing — which is exactly why three bands suffice:

E(N̂)/π  =  Σ_lm  (Â_l/π) · c_lm · Y_lm(N̂)

Taos Engine stores the coefficients so that E(N̂)/π comes out of the evaluator directly — the same quantity the irradiance cube stores (mean radiance, not raw irradiance). That makes the two interchangeable: the shading code keeps doing irradiance · albedo with no extra 1/π, whichever source it reads.

Baking with compute#

Projection is a single integral over the sphere, so the bake is one compute dispatch rather than the irradiance cube's six face dispatches of 256 samples each. sh_project.wgsl runs one workgroup that walks a deterministic Fibonacci-sphere sample set, evaluates the nine basis functions per sample, and tree-reduces the partial sums into the output buffer:

for (var i = lid; i < N_SH_SAMPLES; i += WG_SIZE) {
  let dir = fibonacci_dir(i);                                   // uniform sphere sample
  let rad = clamp_firefly(sample_sky(dir), FIREFLY_LUM_CLAMP);  // FIS mip + luminance clamp (§7.10)
  let Y   = sh_basis(dir);                                      // 9 basis values Y_lm(dir)
  for (var c = 0u; c < 9u; c++) {
    acc[c] += rad * Y[c];                                       // accumulate c_lm = Σ L·Y·(4π/N)
  }
}
// ... workgroup tree-reduce each of the 9 coefficients, write 9 × vec4 (144 B) ...

It reuses the same firefly defenses as the global IBL bake (§7.10) — filtered importance sampling picks a source mip per sample, and a per-sample luminance clamp tames bright HDR texels. The clamp matters more for SH than for the cube: a single very bright sample injected into the low bands produces ringing — the classic dark "negative-lobe" halo opposite a bright sun. A second, optional defense (covered under the caveats below) windows the higher bands at store time. The convolution is driven from IblBaker, so a procedural sky that rebakes as the day advances (§7.10, §10.7) refreshes its SH coefficients in the same command buffer.

Evaluating in the shader#

At shading time the cube's texture fetch becomes a handful of fused multiply-adds — no sampler, no memory traffic. sh.wgsl folds the Â_l/π band weights and the basis normalization constants into the evaluation:

fn sh_irradiance(c: array<vec4<f32>, 9>, n: vec3<f32>) -> vec3<f32> {
  var r = c[0].rgb * (1.0       * 0.282095);            // band 0, Â₀/π = 1
  r += c[1].rgb * ((2.0/3.0) * 0.488603 * n.y);         // band 1, Â₁/π = 2/3
  r += c[2].rgb * ((2.0/3.0) * 0.488603 * n.z);
  r += c[3].rgb * ((2.0/3.0) * 0.488603 * n.x);
  r += c[4].rgb * ((1.0/4.0) * 1.092548 * (n.x * n.y)); // band 2, Â₂/π = 1/4
  r += c[5].rgb * ((1.0/4.0) * 1.092548 * (n.y * n.z));
  r += c[6].rgb * ((1.0/4.0) * 0.315392 * (3.0 * n.z * n.z - 1.0));
  r += c[7].rgb * ((1.0/4.0) * 1.092548 * (n.x * n.z));
  r += c[8].rgb * ((1.0/4.0) * 0.546274 * (n.x * n.x - n.y * n.y));
  return max(r, vec3<f32>(0.0));                         // clamp residual ringing
}

In the deferred path this is wired behind an IBL_DIFFUSE_SH shader variant. A single global_diffuse_irradiance(dir) helper replaces every diffuse-cube lookup — the front-facing N̂ sample and the back-facing −N̂ samples that feed the diffuse-transmission and subsurface terms — so flipping the variant swaps all of them at once:

fn global_diffuse_irradiance(dir: vec3<f32>) -> vec3<f32> {
#ifdef IBL_DIFFUSE_SH
  return sh_irradiance(shCoeffs.c, dir);                 // 9 FMAs, no fetch
#else
  return textureSampleLevel(irradiance_cube, ibl_samp, dir, 0.0).rgb;
#endif
}

DeferredLightingFeature exposes useSphericalHarmonics; because the choice is a pipeline-build-time #define rather than a uniform, the feature lazily builds and caches both pass variants and swaps the active one when the flag is toggled. The sh_ibl_test sample drives this from an on-screen toggle so the two can be compared side by side.

Why SH, and the caveats#

The wins line up with the storage difference. Memory drops ~700× per environment, which matters most for reflection probes (§7.11): a scene with dozens of probes trades megabytes of irradiance cube-array slices for kilobytes of coefficients. Blending two environments — probe crossfades, time-of-day transitions — becomes a nine-coefficient lerp instead of sampling and mixing two cubes, and the reconstruction is continuous, so there are no cube-face seams in the smooth gradient.

The cost is bounded representational power. Three bands cannot hold high-frequency angular detail — but diffuse irradiance has none to lose, which is the entire premise. The one real failure mode is ringing on extreme-contrast environments (a hard sun in an otherwise dark sky), where the truncated series overshoots into faint negative lobes. The firefly clamp suppresses the worst of it; the standard further mitigation, which Taos Engine also implements, is band windowing — scaling each coefficient down by a per-band factor at bake time so the higher, more oscillatory bands contribute less. Taos Engine uses the Hann (raised-cosine) window from Sloan's Stupid Spherical Harmonics Tricks, parameterized by a window width w (smaller w windows more aggressively; w ≤ 0 is the identity, leaving the coefficients un-windowed):

function shBandWindow(l: number, w: number): number {
  if (w <= 0) return 1;          // windowing disabled
  if (l >= w) return 0;
  return 0.5 * (1 + Math.cos((Math.PI * l) / w));   // band 0 → 1, higher bands ↓
}

The width is plumbed through computeIblGpu(…, shWindow) and the IblBaker constructor, applied to the coefficients as the bake writes them out, and exposed as a strength slider in the sh_ibl_test sample. It is off by default: it trades a touch of directional contrast for the removal of the dark halo, so it earns its keep only on the bright-sun skies that actually ring. How well order-3 SH reconstructs the true diffuse irradiance — and how much the window helps — is pinned down by spherical_harmonics_parity.test.ts, which compares the nine-coefficient evaluation against a brute-force cosine-convolved reference: the match is within an RMSE of 0.01 on a smooth sky, and windowing measurably shrinks the negative-lobe dip on a hard-sun sky while leaving the average (band-0) irradiance untouched.

In practice the visual payoff is subtle. On the sh_ibl_test spheres, pushing the slider to full strength gives the shadowed undersides slightly more contrast and reads a touch cleaner — but the change is only really perceptible across the very top of the range (≈0.9 → 1.0), and below that, moderate windowing is visually indistinguishable from none. That matches the math: diffuse irradiance is low-frequency, so the higher bands the window touches carry little energy except when a hard sun rings them.

Note also that SH only replaces the diffuse term — specular IBL still needs the prefiltered cube and the BRDF LUT (§7.10) regardless.

In Taos Engine, SH diffuse is wired through every path that reads diffuse irradiance, all behind the same IBL_DIFFUSE_SH define: the deferred lighting pass, all four forward shaders (forward_pbr, skinned_forward_pbr, forward_plus, skinned_forward_plus), and per-probe diffuse (each reflection probe projects to its own nine coefficients, replacing the per-probe irradiance cube-array slice). Crucially, when that variant is active the engine stops baking and allocating the irradiance cubes — both the global 32³ cube and the per-probe cube-array shrink to 1×1 placeholders and their convolution dispatches are skipped — so the memory and bake-time savings are real rather than a coefficient buffer sitting next to a cube it duplicates. The irradiance cube stays the default (SH is opt-in per renderer, since a renderer that live-toggles SH↔cube still needs the cube present), and the prefiltered specular cube + BRDF LUT are untouched either way.

§ 7.18

Voxel Cone-Traced Global Illumination (VXGI)#

Every technique up to here lights a surface from known sources — the sun, a light list, a baked environment map, or the screen's own depth buffer (SSGI, §7.16). None of them can answer the question that makes an interior read as lit rather than flat: when sunlight falls on a red wall, how much of that red bounces onto the white ceiling beside it? That is indirect, multi-bounce, world-space global illumination, and screen-space methods can only approximate it for surfaces currently on screen. VXGI — voxel cone-traced GI — computes it in world space, so off-screen geometry and emitters contribute correctly and the result is stable as the camera moves. It publishes its result into the same frame.ssgi buffer the deferred lighting pass already consumes (§7.8), so it is a drop-in upgrade to SSGI rather than a new shading path.

The idea, due to Crassin et al. (2011), is a two-step approximation. First voxelize the lit scene into a coarse 3D grid of radiance — each voxel records how much light is leaving the geometry inside it. Then, to gather indirect light at a surface point, march a handful of cones outward over the hemisphere: a cone is a ray that widens as it travels, and sampling a progressively coarser mip of the voxel grid as the cone widens is a cheap stand-in for integrating all the rays the cone covers. Five cones approximate the full hemisphere integral; one extra cone along the reflection direction gives glossy specular.

VXGI per-frame pipeline: voxelize → resolve → mip chain → cone trace → blur → frame.ssgi/ssgiSpecular
VXGI per-frame pipeline: voxelize → resolve → mip chain → cone trace → blur → frame.ssgi/ssgiSpecular

The whole pipeline runs every frame (with an important caching exception, below), driven from one render-graph pass.

The voxel field and radiance grid#

Two small classes own the grid's geometry and its GPU resources, deliberately split so the math is unit-testable without a device. VoxelField is pure data: a cube of resolution³ voxels spanning a world-space AABB (center ± halfSize), plus the world↔voxel and world↔UVW transforms every pass needs. Voxelization rasterizes geometry through worldToVoxelMatrix(); cone tracing samples the radiance texture through worldToUvwMatrix() (voxel_field.ts:100-117). Keeping voxels cubic — fromBounds sizes the box from its largest axis — keeps the cone-trace LOD math isotropic.

VoxelGrid owns the GPU side: a 3D rgba16float texture (rgb = outgoing radiance, a = opacity) with a full mip chain. The format choice is the linchpin — rgba16float is both storage-writable (the inject/mip compute passes write per-mip storage views) and filterable (the cone trace samples across mips with trilinear + LOD). The class hands out three kinds of view, kept deliberately disjoint so a mip-gen pass's read and write subresources never alias (voxel_grid.ts:43-67):

storageView(mip)  // writable single-mip view  (inject / mip-gen target)
mipView(mip)      // sampled single-mip view    (mip-gen source)
fullView()        // sampled all-mips view      (cone-trace LOD sampling)

Geometry voxelization without a geometry shader#

Voxelizing arbitrary meshes is where WebGPU's constraints bite. Desktop VXGI implementations use a geometry shader to route each triangle to its dominant axis and conservative-rasterize it into a storage image with atomics — WebGPU has neither geometry shaders nor storage-texture atomics. Taos Engine works around both with three-axis orthographic rasterization into a plain u32 atomic buffer: each opaque mesh is drawn three times, once with an orthographic camera looking down each of X, Y, and Z, and the fragment shader maps its world position to a voxel index and atomicAdds the lit radiance there. Drawing down all three axes guarantees that a surface near-parallel to one projection (which would rasterize to almost no fragments) is covered densely by another. Because it rasterizes geometry rather than the screen, voxelization is camera-independent — every surface is injected regardless of what the camera sees, which is exactly what screen-space GI can't do.

The vertex shader projects along the host-selected axis, turning the two perpendicular axes into NDC and the axis itself into depth (with no depth test, so every fragment along the axis survives):

let rel = (world - u.gridMin.xyz) / u.gridSize.xyz;   // → [0,1] grid-relative
let axis = u32(u.gridSize.w);
var screen: vec2<f32>; var depth: f32;
if (axis == 0u)      { screen = rel.zy; depth = rel.x; }
else if (axis == 1u) { screen = rel.xz; depth = rel.y; }
else                 { screen = rel.xy; depth = rel.z; }
o.pos = vec4<f32>(screen * 2.0 - 1.0, clamp(depth, 0.0, 1.0), 1.0);

The fragment shader is where the lighting happens — the grid stores radiance, not albedo, so each fragment evaluates direct light (shadowed) plus emissive and accumulates the result. Direct lighting here is a slimmed copy of the deferred model: the sun (sampled against cascade-0 of the directional shadow map for a shadowed bounce), point lights (with cube-VSM Chebyshev shadows), and spot lights (unshadowed cone), all summed in directLight() (vxgi_voxelize.wgsl:160-200). Albedo and emissive are sampled from the material's actual textures — so a glTF emissive map (a reactor panel, a glowing strip) injects its real color and glows into the GI. The accumulate is fixed-point because the buffer is integer atomics:

WGSL
// ── from src/shaders/vxgi/vxgi_voxelize.wgsl ── fs_main
let surfAlbedo = draw.albedo.rgb * albedoTex_c;
let emissive   = draw.emissive.rgb * emissiveTex_c * max(u.spotMeta.z, 0.0);
let radiance   = (surfAlbedo * lit + emissive) * u.counts.z;       // counts.z = injectStrength
let q = vec3<u32>(clamp(radiance, vec3f(0.0), vec3f(CLAMP)) * FIXED); // CLAMP 64, FIXED 256
atomicAdd(&grid[idx + 0u], q.x);
atomicAdd(&grid[idx + 1u], q.y);
atomicAdd(&grid[idx + 2u], q.z);
atomicAdd(&grid[idx + 3u], 1u);   // .w = sample count, for averaging in the resolve

The per-fragment CLAMP ceiling tames a single ultra-bright texel before it dominates the average, and the .w counter lets the resolve pass divide back to a mean rather than a sum (a thicker surface deposits more fragments into a voxel, but its radiance shouldn't grow with fragment count).

Anisotropic voxels: the leak fix#

A naïve voxel grid stores one radiance + one opacity per voxel — isotropic. That produces two notorious artifacts: light leaks through thin walls (a one-voxel wall, averaged into its neighbors at coarse mips, becomes semi-transparent and cones punch through it) and directional radiance streaks (a voxel lit strongly from one side radiates equally in all directions). Taos Engine fixes both by making each voxel anisotropic — storing six directional radiance faces (±X, ±Y, ±Z) instead of one value.

Anisotropic voxels: isotropic injection, directional front-to-back mip compositing, cone sampling three opposing faces
Anisotropic voxels: isotropic injection, directional front-to-back mip compositing, cone sampling three opposing faces

The textbook energy-preserving scheme — and the subtle part — is that injection stays isotropic. Mip 0 is identical across all six faces, so total energy is preserved (a cone reading Σ dir²·face recovers exactly the injected value at the finest level). Directionality emerges only in the mip chain: vxgi_mip_aniso.wgsl downsamples each face by compositing its 8 children front-to-back along that face's own axis with premultiplied alpha, rather than box-averaging them:

fn composite(near: vec4f, far: vec4f) -> vec4f {
  return near + (1.0 - near.a) * far;   // premultiplied: near over far
}
// one dispatch per (face, level); the `FACE` override picks axis = FACE/2 and near→far order

Because opacity accumulates through the volume instead of averaging toward transparent, a solid wall stays opaque at every coarse mip — so a wide cone can no longer see through it. The earlier, naïve approach was found 25× too dim when it instead injected into a single dominant face; isotropic injection plus directional occlusion is what gets both the energy and the leak-resistance right.

Resolve and world-space temporal accumulation#

Voxelization left a fixed-point sum + count in the atomic buffer. vxgi_resolve_aniso.wgsl divides each voxel back to a mean radiance, writes it (premultiplied) into each face's mip 0, and blends it against the previous frame for temporal stability:

WGSL
// ── from src/shaders/vxgi/vxgi_resolve_aniso.wgsl ── cs_resolve
let rgb = vec3f(f32(atomicLoad(&grid[idx+0u])),
                f32(atomicLoad(&grid[idx+1u])),
                f32(atomicLoad(&grid[idx+2u]))) / (FIXED * f32(count));
let blended = mix(prev, cur, alpha);          // alpha = temporalAlpha (1 = no history)
textureStore(radOut, vec3i(gid), blended);

The temporal blend is done in world space, which is what makes it reprojection-cheap. Because the grid is world-anchored, the same voxel index is the same world point frame-to-frame (until the grid re-centers, in which case the history is shifted by the integer voxel offset of the grid origin, u.params.yzw). That denoises the result, smooths the blocky undersampling, and gives brief off-screen persistence — all without the screen-space reprojection SSGI needs. Only the last of the six face passes (the one compiled with override CLEAR = true) persists the new history texture and zeroes the atomic accumulator for next frame.

Cone tracing the hemisphere#

With the radiance grid + mip chain built, the per-pixel cone trace in vxgi_cone_aniso.wgsl gathers the indirect light. It reconstructs each pixel's world position and normal from the G-buffer, offsets the origin ≈1.5 voxels off the surface (to avoid self-intersection), and traces five diffuse cones over the hemisphere — one along the normal, four lateral — each with a 60° aperture (0.577):

VXGI cone tracing: a five-cone diffuse hemisphere plus a single roughness-driven specular reflection cone
VXGI cone tracing: a five-cone diffuse hemisphere plus a single roughness-driven specular reflection cone

A single cone march steps outward, and at each step picks a mip LOD from the cone's current radius — a wide cone reads a coarse, pre-integrated mip, which is the whole trick that makes one march stand in for many rays:

WGSL
// ── from src/shaders/vxgi/vxgi_cone_aniso.wgsl ── traceCone
for (var i = 0; i < 24; i = i + 1) {
  if (acc.a >= 0.95) { break; }                          // saturated — stop
  let coneR = dist * aperture;
  let lod   = max(0.0, log2(max(coneR / vox, 1.0)));     // wider cone → coarser mip
  let s     = sampleAniso(uvw, lod, dir);                // 3 opposing faces, dir²-weighted
  acc = acc + (1.0 - acc.a) * s;                         // premultiplied front-to-back
  dist = dist + max(coneR, vox) * 0.6;
}

sampleAniso is the anisotropic read: for a cone traveling in dir, it takes the face on each axis that emits back toward the cone (moving −X reads the +X face), weighted by dir·dir, so directional opacity is respected (vxgi_cone_aniso.wgsl:67-79). The five cones are combined in coneDiffuse, which also returns the mean cone opacity as a hemisphere occlusion term — enclosed corners accumulate more opacity, so an aoStrength knob can darken them for contrast (vxgi_cone_aniso.wgsl:103-125). The cone start distance is dithered by a world-space hash so the LOD-step banding becomes a stable dither (it doesn't crawl as the camera moves) that the blur and TAA then resolve.

A specular reflection cone runs alongside the diffuse ones: reflect the view vector about the normal and trace one cone whose aperture is driven by roughness — narrow for a mirror, wide for a rough surface. This is essential because the deferred pass applies diffuse GI as irradiance · albedo · (1 − metallic), so a metal (with (1 − metallic) ≈ 0) would catch no indirect light at all without it. The two results are written to separate render targets:

WGSL
// ── from src/shaders/vxgi/vxgi_cone_aniso.wgsl ── fs_main
let rough = textureLoad(roughTex, px, 0).a;
let R     = reflect(-V, n);
let specAp = clamp(rough, 0.04, 0.75);
let spec  = traceCone(surfP, R, specAp, ign);
o.diffuse  = vec4f(gi.rgb * u.gridSize.w * ao, 1.0);   // → frame.ssgi
o.specular = vec4f(spec.rgb * u.gridSize.w, clamp(spec.a, 0.0, 1.0)); // → frame.ssgiSpecular

A final edge-aware bilateral blur (normal- and depth-weighted) smooths the cone-traced diffuse buffer before it is published; the sharp specular buffer is left unblurred so reflections stay crisp.

Multi-bounce feedback#

A single voxelization captures only the first bounce (direct light deposited on surfaces). To get the warm, multiply-bounced fill that sells an interior, the voxelizer re-gathers the previous frame's radiance grid and re-injects it, modulated by the surface albedo — so each frame adds one more bounce and the grid converges to full multi-bounce GI over a few frames. The previous grid is bound read-only during voxelization (the resolve/mip passes overwrite it only later in the same command encoder), and a short interior cone gather feeds the feedback (vxgi_voxelize.wgsl:245-279):

WGSL
// ── from src/shaders/vxgi/vxgi_voxelize.wgsl ── fs_main
var lit = directLight(in.world, n);
if (bounceStrength > 0.0) {
  let indirect = gatherBounce(in.world + n * voxelSizeV() * 1.5, n);  // last frame's grid
  lit = lit + indirect * bounceStrength;                             // 2nd+ bounce
}

This is multi-bounce propagation; bounceStrength ≈ 1.5 carries emitter color to surfaces with no direct line of sight, which is what floods the whole volume.

How the result reaches the screen#

VXGI never touches the deferred shader's BRDF — it only fills two textures the lighting pass already reads. The diffuse irradiance becomes a Lambert term scaled by receiver albedo, and the specular radiance goes through the specular BRDF so metals reflect local emitters (deferred_lighting.wgsl:1148-1155):

let ssgi_contrib    = ssgi_irr * albedo * (1.0 - metallic);
let ssgi_spec_rad   = textureSampleLevel(ssgi_spec_tex, ssgi_samp, in.uv, 0.0).rgb;
let spec_gi_contrib = ssgi_spec_rad * (kS_ibl * env_brdf.x + env_brdf.y) * Ecomp * spec_horizon;
// ...
let color = lit_direct + lit_ambient + ssgi_contrib + spec_gi_contrib + emissive_in;

The specular term is independent of iblIntensity on purpose — a reactor should still reflect in a metal at night, when the sky IBL has gone dark.

The voxelization cache#

Re-running voxelization, resolve, and the mip chain every frame is expensive — at 128³ it is dozens of compute passes and was enough to halve the frame rate. But most of a level is static: only the cone trace genuinely needs to re-run each frame (so a moving camera updates the GI), while the grid itself is unchanged until geometry, materials, or lights move. The default revoxelizeMode: 'auto' exploits this. A cheap FNV-style hash over the packed per-draw data (transforms + materials) and the scene parameters (bounds, strengths, lights) detects any change; on a change it re-voxelizes for a few frames so the temporal + multi-bounce feedback can settle, then freezes the grid and runs only the cone trace + blur (vxgi_pass.ts:203-221):

// VOXELIZE_CONVERGE_FRAMES = 8 — re-voxelize this many frames after a change,
// long enough for the temporal blend + multi-bounce feedback to converge,
// then reuse the cached grid until the next change.

The crucial observation that makes this safe: VXGI only voxelizes frame.opaque (static meshes) and identity-joint instanced level geometry — animated skinned characters are never voxelized, so they don't dirty the cache. On a static Cornell scene the engine drops from 43 compute passes per frame to 1, with the cone trace still re-running so camera motion stays correct. Crafty's chunk path and 'always' mode skip the cache (block edits don't change the draw signature, so they always re-voxelize).

Wiring and authoring#

The whole inject → resolve → mip → cone → blur sequence runs inside a single render-graph transfer pass (VXGIPass), which drives its own compute and render sub-passes on the shared command encoder so WebGPU's automatic barriers order them correctly. Only its inputs (G-buffer normal/depth/albedo-roughness + shadow maps) and its two outputs are graph-tracked; the voxel field, grids, and atomic buffer are pass-owned persistent resources, to dodge the complexity of 3D-mip-subresource tracking in the graph. VxgiFeature gathers the scene's opaque draws and lights each frame and is enabled via deferredPreset({ vxgi: true }) (mutually exclusive with ssgi — both write frame.ssgi, and VXGI wins).

For authored scenes there is an editor component, VoxelGiVolume. Drop it on a GameObject; the transform's world position becomes the grid center, and the box extents plus quality knobs (resolution, bounceStrength, emissiveStrength, aoStrength, temporalAlpha) drive the feature. With requireVolume: true the feature stays inert until a volume exists. There is no bake step — voxelization is per-frame (and cached). The box is a single fixed AABB, not a clipmap, so its coverage is the authoring: size it to enclose the area you want lit. Crafty integrates differently — its block chunks aren't PBR meshes, so a dedicated chunk-voxelization shader injects block palette albedo + emissive (lava, glowstone, torches become free colored GI emitters) with the grid re-centered on the player each frame.

The settings (vxgi_pass.ts:82-136):

SettingDefaultRole
resolution96Voxels per axis (cube). 96 keeps thin emitters from being averaged away; 128 is the practical ceiling (6 directional grids + mips grow fast).
halfSize16Half-extent of the grid box — its coverage.
strength1.0Overall multiplier on the cone-traced indirect diffuse.
bounceStrength1.5Multi-bounce feedback (propagation). 0 = single bounce.
emissiveStrength4.0Multiplier on injected emissive.
temporalAlpha0.2World-space temporal blend weight (lower = smoother, laggier).
aoStrength0.5Cone-occlusion contrast darkening in enclosed corners.
blurtrueEdge-aware bilateral blur of the GI buffer.
revoxelizeMode'auto'Skip voxelize/resolve/mip while inputs are unchanged.
Interactive · Voxel Cone-Traced GIOpen ↗
Interactive demo · loads when scrolled into view
Step through the GI modes. A Cornell box with no lights and no sky: the emissive ceiling panel is the only source, injected into a 96³ voxel grid. With GI off the room is black. One bounce lights what the panel sees through the cone trace; multi-bounce re-gathers the previous frame's grid, so over a few frames light reaches the shadowed block faces and the red and green walls bleed onto the floor. The demo runs with revoxelizeMode: 'always' so the sliders take effect live.
§ 7.19

Dynamic Diffuse GI (DDGI) and Irradiance Probes#

VXGI's per-pixel cone trace is high quality but expensive, and on undersampled grids its diffuse output can show blocky patches where wide cones read coarse mips. DDGI — dynamic diffuse global illumination (Majercik et al., 2019) — is a smoother, cheaper output stage for the same voxelized world. Instead of tracing cones per screen pixel, it maintains a regular grid of irradiance probes, updates each by tracing rays into the voxel radiance grid VXGI already built, and resolves the screen by trilinearly interpolating the nearby probes. The per-pixel cost collapses to a handful of texture fetches, the result is inherently smooth, and a depth-based visibility test makes it markedly less leak-prone than cones.

There is a deeper architectural reason Taos Engine builds DDGI on top of VXGI. An irradiance-probe volume is one data structure that can be filled two ways: per-frame by ray-tracing (DDGI), or offline by a path tracer (a baked probe field). Build the probe volume once and you get both. So DDGI is not a competing GI dimension; it is a smarter way to read out VXGI's voxel grid, and the same volume is the foundation of the engine's baked-GI path.

DDGI probe volume: a probe grid in an AABB, the octahedral irradiance/depth atlas, and 8-probe trilinear sampling
DDGI probe volume: a probe grid in an AABB, the octahedral irradiance/depth atlas, and 8-probe trilinear sampling

The probe volume#

ProbeVolume is a regular 3D grid of probes (default 16 × 8 × 16) spanning a world AABB. Each probe stores its incoming light as an octahedral map — a unit sphere of directions unwrapped into a small square tile — for both irradiance (rgba16float, the lit color) and depth (mean distance + mean distance², used for the visibility test below). Octahedral mapping (ddgi_oct.wgsl) is the standard equal-area unwrap; a direction encodes to [-1,1]² and back with a few absolute values and a sign flip, no trig.

Each probe's tile carries a one-texel border that octahedrally wraps the opposite edge, so a bilinear sampler reading near a tile edge blends across the octahedron seam correctly instead of bleeding in from the neighboring probe. The tiles are packed counts.x across and counts.y · counts.z down. Both atlases are ping-ponged (two copies each) so the per-frame update can read the previous state and write the temporally-blended new state without aliasing. A small per-probe stateBuffer holds a classification weight (1 = active, 0 = buried in geometry) that the resolve uses to drop probes stuck inside walls.

Updating the probes#

The update (ddgi_probe_update.wgsl) runs one workgroup per probe, 64 threads each. Phase 1: every thread traces one ray — a Fibonacci-sphere direction, rotated per-frame and per-probe — through the six directional radiance faces (a zero-aperture cone trace, trace_grid), recording the hit radiance and the distance at which accumulated opacity first crosses 0.5. The rays' results land in shared memory.

DDGI probe update: 64 rays per workgroup trace the voxel grid into octahedral irradiance and depth maps; Chebyshev visibility makes probes occlusion-aware

Phases 2 and 3 cooperatively integrate those 64 rays into the probe's two octahedral maps, with a different directional weight for each. Irradiance uses a cosine weight (it is a Lambertian gather); depth uses a sharp pow(dot, k) weight so each depth texel records the distance of the geometry roughly in its own direction:

WGSL
// ── from src/shaders/ddgi/ddgi_probe_update.wgsl ── (phase 2, irradiance)
for (var r = 0u; r < NUM_RAYS; r = r + 1u) {
  let wt = max(dot(sDir[r], texelDir), 0.0);   // cosine
  irr = irr + sRadiance[r] * wt;
  wsum = wsum + wt;
}
textureStore(irrOut, coord, vec4f(mix(irr / wsum, prev, histHyst), 1.0));  // temporal hysteresis

Three pieces of robustness are worth calling out, because each fixes a real artifact. Temporal hysteresis (mix(current, previous, ~0.97)) is what turns a noisy 64-ray-per-frame estimate into a stable result. History reprojection lets the volume follow the camera in large levels: a probe's previous data lived at index + reproj (the grid origin moved by a whole number of probes), and newly-exposed probes at the volume's leading edge are reseeded with hysteresis forced to zero. Probe classification detects a probe buried in geometry — most of its rays hit within ~1.5 voxels — and fades it out (softly, temporally smoothed, to avoid flickering) so the resolve skips it.

Resolving to the screen#

ddgi_resolve.wgsl reconstructs each pixel's world position and normal from the G-buffer, finds its 8 surrounding probes, and blends them. Each probe contributes its octahedral irradiance looked up in the surface normal direction, weighted by three factors: trilinear cell weight, a backface term (probes behind the surface are down-weighted), and Chebyshev visibility — the leak fix:

WGSL
// ── from src/shaders/ddgi/ddgi_resolve.wgsl ── sample_irradiance
let moments = textureSampleLevel(depthAtl, samp, probe_depth_uv(...), 0.0).rg;
let mean = moments.x;
if (dist > mean) {                                    // surface is beyond the probe's stored geometry
  let variance = max(moments.y - mean * mean, 0.06);
  let d = dist - mean;
  let cheb = variance / (variance + d * d);           // Chebyshev's inequality
  tri = tri * cheb * cheb;                            // squared → firm occlusion
}

If a pixel sits farther from a probe than the geometry that probe recorded in that direction, the probe is on the far side of an occluder and its bright irradiance is suppressed — so a lit probe on one side of a wall does not bleed onto the other. This depth-aware weighting is the single biggest quality difference between DDGI and a naïve irradiance-probe grid.

Three strobing bugs and their lessons. DDGI in motion is unusually prone to coherent flicker, and Taos Engine hit three distinct causes worth recording. (1) Coherent Monte-Carlo noise — sharing one per-frame random rotation across all probes made every probe's 64-ray estimate err the same way each frame, so a small bright light produced a synchronized whole-wall pulse; the fix is a fixed per-probe rotation composed with the global one, so the residual noise is incoherent grain the trilinear blend hides. (2) Sharp depth weights with sparse coverage — with 64 random rays and a sharp depth weight, many depth texels get no ray on a given frame and would write a ~0 distance, a false "occluder at zero" that strobes; the fix is to trust each frame's estimate only as far as its ray coverage and otherwise keep the accumulated value. (3) The real culprit behind the worst strobe was none of the probe math — it was the resolve reconstructing world position with the un-jittered inverse view-projection while the G-buffer was rendered with TAA's jittered projection, so the reconstructed point slid sub-pixel along the view ray each frame. The general lesson: any screen-space pass that reconstructs world position from a jittered G-buffer must use the jittered inverse-VP — point-sampling passes like probe GI flicker under TAA where area-averaging passes (the VXGI cone) hide it.

Wiring, following, and the baked path#

DDGI is a decoupled render-graph pass (DDGIPass) that reads VXGI's grid through a small read-only VxgiRadianceField view and orders itself after VXGI by depending on its result handle. It is enabled through the VXGI feature — deferredPreset({ vxgi: { ddgi: true } }) — and replaces the cone trace's frame.ssgi with its probe resolve. For bounded scenes the probe box is fixed; for large levels, follow: true re-centers the volume on the camera each frame with the history reprojection described above.

The same probe volume is what the engine's baked GI fills offline. Running the DDGI update to convergence and then freezing it (ceasing to dispatch updates) yields static probes at zero per-frame cost. For crisp static detail beyond the sparse probe grid (sharp contact shadows on a floor, fine AO) the engine also supports per-texel baked lightmaps: a path-traced atlas (level.exr) is applied as an additive overlay over the lit HDR by LightmapOverlayPass, matched to instances by node name. DDGI (dynamic) and baked probes/lightmaps (static) are the two halves of a complete GI story; VXGI is the realtime engine underneath both.

The DDGI settings (ddgi_pass.ts:12-41):

SettingDefaultRole
counts(16, 8, 16)Probes per axis.
halfSize16Half-extent of the probe volume.
strength1.0Multiplier on resolved irradiance (frame.ssgi).
hysteresis0.98Temporal blend (high = stable, slow to react).
followfalseRe-center on the camera each frame (large levels).
visibilitytrueApply Chebyshev + classification weighting in the resolve.

VXGI or DDGI?#

They share the voxelization and differ only in the read-out. Cone tracing (vxgi: true) is sharper, supports a glossy specular reflection cone, and resolves fine indirect detail — at a higher per-pixel cost and with a tendency toward blocky patches on coarse grids. DDGI (vxgi: { ddgi: true }) is smoother, cheaper per pixel, and leak-resistant via Chebyshev visibility, but its diffuse-only output is lower-frequency (no specular) and its quality is bounded by probe density. A practical split: DDGI for broad, soft interior fill and large levels; cone tracing when the scene needs crisp indirect detail or metals must reflect local emitters. Both publish into frame.ssgi, so switching between them changes no other pass.

§ 7.20

Summary#

Lighting is a composition of several systems:

SystemPassPurpose
Directional (sun)DeferredLightingPassMain light, CSM shadows
Point lightsPointSpotLightPassAdditive deferred, VSM cube shadows (≤4 casters)
Spot lightsPointSpotLightPassAdditive deferred, VSM 2D-array shadows + RGB cookies (≤8 casters)
Area lightsPointSpotLightPass (deferred) / ForwardPass / ForwardPlusPassRepresentative-point sphere / tube / rect; opt-in LTC rect + disk with textured emitters + PCSS cube shadows (AreaLightShadowPass)
IBLDeferredLightingPassEnvironment-based ambient + specular, anisotropic + horizon-occluded
Reflection probesReflectionProbeCapturePass + ReflectionProbeIblPassLocalized box-projected IBL, blended over global
SSAOSSAOPassNormal-oriented hemisphere ambient occlusion from depth buffer
HBAO+HBAOPlusPassHorizon-based AO — steepest horizon per screen direction
GTAOGTAOPassDefault AO — slice-based horizon integration
SSGISSGIPassOne-bounce indirect light via screen-space ray marching
VXGIVXGIPassWorld-space voxel cone-traced GI (multi-bounce diffuse + specular) → frame.ssgi
DDGIDDGIPassIrradiance-probe read-out of the VXGI grid; smoother, leak-resistant → frame.ssgi
Forward transparencyForwardPassPBR for transparent surfaces
Forward+ (tiled)ForwardPlusPassPer-tile light culling — hundreds of point lights

Ambient occlusion is interchangeable: SSAOPass, GTAOPass, and HBAOPlusPass all consume the same G-Buffer and emit the same half-res r8unorm AO target, selected at runtime by aoMethod.

All paths share the same PBR BRDF functions, ensuring consistent appearance regardless of rendering path.

Further reading: