
1. 从“光照看起来不对劲”说起PBR到底在解决什么问题很多人第一次接触PBR是因为在URP管线里搭完场景后发现一个很尴尬的现象同样的模型、同样的贴图在别人的工程里质感拉满到了自己手里却像塑料玩具。金属不像金属布料不像布料光照一打上去要么死黑一片要么高光糊成一团。这时候你去搜解决方案十有八九会看到三个字母——PBR。PBR全称Physically Based Rendering中文叫基于物理的渲染。它的核心思路其实一句话就能概括用物理世界的规律去描述光与材质的交互而不是靠美术凭感觉调参数。在PBR出现之前我们调材质基本是“看着差不多就行”高光想亮就调亮反射想强就拉满结果就是换一个光照环境整个场景就崩了。PBR要解决的就是这种“换个场景就翻车”的问题。URP管线作为Unity目前主推的通用渲染管线内置了完整的PBR光照模型。它的Shader底层用的是Cook-Torrance BRDF配合金属度Metallic和光滑度Smoothness两个核心参数来控制材质表现。你在URP里新建一个Lit材质看到的Metallic和Smoothness滑块背后就是一整套物理光照计算在支撑。这篇笔记适合谁看如果你正在用URP做项目对Lit材质里那些参数一知半解或者想自己写一个自定义的PBR Shader但不知道从哪下手那接下来的内容应该能帮到你。我会从BRDF的数学原理讲起然后拆解URP的PBR实现最后给出一套可以直接上手操作的自定义Shader方案。整个过程我会尽量用生活化的类比来解释避免堆公式劝退。注意本文基于URP 14.x版本Unity 2022 LTS的管线实现不同版本在Shader库的API上可能有细微差异遇到编译报错先检查版本兼容性。2. BRDF不是玄学拆开看就是三块积木2.1 漫反射与高光反射的物理直觉BRDF全称Bidirectional Reflectance Distribution Function双向反射分布函数。名字听着吓人但你可以把它理解成一个“光线分配规则”当一束光打到表面上有多少能量被散射到各个方向去了。在PBR框架里BRDF被拆成两部分漫反射项和高光反射项。漫反射对应的是光线进入材质内部、经过多次散射后从表面均匀射出的部分它决定了物体的基础颜色。高光反射则是光线在表面直接弹开的部分它决定了物体的反射特性和高光形状。用一个生活场景来类比你拿手电筒照一块木头木头表面有一层淡淡的光晕高光同时整块木头都被照亮了漫反射。如果你换成照一块抛光金属高光会变得非常锐利而漫反射几乎消失——因为金属的自由电子会吸收进入内部的光线只有表面反射。这就是为什么PBR里金属度参数会同时影响漫反射和高光的原因。2.2 Cook-Torrance模型的三个核心函数URP的PBR实现基于Cook-Torrance BRDF模型它的高光项由三个函数相乘组成D项法线分布函数描述微表面法线的统计分布决定了高光的形状和锐利程度。URP用的是GGX/Trowbridge-Reitz分布它比传统的Blinn-Phong能更好地模拟“长尾”高光也就是高光边缘那种柔和过渡的效果。G项几何遮蔽函数描述微表面之间的自遮挡决定了掠射角下高光的衰减。URP用的是Smith-Schlick近似计算量小且效果可接受。F项菲涅尔方程描述不同角度下的反射率变化决定了边缘光的强度。URP用的是Schlick近似公式简单但足够准确。这三个函数各自负责一个维度的物理现象乘在一起就得到了完整的高光BRDF。你不需要背公式但需要理解每个函数控制的是什么——D控制高光“胖瘦”G控制边缘“软硬”F控制反射“强弱”。2.3 金属度与光滑度两个参数背后的物理意义很多人调材质的时候把Metallic和Smoothness当成“好看程度”的滑块这是最大的误区。这两个参数有明确的物理含义Metallic金属度是一个0到1的值但它本质上是个“二选一”的开关。现实中大多数材质要么是金属Metallic1要么是绝缘体Metallic0。中间值只用于模拟氧化、污渍等过渡状态。金属的漫反射为零所有反射都带上了基础色绝缘体的高光反射是白色的漫反射带基础色。Smoothness光滑度控制微表面的粗糙程度。Smoothness越高微表面越平整高光越集中越锐利Smoothness越低微表面越杂乱高光越分散越柔和。它和粗糙度Roughness是反比关系URP用的是Smoothness但底层计算时会转换成Roughness。实操心得如果你不确定一个材质该给什么Metallic值先问自己“这个东西是金属吗”。是就设1不是就设0。中间值只在有明确物理依据时才用比如生锈的铁板可以给0.7左右。3. URP的PBR Shader结构从Lit.shader到BRDF计算3.1 URP Lit Shader的文件组织URP的Lit Shader源码在Packages/com.unity.render-pipelines.universal/Shaders/Lit.shader。打开后你会发现它本身很短大部分逻辑都在include文件里。核心的include链路是这样的// Lit.shader 中的关键include #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/SurfaceInput.hlslLighting.hlsl里包含了BRDF计算的核心函数SurfaceInput.hlsl负责处理表面数据和贴图采样。这种模块化的设计意味着你可以直接复用URP的BRDF函数来写自己的Shader不用从零推导公式。3.2 顶点着色器与片元着色器的分工URP的Lit Shader在顶点阶段主要做三件事坐标变换、法线变换、计算光照相关的插值数据。片元阶段才是PBR计算的主战场它接收顶点阶段传来的数据采样贴图构建SurfaceData结构体然后调用UniversalFragmentPBR函数完成光照计算。SurfaceData结构体是URP PBR的核心数据结构它包含了albedo、metallic、smoothness、normalTS、occlusion等字段。你在自定义Shader时只要正确填充这个结构体就能直接调用URP的PBR光照函数。3.3 UniversalFragmentPBR函数的内部逻辑UniversalFragmentPBR是URP封装好的PBR光照入口函数它的内部流程大致如下根据SurfaceData初始化InputData结构体遍历所有可见光源对每个光源计算直接光照计算间接光照球谐光照和反射探针将直接光照和间接光照累加返回最终颜色直接光照的计算里就包含了BRDF的调用。对于每个光源URP会计算NdotL、NdotV、H向量等中间量然后分别计算漫反射项和高光项。间接光照则依赖球谐函数和反射探针这部分在URP里做了大量优化移动端也能跑得动。注意如果你在自定义Shader里直接调用UniversalFragmentPBR需要确保正确设置了InputData里的lightmapUV、shadowCoord等字段否则阴影和光照贴图会失效。4. 手写一个URP PBR Shader从零到跑通4.1 创建Shader文件与基础框架在Unity项目里新建一个Shader文件命名为CustomPBR.shader。基础框架包括Shader属性块、SubShader标签、Pass结构。URP的Shader必须打上RenderPipeline UniversalPipeline标签否则不会被URP识别。Shader Custom/CustomPBR { 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 } SubShader { Tags { RenderType Opaque RenderPipeline UniversalPipeline Queue Geometry } Pass { Name ForwardLit Tags { LightMode UniversalForward } HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile _ _MAIN_LIGHT_SHADOWS #pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE #pragma multi_compile _ _ADDITIONAL_LIGHTS #pragma multi_compile _ _SHADOWS_SOFT #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl // 后续代码填充 ENDHLSL } } }4.2 顶点着色器的数据传递顶点着色器需要把模型空间的顶点坐标、法线、切线、UV传递到片元阶段。URP提供了GetVertexPositionInputs和GetVertexNormalInputs两个工具函数能自动处理不同平台下的坐标变换。struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float4 tangentOS : TANGENT; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 positionWS : TEXCOORD1; float3 normalWS : TEXCOORD2; float4 tangentWS : TEXCOORD3; float3 viewDirWS : TEXCOORD4; float4 shadowCoord : TEXCOORD5; }; Varyings vert(Attributes input) { Varyings output (Varyings)0; VertexPositionInputs posInputs GetVertexPositionInputs(input.positionOS.xyz); VertexNormalInputs nrmInputs GetVertexNormalInputs(input.normalOS, input.tangentOS); output.positionCS posInputs.positionCS; output.positionWS posInputs.positionWS; output.normalWS nrmInputs.normalWS; output.tangentWS float4(nrmInputs.tangentWS, input.tangentOS.w); output.viewDirWS GetWorldSpaceViewDir(posInputs.positionWS); output.uv input.uv; output.shadowCoord GetShadowCoord(posInputs); return output; }这里有个容易踩的坑切线符号。tangentOS的w分量存储了副切线的方向符号在构建TBN矩阵时必须乘上去否则法线贴图在部分模型上会反向。4.3 片元着色器中的SurfaceData构建片元阶段的核心任务是填充SurfaceData结构体。这个结构体定义在SurfaceInput.hlsl里包含albedo、metallic、smoothness、normalTS、occlusion等字段。half4 frag(Varyings input) : SV_Target { SurfaceData surfaceData (SurfaceData)0; half4 baseMap SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv); surfaceData.albedo baseMap.rgb * _BaseColor.rgb; surfaceData.alpha baseMap.a * _BaseColor.a; half4 normalMap SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, input.uv); surfaceData.normalTS UnpackNormalScale(normalMap, _NormalScale); surfaceData.metallic _Metallic; surfaceData.smoothness _Smoothness; half4 occMap SAMPLE_TEXTURE2D(_OcclusionMap, sampler_OcclusionMap, input.uv); surfaceData.occlusion lerp(1.0, occMap.r, _OcclusionStrength); surfaceData.clearCoatMask 0; surfaceData.clearCoatSmoothness 0; // 构建InputData InputData inputData (InputData)0; inputData.positionWS input.positionWS; inputData.normalWS normalize(input.normalWS); inputData.viewDirectionWS normalize(input.viewDirWS); inputData.shadowCoord input.shadowCoord; inputData.fogCoord 0; inputData.vertexLighting 0; inputData.bakedGI SampleSH(inputData.normalWS); inputData.normalizedScreenSpaceUV GetNormalizedScreenSpaceUV(input.positionCS); inputData.shadowMask 1; half4 color UniversalFragmentPBR(inputData, surfaceData); return color; }4.4 编译测试与常见报错处理写完Shader后创建一个材质球挂上去如果场景里出现粉红色说明编译报错。常见的报错和解决方案报错信息原因解决方案undeclared identifier UniversalFragmentPBR缺少Lighting.hlsl引用检查include路径是否正确cannot map expression to vs_4_0 instruction set顶点着色器指令数超限减少顶点阶段的插值器数量invalid subscript shadowCoord未声明阴影相关multi_compile添加_MAIN_LIGHT_SHADOWS等关键字sampler_BaseMap redefinition重复定义采样器检查是否重复include了同一文件实操心得URP的Shader报错信息有时候会指向错误的行号因为include展开后行号会偏移。遇到莫名其妙的报错先把include的文件路径检查一遍确认没有拼写错误。5. 光照环境搭建让PBR材质真正“活”起来5.1 主光源方向与强度设置PBR材质的表现极度依赖光照环境。在URP里主光源Directional Light的方向决定了高光的位置和形状。一个常见的错误是把光源强度拉得特别高结果高光过曝、暗部死黑。正确的做法是保持光源强度在合理范围通常1到3之间然后通过调整材质的Smoothness来控制高光表现。主光源的角度也有讲究。45度左右的入射角能同时展示漫反射和高光反射的细节适合做材质预览。如果要展示金属材质的反射特性可以把光源角度调低一些让高光更贴近边缘。5.2 环境光照与反射探针的配合URP的间接光照来自两个地方球谐光照SH和反射探针Reflection Probe。球谐光照负责漫反射环境光反射探针负责高光反射。如果你发现金属材质反射的是默认的灰色环境说明场景里没有放置反射探针。反射探针的放置原则是在场景的关键位置放置探针覆盖所有需要反射的区域。对于室内场景可以用一个Box投影的反射探针覆盖整个房间对于室外场景可以用一个大的球体探针配合天空盒。注意反射探针的Resolution不要设得太高128或256通常就够了。太高的分辨率会显著增加显存占用和烘焙时间但视觉提升有限。5.3 后处理对PBR观感的影响URP的后处理栈里有几个效果会直接影响PBR材质的观感Tonemapping色调映射把HDR颜色映射到LDR范围。ACES模式能提供更自然的对比度和色彩推荐开启。Bloom泛光让高光区域产生光晕。适度使用能增强金属和发光材质的表现过度使用会让画面变糊。Color Grading颜色分级调整整体色调。PBR材质在中性色调下表现最准确如果要做风格化处理建议先调好PBR再叠加分级。6. 性能优化移动端PBR的取舍策略6.1 变体管理与Shader剥离URP的Lit Shader有大量关键字变体如果全部编译进去包体和内存都会爆炸。在Project Settings的Graphics设置里可以配置Shader Stripping把不需要的变体剥离掉。比如你的项目不需要阴影级联就可以把_MAIN_LIGHT_SHADOWS_CASCADE剥离。对于自定义Shader建议只声明实际用到的multi_compile关键字。每多一个关键字变体数量就翻一倍。移动端项目尤其要注意这一点。6.2 精度选择half与float的权衡在移动端half精度16位浮点的运算速度通常比float32位快。URP的SurfaceData结构体里albedo、metallic、smoothness等字段都是half类型。但在计算世界空间坐标和法线时必须用float否则会出现明显的精度问题。一个实用的原则颜色和材质参数用half位置和方向用float。如果你不确定某个变量该用什么精度先用float保证正确性性能不够时再降级到half测试。6.3 光照复杂度控制URP的Forward渲染路径下每个物体最多受8个额外光源影响可在URP Asset里配置。超过这个数量的光源会退化为顶点光照或SH光照。对于移动端项目建议把额外光源数量控制在4个以内并且优先使用烘焙光照。如果场景里有大量小光源可以考虑用光照贴图或光照探针来替代实时点光源。PBR材质在烘焙光照下的表现和实时光照基本一致但性能开销低得多。7. 那些文档里不会写的踩坑记录7.1 法线贴图的绿色通道翻转问题从不同软件导出的法线贴图绿色通道的方向可能不同。Unity用的是OpenGL风格的法线贴图绿色通道朝上而有些软件导出的是DirectX风格绿色通道朝下。如果法线贴图看起来凹凸方向反了就是这个问题。解决方案有两种一是在导入设置里勾选“Flip Green Channel”二是在Shader里手动翻转。推荐用第一种因为不需要改代码而且对所有使用该贴图的材质都生效。7.2 金属材质的反射探针更新时机反射探针默认是烘焙时更新一次如果场景里有动态物体或者光源变化反射内容就会过时。URP支持在运行时更新反射探针但开销不小。一个折中方案是设置反射探针为“Realtime”模式但把刷新频率调低比如每几帧刷新一次或者只在关键时机手动触发刷新。7.3 Smoothness贴图的通道打包很多PBR工作流会把Smoothness存在Metallic贴图的Alpha通道或者Occlusion贴图的某个通道里。URP的Lit Shader默认从Metallic贴图的Alpha通道读取Smoothness。如果你用的是分离的贴图需要在Shader里改采样逻辑。通道打包的好处是减少贴图数量降低带宽占用。但缺点是可读性差换一个人接手项目可能看不懂。建议在项目初期就定好打包规范并且写清楚文档。7.4 阴影偏移与自阴影瑕疵PBR材质在接收阴影时如果Shadow Bias设置不当会出现阴影偏移Peter Panning或自阴影瑕疵Shadow Acne。URP的阴影设置里有Depth Bias和Normal Bias两个参数前者控制阴影贴图的深度偏移后者控制法线方向的偏移。调参的原则是先调Normal Bias再调Depth Bias。Normal Bias对自阴影瑕疵更有效但太大会导致阴影收缩。Depth Bias对阴影偏移更有效但太大会导致阴影脱离物体。8. 从PBR延伸出去还能玩出什么花样8.1 清漆层Clear Coat的模拟URP的SurfaceData结构体里有clearCoatMask和clearCoatSmoothness两个字段可以用来模拟车漆、钢琴漆等双层材质。清漆层的原理是在基础BRDF之上再叠加一层高光BRDF两层之间用mask混合。实现思路是在片元着色器里额外采样一张清漆遮罩贴图然后填充到SurfaceData的对应字段。UniversalFragmentPBR会自动处理清漆层的光照计算。需要注意的是清漆层会增加计算量移动端慎用。8.2 各向异性高光的实现思路各向异性高光用于模拟拉丝金属、头发、丝绸等材质。它的核心是修改法线分布函数D让高光沿切线方向拉伸。URP没有内置各向异性支持需要自己写BRDF。实现步骤大致是在片元着色器里计算切线和副切线方向然后用各向异性版本的GGX分布替换默认的D函数。这个改动涉及Lighting.hlsl里的核心函数建议复制一份URP的Lighting文件出来改不要直接修改包内文件。8.3 次表面散射的简化方案次表面散射SSS用于模拟皮肤、蜡、玉石等半透明材质。完整的SSS计算非常昂贵但可以用一些简化方案来近似。一个常见的做法是用一张厚度贴图来控制透光强度然后在光照计算时把背光面的颜色混合进去。URP的Lit Shader不支持SSS但你可以基于UniversalFragmentPBR的返回值做后处理。具体做法是在得到PBR颜色后根据光源方向和法线方向的点积在背光区域叠加一层透光颜色。这个方案虽然不物理但视觉效果可以接受性能开销也很低。9. 我个人的PBR调试流程调PBR材质这件事我自己的习惯是分四步走。第一步先把Metallic和Smoothness都设成0.5看看基础光照有没有问题。如果这一步就出问题说明光照环境或者Shader本身有bug先解决这些再往下走。第二步确定材质的金属度。是金属就设1不是就设0。这一步定下来之后基础色和反射色的关系就确定了后面调Smoothness才有意义。第三步调Smoothness。从0.5开始往高调看高光是否锐利往低调看高光是否柔和。找到一个和目标材质最接近的值然后微调。第四步检查法线贴图和AO贴图。法线贴图影响细节凹凸AO贴图影响暗部层次。这两张贴图调好了材质的立体感和真实感会明显提升。整个流程走下来一个材质大概需要五到十分钟。如果超过这个时间还在纠结大概率是贴图本身有问题或者光照环境需要调整。PBR不是万能药它只是让材质表现更可控前提是输入数据本身是合理的。