CHAPTER :: 03 / 05 LOGGED :: 2026 · MAR · 28

Light, Pre-Filtered

Where the analytic light is retired — the sky itself becomes the light source, projected to nine numbers for diffuse and importance-sampled at runtime for specular.

The renderer at the end of the previous chapter knows direct lighting well. Directional sun, spot lights, point lights, all shadowed correctly with whichever filter the scene calls for. What it doesn’t know is the sky. Step outside on an overcast day — there is no sun, but the world is lit. Indoors, every wall is reflecting some fraction of the room’s light back. None of that exists in the renderer yet.

This chapter replaces analytic point lights with an environment — an HDR panorama wrapped around the scene as a continuous emitter. The path tracer in the previous series did this honestly, importance-sampling the panorama into a CDF and casting tens of thousands of rays per frame. That’s not viable in real time. The trick is to pre-filter the environment — once, at load — into representations that can be evaluated against in a few texture lookups per pixel.

Two pre-filters cover the two halves of the BRDF. Diffuse — low-frequency, smoothly varying — projects beautifully onto spherical harmonics: nine coefficients that summarize the entire diffuse irradiance from any direction at O(1) shader cost. Specular — sharp, view-dependent, roughness-dependent — needs sample-by-sample work, but a small number of importance-sampled GGX samples with the right mip-level selection gets there.

The environment as light source

The HDR panorama is loaded as a single equirectangular texture with linear floating-point RGB. The shader maps a world-space direction to a UV coordinate via standard latitude/longitude unwrapping:

vec2 uvOfW(vec3 dir) {
    return vec2(
        0.5 - atan(dir.x, dir.z) / (2.0 * PI),
        acos(dir.y) / PI
    );
}

Every direction in 4π steradians is now a single texture lookup away. The panorama’s pixel values are radiance — they describe how much light is arriving from every direction. The integral over the hemisphere of those radiance values, weighted by the BRDF, is the surface’s outgoing radiance. The renderer’s job is to compute that integral cheaply.

CLICK TO ZOOM
| Test scene with diffuse-only IBL active — no analytic lights, the entire scene is lit by the surrounding environment |

Diffuse, projected to nine numbers

The diffuse term integrates the environment’s radiance against cos(θ) over the upper hemisphere relative to each surface normal. The integrand is low-frequency — the cosine smooths everything out — which means it can be very accurately approximated by a low-order spherical harmonic projection.

A second-order SH expansion uses nine basis functions Y_lm(direction) for l ∈ {0, 1, 2}. At load time, every pixel of the panorama is projected onto each basis function and summed, producing nine RGB coefficients:

// SH projection: L_lm = ∫∫ L(ω) · Y_lm(ω) · sinθ dθ dφ
float Y[9];
evalBasis(x, y, z, Y);
float weight = sinTheta * dTheta * dPhi;
for (int k = 0; k < 9; ++k)
    Llm[k] += L * Y[k] * weight;

A neat optimization: the cosine-weighted hemisphere convolution that turns radiance into irradiance can be folded into the coefficients at this stage, because the convolution kernel is also expressible in low-order SH and acts diagonally on each band. The Ahat factors per band are constants (l=0: π, l=1: (2/3)π, l=2: (1/4)π) — multiplying them in once at the end means the shader never needs to apply them at runtime:

const float Ahat[9] = {
    M_PI,
    (2.0/3.0)*M_PI, (2.0/3.0)*M_PI, (2.0/3.0)*M_PI,
    (1.0/4.0)*M_PI, (1.0/4.0)*M_PI, (1.0/4.0)*M_PI,
    (1.0/4.0)*M_PI, (1.0/4.0)*M_PI,
};
for (int k = 0; k < 9; ++k) coeffs[k] = Llm[k] * Ahat[k];

What the shader sees is nine pre-baked RGB values that, when dotted against the SH basis evaluated at any normal N, produce the exact diffuse irradiance for that direction — to second-order accuracy:

