1. 项目概述为什么需要多个Pass在Unity引擎里写Shader尤其是想实现一些稍微复杂点的视觉效果时你几乎一定会遇到一个结构一个SubShader里包含了多个Pass。对于刚接触Shader编程的朋友来说这可能会让人有点困惑——为什么一个着色器需要“通过”好几次一个Pass不就够了吗今天我们就来深入聊聊这背后的具体细节从设计逻辑到实际应用再到那些容易踩的坑希望能帮你彻底搞明白。简单来说一个Pass定义了一次完整的渲染流程包括顶点变换、片元着色、深度测试、混合等。而多个Pass就意味着同一个物体或者说同一个SubShader所描述的材质会被渲染多次。这听起来似乎有点“浪费”但在图形渲染的世界里为了实现某些特定的、无法单次绘制完成的视觉效果这种“浪费”是必要且强大的工具。比如你想让一个物体既有漫反射光照又有一个发光的轮廓边还希望它能投射出正确的阴影这些任务往往无法在一个渲染通道内兼顾这时就需要拆分成多个Pass来分别处理。理解多Pass机制是进阶Shader编程、实现复杂特效如透明物体渲染、描边、深度写入控制、多光源处理等的关键。它直接关系到你最终效果的准确性、性能和与Unity渲染管线的兼容性。接下来我们就一层层剥开它的内核。1.1 核心需求解析何时以及为何要动用多个Pass为什么我们不能把所有事情都塞进一个Pass里这得从GPU的渲染管线和一个Pass的能力边界说起。一个标准的Pass其输出最终会写入到帧缓冲Frame Buffer中特别是颜色缓冲和深度缓冲。很多渲染效果之间存在依赖或冲突关系无法在一次写入中和谐共存。典型场景一透明与半透明物体的正确渲染。这是最经典的多Pass用例。渲染透明物体需要开启Alpha混合Blend但这通常会关闭深度写入ZWrite Off以防止后面的透明片元错误地遮挡前面的。问题来了如果一个透明物体自身有复杂的形状它自身的不同部分之间谁前谁后呢如果都不写深度它们就会相互穿透乱成一团。常见的解决方案是使用两个Pass第一个Pass只写入深度ZWrite On, ColorMask 0不输出颜色目的是为物体自身建立一个“深度壳”第二个Pass再进行正常的透明混合渲染并基于第一个Pass建立的深度进行测试这样就能正确处理好自身遮挡了。典型场景二实现非真实感渲染效果如卡通描边。卡通渲染中的描边常用“背面膨胀”法。这需要两个Pass第一个Pass只渲染物体的背面并沿着法线方向将顶点稍微挤出输出一个纯色如黑色作为描边第二个Pass再正常渲染物体的正面。这两个Pass的顺序和渲染状态特别是剔除模式Cull截然不同必须分开。典型场景三兼容不同的渲染路径或光照模式。在Unity内置渲染管线中一个Pass的LightMode标签定义了它如何与光照系统交互。例如一个LightMode为ForwardBase的Pass处理主方向光和环境光而另一个LightMode为ForwardAdd的Pass则用于处理额外的逐像素光。如果你想让物体受多个动态光源影响就必须有对应的Additive Pass。此外投射阴影ShadowCasterPass和接收阴影通常由光照Pass处理也常常是独立的Pass。典型场景四多阶段特效叠加。比如一个魔法盾效果可能包含一个基础折射层、一个流动的符文层、一个外发光边。每一层可能有不同的纹理动画、混合模式和深度测试需求将它们放在不同的Pass中分别渲染逻辑会清晰很多也便于独立调整。注意使用多Pass是一把双刃剑。每一个额外的Pass都意味着对物体所有顶点和片元的多一次处理会直接增加Draw Call和GPU负载。在移动平台或性能敏感的场景中必须谨慎评估时刻考虑是否可以通过更巧妙的单Pass Shader例如使用多张纹理和更复杂的片元着色器计算来替代。2. SubShader与Pass的架构与执行流程要理解多Pass必须先厘清Unity ShaderLab的结构层次Shader-SubShader-Pass。一个Shader资产可以包含多个SubShaderUnity会从上到下选择第一个能在当前运行平台上运行的SubShader。而每个SubShader则包含一个或多个Pass。渲染一个物体时对于选中的那个SubShader其中的所有Pass会按定义的顺序依次执行。2.1 SubShader平台的适配层你可以把SubShader理解为针对不同硬件或渲染配置的“方案集”。在SubShader的开头通常会使用Tags、LOD和Shader特性shader_feature来声明其运行条件。例如你可能为PC写一个包含复杂光照模型和多个Pass的高配版SubShader同时为移动端写一个简化版的、Pass数量更少的SubShader。Unity的自动选择机制保证了兼容性。2.2 Pass渲染指令的原子单元Pass才是渲染工作的具体执行者。每个Pass内部结构相对固定名称与标签Name TagsPass可以有一个名字供其他Shader引用如UsePass。Tags里最重要的莫过于LightMode它告诉Unity这个Pass属于渲染流程的哪一部分如ForwardBase,ForwardAdd,ShadowCaster,DepthOnly等。渲染状态设置RenderSetup这里用命令直接设置GPU的渲染状态。这是多Pass玩法差异化的核心所在。常用命令包括Cull Back/Front/Off设置多边形剔除模式。ZTest LEqual/GEqual/Always...设置深度测试函数。ZWrite On/Off是否写入深度缓冲。Blend SrcAlpha OneMinusSrcAlpha设置Alpha混合模式。ColorMask RGBA/R/0...控制写入颜色缓冲的通道。Stencil { ... }设置模板缓冲操作用于更复杂的遮罩效果。CGPROGRAM/HLSLPROGRAM代码块这里是着色器程序本体包含#pragma指令、变量声明、vert顶点函数和frag片元函数。不同Pass的代码块通常是完全独立的它们可以有不同的输入输出结构、不同的光照计算甚至引用不同的纹理。2.3 多Pass的执行顺序与帧缓冲交互这是理解多Pass行为的关键。当Unity按顺序执行一个SubShader中的多个Pass时后一个Pass的渲染是在前一个Pass完成渲染后的帧缓冲基础上进行的。这意味着深度缓冲Z-Buffer的继承默认情况下深度测试ZTest和深度写入ZWrite状态是开启的。如果Pass1写入了一个像素的深度那么Pass2渲染同一位置的像素时会进行深度比较。你可以通过ZTest和ZWrite命令精确控制每个Pass如何与深度缓冲交互。例如Pass1ZWrite On写入了深度Pass2ZTest EqualZWrite Off就只渲染与Pass1深度完全相等的像素常用于边缘高光。颜色缓冲Color Buffer的叠加颜色缓冲的叠加方式由Blend命令控制。这是实现透明、叠加、发光等效果的核心。比如一个常见的双Pass透明材质Pass1用Blend One One实现自发光AdditivePass2用Blend SrcAlpha OneMinusSrcAlpha实现半透明Alpha Blending。两个Pass的颜色会按设定的混合公式依次叠加到最终屏幕上。模板缓冲Stencil Buffer的协作模板缓冲为多Pass协同提供了更精细的控制。Pass1可以设置模板值Ref和操作Pass/Fail等Pass2则可以基于模板值Comp来决定是否渲染。这在制作门户、镜子、特殊选区等效果时非常有用。一个重要的心智模型把每个Pass想象成一个“图层”物体被多次绘制这些图层按顺序叠加在一起共同构成了最终的视觉效果。你需要精心设计每个图层的“绘画规则”渲染状态以及它们之间的叠加关系。3. 核心细节解析与实操要点了解了架构我们来看看在编写多Pass Shader时有哪些必须关注的细节和容易出错的点。3.1 渲染状态的管理与隔离每个Pass的渲染状态是独立的。一个常见的错误是认为状态会在Pass间延续。例如你在Pass1里写了Cull Front在Pass2里如果不显式声明它会回退到默认的Cull Back。但更危险的是Blend和ZWrite这类状态如果你在Pass1中开启了混合Blend SrcAlpha OneMinusSrcAlpha在Pass2中想进行不透明的渲染必须显式地关闭混合即写上Blend Off否则Pass2也会继续使用混合模式导致错误。实操建议为每个Pass都完整地、显式地定义所有关键的渲染状态即使它与默认值相同。这能提高代码的可读性和可维护性避免因默认值改变或Pass顺序调整带来的隐性Bug。可以建立一个自己的“状态预设”注释模板。// Pass 1: 深度预写入 Pass { Name DEPTHPREPASS Tags { LightMode ForwardBase } // 显式声明所有状态即使部分为默认值 Cull Back ZTest LEqual ZWrite On // 关键写入深度 ColorMask 0 // 关键不输出任何颜色纯深度Pass Blend Off // 关键关闭混合 HLSLPROGRAM // ... shader code ... ENDHLSL } // Pass 2: 正常透明渲染 Pass { Name FORWARDBASE Tags { LightMode ForwardBase } // 显式声明所有状态 Cull Back ZTest LEqual // 基于Pass1写入的深度进行测试 ZWrite Off // 关键透明物体通常关闭深度写入避免遮挡问题 ColorMask RGBA Blend SrcAlpha OneMinusSrcAlpha // 关键开启标准Alpha混合 HLSLPROGRAM // ... shader code ... ENDHLSL }3.2 LightMode标签的精确匹配在Forward渲染路径下LightMode标签决定了Pass何时被调用。ForwardBasePass在每个物体渲染时调用一次处理环境光、主平行光和逐顶点/SH光源。ForwardAddPass则为每个额外的逐像素光源调用一次并且其混合模式必须设置为Blend One One相加混合因为多个光源的效果是叠加的。一个常见的性能陷阱是在ForwardAddPass中错误地使用了复杂的、本应在ForwardBase中计算一次的信息如采样一张昂贵的大纹理导致该计算为每个额外光源重复执行造成巨大的性能开销。正确的做法是将这些昂贵计算的结果从ForwardBasePass通过变量如v2f结构体传递出来或者在ForwardAddPass中只进行与光源直接相关的轻量计算。对于ShadowCaster Pass这是Unity为物体生成阴影贴图时调用的Pass。如果你自定义的Shader没有显式定义一个LightMode为ShadowCaster的PassUnity会尝试使用一个内置的Fallback。但如果你物体的顶点变换或裁剪行为特殊比如使用了顶点动画、透明度裁剪就必须自定义ShadowCasterPass确保阴影的形状正确。在这个Pass里你通常只需要进行顶点变换和深度输出片元着色器极其简单。3.3 顶点与片元着色器程序的复用与差异不同Pass的CGPROGRAM块是独立的。这意味着你可以复用代码通过#include指令将共同的函数、结构体定义、常量缓冲区等放在一个.cginc或.hlsl文件中然后在各个Pass中包含它。这是保持代码整洁的最佳实践。它们必须有独立的入口函数即使代码完全一样每个Pass也必须定义自己的#pragma vertex vert和#pragma fragment frag。你不能让两个Pass指向同一个函数。输入输出结构可以不同ForwardBasePass可能需要接收光照贴图的UV坐标而ShadowCasterPass则不需要。你应该为每个Pass定义最精简的appdata顶点输入和v2f顶点到片元输出结构只包含该Pass必需的数据这有助于优化性能。3.4 深度与模板测试的精细控制多Pass的强大很大程度上源于对深度和模板缓冲的精细操控。深度预写入Z-Prepass如前所述用于透明物体。第一个PassZWrite On, ColorMask 0第二个PassZWrite Off。确保两个Pass的ZTest函数一致通常都是LEqual。深度相等测试ZTest Equal用于在物体表面精确位置绘制效果如腐蚀边缘、积雪效果。先有一个Pass正常渲染写入深度第二个Pass使用ZTest Equal和ZWrite Off并稍微修改顶点位置如沿法线偏移就能只在与原表面深度相等的“边缘”处绘制。模板缓冲多Pass协作这是更高级的技法。例如制作一个X光透视效果Pass1: 正常渲染物体写入深度和颜色同时将模板值设为1Stencil { Ref 1 Pass Replace }。Pass2: 渲染一个放大的、代表“透视轮廓”的模型。设置Stencil { Ref 1 Comp NotEqual }。这意味着只有那些模板值不等于1的像素即不在正常物体范围内的像素才会被渲染从而完美地在外围形成轮廓。这个Pass可以关闭深度写入和测试并采用Additive混合。4. 常见问题与排查技巧实录即使理解了原理在实际编写和调试多Pass Shader时还是会遇到各种诡异的问题。下面是我踩过的一些坑和总结的排查思路。4.1 问题透明物体渲染顺序错乱或内部穿透现象一个半透明的球体其内部的某些部分看起来穿透到了前面或者多个透明物体叠加时顺序不对。排查与解决检查渲染队列Queue确保Shader的SubShader或Pass的Tags里设置了正确的QueueTransparent。透明物体必须晚于不透明物体渲染。检查多Pass顺序如果你使用了深度预写入的双Pass方案确保第一个是只写深度的Pass第二个是进行Alpha混合的Pass。顺序反了会导致深度信息在混合后才写入失去意义。检查深度写入ZWrite在进行透明混合的Pass中确认设置了ZWrite Off。如果打开后渲染的透明片元会覆盖先渲染的破坏混合顺序。考虑排序问题Unity对于Queue相同的透明物体默认按到相机的距离排序后渲染。但对于单个复杂透明物体内部GPU片元渲染顺序是不确定的。深度预写入双Pass是解决自相交的标准方案。对于多个透明物体如果排序仍有问题可能需要手动调整它们的渲染队列值如QueueTransparent100来强制顺序。4.2 问题使用了多个Pass但物体始终只显示一个Pass的效果现象明明写了两个Pass比如一个描边Pass和一个主体Pass但屏幕上只看到了主体颜色描边没出现。排查与解决检查剔除Cull模式描边Pass常用Cull Front剔除正面渲染背面主体Pass用Cull Back。如果两个Pass用了相同的剔除模式后一个Pass会覆盖前一个。确认它们不同。检查颜色掩码ColorMask和混合Blend描边Pass是否正确地输出了颜色如果描边Pass不小心设置了ColorMask 0或者混合模式设置错误如Blend Zero One结果不显示颜色就无法写入缓冲。用帧调试器Frame Debugger查看每个Pass的输出最直观。检查深度测试ZTest如果描边Pass的顶点向外挤出其深度值可能比主体Pass更大更远。如果描边Pass的ZTest是默认的LEqual小于等于才通过那么当它比主体深度大时片元就会被丢弃。对于描边通常需要设置ZTest Always或Less如果挤出方向是沿法线深度可能变小需具体分析。使用帧调试器Frame Debugger这是Unity提供的终极武器。打开Window - Analysis - Frame Debugger逐帧、逐Draw Call、逐Pass地查看渲染过程。你可以清晰地看到每个Pass是否被执行它的渲染状态是什么输出结果如何。这是诊断多Pass问题的首选工具。4.3 问题自定义ShadowCaster Pass后阴影形状不对或缺失现象为带有顶点动画的Shader添加了自定义ShadowCasterPass但物体投射的阴影是静止的或者根本没有阴影。排查与解决确保顶点变换一致在ShadowCasterPass的顶点着色器中对顶点位置和法线的变换必须与主渲染Pass完全一致。如果你在主Pass里做了顶点动画如正弦波摆动在ShadowCasterPass里必须用完全相同的算法和参数再做一遍。一个技巧是将顶点动画的函数抽象出来放在一个#include文件中供两个Pass共用。处理透明度裁剪Alpha Clip如果你的Shader使用了clip()函数根据纹理Alpha值丢弃片元那么在ShadowCasterPass中也必须进行同样的裁剪操作否则阴影会是一个完整的矩形。需要将纹理采样和裁剪逻辑复制到该Pass的片元着色器中。检查LightMode标签确认Pass的Tags是LightMode ShadowCaster。输出深度ShadowCasterPass的片元着色器通常非常简单甚至可以直接返回float4(0,0,0,0)因为阴影贴图只关心深度信息。但要确保顶点着色器正确输出了裁剪空间下的位置。4.4 问题性能急剧下降特别是使用ForwardAdd Pass后现象场景里放了几个点光源使用了包含ForwardAddPass的Shader帧率暴跌。排查与解决审视Add Pass的复杂度如前所述ForwardAddPass会为每个逐像素光源执行一次。检查这个Pass里的计算是否采样了高分辨率纹理是否进行了复杂的数学运算如pow,sin尽量将计算移到ForwardBasePass中或者使用更廉价的近似。使用光照衰减纹理对于内置管线使用UNITY_LIGHT_ATTENUATION宏来获取衰减它通常是基于纹理查找的比较高效。避免自己手动计算基于距离的复杂衰减公式。限制光源数量和类型在Unity灯光设置中合理设置光源的渲染模式Important为逐像素Not Important可能为逐顶点或SH。在Quality Settings中限制像素光源数量。考虑使用延迟渲染Deferred Rendering如果场景动态光源非常多Forward渲染路径下每个物体受多个光源影响会产生大量Draw Call每个光源每个物体一个Add Pass。切换到延迟渲染路径可以极大地缓解此问题因为光照计算是在屏幕空间进行的与场景复杂度解耦。但延迟渲染也有其限制如对半透明和抗锯齿的支持。4.5 一个实用的多Pass调试技巧ColorMask隔离法当你搞不清是哪个Pass贡献了最终颜色的哪个部分时可以使用ColorMask命令进行隔离调试。例如你有一个三Pass的Shader描边、漫反射、高光效果不对。你可以将描边Pass的ColorMask改为R只输出红色。将漫反射Pass的ColorMask改为G只输出绿色。将高光Pass的ColorMask改为B只输出蓝色。然后运行游戏观察物体颜色。如果描边应该是红色区域却显示为黄色红绿那就说明绿色漫反射Pass也错误地渲染到了描边区域问题可能出在深度测试或剔除设置上。这种方法能快速定位问题Pass。5. 高级应用与性能优化策略掌握了基础我们可以看看一些更高级的多Pass用法和如何让它们跑得更快。5.1 利用GrabPass实现屏幕后处理效果GrabPass是一个特殊的Pass它不在物体自身的坐标系中渲染而是抓取当前帧缓冲中在该物体被渲染之前的内容。这可以用来实现折射、热扭曲、屏幕空间溶解等效果。一个典型的用法是在一个SubShader的最前面定义一个GrabPass { }它会将屏幕图像抓取到一张名为_GrabTexture的纹理中后续的Pass就可以采样这张纹理。需要注意的是GrabPass比较耗费性能因为它可能打断渲染批处理并且需要额外的纹理拷贝。在移动平台上要慎用。优化方法是使用GrabPass { _MyGrabTex }为其指定一个名称这样同一帧内多个使用相同名称GrabPass的Shader可以共享抓取结果减少拷贝次数。5.2 多Pass与GPU Instancing的兼容性GPU Instancing是提升渲染大量相同物体性能的关键技术。但多Pass可能会破坏Instancing。Unity的GPU Instancing是按Draw Call合并的而每个Pass通常会产生独立的Draw Call。因此一个包含3个Pass的Shader即使渲染100个相同物体也可能会产生300个Draw CallInstancing无法在Pass间生效。优化策略精简Pass数量这是最根本的。思考是否真的需要这么多Pass能否用更复杂的片元计算合并效果使用Shader变体Variants通过#pragma multi_compile或shader_feature为不同的功能组合生成变体。例如一个“高级”变体包含3个Pass一个“低级”变体只包含1个Pass。在运行时根据物体与相机的距离或质量设置动态切换MaterialPropertyBlock或使用不同的材质实例。对于ShadowCaster Pass幸运的是Unity在渲染阴影时对于使用相同ShadowCasterPass的物体即使主Pass不同有时也能进行一定程度的合批。确保你的自定义ShadowCasterPass尽可能简洁高效。5.3 针对移动平台如OpenGL ES的特殊考量移动平台的GPU架构和驱动与PC不同对多Pass的支持和性能表现也有差异。精度问题在移动端应尽量使用half或fixed精度在HLSL中对应min16float等来声明变量和进行计算特别是在片元着色器中。多Pass意味着多次计算精度优化带来的性能收益更明显。Alpha测试Alpha Test的开销在部分移动GPU特别是旧的PowerVR架构上使用clip()进行Alpha测试即镂空性能开销很大因为它会打断硬件优化。如果可能尽量使用Alpha混合来代替。如果必须用考虑将测试逻辑放在顶点着色器或使用alphatest:variable指令。Overdraw过度绘制多Pass必然导致Overdraw增加。在移动端Overdraw是性能杀手。务必确保每个Pass的片元着色器尽可能轻量并利用深度测试尽早丢弃片元。对于完全被遮挡的Pass例如一个在物体内部的Pass考虑是否可以通过更巧妙的单Pass方案避免。纹理采样次数多个Pass可能意味着对同一张纹理的多次采样。检查是否可以通过在顶点着色器中计算纹理坐标或者将多个纹理打包到一张图集的不同通道如RGBA存储不同信息来减少采样次数。5.4 使用Shader变体Multi_compile来管理多Pass特性对于需要支持多种配置如是否有阴影、是否有镜面高光、是否有法线贴图的Shader为每种配置都写一个独立的SubShader或通过if分支判断都不是好主意。前者造成代码冗余后者可能导致所有分支的代码都被编译进Shader增大包体。更好的做法是使用#pragma multi_compile或#pragma shader_feature。你可以定义一些关键字然后根据关键字的存在与否来编译不同的Pass或Pass内的不同代码块。例如你可以这样组织一个支持镜面高光_SPECULAR和法线贴图_NORMALMAP的ShaderSubShader { // 一个基础Pass始终存在 Pass { ... } // 例如 ForwardBase Pass // 一个可选的镜面高光Add Pass通过关键字控制 #pragma shader_feature _SPECULAR_ON #ifdef _SPECULAR_ON Pass { Tags { LightMode ForwardAdd } Blend One One // ... 镜面高光计算代码 ... } #endif // ShadowCaster Pass其内部代码根据_NORMALMAP关键字变化 Pass { Tags { LightMode ShadowCaster } #pragma shader_feature _NORMALMAP HLSLPROGRAM #ifdef _NORMALMAP // 使用法线贴图的变换代码 #else // 不使用法线贴图的代码 #endif ENDHLSL } }在脚本中你可以通过Material.EnableKeyword(_SPECULAR_ON)来动态启用或禁用这个Pass。这样你就拥有了一个模块化、可配置的多Pass Shader系统既能满足功能需求又能避免不必要的性能开销。理解Unity Shader中多Pass的细节是从“能用Shader”到“精通Shader”的必经之路。它要求你不仅关注片元着色器里的数学计算更要建立起对完整渲染管线的宏观认知懂得如何安排多个渲染图层并控制它们与各种缓冲区的交互。