PBR 光照、Cook-Torrance 与线性 HDR 输出
PBR 光照、Cook-Torrance 与线性 HDR 输出:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。
学习目标
- 能改出一个 PBR 球的粗糙度和金属度效果:在片段着色器里实现完整的 Cook-Torrance BRDF(D/G/F 三项 + 漫反射 + 镜面反射),拖 roughness 和 metallic 两个滑块看高光和颜色变化
- 能实现并说出逐片段 PBR 光照循环的完整流程:对每盏点光源算辐射度 → 算 D/G/F → 组合 Cook-Torrance 镜面项 → 累加 Lo → 加环境光 → Reinhard 色调映射 → gamma 校正
- 能回答:如果你在 sRGB 空间(而不是线性空间)做了光照计算,渲染出来的球会有什么问题?色调映射那一步在防止什么?
为什么公式要在线性 HDR 中亮起来
上一章把物理规律拆成了公式——什么样的表面反多少光、从哪个角度更亮。但公式写在纸上不会发光。这一章要做的就是:把纸上的公式一行一行翻译成着色器代码,让 GPU 真正算出每个像素该多亮多暗。
翻译的过程并不复杂:上一章推了一个积分,但真实场景里光源数量有限(比如 4 盏灯),积分变成了简单的循环求和——每盏灯算一遍贡献,加起来就行。难点在于把每一项写对、不漏、顺序不错。
写完之后你将得到一个可以拖参数的 PBR 球:拖粗糙度,高光从小而亮变成大而暗;拖金属度,漫反射消失、高光染上颜色——上一章的公式全部变成了肉眼可见的画面。
从积分到循环:直接光照的离散化
上一章的反射率方程是一个半球积分——把所有方向的入射光贡献积起来。但在↡直接光照(Direct Lighting):光源直接照到表面的光照计算方式。与环境光(ambient)和间接光(IBL/GI)相对。直接光照中,每盏光源单独计算辐射度和 BRDF 贡献,再循环求和。场景中,光源数量是有限的(比如 4 盏点光源),半球上绝大部分方向没有光。于是积分退化成一个简单的求和:
对每盏灯循环:算出这盏灯打过来的辐射度,乘以 BRDF 和余弦项,累加到出射辐射度 Lo 上。
点光源辐射度:光强除以距离的平方
一盏↡点光源(Point Light):从一个点向所有方向均匀发光的理想化光源。辐射度随距离平方反比衰减:radiance = lightColor / distance^2。距离翻倍,亮度降到四分之一。朝所有方向均匀发光,到达表面某点的辐射度取决于两件事:光有多亮(lightColor)和离多远(distance)。距离翻倍,光照面积扩大四倍,单位面积能量降到四分之一——这就是↡平方反比衰减(Inverse-Square Attenuation):物理学定律——点光源的辐射度与距离的平方成反比。attenuation = 1 / distance^2。PBR 采用物理正确的平方反比衰减,而非旧式光照的线性/二次多项式衰减。:
radiance = lightColor * (1.0 / (distance * distance))
线性空间与 HDR 管线
PBR 要求所有光照运算在线性空间中进行。线性空间意味着数值翻倍 = 亮度翻倍,物理上才算对。但光源辐射度可以很大(比如 150.0),累加后颜色值远超 1.0——这就是 ↡HDR(High Dynamic Range,高动态范围):颜色值允许超过 1.0 的渲染方式。PBR 场景中光源辐射度通常很大,累积后的 Lo 可能远超 1.0。最后通过色调映射(tone mapping)把高动态范围压缩到显示器能显示的 0~1 范围。。最后用↡色调映射(Tone Mapping):把 HDR 高动态范围的颜色值压缩到 0~1 的低动态范围,以便在显示器上正确显示。常用算法:Reinhard(c / (c + 1)),简单有效。还有 ACES、Uncharted 2 等更高级的映射曲线。把 HDR 压缩到 0~1,再做 gamma 校正输出到屏幕:
单步确认:从材质参数到像素输出
把一大段 PBR 着色器拆成三段检查,就能定位绝大多数“像塑料、发灰或过曝”的问题。
先定金属与非金属的能量分配
F0 决定正看时的镜面基础反射率;kD = (1-kS)*(1-metallic) 确保金属不再错误地产生漫反射。
动手:拖粗糙度和金属度,看 PBR 球怎么变
猜一猜:把粗糙度从 0.05 一路拉到 1.0,球面的高光会发生什么变化?再把金属度从 0 拉到 1,球的颜色来源会从哪儿变到哪儿?先动手拖一拖,再往下看代码解释。
实时演示加载中…
拖完之后你应该看到:
- 粗糙度 0.05:球面几乎像镜子,4 个高光点极小极亮
- 粗糙度 1.0:高光散成一大片,球面看起来像粉笔
- 金属度 0:球是红色的(有漫反射),高光接近白色
- 金属度 1:红色消失(漫反射被压到零),高光变成红色(F0 = albedo)
代码逐段拆解
这一节把上面 ShaderDemo 里的 GLSL 逐段拆开讲。D/G/F 三个函数在上一章已推导过,这里只点出「公式→代码」翻译中的关键细节;重点放在光照循环的组装逻辑和后处理管线。
D / G / F 三函数回顾
// ======== D:GGX NDF ========
// roughness 先平方一次 → alpha(业界约定)
// alpha 再平方 → a2,代入公式
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;
denom = PI * denom * denom;
return a2 / denom;
}
// ======== G:Smith-Schlick-GGX ========
// 直接光照 k = (roughness+1)^2 / 8
float GeometrySchlickGGX(float NdotV, float k) {
return NdotV / (NdotV * (1.0 - k) + k);
}
float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness) {
float r = roughness + 1.0;
float k = (r * r) / 8.0;
return GeometrySchlickGGX(max(dot(N,V),0.0), k)
* GeometrySchlickGGX(max(dot(N,L),0.0), k);
}
// ======== F:Fresnel-Schlick ========
// cosTheta = dot(H, V);clamp 防负值;返回 vec3(金属 F0 有色)
vec3 fresnelSchlick(float cosTheta, vec3 F0) {
return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}这三个函数和上一章的公式一一对应。唯一需要注意的是 DistributionGGX 里 roughness 被平方了两次(先 a = roughness * roughness,再 a2 = a * a)——这是业界约定,让美术调粗糙度时手感更线性。
材质参数与 F0
vec3 albedo = vec3(0.8, 0.2, 0.2); // 反照率颜色(红色)
float roughness = uRoughness; // 从控件读入
float metallic = uMetallic; // 从控件读入
// F0:非金属固定 0.04,金属用 albedo 颜色
vec3 F0 = mix(vec3(0.04), albedo, metallic);mix(vec3(0.04), albedo, metallic) 是一行关键代码:当 metallic = 0 时 F0 = 0.04(电介质通用值);当 metallic = 1 时 F0 = albedo 颜色(金属的基础反射率就是它的颜色)。中间值做线性插值。
逐光源循环(核心)
vec3 Lo = vec3(0.0);
for (int i = 0; i < 4; i++) {
// ① 方向和半程向量
vec3 L = normalize(lightPositions[i] - P);
vec3 H = normalize(V + L);
// ② 点光源辐射度 = 光色 × 平方反比衰减
float dist = length(lightPositions[i] - P);
float attenuation = 1.0 / (dist * dist);
vec3 radiance = lightColors[i] * attenuation;
// ③ Cook-Torrance 三项
float D = DistributionGGX(N, H, roughness);
float G = GeometrySmith(N, V, L, roughness);
vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0);
// ④ 镜面项 = DGF / (4 · NdotV · NdotL)
vec3 numerator = D * G * F;
float denominator = 4.0 * max(dot(N,V),0.0) * max(dot(N,L),0.0) + 0.0001;
vec3 specular = numerator / denominator;
// ⑤ 能量守恒:kS 来自 Fresnel,kD = (1-kS)·(1-metallic)
vec3 kS = F;
vec3 kD = (1.0 - kS) * (1.0 - metallic);
// ⑥ 漫反射 + 镜面反射,乘辐射度和余弦项,累加
float NdotL = max(dot(N, L), 0.0);
Lo += (kD * albedo / PI + specular) * radiance * NdotL;
}逐行对照:
- L 和 H:光线方向和半程向量,和上一章定义一致
- 辐射度:
lightColors[i]值很大(150.0),乘以衰减后才变合理——这就是 HDR - D/G/F:分别调上面三个函数,输入都是标准的 N、H、V、L、roughness、F0
- 镜面项分母:
4 * NdotV * NdotL是 Cook-Torrance 的归一化修正。+ 0.0001防止分母为零导致黑点(误区之一) - 能量守恒:
kS = F(Fresnel 给出反射占比),kD = (1 - kS) * (1 - metallic)。乘以(1 - metallic)确保金属的漫反射为零 - 累加:
kD * albedo / PI是 Lambertian 漫反射;specular是 Cook-Torrance 镜面;两者加起来乘辐射度和余弦项,就是这盏灯的贡献
后处理:环境光 + 色调映射 + Gamma
// 简易环境光(后续 IBL 篇会替换成基于图像的环境光照)
vec3 ambient = vec3(0.03) * albedo;
vec3 color = ambient + Lo;
// Reinhard 色调映射:把 HDR 压缩到 [0,1]
color = color / (color + vec3(1.0));
// Gamma 校正:线性空间 → sRGB
color = pow(color, vec3(1.0 / 2.2));
FragColor = vec4(color, 1.0);三步后处理缺一不可:
- 环境光:
0.03 * albedo是一个极简近似,让球的暗面不至于纯黑。后续 IBL 篇会换成真正的基于图像的环境光 - Reinhard 色调映射:
c / (c + 1)把无穷大的 HDR 值压缩到 0~1。值越大越接近 1 但永远到不了——保留了亮部细节又不会爆白 - Gamma 校正:
pow(c, 1/2.2)把线性空间的值转换到 sRGB 显示空间。显示器硬件会做一个 2.2 次方的「反转」,两次抵消后你看到的才是物理正确的亮度
常见误区
小结与练习
这一章把上一篇的 Cook-Torrance 反射率方程从纸上搬到了 GLSL 里:D/G/F 三个函数直接翻译公式,逐光源循环累加 Lo,加上环境光后经 Reinhard 色调映射 + gamma 校正输出。你现在有了一个可拖参数的 PBR 球——粗糙度控制高光形状,金属度控制颜色来源和漫反射有无。下一章将用纹理贴图替代这些 uniform 标量,让同一个球上不同位置有不同材质。
练习
实现题
- 把上面 ShaderDemo 的
albedo从代码里的vec3(0.8, 0.2, 0.2)(红色)改成vec3(1.0, 0.86, 0.57)(金色),同时把metallic默认值设为 1.0,观察效果。说一说为什么金属球的高光是金色而不是白色——是哪行代码导致的?
- 试着把色调映射那一行
color = color / (color + vec3(1.0))注释掉(直接输出 HDR 值经 gamma 后的结果),观察球面亮部发生了什么。再把 gamma 校正也注释掉,看暗部有什么变化。
- 把 4 盏灯的颜色从全白
vec3(150.0)改成不同颜色(如红、绿、蓝、黄各 150.0),观察球面上的高光如何混色。解释为什么 PBR 能自然混色而不用你手动调——背后的数学原理是什么?
概念题
- Cook-Torrance 分母
4 * NdotV * NdotL + 0.0001中,为什么需要同时出现NdotV(观察方向余弦)和NdotL(光线方向余弦)两项?如果只有其中一个会怎样?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 直接光照
Direct Lighting:光源直接照到表面的光照方式。每盏光源独立计算辐射度和 BRDF 贡献,循环求和。与 IBL(基于图像的光照)/ GI(全局光照)相对。
- 点光源
Point Light:从一个点向所有方向均匀发光的理想化光源。辐射度按平方反比衰减:radiance = lightColor / distance^2。
- 平方反比衰减
Inverse-Square Attenuation:点光源辐射度与距离的平方成反比。距离翻倍,亮度降到四分之一。PBR 使用物理正确的平方反比,而非旧式线性/二次多项式衰减。
- HDR
High Dynamic Range(高动态范围):颜色值允许超过 1.0。PBR 场景中光源辐射度通常很大,累积后 Lo 可能远超 1.0,需通过色调映射压缩到 0~1 才能显示。
- 色调映射
Tone Mapping:把 HDR 颜色值压缩到显示器能显示的 0~1 范围。Reinhard 映射(c / (c + 1))简单有效;ACES、Uncharted 2 等提供更精细的对比度控制。
- Gamma 校正
Gamma Correction:在线性空间完成光照计算后,通过 pow(c, 1/2.2) 将值转换到 sRGB 显示空间。显示器硬件做 2.2 次方的反转,两次抵消后人眼看到物理正确的亮度。
版本、来源与运行边界
本章以 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 结果的验证。
正式概念与状态责任
- reflectance equation:在“PBR 光照、Cook-Torrance 与线性 HDR 输出”中由PBR fragment shader、点光数组与线性 HDR target负责解释其输入、受控状态和可观察结果;运行时以材质参数、逐灯 radiance、D/G/F、Lo HDR 值、finite 检查与 tone-map 像素定位它的第一处变化。
- metallic:在“PBR 光照、Cook-Torrance 与线性 HDR 输出”中由PBR fragment shader、点光数组与线性 HDR target负责解释其输入、受控状态和可观察结果;运行时以材质参数、逐灯 radiance、D/G/F、Lo HDR 值、finite 检查与 tone-map 像素定位它的第一处变化。
- roughness:在“PBR 光照、Cook-Torrance 与线性 HDR 输出”中由PBR fragment shader、点光数组与线性 HDR target负责解释其输入、受控状态和可观察结果;运行时以材质参数、逐灯 radiance、D/G/F、Lo HDR 值、finite 检查与 tone-map 像素定位它的第一处变化。
- radiance:在“PBR 光照、Cook-Torrance 与线性 HDR 输出”中由PBR fragment shader、点光数组与线性 HDR target负责解释其输入、受控状态和可观察结果;运行时以材质参数、逐灯 radiance、D/G/F、Lo HDR 值、finite 检查与 tone-map 像素定位它的第一处变化。
章专属 OpenGL 状态实验
先预测“逐灯求 inverse-square radiance 与 BRDF,按 NdotL 累加 Lo,再 tone map”发生后,PBR fragment shader、点光数组与线性 HDR target应怎样改变albedo/metallic/roughness/ao、light radiance、NdotL、BRDF、Lo 和输出编码;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。
实验一:Context—资源—结果合同
选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。
Context · resource · observable result
PBR 光照、Cook-Torrance 与线性 HDR 输出:状态合同
把反射率方程离散为多点光循环,在线性 HDR 中计算 radiance 与 Cook-Torrance
验证场景
官方教程正式概念
logl-40 · 基线帧
reflectance equation:固定 context、资源内容与输入事件,执行“逐灯求 inverse-square radiance 与 BRDF,按 NdotL 累加 Lo,再 tone map”
冻结输入:reflectance equation
PBR fragment shader、点光数组与线性 HDR target记录albedo/metallic/roughness/ao、light radiance、NdotL、BRDF、Lo 和输出编码
结果:得到可重复的初始 GL 状态与资源身份
观测:材质参数、逐灯 radiance、D/G/F、Lo HDR 值、finite 检查与 tone-map 像素中的初始快照
预期:PBR fragment shader、点光数组与线性 HDR target得到可复查结果,并持续满足“albedo 在进入光照前线性化;roughness 保持有效下界;输出只编码一次”
实验二:CPU 命令到 GPU 结果的五段轨迹
逐段执行“逐灯求 inverse-square radiance 与 BRDF,按 NdotL 累加 Lo,再 tone map”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“albedo 在进入光照前线性化;roughness 保持有效下界;输出只编码一次”。
CPU command · GL state · GPU result
PBR 光照、Cook-Torrance 与线性 HDR 输出:五段轨迹
当前观测:材质参数、逐灯 radiance、D/G/F、Lo HDR 值、finite 检查与 tone-map 像素中的初始快照
不变量:albedo 在进入光照前线性化;roughness 保持有效下界;输出只编码一次
实验三:单故障与同输入恢复
注入“roughness 允许精确为 0,D/G 分母在对齐方向产生 Inf/NaN 像素”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有材质参数、逐灯 radiance、D/G/F、Lo HDR 值、finite 检查与 tone-map 像素一起恢复才算修复。
Single fault · first divergence · replay
PBR 光照、Cook-Torrance 与线性 HDR 输出:反例与恢复
故障:roughness 允许精确为 0,D/G 分母在对齐方向产生 Inf/NaN 像素
第 1 次沿用同一 context、资源、uniform 与 draw 输入
保持其余输入不变,仅注入“roughness 允许精确为 0,D/G 分母在对齐方向产生 Inf/NaN 像素”
albedo 在进入光照前线性化;roughness 保持有效下界;输出只编码一次
材质参数、逐灯 radiance、D/G/F、Lo HDR 值、finite 检查与 tone-map 像素
最小可重放检查
unit: logl-40
owner: PBR fragment shader、点光数组与线性 HDR target
state_or_resource: albedo/metallic/roughness/ao、light radiance、NdotL、BRDF、Lo 和输出编码
command: 逐灯求 inverse-square radiance 与 BRDF,按 NdotL 累加 Lo,再 tone map
pass_invariant: albedo 在进入光照前线性化;roughness 保持有效下界;输出只编码一次
single_fault: roughness 允许精确为 0,D/G 分母在对齐方向产生 Inf/NaN 像素
required_evidence: 材质参数、逐灯 radiance、D/G/F、Lo HDR 值、finite 检查与 tone-map 像素复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。