Unity Shader多Pass渲染:从Standard Shader源码解析到自定义AddPass实战 1. 项目概述为什么我们要深入AddPass如果你在Unity里做过一些稍微复杂点的渲染效果比如给角色加个发光轮廓或者让武器在特定时候高亮显示那你大概率已经接触过或听说过“多Pass渲染”。Standard Shader作为Unity内置的“万金油”着色器它的强大和复杂是并存的。上一期我们拆解了它的基础结构和主Pass但很多炫酷效果的关键其实藏在那些“附加”的Pass里也就是我们常说的AddPass。简单来说一个Shader可以包含多个Pass。主Pass通常叫ForwardBase或UniversalForward负责最基础的漫反射、高光、法线贴图等光照计算是物体的“本体”渲染。而AddPass则是在这个“本体”之上额外再渲染一次或多次用来叠加一些特殊效果。你可以把它想象成Photoshop里的图层——先画好底图主Pass然后在上面新建一个图层用不同的混合模式比如Blend SrcAlpha One画上发光、边缘光等效果。这个新建的图层就是AddPass。为什么Standard Shader里会有AddPass因为现实世界的材质和视觉效果是极其丰富的。金属的高光反射、丝绸的菲涅尔边缘光泽、透明材质的折射和反射叠加、卡通渲染的二次描边……这些效果很难在一个单一的、追求通用性能的Pass里完美实现。通过AddPassStandard Shader实现了模块化的效果叠加主Pass保证基础物理可信度AddPass则像插件一样按需添加特定的视觉增强。理解AddPass就等于拿到了自定义和扩展Standard Shader效果、甚至从头构建自己复杂着色器的钥匙。2. Standard Shader中AddPass的设计哲学与结构Standard Shader的源码结构清晰地体现了Unity渲染团队对于“通用性”和“可配置性”的权衡。它不是一堆硬编码的Pass堆砌而是一个高度参数化、由宏和条件编译驱动的模板。2.1 核心设计基于#pragma multi_compile的效果开关打开Standard Shader源码通常位于Unity安装目录/Editor/Data/CGIncludes/下的UnityStandardCore.cginc等文件你会发现AddPass并不是一个固定的代码块。相反它是通过一系列#pragma multi_compile指令来动态生成的。例如你经常会看到这样的指令#pragma multi_compile _ _EMISSION #pragma multi_compile _ _METALLICGLOSSMAP #pragma multi_compile _ _DETAIL_MULX2这些指令是什么意思它们告诉Unity的着色器编译器“请为这个Shader生成多个变体Variant”。一个变体对应一套特定的宏定义组合。比如当材质勾选了“Emission”自发光属性时_EMISSION这个关键字就会被定义编译器在编译该材质使用的Shader变体时就会包含所有被#ifdef _EMISSION包裹的代码块——其中就包括了用于渲染自发光效果的AddPass代码。这种设计的好处显而易见按需编译节省资源如果你的材质根本用不到自发光那么对应的自发光AddPass代码根本不会被编译进最终的游戏包节省了GPU内存和Shader加载时间。高度可配置美术人员只需要在材质Inspector面板上勾选不同的选项如Emission, Detail Normal, Parallax等底层Shader就会自动组合出对应的Pass序列。结构清晰将不同功能的代码通过宏隔离使得核心的SurfaceShader模板那个surf()函数保持相对简洁而各种复杂特性在各自的编译块中实现。2.2 AddPass的触发与执行流程一个Standard Shader材质在渲染时GPU是如何知道该执行哪个Pass的呢这由渲染队列Queue和材质的当前配置共同决定。排序与提交Unity的渲染管线无论是内置管线还是URP/HDRP会根据物体的渲染队列如GeometryTransparent和深度进行排序。对于同一个物体如果它的Shader包含多个Pass这些Pass会按在文件中的声明顺序依次执行。变体选择当渲染到一个使用Standard Shader的物体时Unity会根据该材质球当前的属性设置哪些贴图已分配哪些功能已开启从该Shader已编译好的众多变体库中精准选择匹配的那个变体。Pass序列执行选中的变体包含了定义好的Pass序列。首先执行主PassForwardBase完成基础颜色、深度和法线信息写入。然后如果变体中包含了额外的AddPass它们会依次执行。每个AddPass都会重新执行顶点着色器和片元着色器计算但可能会使用不同的混合状态、深度测试状态和片元着色器逻辑。这里有一个关键点AddPass通常会重新计算光照。主Pass计算了一次光照但某些AddPass特别是涉及额外方向光的可能会以不同的光照模型或参数再次计算。这就要求我们在阅读源码时特别注意每个AddPass的光照输入和计算上下文它与主Pass的异同点是理解其行为的关键。3. 关键AddPass源码逐行解析让我们深入到具体的代码中以最典型的“自发光”Emission和“细节贴图”Detail的AddPass为例看看它们是如何实现的。3.1 自发光EmissionAddPass解析自发光效果允许材质表面自己发出光线不受场景光照影响。在Standard Shader中它通常通过一个AddPass实现。首先在UnityStandardInput.cginc中你会找到自发光纹理和颜色的定义sampler2D _EmissionMap; half4 _EmissionColor;在表面着色器函数surf()中自发光颜色的计算通常如下void surf (Input IN, inout SurfaceOutputStandard o) { // ... 其他贴图采样和计算albedo, metallic, smoothness等 half3 emission tex2D(_EmissionMap, IN.uv_MainTex).rgb * _EmissionColor.rgb; o.Emission emission; // ... }但关键在于这个o.Emission是如何在最终的AddPass中被使用的我们需要找到对应的Pass和光照模型。在UnityStandardCore.cginc中搜索与_EMISSION相关的光照模型。你会发现一个名为LightingStandard系列的函数。但自发光通常不依赖于光照模型它是在基础颜色计算之后直接叠加的。真正的魔法发生在最终的片元着色器输出阶段。实际上对于内置渲染管线的Standard Shader自发光通常并不需要一个独立的AddPass。它的计算被集成在了主Pass的片元着色器末尾。在计算完所有直接光、间接光贡献后会有一行类似这样的代码#ifdef _EMISSION finalColor.rgb emission.rgb; #endif这是因为自发光是简单的颜色叠加不依赖于视角或新的光照计算所以可以直接在主Pass完成。注意这里有一个常见的理解误区。很多初学者认为每个材质属性如Emission, Detail都必然对应一个独立的渲染Pass。实际上Unity的优化策略是“能合并就合并”。只有那些需要改变渲染状态如混合模式、或需要以完全不同方式重新计算光照的效果如额外的逐像素光才会被拆分成独立的AddPass。自发光因其简单性被合并处理了。那么什么情况下会有真正的AddPass呢一个经典的例子是额外的逐像素光Additional Per-Pixel Lights。3.2 额外逐像素光AddPass解析在正向渲染Forward Rendering中最亮的一个或几个光源会以逐像素的方式处理主Pass通常处理最亮的方向光。其他光源则可能以逐顶点或球谐函数SH等开销较低的方式处理。但有时为了质量我们希望某些光源也以逐像素的方式作用于物体这就需要额外的Pass。在Standard Shader的Pass定义中你会看到这样的结构Pass { Tags { LightMode ForwardAdd } Blend One One ZWrite Off // ... 其他状态设置 CGPROGRAM #pragma vertex vertAdd #pragma fragment fragAdd #pragma multi_compile_fwdadd // ... 其他编译指令 #include UnityStandardCore.cginc ENDCG }这个Pass的标签是LightMode ForwardAdd这告诉Unity渲染管线“这个Pass用于渲染额外的逐像素光。” 状态设置是关键Blend One One这是典型的加法混合Additive Blending。每个额外光源的计算结果RGB值会直接叠加到当前帧缓冲的颜色上。这正是模拟多个光源贡献叠加的物理行为——光越照越亮。ZWrite Off关闭深度写入。在渲染完主Pass后深度缓冲区已经记录了物体的深度信息。在渲染AddPass时我们只需要对同一像素通过深度测试ZTest通常是LEqual进行颜色叠加而不需要再次写入深度避免深度冲突和不必要的性能开销。在这个Pass里包含的文件UnityStandardCore.cginc会通过条件编译包含一套不同的顶点和片元着色器入口vertAdd,fragAdd以及可能简化版的光照模型。这套着色器代码会针对当前正在处理的这个“额外光源”进行计算并将结果通过加法混合叠加上去。3.3 细节贴图Detail Map的混合策略细节贴图用于在近距离观察时提供更丰富的表面细节。它通常包括一张细节反照率图Detail Albedo Map和一张细节法线图Detail Normal Map。细节贴图的处理也不一定需要独立的AddPass。它更常见的是在主Pass的纹理采样阶段就被混合进去。在surf()函数中你可能会看到这样的逻辑void surf (Input IN, inout SurfaceOutputStandard o) { // 主纹理采样 fixed4 mainTex tex2D(_MainTex, IN.uv_MainTex); fixed4 detailTex tex2D(_DetailAlbedoMap, IN.uv_DetailTex); // 细节混合模式通常是Mulx2 (乘以2.0) 或 Add #ifdef _DETAIL_MULX2 o.Albedo mainTex.rgb * detailTex.rgb * 2.0; #elif _DETAIL_ADD o.Albedo mainTex.rgb detailTex.rgb * 0.5; #endif // 细节法线混合如果存在 #ifdef _DETAIL_MULX2 defined(_NORMALMAP) defined(_DETAIL_NORMALMAP) fixed3 detailNormal UnpackNormal(tex2D(_DetailNormalMap, IN.uv_DetailTex)); o.Normal BlendNormals(o.Normal, detailNormal); #endif // ... }细节贴图的混合发生在表面数据准备阶段因此它影响的是SurfaceOutputStandard结构体中的Albedo和Normal属性。这些被修改后的属性会随后被主Pass的光照模型使用。所以细节贴图是“融入”了基础材质属性而非通过一个独立的渲染Pass叠加视觉效果。4. 自定义与扩展AddPass的实战指南理解了Standard Shader中AddPass的原理我们就可以在自己的Shader中灵活运用这一机制。4.1 编写一个简单的边缘光AddPass假设我们想给物体添加一个基于视角和法线的边缘光Rim Light效果并且希望这个效果作为一个独立的AddPass使用加法混合。定义属性和变量Properties { _RimColor (Rim Color, Color) (1, 0.5, 0, 1) _RimPower (Rim Power, Range(0.5, 8.0)) 3.0 _RimIntensity (Rim Intensity, Range(0, 5)) 1.0 }在CGPROGRAM中声明对应的变量float4 _RimColor; float _RimPower; float _RimIntensity;创建AddPass 在主Pass比如ForwardBase之后添加一个新的Pass。Pass { Tags { LightMode ForwardAdd } Blend One One // 加法混合 ZWrite Off // 关闭深度写入 Cull Back // 背面剔除通常渲染正面 CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdadd // 为额外光源编译变体 #include UnityCG.cginc #include Lighting.cginc #include AutoLight.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; float3 worldNormal : TEXCOORD0; float3 worldViewDir : TEXCOORD1; LIGHTING_COORDS(2,3) // 声明光照衰减和阴影纹理坐标 }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.worldNormal UnityObjectToWorldNormal(v.normal); float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; o.worldViewDir normalize(UnityWorldSpaceViewDir(worldPos)); TRANSFER_VERTEX_TO_FRAGMENT(o); // 传递光照衰减 return o; } fixed4 frag (v2f i) : SV_Target { // 计算边缘因子视角方向与法线点积越边缘值越大 float rim 1.0 - saturate(dot(i.worldViewDir, i.worldNormal)); rim pow(rim, _RimPower) * _RimIntensity; // 应用光照衰减来自额外光源 UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos); // 边缘光颜色乘以衰减和边缘因子 fixed4 rimColor _RimColor * rim * atten; return rimColor; } ENDCG }关键点解析Blend One One和ZWrite Off是这个AddPass的核心状态确保效果是叠加的且不影响深度。#pragma multi_compile_fwdadd指令至关重要它让Unity为这个Pass编译处理不同光源类型点光、聚光、方向光所需的变体。在片元着色器中我们通过UNITY_LIGHT_ATTENUATION宏获取了当前处理的这个额外光源的衰减值并将边缘光效果与之相乘。这意味着边缘光的亮度会随着距离这个额外光源的远近而变化与真实的光照行为一致。4.2 性能权衡何时该用AddPassAddPass虽然强大但代价是额外的绘制调用Draw Call和像素着色器计算。每个AddPass都会导致物体被多渲染一次。因此必须谨慎使用。推荐使用AddPass的场景高质量的多光源渲染当场景中有多个重要的动态光源需要逐像素精度时。独立的后期叠加效果如完全独立于基础材质的全屏扭曲、溶解特效等这些效果需要不同的混合模式或深度测试状态。渲染技术实现例如用于生成深度图、法线图的ShadowCaster Pass本身就是一个功能特定的Pass。应避免或谨慎使用AddPass的场景大量小物体每个物体多一个PassDraw Call数会急剧上升成为CPU瓶颈。全屏覆盖物体AddPass会导致整个屏幕的像素都被多计算一次填充率压力巨大。可以用更廉价方式实现的效果比如前文提到的自发光和细节贴图在主Pass内完成是更优选择。一个重要的优化技巧对于自定义的、非光照相关的视觉AddPass如上面的边缘光例子可以考虑将LightMode标签设为Always而不是ForwardAdd。这样它就不会为每个额外光源执行一次而是只执行一次。但同时你也就无法利用UNITY_LIGHT_ATTENUATION来获取单个光源的衰减了需要自己定义一套光照或强度逻辑。5. 常见问题与深度调试技巧在实际使用和阅读AddPass源码时你肯定会遇到各种问题。这里记录一些典型的“坑”和排查思路。5.1 AddPass不显示或显示异常这是最常见的问题。排查步骤如下检查渲染队列Render Queue某些混合模式如透明度混合Blend SrcAlpha OneMinusSrcAlpha要求物体在透明队列QueueTransparent中渲染。如果你的AddPass使用了这类混合但物体的主队列是Geometry可能会导致渲染顺序错乱AddPass效果不显示或被遮挡。确保Pass的渲染状态与物体的Queue匹配。检查深度测试ZTest和深度写入ZWrite这是AddPass调试的重中之重。一个经典的错误组合是AddPass中ZWrite On且ZTest LEqual。这会导致AddPass渲染的像素覆盖掉主Pass写入的深度可能使得后续本应被遮挡的物体错误地渲染在它上面。对于叠加型效果ZWrite Off是标准配置。检查混合模式Blend错误的混合公式会导致颜色计算异常。例如你想做加法变亮却用了Blend SrcAlpha OneMinusSrcAlpha透明度混合结果会变暗。务必根据你想要的光学效果加色、减色、叠加、屏幕等选择正确的混合指令。检查光源模式LightMode标签如果你的AddPass依赖于ForwardAdd请确保场景中存在启用了Render Mode为Important的动态像素光并且物体的渲染路径是Forward。在Deferred延迟渲染路径下ForwardAddPass是不会被执行的。使用Frame Debugger这是Unity内置的最强调试工具。在Window - Analysis - Frame Debugger中打开它然后启动游戏并捕获一帧。你可以逐步骤查看每个Draw Call和每个Pass的执行情况。找到你的物体展开它的渲染列表查看你的AddPass是否被调用它的输入输出状态是什么。这是定位问题的终极手段。5.2 性能热点分析与优化当你发现使用了AddPass后帧率下降需要定位是CPU瓶颈Draw Call过多还是GPU瓶颈填充率或着色器复杂度过高。CPU瓶颈Draw Call在Unity的Stats面板或Frame Debugger中查看Draw Call数量。如果AddPass导致Draw Call翻倍对于大量物体就是灾难。优化策略静态批处理Static Batching对于不会移动的物体勾选StaticUnity可以将其合并以减少Draw Call。但注意批处理对每个物体的材质属性一致性有要求。GPU Instancing在Shader中添加#pragma multi_compile_instancing并处理实例化属性对于大量使用相同材质和网格的物体可以极大减少Draw Call。重新考虑架构是否真的需要每个物体都有这个AddPass效果能否通过后处理Post-Processing屏幕特效来近似实现GPU瓶颈填充率过高在Frame Debugger中查看Overdraw可以通过渲染线框模式辅助观察。如果AddPass导致大片区域被重复绘制多次就会造成填充率瓶颈。优化方法是尽量缩小AddPass的效果范围通过顶点颜色、遮罩纹理控制或者降低渲染分辨率。着色器复杂度过高在AddPass的片元着色器中使用了过于复杂的计算如循环、多次纹理采样、复杂数学函数。使用Unity的Shader Profiler或外部GPU性能分析工具如RenderDoc来定位热点。优化方法是简化计算、使用查找表LUT、或将部分计算烘焙到纹理中。5.3 与URP/HDRP的AddPass对应概念如果你在使用更现代的Universal RP (URP) 或 High Definition RP (HDRP)概念是相通的但实现方式不同。URPURP倡导的是“单Pass前向渲染”默认情况下一个物体在一个渲染Pass中处理所有光源通过AdditionalLights循环。因此传统意义上的“ForwardAdd” Pass在URP的默认前向渲染器中不存在。但是你仍然可以通过编写自定义的ScriptableRenderPass在渲染管线中插入一个完整的Pass来实现类似的叠加效果这给了你更大的控制权但也更复杂。HDRPHDRP使用基于物理的、高度可编程的渲染管线。它通过Shader Graph和Custom Pass节点提供了可视化且强大的多Pass效果创建方式。在HDRP中实现类似效果通常是在材质中配置多个Surface Options或者通过Custom Pass在渲染流程的特定阶段注入全屏或基于深度的后处理效果。理解内置管线Standard Shader的AddPass机制是掌握这些更高级渲染管线中对应概念的重要基石。它让你从“黑盒使用”走向“透明控制”是Shader学习道路上必须攻克的一个核心关卡。当你再看到那些复杂的商业Shader包含多个Pass时你不会再感到困惑而是能清晰地分析出每个Pass的职责、代价和实现思路从而更好地使用、调试乃至创造属于自己的渲染魔法。