ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity URP PBR Shader从理论到实战:手写Cook-Torrance BRDF与性能优化

Unity URP PBR Shader从理论到实战:手写Cook-Torrance BRDF与性能优化 1. 从零搞懂PBR为什么它成了现代渲染的标配1.1 先聊聊我为什么决定啃PBR这块硬骨头最开始接触Unity的URP管线时我和很多人一样觉得把材质往上一挂、调调Metallic和Smoothness滑块就完事了。直到有次做一个小场景金属球在暗部看起来像塑料布料在强光下又像涂了油我才意识到——不搞懂PBR背后的物理逻辑调参就是瞎蒙。PBR全称Physically Based Rendering中文叫基于物理的渲染。它的核心思想一句话就能概括用物理学的规律去描述光与物体表面的交互而不是靠美术凭感觉去凑效果。这件事的意义在于同一个材质在不同光照环境下都能表现一致不会出现“在这个场景好看、换个场景就崩”的情况。URP管线是Unity推出的轻量级可编程渲染管线相比内置管线它的Shader编写更规范、性能更可控特别适合移动端和中等规模的PC项目。而PBR在URP中的实现核心就是围绕BRDF双向反射分布函数展开的一整套光照计算模型。这篇笔记适合谁看如果你已经会用Unity搭场景、挂材质但对URP的Shader编写还停留在“改改颜色”的阶段想真正理解PBR的数学原理和工程实现那接下来的内容应该能帮到你。我会从理论推导讲到Shader代码落地把每个参数背后的“为什么”都掰开说清楚。1.2 PBR的两条核心公式线索PBR的数学基础是渲染方程但实际工程中我们不会去解完整的积分方程而是把它拆解成几个可计算的部分。在URP的PBR实现里最核心的是反射率方程最终颜色 ∫ (BRDF × 入射光辐射度 × 入射方向与法线夹角余弦) dω这个积分在实际计算中会被离散化变成对每个光源的累加。而BRDF本身又被拆成漫反射项和高光反射项两部分。漫反射项通常用Lambert模型简单说就是光线打到粗糙表面后均匀向各个方向散射。高光反射项则用Cook-Torrance模型它包含三个关键函数法线分布函数D、几何遮蔽函数G、菲涅尔函数F。这三个函数分别描述了微观表面法线的分布、微面元之间的遮挡、以及不同角度下的反射率变化。我一开始看这些公式也头大但后来发现你不需要手推每一个积分只需要理解每个函数在“控制什么视觉效果”就够了。D控制高光的大小和形状G控制边缘的暗化程度F控制反射的强度随角度变化。理解了这层调参就有方向了。1.3 URP中PBR的材质参数到底在调什么URP的Lit Shader暴露了这几个核心参数Base Map、Metallic、Smoothness、Normal Map、Occlusion、Emission。很多人调材质就是挨个滑块拖但每个参数在BRDF里对应什么心里没数。Base Map提供的是反照率Albedo也就是漫反射的基础颜色。注意这里的颜色应该是“去光照”的纯颜色不应该包含阴影或高光信息。Metallic控制的是金属度它决定了有多少光被反射、多少光被吸收后重新散射。金属的漫反射几乎为零而非金属的漫反射很强。Smoothness控制的是粗糙度的倒数粗糙度越低高光越集中越亮。Normal Map提供的是切线空间法线用来模拟表面微观凹凸。Occlusion是环境光遮蔽用来压暗暗部区域。Emission是自发光不参与BRDF计算直接叠加到最终颜色上。我踩过的一个坑是把Metallic调成0.5以为能得到“半金属”效果。实际上在PBR里金属度是一个二元属性——要么是金属要么是非金属中间值只适合做过渡区域的混合不应该作为常规材质参数。这个认知纠正后我的材质质感立刻上了一个台阶。2. URP管线下的Shader结构拆解2.1 URP Shader的文件组织方式URP的Shader和内置管线的Shader在结构上有明显区别。内置管线用Surface Shader可以自动生成光照代码但URP不支持Surface Shader必须手写顶点和片元着色器。这意味着你需要自己处理光照计算、阴影采样、雾效等。一个标准的URP PBR Shader通常包含这几个部分属性块Properties、子着色器SubShader、Pass块、HLSL代码块。属性块定义暴露给美术的参数SubShader里定义渲染状态和PassHLSL代码块里引入URP的核心库并实现光照计算。我习惯把HLSL代码拆成几个.hlsl文件一个放结构体定义一个放工具函数一个放光照计算。这样复用起来方便改起来也不会牵一发动全身。URP自带的Lighting.hlsl和BRDF.hlsl是很好的参考建议直接读源码比看任何教程都管用。2.2 顶点着色器里该做什么、不该做什么顶点着色器的职责很明确把顶点从模型空间变换到裁剪空间同时计算一些插值数据传给片元着色器。在URP的PBR Shader里顶点着色器通常要输出裁剪空间位置、世界空间位置、世界空间法线、世界空间切线、UV坐标、顶点色如果有。这里有个容易忽略的点法线和切线必须变换到世界空间因为光照计算是在世界空间进行的。变换法线不能用普通的模型变换矩阵要用法线矩阵模型变换矩阵的逆转置否则非均匀缩放会导致法线方向错误。我见过有人在顶点着色器里做光照计算觉得这样性能好。但在PBR里光照计算依赖逐像素的法线和视角方向放在顶点着色器里会导致高光破碎尤其是低模。所以除非是做特殊风格化效果否则光照计算一定要放在片元着色器。2.3 片元着色器中的BRDF计算流程片元着色器是PBR的核心战场。整个计算流程大致是这样的先准备好所有输入数据法线、视角方向、光照方向、粗糙度、金属度、反照率然后对每个光源计算BRDF累加结果最后加上环境光贡献。具体到代码层面URP提供了UniversalFragmentPBR函数你只需要把SurfaceData和InputData填好它内部会帮你完成所有光照计算。但如果你想真正理解PBR我建议至少手写一次完整的BRDF计算把Cook-Torrance的三个函数都实现一遍。手写的过程中你会遇到几个关键问题如何采样阴影、如何处理多光源、如何计算环境光。这些问题在URP的源码里都有答案但只有自己写过一遍才能真正记住。2.4 阴影和环境光的处理细节阴影在URP里通过ShadowCasterPass实现但光照计算时需要采样阴影贴图。URP提供了GetMainLightShadowCoord和SampleShadowmap等函数但要注意阴影坐标的计算需要在顶点着色器里完成片元着色器里只做采样。环境光在PBR里分两部分漫反射环境光和高光环境光。漫反射环境光通常用球谐函数SH来近似URP的SampleSH函数可以直接用。高光环境光则需要采样反射探针Reflection Probe用GlossyEnvironmentReflection函数。这里有个性能陷阱反射探针的采样开销不小移动端要谨慎使用。如果场景里没有反射探针URP会回退到一个默认的立方体贴图效果会差很多。我的做法是在质量设置里根据目标平台动态开关反射探针。3. 手把手实现一个URP PBR Shader3.1 创建Shader文件与属性块定义先在Unity里新建一个Shader文件命名为CustomPBR.shader。属性块里定义我们需要暴露给美术的参数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 _EmissionMap (Emission Map, 2D) black {} _EmissionColor (Emission Color, Color) (0,0,0,1) }这些参数和URP自带的Lit Shader基本一致但少了Clear Coat和Detail Map等高级选项。对于大多数项目来说这些核心参数已经够用了。属性块里有个细节_BaseMap的默认值写white_NormalMap的默认值写bump。这是Unity的约定bump对应一个平坦的法线贴图避免没有法线贴图时出现奇怪的着色。3.2 SubShader与Pass的渲染状态配置SubShader里需要定义几个关键状态Tags、Cull、ZWrite、ZTest、Blend。对于不透明的PBR材质通常这样配置SubShader { Tags { RenderType Opaque RenderPipeline UniversalPipeline Queue Geometry } Pass { Name ForwardLit Tags { LightMode UniversalForward } Cull Back ZWrite On ZTest LEqual Blend One Zero } }RenderPipeline标签告诉Unity这个Shader是给URP用的。LightMode设为UniversalForward表示这是前向渲染的主Pass。Cull Back表示剔除背面ZWrite On表示写入深度Blend One Zero表示不混合。如果需要双面材质把Cull Back改成Cull Off即可。但要注意双面材质在光照计算时可能需要翻转法线否则背面会得到错误的光照结果。这个后面会细说。3.3 HLSL代码块引入库与定义结构体HLSL代码块以HLSLPROGRAM开头ENDHLSL结尾。首先引入URP的核心库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_VERTEX _ADDITIONAL_LIGHTS #pragma multi_compile_fragment _ _SHADOWS_SOFT #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl这些#pragma multi_compile指令用来生成Shader变体支持阴影、多光源、软阴影等功能。如果不加这些阴影和多光源就不会生效。接下来定义输入输出结构体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; };Attributes是顶点着色器的输入Varyings是顶点着色器的输出、片元着色器的输入。注意shadowCoord也放在Varyings里因为阴影坐标需要在顶点着色器里计算。3.4 顶点着色器的完整实现顶点着色器的实现如下Varyings vert(Attributes input) { Varyings output (Varyings)0; VertexPositionInputs positionInputs GetVertexPositionInputs(input.positionOS.xyz); VertexNormalInputs normalInputs GetVertexNormalInputs(input.normalOS, input.tangentOS); output.positionCS positionInputs.positionCS; output.positionWS positionInputs.positionWS; output.normalWS normalInputs.normalWS; output.tangentWS float4(normalInputs.tangentWS, input.tangentOS.w); output.viewDirWS GetWorldSpaceViewDir(positionInputs.positionWS); output.uv input.uv; output.shadowCoord GetShadowCoord(positionInputs); return output; }GetVertexPositionInputs和GetVertexNormalInputs是URP提供的工具函数自动处理了空间变换。GetWorldSpaceViewDir计算从顶点指向相机的方向。GetShadowCoord计算阴影坐标内部会根据是否启用级联阴影做不同处理。这里有个性能优化点viewDirWS可以在片元着色器里用normalize(_WorldSpaceCameraPos - positionWS)计算但放在顶点着色器里插值可以省一次减法。不过对于大三角形插值后的视角方向可能不准确需要权衡。3.5 片元着色器从贴图采样到BRDF计算片元着色器是重头戏。先采样所有贴图half4 frag(Varyings input) : SV_Target { half4 baseMap SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv); half3 albedo baseMap.rgb * _BaseColor.rgb; half metallic _Metallic * baseMap.a; half smoothness _Smoothness; half roughness 1.0 - smoothness; half3 normalTS UnpackNormalScale( SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, input.uv), _NormalScale ); half occlusion lerp(1.0, SAMPLE_TEXTURE2D(_OcclusionMap, sampler_OcclusionMap, input.uv).r, _OcclusionStrength ); half3 emission SAMPLE_TEXTURE2D(_EmissionMap, sampler_EmissionMap, input.uv).rgb * _EmissionColor.rgb;注意baseMap.a被用作金属度贴图的通道这是URP的惯例。法线贴图用UnpackNormalScale解包它会根据目标平台自动处理法线的编码格式。接下来构建SurfaceData和InputDataSurfaceData surfaceData (SurfaceData)0; surfaceData.albedo albedo; surfaceData.metallic metallic; surfaceData.smoothness smoothness; surfaceData.occlusion occlusion; surfaceData.emission emission; surfaceData.normalTS normalTS; surfaceData.alpha 1.0; 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);SurfaceData封装了所有材质属性InputData封装了所有光照计算需要的输入。SampleSH采样球谐函数得到漫反射环境光。最后调用URP的PBR函数half4 color UniversalFragmentPBR(inputData, surfaceData); return color; }UniversalFragmentPBR内部会完成所有BRDF计算、阴影采样、环境光叠加。如果你想手写BRDF可以不用这个函数自己实现Cook-Torrance模型。3.6 手写Cook-Torrance BRDF的核心代码手写BRDF的话核心是这三个函数// 法线分布函数GGX/Trowbridge-Reitz float D_GGX(float NdotH, float roughness) { float a roughness * roughness; float a2 a * a; float NdotH2 NdotH * NdotH; float denom NdotH2 * (a2 - 1.0) 1.0; return a2 / (PI * denom * denom); } // 几何遮蔽函数Smith-Schlick float G_Smith(float NdotV, float NdotL, float roughness) { float k (roughness 1.0) * (roughness 1.0) / 8.0; float Gv NdotV / (NdotV * (1.0 - k) k); float Gl NdotL / (NdotL * (1.0 - k) k); return Gv * Gl; } // 菲涅尔函数Schlick近似 float3 F_Schlick(float VdotH, float3 F0) { return F0 (1.0 - F0) * pow(1.0 - VdotH, 5.0); }然后组合成完整的BRDFfloat3 BRDF(float3 N, float3 V, float3 L, float3 albedo, float metallic, float roughness) { float3 H normalize(V L); float NdotV saturate(dot(N, V)); float NdotL saturate(dot(N, L)); float NdotH saturate(dot(N, H)); float VdotH saturate(dot(V, H)); float3 F0 lerp(0.04, albedo, metallic); float D D_GGX(NdotH, roughness); float G G_Smith(NdotV, NdotL, roughness); float3 F F_Schlick(VdotH, F0); float3 specular (D * G * F) / (4.0 * NdotV * NdotL 0.001); float3 kd (1.0 - F) * (1.0 - metallic); float3 diffuse kd * albedo / PI; return (diffuse specular) * NdotL; }这段代码就是PBR的核心。F0是非金属的基础反射率通常取0.04。kd是漫反射系数金属材质没有漫反射所以乘以(1.0 - metallic)。分母加0.001防止除零。4. 实操中踩过的坑与排查技巧4.1 法线贴图方向错误导致光照异常这是最常见的问题。表现是物体表面看起来凹凸感很奇怪高光位置不对或者整体发灰。原因通常是法线贴图的切线空间和Shader的切线空间不一致。Unity默认使用OpenGL风格的切线空间法线贴图的绿色通道朝上。但有些美术从其他软件导出的法线贴图是DirectX风格绿色通道朝下。这时候需要在Shader里翻转绿色通道normalTS.y -normalTS.y;或者在导入设置里勾选Flip Green Channel。我建议在导入设置里处理而不是改Shader因为这样美术能直观看到效果。另一个可能的原因是模型没有切线数据。在导入设置里把Tangents设为Calculate让Unity自动生成切线。如果模型是程序化生成的需要手动计算切线并填充到Mesh里。4.2 金属材质在暗部发黑的问题金属材质在暗部应该反射环境光但如果环境光采样不对就会发黑。问题通常出在反射探针上。如果场景里没有反射探针URP会用一个默认的黑色立方体贴图导致金属暗部完全黑掉。解决办法是在场景里放一个Reflection Probe设置好Bake范围然后Bake一次。如果场景是动态的可以把Reflection Probe设为Realtime但性能开销会大很多。移动端建议用Baked模式配合一个简单的天空盒。还有一个细节SampleSH采样的漫反射环境光对金属无效因为金属没有漫反射。所以金属的环境光完全依赖反射探针。如果反射探针质量不够金属看起来会很脏。4.3 多光源下性能骤降的优化思路URP的Additional Lights默认是逐像素计算的每多一个光源就多一次BRDF计算。如果场景里有十几个点光源性能会崩。优化思路有几个第一把不重要的光源设为Vertex模式在顶点着色器里计算光照虽然效果差一些但性能好很多。在URP的Asset设置里可以配置Additional Lights的Per Object Limit和Render Mode。第二用光照贴图Lightmap烘焙静态光源。静态物体的光照完全可以用烘焙解决运行时只需要采样Lightmap不需要实时计算。第三用光照探针Light Probe处理动态物体。动态物体不能烘焙但可以用光照探针采样周围的光照信息比实时计算便宜得多。我实测下来一个场景里如果有超过4个实时点光源移动端基本就扛不住了。所以我的习惯是主光源实时其他光源全部烘焙或走顶点模式。4.4 常见问题速查表问题现象可能原因排查方法解决方案材质整体发灰环境光过强或Albedo过暗检查SampleSH和Albedo值调整环境光强度或Albedo亮度高光破碎法线贴图未解包或切线错误检查UnpackNormal和切线数据正确解包法线重新计算切线金属暗部发黑缺少反射探针检查场景是否有Reflection Probe添加并Bake反射探针阴影闪烁阴影坐标计算错误检查GetShadowCoord调用确保在顶点着色器计算阴影坐标多光源性能差Additional Lights逐像素计算查看Frame Debugger改为顶点模式或烘焙双面材质背面光照错误法线未翻转检查片元着色器法线方向背面翻转法线normalWS * sign(dot(normalWS, viewDirWS))双面材质那个问题值得多说一句。当Cull Off时背面三角形的法线是指向远离相机的方向光照计算会得到错误结果。解决办法是在片元着色器里判断当前是正面还是背面如果是背面就翻转法线float faceSign sign(dot(input.normalWS, input.viewDirWS)); inputData.normalWS * faceSign;这样背面也能得到正确的光照。但要注意翻转法线后切线空间也要相应调整否则法线贴图的方向会错。4.5 关于Shader变体爆炸的避坑经验URP的Shader变体数量很容易失控。每个multi_compile都会让变体数量翻倍。如果你有5个multi_compile每个2个选项那就是32个变体。再加上shader_feature变体数量可能上百。变体太多的后果是打包时间变长、包体变大、运行时加载变慢。我的做法是只保留项目实际需要的变体。比如项目不用软阴影就把_SHADOWS_SOFT的multi_compile去掉。不用级联阴影就把_MAIN_LIGHT_SHADOWS_CASCADE去掉。另外用shader_feature代替multi_compile因为shader_feature只会在材质实际使用时生成变体而multi_compile会生成所有变体。这个区别在大型项目里非常明显。还有一个技巧用#pragma skip_variants跳过不需要的变体。比如#pragma skip_variants _ADDITIONAL_LIGHTS_VERTEX这样就不会生成顶点光照的变体减少变体数量。5. PBR进阶方向与个人学习路线5.1 从PBR延伸到IBL和球谐光照PBR的基础是直接光照但真实场景中大部分光照来自环境反射也就是IBLImage Based Lighting。IBL的核心是把环境贴图预计算成漫反射辐照度贴图和镜面反射预过滤贴图运行时直接采样。URP里可以通过GlossyEnvironmentReflection函数采样反射探针但反射探针的预计算是Unity自动完成的。如果你想自己控制IBL的质量可以手动生成 cubemap 并做卷积。这个过程涉及球谐函数和预过滤是PBR进阶的必经之路。球谐光照是另一种环境光近似方法用9个系数就能表示整个球面的光照分布。URP的SampleSH就是用的这个方法。理解球谐函数的原理能帮你更好地控制环境光的颜色和强度。5.2 清漆层和布料材质的扩展思路标准PBR模型只考虑了金属和非金属但现实中有很多特殊材质比如清漆Clear Coat、布料Cloth、次表面散射SSS。URP的Lit Shader支持Clear Coat但需要额外开启。清漆层的原理是在基础BRDF上再叠加一层透明的镜面反射模拟车漆或钢琴漆的效果。实现上需要两个粗糙度基础层粗糙度和清漆层粗糙度。计算时分别算两层BRDF然后按菲涅尔权重混合。布料材质则用另一种BRDF模型比如Charlie分布或Ashikhmin分布特点是高光更柔和、边缘有绒感。URP没有内置布料Shader需要自己写。我建议先从Clear Coat入手理解多层BRDF的混合逻辑再扩展到布料。5.3 性能与效果的平衡策略PBR的效果好但性能开销也大。移动端上一个全屏的PBR材质可能就跑不满60帧。平衡策略有几个方向第一分级质量。高端机用完整PBR中端机简化BRDF比如用Blinn-Phong代替Cook-Torrance低端机用Unlit。URP的Quality设置可以配置不同级别的Shader变体。第二烘焙为主实时为辅。静态物体全部烘焙动态物体用简化光照。这样运行时几乎没有光照计算开销。第三限制光源数量。主光源实时其他光源烘焙或走顶点模式。点光源的影响范围尽量小避免全屏计算。第四降低分辨率。URP支持Render Scale可以把渲染分辨率降到0.8或0.7然后上采样。视觉损失不大但性能提升明显。我个人的经验是移动端项目里PBR材质不要超过场景的30%其余用简化Shader。这样既能保证核心物体的质感又能控制整体性能。5.4 我自己的学习路线复盘回头看我学PBR走了不少弯路。最开始是直接看Unity的文档但文档只讲参数不讲原理调参全靠猜。后来去读URP的源码Lighting.hlsl和BRDF.hlsl翻了好几遍才慢慢理解每个函数的作用。再后来是手写BRDF把Cook-Torrance的三个函数都实现一遍。写完之后再看URP的UniversalFragmentPBR就有一种“原来如此”的感觉。最后是看一些经典的PBR论文比如Cook-Torrance的原始论文、Disney的BRDF模型理解工业界和学术界的差异。如果你也在学PBR我的建议是先跑通再优化先手写再用库。不要一上来就追求完美先把一个简单的PBR Shader跑起来看到效果再逐步深入。遇到不懂的函数去查源码、查论文不要跳过。PBR的每一个参数背后都有物理意义理解了这些意义调参就不再是玄学。最后分享一个我常用的调试技巧在片元着色器里把中间结果可视化。比如把D项、G项、F项分别输出成颜色看看它们对最终效果的贡献。这样能直观地理解每个函数的作用比看公式快得多。
返回列表