IBL 漫反射辐照、环境卷积与法线采样

IBL 漫反射辐照、环境卷积与法线采样:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。

学习目标

  • 能实现并改出 PBR 球的环境光照方式:把硬编码的 vec3(0.03) 常量替换成基于环境贴图的漫反射 IBL,让球从环境中接收真实的漫反射光
  • 能说出 IBL 漫反射的完整预计算管线:加载 HDR → 等距柱状转立方体贴图 → 半球卷积得到辐照度贴图 → 着色器用法线采样
  • 能回答:为什么辐照度贴图只需要 32×32 这么低的分辨率就够用?卷积过程中 cos(θ)×sin(θ) 权重分别在补偿什么?

为什么暗面不该漆黑

上一章把 PBR 球渲出来了——但拨开那四盏点光源,球的暗面是一片死黑,只靠 vec3(0.03) * albedo 这个硬编码常量打点底光。现实里的暗面从不是死黑的:墙上、地板、天空都在往那里补光。

没有环境光的 PBR,就像把球放进一个漆黑的房间里只用手电筒照。手电直射的那面很亮,背光的那面漆黑。你知道这不对——房间的墙和地面会把光弹来弹去,把暗面也照亮。

这一章要做的就是:把一张环境照片「包」在场景外面当全方向光源,让球从四面八方收到来自环境的真实漫反射光。

IBL:环境贴图作为光源

的核心思想很简单:不用 4 盏灯,而是用一张全景照片。照片上的每个像素都代表一个方向的光——蓝天的像素是蓝色入射光,草地的像素是绿棕色入射光,灯光区域的像素亮度极高。

IBL:环境贴图 = 全方向光源环境贴图(天空、建筑、地面…)场景对象每个方向都是一个光源 → 无穷盏传统4 盏点光源IBL全方向环境光
IBL 把一张环境贴图当作光源:贴图上的每个像素都是一个方向的入射光,球从 四面八方收到来自环境的光照,而不只是来自几盏离散点光源。

这样一张照片要能「包住」整个场景,就需要一种能表示所有方向的投影格式。

HDR 与等距柱状投影

真实世界的光照动态范围极大——太阳直射可以是阴影区亮度的几千倍。普通 8bit/通道的 JPG 把这一切压成 0~1,高光全爆白。用浮点数存储,让太阳和阴影的亮度差真实保留下来,这样卷积出的环境光才能正确反映明暗对比。

常用格式是(.hdr 文件宽高比 2:1)——把球面展开成一张矩形,左右两端是接缝,上下是南北极。

等距柱状投影 → 立方体贴图等距柱状投影 HDR (.hdr)宽:高 = 2:1(经纬展开)SampleSphericalMap渲染立方体内表面+Y-X+Z+X-Z-Y立方体贴图(6 面展开)SampleSphericalMap 公式uv = vec2(atan(dir.z, dir.x), asin(dir.y)) * vec2(1/2π, 1/π) + 0.5方位角 atan → U;仰角 asin → V;结果映射到 [0,1]
SampleSphericalMap 把球面方向转成等距柱状 UV——用 atan 计算方位角(经度)、asin 计算仰角(纬度),归一化到 [0,1],就能从 2:1 HDR 贴图正确采样。渲染立方体六面内表面时,逐片段调用此函数,把片段方向转成 UV 采样,便得到立方体贴图各面。

这张贴图不能直接拿来用——GPU 更擅长采样(Cubemap)。所以第一步是把等距柱状 HDR 转成 Cubemap:对立方体六个面各渲一遍,逐片段用方向向量调用 SampleSphericalMap 从 HDR 贴图采样。

辐照度卷积

拿到立方体环境贴图之后,漫反射 IBL 需要预计算每个法线方向的——即对以法线 N 为轴的半球,把所有方向的环境光加权积分起来。

辐照度卷积:半球采样加权求和表面N法线方向着色点 Pθ卷积权重分解cos(θ) — 朗伯余弦律正对法线 θ=0° 贡献最强sin(θ) — 雅可比行列式补偿球面均匀采样面积Σ 黎曼和 → 辐照度32×32 辐照度贴图已足够(低频信号,高分辨率冗余)辐照度卷积公式(黎曼求和近似)irradiance(N) ≈ π × Σ Li(ωi) × cos(θi) × sin(θi) × dφ × dθ对半球所有采样方向 ωi 加权求和,得到法线 N 方向接收到的漫反射光总量
辐照度卷积:对着色点法线 N 朝向的半球,均匀采样所有方向 ωi,每个方向的环境光 Li(ωi) 乘以 cos(θ)(朗伯余弦律)和 sin(θ)(面积雅可比)加权后求和。低频漫反射结果只需 32×32 辐照度贴图存储。

