Pre-Filtering Cubemaps for Split-Sum PBR in GLSL
This article explains how to pre-filter environment cubemaps across their mipmap chain for use in the split-sum specular Image-Based Lighting (IBL) approximation. It covers the mathematical foundation of Epic Games' split-sum approximation, the generation of low-discrepancy samples using the Hammersley sequence, GGX importance sampling in GLSL, the application of solid-angle mipmap weighting to suppress sampling noise, and the final runtime texture lookup.
The Split-Sum Specular Approximation
The specular component of the rendering equation for Image-Based Lighting is defined as:
\[L_o(p, \omega_o) = \int_{\Omega} L_i(p, \omega_i) f_r(p, \omega_i, \omega_o) (\omega_i \cdot n) \, d\omega_i\]
Evaluating this integral in real time for every fragment is computationally expensive. The split-sum approximation divides this integral into two independent terms:
- The Pre-Filtered Environment Map: Integrates the incoming radiance \(L_i\) with the Normal Distribution Function (NDF) across varying roughness levels, assuming the viewing angle equals the surface normal (\(V = N = R\)).
- The Environment BRDF (2D LUT): Integrates the Fresnel term, shadowing/masking (Geometry term), and the directional response across varying roughness and viewing angles.
The pre-filtered environment map stores the first term. Roughness is mapped directly to the mipmap chain: mip level 0 represents a roughness of 0.0 (a pure mirror reflection), while the highest mip level represents a roughness of 1.0 (maximum diffusion).
GGX Importance Sampling
To evaluate incoming radiance efficiently, Monte Carlo integration is paired with importance sampling based on the GGX (Trowbridge-Reitz) distribution. Instead of casting uniform rays across the hemisphere, sample directions are biased toward the microfacet specular lobe where the BRDF contributes the most light.
1. Low-Discrepancy Sequences (Hammersley)
A low-discrepancy Hammersley sequence generates well-distributed pseudo-random 2D points \(\Xi = (x, y)\) on the unit square \([0, 1)^2\):
float RadicalInverse_VdC(uint bits) {
bits = (bits << 16u) | (bits >> 16u);
bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u);
bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u);
bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u);
bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u);
return float(bits) * 2.3283064365386963e-10; // / 0x100000000
}
vec2 Hammersley(uint i, uint N) {
return vec2(float(i) / float(N), RadicalInverse_VdC(i));
}2. Transforming Points to the GGX Lobe
The 2D point \(\Xi\) is mapped to a spherical microfacet half-vector \(H\) aligned to the surface normal:
vec3 ImportanceSampleGGX(vec2 Xi, vec3 N, float roughness) {
float a = roughness * roughness; // Perceptual roughness squared
float phi = 2.0 * 3.14159265359 * Xi.x;
float cosTheta = sqrt((1.0 - Xi.y) / (1.0 + (a * a - 1.0) * Xi.y));
float sinTheta = sqrt(1.0 - cosTheta * cosTheta);
// Spherical to Cartesian coordinates (tangent space)
vec3 H;
H.x = cos(phi) * sinTheta;
H.y = sin(phi) * sinTheta;
H.z = cosTheta;
// Tangent space to world space
vec3 up = abs(N.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
vec3 tangent = normalize(cross(up, N));
vec3 bitangent = cross(N, tangent);
vec3 sampleVec = tangent * H.x + bitangent * H.y + N * H.z;
return normalize(sampleVec);
}Solid Angle Filtering and Source Mip Selection
With low sample counts (e.g., 512 to 2048 samples), high-frequency details from bright light sources can cause bright dot artifacts ("fireflies"). To mitigate this, sample directly from lower mip levels of the source environment cubemap based on the solid angle of the sample compared to the solid angle of the cubemap texel.
The Probability Density Function (PDF) of a GGX sample is:
\[\text{pdf} = \frac{D \cdot (N \cdot H)}{4 \cdot (V \cdot H)}\]
The solid angle of the sample (\(\Omega_s\)) and the solid angle of a texel (\(\Omega_p\)) are defined as:
\[\Omega_s = \frac{1.0}{N_{\text{samples}} \cdot \text{pdf}}, \quad \Omega_p = \frac{4\pi}{6 \cdot \text{resolution}^2}\]
The target mip level \(L\) of the source cubemap is determined by:
\[L = 0.5 \cdot \log_2\left(\frac{\Omega_s}{\Omega_p}\right)\]
GLSL Pre-filtering Shader
This compute or fragment shader runs for each face of each mip level
of the target cubemap. The roughness uniform is adjusted
per pass to match the target mip level.
#version 450 core
in vec3 v_WorldPos; // Direction corresponding to cubemap face texel
out vec4 FragColor;
uniform samplerCube u_EnvironmentMap;
uniform float u_Roughness;
uniform float u_SourceResolution; // e.g., 512.0 or 1024.0
const uint SAMPLE_COUNT = 1024u;
const float PI = 3.14159265359;
float DistributionGGX(vec3 N, vec3 H, float roughness) {
float a = roughness * roughness;
float a2 = a * a;
float NdotH = max(dot(N, H), 0.0);
float NdotH2 = NdotH * NdotH;
float denom = (NdotH2 * (a2 - 1.0) + 1.0);
return a2 / (PI * denom * denom);
}
void main() {
vec3 N = normalize(v_WorldPos);
vec3 R = N;
vec3 V = R;
vec3 prefilteredColor = vec3(0.0);
float totalWeight = 0.0;
for(uint i = 0u; i < SAMPLE_COUNT; ++i) {
vec2 Xi = Hammersley(i, SAMPLE_COUNT);
vec3 H = ImportanceSampleGGX(Xi, N, u_Roughness);
vec3 L = normalize(2.0 * dot(V, H) * H - V);
float NdotL = max(dot(N, L), 0.0);
if(NdotL > 0.0) {
float NdotH = max(dot(N, H), 0.0);
float VdotH = max(dot(V, H), 0.0);
// Calculate sample PDF
float D = DistributionGGX(N, H, u_Roughness);
float pdf = (D * NdotH / (4.0 * VdotH)) + 0.0001;
// Solid angle calculations to select source mip level
float omegaS = 1.0 / (float(SAMPLE_COUNT) * pdf);
float omegaP = 4.0 * PI / (6.0 * u_SourceResolution * u_SourceResolution);
float mipLevel = u_Roughness == 0.0 ? 0.0 : 0.5 * log2(omegaS / omegaP);
prefilteredColor += textureLod(u_EnvironmentMap, L, mipLevel).rgb * NdotL;
totalWeight += NdotL;
}
}
prefilteredColor = prefilteredColor / totalWeight;
FragColor = vec4(prefilteredColor, 1.0);
}Runtime Sampling
During the standard PBR evaluation pass, the filtered cubemap is
sampled using the reflection vector \(R\) and the material's perceptual roughness
value via textureLod:
vec3 R = reflect(-V, N);
const float MAX_REFLECTION_LOD = 4.0; // Corresponds to total mip count - 1
vec3 prefilteredColor = textureLod(
u_PrefilteredEnvMap,
R,
roughness * MAX_REFLECTION_LOD
).rgb;This retrieves the approximated radiance value, which is then multiplied by the corresponding Fresnel and visibility terms obtained from the precomputed 2D BRDF integration map.