
1. 从零理解PBR为什么它成了现代渲染的标配第一次接触PBRPhysically Based Rendering基于物理的渲染是在做一个金属材质场景的时候。当时用传统的高光贴图调来调去金属看着像塑料塑料看着像金属怎么都不对劲。后来被一位前辈点了一句“你试试PBR”才算是真正入了这个坑。这篇笔记就是把我从URP管线里一步步搭建PBR Shader的过程整理出来包括原理理解、代码实现、参数调试和踩过的坑适合有一定Shader基础、想在URP里手写PBR的开发者参考。PBR说白了就是一套“让渲染结果符合物理直觉”的着色方案。它的核心思路是光与物体表面的交互遵循能量守恒反射的光不会凭空多出来也不会凭空消失。传统渲染里我们经常随手给一个高光值看着好看就行但PBR要求你按照材质的物理属性来定义——这个材质是金属还是非金属粗糙度多少反射率多少。这些参数一旦定下来光照环境变了材质表现依然自洽。在URP管线里做PBR和内置管线有几点关键区别。URP的Shader写法用的是HLSL的Shader Library体系光照计算通过Lighting.hlsl里的函数来完成而不是内置管线那套UNITY_LIGHT_ATTENUATION宏。另外URP默认走的是前向渲染路径每个Pass处理一盏灯这意味着我们的PBR Shader需要自己处理多光源的叠加逻辑。这些差异如果不搞清楚直接从内置管线抄代码过来大概率是编译报错或者光照结果完全不对。我选择在URP里手写PBR而不是直接用Lit Shader原因有两个。一是Lit Shader虽然开箱即用但它的参数面板和内部逻辑是封装好的想加一些自定义效果比如各向异性高光、清漆层就得改源码维护成本高。二是手写一遍才能真正理解BRDF每一项在干什么后面做风格化渲染或者性能优化的时候心里有底。下面我会从BRDF的数学原理开始拆然后一步步在URP里实现最后分享调试过程中遇到的那些坑。2. PBR核心原理拆解BRDF到底在算什么2.1 渲染方程与BRDF的物理含义PBR的理论基础是渲染方程完整形式是出射辐射度等于自发光加上入射辐射度乘以BRDF再乘以余弦项在半球上的积分。这个公式看着吓人但拆开理解就还好。BRDF双向反射分布函数描述的是从某个方向来的光打到表面后有多少比例反射到了观察方向。它是个比值输入是入射方向和出射方向输出是一个分布值。BRDF必须满足两个物理约束。第一是能量守恒在整个半球上积分BRDF乘以余弦项结果不能大于1否则材质会自己发光。第二是亥姆霍兹互易性交换入射和出射方向BRDF值不变。这两个约束是后面选择具体BRDF模型时的硬性标准不满足的模型在物理上就是错的虽然有时候看着好看但在多光源环境下会出问题。实际工程中我们用的BRDF模型是简化过的最经典的就是Cook-Torrance模型。它把BRDF拆成漫反射项和镜面反射项两部分。漫反射项用Lambert模型镜面反射项用微表面模型。微表面理论的核心假设是物体表面在微观尺度上是由无数微小的镜面组成的每个微面元都遵循菲涅尔反射但它们的法线方向各不相同。宏观上看到的粗糙度其实就是这些微面元法线分布的方差。2.2 微表面模型的三个核心函数Cook-Torrance的镜面反射项由三个函数组成法线分布函数D、几何遮蔽函数G、菲涅尔函数F。这三个函数各自负责一个物理现象缺一不可。法线分布函数D描述的是微面元法线朝向半角向量的概率密度。半角向量是入射方向和观察方向的中间方向只有法线正好朝向这个方向的微面元才能把光从入射方向反射到观察方向。D函数的选择直接影响高光的形状和宽度。常用的有GGX、Beckmann、Blinn-Phong。GGX的尾部衰减更慢高光边缘更柔和更符合真实材质的测量数据所以现在基本是默认选择。几何遮蔽函数G描述的是微面元之间的遮挡关系。当表面粗糙度很高的时候微面元会互相挡住一部分反射光被遮挡一部分入射光被遮挡。这个函数保证了能量守恒没有它的话粗糙材质在掠射角下会异常亮。常用的有Smith-GGX、Schlick-GGX。Smith模型把遮蔽拆成入射和出射两部分分别计算再相乘物理上更合理。菲涅尔函数F描述的是反射率随观察角度变化的现象。垂直看水面的时候反射很弱能看到水底斜着看的时候反射很强像镜子一样。这就是菲涅尔效应。在PBR里菲涅尔函数决定了镜面反射的强度。Schlick近似是最常用的形式它用一个F0值垂直入射时的反射率和视角余弦的幂次来插值。对于非金属F0一般在0.02到0.05之间对于金属F0就是基础颜色本身。2.3 金属度工作流与高光工作流的选择PBR材质有两种主流工作流金属度工作流和高光工作流。金属度工作流用三个贴图基础颜色、金属度、粗糙度。高光工作流用四个贴图漫反射颜色、高光颜色、光泽度、粗糙度。两者在数学上可以互相转换但工程上选择哪种取决于项目需求。金属度工作流的优势是参数少美术容易理解。金属度是个二值化的概念要么是金属要么不是中间值只用于过渡区域。基础颜色在金属区域表示F0在非金属区域表示漫反射颜色。这种工作流在URP的Lit Shader里是默认支持的贴图打包也简单金属度和粗糙度可以塞进同一张贴图的不同通道。高光工作流的优势是控制更精细适合需要精确控制反射颜色的场景比如车漆、珠宝。但它的缺点是美术容易调出物理上不合理的材质而且贴图数量多内存占用大。我在项目里一般用金属度工作流只有在做特定材质比如多层清漆的时候才会切换到高光工作流。选择金属度工作流还有一个实际原因URP的Lit Shader和大多数PBR材质库都默认输出金属度工作流的贴图。如果自己写Shader用高光工作流导入外部资源的时候还得做转换增加管线复杂度。除非项目有明确的艺术方向要求否则金属度工作流是更稳妥的选择。3. URP管线下的PBR Shader实现3.1 Shader框架搭建与属性定义在URP里写Shader第一步是搭好框架。URP的Shader必须包含RenderPipeline标签指定为UniversalPipeline。Pass里需要包含LightMode标签前向渲染用UniversalForward。另外URP要求Shader包含UniversalForwardOnly或者SRPDefaultUnlit来处理一些特殊情况但基础的PBR Shader只需要UniversalForward就够了。属性块里需要定义PBR的核心参数。基础贴图、基础颜色、金属度、粗糙度、法线贴图、环境光遮蔽贴图。金属度和粗糙度我习惯用滑条控制范围0到1。法线贴图需要额外一个强度参数。环境光遮蔽用滑条控制强度。这些参数的定义方式直接影响材质面板的易用性参数命名要清晰分组要合理。Properties { _BaseMap(Base Map, 2D) white {} _BaseColor(Base Color, Color) (1,1,1,1) _Metallic(Metallic, Range(0,1)) 0.0 _Smoothness(Smoothness, Range(0,1)) 0.5 _NormalMap(Normal Map, 2D) bump {} _NormalScale(Normal Scale, Range(0,2)) 1.0 _OcclusionMap(Occlusion Map, 2D) white {} _OcclusionStrength(Occlusion Strength, Range(0,1)) 1.0 }这里有个细节粗糙度和光滑度是反的。粗糙度越高高光越散光滑度越高高光越集中。URP的Lit Shader用的是光滑度但很多PBR贴图库输出的是粗糙度。我建议在Shader里统一用粗糙度因为BRDF公式里用的是粗糙度转换来转换去容易出错。如果美术习惯用光滑度可以在属性面板里做一次转换内部计算还是用粗糙度。3.2 顶点着色器与基础数据传递顶点着色器的任务是把模型空间的顶点位置、法线、切线转换到世界空间并计算纹理坐标。URP提供了TransformObjectToWorld和TransformObjectToWorldNormal等函数比内置管线的UnityObjectToWorldNormal更规范。切线需要单独处理因为法线贴图的解码需要切线空间。Varyings vert(Attributes input) { Varyings output (Varyings)0; VertexPositionInputs vertexInput GetVertexPositionInputs(input.positionOS.xyz); VertexNormalInputs normalInput GetVertexNormalInputs(input.normalOS, input.tangentOS); output.positionCS vertexInput.positionCS; output.positionWS vertexInput.positionWS; output.normalWS normalInput.normalWS; output.tangentWS normalInput.tangentWS; output.bitangentWS normalInput.bitangentWS; output.uv TRANSFORM_TEX(input.uv, _BaseMap); return output; }这里有个容易踩的坑GetVertexNormalInputs返回的切线是float4其中w分量表示副切线的方向。如果模型没有导入切线数据这个函数会返回一个默认值法线贴图就会失效。所以导入模型的时候一定要勾选“Import Tangents”。另外如果模型做了非统一缩放切线也需要用逆转置矩阵变换URP的GetVertexNormalInputs内部已经处理了这个问题直接调用就行。3.3 片元着色器中的BRDF计算片元着色器是核心部分。首先采样基础贴图、法线贴图、金属度粗糙度贴图、环境光遮蔽贴图。然后解码法线贴图到世界空间。接着计算BRDF的各个项。最后把所有光源的贡献加起来再加上环境光。法线贴图的解码需要构建TBN矩阵。TBN矩阵由切线、副切线、法线三个正交基组成。解码后的法线是切线空间的乘以TBN矩阵转换到世界空间。这里要注意法线贴图的格式URP默认的法线贴图是OpenGL格式y分量需要翻转。如果导入的贴图是DirectX格式需要在导入设置里勾选“Flip Green Channel”。half4 baseMap SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv); half3 albedo baseMap.rgb * _BaseColor.rgb; half metallic SAMPLE_TEXTURE2D(_MetallicMap, sampler_MetallicMap, input.uv).r * _Metallic; half roughness 1.0 - SAMPLE_TEXTURE2D(_SmoothnessMap, sampler_SmoothnessMap, input.uv).r * _Smoothness; half occlusion lerp(1.0, SAMPLE_TEXTURE2D(_OcclusionMap, sampler_OcclusionMap, input.uv).r, _OcclusionStrength); half3 normalTS UnpackNormalScale(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, input.uv), _NormalScale); half3 normalWS TransformTangentToWorld(normalTS, half3x3(input.tangentWS, input.bitangentWS, input.normalWS)); normalWS normalize(normalWS);BRDF计算部分漫反射用Lambert镜面反射用Cook-Torrance。菲涅尔用Schlick近似法线分布用GGX几何遮蔽用Smith-GGX。这些函数在URP的Lighting.hlsl里都有现成的实现但为了理解原理我建议先自己写一遍再对比URP的实现。half3 F0 lerp(0.04, albedo, metallic); half3 F F0 (1 - F0) * pow(1 - saturate(dot(halfDir, viewDir)), 5); half D GGX_Distribution(NdotH, roughness); half G Smith_GGX_Geometry(NdotV, NdotL, roughness); half3 specular (D * G * F) / (4 * NdotV * NdotL 0.001); half3 diffuse (1 - F) * (1 - metallic) * albedo / PI; half3 color (diffuse specular) * lightColor * NdotL * shadowAttenuation;这里有几个数值稳定性的细节。分母加0.001是为了防止除零但更好的做法是用max(4 * NdotV * NdotL, 0.001)。另外NdotL为负的时候要直接跳过否则会出现背面漏光。URP的Lighting.hlsl里用NdotL saturate(dot(normalWS, lightDir))来处理但这样在掠射角下会有精度问题建议用max(0, dot(normalWS, lightDir))。3.4 多光源与环境光的处理URP的前向渲染路径下每个Pass处理一盏灯。主光源用UniversalForward额外光源用UniversalForwardAdd。在Shader里需要通过_ADDITIONAL_LIGHTS宏来判断当前Pass处理的是主光源还是额外光源。额外光源的Pass需要开启混合模式把结果叠加到主光源的结果上。#ifdef _ADDITIONAL_LIGHTS uint pixelLightCount GetAdditionalLightsCount(); for (uint lightIndex 0; lightIndex pixelLightCount; lightIndex) { Light light GetAdditionalLight(lightIndex, input.positionWS); color CalculatePBR(light, ...); } #endif环境光部分URP提供了SampleSH函数来采样球谐光照。对于镜面反射需要用反射探针。GlossyEnvironmentReflection函数可以根据粗糙度采样不同级别的反射探针。粗糙度越高采样的mip级别越高反射越模糊。这个函数内部用了SAMPLE_TEXTURE2D_LOD来手动指定mip级别因为反射探针的mip和粗糙度的对应关系不是线性的需要做一次转换。half3 ambient SampleSH(normalWS) * albedo * occlusion; half3 reflection GlossyEnvironmentReflection(reflect(-viewDir, normalWS), positionWS, roughness, occlusion); half3 environment ambient reflection * F0;环境光遮蔽只影响漫反射环境光不影响镜面反射。因为镜面反射是方向性的遮蔽贴图是标量没法表达方向性遮蔽。如果要让镜面反射也有遮蔽效果需要用屏幕空间的环境光遮蔽但那是后处理的事了。4. 调试与优化那些文档里不会写的坑4.1 常见视觉问题与排查思路PBR Shader写完之后最常见的视觉问题有几种。第一种是材质看起来像塑料金属感不足。这通常是金属度贴图没起作用或者F0值设置不对。检查方法是把金属度拉到1看基础颜色是否变成了反射颜色。如果没变化说明金属度参数没传到片元着色器里。第二种是高光过曝或者过暗。过曝通常是菲涅尔项在掠射角下没有做饱和处理导致F值超过1。过暗可能是粗糙度贴图反了光滑度当粗糙度用了。我习惯在Shader里加一个调试模式用#ifdef DEBUG_ROUGHNESS把粗糙度直接输出成颜色一眼就能看出来对不对。第三种是法线贴图方向不对。表现是光照方向反了或者凹凸感不对。检查方法是把法线贴图关掉看基础光照是否正常。如果正常那就是TBN矩阵的问题。常见原因是切线空间的副切线方向反了需要把bitangent乘以-1。URP的GetVertexNormalInputs返回的bitangent方向是右手系的如果模型是从其他引擎导入的可能需要翻转。第四种是能量不守恒粗糙材质在掠射角下异常亮。这通常是几何遮蔽函数没写对或者菲涅尔项没有和几何遮蔽项正确耦合。Smith-GGX的几何遮蔽需要同时考虑入射和出射方向的遮蔽只算一个方向的话就会出问题。4.2 性能优化与移动端适配PBR在移动端的性能开销主要来自三个方面BRDF计算、多光源循环、反射探针采样。BRDF计算里GGX的法线分布函数和Smith的几何遮蔽函数都有除法移动端GPU的除法指令比较慢可以用近似公式替代。比如用1/(a b)替代1/a 1/b虽然精度差一点但速度快很多。多光源循环在移动端要严格控制光源数量。URP的Additional Lights设置里可以限制每个物体的最大光源数一般设成4就够了。超过4个光源的场景用顶点光照或者烘焙光照来补充。另外额外光源的Pass会打断合批如果场景里大量物体受多光源影响DrawCall会飙升。解决办法是用光照贴图或者光照探针把静态物体的光照烘焙掉。反射探针的采样在移动端也很贵。GlossyEnvironmentReflection内部会采样多次粗糙度越高采样次数越多。如果项目不需要精确的镜面反射可以把反射探针的Resolution调低或者直接用球谐光照代替。球谐光照只能表达低频的环境光但性能开销几乎为零。还有一个容易被忽略的点Shader变体数量。PBR Shader如果开了法线贴图、环境光遮蔽、多光源、阴影等关键字变体数量会爆炸。移动端打包的时候Shader变体过多会导致包体增大和加载变慢。解决办法是用shader_feature_local替代multi_compile只保留实际用到的变体。另外可以在URP的Shader Stripping设置里把不需要的关键字剥掉。4.3 参数调试的实操心得调PBR材质参数的时候我习惯先定金属度和粗糙度再调基础颜色。因为金属度和粗糙度决定了材质的光照响应基础颜色只是在这个响应上做微调。如果先调颜色后面改金属度的时候颜色又得重调来回折腾。金属度贴图我一般用二值化的要么0要么1只在边缘过渡区域用中间值。因为现实中纯金属和纯非金属是明确的合金的金属度也是接近1的。中间值只用于表现氧化层、污渍这些过渡区域。粗糙度贴图可以丰富一些从0.1到0.9都有但要注意粗糙度的分布要符合材质的物理特性。比如塑料的粗糙度一般在0.2到0.5之间金属的粗糙度范围更广从镜面到磨砂都有。环境光遮蔽贴图我一般只用在漫反射环境光上强度控制在0.5到0.8之间。太强的话物体的缝隙会显得很脏太弱的话又起不到遮蔽效果。另外环境光遮蔽贴图不要和法线贴图冲突如果法线贴图已经表达了凹凸细节环境光遮蔽贴图就只需要表达大尺度的遮蔽关系比如墙角、缝隙这些。调试的时候我建议用一个中性灰的环境光不要用纯白或者纯黑。纯白环境光会让所有材质看起来都很亮分不清是材质本身亮还是环境光亮。中性灰环境光下材质的反射和漫反射都能看清楚。另外可以在场景里放一个参考球用已知参数的材质比如粗糙度0.5、金属度0的灰色球用来对比其他材质的亮度。5. 从PBR延伸还能怎么玩5.1 各向异性与清漆层的实现思路基础的PBR Shader写完之后可以往上加各向异性。各向异性适合表现拉丝金属、头发、丝绸这些材质。实现思路是修改法线分布函数把GGX的粗糙度从一个标量改成两个值分别对应切线和副切线方向的粗糙度。这样高光就会沿着切线方向拉长形成拉丝效果。清漆层适合表现车漆、钢琴漆这些有透明涂层的材质。实现思路是在基础BRDF之上再叠一层镜面反射这一层的粗糙度很低菲涅尔项用固定的F0值比如0.04。清漆层的反射和基础层的反射要按能量守恒的方式混合清漆层反射的光不能再参与基础层的计算。half3 clearCoatF F0_clearCoat (1 - F0_clearCoat) * pow(1 - NdotV, 5); half3 clearCoatSpecular clearCoatF * D_clearCoat * G_clearCoat / (4 * NdotV * NdotL); half3 baseLayer (diffuse specular) * (1 - clearCoatF); half3 finalColor baseLayer clearCoatSpecular;各向异性和清漆层都会增加Shader的复杂度和变体数量移动端要谨慎使用。如果项目里只有少数几个材质需要这些效果可以单独写一个Shader不要往基础PBR Shader里塞。5.2 与卡通渲染的结合PBR和卡通渲染看起来是两条路但其实可以结合。卡通渲染的核心是色阶化把连续的光照结果映射到几个离散的色阶上。PBR的结果可以作为色阶化的输入这样卡通材质也能有物理正确的反射和菲涅尔效果。实现思路是在PBR计算完之后对漫反射部分做色阶化镜面反射部分保留连续值。这样既有卡通的硬边阴影又有PBR的高光质感。色阶化的阈值和过渡宽度可以做成参数让美术调整。这种混合方案在一些二次元风格的游戏里很常见比如用PBR做基础光照再用色阶化做风格化处理。5.3 后续学习方向PBR的进阶方向有几个。一个是基于图像的照明用环境贴图做漫反射和镜面反射的卷积比球谐光照和反射探针更精确。另一个是屏幕空间的反射用深度和法线重建场景实时计算反射适合水面和光滑地面。还有体积雾和次表面散射这些都属于PBR的延伸但实现复杂度高很多。如果想把PBR吃透我建议从URP的Lit Shader源码读起。URP的Lit Shader是官方实现的PBR代码结构清晰注释也全。读一遍能解决很多自己写的时候想不通的问题。另外可以看看Filament的文档Google的Filament引擎对PBR的讲解非常系统从理论到实现都有而且有在线Demo可以对照着调参数。我在实际项目里用PBR最大的体会是参数不要凭感觉调要对着参考图调。找一张真实材质的照片把它的金属度、粗糙度、基础颜色分析出来再往Shader里填。这样调出来的材质放到任何光照环境下都不会崩。凭感觉调的材质换个场景就露馅了。