Real-Time Global Illumination: From Theory to Shader — Cornell Box with Color Bleeding

346 views 0 replies
Live Shader
Loading versions...

Global Illumination (GI) is the holy grail of real-time rendering. Unlike direct lighting — where we only consider light traveling straight from a source to a surface — GI simulates the full transport of light as it bounces around a scene. When a photon hits a red wall, it picks up red color and bounces onto nearby white surfaces, tinting them with a warm red hue. This phenomenon, known as color bleeding, is one of the most visually important cues that makes a scene feel grounded and physically plausible.

In this post, we'll explore a complete real-time GI shader that renders a classic Cornell Box with raymarching and simulated indirect illumination, then dive deep into the major GI techniques used in modern game engines — from screen-space methods to voxel cone tracing to Epic's Lumen and the emerging Radiance Cascades approach.

Complete Cornell Box Shader with Color Bleeding

The following shader is a fully self-contained WebGL1 fragment shader. It raymarches a Cornell Box scene with a light source on the ceiling, colored walls (red on the left, green on the right), and two boxes inside. The key feature is the indirect lighting pass: after computing direct illumination, we sample bounced light from the walls to simulate one-bounce GI, producing visible color bleeding onto the white floor, ceiling, and boxes.

precision mediump float;

uniform vec2 iResolution;
uniform float iTime;

// ============================================================
// REAL-TIME GLOBAL ILLUMINATION — CORNELL BOX WITH COLOR BLEED
// Demonstrates 1-bounce indirect lighting via wall sampling
// ============================================================

#define MAX_STEPS 128
#define MAX_DIST 20.0
#define SURF_DIST 0.002
#define PI 3.14159265

// --- Material IDs ---
// 0 = none, 1 = white walls/floor/ceiling, 2 = red wall (left)
// 3 = green wall (right), 4 = light source, 5 = tall box, 6 = short box

// Signed distance function for an axis-aligned box
float sdBox(vec3 p, vec3 b) {
    vec3 q = abs(p) - b;
    return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}

// Rotate a 2D vector by angle a
vec2 rot2D(vec2 v, float a) {
    float c = cos(a);
    float s = sin(a);
    return vec2(v.x * c - v.y * s, v.x * s + v.y * c);
}

// Scene SDF — returns vec2(distance, materialID)
vec2 mapScene(vec3 p) {
    // Cornell box: room centered at origin, size 2x2x2
    // The room is an inverted box (we are inside it)
    float room = -sdBox(p, vec3(1.0, 1.0, 1.0));

    // Start with the room shell — default white (material 1)
    vec2 res = vec2(room, 1.0);

    // Left wall (red): detect proximity to left wall
    // We carve out the left wall plane as a separate material
    float leftWall = abs(p.x + 1.0) - 0.01;
    if (leftWall < res.x) {
        // Only assign red if we are actually on the left wall region
        if (p.x < -0.95 && abs(p.y) < 1.0 && abs(p.z) < 1.0) {
            res = vec2(leftWall, 2.0);
        }
    }

    // Right wall (green)
    float rightWall = abs(p.x - 1.0) - 0.01;
    if (rightWall < res.x) {
        if (p.x > 0.95 && abs(p.y) < 1.0 && abs(p.z) < 1.0) {
            res = vec2(rightWall, 3.0);
        }
    }

    // Ceiling light panel (emissive)
    float lightPanel = sdBox(p - vec3(0.0, 0.98, 0.0), vec3(0.3, 0.02, 0.3));
    if (lightPanel < res.x) {
        res = vec2(lightPanel, 4.0);
    }

    // Tall box (rotated) — white material 5
    vec3 bp1 = p - vec3(-0.33, -0.4, -0.2);
    bp1.xz = rot2D(bp1.xz, 0.3);
    float box1 = sdBox(bp1, vec3(0.28, 0.6, 0.28));
    if (box1 < res.x) {
        res = vec2(box1, 5.0);
    }

    // Short box (rotated) — white material 6
    vec3 bp2 = p - vec3(0.33, -0.7, 0.2);
    bp2.xz = rot2D(bp2.xz, -0.28);
    float box2 = sdBox(bp2, vec3(0.28, 0.3, 0.28));
    if (box2 < res.x) {
        res = vec2(box2, 6.0);
    }

    return res;
}

