ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从Blinn-Phong到Cook-Torrance:PBR核心BRDF与Shader实现解析

从Blinn-Phong到Cook-Torrance:PBR核心BRDF与Shader实现解析 “同样的材质球白天看高光是糊的晚上看高光是飘的怎么调都像塑料……”这是我刚开始写渲染器时用Blinn-Phong调材质的真实状态。后来咬牙把光照模型换成Cook-Torrance问题一下子变得清晰不是参数没调好而是模型本身就不具备物理一致性。PBRPhysically Based Rendering基于物理的渲染如今已经是游戏引擎和DCC工具里的默认方案它的核心数学工具就是BRDFBidirectional 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的全称是双向反射分布函数它描述的是“一束光从某个方向打过来有多少能量往另一个方向反射出去”。要读懂它的公式得先分清辐射度量学里的两个概念辐照度IrradianceE单位面积上接收到的辐射通量单位是W/m²。可以把它理解为“这个表面接受到多少光”。辐射亮度RadianceL单位投影面积、单位立体角上的辐射通量单位是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)² / 8IBLG1(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 漫反射项能量守恒约束下的LambertCook-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-Torrance4.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。如果不做normalizeNdotH、NdotV这些点乘结果会整体偏大或偏小D和G函数对N依赖很强高光形状就被扭曲了。解决办法很简单在片元着色器开头加一行vec3 N normalize(normalWS);。但要注意如果你的法线来自法线贴图还需要先通过TBN矩阵变换到世界空间再归一化顺序不能反。我见过有同事把切线空间的法线直接放到世界空间计算光照结果整个高光方向都是错的。5.2 菲涅尔闪烁浮点误差在掠射角现形另一个烦人的问题是相机在某个角度移动时材质边缘会出现像素级的高光闪烁像电视雪花一样。这类闪烁通常和Fresnel项的计算有关。Schlick近似里的(1.0 - VdotH)的5次方在VdotH接近0或1时对误差非常敏感。如果VdotH是负数或者略大于1pow函数的结果会产生跳变。所以我把FresnelSchlick函数里的cosTheta强制clamp到0到1之间同时在specular分母里保留一个很小的epsilon。这两个小改动把90%的边缘闪烁都消掉了。剩下那10%的闪烁往往来自半精度浮点。移动平台用mediump精度时H向量在运算中会出现量化误差。我的建议是法线、视线、光线方向这些关键向量至少用highp计算否则掠射角上的微小误差会被D项放大成肉眼可见的噪点。5.3 能量守恒自检方法实现完整套模型后我习惯用一个小实验来验证能量是否守恒创建一个纯白球体把lightColor设成纯白放到全白环境光下观察球体亮度是否约等于背景亮度。如果球体明显偏亮说明高光项和漫反射项叠加后超出了入射能量如果偏暗说明某处能量被吞掉了。最常见的能量丢失场景是粗糙金属。把metallic1、roughness0.8的金色球放到纯白环境下会明显比非金属暗。原因就是我前面说的单次散射的Cook-Torrance丢失了微表面多次弹射的能量。简单的修复是在specular项上乘一个能量补偿系数或者直接使用Frostbite的kD补偿近似。实时渲染里不需要完全精确只要保证不会暗得发灰就行。5.4 线性空间与颜色管理PBR的另一个隐形前提是线性空间计算。albedo贴图如果是sRGB编码直接塞进Shader会让漫反射和高光的亮度关系全乱。正确做法是albedo和color贴图的采样结果从sRGB转换到线性空间roughness和metallic这类数据纹理保持线性即可。最终输出到屏幕前再根据显示设备做一次ToneMapping和Gamma校正。我踩过最深的坑是在移动端有些引擎默认关闭sRGB纹理标记导致所有颜色都比预期暗然后大家开始疯狂调高lightIntensity。最后发现问题的根源只是纹理颜色空间不对和材质参数毫无关系。所以当你觉得PBR调什么都怪的时候先查颜色空间再查能量守恒最后才去动参数。5.5 调参顺序与观察方法最后分享一个我目前比较顺手的调参方法先把metallic定为0或1不要给中间值。金属和非金属的界限在物理上是分明的中间值只会在特殊效果里用到。再调roughness。用一张带明显高光的HDR环境贴图做参考观察高光边缘的拖尾拖尾太长说明roughness偏大太短则偏小。最后微调albedo。金属的albedo会影响F0因为它直接作为高光反射颜色非金属的albedo只影响漫反射和粗糙度关系不大。观察掠射角如果边缘高光过亮检查G项系数和roughness的映射如果边缘偏暗注意NdotV的钳制是否太小。我还在项目里加了一个调试模式把D、G、F分别可视化输出这样能一眼看出哪一项贡献了异常亮度。这个方法在调粗糙金属时尤其好用D项的拖尾和G项的压暗能在可视化视图里直接看明白。在实际项目中把Blinn-Phong换成Cook-Torrance不是简单替换一个公式而是整个渲染管线的世界观升级。你可能要多处理线性空间、HDR、ToneMapping、IBL但换来的是材质的可复用性和跨平台一致性。我个人的体会是一旦习惯了用物理约束去思考光照问题就很难再退回经验模型的玄学调参了。
返回列表