一个我自己都想吐槽的起点:我第一次在URP里认真调PBR参数,是在Unity 2019那个“LWRP还叫LWRP”的年代。场景里一颗金属球,我把Metallic拉到1、Smoothness拉到0.8,心想着它应该立刻变成一个亮闪闪的镜子,结果画面给我回了一记响亮的耳光——那个球像块脏橡皮泥,高光模糊、暗部死黑,怎么看怎么假。那会儿我一度怀疑URP管线是不是砍了太多东西,后来才意识到问题根本出在我自己这边:PBR不是“把几个滑条拖到1”就能出效果的,它背后是一整套基于物理的着色逻辑,URP只是在替你执行公式,你怎么喂参数、怎么配环境,才决定最终画面是“真实”还是“塑料”。这篇笔记,就是我从“对着参数瞎拖”到“基本能预判结果”的完整过程,全部围绕PBR和URP管线展开,适合刚开始学渲染、或者已经会用Lit但总调不出质感的Unity开发者参考。
1. 为什么PBR总给人一种“调对了就真,调错了就塑料”的感觉
1.1 PBR与传统光照模型的核心差别
传统Blinn-Phong那种思路,本质上是把高光当成一个“不知道从哪里来的聚光灯”:Specular = pow(max(0, dot(N, H)), Shininess)。你会发现这里的Shininess只是个控制高光范围的指数,和材质本身的属性没有物理对应关系。同一个材质,Sun方向变了、相机角度变了,高光形态还是那套经验公式硬算出来的,所以场景一复杂就容易假。
PBR不一样。它把材质的光学属性拆成两个更本质的维度:金属度和粗糙度。金属度决定反射是“透明玻璃式的纯镜面”还是“漫反射加微高光”;粗糙度则描述表面微观起伏带进来的能量扩散。URP的Lit Shader里,你拖动的每一个参数,最终都会进入一套固定的光学计算管线,而不是简单靠经验拟合。这也是为什么PBR材质在不同光照环境下通常比传统模型“扛得住”:它模拟了光在无限小尺度上如何在表面反射和折射。
但请注意,PBR并不会自动让画面变真实。它更像一台调音台,把“材质表达的自由度”收敛到了少数几个有物理含义的旋钮上。你如果不知道这台调音台每个旋钮具体在改什么频段,那调起来比Blinn-Phong更容易翻车。
1.2 所有PBR参数到底在描述什么
在URP的Lit Shader里,你看到的核心参数就这几个:BaseMap、Metallic、Smoothness、Normal Map、Occlusion、Emission。它们之间的关系,我后来总结成了一句很土但很好用的话:BaseMap决定这物体“是”什么颜色,Metallic决定它“像不像金属”,Smoothness决定它“光不光滑”,法线贴图决定它“表面是不是看起来有起伏”,AO决定它“缝隙里有没有被遮挡”。
刚开始学习时,最容易把BaseMap当成传统美术的“固有色贴图”。这在一般材质里没大问题,但在金属材质里你要格外小心:金属的BaseMap其实更多是在描述金属表面的反射率颜色,而不是“涂上去的颜色”。比如一张铜的贴图,如果你把Metallic设为0,它会看起来像一面涂了铜色漆的墙壁;只有把Metallic拉高,铜色才会真正变成铜的反射特征色。这个差别用一句话记:金属和绝缘体的视觉差异,不在于表面颜色,而在于光的去处。
Smoothness更要命。我见过太多新手(包括我自己)把Smoothness拉到0.9,然后抱怨高光刺眼。Smoothness在URP里是粗糙度的反向映射:Smoothness=1对应Roughness=0的完美镜面,Smoothness=0对应粗糙得像砂纸的表面。所以调参数前,先问自己是按粗糙度思维还是按光滑度思维在调,不然很容易调出“抛光过的砂纸”这种诡异质感。
2. 微表面和能量守恒:PBR的底层物理
2.1 Cook-Torrance BRDF公式拆解
如果你去看URP Shader库里的光照函数,最终会撞上大名鼎鼎的Cook-Torrance BRDF模型。它的高光项长这样:
specular = D * G * F / (4 * NdotL * NdotV)
初次看到这个公式的人都会头大,但拆开其实很直观:
- D(法线分布函数):决定微表面法线朝向的统计分布。粗糙度越高,法线越散,高光区域越大越柔和;粗糙度越低,法线越整齐,高光越集中。
- G(几何遮蔽函数):描述微观凹凸不平的表面,在某个视线方向和光照方向下,有多少微表面被相邻凸起遮挡。这直接影响高光边缘的柔和程度和暗部质量。
- F(菲涅尔项):描述不同角度下反射能量和折射能量的比例。掠射角越接近90度,反射越强。这就是为什么你在玻璃侧面看,哪怕玻璃本身透明,也会看到一片强烈反光。
URP里的UniversalFragmentPBR函数,本质上就是把这三项按物理规则组合起来,再叠加漫反射项。你在材质面板上拖动的Smoothness,最终影响的是D和G;Metallic影响的是F和漫反射/镜面反射的能量分配。
这里我想强调一件事:不要背诵公式,去观察现象。我当时为了理解菲涅尔,盯着一个白瓷杯看了十分钟。瓷杯正对着你的地方是细腻的漫反射,而靠近轮廓边缘的地方会出现一条亮边——那就是F项在掠射角附近发威。理解了这条亮边,你就理解了为什么PBR材质在高光边缘会有一层自然过渡,而Blinn-Phong常常在高光边缘显得生硬。
2.2 能量守恒:为什么PBR不会越调越“过曝”
能量守恒在PBR里核心含义是:反射出去的能量 + 折射吸收的能量 <= 入射总能量。URP渲染不是光追,但它在反射率参数上做了一定限制。比如金属的漫反射项通常非常弱,因为金属表面把可见光大部分反射掉了,很少发生散射进入内部再出射的漫反射过程。所以金属物体的BaseMap看起来往往比非金属更“深”更“饱和”,这才是符合物理的现象。
如果你在URP里把Metallic拉到1,同时又给了很亮的BaseMap,得到的结果很可能是强烈反射环境导致的高光溢出。这其实是能量分配不自然:金属材质应该更多依赖反射来呈现质感,而不是靠固有色瓷砖。反过来,非金属物体如果给了太高的Smoothness和太强的环境反射,画面也会发腻,像包裹了一层塑料。PBR的“真实感”就藏在这些能量分配的比例里,而不是单个数值本身。
我建议你在场景里摆一个Unity自带的Sphere,专门调它,用它当“材质标尺”。一边拖动Metallic和Smoothness,一边观察反射高光的形态变化。连续调上半小时,你对PBR的感觉就和只看教程完全不一样了。
3. URP里Lit Shader到底做了哪些事:从变体到间接光
3.1 URP与内置管线的PBR差异
用过Built-in管线的朋友都知道Standard Shader有多“胖”:几十个变体、庞大的关键字组合,移动端一个材质就可以撑爆Shader变体预算。URP在设计时最大的变化就是精简了变体,并且把PBR的常见参数收敛到一个更明确的集合。
URP的Lit Shader核心Pass是UniversalForward,它被设计成同时处理主光源、附加光源、阴影、全局光照和反射探针。URP 12版本之后在Forward+模式中还能支持多光源逐物体剔除。还有一个容易被忽略的点:URP对同一Pass会优先走SRP Batcher,让不同材质之间的Draw Call提交变得非常轻量。内置管线里我们还要手动去搞的合批优化,在URP里只要你Shader写得规范,SRP Batcher能自动吃上很多红利。
但代价是:你不能再用内置管线的老Shader库直接跑在URP上。内置Shader里的LightMode、阴影宏、顶点光照函数名都和URP不兼容。项目从内置管线切换到URP时常见的一堆紫色材质,很大程度就是因为Shader里写的是UnityCG.cginc、_LightColor0这些内置管线产物,而URP只认Core.hlsl和Lighting.hlsl。
3.2 PBR所需的间接光在URP如何处理
PBR之所以比传统高光模型“有质感”,很大功劳来自间接光,尤其是环境反射。URP处理间接光分两条路:
- 间接漫反射:URP会把Lightmap、Light Probe、SH(球谐)信息烘焙出来。对动态物体来说,
SampleSH提供的低频环境光已经足够让金属和非金属区分开。 - 间接镜面反射:靠反射探针(Reflection Probe)采样Cubemap。URP里实时反射探针默认是混合进入环境光照的,材质粗糙度越高,Cubemap采样用的LOD级别越高,反射越模糊。
这里要单独强调反射探针的重要性。PBR的金属材质几乎全靠反射探针撑场子,如果场景没有放置任何反射探针,金属球就只剩一个直射高光,周围什么都映不出来,看起来就是一块黑铁。很多人在URP里调了半天金属质感,其实只要在场景中心放一个Reflection Probe再调一下分辨率,效果立刻就不一样了。与其纠结具体参数,不如先检查“环境里给PBR的东西到底够不够”。
URP还支持屏幕空间反射(Screen Space Reflection),但SSR在URP里默认是关闭的,渲染开销比较大。移动端项目通常不建议开,PC端如果追求质感可以尝试,但也要注意它和反射探针的衔接关系。
4. 自己动手写一个URP PBR Shader:核心结构逐行拆
4.1 为什么建议手写一个基础版
用URP的Lit材质做项目,你其实完全可以不写Shader。但如果你想把PBR原理落到自己的代码里,我强烈建议手写一个最简版。原因很简单:Lit Shader封装了太多东西,你没法在Inspector里看到“UniversalFragmentPBR在调参时具体做了什么”。
我自己手写PBR Shader的契机,是项目里需要一个带自定义顶点动画的材质。把Lit Shader改到一半才发现,Lit是一个巨大的Shader,改错了某个变体逻辑,材质直接在Game视图里消失。后来我一咬牙,基于URP库写了一个只保留核心PBR计算的Shader,出问题时排查起来反而快得多。
手写的好处有三个:第一,你能直观看到URP自带的光照和阴影宏如何工作;第二,材质属性完全由自己控制,可以在顶点阶段做各种自定义动画;第三,去掉大量用不到的变体后,构建体积会更小。
4.2 一个可运行的URP PBR Shader框架
下面这个Shader基于URP 14(Unity 2022.3)的库函数,实现了最核心的PBR光照:BaseMap、Metallic、Smoothness、法线贴图、AO、主光源阴影和间接光。它没有写额外的变体宏,也没有做多光源循环,适合作为学习起点:
Shader "Custom/URP_BasicPBR" { 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 _BumpMap("Normal Map", 2D) = "bump" {} _BumpScale("Bump Scale", Range(0,1)) = 1.0 _Occlusion("Occlusion", 2D) = "white" {} _OcclusionStrength("Occlusion Strength", Range(0,1)) = 1.0 } SubShader { Tags { "RenderPipeline" = "UniversalPipeline" "RenderType" = "Opaque" "Queue" = "Geometry" } Pass { Name "ForwardLit" Tags { "LightMode" = "UniversalForward" } HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; float3 normalOS : NORMAL; float4 tangentOS : TANGENT; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; float3 normalWS : TEXCOORD1; float3 positionWS : TEXCOORD2; float4 tangentWS : TEXCOORD3; }; TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); TEXTURE2D(_BumpMap); SAMPLER(sampler_BumpMap); TEXTURE2D(_Occlusion); SAMPLER(sampler_Occlusion); CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; float4 _BumpMap_ST; float4 _Occlusion_ST; half4 _BaseColor; half _Metallic; half _Smoothness; half _BumpScale; half _OcclusionStrength; CBUFFER_END Varyings vert(Attributes IN) { Varyings OUT; OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz); OUT.uv = TRANSFORM_TEX(IN.uv, _BaseMap); OUT.normalWS = TransformObjectToWorldNormal(IN.normalOS); OUT.positionWS = TransformObjectToWorld(IN.positionOS.xyz); real sign = IN.tangentOS.w * GetOddNegativeScale(); OUT.tangentWS = real4(TransformObjectToWorldDir(IN.tangentOS.xyz), sign); return OUT; } half4 frag(Varyings IN) : SV_Target { half4 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv) * _BaseColor; half metallic = _Metallic; half smoothness = _Smoothness; half occlusion = SAMPLE_TEXTURE2D(_Occlusion, sampler_Occlusion, IN.uv).r * _OcclusionStrength; half3 normalTS = UnpackNormal(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, IN.uv)); normalTS.xy *= _BumpScale; half3 normalWS = normalize(TransformTangentToWorld(normalTS, real3x3( IN.tangentWS.xyz, cross(IN.normalWS, IN.tangentWS.xyz) * IN.tangentWS.w, IN.normalWS ))); InputData inputData = (InputData)0; inputData.positionWS = IN.positionWS; inputData.normalWS = normalWS; inputData.viewDirectionWS = GetWorldSpaceNormalizeViewDir(IN.positionWS); inputData.shadowCoord = TransformWorldToShadowCoord(IN.positionWS); inputData.bakedGI = SampleSH(normalWS); SurfaceData surfaceData = (SurfaceData)0; surfaceData.albedo = albedo.rgb; surfaceData.metallic = metallic; surfaceData.smoothness = smoothness; surfaceData.normalWS = normalWS; surfaceData.occlusion = occlusion; surfaceData.alpha = albedo.a; surfaceData.specular = half3(0.5, 0.5, 0.5); surfaceData.emission = half3(0, 0, 0); half4 color = UniversalFragmentPBR(inputData, surfaceData); return color; } ENDHLSL } } }这段代码最核心的调用就一行:UniversalFragmentPBR(inputData, surfaceData)。它会把漫反射、高光、阴影、环境光全部算好。你真正要关心的其实是SurfaceData这个结构体里各项的赋值,它就是材质参数的“物理含义入口”。
有人会问,为什么法线贴图要UnpackNormal,然后TransformTangentToWorld?因为URP默认的PBR计算主要在世界空间完成,而法线贴图里存的是切线空间的法线。你如果不做这两步,直接用顶点法线替代,高光细节就会完全消失,表面看起来会非常光滑。
4.3 SRP Batcher兼容与Pass完整性
上面的Shader在编辑器里可以跑起来并和Lit材质做对比,但离真正落地还差三件套:CBUFFER_START(UnityPerMaterial)、额外的Pass、以及变体声明。
CBUFFER_START(UnityPerMaterial)是关键中的关键。我上面代码里特意加上了这一块,因为URP的SRP Batcher要求每个材质属性都必须放在名为UnityPerMaterial的常量缓冲里,否则材质之间没法批量合批。很多人手写Shader后发现Draw Call数量暴增,十有八九是属性没有包进CBuffer。
然后是Pass完整性。URP对PBR材质要求不止一个前向Pass,常见还需要:
ShadowCasterPass:用于投射阴影,没有它物体就无法在地面投出正确影子。DepthOnlyPass:用于URP的深度预处理,很多后处理效果会受影响。MetaPass:用于光照贴图烘焙,没有它物体烘焙出来的GI信息就不完整。DepthNormalsPass:需要摄像机深度法线纹理时使用。
你可以在#pragma里写:
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE #pragma multi_compile_fragment _ _SHADOWS_SOFT这样才能让物体正确接收阴影,并让阴影在距离较远时有级联效果。本节给出了一个可扩展的框架,真正的项目里你还会需要阴影采样、顶点雾效、光照贴图等一堆东西,但核心PBR逻辑已经完整呈现。从这套框架出发,往里加功能,总比拿Lit整个魔改来得轻松。
5. PBR进URP后最容易翻车的五个细节
5.1 线性空间与sRGB:为什么颜色偏暗或者偏亮
PBR在大屏幕上容易翻车的头号原因,不是参数,而是色彩空间。URP项目如果开了Linear着色空间,那么所有sRGB颜色贴图在GPU采样后会先被转换到线性空间参与计算,最后输出再转回sRGB。这是符合物理的流程,但有个前提:你的贴图导入设置必须勾对。
BaseMap属于sRGB贴图,导入纹理时要保持“sRGB (Color Texture)”勾选;而Metallic、Roughness、AO这类面积数据贴图,不应该作为颜色贴图处理,应当取消sRGB,让数据保持原始数值。我见过不少项目把AO贴图直接设置成sRGB,结果场景暗部偏灰、对比度诡异,调了半天PBR参数都没用,最后发现是贴图空间搞错了。
如果你是个人练习项目,建议直接在Player Settings里把Color Space设为Linear,然后拿一个红球当标尺。线性空间下的高光衰减更顺滑,颜色混合不容易出现脏脏的暗部。URP是强烈推荐线性空间的管线,Gamma空间下PBR的很多效果都会显得发灰。
5.2 法线贴图与切线空间:URP里的坑
法线贴图在URP里失败的最常见原因,是导入设置里**“Normal Map”勾选没开**。如果你把一张法线贴图当作普通纹理采样,UnpackNormal拿到的数据全是错的,表面会出现奇怪的晒伤状高光。
还有一个容易踩的坑是压缩格式。移动平台上法线贴图常用RGBA压缩,但因为法线Z方向精度问题,某些硬件上会出明显的硬边。我个人的习惯是:法线贴图尽量用ASTC,避免过强的压缩率,同时把_BumpScale控制在1.0附近,太高的强度会让法线失真,出现“贝壳状”反光。
如果你写自定义Shader,记得在属性里声明法线贴图的默认贴图为"bump",这会让Unity自动提供一个内建的凹法线贴图,代表“没有法线细节”的情况。不然即使材质面板上没拖法线贴图,代码里采样出来也可能是一张全零贴图,导致整个物体黑掉。
5.3 反射探针缺失:金属直接变成死黑
前面提到PBR的金属质感高度依赖反射探针,这里具体说下表现和排查方式。当你把Metallic设为1、Smoothness设为0.9,却没有Reflection Probe时,一个金属表面只能看到远处主光源的一个亮点,其余区域都是暗色。这个时候你往往会误以为是Metallic太高,去把Metallic往下拉,结果金属变成了塑料。
正确的验证方式:在场景里放一个Reflection Probe,并把Type设为Realtime,然后观察金属球的反射。URP的反射探针不支持实时更新所有物体,你可以在编辑器里点击Bake一次。看到Cubemap反射出现在曲面上,你才会真正明白PBR的“真实感”到底是怎么来的。
另外要注意,URP的反射探针通常用LOD来模拟粗糙表面的模糊反射。Smoothness越低,采样Cubemap时使用的mip级别越高。如果你手动写Shader时没有根据roughness去调整采样mip,金属表面的反射会一直锐利得像个镜子,看起来也很假。
5.4 Smoothness/粗糙度倒置和贴图通道打包
Unity的默认模板里叫Smoothness,但很多Substance Designer、Quixel Megascan资源导出的贴图叫Roughness。如果你把Roughness贴图直接拖进URP Lit的Smoothness通道,得到的高光反射将完全是反向的——金属最亮的地方会变暗,粗糙的地方反而像镜面。这是最经典的“倒置问题”。
解决方式很简单,要么在贴图导入时写一个简单的饱和度反转,要么在Shader属性里加一句注释提醒自己。Substance导出时也可以直接选择导出Smoothness贴图,省去后期处理。
更进阶的做法是使用打包贴图:Metallic放在R通道,Smoothness放在G通道,AO放在B通道,甚至高度信息放A通道。这样一张纹理就携带了三个PBR参数,带宽节省非常明显。URP的Surface Input里完全可以支持这种用法,你只需要在Shader里拆通道即可:
half metallic = SAMPLE_TEXTURE2D(_MaskMap, sampler_MaskMap, uv).r; half smoothness = SAMPLE_TEXTURE2D(_MaskMap, sampler_MaskMap, uv).g; half occlusion = SAMPLE_TEXTURE2D(_MaskMap, sampler_MaskMap, uv).b;很多游戏项目美术资源规范里,金属、粗糙度、AO都是这样压在一张贴图里的。URP本身没有强制要求,但掌握这种做法对控制项目包体和内存非常有帮助。
5.5 变体裁剪与不必要的Keyword:性能隐忧
URP的Lit Shader已经做了大量变体管理,但你自己写的PBR Shader容易变成变体炸弹。比如你在Shader里写了多个multi_compile,但没有用到对应功能,Unity在构建时会把所有组合全部编译进去,可能导致构建变体数量暴涨。
一个实践经验:在Project Settings里,找到URP的全局设置,开启“Shader Variant Log”和“Strip Unused Variants”。构建后检查日志,把用不到的Keyword从Shader里手动删除。特别是移动端,如果不需要级联阴影、不需要附加光源,就把相关Keyword彻底去掉,只保留主光源和阴影最基本的组合。
我见过一个项目,美术自研了几十个带PBR的自定义Shader,每个人在propety里都添加了一堆无关的Keyword,最后Android包的Shader变体数量到了几万个,启动时卡了很久。做PBR本身没毛病,但变体控制不到位,再好的PBR也会被交付流程拖垮。
6. 移动端与性能:PBR在URP下的实用性
6.1 Lit、SimpleLit、ComplexLit怎么选
URP提供了一套“按需求选型号”的着色方案,常常有人忽略这里面的性能差异。常规PBR看Lit就够了,但如果你在手机项目里追求更高性能和更低发热,SimpleLit也是一个合理选择。SimpleLit是简化版的PBR,高光项更粗糙,没有复杂的多光源和间接镜面处理,但胜在速度快。
还有ComplexLit,它支持额外的Clear Coat(清漆层)等效果。绝大多数项目的PBR根本用不到那么高级的光学特性,盲目上ComplexLit只会白白吃掉性能预算。我的建议是:默认用Lit,先看Profiler,再决定是否降级到SimpleLit。很多团队在前期就统一做了决定,结果后面美术需要一点金属边缘反射时只能后悔。
6.2 从标准PBR退回近似方案的经验
有一类项目特别适合“退回近似方案”:纯卡渲或风格化项目。PBR不是万能药,也不一定永远比风格化渲染适合。URP的Lit Shader可以调出非常写实的金属,但如果你整个游戏的风格是柔和卡渲,一个写实的金属物体反而会刺眼。
URP里的UniversalRendererData允许你挑选材质和Shader组合,甚至可以自制一个到两个Shader代表不同的PBR层级:最高精度层用于主角和道具,低精度层用于场景中的大量重复物件。这样PBR带来的内存、变体、贴图带宽问题,可以通过分层次控制来缓解。
从我踩过的坑来看,团队里引入PBR一定要配一份“参数规范”。什么是金属贴图的最小尺寸,AO贴图怎么命名,是否强制打包成MaskMap,这些看似琐碎的规范,能在后期省下无数沟通成本。PBR真正落地困难的地方往往不是算法,而是流程一致性。毕竟URP已经把公式写好了,剩下的,是人对“物理”、“环境”和“性能”三者之间的理解与取舍。