这个积分对每个法线方向都做一遍,结果存进一张 32×32 的辐照度贴图。32×32 就够,因为漫反射 BRDF 对高频细节不敏感——不同法线方向的辐照度变化很平滑,高分辨率是浪费。

单步确认:从 HDR 环境到漫反射 IBL

分步1 / 3

把 HDR 全景转成 cubemap

等距柱状图适合存储,但运行时的方向查询更适合 cubemap:用六个 90 度捕获视图把 HDR 环境重投影到六个面,并保持浮点亮度。

动手:开关 IBL,看环境光怎么点亮暗面

猜一猜:把「IBL 环境光」滑块拖到 0(关掉),球的暗面会变成什么颜色?再拖到 1(开启),暗面的光从哪里来?拖一拖粗糙度,观察 IBL 与粗糙度如何相互影响。

可交互

实时演示加载中…

拖完之后你应该看到:

  • IBL=1(开启):背景变成蓝天→地面渐变,球的暗面收到来自天空和地面颜色的环境光——上方暗面偏蓝,下方暗面偏棕
  • IBL=0(关闭):背景变暗色,暗面退回死黑的 0.03 常量,高光孤立在漆黑球上,明显不自然
  • 粗糙度拉高:IBL 环境光在粗糙球上散得更匀,暗面更「饱满」;光滑球的 IBL 影响集中在法线朝上的区域
  • 金属度拉高kD_ibl 被压到零,漫反射 IBL 消失——金属表面没有漫反射

代码逐段拆解

这一节把 ShaderDemo 里 IBL 相关的代码从整体着色器中抽出来逐段讲。直接光照部分(D/G/F + 逐光源循环)与上一章完全相同,这里重点放在四个 IBL 新增部分。

a) SampleSphericalMap:等距柱状 UV 转换

在实际项目里,把 HDR 等距柱状投影转成立方体贴图,需要对立方体六个面各渲一遍,每个片段调用:

// 把归一化方向向量转成等距柱状贴图 UV
// 输入: v = normalize(localPos)(立方体片段的本地空间方向)
// 输出: UV ∈ [0,1]
vec2 SampleSphericalMap(vec3 v) {
    // atan(z,x): 方位角 (-π, π),除以 2π → (-0.5, 0.5)
    // asin(y):   仰角 (-π/2, π/2),除以 π → (-0.5, 0.5)
    // + 0.5 → (0, 1)
    const vec2 invAtan = vec2(0.1591, 0.3183); // 1/2π, 1/π
    vec2 uv = vec2(atan(v.z, v.x), asin(v.y));
    uv *= invAtan;
    uv += 0.5;
    return uv;
}

两个反三角函数分别对应经度和纬度:atan(z, x) 算方位角(球面水平位置),asin(y) 算仰角(球面垂直位置)。乘以 invAtan 把范围从 (-π, π)/(−π/2, π/2) 归一化到 (-0.5, 0.5),再 + 0.5 移到 (0, 1)。

b) 辐照度卷积着色器:半球采样

这是预计算步骤(离线渲染辐照度贴图),不在每帧执行:

// irradiance convolution fragment shader
// 对当前面片法线方向 N 的上半球离散采样,加权求和
void main() {
    vec3 N = normalize(localPos); // 当前立方体面上的法线
    // 构建切线空间(以 N 为 up 的局部坐标系)
    vec3 up    = abs(N.y) < 0.999 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);
    vec3 right = normalize(cross(up, N));
    up         = normalize(cross(N, right));
    // 双重循环:经度 φ ∈ [0, 2π],纬度 θ ∈ [0, π/2]
    vec3 irradiance = vec3(0.0);
    float sampleDelta = 0.025;
    float nrSamples = 0.0;
    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;
            // cos(θ): 朗伯余弦律;sin(θ): 球面雅可比(面积权重)
            irradiance += texture(environmentMap, sampleVec).rgb * cos(theta) * sin(theta);
            nrSamples++;
        }
    }
    irradiance = PI * irradiance / nrSamples;
    FragColor = vec4(irradiance, 1.0);
}

