☰
从Blinn-Phong到Cook-Torrance:PBR核心BRDF与Shader实现解析
2026/10/5 5:20:58 网站建设 项目流程

“同样的材质球,白天看高光是糊的,晚上看高光是飘的,怎么调都像塑料……”这是我刚开始写渲染器时,用Blinn-Phong调材质的真实状态。后来咬牙把光照模型换成Cook-Torrance,问题一下子变得清晰:不是参数没调好,而是模型本身就不具备物理一致性。

PBR(Physically Based Rendering,基于物理的渲染)如今已经是游戏引擎和DCC工具里的默认方案,它的核心数学工具就是BRDF(Bidirectional Reflectance Distribution Function,双向反射分布函数)。很多人把PBR当成一套“调好的参数模板”,但在自己动手实现过Cook-Torrance模型之后,才会明白那些参数背后的物理含义,以及在Shader层面为什么非这么写不可。

这篇文章适合三类人:已经能写出Blinn-Phong高光、想理解PBR底层原理的人;正在照着教程敲代码但不知道D、G、F分别起什么作用的人;被金属度、粗糙度搞得头大,想搞清楚它们到底控制什么的人。我会从BRDF的辐射度量学基础讲起,拆开Cook-Torrance的每一项,给出一份能直接跑起来的GLSL实现,最后聊几个我在实际工程里踩过且网上很少说透的坑。

1. 为什么要放弃Blinn-Phong:一次真实渲染事故

1.1 那个怎么调都不对的金属高光

先说一段真实经历。某个项目里要渲染一个拉丝金属的机身,我最初用的是Blinn-Phong模型,shininess从8一路调到128,高光形状还是不对:光滑区域高光太亮,拉丝纹理对应的长条高光又完全出不来。更诡异的是旋转相机时,高光边缘会出现明显跳变,从一个像素突然亮到下一个像素。

这个问题的根源不在参数,而在Blinn-Phong本身。它的高光项是一个纯粹的数学构造,用半程向量和法线的点乘再乘一个指数,模拟出“中间亮、四周暗”的圆形光斑。这个指数shininess并没有物理单位,也不对应任何真实材质属性。你调出来的“像塑料”其实是这个指数模型的信息量上限决定的:它永远无法同时表现金属的强烈菲涅尔反射和粗糙表面的拖尾高光。

1.2 能量不守恒带来的连锁反应

Blinn-Phong还有一个更隐蔽的问题:能量不守恒。它的高光反射率可以大于入射能量,尤其在shininess比较低的时候,高光会亮得发白、发飘。在单方向上可能不明显,一旦叠加环境光、多个光源,或者HDR场景,材质表面就会泛着一层“死白”,像是蒙了层雾。

这种能量不守恒还会污染后续的色调映射。HDR渲染管线里,超过1的亮度会被ToneMapping压回来,Blinn-Phong导致的能量堆积会让高光大面积过曝,丢掉细节。我试过用曝光补偿去救,但相当于给所有材质乘一个变量,反而把材质之间的差异抹平了。这也是为什么后来大家宁愿牺牲一点计算性能,也要换一个物理上站得住脚的模型。

1.3 从经验模型到物理模型:PBR想解决什么

PBR不是某一个公式的功劳,而是一整套工作流的统称:基于物理的材质参数、基于物理的光照模型、线性空间的光照计算,以及配套的相机曝光和ToneMapping。Cook-Torrance模型是这套工作流里最核心的一环:它用微表面理论去描述“光打到材质表面后怎么反射”,而不是凭空捏一个高光形状。

我当时换掉Blinn-Phong之后,最直观的感受是:材质参数的可解释性变强了。金属度、粗糙度、基础颜色,每一个参数都能对应到真实的材质特征,调参不再靠猜。高光形状、菲涅尔边缘、掠射角暗边,这些现象都在物理约束下自动呈现出来。接下来要理解的,就是BRDF这个函数本身。

2. BRDF是什么:一个函数如何描述“光的命运”

2.1 先认识辐射度量学里两个容易卡住的概念