vec3 evalSHIrradiance(vec3 n) {
    float x = n.x, y = n.y, z = n.z;
    float Y[9];
    Y[0] = 0.5  * sqrt(1.0  / PI);
    Y[1] = 0.5  * sqrt(3.0  / PI) * y;
    Y[2] = 0.5  * sqrt(3.0  / PI) * z;
    Y[3] = 0.5  * sqrt(3.0  / PI) * x;
    Y[4] = 0.5  * sqrt(15.0 / PI) * x * y;
    Y[5] = 0.5  * sqrt(15.0 / PI) * y * z;
    Y[6] = 0.25 * sqrt(5.0  / PI) * (3.0 * z*z - 1.0);
    Y[7] = 0.5  * sqrt(15.0 / PI) * x * z;
    Y[8] = 0.25 * sqrt(15.0 / PI) * (x*x - y*y);

    vec3 irradiance = vec3(0.0);
    for (int k = 0; k < 9; k++) irradiance += shCoeffs[k] * Y[k];
    return max(irradiance, vec3(0.0));
}

vec3 diffuse = (albedo / PI) * evalSHIrradiance(normalize(N));

Nine multiply-adds. The full diffuse integral, evaluated in the time it takes to read nine constant uniforms.

CLICK TO ZOOM
| SH diffuse against two environments — a noon-sun garden tints the upper hemisphere bright white while the lower picks up the green of the grass; a coastal balcony shifts the tone toward the bright walls |

The accuracy is limited by the SH order. A second-order projection captures the directional flow of light correctly but smooths over high-frequency features (sharp sun edges, narrow window slits). For a low-frequency diffuse response, that smoothing is a feature — it matches the physical convolution against cos(θ). For specular, where high-frequency detail is the entire point, SH won’t work.

Specular, sampled importance-aware

Specular reflectance varies sharply with view direction and roughness — a polished sphere shows a tight reflection of the sun, a rougher one smears it across half the sky. The GGX BRDF concentrates its energy in a lobe whose width depends on roughness. Sampling that lobe correctly is the work of importance sampling.

For each fragment, the shader draws N quasi-random sample pairs (e1, e2) from the Hammersley sequence — a low-discrepancy sequence built from the Van der Corput radical inverse. Hammersley is preferred over uniform random because it covers the unit square stratifiedly, which means the sample directions cover the GGX lobe uniformly with much lower variance than the same number of random samples would.

// (e1, e2) drawn from Hammersley, warped into the GGX lobe
float theta = atan(roughness * sqrt(e2) / sqrt(1.0 - e2));    // GGX half-angle
vec3 D = vectorOf(e1, theta / PI);                             // half-vector in local frame
vec3 L = tangent * D.x + R * D.y + bitangent * D.z;            // world-space sample dir

The trick is that the sample’s probability density is itself an output of the GGX distribution function D(m). A direction with high D (smooth surface) means the sample fell in a tight, concentrated lobe — so the environment at that direction should be sampled sharply. A direction with low D (rough surface) means the sample is spread across a wide lobe — so a blurred environment value is the correct one to sample.

Equirectangular textures with mipmaps are the natural fit. The shader picks a mip level proportional to the inverse of the PDF:

ivec2 size = textureSize(envMap, 0);
float level = 0.5 * log2(float(size.x * size.y) / float(N))
            - 0.5 * log2(max(D, 0.0001) / 4.0);
level = clamp(level, 0.0, maxLod);

vec3 Li = textureLod(envMap, uv, level).rgb;

Sample-by-sample, the shader fetches a mip-correct radiance value, plugs it through the Cook-Torrance terms, and accumulates:

float D = DistributionGGX(N, H, roughness);
float G = GeometrySmith(N, V, L, roughness);
vec3  F = FresnelSchlick(HdotV, F0);
specColor += Li * NdotL * (G * F) / (4.0 * NdotV * NdotL);

The accumulator is normalized by the number of accepted samples — those with dot(N, L) > 0 — rather than total N, which prevents surfaces near the horizon from darkening because half their hemisphere falls below the surface plane.

