IBL 镜面反射、Split-Sum 与粗糙度预滤波
IBL 镜面反射、Split-Sum 与粗糙度预滤波:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。
学习目标
- 能实现并改出完整的 IBL 环境光照:在上一章漫反射 IBL 的基础上加入镜面 IBL——用预滤波环境贴图和 BRDF LUT 让球面上的高光也从环境中取光
- 能说出 Split-Sum 近似的拆法:镜面积分 = 预滤波环境颜色(R, roughness) × BRDF 积分(NdotV, roughness),以及为什么这样拆能让实时渲染变成两次贴图采样
- 能回答:为什么预滤波环境贴图用 mipmap level 对应 roughness?如果 roughness=0 采 mip 0、roughness=1 采 mip 4,中间的 roughness 采样到的图像会有什么变化?
为什么金属会丢掉环境倒影
上一章给 PBR 球加上了漫反射 IBL——球的暗面不再死黑,从环境里收到了真实的漫反射光。但盯着光滑金属球看一眼你就会发现不对:一块光滑的金属应该像镜子一样,把整片蓝天或地面倒映在自己身上。现在却只有四盏灯的高光亮点,背景环境在球面上没有任何反射。
没有镜面 IBL,光滑金属球就像一个只有四个白点的黑球,完全不像真实金属的感觉。把粗糙度调低,期待的是清晰的环境倒影,得到的却是没有内容的纯黑背景。
这一章要做的就是:把环境贴图按不同模糊程度存成多级,光滑的查清晰版、粗糙的查模糊版,加上一张记录 Fresnel 积分的小贴图,实时渲染时只查两张表就能还原完整的镜面积分。
Split-Sum 近似
镜面 IBL 的核心挑战是:镜面 BRDF 里有两个未知量(入射方向 ωi 和观察方向 ωo),无法像漫反射那样只靠法线方向预计算。Epic Games 的 Brian Karis 提出了 ↡Split-Sum 近似:把镜面积分拆成两个只依赖单一变量的独立积分相乘——预滤波环境颜色(只依赖反射方向 R 和 roughness)× BRDF 积分(只依赖 NdotV 和 roughness)。两项可独立离线预计算成贴图,运行时各查一次即可还原积分。出自 Epic Games Karis 2013「Real Shading in Unreal Engine 4」。:把积分拆成两个独立部分相乘——一个只依赖反射方向和粗糙度,另一个只依赖视角与法线夹角和粗糙度。
这样每部分都可以独立预计算存成贴图,运行时用参数查表,把实时积分变成两次贴图采样。
预滤波环境贴图
↡预滤波环境贴图(Pre-filtered Environment Map):对原始环境贴图按不同粗糙度做卷积(重要性采样 GGX),把结果存成 cubemap mipmap 的各级。roughness=0 存 mip 0(清晰),roughness=1 存最高 mip(模糊)。运行时用反射方向 R 和 roughness 查对应层级。是 Split-Sum 的左乘项:把原始环境贴图按不同粗糙度做模糊卷积,每个粗糙度等级存成 cubemap 的一个 mipmap 层级。
卷积时不能用均匀采样(效率太低),改用对 GGX 法线分布函数做↡重要性采样(Importance Sampling):蒙特卡洛积分的一种优化:不均匀地在积分域上采样,而是集中在对结果贡献大的区域采样(这里是 GGX 波瓣的中心方向附近),用较少样本数就能收敛到准确结果。——让采样方向偏向 GGX 镜面波瓣的中心,用 1024 个样本就能获得高质量的预计算结果。
BRDF 积分 LUT
Split-Sum 的右乘项是 ↡BRDF 积分 LUT:一张 2D 查找纹理(512×512),横轴是 NdotV(0~1),纵轴是 roughness(0~1)。R 通道存 F0 的缩放系数 A,G 通道存偏移量 B。运行时用 F0·A + B 重建镜面 Fresnel 积分,完全不依赖具体的 F0 值(与场景材质解耦)。:一张以 NdotV 和 roughness 为轴的 2D 贴图,R 通道存 F0 的缩放系数,G 通道存偏移量。
预计算时对每个 (NdotV, roughness) 点做 1024 次 GGX 重要性采样,累加 G 和 Fresnel 贡献。最终公式:specular = prefilteredColor × (F0 · scale + bias)。
单步确认:把镜面积分拆成两张表
粗糙度决定预滤波层级
原始 HDR cubemap 的每个 mip 都不是自动缩小图,而是针对一个粗糙度做 GGX 卷积。光滑材质查清晰层,粗糙材质查更模糊的层。
动手:开关镜面 IBL,看高光从哪里来
猜一猜:金属度拉到最高(metallic=1),粗糙度调低(roughness≈0.1),关掉 IBL(拖到 0)和开启 IBL(拖到 1),球面的高光有什么本质差别?再把粗糙度慢慢拉高到 1.0,观察预滤波贴图的模糊效果。
实时演示加载中…
拖完之后你应该看到:
- IBL=1 + metallic=0.9 + roughness=0.3:球面出现蓝色天空和棕色地面的反射,光滑金属球面有清晰的环境倒影,高光不只是四个亮点
- IBL=0:背景变暗,镜面 IBL 消失,球面退回只有四盏点光源的孤立高光,完全没有环境感
- roughness 从 0.05 慢慢拉到 1.0:高光越来越扩散模糊,对应
proceduralSpecular把环境贴图从清晰混合到平均色(模拟 mip 从 0 到 4 的模糊) - metallic=0 时:
kD_ibl不为零,漫反射 IBL 占主导;镜面 IBL 也存在(F0=0.04 的非金属也有菲涅尔反射)
代码逐段拆解
以下把预计算和运行时着色器里镜面 IBL 相关的核心片段逐段拆解。预计算着色器(GPU 端离线运行)负责生成两张贴图;运行时着色器负责采样并合并。
a) Hammersley 序列:低差异采样点生成
重要性采样需要一套「均匀但又不规则」的 2D 样本序列。↡Hammersley 序列:Van der Corput 序列(二进制位翻转)× 均匀递增序列组合成的二维低差异序列。比伪随机采样收敛更快,在 1024 个点内就能获得高质量的积分估计。基于二进制位翻转(Van der Corput 序列):
// 二进制位翻转,生成 Van der Corput 序列的第 i 项
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
}
// Hammersley 2D 样本点:(i/N, VdC(i))
vec2 Hammersley(uint i, uint N) {
return vec2(float(i) / float(N), RadicalInverse_VdC(i));
}RadicalInverse_VdC 把整数 i 的二进制表示左右翻转,再归一化到 (0,1)——这让相邻索引产生的值尽量分散,比随机数更均匀地覆盖 (0,1)。组合成 (i/N, VdC(i)) 的二维点,在 GGX 重要性采样里用作两个随机数 (u1, u2)。
b) ImportanceSampleGGX:GGX 重要性采样
根据 Hammersley 样本点,用 GGX NDF 推导出偏向镜面波瓣的采样方向:
// 输入: Xi = Hammersley 样本 (u1, u2),roughness,N = 法线
// 输出: H = 半程向量方向(世界空间),偏向 GGX 波瓣中心
vec3 ImportanceSampleGGX(vec2 Xi, vec3 N, float roughness) {
float a = roughness * roughness;
// 球坐标:phi 均匀,cosTheta 由 GGX CDF 逆变换得到
float phi = 2.0 * PI * Xi.x;
float cosTheta = sqrt((1.0 - Xi.y) / (1.0 + (a*a - 1.0) * Xi.y));
float sinTheta = sqrt(1.0 - cosTheta * cosTheta);
// 切线空间 H 向量
vec3 H = vec3(sinTheta * cos(phi), sinTheta * sin(phi), cosTheta);
// 切线空间 → 世界空间
vec3 up = abs(N.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
vec3 tangentX = normalize(cross(up, N));
vec3 tangentY = cross(N, tangentX);
return normalize(tangentX * H.x + tangentY * H.y + N * H.z);
}关键公式是 cosTheta = sqrt((1 - u2) / (1 + (a⁴ - 1) * u2))——这是 GGX NDF 的累积分布函数(CDF)反函数,使采样方向概率密度与 GGX 波瓣形状匹配,集中在高 NDF 值的方向附近(specular lobe 中心),通常以更少样本获得稳定结果。
c) 预滤波卷积核心循环:V=R=N 近似
预计算时对每个 roughness 等级做 1024 次重要性采样累加:
// 预滤波卷积:对当前 roughness 生成预滤波环境颜色
// R = normalize(localPos) 是当前立方体面的反射方向
vec3 prefilteredColor = vec3(0.0);
float totalWeight = 0.0;
const uint SAMPLE_COUNT = 1024u;
for (uint i = 0u; i < SAMPLE_COUNT; i++) {
vec2 Xi = Hammersley(i, SAMPLE_COUNT);
// V=R=N:独立于视角的近似,会损失掠射角的视角相关反射变化
vec3 H = ImportanceSampleGGX(Xi, R, roughness);
vec3 L = normalize(2.0 * dot(V, H) * H - V); // V = R = N
float NdotL = max(dot(R, L), 0.0);
if (NdotL > 0.0) {
prefilteredColor += texture(environmentMap, L).rgb * NdotL;
totalWeight += NdotL;
}
}
prefilteredColor = prefilteredColor / totalWeight;V=R=N 是一个工程近似:预计算时无法知道运行时实际的视角 V,所以假设 V=R=法线。代价是掠射角的视角相关反射会有误差,但对大多数材质在视觉上可接受。NdotL 权重给正对反射方向的采样赋更高贡献。
d) IntegrateBRDF:BRDF LUT 预计算
对每个 (NdotV, roughness) 点做重要性采样,分别累加 scale(A)和 bias(B):
// 输入: NdotV, roughness → 输出: (scale=A, bias=B)
vec2 IntegrateBRDF(float NdotV, float roughness) {
vec3 V = vec3(sqrt(1.0 - NdotV*NdotV), 0.0, NdotV);
float A = 0.0, B = 0.0;
vec3 N = vec3(0.0, 0.0, 1.0);
const uint SAMPLE_COUNT = 1024u;
for (uint i = 0u; i < SAMPLE_COUNT; i++) {
vec2 Xi = Hammersley(i, SAMPLE_COUNT);
vec3 H = ImportanceSampleGGX(Xi, N, roughness);
vec3 L = normalize(2.0 * dot(V, H) * H - V);
float NdotL = max(L.z, 0.0);
float NdotH = max(H.z, 0.0);
float VdotH = max(dot(V, H), 0.0);
if (NdotL > 0.0) {
// IBL 版几何函数:k = roughness^2 / 2(与直接光照不同)
float k = roughness * roughness / 2.0;
float G_Vis = GeometrySchlickGGX(NdotV, k) * GeometrySchlickGGX(NdotL, k);
G_Vis *= VdotH / (NdotH * NdotV + 0.0001);
float Fc = pow(1.0 - VdotH, 5.0); // Fresnel 插值因子
A += (1.0 - Fc) * G_Vis; // F0 的缩放系数
B += Fc * G_Vis; // 偏移量(不依赖 F0)
}
}
return vec2(A, B) / float(SAMPLE_COUNT);
}注意 IBL 版几何函数直接按 k = roughness²/2 计算(直接光照版是 k = (roughness+1)²/8,§7 第 1 条误区)。把 Fresnel 因子 Fc 拆开分别累加,就把 F0 的依赖分离到了 A 系数上——运行时用任意 F0 代入 F0·A + B 即可,LUT 与场景材质解耦。
e) 最终合并:diffuse IBL + specular IBL + direct lighting
运行时着色器把三部分组装在一起:
// 1. IBL 版 Fresnel(粗糙度修正,用 dot(N,V) 而非 dot(H,V))
vec3 F_ibl = fresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness);
vec3 kS_ibl = F_ibl;
vec3 kD_ibl = (1.0 - kS_ibl) * (1.0 - metallic);
// 2. 漫反射 IBL(同上一章)
vec3 irradiance = texture(irradianceMap, N).rgb;
vec3 diffuseIBL = irradiance * albedo;
// 3. 镜面 IBL:预滤波环境 × BRDF LUT
vec3 R = reflect(-V, N);
float lod = roughness * float(MAX_LOD);
vec3 prefilteredColor = textureLod(prefilterMap, R, lod).rgb;
vec2 envBRDF = texture(brdfLUT, vec2(max(dot(N,V), 0.0), roughness)).rg;
vec3 specularIBL = prefilteredColor * (F_ibl * envBRDF.x + envBRDF.y);
// 4. 合并(注意:specularIBL 不再额外乘 kS!已包含在 Fresnel 中)
vec3 ambient = kD_ibl * diffuseIBL + specularIBL;
// 5. 加上直接光照
vec3 color = ambient + Lo;关键细节:specularIBL 不额外乘 kS——镜面 Fresnel 已经在 BRDF LUT 里积分进去了,F_ibl * envBRDF.x + envBRDF.y 整体就是把 Fresnel 效果合并进去的系数。如果再乘 kS,会造成能量守恒破坏(§7 第 4 条误区)。
常见误区
小结与练习
这一章完成了 PBR + IBL 的最后一块拼图:Split-Sum 近似把镜面积分拆成预滤波环境贴图(roughness → mip level)和 BRDF 积分 LUT(NdotV × roughness → scale, bias),两项分别离线预计算,运行时各查一次表,加上上一章的漫反射 IBL,组装出完整的 IBL 环境光照。
- Split-Sum 拆法的关键:把双变量积分拆成两个单变量积分相乘,每项都可独立预计算
- 预滤波卷积用 GGX 重要性采样(Hammersley + ImportanceSampleGGX),比均匀采样快得多
- BRDF LUT 预计算时 k = roughness²/2(IBL 版),与直接光照的 k = (roughness+1)²/8 不同
- 合并时 specular IBL 不额外乘 kS,避免能量守恒破坏
- 完整 IBL:
ambient = kD·irradiance·albedo + prefilteredColor·(F·A + B) + Lo
练习
实现题
- 在 Demo 的
proceduralSpecular函数里,把天空颜色从vec3(0.5, 0.7, 1.2)改为橙红色日落vec3(1.2, 0.5, 0.2)。观察 metallic=0.9、roughness=0.1 时球面倒影颜色的变化;再把 roughness 拉到 0.8,看倒影是否还保留橙色调(模拟粗糙面趋向平均色)。
- 把 BRDF LUT 近似函数
approxBrdfLUT的返回值改为vec2(1.0, 0.0)(scale=1, bias=0,即假装 F0=1 的完美导体)。观察金属球(metallic=0.9)和非金属球(metallic=0)在 IBL=1 下的镜面反射各有什么异常——特别是掠射角边缘的 Fresnel 效果是否消失。
概念题
- 预滤波卷积时用了 V=R=N 近似,这意味着独立于视角方向预计算——失去了什么?在哪类材质或视角下这个误差最明显?
- 为什么 BRDF LUT 与场景中 F0 的具体值无关?同一张 LUT 如何同时服务于 F0=0.04 的非金属和 F0=0.56 的铜金属?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- Split-Sum 近似
把镜面积分拆成两个只依赖单一变量的独立积分相乘——预滤波环境颜色(反射方向 R + roughness)× BRDF 积分(NdotV + roughness)。两项可独立离线预计算成贴图,运行时各查一次表即可还原积分。出自 Epic Games Karis 2013「Real Shading in Unreal Engine 4」。
- 预滤波环境贴图
Pre-filtered Environment Map:对原始环境贴图按不同粗糙度做 GGX 重要性采样卷积,把结果存成 cubemap mipmap 各级。roughness=0 存 mip 0(清晰高频),roughness=1 存最高 mip(接近环境平均色)。运行时用 textureLod(prefilterMap, R, roughness × MAX_LOD) 采样。
- 重要性采样
Importance Sampling:蒙特卡洛积分的优化策略,集中在对结果贡献大的方向采样(这里是 GGX 波瓣中心附近),减少方差,用较少样本(1024)收敛到与数千次均匀采样相当的精度。Hammersley 序列 + GGX CDF 逆变换共同实现。
- BRDF 积分 LUT
一张以 NdotV(横轴)和 roughness(纵轴)为输入的 2D 查找纹理,R 通道存 F0 的缩放系数 A,G 通道存偏移量 B。与 F0 无关——任意材质用同一张 LUT,运行时用 F0·A + B 重建镜面 Fresnel 积分。
- Hammersley 序列
Van der Corput 序列(二进制位翻转归一化)× 均匀递增序列组合成的二维低差异序列。比伪随机采样在同样样本数下均匀覆盖积分域,蒙特卡洛积分收敛更快。常用于 GPU 端 1024 样本的预计算着色器。
- fresnelSchlickRoughness
IBL 专用 Fresnel 变体:把普通 Fresnel-Schlick 的反射率上限 vec3(1.0) 替换成 max(vec3(1-roughness), F0),用粗糙度压低掠射角时的最大反射率,防止粗糙面 kS 趋近 1 导致 kD 归零。IBL 里输入 dot(N,V) 而非 dot(H,V)。
- V=R=N 近似
预滤波环境贴图预计算时,因为无法知道运行时的实际视角 V,将 V 近似为反射方向 R,进而近似为法线 N。会丢失掠射角的视角相关反射变化,光滑金属边缘的误差最明显;高粗糙度材质的视觉误差通常可接受。
版本、来源与运行边界
本章以 Joey de Vries 的 LearnOpenGL 原章 为授权改编依据,教学运行基线是 OpenGL 3.3 Core Profile。Khronos 当前发布的规范参照是 OpenGL 4.6 Core Profile;这里用 4.6 规范核查术语和状态合同,但不把 4.6 API 偷偷倒填为原教程内容。GLFW、GLAD、Assimp 与驱动版本都属于运行环境,不能拿“编译通过”替代对 context、资源和 framebuffer 结果的验证。
正式概念与状态责任
- prefilter map:在“IBL 镜面反射、Split-Sum 与粗糙度预滤波”中由prefilter cubemap、BRDF 2D LUT 与 PBR environment pass负责解释其输入、受控状态和可观察结果;运行时以mip count/roughness、预滤波 texel、LUT 坐标/值、Fresnel 与最终 specular定位它的第一处变化。
- brdf lut:在“IBL 镜面反射、Split-Sum 与粗糙度预滤波”中由prefilter cubemap、BRDF 2D LUT 与 PBR environment pass负责解释其输入、受控状态和可观察结果;运行时以mip count/roughness、预滤波 texel、LUT 坐标/值、Fresnel 与最终 specular定位它的第一处变化。
- split sum:在“IBL 镜面反射、Split-Sum 与粗糙度预滤波”中由prefilter cubemap、BRDF 2D LUT 与 PBR environment pass负责解释其输入、受控状态和可观察结果;运行时以mip count/roughness、预滤波 texel、LUT 坐标/值、Fresnel 与最终 specular定位它的第一处变化。
- specular ibl:在“IBL 镜面反射、Split-Sum 与粗糙度预滤波”中由prefilter cubemap、BRDF 2D LUT 与 PBR environment pass负责解释其输入、受控状态和可观察结果;运行时以mip count/roughness、预滤波 texel、LUT 坐标/值、Fresnel 与最终 specular定位它的第一处变化。
章专属 OpenGL 状态实验
先预测“生成各 roughness mip 与 BRDF LUT,运行时选择 mip 并组合 prefilteredColor*(F*scale+bias)”发生后,prefilter cubemap、BRDF 2D LUT 与 PBR environment pass应怎样改变roughness→mip、GGX samples、prefilter color、NdotV、LUT scale/bias 和 F0;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。
实验一:Context—资源—结果合同
选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。
Context · resource · observable result
IBL 镜面反射、Split-Sum 与粗糙度预滤波:状态合同
用 roughness 分级预滤波环境和 BRDF LUT 实现 split-sum specular IBL
验证场景
官方教程正式概念
logl-42 · 基线帧
prefilter map:固定 context、资源内容与输入事件,执行“生成各 roughness mip 与 BRDF LUT,运行时选择 mip 并组合 prefilteredColor*(F*scale+bias)”
冻结输入:prefilter map
prefilter cubemap、BRDF 2D LUT 与 PBR environment pass记录roughness→mip、GGX samples、prefilter color、NdotV、LUT scale/bias 和 F0
结果:得到可重复的初始 GL 状态与资源身份
观测:mip count/roughness、预滤波 texel、LUT 坐标/值、Fresnel 与最终 specular中的初始快照
预期:prefilter cubemap、BRDF 2D LUT 与 PBR environment pass得到可复查结果,并持续满足“prefilter 最大 mip 与运行时 roughness 映射一致;LUT 坐标限定在有效范围”
实验二:CPU 命令到 GPU 结果的五段轨迹
逐段执行“生成各 roughness mip 与 BRDF LUT,运行时选择 mip 并组合 prefilteredColor*(F*scale+bias)”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“prefilter 最大 mip 与运行时 roughness 映射一致;LUT 坐标限定在有效范围”。
CPU command · GL state · GPU result
IBL 镜面反射、Split-Sum 与粗糙度预滤波:五段轨迹
当前观测:mip count/roughness、预滤波 texel、LUT 坐标/值、Fresnel 与最终 specular中的初始快照
不变量:prefilter 最大 mip 与运行时 roughness 映射一致;LUT 坐标限定在有效范围
实验三:单故障与同输入恢复
注入“运行时假定 5 个 mip,但预滤波纹理只生成 4 层,粗糙材质采到未定义层”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有mip count/roughness、预滤波 texel、LUT 坐标/值、Fresnel 与最终 specular一起恢复才算修复。
Single fault · first divergence · replay
IBL 镜面反射、Split-Sum 与粗糙度预滤波:反例与恢复
故障:运行时假定 5 个 mip,但预滤波纹理只生成 4 层,粗糙材质采到未定义层
第 1 次沿用同一 context、资源、uniform 与 draw 输入
保持其余输入不变,仅注入“运行时假定 5 个 mip,但预滤波纹理只生成 4 层,粗糙材质采到未定义层”
prefilter 最大 mip 与运行时 roughness 映射一致;LUT 坐标限定在有效范围
mip count/roughness、预滤波 texel、LUT 坐标/值、Fresnel 与最终 specular
最小可重放检查
unit: logl-42
owner: prefilter cubemap、BRDF 2D LUT 与 PBR environment pass
state_or_resource: roughness→mip、GGX samples、prefilter color、NdotV、LUT scale/bias 和 F0
command: 生成各 roughness mip 与 BRDF LUT,运行时选择 mip 并组合 prefilteredColor*(F*scale+bias)
pass_invariant: prefilter 最大 mip 与运行时 roughness 映射一致;LUT 坐标限定在有效范围
single_fault: 运行时假定 5 个 mip,但预滤波纹理只生成 4 层,粗糙材质采到未定义层
required_evidence: mip count/roughness、预滤波 texel、LUT 坐标/值、Fresnel 与最终 specular复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。