BRDF的全称是双向反射分布函数,它描述的是“一束光从某个方向打过来,有多少能量往另一个方向反射出去”。要读懂它的公式,得先分清辐射度量学里的两个概念:

  • 辐照度(Irradiance)E:单位面积上接收到的辐射通量,单位是W/m²。可以把它理解为“这个表面接受到多少光”。
  • 辐射亮度(Radiance)L:单位投影面积、单位立体角上的辐射通量,单位是W/(m²·sr)。可以把它理解为“从某个方向看过去,这个表面有多亮”。

用下雨来类比:辐照度就是每平方米地面接到了多少升雨水,辐射亮度则是你从某个倾斜角度望过去,单位投影面积里落下的雨量。区别在于辐射亮度考虑了方向和立体角,因此更接近人眼感知到的“亮度”。

2.2 BRDF的数学定义与单位直觉

BRDF的定义是出射辐射亮度与入射辐照度的比值:

f(l, v) = dL_o(v) / dE_i(l) = dL_o(v) / (L_i(l) * cos θ_i * dω_i)

其中:

  • l是入射光方向,v是视线方向;
  • θ_i是入射方向与表面法线N的夹角;
  • dω_i是入射方向对应的微分立体角。

这个公式读起来费劲,但它的直觉很朴素:分子是“从v方向看,表面反射了多少亮度”,分母是“从l方向打过来的光,贡献了多少辐照度”。两者相除,得到的是一个密度值,单位是sr⁻¹,不是0到1的百分比。

这也是初学者最容易懵的地方:BRDF的取值可以大于1,只要对半球所有出射方向积分后不超过1,能量就守恒。单个方向上的值再大,也只是说明能量集中往某个方向反射,比如镜面反射方向,并不会违反物理规律。

2.3 互易性与能量守恒:不是装饰,是底线

BRDF有两个必须满足的性质:

第一是互易性(Helmholtz Reciprocity):交换入射和出射方向,函数值不变,即f(l, v) = f(v, l)。这个性质看起来只是数学上的对称,实际上非常重要。路径追踪里的双向方法、实时渲染中的很多重要性采样策略,都依赖这个性质。如果自己写BRDF时打破了这个对称性,很多高级光照算法会出问题。

第二是能量守恒:所有出射方向的反射能量积分不能超过入射能量。实时渲染里,我们通常用一个简化约束:漫反射系数k_d和高光系数k_s相加的结果不能超过1。

这两个性质是PBR材质表现稳定的根本原因。Blinn-Phong不保证它们,所以无论怎么调都容易出现“物理上不存在”的光照效果。而Cook-Torrance模型在结构上就内置了这些约束。

3. Cook-Torrance模型全拆解

3.1 微表面理论:为什么能看到“粗糙的镜子”

Cook-Torrance模型建立在微表面理论之上。它假设宏观表面由无数个微小的完美镜面平面组成,每个微平面都按照菲涅尔定律反射光。表面粗糙,微平面法线的分布就越乱;表面光滑,微平面法线就越集中在宏观法线附近。

这个理论的价值在于:它用一个统一的机制解释了为什么同一块金属,光滑时能看到清晰的倒影,粗糙时高光会弥散开来。关键不在“表面是不是镜子”,而在“有多少微平面恰好把光反射到你的眼睛里”。

对于一束从l方向射来的光,只有法线方向恰好等于半程向量H = normalize(l + v)的微平面,才会把光反射到v方向。这个条件很严格,所以我们需要一套数学工具来回答:在某个粗糙度下,符合这个条件的微平面到底占多少比例?这就是法线分布函数D要干的事。

3.2 D项:法线分布函数与GGX

法线分布函数D(h)描述的是:微平面法线朝向h方向的概率密度。Cook-Torrance模型的高光项里,D决定了高光的形状和亮度分布。

实际渲染中最常用的两个选择是Beckmann和GGX(即Trowbridge-Reitz分布):

分布特点适用场景
Beckmann高斯形态,拖尾短,粗糙表面高光衰减较快打磨得很均匀的表面
GGX长拖尾,粗糙表面高光平滑衰减,更接近真实测量大多数材质,尤其是金属

GGX的公式如下:

D(h) = α² / (π * ((N·H)² * (α² - 1) + 1)²)