// Raymarching — returns vec2(distance, materialID)
vec2 raymarch(vec3 ro, vec3 rd) {
    float t = 0.0;
    float matID = 0.0;
    for (int i = 0; i < MAX_STEPS; i++) {
        vec3 p = ro + rd * t;
        vec2 d = mapScene(p);
        if (d.x < SURF_DIST) {
            matID = d.y;
            break;
        }
        t += d.x;
        if (t > MAX_DIST) break;
    }
    return vec2(t, matID);
}

// Compute surface normal via central differences
vec3 getNormal(vec3 p) {
    vec2 e = vec2(0.001, 0.0);
    float d = mapScene(p).x;
    return normalize(vec3(
        mapScene(p + e.xyy).x - mapScene(p - e.xyy).x,
        mapScene(p + e.yxy).x - mapScene(p - e.yxy).x,
        mapScene(p + e.yyx).x - mapScene(p - e.yyx).x
    ));
}

// Get material albedo from material ID
vec3 getAlbedo(float matID) {
    if (matID < 1.5) return vec3(0.85, 0.85, 0.85); // white
    if (matID < 2.5) return vec3(0.85, 0.08, 0.05);  // red
    if (matID < 3.5) return vec3(0.08, 0.85, 0.10);  // green
    if (matID < 4.5) return vec3(0.0);                // light (handled separately)
    if (matID < 5.5) return vec3(0.85, 0.85, 0.85);  // tall box white
    return vec3(0.85, 0.85, 0.85);                    // short box white
}

// Soft shadow — march toward light, accumulate occlusion
float softShadow(vec3 ro, vec3 rd, float tmax) {
    float res = 1.0;
    float t = 0.05;
    for (int i = 0; i < 48; i++) {
        vec3 p = ro + rd * t;
        float d = mapScene(p).x;
        if (d < 0.001) return 0.0;
        res = min(res, 12.0 * d / t);
        t += clamp(d, 0.02, 0.2);
        if (t > tmax) break;
    }
    return clamp(res, 0.0, 1.0);
}

// Ambient occlusion — short-range occlusion sampling
float calcAO(vec3 p, vec3 n) {
    float occ = 0.0;
    float scale = 1.0;
    for (int i = 1; i < 6; i++) {
        float h = 0.02 + 0.06 * float(i);
        float d = mapScene(p + n * h).x;
        occ += (h - d) * scale;
        scale *= 0.75;
    }
    return clamp(1.0 - 1.5 * occ, 0.0, 1.0);
}

