IBL
需要预先学习:
1. IBL 概述
PBR 的直接光照部分把渲染方程(渲染方程与重要性采样)的积分退化成对光源求和,但环境光仍是个常数——没有方向性,金属物体反射不出环境的颜色,粗糙金属的反射也不会模糊。要补全 PBR 观感,需要来自环境各方向的光。
IBL(Image Based Lighting,基于图像的光照)的做法:用一张环境贴图(cubemap,见 纹理)记录周围环境各方向的入射光 ,把渲染方程的积分预先算好存进纹理,运行时查表代替求积分。
这套"预计算换实时性"的思路与 ShadowMap与PCF、PCSS、纹理 的 mipmap 一脉相承,是实时渲染的核心套路。
1.1 渲染方程的拆分
漫反射 IBL 与高光 IBL 分开处理,因为两者的积分性质不同:
| 项 | 积分 | 依赖 |
|---|---|---|
| 漫反射 | 只依赖法线方向,与视角无关 | |
| 高光 | 依赖粗糙度与视角(菲涅尔、GGX lobe) |
- 漫反射积分:与视角无关,对每个法线方向预计算一次即可——结果叫辐照度图(irradiance map);
- 高光积分:依赖粗糙度和视角,无法预计算成一张图。用分裂求和近似(split-sum approximation)把它拆成两个独立部分的乘积:
前一个积分只依赖粗糙度(按粗糙度预滤波成 mipmap),后一个积分只依赖 与粗糙度(预计算成 2D 查找表)。两者相乘就是高光 IBL。
注:分裂求和是近似,但误差在可接受范围,是实时 PBR 的工业标准做法(Unreal Engine 4 的实时光照方案即基于此)。
2. 漫反射 IBL:辐照度图
2.1 卷积的含义
辐照度(irradiance)是单位面积接收的辐射通量。对每个法线方向 ,辐照度图存的是半球积分:
几何上看,这就是把环境贴图按余弦权重模糊一遍:每个 texel 的值等于"以该 texel 方向为中心,半球内所有方向取平均"。因为积分抹掉了高频细节,辐照度图是低频信息,分辨率 32×32 就足够,无需 mipmap。
2.2 预计算:卷积 shader
离线阶段把 cubemap 的每个 texel 当作法线方向,在它的半球内均匀采样求和:
// 从 cubemap 采样环境光的辐照度卷积
#version 330 core
out vec4 FragColor;
in vec3 localPos; // 当前 texel 方向(立方体面的局部坐标)
uniform samplerCube environmentMap;
const float PI = 3.14159265359;
void main()
{
vec3 N = normalize(localPos);
vec3 up = vec3(0.0, 1.0, 0.0);
vec3 right = normalize(cross(up, N));
up = cross(N, right);
vec3 irradiance = vec3(0.0);
float sampleDelta = 0.025; // 采样步长(弧度)
float nrSamples = 0.0;
// 在半球上按球坐标均匀采样,权重取 cos(theta)
for (float phi = 0.0; phi < 2.0 * PI; phi += sampleDelta)
{
for (float theta = 0.0; theta < 0.5 * PI; theta += sampleDelta)
{
// 球坐标转方向,再转到世界空间
vec3 tangentSample = vec3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta));
vec3 sampleVec = tangentSample.x * right + tangentSample.y * up + tangentSample.z * N;
irradiance += texture(environmentMap, sampleVec).rgb * cos(theta) * sin(theta);
nrSamples++;
}
}
// 归一化:除以样本数再乘 PI。sin(theta) 来自球坐标面积元,
// 乘 PI 后结果与 Lambert BRDF 的 1/PI 因子抵消,采样一次即为漫反射项
irradiance = PI * irradiance * (1.0 / nrSamples);
FragColor = vec4(irradiance, 1.0);
}
代码中的 来自球坐标的雅可比(球面面积元 ),最后乘以 是余弦卷积归一化。把 6 个面分别渲染进辐照度 cubemap 即完成预计算。
注:辐照度卷积的采样数是 64×64 量级,每 texel 都要做——预计算在初始化时做一次,代价可以接受。这就是"预计算换实时性"。
3. 高光 IBL:预滤波环境贴图
3.1 按粗糙度模糊成 mipmap
高光 lobe 的形状随粗糙度剧烈变化:光滑表面的 lobe 极窄,反射清晰;粗糙表面的 lobe 宽而模糊。一张模糊图无法同时表达——解决方案是按粗糙度把环境贴图预滤波成 mipmap 链:mip 0 是原始环境,mip 越高越模糊,采样时按粗糙度选择 mip 层。
预滤波的卷积与漫反射类似,但有两个区别:
- 采样分布用 GGX 重要性采样(渲染方程与重要性采样)而不是均匀采样——lobe 窄,均匀采样会大量浪费样本;
- 采样方向围绕反射方向 (而不是法线)分布,且按 加权。
// 预滤波环境贴图:每个 mip 层对应一个粗糙度
#version 330 core
out vec4 FragColor;
in vec3 localPos;
uniform samplerCube environmentMap;
uniform float roughness;
const float PI = 3.14159265359;
// Hammersley 序列与 GGX 重要性采样(见《渲染方程与重要性采样》)
float radicalInverse_VdC(uint bits) { /* ... 位反转 ... */ }
vec2 hammersley(uint i, uint N) { return vec2(float(i) / float(N), radicalInverse_VdC(i)); }
vec3 importanceSampleGGX(vec2 xi, vec3 N, float roughness) { /* ... 见前文 ... */ }
void main()
{
vec3 N = normalize(localPos);
vec3 R = N;
vec3 V = R; // 预计算时假设视线方向 == 反射方向(等价于法线)
const uint SAMPLE_COUNT = 1024u;
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, roughness);
vec3 L = normalize(2.0 * dot(V, H) * H - V); // 由半程向量反射出入射方向
float NdotL = max(dot(N, L), 0.0);
if (NdotL > 0.0)
{
prefilteredColor += texture(environmentMap, L).rgb * NdotL;
totalWeight += NdotL;
}
}
FragColor = vec4(prefilteredColor / totalWeight, 1.0);
}
3.2 生成 mipmap 链
对粗糙度 在 间取若干档(如每 mip 层一档),分别渲染到预滤波 cubemap 的对应 mip 层:
// 预滤波 cubemap:5 个 mip 层,粗糙度从 0 到 1
unsigned int prefilterMap;
glGenTextures(1, &prefilterMap);
glBindTexture(GL_TEXTURE_CUBE_MAP, prefilterMap);
for (unsigned int i = 0; i < 6; ++i)
{
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0,
GL_RGB16F, 128, 128, 0, GL_RGB, GL_FLOAT, nullptr);
}
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glGenerateMipmap(GL_TEXTURE_CUBE_MAP);
// 逐 mip 层渲染卷积
for (unsigned int mip = 0; mip < 5; ++mip)
{
unsigned int mipWidth = 128 * std::pow(0.5, mip);
unsigned int mipHeight = 128 * std::pow(0.5, mip);
glBindRenderbuffer(GL_RENDERBUFFER, captureRBO);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, mipWidth, mipHeight);
glViewport(0, 0, mipWidth, mipHeight);
float roughness = (float)mip / (5 - 1);
glUniform1f(glGetUniformLocation(prefilterShader, "roughness"), roughness);
// 渲染 6 个面到对应 mip 层
for (unsigned int face = 0; face < 6; ++face)
{
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_TEXTURE_CUBE_MAP_POSITIVE_X + face,
prefilterMap, mip);
renderCube();
}
}
4. 高光 IBL:BRDF 积分 LUT
4.1 分裂求和的后半部分
分裂求和剩下的积分只与 BRDF 有关,与环境光无关:
把 Schlick 菲涅尔近似 代入后, 可以提出积分号外,剩下只依赖两个变量的部分:
其中 与 只依赖 和粗糙度。于是预计算一张 2D 查找表(LUT):横轴 ,纵轴粗糙度,每个 texel 存 (scale, bias) 两个值。运行时一行代码查表,就得到了"这个粗糙度、这个视角下的菲涅尔积分"。
4.2 预计算 LUT
LUT 用 GGX 重要性采样预计算,需要把它定义成只依赖两个标量——把积分变换到 空间的极坐标后,样本的 角是均匀的, 由 分布采样得到:
// BRDF LUT:输出 (scale, bias),写入 2D 纹理
#version 330 core
out vec2 FragColor;
in vec2 texCoords;
const float PI = 3.14159265359;
uniform sampler2D lutTexture; // 占位,实际无需采样
float radicalInverse_VdC(uint bits) { /* ... */ }
vec2 hammersley(uint i, uint N) { /* ... */ }
vec3 importanceSampleGGX(vec2 xi, vec3 N, float roughness) { /* ... */ }
// 几何函数(与 PBR 一致)
float geometrySchlickGGX(float NdotV, float roughness) { /* ... */ }
float geometrySmith(vec3 N, vec3 V, vec3 L, float roughness) { /* ... */ }
vec2 integrateBRDF(float NdotV, float roughness)
{
vec3 V = vec3(sqrt(1.0 - NdotV * NdotV), 0.0, NdotV); // 由 NdotV 重建视角方向
float A = 0.0;
float 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)
{
float G = geometrySmith(N, V, L, roughness);
float G_Vis = (G * VdotH) / (NdotH * NdotV);
float Fc = pow(1.0 - VdotH, 5.0);
A += (1.0 - Fc) * G_Vis; // scale:F0 的系数
B += Fc * G_Vis; // bias:与 F0 无关的项
}
}
return vec2(A, B) / float(SAMPLE_COUNT);
}
void main()
{
FragColor = integrateBRDF(texCoords.x, texCoords.y);
}
LUT 的尺寸 512×512 即可满足精度,渲染到 GL_RG16F 纹理(两个分量存 scale 与 bias)。
5. 组合采样
运行时,PBR 的环境光由三部分查表组成:
// 环境光:漫反射 IBL + 高光 IBL
#version 330 core
uniform samplerCube irradianceMap; // 辐照度图(漫反射)
uniform samplerCube prefilterMap; // 预滤波环境贴图(高光)
uniform sampler2D brdfLUT; // BRDF 查找表
uniform vec3 albedo;
uniform float metallic;
uniform float roughness;
uniform vec3 viewPos;
in vec3 FragPos;
in vec3 Normal;
const float MAX_REFLECTION_LOD = 4.0; // 预滤波 mip 层数 - 1
// 菲涅尔项(与 PBR 一致)
vec3 fresnelSchlick(float cosTheta, vec3 F0) { /* ... */ }
void main()
{
vec3 N = normalize(Normal);
vec3 V = normalize(viewPos - FragPos);
vec3 R = reflect(-V, N); // 反射方向
vec3 F0 = mix(vec3(0.04), albedo, metallic);
vec3 F = fresnelSchlick(max(dot(N, V), 0.0), F0);
vec3 kS = F;
vec3 kD = (1.0 - kS) * (1.0 - metallic);
// 漫反射:辐照度图按法线采样,一次采样即完成半球积分
vec3 irradiance = texture(irradianceMap, N).rgb;
vec3 diffuse = irradiance * albedo;
// 高光:按反射方向 + 粗糙度采样预滤波 mip,再乘 LUT 查表结果
vec3 prefilteredColor = textureLod(prefilterMap, R, roughness * MAX_REFLECTION_LOD).rgb;
vec2 brdf = texture(brdfLUT, vec2(max(dot(N, V), 0.0), roughness)).rg;
vec3 specular = prefilteredColor * (F0 * brdf.x + brdf.y);
vec3 ambient = kD * diffuse + specular;
// 与直接光照 Lo 相加输出……
}
对照三张纹理看它们各承担渲染方程(渲染方程与重要性采样)的哪一部分:
| 纹理 | 预计算内容 | 采样方式 | 替代的积分 |
|---|---|---|---|
| 辐照度图 | 余弦卷积后的环境光 | 按法线 采样 | 漫反射半球积分 |
| 预滤波贴图 | 按粗糙度模糊的 mipmap 链 | 按反射方向 + 粗糙度采样 | 高光积分的环境光部分 |
| BRDF LUT | 菲涅尔积分的 (scale, bias) | 按 + 粗糙度采样 | 高光积分的 BRDF 部分 |
注:至此 PBR 的最后一块拼图完成——金属反射出环境的颜色,粗糙金属的反射是模糊的,掠射角有菲涅尔增强。整条链路的理论根基是 渲染方程与重要性采样 的蒙特卡洛与重要性采样:预计算卷积本身就是用重要性采样做蒙特卡洛积分,只是把昂贵的采样挪到了初始化阶段。



