Three.js From Zero · Article s4-02
Image-Based Lighting Internals
Image-Based Lighting Internals
S4-01 handled direct lights. But real scenes get ambient light from everywhere — sky, walls, nearby bounces. You can't represent that with a single directional light. The answer: image-based lighting. Store the full environment in a cubemap, then sample it correctly for every surface.
Three.js's PMREMGenerator (from S1-04) does this. This article is what it's
actually doing — the split-sum approximation, the prefiltering, the roll-your-own version.
The split-sum approximation
The full environment-lighting integral is expensive to compute at runtime:
L_out = ∫ BRDF(ωᵥ, ωₗ) · L_env(ωₗ) · (n·ωₗ) dωₗ
Too slow. Karis (UE4) proposed splitting it into two precomputed parts:
L_out ≈ ( ∫ L_env · weight ) · ( ∫ BRDF · weight )
↑ prefiltered envmap ↑ BRDF LUT
Both halves are precomputed offline (at PMREM generation time). Runtime just samples two textures and multiplies.
Half 1 — prefiltered specular envmap
For a given roughness, pre-integrate the specular reflection over the environment. Low roughness → sharp mirror-like cubemap. High roughness → blurred. Store as mipmaps in one cubemap; roughness selects the mip level.
mip 0: roughness = 0.0 (sharp)
mip 1: roughness = 0.25
mip 2: roughness = 0.5
mip 3: roughness = 0.75
mip 4: roughness = 1.0 (maximally blurred)
PMREM does this on the GPU in milliseconds.
Half 2 — the BRDF LUT
A 256×256 2D texture encoding the BRDF response at every combination of (NdotV, roughness). Two channels: a scale for F0 and a bias for (1 - F0). Same for every scene; ship once.
Runtime sampling
// Sample prefiltered environment at roughness-weighted mip
float mipLevel = roughness * float(NUM_MIPS - 1);
vec3 prefilteredSpec = textureCubeLod(uEnvMap, reflect(-V, N), mipLevel).rgb;
// Sample BRDF LUT
vec2 brdf = texture2D(uBRDFLUT, vec2(NoV, roughness)).rg;
// Combine
vec3 specularIBL = prefilteredSpec * (F0 * brdf.x + brdf.y);
For the diffuse part, you also prefilter the environment for irradiance — a low-frequency version that approximates Lambertian integration:
vec3 irradiance = textureCube(uIrradianceMap, N).rgb;
vec3 diffuseIBL = irradiance * albedo / PI;
Energy conservation with IBL
The direct-lighting weights (S4-01) must be adjusted:
vec3 F = F_Schlick_Roughness(NoV, F0, roughness);
vec3 kS = F;
vec3 kD = (1.0 - kS) * (1.0 - metalness);
vec3 color = kD * diffuseIBL + specularIBL;
Note: F_Schlick_Roughness — a rough version that softens Fresnel at high
roughness. Fisher-Price paper, from Sebastien Lagarde.
Three.js's PMREM pipeline
Inside PMREMGenerator.fromScene():
- Render the scene to a cubemap (6 faces)
- For each mip level 0..N:
- Compute roughness for this level
- GPU pass: importance-sample the lower mip, integrate GGX lobe
- Write to this mip level
The fromEquirectangular entry point does the same but from a 2:1 HDR image
instead of a cubemap. fromScene with RoomEnvironment gets you an
OK generic studio lighting in ~5ms.
Common first-time pitfalls
- Specular is too bright at rough values. BRDF LUT missing. The tail-end should darken.
- Envmap seams visible. Each cubemap face was prefiltered separately without cross-face awareness. Use Three's PMREM, don't roll a naïve prefilter.
- Envmap intensity = 0 → pitch black. PBR without env = pitch black. Add a tiny directional light.
- Colors wrong. HDR envmap's color space must be linear, not sRGB.
loader.type = THREE.HalfFloatType+ linear workflow.
Exercises
- Roll your own PMREM: write a ShaderPass that takes a cubemap + roughness, outputs a prefiltered face. Importance-sample GGX.
- BRDF LUT bake: render a 256×256 texture with the split-sum's second half. Ship as KTX2. Compare to runtime fetch.
- Spherical harmonics diffuse: for low-frequency IBL diffuse, a 9-coefficient SH representation is ~100x cheaper than sampling an irradiance cubemap.
What's next
S4-03 — Shadow techniques. PCF, VSM, CSM, ray-traced. The full catalog of shadow maps.