其中α = roughness²。这是很多初学者都会忽略的一个细节:引擎里的roughness参数不要直接塞进GGX的α,要先平方。原因在于GGX的α和美术感知上的粗糙度不是线性关系。roughness²这个映射,能让美术调参时变化更均匀,从光滑到粗糙的过渡更符合直觉。

另一个容易忽略的点:当roughness趋近0时,GGX的D项会趋近无穷大,因为所有微平面法线都集中到一个方向上。这本身符合物理,但代码里会出现NaN和巨大的数值抖动。所以实际实现中建议给roughness加一个下限,比如0.02到0.05。

3.3 G项:几何遮蔽如何拯救掠射角

几何函数G描述的是微表面之间的相互遮挡。分两种情况:视线方向看过去,部分微平面被其他微平面挡住,叫遮蔽(Masking);光线方向打过来,部分微平面被挡住,叫阴影(Shadowing)。

没有G项会怎样?在接近掠射角(视线和表面几乎平行)的时候,D项会急剧增加,高光会亮成一片,物体边缘像在发光。这个现象物理上不存在,因为微表面在倾斜视角下会严重互相遮挡,反射能量应该被压下去而不是涨上来。

我在实际项目里用的还是Smith模型的Schlick近似:

k = roughness² / 2(直接光)或者 k = (roughness + 1)² / 8(IBL)

G1(v) = (N·V) / ((N·V) * (1 - k) + k)

G(l, v) = G1(l) * G1(v)

这里直接光和IBL用不同的k系数,很多文章不会说清楚。如果你用IBL的系数去算直接光,掠射角高光会略微偏暗;反过来,直接光系数用在IBL上,环境光边缘会偏亮。理想做法是保持统一,要么都按驱动方式选择,要么都用一个中间值并接受轻微误差。

3.4 F项:菲涅尔与Schlick近似

Fresnel项描述的是:光在不同入射角下,表面反射率的差异。生活中的现象就是:站在湖边低头看,水面是透明的,能看到水底;抬头向远处看,水面变成了一面镜子。所有材质都有这个特性,只是明显程度不同。

非金属材质(电介质)的F0很低,通常围绕4%到8%,所以垂直看时高光很弱,但掠射角时反射率会逼近100%。金属导体的F0很高,而且是RGB三通道都不同的彩色值,这也是金属高光带有自身颜色的原因。

Schlick近似是实时渲染的标配:

F = F0 + (1 - F0) * (1 - V·H)⁵

有一个细节:V·H这里应该用clamp(dot(V, H), 0.0, 1.0),因为点乘可能因为浮点误差变成负数。另外,严格意义上Fresnel应该用V·H还是N·V?大多数PBR实现用V·H,因为半程向量H在微表面模型里才是微平面的法线,Fresnel发生在微平面反射瞬间,用V·H更接近物理。如果你用N·V,高光边缘的颜色会偏得比较厉害。

3.5 漫反射项:能量守恒约束下的Lambert

Cook-Torrance模型的完整形式是:

f = k_d * f_lambert + k_s * f_spec

其中漫反射项f_lambert = c / π,c是表面基础颜色(albedo)。

为什么要除以π?BRDF的单位是sr⁻¹,Lambert漫反射假设反射能量均匀分布到整个半球。对半球所有方向做积分,cos θ dω在半球上的积分就是π,所以除以π能保证垂直入射时反射率刚好等于albedo,满足能量守恒。

k_s通常等于Fresnel项的值,k_d则用(1 - F)来近似剩余的能量。但实际材质中,微表面之间的多次弹射也会贡献漫反射能量,单纯的(1 - F) * k_d会在粗糙金属上产生明显的暗化。Disney和Frostbite的做法是在漫反射项上加入粗糙度依赖,或者用multi-scatter补偿项。实时渲染中,一个成本很低的近似是:

k_d = (1.0 - F) * (1.0 - metallic)

这样金属的漫反射会被清零,非金属的漫反射则保留了Fresnel对能量的调节作用。虽然和离线渲染的精度有差距,但在性价比上非常划算。

4. 在Shader里落地Cook-Torrance

4.1 核心函数实现与防除零细节