// ============================================================
// INDIRECT ILLUMINATION (1-bounce GI approximation)
// We sample several points on the walls/surfaces and compute
// how much colored light bounces back to our shading point.
// This creates the characteristic color bleeding effect.
// ============================================================
vec3 indirectLight(vec3 p, vec3 n) {
    vec3 indirect = vec3(0.0);
    float totalWeight = 0.0;

    // Light position (ceiling light center)
    vec3 lightPos = vec3(0.0, 0.95, 0.0);
    vec3 lightColor = vec3(1.0, 0.95, 0.85) * 2.5;

    // Sample points on the colored walls and major surfaces
    // to gather bounced radiance. This is a simplified form
    // of hemispherical gathering / final gathering.

    // We define sample points on walls and compute:
    // 1. Direct light arriving at that wall point
    // 2. The reflected color (wall albedo * light)
    // 3. The contribution to our shading point (form factor)

    // Left wall samples (RED) — several points along the left wall
    for (int i = 0; i < 4; i++) {
        for (int j = 0; j < 3; j++) {
            vec3 wallPt = vec3(-0.98,
                              -0.6 + float(i) * 0.4,
                              -0.6 + float(j) * 0.6);
            vec3 wallNormal = vec3(1.0, 0.0, 0.0);
            vec3 wallAlbedo = vec3(0.85, 0.08, 0.05); // red

            // Vector from wall point to light
            vec3 toLight = lightPos - wallPt;
            float distL = length(toLight);
            vec3 dirL = toLight / distL;

            // Direct illumination at wall point
            float nDotL_wall = max(dot(wallNormal, dirL), 0.0);
            vec3 wallIrradiance = lightColor * nDotL_wall / (distL * distL + 0.5);

            // Reflected radiance from wall (Lambertian: albedo / PI)
            vec3 wallReflected = wallAlbedo * wallIrradiance / PI;

            // Contribution to our point: form factor approximation
            vec3 toP = p - wallPt;
            float dist = length(toP);
            vec3 dir = toP / dist;

            // Cosine at wall and cosine at receiving point
            float cosWall = max(dot(wallNormal, dir), 0.0);
            float cosReceiver = max(dot(n, -dir), 0.0);

            // Approximate form factor
            float ff = cosWall * cosReceiver / (dist * dist + 0.3);

            indirect += wallReflected * ff;
            totalWeight += 1.0;
        }
    }

    // Right wall samples (GREEN)
    for (int i = 0; i < 4; i++) {
        for (int j = 0; j < 3; j++) {
            vec3 wallPt = vec3(0.98,
                              -0.6 + float(i) * 0.4,
                              -0.6 + float(j) * 0.6);
            vec3 wallNormal = vec3(-1.0, 0.0, 0.0);
            vec3 wallAlbedo = vec3(0.08, 0.85, 0.10); // green

            vec3 toLight = lightPos - wallPt;
            float distL = length(toLight);
            vec3 dirL = toLight / distL;

            float nDotL_wall = max(dot(wallNormal, dirL), 0.0);
            vec3 wallIrradiance = lightColor * nDotL_wall / (distL * distL + 0.5);
            vec3 wallReflected = wallAlbedo * wallIrradiance / PI;

            vec3 toP = p - wallPt;
            float dist = length(toP);
            vec3 dir = toP / dist;

            float cosWall = max(dot(wallNormal, dir), 0.0);
            float cosReceiver = max(dot(n, -dir), 0.0);
            float ff = cosWall * cosReceiver / (dist * dist + 0.3);

            indirect += wallReflected * ff;
            totalWeight += 1.0;
        }
    }

    // Floor and ceiling bounce (white surfaces)
    for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 3; j++) {
            // Floor sample
            vec3 floorPt = vec3(-0.6 + float(i) * 0.6, -0.98,
                                -0.6 + float(j) * 0.6);
            vec3 floorN = vec3(0.0, 1.0, 0.0);
            vec3 floorAlbedo = vec3(0.85);

            vec3 toLight = lightPos - floorPt;
            float distL = length(toLight);
            vec3 dirL = toLight / distL;

            float nDotL_f = max(dot(floorN, dirL), 0.0);
            vec3 fIrrad = lightColor * nDotL_f / (distL * distL + 0.5);
            vec3 fRefl = floorAlbedo * fIrrad / PI;

            vec3 toP = p - floorPt;
            float dist = length(toP);
            vec3 dir = toP / dist;

            float cosW = max(dot(floorN, dir), 0.0);
            float cosR = max(dot(n, -dir), 0.0);
            float ff = cosW * cosR / (dist * dist + 0.3);

            indirect += fRefl * ff;
            totalWeight += 1.0;
        }
    }

    // Back wall bounce (white)
    for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 3; j++) {
            vec3 wallPt = vec3(-0.6 + float(i) * 0.6,
                               -0.6 + float(j) * 0.6, -0.98);
            vec3 wallN = vec3(0.0, 0.0, 1.0);
            vec3 wallAlbedo = vec3(0.85);

            vec3 toLight = lightPos - wallPt;
            float distL = length(toLight);
            vec3 dirL = toLight / distL;

            float nDotL_w = max(dot(wallN, dirL), 0.0);
            vec3 wIrrad = lightColor * nDotL_w / (distL * distL + 0.5);
            vec3 wRefl = wallAlbedo * wIrrad / PI;

            vec3 toP = p - wallPt;
            float dist = length(toP);
            vec3 dir = toP / dist;

            float cosW = max(dot(wallN, dir), 0.0);
            float cosR = max(dot(n, -dir), 0.0);
            float ff = cosW * cosR / (dist * dist + 0.3);

            indirect += wRefl * ff;
            totalWeight += 1.0;
        }
    }

    // Scale the accumulated indirect light
    // The factor accounts for the discrete sampling vs. hemisphere integral
    return indirect * 4.5 / totalWeight;
}

