这问题隔三差五就会在群里看到一次: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. 从公式到Shader:Unity里PBR参数是怎么一步步进方程
4.1 URP Lit Shader里的PBS代码长什么样
理论说了一堆,也该看看Unity里实际代码了。Built-in管线的Standard Shader在UnityStandardBRDF.cginc里实现BRDF;URP管线则把相关代码集中到了Packages/com.unity.render-pipelines.universal/Shaders/BRDF.hlsl。核心函数是InitializeBRDFData和DirectBRDF。
InitializeBRDFData干的事,是把材质面板上的Albedo、Metallic、Smoothness这些美术参数转成BRDF真正使用的diffuse、specular、roughness:
inline 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取Albedo,specular固定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用GGX,G用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 Lit:Shader更结构化,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渲染方程之外,真正需要时间积累的东西。