A single sample produces a single sharp reflection of the environment; ten samples produce ten superimposed reflections that visibly blend; forty samples produce a smooth, well-converged specular term that already looks correct on most materials at most roughnesses. Above forty, the gains are diminishing and the cost is linear.

CLICK TO ZOOM
| Diffuse + specular IBL — every metallic and reflective surface picks up the appropriate part of the environment |

The material parameter space

With both halves of the IBL working, the renderer covers the full (metallic, roughness) parameter space. Sweeping each axis independently makes the contribution of each term legible:

CLICK TO ZOOM
| Holding roughness at 0, increasing metallic from 0 to 1 — the diffuse term is suppressed and F0 climbs toward the albedo, the surface transitions from shiny dielectric to mirror metal |
CLICK TO ZOOM
| Holding metallic at 1, increasing roughness from 0 to 1 — the specular lobe widens, the sharp sun reflection smears into a soft, broad highlight |

Energy conservation enforces the trade. As metallic rises, kD = (1 - F) · (1 - metallic) drives the diffuse contribution toward zero — metals are physically diffuse-free. As roughness rises, the GGX NDF broadens — the specular energy is spread over more of the sky and the highlight loses contrast. Both terms remain in unit balance: the surface never reflects more light than it received.

Real models bring real complexity — color, normal, metallic, roughness all sampled per-UV from texture maps so a single object can have varied material response across its surface:

CLICK TO ZOOM
| Aged-bronze lion head against a sunset HDRI — the specular sheen tracks the sun across the geometry as the model rotates, brighter on the smoother ridges and duller on the worn metal |
CLICK TO ZOOM
| Rough marble bust against a colored studio HDRI — the diffuse SH irradiance carries the room's tints across the surface as the scene rotates |

Tone mapping HDR back to LDR

The IBL pipeline produces values that can be arbitrarily bright — the panorama’s sun is several orders of magnitude brighter than its sky. Display devices, however, are 8-bit per channel and clamp at (1, 1, 1). A naïve clamp renders any pixel with even modestly high radiance as pure white, losing all the detail in the highlight.

Tone mapping is the curve that compresses the HDR range back to LDR while preserving as much perceptual detail as possible. Several operators are common:

OperatorCurveCharacter
Reinhardx / (1 + x)Simple, desaturates highlights
ACES(x(ax + b)) / (x(cx + d) + e)Industry-standard filmic look
Uncharted 2 (Hable)Explicit shoulder/toe parametersTunable, rich shadows
PBR NeutralKhronos-spec, desaturation-preservingFaithful to specular highlights

All operators run after exposure has been applied (a linear multiplier on radiance) and are followed by gamma correction (pow(color, 1/2.2)) to convert the linear output to sRGB. ACES is the personal default — its filmic shoulder reads as “cinematic” without being too aggressive about color shift.

CLICK TO ZOOM
| Cycling through tone-mapping operators on the same scene — Reinhard washes highlights, ACES preserves the filmic feel, PBR Neutral keeps colors saturated through the shoulder |

What’s still flat

The renderer at the end of this chapter is a competent real-time PBR engine. It has correctly-shadowed direct lighting, an HDR environment as the ambient and specular source, energy-conserving BRDFs, and tone-mapped output that holds up on a normal display. Walked through, the scene looks pretty good.

But every fragment receives the full environment irradiance for its normal direction. There’s no concept of some directions of light being blocked by nearby geometry. A statue’s eye socket is lit by exactly the same SH irradiance as a flat patch of floor next to it — the algorithm doesn’t know the eye socket is a recessed cavity surrounded on all sides by skull. Corners, crevices, contact points between objects — places that physically receive less ambient light because the surrounding geometry blocks part of the hemisphere — all read as fully exposed.

The next chapter is the screen-space approximation that fixes that. For each pixel, sample a small disc of nearby fragments, ask how many of them sit above the current surface (and would therefore block ambient light from above), and use the result to attenuate the IBL term. It’s an old trick — ambient occlusion — but the version that runs on a G-buffer in screen space is fast enough to apply to every visible pixel every frame.

SCENE_GRAPH