void main() {
    vec2 uv = (gl_FragCoord.xy - 0.5 * iResolution.xy) / iResolution.y;

    // Camera setup — slightly animated to show 3D depth
    float camAngle = 0.08 * sin(iTime * 0.3);
    vec3 ro = vec3(camAngle, 0.0, 0.9); // camera inside the box
    vec3 lookAt = vec3(0.0, 0.0, 0.0);
    vec3 fwd = normalize(lookAt - ro);
    vec3 right = normalize(cross(fwd, vec3(0.0, 1.0, 0.0)));
    vec3 up = cross(right, fwd);
    vec3 rd = normalize(fwd * 1.2 + right * uv.x + up * uv.y);

    // Raymarch the scene
    vec2 hit = raymarch(ro, rd);
    float t = hit.x;
    float matID = hit.y;

    // Background (should not be visible inside box)
    vec3 col = vec3(0.0);

    if (t < MAX_DIST) {
        vec3 p = ro + rd * t;
        vec3 n = getNormal(p);
        vec3 albedo = getAlbedo(matID);

        // Light source — emit pure white/warm light
        if (matID > 3.5 && matID < 4.5) {
            col = vec3(1.0, 0.95, 0.85) * 3.0;
        } else {
            // ---- DIRECT ILLUMINATION ----
            vec3 lightPos = vec3(0.0, 0.95, 0.0);
            vec3 lightColor = vec3(1.0, 0.95, 0.85) * 2.5;

            vec3 toLight = lightPos - p;
            float lightDist = length(toLight);
            vec3 lightDir = toLight / lightDist;

            // Lambertian direct lighting
            float nDotL = max(dot(n, lightDir), 0.0);
            float attenuation = 1.0 / (lightDist * lightDist + 0.3);
            float shadow = softShadow(p + n * 0.01, lightDir, lightDist);
            float ao = calcAO(p, n);

            vec3 direct = albedo * lightColor * nDotL * attenuation * shadow;

            // ---- INDIRECT ILLUMINATION (GI) ----
            // This is where the magic happens: color bleeding
            vec3 indirect = indirectLight(p, n);

            // Combine direct + indirect with ambient occlusion
            col = (direct + indirect * albedo) * ao;

            // Small ambient term to prevent pure black
            col += albedo * 0.015;
        }
    }

    // Tone mapping (ACES-inspired)
    col = col / (col + vec3(1.0));
    // Slight contrast boost
    col = pow(col, vec3(0.92));
    // Gamma correction
    col = pow(col, vec3(1.0 / 2.2));

    gl_FragColor = vec4(col, 1.0);
}

If you run this shader, you'll see the hallmark of global illumination: the white floor near the left wall takes on a subtle red tint, while the area near the right wall picks up a green tint. The boxes also exhibit color bleeding on their side faces. This is achieved not through any tricks or hacks — it follows the actual physics of light transport, just simplified to a single bounce.

The Rendering Equation — The Foundation of GI

All global illumination techniques are, at their core, attempts to solve the rendering equation introduced by James Kajiya in 1986. It describes how much light leaves a point on a surface in a given direction:

// The Rendering Equation (conceptual GLSL pseudocode):
//
// L_out(p, wo) = L_emit(p, wo)
//              + integral over hemisphere {
//                    BRDF(p, wi, wo) * L_in(p, wi) * cos(theta_i) dwi
//                }
//
// Where:
//   L_out  = outgoing radiance at point p in direction wo
//   L_emit = emitted light (for light sources)
//   BRDF   = bidirectional reflectance distribution function
//   L_in   = incoming radiance from direction wi (THIS IS RECURSIVE!)
//   theta_i = angle between wi and surface normal
//
// The recursive nature of L_in is what makes GI expensive:
// L_in at point p requires knowing L_out at every other visible point.

// In our Cornell box shader, we approximated the integral like this:
vec3 gatherIndirect(vec3 p, vec3 n) {
    vec3 result = vec3(0.0);
    // Instead of integrating over the full hemisphere,
    // we sample known wall positions and sum their contributions:
    // result += wallReflectedRadiance * formFactor;
    // This is essentially a discrete form of "final gathering"
    return result;
}

The integral is over the hemisphere of directions above the surface. The incoming radiance L_in is itself the result of the rendering equation evaluated at whatever surface is visible in that direction — making the equation recursive. Solving this recursion is the central challenge of global illumination.

Screen-Space Global Illumination (SSGI)

Screen-space GI works entirely from the information already rendered to screen buffers — the depth buffer, normal buffer, and color buffer from the previous frame. It's essentially an extension of SSAO (Screen-Space Ambient Occlusion) that also gathers color information.