下面是一份基于GLSL的实现,可以直接放进你自己的渲染器里。先实现三个核心函数:

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); denom = PI * denom * denom; return a2 / max(denom, 1e-6); } float GeometrySchlickGGX(float NdotV, float roughness) { float r = roughness + 1.0; float k = (r * r) / 8.0; return NdotV / (NdotV * (1.0 - k) + k); } float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness) { float NdotV = max(dot(N, V), 1e-4); float NdotL = max(dot(N, L), 1e-4); return GeometrySchlickGGX(NdotV, roughness) * GeometrySchlickGGX(NdotL, roughness); } vec3 FresnelSchlick(float cosTheta, vec3 F0) { return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0); }

代码里的注释都省略了,但有三个细节你必须知道:

第一,DistributionGGX的分母最后加了个max(denom, 1e-6),避免roughness极小时分母为0。第二,GeometrySmith里的NdotV和NdotL都用max(, 1e-4)钳制,防止除零,同时避免在边缘处产生无限大高光。第三,FresnelSchlick里的cosTheta用的是V·H,不是N·V,注释里要写清楚,否则过两个月回来看会怀疑自己写错了。

4.2 主光照循环:金属度工作流的完整代码

有了上面的基础函数,光照计算的主体如下:

vec3 CalculateLighting( vec3 N, vec3 V, vec3 L, vec3 lightColor, float lightIntensity, vec3 albedo, float metallic, float roughness) { vec3 radiance = lightColor * lightIntensity; vec3 H = normalize(V + L); float NdotL = max(dot(N, L), 0.0); if (NdotL <= 0.0) return vec3(0.0); float NdotV = max(dot(N, V), 1e-4); float VdotH = max(dot(V, H), 0.0); float NdotH = max(dot(N, H), 0.0); float D = DistributionGGX(N, H, roughness); float G = GeometrySmith(N, V, L, roughness); vec3 F0 = mix(vec3(0.04), albedo, metallic); vec3 F = FresnelSchlick(VdotH, F0); vec3 specular = (D * G * F) / max(4.0 * NdotV * NdotL, 1e-4); vec3 kS = F; vec3 kD = (1.0 - kS) * (1.0 - metallic); vec3 diffuse = kD * albedo / PI; return (diffuse + specular) * radiance * NdotL; }

这段代码里有几个点到为止但很重要的设计:

F0 = mix(vec3(0.04), albedo, metallic),这一行就是金属度工作流的精髓。非金属的F0用0.04的灰度值,金属的F0直接用albedo作为反射颜色,然后按metallic线性插值。这样一张albedo贴图加一张金属度贴图,就能驱动完整的反射模型。

kD = (1.0 - kS) * (1.0 - metallic),等于说Fresnel反射掉的那部分能量不再参与漫反射,同时金属表面不允许漫反射。这是实时PBR里最常见的能量近似分配方式,它够快,效果也够好。

4.3 金属度工作流与镜面反射工作流的取舍

实现的时候你可能还会遇到另一套方案:镜面反射工作流(Specular/Glossiness workflow)。两套工作流的数学基础一样,只是材质参数的表示方式不同:

对比项金属度工作流镜面反射工作流
基础颜色albedo贴图diffuse贴图
高光颜色由metallic推导specular贴图直接给
表面粗糙度roughness贴图glossiness贴图(取反)
参数数量较少,直观较多,灵活
典型引擎UE4 / Unity HDRP早期PBR资产,部分DCC工具

我在实践中更推荐金属度工作流,因为它强制你把材质分为“金属”和“非金属”两类,减少了很多无意义的调参空间。Specular工作流虽然在处理电介质表面时更细,但也更容易让美术在非金属上把高光颜色调成彩色,导致能量不守恒。

如果你要兼容Specular工作流的资产,转换时记住一个近似:specular颜色整体值接近0.04的是非金属,接近albedo的是金属。用这个规则去生成metallic贴图,再把specular压成灰阶,能救回大部分旧资产。

5. 实测翻车记录与调参经验

5.1 忘掉归一化:高光形状直接扭曲

遇到第一个诡异现象是:金属球的高光中央总是有个不自然的暗点,旋转视角时暗点还会移动。排查了很久,最后发现顶点着色器传过来的法线没有重新归一化。顶点法线在光栅化过程中会被插值,插值后的向量长度不再严格等于1。如果不做normalize,NdotH、NdotV这些点乘结果会整体偏大或偏小,D和G函数对N依赖很强,高光形状就被扭曲了。