双重循环遍历半球上所有方向:外层 φ 是方位角(绕 N 轴转一圈),内层 θ 是仰角(从顶部到赤道)。cos(theta) 是朗伯余弦律——正对法线的方向贡献最大;sin(theta) 是球面面积的雅可比补偿——越靠近赤道,同样角度增量对应的球面面积越大。最后乘以 π 是半球积分公式的归一化常量。

c) fresnelSchlickRoughness:IBL 专用 Fresnel

// IBL 版 Fresnel:max(vec3(1-roughness), F0) 替代 vec3(1.0)
// 粗糙面在掠射角不应达到全反射;用 roughness 压低上限
vec3 fresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness) {
    return F0 + (max(vec3(1.0 - roughness), F0) - F0)
              * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

与直接光照的 fresnelSchlick 相比,只有一处不同:(1.0 - F0) 变成了 (max(vec3(1.0 - roughness), F0) - F0)。当粗糙度为 0,退化成普通菲涅尔;当粗糙度为 1,反射率上限被压到 F0,掠射角不再趋近全反射——符合粗糙表面的物理行为。

IBL 里用这个变体而非普通 fresnelSchlick,是因为 IBL 输入的 cosThetadot(N, V)(而不是 dot(H, V)),直接看法线时 cos 值最大,普通 Fresnel 在此处返回 F0,不会出问题;但掠射角 dot(N,V)≈0 时普通 Fresnel 会把粗糙面的 kS 推得很高,导致漫反射 kD 被压得过低,画面偏暗且不物理正确。

d) 漫反射 IBL 合并:替换 0.03 常量

// IBL 漫反射:用粗糙度修正的 Fresnel 计算 kD_ibl
vec3 kS_ibl = fresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness);
vec3 kD_ibl = (1.0 - kS_ibl) * (1.0 - metallic);
// 从辐照度贴图采样(此处用程序化辐照度模拟)
vec3 irradiance = proceduralIrradiance(N);
vec3 diffuseIBL = irradiance * albedo;
// 最终环境光 = kD_ibl × 辐照度 × albedo
vec3 ambient = kD_ibl * diffuseIBL;
// (上一章:vec3 ambient = vec3(0.03) * albedo;)
vec3 color = ambient + Lo;

关键变化只有两行:

  1. irradiance = texture(irradianceMap, N).rgb 从辐照度贴图采样(Demo 里用程序化模拟)——输入是法线方向,返回该法线方向接收到的漫反射光总量
  2. ambient = kD_ibl * irradiance * albedo——比上一章多了 kD_ibl 能量守恒因子,防止反射率(kS)和漫反射率(kD)之和超过 1

vec3(0.03) * albedo 被完全取代,球的暗面不再是死黑,而是从环境中接收到各方向的漫反射光。

常见误区

小结与练习

这一章把上一章的 vec3(0.03) * albedo 常量环境光替换成了真实的漫反射 IBL:加载 HDR 等距柱状贴图 → 用 SampleSphericalMap 转成 Cubemap → 半球卷积积分出 32×32 辐照度贴图 → PBR 着色器用法线采样辐照度 × kD_ibl 得到真实漫反射环境光。球的暗面不再是死黑,而是从各方向收到来自环境的漫反射光。

  • 预计算一次辐照度贴图,运行时只需一次贴图采样
  • fresnelSchlickRoughness 是 IBL 版专用 Fresnel,用 roughness 压低掠射角上限
  • 辐照度只需 32×32,因为漫反射是低频信号
  • 下一章将处理镜面 IBL(预滤波环境贴图 + BRDF LUT),让高光也从环境中取光

练习

实现题

  1. 在 Demo 的 proceduralIrradiance 函数里,把天空颜色从蓝色 vec3(0.4, 0.6, 1.0) 改成红橙色日落 vec3(1.0, 0.4, 0.2),地面颜色改成深绿色 vec3(0.1, 0.3, 0.1)。观察球的暗面颜色如何随法线方向变化——朝上的区域和朝下的区域分别收到什么颜色的 IBL?
  1. 把 IBL 那行 vec3 kD_ibl = (1.0 - kS_ibl) * (1.0 - metallic) 简化成 vec3 kD_ibl = vec3(1.0 - metallic)(去掉 (1.0 - kS_ibl) 能量守恒因子),观察非金属球面的高光边缘有什么变化。