// SSGI Conceptual Approach (pseudocode)
//
// For each pixel, cast rays in screen space:
// 1. Generate sample directions in the hemisphere around the surface normal
// 2. March along each direction in screen space (using the depth buffer)
// 3. When a ray "hits" (depth test passes), read the color at that pixel
// 4. Accumulate the color contribution weighted by cosine and distance
//
// The key data structure: G-Buffer
// - texture2D gColor;    // RGB albedo or lit color
// - texture2D gNormal;   // World-space normals
// - texture2D gDepth;    // Linear depth

// Screen-space ray march (simplified)
vec3 ssgiGather(vec2 uv, vec3 pos, vec3 normal, int sampleCount) {
    vec3 indirect = vec3(0.0);
    for (int i = 0; i < sampleCount; i++) {
        // Generate a cosine-weighted random direction in hemisphere
        vec3 sampleDir = cosineWeightedDirection(normal, randomVec(uv, i));

        // March in screen space
        vec3 marchPos = pos;
        for (int step = 0; step < 16; step++) {
            marchPos += sampleDir * stepSize;
            vec2 sampleUV = projectToScreen(marchPos);

            // Compare our ray depth with the depth buffer
            float sceneDepth = texture2D(gDepth, sampleUV).r;
            float rayDepth = computeDepth(marchPos);

            if (rayDepth > sceneDepth && rayDepth - sceneDepth < thickness) {
                // Hit! Gather the color at this screen position
                vec3 hitColor = texture2D(gColor, sampleUV).rgb;
                vec3 hitNormal = texture2D(gNormal, sampleUV).rgb;
                float weight = max(dot(hitNormal, -sampleDir), 0.0);
                indirect += hitColor * weight;
                break;
            }
        }
    }
    return indirect / float(sampleCount);
}

Advantages: Very fast, requires no precomputation, works with fully dynamic scenes. Used extensively in modern games alongside other techniques.

Limitations: Can only gather light from surfaces visible on screen — objects off-screen or behind the camera contribute nothing. Suffers from artifacts at screen edges, thin objects, and surfaces at grazing angles. Typically limited to short-range indirect lighting.

Light Probes and Irradiance Volumes

Light probes are one of the oldest and most widely-used real-time GI techniques. The idea is to precompute (or update in real-time) the incoming light at specific positions in the world, store it in a compact representation (usually Spherical Harmonics), and interpolate between probes at runtime.

// Spherical Harmonics for Irradiance (L2 / 2nd order)
//
// 9 coefficients capture low-frequency lighting from all directions.
// For RGB, that's 27 floats per probe — extremely compact.
//
// SH basis functions (first 9):
// Y_00 = 0.2821   (constant — ambient)
// Y_1m = 0.4886 * {y, z, x}   (linear — directional)
// Y_2m = quadratic terms       (soft shadows / color variation)

// Evaluate SH irradiance at a surface with normal n:
vec3 evaluateSH(vec3 n, vec3 shCoeffs[9]) {
    vec3 result = shCoeffs[0] * 0.886;
    result += shCoeffs[1] * 2.0 * 0.511 * n.y;
    result += shCoeffs[2] * 2.0 * 0.511 * n.z;
    result += shCoeffs[3] * 2.0 * 0.511 * n.x;
    result += shCoeffs[4] * 2.0 * 0.429 * n.x * n.y;
    result += shCoeffs[5] * 2.0 * 0.429 * n.y * n.z;
    result += shCoeffs[6] * (0.743 * n.z * n.z - 0.248);
    result += shCoeffs[7] * 2.0 * 0.429 * n.x * n.z;
    result += shCoeffs[8] * 0.429 * (n.x * n.x - n.y * n.y);
    return max(result, vec3(0.0));
}

// At runtime, find the nearest probes and interpolate:
// vec3 gi = mix(evaluateSH(normal, probeA), evaluateSH(normal, probeB), blendFactor);

Modern implementations like DDGI (Dynamic Diffuse Global Illumination) update probes every frame using short ray traces on the GPU, enabling fully dynamic GI. Each probe stores both irradiance (SH or octahedral map) and a visibility/distance term to handle occlusion between probes and surfaces. DDGI is used in titles like The Callisto Protocol, various UE5 projects, and NVIDIA's RTX demos.

Voxel Global Illumination (VXGI)

