1. 从零理解PBR:为什么它成了现代渲染的标配
第一次接触PBR(Physically 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里填。这样调出来的材质,放到任何光照环境下都不会崩。凭感觉调的材质,换个场景就露馅了。