概念题

  1. 辐照度卷积的双重循环(φ 方位角 × θ 仰角)使用均匀步长采样。球面上越靠近赤道,每个角度增量对应的球面积越大,越靠近极点越小。不加 sin(theta) 权重的话,极点附近的采样会被过度计数。解释为什么 sin(theta) 能修正这个偏差:当 θ 趋近 90°(赤道方向),sin 值接近 1(权重大,覆盖面积大);当 θ 趋近 0°(极点方向),sin 值接近 0(权重小,覆盖面积小)。这个补偿因子在数学上的来源是什么?
  1. 为什么辐照度贴图可以低至 32×32 分辨率?如果把辐照度直接从原始 2K HDR 环境贴图逐像素计算(不预计算),每帧的开销是多少?

名词解释

本章出现的专业名词,用大白话再讲一遍。

IBL

Image-Based Lighting(基于图像的光照):用一张(或多张)图像代替离散光源,把图像上每个像素都当作一个方向的入射光,从而捕捉真实世界的复杂光照环境。分漫反射 IBL(辐照度贴图)和镜面 IBL(预滤波环境贴图 + BRDF LUT)两部分。

HDR 环境贴图

High Dynamic Range 环境贴图:使用浮点数(如 RGBE 或 float16)存储颜色值,允许超过 1.0 的亮度,真实保留高光与阴影的亮度比例。文件格式通常是 .hdr(RGBE)或 .exr(OpenEXR)。须用 GL_RGB16F 格式上传 GPU。

等距柱状投影

Equirectangular Projection:把球面展开成宽高比 2:1 的矩形。横轴是经度(0°~360°,方位角),纵轴是纬度(-90°~90°,仰角)。常见于 .hdr 全景贴图格式。

立方体贴图

Cubemap:由 6 张正方形纹理(+X/-X/+Y/-Y/+Z/-Z)拼成的立方体表面贴图。用一个 3D 方向向量采样,GPU 自动计算命中哪个面。支持 GPU 硬件滤波,全方向采样效率高。

辐照度

Irradiance:单位面积上从所有方向接收到的辐射通量总和(单位 W/m²)。漫反射 BRDF 只依赖法线方向的辐照度,而非入射方向细节,因此可以预计算成低分辨率(32×32)辐照度贴图。

辐照度卷积

Irradiance Convolution:对每个法线方向,在其朝向的上半球对环境光做加权积分,权重为 cos(θ)×sin(θ)。结果存成辐照度贴图,在 PBR 着色器里用法线采样替代常量环境光。

fresnelSchlickRoughness

IBL 专用 Fresnel 变体:把普通 Fresnel-Schlick 的反射率上限 vec3(1.0) 替换成 max(vec3(1-roughness), F0),用粗糙度压低掠射角时的最大反射率,防止粗糙面边缘 kS 趋近 1 导致漫反射 IBL 消失。

版本、来源与运行边界

本章以 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 结果的验证。

正式概念与状态责任

  • image based lighting:在“IBL 漫反射辐照、环境卷积与法线采样”中由capture FBO、environment cubemap、irradiance cubemap 与 PBR sampler负责解释其输入、受控状态和可观察结果;运行时以六面矩阵、HDR/cubemap 对照、样本权重、irradiance texel 与暗面像素定位它的第一处变化。
  • irradiance map:在“IBL 漫反射辐照、环境卷积与法线采样”中由capture FBO、environment cubemap、irradiance cubemap 与 PBR sampler负责解释其输入、受控状态和可观察结果;运行时以六面矩阵、HDR/cubemap 对照、样本权重、irradiance texel 与暗面像素定位它的第一处变化。
  • convolution:在“IBL 漫反射辐照、环境卷积与法线采样”中由capture FBO、environment cubemap、irradiance cubemap 与 PBR sampler负责解释其输入、受控状态和可观察结果;运行时以六面矩阵、HDR/cubemap 对照、样本权重、irradiance texel 与暗面像素定位它的第一处变化。
  • environment map:在“IBL 漫反射辐照、环境卷积与法线采样”中由capture FBO、environment cubemap、irradiance cubemap 与 PBR sampler负责解释其输入、受控状态和可观察结果;运行时以六面矩阵、HDR/cubemap 对照、样本权重、irradiance texel 与暗面像素定位它的第一处变化。