Voxel-based GI, popularized by Crassin et al. and implemented in engines like NVIDIA VXGI, works by voxelizing the scene into a 3D grid, injecting direct lighting into those voxels, and then cone-tracing through the voxel volume to gather indirect illumination.

// VXGI Pipeline Overview:
//
// STEP 1: Voxelize the scene
//   - Render the scene from 3 axis-aligned views (X, Y, Z)
//   - Write surface albedo and normals into a 3D texture (e.g., 256^3)
//   - Use imageStore() or atomic operations to fill voxels
//
// STEP 2: Inject direct light
//   - For each lit voxel, compute direct illumination
//   - Store outgoing radiance in voxel (with directional info via SH or 6 faces)
//
// STEP 3: Generate mipmap chain (anisotropic)
//   - Create a 3D mipmap of the voxel radiance volume
//   - Each mip level represents a coarser view of the scene's light
//   - This is KEY: coarser mips = wider cone traces = distant GI
//
// STEP 4: Cone tracing (at render time)
//   - For each pixel, trace several cones into the voxel volume
//   - Start at fine mip levels (near surface) and move to coarser levels (far away)
//   - The cone's width determines which mip level to sample

// Simplified cone trace through a 3D radiance volume:
vec4 coneTrace(vec3 origin, vec3 dir, float coneAngle) {
    vec4 accum = vec4(0.0);  // RGB = color, A = opacity
    float t = 0.1;           // start offset to avoid self-intersection

    for (int i = 0; i < 64; i++) {
        // Cone radius grows with distance
        float radius = t * tan(coneAngle);

        // Mip level based on radius relative to voxel size
        float mipLevel = log2(radius / voxelSize);

        // Sample the 3D radiance texture at this mip level
        vec3 samplePos = origin + dir * t;
        vec4 sampleVal = textureLod(voxelRadiance, samplePos, mipLevel);

        // Front-to-back compositing
        accum.rgb += (1.0 - accum.a) * sampleVal.rgb * sampleVal.a;
        accum.a += (1.0 - accum.a) * sampleVal.a;

        // Step size grows with distance (matching cone expansion)
        t += max(radius * 0.5, voxelSize);

        if (accum.a > 0.98) break;
    }
    return accum;
}

// Typically trace 5-7 cones:
// 1 specular cone (narrow, along reflection vector)
// 4-6 diffuse cones (wide, distributed over hemisphere)

VXGI produces high-quality diffuse and specular indirect lighting and handles fully dynamic scenes. The main cost is memory (3D textures are large) and the voxelization step. Resolution is typically limited to 256-512 voxels per axis for real-time. Cascaded voxel volumes help extend range without losing detail near the camera.

Lumen — Unreal Engine 5's Hybrid GI

Lumen, the GI system in Unreal Engine 5, uses a hybrid approach combining multiple techniques to deliver high-quality fully dynamic global illumination without baking:

1. Surface Cache: Lumen renders simplified views of meshes from multiple angles and stores them in an atlas (the "surface cache"). These cached views provide the material and lighting data needed for secondary bounces without re-rendering the full scene.

2. Signed Distance Fields: UE5 generates per-mesh SDFs and a global merged SDF. These allow fast ray intersections for tracing indirect rays — much faster than tracing against full triangle meshes. For short-range traces, screen-space tracing is used first (similar to SSGI), falling back to SDF tracing for longer distances.

3. Radiance Caching: Rather than computing full GI per pixel, Lumen places "probes" adaptively in screen space, traces rays from these probes, and interpolates results for nearby pixels. This amortizes the cost of ray tracing across multiple pixels.

4. Hardware RT (optional): When hardware ray tracing is available, Lumen can use it for the final hit-lighting step, providing higher geometric precision than SDF traces. This is the "Lumen High Quality" mode.

The genius of Lumen is its layered fallback strategy: screen-space tracing for nearby surfaces (cheap, high detail), SDF tracing for medium range (good quality, moderate cost), and distance field-based far-field for everything else. This produces convincing GI across all distance ranges at manageable cost.

Radiance Cascades — The New Contender

Radiance Cascades is a technique originally described by Alexander Sannikov for 2D GI (in the game Path of Acacia) and now being actively researched for 3D applications. It represents a fundamentally different way of thinking about light transport.

