ARTICLE DETAIL

资讯详情

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

Unity PBR渲染方程:原理剖析与Shader实现指南

Unity PBR渲染方程:原理剖析与Shader实现指南 这问题隔三差五就会在群里看到一次Unity PBR的渲染方程是啥而且每次都会有人顺手把PBR打成“策略路由”我一开始也会懵毕竟网络那边确实也有个PBR。先把概念拉直——Unity社区里说的PBR几乎都是基于物理渲染Physically Based Rendering跟“PBR策略路由”完全两码事。你既然搜的是渲染方程说明你是想从底层搞明白Unity里的材质为什么长这样为什么金属几乎没有漫反射为什么粗糙度调低后高光锐利得像刀为什么同一套材质在完全不同光照环境下的一致性远好过BlinnPhong。这篇文章我打算从渲染方程本身拆到Unity Shader里的实际实现再把我在真实项目里调PBR材质踩过的坑一并整理出来。适合人群很明确已经会用Unity基础材质和Shader但看到Standard或Lit Shader里的GGXTerm、SmithJointGGXTerm、FresnelTerm这些名词一头雾水的同学或者做卡通渲染做腻了想回头理解“物理正确”到底是怎么回事的同学。对美术同学也友好我不会把公式堆到让你睡着每个符号都会翻译成人话。1. 先理清一件事Unity里的PBR到底指什么1.1 PBR是策略路由还是物理渲染我几乎敢肯定搜“Unity PBR的渲染方程”的人里至少有一半最开始是被“PBR”这个词带偏的。PBR在计算机网络领域确实是Policy-Based Routing的缩写叫策略路由主要用来处理包转发路径选择问题。你如果在搜索引擎里看到“PBR策略路由”“稳定”“分流”这类词基本是网络方向的内容和Unity没有任何关系。但在Unity、UE、Blender、Substance这类工具的用户群体里PBR就一个意思基于物理的渲染。它是一套材质描述规范加一套光照模型核心让材质在不同环境下表现一致。你在Asset Store里看到“PBR Master”“PBR Textures”在Shader里看到SurfaceOutputStandard全都是这个意思。所以第一步就是别再把两个PBR混在一起搜否则你会发现自己在Unity社区找了一晚上“路由策略怎么配”。1.2 PBR的真实诉求能量守恒与微表面理论PBR不是一个具体Shader而是一套理论框架。传统光照模型里高光强度经常是美术或TA拍脑袋调出来的换个灯光角度材质表现就可能崩掉同一种材质在暗环境下像塑料在亮环境下像金属缺乏一致性。PBR用两个底层法则约束所有材质让结果尽量接近物理世界。第一个法则是能量守恒出射光的总能量不能超过入射光的总能量。这句话听起来像废话但实际很多自定义Shader就是栽在这里——漫反射和镜面反射各算各的加起来超过1材质看起来就“溢出”“过曝”。PBR通过BRDF的数学结构天然保证这一点。第二个法则是微表面模型宏观上平整的物体表面放大到微米级其实是无数朝向各异的小平面。光打到表面每个微平面都在做镜面反射但它们朝向不同有的微平面会被相邻微平面遮挡。最终我们看到的高光模糊程度就是微平面朝向分布和遮挡关系共同作用的结果。这就是为什么PBR要用D法线分布、G几何遮蔽、F菲涅尔这三类函数来描述镜面反射。2. 渲染方程本体所有PBR的源头2.1 那个让人头疼的积分式到底在算什么渲染方程可以追溯到Kajiya在1986年提出的形式图形学里叫Rendering Equation。PBR只是它的一个实时渲染近似实现。完整形式是Lo(p, ωo) Le(p, ωo) ∫Ω f_r(p, ωi, ωo) Li(p, ωi) (n · ωi) dωi逐项翻译Lo(p, ωo)从表面点p朝观察方向ωo出射的光线辐射率也就是最终进眼睛的值。Le(p, ωo)p点自己发出的光比如自发光材质。实时渲染里常见PBR材质一般把这个置0。Li(p, ωi)从入射方向ωi打到p点的光。f_r(p, ωi, ωo)BRDF双向反射分布函数描述“一束光从ωi进来有多少比例从ωo出去”。n · ωi法线和入射方向的余弦用于把光投影到表面面积上。∫Ω ... dωi对法线半球上所有可能入射方向做积分。我第一次看这个式子也晕后来找到一个人话解释它就是把四面八方可能照到这个点的光全累加起来乘上材质反射比例得到你看到的结果。积分符号只是“把所有方向都算一遍并加总”的数学写法。现在实时渲染不可能真的对半球连续积分于是拆成两部分——直接光和间接光分别处理。2.2 BRDF一体两面漫反射加镜面反射渲染方程里真正决定材质长相的是BRDF也就是f_r函数。实时PBR里几乎所有的方案都把BRDF拆成两项f_r f_d漫反射项 f_s镜面反射项漫反射项对应光线进入材质内部经过多次散射后重新从表面射出来的那部分。经典Lambert模型认为这一项与观察方向无关只和光的入射角度有关公式是f_d diffuseColor / π这里除以π不是凑数而是为了保证球面积分时能量守恒——漫反射在半球上积分后的总能量正好是入射能量的比例除π后归一。如果你在Unity代码里看到UNITY_INV_PI那其实就是1 / π在漫反射项里出现。镜面反射项才是PBR真正精彩的部分负责高光和反射由Cook-Torrance模型承担。实时渲染中99%的PBR镜面BRDF都长这样f_s D * G * F / (4 * n·l * n·v)D是法线分布函数G是几何遮蔽函数F是菲涅尔函数。每一项代表微表面模型的不同侧面下一章逐项拆。3. Cook-Torrance镜面反射Unity PBR的真正核心3.1 D项GGX法线分布函数为什么是默认选择D项描述微表面中法线恰好朝向半矢量h的比例。这里的h是指向入射光和视线中间方向的单位向量。D值越大说明越多微表面的朝向正好反射光线到眼睛高光就越亮越锐利。历史上用过很多NDF最早常用Beckmann分布数学上接近高斯高光衰减比较“圆滑”。后来业界发现GGX分布——也就是Trowbridge-Reitz分布——对真实材质的模拟更好尤其是粗糙金属表面因此成为主流D_GGX(n, h, α) α² / (π * ((n·h)² * (α² − 1) 1)²)α是粗糙度参数。这里有个Unity新手必踩的坑材质面板上你调的其实是Smoothness光滑度它和粗糙度是相反数关系roughness 1 - smoothness然后进入GGX公式时很多实现还会再平方alpha roughness * roughness所以你在Unity里把光滑度从0.9调到0.99视觉上高光变化会非常剧烈因为GGX里的α按平方关系缩小。老Unity Standard Shader里有GGXTerm函数URP里也保留类似实现inline half GGXTerm(half NdotH, half roughness) { half a2 roughness * roughness; half d (NdotH * a2 - NdotH) * NdotH 1.0h; return UNITY_INV_PI * a2 / (d * d 1e-7h); }GGX和Beckmann相比最大的特点是长尾高光中心衰减后还保留一层淡淡的拖尾余晖。粗糙金属离高光中心较远的位置仍然有反射能量视觉上更接近现实。后来很多延伸模型——比如GGX各向异性、带Clear Coat的多层GGX——主干还是它。3.2 G项Smith几何遮蔽管的其实是“遮挡”G项解决的是微表面互相遮挡的问题。一个微平面上的反射光可能在到达眼睛前被另一个微平面挡住同理入射光也可能被相邻微平面先挡住一部分。前者叫Masking后者叫Shadowing。这在粗糙表面和掠射角观察时会特别明显。如果漏掉G项粗糙金属在边缘会亮得反常看起来像发光。Smith近似把G拆成两个独立函数的乘积G(l, v) G1(l) * G1(v)每个方向单独计算被遮挡的概率。配合GGX分布时常用Smith-Schlick-GGX形式k α / 2 G1(x) (n·x) / ((n·x) * (1 - k) k)Unity里你会看到两个相关函数SmithBeckmannTerm和SmithJointGGXTerm。前者是老实现后者把阴影和遮蔽联合计算性能和匹配度更好。URP/HDRP里主推SmithJointGGXTerm。实际调Shader时如果发现粗糙表面高光异常刺眼可以先怀疑G项没接对或者接成了只乘一个方向的简化版。3.3 F项Schlick近似让菲涅尔变得便宜菲涅尔效应在生活中随处可见正面看水面是透明的斜着看水面变成一面镜子。反射率会随视线与法线的夹角变化。数学上精确的菲涅尔方程涉及折射率、吸收系数运算量不小。实时PBR普遍用Schlick近似F(v,h) F0 (1 - F0) * (1 - v·h)^5F0是垂直入射视线和表面垂直时的反射率。对非金属电介质F0大约0.04就是这个原因导致所有塑料材质暗部都有一层微弱的灰色高光对金属F0是彩色值直接取Albedo颜色作为反射主色所以金属会反射出自身的颜色。Unity里的FresnelTerm就是按Schlick实现的inline half3 FresnelTerm(half3 F0, half cosA) { return F0 (1 - F0) * pow(1 - cosA, 5); }Schlick近似的好处是只用一个5次方就能得到视觉上很准的结果。实际问题中它的表现也基本够用——除非你是写离线渲染器或者对掠射角要求极高否则不需要上完整菲涅尔公式。调材质时建议经常调整观察角度因为正面的菲涅尔变化不明显掠射角才是判断F项是否设置正确的关键。3.4 分母里的4(n·l)(n·v)能量守恒里最容易被漏掉的除数Cook-Torrance镜面BRDF里那个分母经常被人忽略但它非常重要f_s D * G * F / (4 * n·l * n·v)这个分母的作用是把微表面局部立体角转换到宏观表面立体角本质是几何与积分的对应关系。没有这个分母BRDF不符合能量守恒高光会明显过亮。但由于n·l和n·v可能趋近0分母会带来数值爆炸风险所以引擎代码里几乎都要加一个极小保护值float specularTerm D * G * F / max(4.0h * NdotL * NdotV, 0.001);我见过不少同学手写PBR时忘记这个除数高光亮度翻倍还找不到原因。另一个容易漏的是顺序BRDF里已经有一个n·l而直接光照的radiance里通常也会乘一个n·l代码里经常把这两个乘到一起再约掉你能看到实现里变成了只除一个4NdotV。这是合法的代数化简不是bug。4. 从公式到ShaderUnity里PBR参数是怎么一步步进方程4.1 URP Lit Shader里的PBS代码长什么样理论说了一堆也该看看Unity里实际代码了。Built-in管线的Standard Shader在UnityStandardBRDF.cginc里实现BRDFURP管线则把相关代码集中到了Packages/com.unity.render-pipelines.universal/Shaders/BRDF.hlsl。核心函数是InitializeBRDFData和DirectBRDF。InitializeBRDFData干的事是把材质面板上的Albedo、Metallic、Smoothness这些美术参数转成BRDF真正使用的diffuse、specular、roughnessinline void InitializeBRDFData( half3 albedo, half metallic, half specular, half smoothness, half alpha, out BRDFData outBRDFData) { half oneMinusReflectivity; half3 specularTint; // 高光工作流specular是美术给的彩色高光 // 金属工作流specular由albedo和metallic混合得到 #if defined(_SPECULAR_SETUP) specularTint specular; oneMinusReflectivity 1.0h - Max3(specularTint.r, specularTint.g, specularTint.b); #else specularTint lerp(kDielectricSpec.rgb, albedo, metallic); oneMinusReflectivity 1.0h - metallic; #endif outBRDFData.diffuse albedo * oneMinusReflectivity; outBRDFData.specular specularTint; half perceptualRoughness 1.0h - smoothness; outBRDFData.roughness perceptualRoughness * perceptualRoughness; outBRDFData.roughness2 outBRDFData.roughness * outBRDFData.roughness; ... }这里的kDielectricSpec.rgb就是那个0.04的非金属基准反射率。金属工作流里金属度等于1时漫反射系数归零specular完全取Albedo颜色金属度等于0时diffuse取Albedospecular固定0.04灰。这样就从代码层面保证了金属材质没有漫反射这个物理事实。DirectBRDF里就是实际光照计算大致逻辑half3 DirectBDRF(BRDFData brdfData, half3 normalWS, half3 lightDirectionWS, half3 viewDirectionWS, half NdotL, half NdotV) { half NdotH saturate(dot(normalWS, normalize(lightDirectionWS viewDirectionWS))); half VdotH saturate(dot(viewDirectionWS, normalize(lightDirectionWS viewDirectionWS))); half3 radiance lightColor * lightAttenuation * NdotL; half3 brdf brdfData.diffuse; brdf brdfData.specular * DirectBRDFSpecular(brdfData, normalWS, lightDirectionWS, viewDirectionWS, NdotL, NdotV, NdotH, VdotH); return brdf * radiance; }DirectBRDFSpecular内部就是DGF除以保护后的分母D用GGXG用Smith联合项F用Schlick。你把这套代码和第三章方程逐项对照完全能一一对应上。4.2 金属度、光滑度、AO分别流向哪里美术同学最关心的通常是贴图实际影响哪部分。我用一张表概括三个核心输入贴图/参数进入方程的哪部分视觉影响Albedo非金属进漫反射diffuse金属进菲涅尔F0决定固有色和金属反射主色Metallic控制diffuse与specular的混合权重0是电介质1是金属不能出现中间值Smoothness转换后进入GGX的α和Smith的k值控制高光锐利程度与反射模糊程度AO主要乘间接光diffuse可扩展到specular让接触阴影更扎实理论不参与直接光这里我特别想提醒一个Unity上游经验金属度不要设置成0.5这种中间值。物理上材质要么导电金属要么不导电电介质不存在半金属半塑料。程序员测试时随手把Metallic拖到0.5会得到一片既不漫反射也不镜面反射的“死灰”区域这种现象本质是能量不守恒不是PBR做错了。磨损旧金属的正确表达应该是用多张贴图或上下两层材质混合而不是单个材质上画0.5过渡。另外金属度和AO贴图在导入时要注意Color Space。金属度和Smoothness这类数据是线性变化如果被Unity当成sRGB贴图处理渲染结果会明显偏灰或偏暗。老项目里经常出现“我把材质参数都调对了但画面就是不对劲”的情况最后发现是贴图导入设置里没关sRGB。4.3 间接光与IBL光靠公式是渲染不出PBR的渲染方程的半球积分在实时渲染里没法逐方向算。主流方案是IBL也就是基于图像的光照。Unity里漫反射间接光和镜面间接光是分开算的漫反射间接光把环境立方体贴图卷积成辐照度图IrradianceMap运行时采样一次再乘漫反射项。Unity里可以理解为Light Probe和环境光采样的来源。镜面间接光把环境立方体贴图按不同粗糙度预过滤成不同Mip级别的PrefilteredEnvMap运行时根据材质粗糙度选择对应的Mip层采样再用菲涅尔项加权。于是又能解释一个常见问题为什么场景没有反射探针时金属材质会发黑或者像涂了锅底灰因为镜面IBL没有数据来源直接光只能提供一个太阳点光源级别的高光剩下的半球环境反射全是零。金属材质反射率本来就高没了环境输入就变成黑块。解决办法是在场景里布置反射探针或者至少让Skybox作为默认环境源存在。后处理管线里现在的SSR屏幕空间反射能在没有探针的情况下补锐利反射HDRP做得尤其完整但本质上还是对渲染方程中间接光部分的一种近似采样。移动端上SSR很难跑反射探针依然是最可靠的方案。5. PBR调参与性能优化实操中的坑和思路5.1 材质偏暗、过曝、发灰的排查顺序在Unity里做PBR最容易遇到的怪异现象就是这三个偏暗、过曝、发灰。我按自己排查经验给一个固定顺序第一验证光照环境是否完整。PBR对环境的依赖远超旧光照模型。先给场景加一个反射探针把环境光强度拉到一个合理范围再调整PBR材质本身。很多时候材质没问题是环境没喂够。第二检查贴图导入设置。金属度、粗糙度、AO这些线性数据千万别按sRGB导入。Unity默认UI看着挺正常实际渲染里可能已经偏了一个量级。你可以在材质面板上右键Shader着色查中间值颜色也可以直接用Frame Debugger看输入贴图。第三检查高光是否过曝。BRDF的F项在掠射角会快速逼近1配合高动态灯光很容易爆。Unity里灯光强度如果继续沿用旧式几千Lux的做法配合PBR高光会飞得很厉害。用ACES或Filmic色调映射能救回来但最好从源头把灯光物理单位调合理。第四检查能量守恒是否被破坏。比如金属度用了中间过渡或自定义Shader漏掉除以π和分母4。把这些除数补上过曝基本能缓解60%。发灰往往发生在粗糙度平均值过高、镜面反射缺乏环境细节的情况。一把低粗糙度材质靠探针恢复反射后灰感会立刻消失。5.2 Built-in、URP、HDRP同一方程的不同落地方式很多人关心自己在Built-in里做的PBR材质迁到URP会不会变样。先给结论底层方程完全相同仍然是GGX加Smith加Schlick的Cook-Torrance框架。差异在配套功能Built-in Standard包含完整的PBS代码在UnityStandardBRDF.cginc间接光采样用了球谐和反射探针功能丰富但源码历史包袱重。URP LitShader更结构化BRDF集中在BRDF.hlsl去掉了部分非物理扩展项参数语义更清晰。如果你原来依赖高光调得夸张的旧Shader切到URP会发现高光表现明显收敛——这不是引擎坏了是URP默认把非物理的“魔法值”去掉了。HDRP Lit在同一个BRDF主干上扩展了各向异性、清漆、次表面散射、SSS等高级功能。底层仍然是加过料的GGX体系。我自己的经验是中小项目直接用URP跟着Unity 6走材质迁移时不要指望自动完美重点看金属度、光滑度、AO这三张贴图导入设置以及反射探针是否同步迁移。Surface Type和Render Face这些选项在URP里还会影响阴影和深度写入需要逐材质过一遍。还要提一个热词里不少人问到的“Unity 2018入门与实战”相关点如果你是从老版本项目升上来Unity会自动把Standard Shader材质转成URP Lit但转换过程中Custom Shader可能变成洋红色材质需要手工重新指定。这个不用慌把Shader换成Lit重新调一遍参数就行。5.3 移动端上PBR的性能账移动端跑PBR性能瓶颈往往不是BRDF那几次乘除而是间接光采样和后处理。BRDF本身的GGX和Smith计算在现代手机GPU上不算重真正贵的是反射探针立方体贴图采样尤其多个探针插值时。后处理链Bloom、SSR、TAA这些与PBR配套的效果开销远大于材质本身。高分辨率阴影贴图与PBR高光的匹配粗糙度低时高光对阴影边缘敏感阴影质量差甚至会直接破坏材质质感。优化方向我给三条。第一移动端尽量用单反射探针或干脆用环境贴图模拟镜面IBL不要场景里铺一堆探针做实时插值。第二把PBR相关的高频噪声贴图压缩能走单通道的贴图尽量不要存RGBA四通道带宽省下来效果更明显。第三如果手写PBR可以考虑把预积分的DFG项存成LUT运行时采样省掉每像素多次pow运算。URP内部已经有类似优化自定义Shader时要记得接上。另外还有一个冷门但常见的问题粒子特效内存泄露和PBR无关但只要粒子系统上用了带镜面反射的材质在高反射环境下DrawCall和Overdraw会暴涨。做移动端时粒子材质尽量选Unlit必要的高光投射交给美术贴图模拟比实时PBR划算得多。我个人在实际项目里最后留下一个习惯无论脚本还是Shader都先把手写的PBR核心单独抽成一个工具材质场景里放一个标准光源和反射探针用对照法调参。等美术把一套材质调完再整批铺到场景。这比一边调材质一边被场景灯光干扰要高效得多。另外如果你是从二次元Shader或NPR卡通渲染转过来的请记住PBR默认假设的是物理光照环境——它不会像卡通渲染那样“逆光也清晰”。你需要给它配套合理的环境光、反射源和色调映射才能得到那种真实但不发灰的画面。这也是Unity PBR渲染方程之外真正需要时间积累的东西。
返回列表