章专属 OpenGL 状态实验

先预测“捕获六面环境,按每个方向积分半球辐照度,再与 albedo/kD 组合”发生后,capture FBO、environment cubemap、irradiance cubemap 与 PBR sampler应怎样改变HDR 投影、六面 capture matrices、环境 texel、半球样本、卷积和 normal lookup;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。

实验一:Context—资源—结果合同

选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。

Context · resource · observable result

IBL 漫反射辐照、环境卷积与法线采样:状态合同

把 equirectangular HDR 转 cubemap,再卷积为按法线查询的 diffuse irradiance

验证场景

官方教程正式概念

logl-41 · 基线帧

image based lighting固定 context、资源内容与输入事件,执行“捕获六面环境,按每个方向积分半球辐照度,再与 albedo/kD 组合”

状态所有者capture FBO、environment cubemap、irradiance cubemap 与 PBR sampler
受控状态/资源HDR 投影、六面 capture matrices、环境 texel、半球样本、卷积和 normal lookup
触发命令捕获六面环境,按每个方向积分半球辐照度,再与 albedo/kD 组合

冻结输入:image based lighting

capture FBO、environment cubemap、irradiance cubemap 与 PBR sampler记录HDR 投影、六面 capture matrices、环境 texel、半球样本、卷积和 normal lookup

结果:得到可重复的初始 GL 状态与资源身份

观测:六面矩阵、HDR/cubemap 对照、样本权重、irradiance texel 与暗面像素中的初始快照

预期:capture FBO、environment cubemap、irradiance cubemap 与 PBR sampler得到可复查结果,并持续满足“六面 view/projection 朝向一致;卷积权重包含 cosθ·sinθ 与采样步长”

实验二:CPU 命令到 GPU 结果的五段轨迹

逐段执行“捕获六面环境,按每个方向积分半球辐照度,再与 albedo/kD 组合”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“六面 view/projection 朝向一致;卷积权重包含 cosθ·sinθ 与采样步长”。

CPU command · GL state · GPU result

IBL 漫反射辐照、环境卷积与法线采样:五段轨迹

选择一段命令—资源—结果1 / 5

当前观测:六面矩阵、HDR/cubemap 对照、样本权重、irradiance texel 与暗面像素中的初始快照

不变量:六面 view/projection 朝向一致;卷积权重包含 cosθ·sinθ 与采样步长

实验三:单故障与同输入恢复

注入“正 Y/负 Y capture view 的 up 向量写反,cubemap 顶底出现接缝和翻转”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有六面矩阵、HDR/cubemap 对照、样本权重、irradiance texel 与暗面像素一起恢复才算修复。

Single fault · first divergence · replay

IBL 漫反射辐照、环境卷积与法线采样:反例与恢复

故障:正 Y/负 Y capture view 的 up 向量写反,cubemap 顶底出现接缝和翻转

1. 冻结输入一致

第 1 次沿用同一 context、资源、uniform 与 draw 输入

2. 注入单故障一致

保持其余输入不变,仅注入“正 Y/负 Y capture view 的 up 向量写反,cubemap 顶底出现接缝和翻转”

3. 定位首差一致

六面 view/projection 朝向一致;卷积权重包含 cosθ·sinθ 与采样步长

4. 清理并重放一致

六面矩阵、HDR/cubemap 对照、样本权重、irradiance texel 与暗面像素

最小可重放检查

unit: logl-41
owner: capture FBO、environment cubemap、irradiance cubemap 与 PBR sampler
state_or_resource: HDR 投影、六面 capture matrices、环境 texel、半球样本、卷积和 normal lookup
command: 捕获六面环境,按每个方向积分半球辐照度,再与 albedo/kD 组合
pass_invariant: 六面 view/projection 朝向一致;卷积权重包含 cosθ·sinθ 与采样步长
single_fault: 正 Y/负 Y capture view 的 up 向量写反,cubemap 顶底出现接缝和翻转
required_evidence: 六面矩阵、HDR/cubemap 对照、样本权重、irradiance texel 与暗面像素

复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。

出处声明

本文为改编重写,改编自 Joey de Vries 的 LearnOpenGL。原文:learnopengl.com

原作及译作以 CC BY-NC 4.0 协议授权,本改编版同样遵循该协议(署名—非商业性使用)。

讨论

评论区加载中…