// Radiance Cascades — Key Concept
//
// The core insight: light information at different DISTANCES from a point
// requires different ANGULAR resolutions.
//
// - Nearby light sources: need HIGH angular resolution (many directions)
//   because small movements change which light you see
// - Distant light sources: need LOW angular resolution (few directions)
//   because the light field changes slowly with position
//
// This is the OPPOSITE of how mip-maps work for textures!
//
// Cascade Structure:
// Level 0 (finest): many probes, few directions each, short range
// Level 1: fewer probes, more directions, medium range
// Level 2: even fewer probes, even more directions, long range
// ...and so on
//
// Each cascade level i has:
//   - Probe spacing: baseSpacing * 2^i
//   - Angular resolution: baseAngles * 2^i (or 4^i in 2D)
//   - Ray range: [2^i * baseRange, 2^(i+1) * baseRange]
//
// The cascades are MERGED from coarse to fine:
// Start with the coarsest level (far-field illumination)
// Merge down to finer levels, each time adding near-field detail

// Conceptual merge operation (2D version):
// For each probe at level N:
//   For each direction at level N:
//     If the ray at level N hit something -> use that radiance
//     Else -> look up the radiance from level N+1 at the endpoint
//             (interpolate from the coarser probe grid)
//     This "stitches" near-field and far-field together seamlessly

The elegance of Radiance Cascades is that it achieves O(N) scaling with respect to scene size — each cascade level does the same amount of work regardless of how large the world is. Traditional path tracing scales much less favorably. The technique naturally handles infinite light bounces because the cascade merging implicitly propagates light from distant sources through the cascade hierarchy.

For 3D, the research is ongoing. The angular resolution scaling becomes more expensive (directions scale with the square in 3D vs. linearly in 2D), but several promising adaptations are being explored, including hemispherical cascades and hybrid approaches combining cascades with probe-based methods.

Comparing GI Approaches

Here's how the major techniques stack up for real-time applications:

Lightmaps (baked): Perfect quality for static scenes, zero runtime cost for GI itself, but completely static. Still the gold standard for architectural visualization and games with mostly static environments. Can be augmented with light probes for dynamic objects.

Light Probes / DDGI: Good diffuse GI, low runtime cost, works with dynamic lighting. Limited spatial resolution — cannot capture sharp indirect shadows or fine contact lighting. Best for filling in ambient bounce light.

SSGI: Fast, dynamic, no precomputation. But incomplete — only knows about on-screen surfaces. Best used as a supplement to another GI technique, adding near-field contact color bleeding.

VXGI: Good balance of quality and performance, handles both diffuse and specular GI. Memory-hungry and resolution-limited. Works well in indoor or bounded scenes.

Lumen: Currently the most complete real-time GI solution in a shipping engine. Excellent quality with hardware RT, good quality with software tracing. Performance cost is significant but manageable on current-gen hardware.

Radiance Cascades: Promising new approach with excellent theoretical scaling properties. Already proven in 2D. 3D implementations are still maturing, but this could be a game-changer for the next generation of engines.

Practical Takeaways for Shader Developers

If you're implementing GI in your own renderer, start simple and layer complexity:

1. Start with ambient + AO. Even a flat ambient color with SSAO gives surfaces a grounded look. This is the baseline.

2. Add a simple hemisphere gather. As demonstrated in our Cornell box shader, sampling a few known points in the scene and computing form factors gives surprisingly convincing color bleeding. For a known scene layout, this can be hard-coded.

3. Implement SSGI for near-field. Screen-space ray marching against the depth buffer catches contact color bleeding — surfaces near colored objects picking up that color. This is the highest-impact dynamic GI technique relative to its cost.

4. Add probe-based far-field. For light that travels across rooms or through large spaces, irradiance probes (especially DDGI-style dynamic probes) fill in the long-range indirect illumination that SSGI misses.

The combination of SSGI (near) + dynamic probes (far) covers most real-time GI needs and is the approach used by many modern titles. Each technique compensates for the other's weaknesses — SSGI provides high-frequency detail while probes provide complete hemispherical coverage including off-screen contributions.

Global illumination remains one of the most active areas of research in real-time rendering. As GPU compute power grows and new algorithmic approaches like Radiance Cascades mature, we're steadily approaching the point where fully dynamic, multi-bounce GI at high resolution will be standard in real-time applications.

Moonjump
Forum Search Shader Sandbox
Sign In Register