ARTICLE DETAIL

资讯详情

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

PBR核心BRDF与Cook-Torrance模型从原理到Shader实现

PBR核心BRDF与Cook-Torrance模型从原理到Shader实现 PBRPhysically Based Rendering基于物理的渲染在图形学领域已经火了十多年从影视VFX到游戏实时渲染几乎所有现代渲染管线里它都是标配思路。而PBR的物理基础就落在BRDFBidirectional Reflectance Distribution Function双向反射分布函数上。很多初次接触PBR的读者会在这一步被劝退——数学公式多、坐标系绕、微积分符号让人头大。很多相关教程喜欢搬出Fresnel菲涅尔反射公式直接砸出一堆术语新手看完依旧不知道代码从哪下手。我想聊的是更务实的一条路径不追求一步到位推导所有物理含义而是把BRDF当成一种描述光和表面“怎么约架”的规则然后带着你一步步实现一个现阶段最流行的Cook-Torrance模型。整个过程从原理讲到工程把公式转成可直接运行的片段最终渲染效果至少能对标市面上主流引擎的“标准PBR材质”品质。这篇文章适合两类读者一类是刚入行、想搞明白BRDF到底是什么的图形学初学者另一类是已经在用引擎但想自己动手写Shader、理解底层细节的开发者。C搭配GLSL或者纯用Unity ShaderLab内置渲染管线BRP都能看懂代码手写核心片段不依赖后期处理特效。核心案例以实时渲染为前提给出可直接参考的粗糙度、金属度参数实践经验。1. 先从BRDF是什么说起1.1 光和表面的“计量合约”BRDF全称Bidirectional Reflectance Distribution Function双向反射分布函数。名字很长但核心思想很朴素对于表面上任意一个点一束光从某个方向入射它会被表面弹射到四面八方而BRDF描述的就是“从某个入射方向来的光有多少比例反射到某个出射方向”。更准确地说BRDF的数学定义是反射辐射度与入射辐照度的比值。你可以把它理解成“履约率”——表面收到了一份光能量按照BRDF给定的比例分配到各个出射方向。不同的出射方向这个比例不一样所以BRDF是个关于入射方向、出射方向和表面参数的函数。在渲染方程里BRDF扮演的角色是连接入射光和出射光的桥梁L_out(p, ω_out) ∫ f(p, ω_in, ω_out) * L_in(p, ω_in) * (n·ω_in) dω_in这个积分就是在半球空间里把所有从四面八方打过来的光照能量乘以BRDF表面响应再乘上入射角余弦项最后累加起来。写得再直白点屏幕上每个像素的亮度和颜色是所有来自各个方向的光照经过表面“分账”之后的结果。1.2 为什么PBR非要用BRDF在早期的固定管线和Blinn-Phong时代光照计算是“经验法则”公式怎么给就怎么算缺乏物理一致性。同一个材质在不同角度、不同光照条件下表现经常“失真”。PBR的思路是模拟真实物理过程能量必须守恒反射到各个方向的总能量不能超过入射能量。BRDF因为定义了反射能量的分布比例天然可以满足能量守恒的要求。具体到工程上BRDF常见的类型有Lambert漫反射、Oren-Nayar粗糙漫反射、Cook-Torrance镜面反射等。其中Cook-Torrance模型后来成为PBR金属/粗糙度工作流的绝对主流原因很简单它用一套公式同时处理漫反射和镜面反射对金属、非金属、粗糙、光滑都能统一建模尤其对于实时渲染来说计算量可控、效果可控。1.3 这玩意在游戏里的直观影响如果你玩过3A大作应该能明显感受到PBR材质和老式渲染对比的质感差异。比如枪械的金属枪管在PBR工作流下高光会随着视角变化呈现出明显的金属感粗糙区域高光散开、镜面区域高光聚拢不像以前那样贴一张高光贴图就完事。这就是BRDF函数在起作用。理解了这一点之后接下来我们正式进入Cook-Torrance模型的分解与实现。2. Cook-Torrance模型核心框架拆解2.1 反射两大类漫反射与镜面反射Cook-Torrance模型的整体思路是把表面反射分成两部分漫反射项diffuse光进入表面内部被多次散射后重新出射方向不依赖入射方向。镜面反射项specular光在表面直接反射遵守反射定律方向由入射方向、法线方向共同决定。看到这里可能有人会问那高光不是统一用镜面项不就完了吗为什么还要留一个漫反射项因为不同材料的物理机制不一样。导体如金属几乎没有漫反射入射光基本都被导电层吸收或直接反射所以金属材质的颜色主要体现在镜面高光上。而绝缘体如塑料、岩石除了镜面反射还有很大的漫反射分量光线能进入材质内部再出来。因此漫反射项对于非金属材质非常重要对金属则趋近于0。Cook-Torrance的反射率公式写起来非常清晰f k_d * (diffuse) k_s * (specular)其中k_d、k_s是漫反射和镜面反射的比例系数。这里要留意这两个系数不是随便定的包含的能量分配逻辑。金属度和菲涅尔项都参与控制这两个系数后文我们会详细展开。2.2 Cook-Torrance镜面BRDF三剑客Cook-Torrance的镜面BRDF不是单个函数而是一个融合了三个子函数的方案这也是它最有辨识度的地方D * F * G specular_brdf ───────────── 4 * (ω_i·n) * (ω_o·n)三个子函数各管一摊D项法线分布函数NDF描述表面微观法线朝向的分布。粗糙度越高微表面法线排列越杂乱高光越扩散粗糙度越低高光越锐利。常用实现有GGXTrowbridge-Reitz等。F项菲涅尔方程描述反射率随入射角变化的现象。垂直入射时反射率最低掠射角接近90度时反射率接近1。常见实现是Schlick近似。G项几何遮蔽/阴影函数描述微表面自遮挡效应。粗糙表面上微表面峰谷会使光线被遮挡或阻挡导致边缘区域变暗。常用Smith方法配合Schlick-GGX近似。这三个函数相乘除以分母上的几何因子即是Cook-Torrance镜面BRDF的最终形式。分母的存在是为了在微表面模型里做投影校正保证能量守恒与积分一致性。2.3 为什么Cook-Torrance成为主流很多人会问现在这么多PBR模型GGX配Smith是标配为什么偏偏是Cook-Torrance框架胜出我的理解是它把“材质表现”拆分成几个相对独立的物理量度每个部分都能单独调参、单独出贴图美术在DCC软件里做材质时非常直观。金属度贴图控制F项权重粗糙度贴图控制D项和G项基础色贴图控制漫反射比例只要这些值在合理范围内渲染出来的效果天然材质感很强不太容易做脏。另一个重要原因是Cook-Torrance的数学形式适合实时渲染。D、F、G都能用近似公式高效计算在GPU上跑得非常快。在PBR这个概念还没普及的年代前辈们用Phong调高光效果容易“塑料”换成Cook-Torrance之后实时渲染画面质感一下拉满。3. 关键函数实现从公式到Shader代码3.1 法线分布函数D的GGX实现细节GGXTrowbridge-Reitz是目前使用最广泛的NDF。核心思想是用半角向量和法线的夹角分布描述微表面朝向分布粗糙度α控制分布的集中程度。float distributionGGX(float3 N, float3 H, float roughness) { float a roughness * roughness; float a2 a * a; float NdotH saturate(dot(N, H)); float NdotH2 NdotH * NdotH; float denominator NdotH2 * (a2 - 1.0) 1.0; denominator 3.14159265 * denominator * denominator; return a2 / max(denominator, 0.001); }这个公式实现极短但它解释了一个关键现象粗糙度一高高光区域会快速铺开峰值降低粗糙度一低高光区域集中峰值暴涨。注意这里roughness * roughness是一次重映射美术给的粗糙度是线性感知的而物理计算里期望的是更平滑的变化转换后高光过渡更自然。为什么分母要max限定最小值因为极端情况下denominator可能接近0GPU除法意外产生inf/NaN先堵住这个坑。3.2 菲涅尔F的Schlick近似与金属度结合Schlick近似是全行业用的标准方案计算量小且精度足够float3 fresnelSchlick(float cosTheta, float3 F0) { return F0 (1.0 - F0) * pow(1.0 - cosTheta, 5.0); }其中F0是垂直入射时的反射率。对于非金属通常取0.04这个经验值对于金属直接用基础色albedo作为F0。这个区别非常重要——PBR里“金属度”参数的本质就是在普通非金属F0和金属F0之间做插值float3 F0 lerp(0.04, albedo, metallic);在做这个接线时我遇到过很多次效果异常的情况最终定位到是F0没有按金属度正确插值。尤其金属度介于0.5左右时镜面高光会显得脏、灰原因就是高光颜色没有跟基础色融合这就是工程实现里最常见的坑之一。3.3 几何遮蔽G的Smith-Schlick实现几何函数解决的核心问题是“微表面互遮挡”。在粗糙表面上从某个角度看去很多微表面会被邻居挡住光线进不来或者反射出不去。Smith方法将这个效应分解为两个独立的方向光照方向和视线方向。float geometrySchlickGGX(float NdotX, float roughness) { float r roughness 1.0; float k (r * r) / 8.0; float denominator NdotX * (1.0 - k) k; return NdotX / denominator; } float geometrySmith(float3 N, float3 V, float3 L, float roughness) { float NdotV saturate(dot(N, V)); float NdotL saturate(dot(N, L)); return geometrySchlickGGX(NdotV, roughness) * geometrySchlickGGX(NdotL, roughness); }这里要注意roughness 1.0这个变换让几何项的衰减更贴近实际观测效果是针对实时渲染经验修正的。除以8.0是将Smith G2项中的因子统一到Schlick形式的近似值。有些人会在GGX的NDF中用过roughness^2在几何中却直接使用UI传入的roughness这是不一致的会导致粗糙表面高光边缘出现不该有的暗边我在调试时踩过这个坑代码里一定要保持一致的重映射规则。3.4 组装镜面BRDF与最终反射方程镜面部分的最终代码是一个串联float3 specular precomputedDFG * D * F * G / (4.0 * NdotV * NdotL 0.0001);这个0.0001是典型的数值稳定保护防止分母为0。当视线或者光照方向接近切线方向时NdotV或NdotL会接近0所以直接加一个极小数就能避免爆亮。完整的PBR漫反射加镜面反射方程式如下float3 cookTorranceBRDF(float3 albedo, float metallic, float roughness, float3 N, float3 V, float3 L) { float3 H normalize(V L); float NdotL saturate(dot(N, L)); float NdotV saturate(dot(N, V)); float3 F0 lerp(0.04, albedo, metallic); // 菲涅尔项 float3 F fresnelSchlick(max(dot(H, V), 0.0), F0); // 法线分布项 float D distributionGGX(N, H, roughness); // 几何遮蔽项 float G geometrySmith(N, V, L, roughness); // 镜面反射 float3 specular (D * F * G) / max(4.0 * NdotV * NdotL, 0.0001); // 漫反射部分金属吸收大部分漫反射所以乘以(1-F) float3 kS F; float3 kD (1.0 - kS) * (1.0 - metallic); return (kD * albedo / 3.14159265 specular) * NdotL; }这里写了两个关键点H向量是视线方向V和光照方向L的中间向量用来计算D项。漫反射项里kD乘以(1 - metallic)是PBR的核心物理约束金属没有漫反射。同时kD乘以(1 - kS)保证能量的基本守恒——镜面反射掉的能量不能再进入漫反射积分。4. 光照积分与Roughness/Metallic的工程实践4.1 单个直射光实现与多个光源叠加上面实现的BRDF函数只是“表面在一次光照下的响应”要得到最终像素颜色还需要把它带入渲染方程做光照积分。在实时渲染中我们通常对每个光源单独计算BRDF然后累加。比如场景中有方向光点光源聚光灯伪代码如下float3 finalColor float3(0.0, 0.0, 0.0); // 方向光 { float3 L normalize(-directionalLightDirection); float3 radiance directionalLightColor * directionalLightIntensity; finalColor cookTorranceBRDF(albedo, metallic, roughness, N, V, L) * radiance; } // 点光源以点光源为例考虑距离衰减 { float3 L normalize(pointLightPos - worldPos); float distance length(pointLightPos - worldPos); float attenuation 1.0 / (distance * distance); float3 radiance pointLightColor * attenuation; finalColor cookTorranceBRDF(albedo, metallic, roughness, N, V, L) * radiance; }这里注意漫反射项和镜面项最后都要乘NdotL在BRDF内部已经乘过NdotL的话外面不要再乘一次。很多初学PBR的读者在这里反复出bug渲染结果一会儿黑一会儿爆亮排查半天发现是多乘了一次余弦项。建议把“BRDF只负责算表面响应光照负责算辐照度”这两件事分开思考。4.2 直接光照验证场景极其重要的参考球当你把上述代码跑到屏幕上时第一步不是在场景里摆一堆精美模型而是先放一排金属度和粗糙度不同的标准球Sphere并用HDR环境贴图做可视化对比。这是我做PBR材质调试时最有效的验证手段。具体做法是渲染一排圆球从左到右粗糙度递增从0.0到1.0从上到下金属度递增从0.0到1.0。那样你一眼就能看出粗糙度0.0时高光应是一个小而锐利的点粗糙度1.0时高光应均匀散开呈现天鹅绒般的质感金属度为1.0时基础色应该几乎完全由镜面反射颜色体现漫反射分量趋近0非金属F0应接近0.04高光颜色发白而不是染上本身的基础色。如果你的渲染结果不符合这些规律绝大多数时候都能定位到参数映射或BRDF系数写错了。4.3 Roughness/Metallic参数的物理含义与美术经验工程上PBR材质参数设定有几个“默认底线”值得记住Roughness粗糙度范围通常限制在0.0~1.0。对于金属拉丝表面各向异性效果需要额外处理但标准PBR只模拟各向同性。Metallic金属度范围0或1是“标准用法”不推荐中间值。因为真实材质要么是金属要么不是。但游戏里为了混合材质如油漆剥落露出的金属底层会使用中间值这也是常见做法。Albedo值域非金属的表面反射率一般不会超过0.8如果你把albedo设成1.0并且是0%金属画面可能过曝而金属albedo可以很低甚至接近0.01因为颜色主要由镜面反射控制。4.4 数值颜色空间的坑很多人渲完PBR场景会发现颜色发灰或偏暗尤其是直射光场景没有使用HDR颜色导致高光直接裁剪到1.0出现一片惨白。在实时渲染中建议你在输出最终颜色前应用色调映射ToneMapping比如ACES或者Reinhard。没有色调映射的PBR直出任何超过1.0的高光亮点都会丢失细节。在移动端有时为了性能会用简化的色调映射但标准PC/Console项目建议ACES风格。这套光照模型做到这一步基本可以支持一个多光源实时渲染Demo了。不过要做出“所有物体都能被现实环境的光照亮”的效果还需要将环境光照封装进IBL流程。5. 环境光照从预滤波贴图到IBL集成5.1 为什么直接光不够用只有直接光方向光、点光源、聚光灯的PBR场景在户外环境里会显得空旷失准原因很简单天空、建筑、地面反射到物体上的间接光都被忽略了。真实世界里没有无条件黑暗的地方即使背光面接收到的也是天空和周围物体的反弹光。IBL基于图像的光照就是解决这个问题的手段——用一张环境贴图近似入射的远场辐照度。IBL分为两部分漫反射部分对漫反射半球积分环境光预计算Irradiance Map辐照度图。镜面部分分粗糙度层级预滤波环境贴图Pre-filtered Environment Map配合BRDF LUT查找表处理菲涅尔积分。5.2 预滤波环境贴图与Split-Sum近似镜面IBL的完整积分在实时渲染中没法直接做常用Unreal提出的Split-Sum近似。它把包含D、F、G的镜面积分拆成两部分一部分是环境贴图的预滤波结果对应的粗糙度层级另一部分是BRDF LUT一个2D查找表输入NdotV和roughness输出菲涅尔积分的缩放和偏移系数。实时渲染时最终镜面IBL项float3 prefilteredColor textureLod(prefilterMap, R, roughness * MAX_REFLECTION_LOD).rgb; float2 brdfLUT texture(brdfLUTTexture, float2(NdotV, roughness)).rg; float3 specularIBL prefilteredColor * (F * brdfLUT.x brdfLUT.y);这里第一步用反射向量R从预滤波贴图里采样对应粗糙度的颜色第二步用BRDF LUT对菲涅尔项做积分修正。这一步是整个PBR实现中代码量最少但概念最绕的地方。我建议读者第一次做的时候先把Irradiance Map和Prefiltered Environment Map从引擎里导出来观察你会发现它们是模糊程度不同的环境贴图一旦看到了这个“物理事实”再回来看split-sum公式就很好理解了。5.3 IBL需要三张贴图的生成管线要正确落地IBL通常需要以下流程从HDR环境贴图出发。生成漫反射Irradiance Map对每个方向做余弦加权半球积分可以用卷积的方式处理。生成不同粗糙度镜面反射Prefiltered Map按照不同粗糙度范围做分布采样并累加输出多级mipmap。计算BRDF LUT一般用一张离线2D纹理表示横轴NdotV纵轴roughness。如果不想自己写离线工具可以直接使用引擎或第三方工具预计算比如AMD的CubeMapGen、Unreal自带的TAAO、或者HDR Shop等。但是自己写一遍预滤波采样过程对理解很有帮助也可以让你在调项目时知道该去哪里优化不会对着几个模糊贴图一头雾水。5.4 环境光照代码接入示例在Shader里接入IBL主代码思路如下// 漫反射IBL float3 irradiance texture(irradianceMap, N).rgb; float3 diffuseIBL irradiance * albedo; // 镜面IBL float3 R reflect(-V, N); float3 prefilteredColor textureLod(prefilterMap, R, roughness * MAX_REFLECTION_LOD).rgb; float2 envBRDF texture(brdfLUT, float2(NdotV, roughness)).rg; float3 specularIBL prefilteredColor * (F * envBRDF.x envBRDF.y); // 混合漫反射镜面 float3 ambient kD * diffuseIBL specularIBL;这里kD仍然要乘(1 - F)和(1 - metallic)保证与直接光逻辑一致。至此一个支持直接光照和间接光照的完整PBR材质已经成型。结合前面第3节的直接光照BRDF你的渲染方程已经可以完整地写出“全局光照”的近似轮廓。6. 常见坑位排查清单与实测心得6.1 高光过曝 / 色偏问题症状场景中高光区域一片死白看不出材质颜色。排查步骤确认F0是否正确。非金属F0固定0.04金属F0为albedo若混用会出现偏灰/漂白问题。检查是否开启了HDR渲染与色调映射线性HDR没有ToneMapping高光裁切必然过曝。检查kD (1 - F) * (1 - metallic)是否过于强势。金属度0.0时非金属F0约0.04非常小接近1的kD是对的但如果F没参与能量分配外部光源叠加时容易过亮。实测经验我在调参时经常用纯灰色基础色albedo0.5测试这样观察高光不容易被颜色干扰。等基础色确定后再替换成项目美术资源。6.2 高光边缘发黑或出现暗边症状粗糙金属球在掠射角方向上高光区域周边出现异常暗带。排查步骤GGX的NDF和几何函数是否用了同一个粗糙度重映射规则。混合使用roughness和roughness^2会造成常见的不匹配。几何函数G是否缺乏对入射光和出射光方向的统一处理。分母4.0*NdotV*NdotL是否被max保护是否出现除0导致的NaN继而影响周边像素。实操心得把粗糙度贴图可视化输出到Debug视角看看美术资源到底给了多少。很多“暗边”问题来自粗糙度贴图在UV接缝处压缩脏了你可以先强制粗糙度为0.3测试排除贴图因素。6.3 金属度参数导致渲染“灰”症状金属物体看起来像石膏或者高光被基础色污染。排查步骤确认F0是否根据金属度插值。如果非金属物体用了albedo做F0高光就会带材质色造成塑料感。确认漫反射项是否乘了(1 - metallic)。金属漫反射率几乎为0。确认BRDF LUT采样坐标是否正确。BRDF LUT横轴NdotV和纵轴roughness调换导致镜面反射整体偏灰。实测经验新手经常会出现“金属球上既看得到清楚的反射又拖着大片白色高光”的问题通常是把金属度和粗糙度混为一谈。金属度管反射率与颜色来源粗糙度管反射清晰度两个参数调试时分开控制先定金属度再微调粗糙度。6.4 环境光模糊度与粗糙度不匹配症状IBL反射看起来像是“贴了一层模糊玻璃”跟物体本身的粗糙度设置对不上。排查步骤确认Prefiltered Map的mipmap选择是否正确。不同的引擎对mipmap level对应粗糙度的映射不同标准的映射是roughness * MAX_REFLECTION_LOD如果MAX_REFLECTION_LOD的值需要根据mip数量合理设置。确认环境贴图是否在gamma空间被错误处理线性空间和sRGB空间采样不一致造成的偏暗偏灰。确认反射向量R是否用了世界空间的视线方向V和法线方向N而不是切线空间值。实操坑位我曾在引擎里把Precompute环境贴图用错了编码把HDR当成sRGB采样所有物体莫名变暗排查了一个下午才发现是纹理解码函数的问题。6.5 性能优化心得PBR本身比Phong重不少但优化空间也大。实时场景中我用过以下几个手段粗糙度大于0.95的物体可以降级使用纯漫反射两张贴图计算减少高光项计算量。远处的低权重光源可以直接砍掉不要无上限叠加光源数。BRDF LUT用低分辨率如64x64Prefiltered Map的mip层可以做跳级采样。移动端可以关闭GGX精确计算改用预烘焙的GGX LUT或纯Phong近似效果打折但性能提升明显。7. 后续延展还有哪些方向可以深入Cook-Torrance只是PBR家族里的一个里程碑不是终点。实际项目中仍会面临很多细节问题各向异性表面如拉丝金属需要额外的各向异性NDF次表面散射SSS材料需要增加额外的散射项清漆涂层材质则要叠加两层BRDF。在标准PBR基础上做扩展时核心仍然是回到BRDF的数学构架里在能量守恒的框架下加入新的近似项。当然光线追踪的普及也让PBR跨入了一个新阶段实时渲染中可以进一步用光线追踪直接积分环境光但Cook-Torrance模型作为材质层面的着色描述依然承担了“BRDF函数”的角色只是在积分方式上发生了改变。所以把Cook-Torrance的D、F、G吃透随时可以迁移到更复杂的渲染器中去。如果你刚接触PBR我的实际建议是不要直接冲去写一大堆IBL和全局光照代码先把一个球、一个方向光、一套完整BRDF函数跑通把粗糙度和金属度两个参数在渲染中“玩熟”理解每一种参数变化对最终画面造成的影响。再逐步加入环境贴图、IBL、多光源和场景这条路走稳了PBR对你的神秘感自然就消失了。
返回列表