解决办法很简单:在片元着色器开头加一行vec3 N = normalize(normalWS);。但要注意,如果你的法线来自法线贴图,还需要先通过TBN矩阵变换到世界空间再归一化,顺序不能反。我见过有同事把切线空间的法线直接放到世界空间计算光照,结果整个高光方向都是错的。

5.2 菲涅尔闪烁:浮点误差在掠射角现形

另一个烦人的问题是:相机在某个角度移动时,材质边缘会出现像素级的高光闪烁,像电视雪花一样。这类闪烁通常和Fresnel项的计算有关。

Schlick近似里的(1.0 - VdotH)的5次方,在VdotH接近0或1时对误差非常敏感。如果VdotH是负数或者略大于1,pow函数的结果会产生跳变。所以我把FresnelSchlick函数里的cosTheta强制clamp到0到1之间,同时在specular分母里保留一个很小的epsilon。这两个小改动,把90%的边缘闪烁都消掉了。

剩下那10%的闪烁,往往来自半精度浮点。移动平台用mediump精度时,H向量在运算中会出现量化误差。我的建议是,法线、视线、光线方向这些关键向量至少用highp计算,否则掠射角上的微小误差会被D项放大成肉眼可见的噪点。

5.3 能量守恒自检方法

实现完整套模型后,我习惯用一个小实验来验证能量是否守恒:创建一个纯白球体,把lightColor设成纯白,放到全白环境光下,观察球体亮度是否约等于背景亮度。如果球体明显偏亮,说明高光项和漫反射项叠加后超出了入射能量;如果偏暗,说明某处能量被吞掉了。

最常见的能量丢失场景是粗糙金属。把metallic=1、roughness=0.8的金色球放到纯白环境下,会明显比非金属暗。原因就是我前面说的:单次散射的Cook-Torrance丢失了微表面多次弹射的能量。简单的修复是在specular项上乘一个能量补偿系数,或者直接使用Frostbite的kD补偿近似。实时渲染里不需要完全精确,只要保证不会暗得发灰就行。

5.4 线性空间与颜色管理

PBR的另一个隐形前提是线性空间计算。albedo贴图如果是sRGB编码,直接塞进Shader会让漫反射和高光的亮度关系全乱。正确做法是:albedo和color贴图的采样结果从sRGB转换到线性空间,roughness和metallic这类数据纹理保持线性即可。最终输出到屏幕前,再根据显示设备做一次ToneMapping和Gamma校正。

我踩过最深的坑是在移动端:有些引擎默认关闭sRGB纹理标记,导致所有颜色都比预期暗,然后大家开始疯狂调高lightIntensity。最后发现问题的根源只是纹理颜色空间不对,和材质参数毫无关系。所以当你觉得PBR调什么都怪的时候,先查颜色空间,再查能量守恒,最后才去动参数。

5.5 调参顺序与观察方法

最后分享一个我目前比较顺手的调参方法:

  1. 先把metallic定为0或1,不要给中间值。金属和非金属的界限在物理上是分明的,中间值只会在特殊效果里用到。
  2. 再调roughness。用一张带明显高光的HDR环境贴图做参考,观察高光边缘的拖尾:拖尾太长说明roughness偏大,太短则偏小。
  3. 最后微调albedo。金属的albedo会影响F0,因为它直接作为高光反射颜色;非金属的albedo只影响漫反射,和粗糙度关系不大。
  4. 观察掠射角:如果边缘高光过亮,检查G项系数和roughness的映射;如果边缘偏暗,注意NdotV的钳制是否太小。

我还在项目里加了一个调试模式,把D、G、F分别可视化输出,这样能一眼看出哪一项贡献了异常亮度。这个方法在调粗糙金属时尤其好用:D项的拖尾和G项的压暗能在可视化视图里直接看明白。

在实际项目中,把Blinn-Phong换成Cook-Torrance不是简单替换一个公式,而是整个渲染管线的世界观升级。你可能要多处理线性空间、HDR、ToneMapping、IBL,但换来的是材质的可复用性和跨平台一致性。我个人的体会是,一旦习惯了用物理约束去思考光照问题,就很难再退回经验模型的玄学调参了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询