Unity Shader Stencil模板测试:从原理到实战,实现高级溶解与高亮效果
1. 项目概述从Mask的局限到Stencil的无限可能如果你在Unity里做过UI特效或者场景交互肯定对Mask组件不陌生。它就像一把剪刀能把图片、模型超出指定范围的部分“剪掉”是实现圆形头像、不规则面板的标配。但用久了你会发现Mask的“剪刀”太钝了——它只能做“显示”或“不显示”这种非黑即白的裁切一旦你想玩点花的比如让物体被遮挡的部分呈现半透明的溶解效果或者只在特定区域高亮而其他地方保持原样Mask就立刻束手无策了。这正是我们今天要聊的**Unity Shader Stencil模板测试**技术的用武之地。Stencil不像Mask那样粗暴地丢弃像素它更像一个精密的“签证官”基于一套灵活的规则模板值、比较函数来决定每个像素的命运从而实现溶解边缘、区域高亮、透视描边等Mask根本无法企及的高级视觉效果。我最初接触Stencil是为了解决一个战斗场景中的技能范围提示问题。用Mask做的环形范围指示器边缘生硬得像用尺子画出来的毫无美感。而改用Stencil后我们实现了边缘带有噪声纹理的、动态扩散的溶解效果视觉反馈立刻提升了几个档次。这不仅仅是换了个技术更是打开了特效设计思路的一扇新大门。无论你是想实现《只狼》里那种敌人锁定后的区域高亮还是类似《Apex英雄》中角色护盾破裂时的能量溶解边缘Stencil都是你工具箱里不可或缺的利器。它直接运行在GPU的渲染管线中性能开销可控且提供了像素级的精确控制。2. Stencil模板测试的核心原理与工作流程要玩转Stencil不能只停留在“抄代码”的层面必须理解它在渲染流水线中的位置和工作原理。你可以把整个渲染过程想象成在一张画布上作画而Stencil就是作画前铺在画布上的一张带镂空图案的“模板纸”。2.1 渲染管线中的“守门员”在Unity的渲染管线无论是内置管线还是URP/HDRP中一个像素要想最终被画到屏幕上通常需要闯过三道关卡深度测试Depth Test、模板测试Stencil Test和混合Blending。深度测试决定谁在前谁在后模板测试则根据我们设定的规则决定这个像素有没有“资格”被继续处理混合则决定了这个像素的颜色如何与屏幕上已有的颜色进行混合。Stencil测试发生在深度测试之后、混合之前它是一个非常高效的“守门员”。Stencil的核心是模板缓冲区Stencil Buffer。这是一个与屏幕像素一一对应的缓冲区每个像素位置存储着一个整数值通常是8位范围0-255。在渲染一个物体前我们可以先通过一个“准备Pass”向模板缓冲区写入特定的值比如1划定一个区域。然后在渲染主体物体时设置其Stencil操作只有当像素位置的模板值满足特定条件例如等于1时才允许这个像素通过测试并进行后续渲染否则就被丢弃。这套“写入规则-测试规则”的机制赋予了Stencil无与伦比的灵活性。2.2 Stencil语法与关键参数详解在Unity ShaderLab中我们在Pass块里使用Stencil块来配置模板测试。其核心参数可以分为两大类测试规则和操作规则。Stencil { Ref 1 // 参考值用于比较 Comp Always // 比较函数 Pass Keep // 测试通过时的操作 Fail Keep // 测试失败时的操作 ZFail Keep // 深度测试失败时的操作 ReadMask 255 // 读取掩码 WriteMask 255 // 写入掩码 }Ref (Reference Value)这是一个我们设定的整型参考值。它会在比较函数Comp中被用到也可以被写入模板缓冲区。Comp (Comparison Function)比较函数。定义了当前像素的模板缓冲值经过ReadMask处理与Ref值进行比较的规则。常见选项有Always永远通过测试相当于不管模板值。Never永远不通过。Equal相等时通过。NotEqual不相等时通过。Less/LEqual/Greater/GEqual小于/小于等于/大于/大于等于时通过。Pass / Fail / ZFail (Stencil Operation)这三个参数定义了在不同测试结果下对模板缓冲区值的操作。Keep保持原值不变。Zero将值设为0。Replace用Ref值替换当前模板值。IncrSat/DecrSat增加1或减少1并钳制在0-255之间。IncrWrap/DecrWrap增加1或减少1但允许溢出后循环25510。ReadMask / WriteMask位掩码。在读取模板值进行比较或写入新值前会先与这两个掩码进行按位与()操作。默认255二进制11111111意味着所有位都参与。注意Stencil块是写在每个Pass里的。这意味着一个Shader的多个Pass可以有不同的Stencil配置从而实现多阶段、复杂的模板效果。这是理解高级用法的关键。2.3 与Mask组件的本质区别理解了Stencil的原理再回头看Mask组件其局限性就一目了然了。Unity的UI Mask或Sprite Mask其底层实现虽然也可能用到模板测试但它对开发者是完全封装的只提供了“矩形”、“圆形”等少数几种固定形状的裁切且规则是硬编码的“内部显示外部裁剪”。你无法自定义测试规则比如“不等于某个值才显示”也无法控制测试失败时的行为比如让失败的部分以半透明方式渲染。而Stencil将所有这些控制权都交给了开发者。你可以定义任意形状的“模板”通过一个渲染特定形状的Pass来写入模板值。制定任意复杂的“显示规则”通过Comp设置比较条件例如NotEqual可以让模板区域外部的内容显示出来这是Mask做不到的。实现非二值化的效果结合Shader的片段着色器在测试通过或失败时你不仅可以决定画不画还可以决定怎么画。比如测试失败的像素你可以让它渲染为半透明的红色从而实现“区域外高亮”或“溶解边缘”。3. 实战一实现动态溶解边缘效果溶解效果是游戏中非常常见的视觉特效常用于物体消失、被腐蚀、传送等场景。传统的溶解Shader通常基于一张噪声图根据阈值来丢弃clip像素。但这样实现的边缘是全局统一的且无法与其他物体产生基于空间的交互。而利用Stencil我们可以实现一种“被特定区域接触时才溶解”的边缘效果比如一个魔法结界在碰到玩家时接触边缘开始融化。3.1 效果设计与Shader结构规划我们的目标是一个普通的物体当另一个指定的“溶解源”物体比如一个碰撞球靠近它时两者接触的边缘会产生动态的、带有噪声纹理的溶解效果。这需要两个Shader协同工作溶解源Shader负责在模板缓冲区中标记出“溶解源”物体所占的屏幕区域。我们将其模板值设为1。被溶解物体Shader其渲染分为两个部分。首先正常渲染物体本体。然后在一个单独的Pass中只渲染那些位于“溶解源”模板区域边缘的像素并应用溶解算法。这里的关键在于“边缘”的检测。我们可以利用模板测试的Equal和NotEqual结合多Pass渲染来实现。3.2 溶解源物体的模板标记Shader这个Shader非常简单它的唯一目的就是向模板缓冲区写入一个标记值并且本身不进行任何颜色输出或者输出一个极小的深度值避免影响后续深度测试。Shader Custom/StencilDissolveSource { SubShader { Tags { RenderTypeOpaque QueueGeometry-1 } // 确保先渲染 ColorMask 0 // 不写入任何颜色 ZWrite Off // 关闭深度写入避免影响后续物体 Pass { Stencil { Ref 1 Comp Always Pass Replace // 无条件地用1替换模板缓冲区的值 ZFail Keep } CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); return o; } fixed4 frag (v2f i) : SV_Target { return 0; } // 输出黑色但ColorMask 0使其无效 ENDCG } } }关键点解析QueueGeometry-1我们将这个物体的渲染队列设置为比普通几何体Geometry队列值2000更早1999。这是为了确保它先于被溶解物体渲染从而先完成模板缓冲区的写入。ColorMask 0这是一个关键指令。它表示这个Pass不向颜色缓冲区写入任何颜色R、G、B、A通道全部关闭。这样这个物体在屏幕上就完全不可见了但它依然会进行深度和模板测试。ZWrite Off关闭深度写入。因为我们不关心它的深度信息只关心模板值。关闭后可以避免它错误地遮挡住后面的物体。Stencil块Comp Always和Pass Replace组合意味着这个物体覆盖的所有像素其模板缓冲区值都会被无条件地设置为Ref值也就是1。将这个Shader赋给作为“溶解源”的物体比如一个Sphere它就会在屏幕上无形地刻下一个值为1的模板区域。3.3 被溶解物体的多Pass溶解Shader这是实现效果的核心。我们需要三个PassPass 0: 正常渲染本体。渲染物体原本的样子但需要设置Stencil确保这个Pass不会破坏溶解源写入的模板值通常用Keep操作。Pass 1: 渲染溶解边缘在模板区域内。这个Pass只渲染物体与模板区域重叠的部分并应用边缘溶解算法。Pass 2: 渲染溶解边缘在模板区域外。这个Pass只渲染物体在模板区域之外但紧邻边缘的部分同样应用溶解算法可能颜色有所不同以增强立体感。Shader Custom/StencilDissolve { Properties { _MainTex (Albedo (RGB), 2D) white {} _NoiseTex (Dissolve Noise, 2D) white {} _EdgeWidth (Edge Width, Range(0, 0.5)) 0.1 _DissolveThreshold (Dissolve Threshold, Range(0, 1)) 0.5 _EdgeColor (Edge Color, Color) (1, 0.5, 0, 1) } SubShader { Tags { RenderTypeOpaque } // Pass 0: 正常渲染物体本体 Pass { Stencil { Ref 0 // 参考值不重要因为操作是Keep Comp Always Pass Keep // 最关键的操作保持模板缓冲区原有值不变 Fail Keep ZFail Keep } CGPROGRAM // ... 标准的光照模型和纹理采样代码此处省略 ENDCG } // Pass 1: 渲染模板区域内的溶解边缘 Pass { Blend SrcAlpha OneMinusSrcAlpha // 开启Alpha混合 Cull Off // 关闭背面剔除边缘两面都要渲染 Stencil { Ref 1 Comp Equal // 仅当模板值等于1即在溶解源区域内时渲染 Pass Keep } CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _NoiseTex; float _EdgeWidth; float _DissolveThreshold; float4 _EdgeColor; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldPos : TEXCOORD1; }; // ... 顶点着色器代码省略 fixed4 frag (v2f i) : SV_Target { // 采样噪声图基于世界坐标或UV使其动起来 float noise tex2D(_NoiseTex, i.worldPos.xz * 0.1 _Time.y * 0.2).r; // 计算溶解因子噪声值减去阈值再映射到边缘宽度 float dissolveFactor (noise - _DissolveThreshold) / _EdgeWidth; // 钳制到0-1范围smoothstep让边缘过渡更柔和 float edgeAlpha smoothstep(0, 1, dissolveFactor); // 越靠近阈值中心溶解前沿颜色越亮 float edgeStrength 1.0 - abs(dissolveFactor - 0.5) * 2.0; fixed4 col _EdgeColor; col.a * edgeAlpha * edgeStrength; return col; } ENDCG } // Pass 2: 渲染模板区域外的溶解边缘可选用于外发光 Pass { Blend SrcAlpha One // 加法混合用于发光效果 Cull Off Stencil { Ref 1 Comp NotEqual // 仅当模板值不等于1即在溶解源区域外时渲染 Pass Keep } CGPROGRAM // 片段着色器与Pass 1类似但可以使用不同的颜色如蓝色和更宽的边缘 // _EdgeColor2 (0, 0.5, 1, 1) // _EdgeWidth2 _EdgeWidth * 1.5 ENDCG } } }实操心得与避坑指南渲染顺序是生命线必须确保“溶解源”物体使用StencilDissolveSource先于被溶解物体渲染。除了设置Queue还可以通过脚本控制Renderer的material.renderQueue或者在URP中配置Render Objects Renderer Feature来精确排序。模板值的“污染”在Pass 0中我们使用了Pass Keep。这是为了防止物体本体的渲染覆盖掉溶解源写入的模板值1。如果错误地使用了Replace会导致整个物体所在的模板区域都被重置后续Pass无法正确识别边缘。噪声与动画溶解的动态效果来源于噪声纹理的采样坐标动画如加上_Time.y。使用世界坐标i.worldPos而非UV坐标进行采样可以使溶解效果在物体移动时保持稳定不会“粘”在模型表面滑动。性能考量这个Shader包含了3个Pass意味着物体会被渲染三次。对于大量物体这会带来显著的Draw Call开销。在实际项目中应将其用于关键的特效物体并考虑使用GPU Instancing或SRP Batcher进行优化。4. 实战二实现精准区域高亮效果区域高亮是另一个Stencil大放异彩的领域。想象一下在策略游戏中你需要高亮显示一个单位可移动的复杂不规则区域或者在解谜游戏中需要高亮场景中可互动的物品。用Mesh拼一个高亮区域不仅笨拙而且难以实现与场景物体的精确遮挡关系。Stencil方案则优雅得多先渲染一个覆盖高亮区域的“模板”然后让高亮效果只在这个模板区域内生效。4.1 单模板值高亮锁定与标记最简单的场景是单一高亮区域。例如玩家鼠标悬停在一个建筑上建筑轮廓发出光芒。实现思路模板写入阶段创建一个与建筑模型形状一致但略微放大的透明模型或使用相同的模型为其分配一个Shader该Shader的唯一任务是将模板缓冲区中对应像素的值写入一个特定值如2。这个阶段同样需要关闭颜色写入(ColorMask 0)和深度写入(ZWrite Off)并设置合适的渲染队列确保优先执行。高亮渲染阶段创建一个全屏或覆盖整个高亮区域的大Quad使用一个Shader该Shader的Stencil测试条件设置为Comp Equal 2。这样这个Quad就只会在模板值为2的区域即建筑轮廓内被渲染。在这个Shader的片段着色器中我们可以实现边缘光、扫描线、脉动等任何高亮效果。// 阶段1模板写入Shader (HighlightMask.shader) Stencil { Ref 2 Comp Always Pass Replace } // 阶段2高亮效果Shader (HighlightEffect.shader) Stencil { Ref 2 Comp Equal // 只在标记为2的区域绘制 Pass Keep } Blend SrcAlpha One // 加法混合使高亮发光这种方法的优势在于高亮效果阶段2可以完全独立于原始物体。你可以随意更换高亮Shader实现从简单的纯色填充到复杂的后处理风格特效而无需修改原始物体的材质。4.2 多模板值管理复杂区域与优先级更复杂的情况是同时存在多个需要高亮的区域且它们可能有重叠或不同的优先级。例如在RTS游戏中同时选中了多个单位每个单位都有一个高亮圈这些圈重叠时颜色需要加深或采用另一种高亮模式。这时单一的模板值就不够用了。我们需要引入模板值作为ID的概念。为每个类型的高亮区域分配一个唯一的模板值ID比如友方单位3敌方单位4可交互物品5。在模板写入阶段不同物体写入不同的Ref值。在高亮渲染阶段我们可以设计一个更强大的Shader它根据不同的模板值渲染不同的高亮效果。// 一个支持多ID的高亮Shader示例片段 fixed4 frag (v2f i) : SV_Target { // 通过Stencil的Ref值传入模板ID这里需要一些技巧。 // 一种常见方法是将模板值编码到顶点颜色或第二个UV通道在写入阶段写入在高亮阶段读取。 // 另一种更简洁的方法在支持的情况下是使用StencilRead。 // 假设我们通过某种方式获取了当前像素的模板值stencilID。 if (stencilID 3) { // 友方高亮-绿色脉冲 return float4(0,1,0,0.5) * (sin(_Time.y*3)*0.50.5); } else if (stencilID 4) { // 敌方高亮-红色警戒 return float4(1,0,0,0.7); } else if (stencilID 5) { // 可交互-金色描边 // 实现描边逻辑... } return 0; }管理多个模板值的关键在于规划和隔离。你需要确保不同的物体在写入模板时不会相互覆盖除非这是你期望的叠加效果。这通常通过精心设计渲染队列和Stencil操作如使用IncrSat在重叠区域递增模板值来实现。4.3 与后处理栈结合屏幕空间高亮上述方法的高亮效果是绘制在几何体之上的。另一种更高级、性能通常也更优的思路是结合后处理Post-Processing。思路如下模板标记和之前一样用第一个Pass将需要高亮的区域标记到模板缓冲区值设为1。后处理抓取使用一个后处理Shader例如通过URP的RenderObjectsBlit或CommandBuffer在全屏渲染时检查每个像素的模板值。屏幕空间处理在后处理Shader中如果发现当前像素的模板值为1则对该像素及其周围像素进行图像处理例如进行边缘检测Sobel算子、颜色叠加、模糊等从而生成屏幕空间的高亮、描边或发光效果。这种方法的好处是效果统一、易于控制整体风格并且不受场景中物体几何复杂性的影响。缺点是实现门槛稍高需要熟悉渲染命令缓冲区或SRP的渲染管线扩展。重要提示在URP/HDRP中模板缓冲区的使用和内置管线略有不同。你需要通过RenderObjectsRenderer Feature来插入一个渲染通道并在该通道的Filters中设置Stencil参数。这提供了更管线化的控制方式但原理是相通的。5. 性能优化、常见问题与调试技巧Stencil功能强大但使用不当也会带来性能问题和难以调试的Bug。以下是一些实战中总结的经验。5.1 性能优化要点严格控制模板写入物体的渲染用于写入模板的物体ColorMask 0的那些虽然不输出颜色但它们依然会产生顶点变换、光栅化、模板测试/写入的开销。确保这些物体的网格尽可能简单使用低面数代理网格并且只在必要时渲染通过距离裁剪、视锥体剔除、层控制等。减少不必要的Pass和Overdraw每个使用Stencil的Pass都可能增加Draw Call和Overdraw过度绘制。评估你的效果是否真的需要多个Pass。例如溶解效果是否可以用一个Pass结合深度偏移来实现伪边缘高亮效果是否可以用一个全屏后处理代替多个逐物体绘制利用Shader LOD和变体为复杂的Stencil Shader设置不同的LOD细节级别当物体远离相机时使用简化版本甚至关闭Stencil效果。对于支持多模板ID的Shader使用shader_feature或multi_compile来编译不同的变体避免在运行时使用用不到的判断分支。批处理中断Unity的动态批处理和静态批处理通常要求材质球完全相同。使用Stencil的物体会因为Stencil状态的不同而打断批处理。对于大量使用相同Stencil配置的物体确保它们使用同一个材质实例以维持合批。5.2 常见问题排查表问题现象可能原因排查步骤与解决方案Stencil效果完全没出现1. 渲染顺序错误模板写入物体在效果物体之后渲染。2. 模板写入物体的Shader未正确写入模板值如Pass操作不是Replace。3. 效果物体的Shader模板测试条件Comp设置错误。1. 检查所有相关物体的渲染队列Queue标签或通过Frame Debugger查看绘制顺序。2. 在Frame Debugger中查看模板缓冲区的可视化Unity编辑器有相关选项确认模板值是否被写入。3. 核对效果Shader的Ref和Comp确保其逻辑与写入Shader匹配。效果闪烁或时有时无1. 深度测试ZTest与模板测试冲突。模板写入物体可能被后续物体深度测试失败ZFail导致模板操作未执行。2. 多个物体竞争写入同一像素的模板值且操作不一致。1. 尝试将模板写入物体的ZTest设置为Always或LessEqual确保其总能通过深度测试。同时关闭其ZWrite。2. 理清多个模板物体的渲染顺序和逻辑确保它们对同一像素的模板操作是确定性的。可以使用IncrSat等操作来叠加而非覆盖。效果边缘锯齿严重模板测试发生在像素级别对于非轴对齐的边缘特别是低分辨率下会产生锯齿。1. 在效果渲染的Pass中开启多重采样抗锯齿MSAA。模板缓冲区同样支持MSAA可以平滑边缘。2. 在片段着色器中可以尝试对模板测试的结果进行软化。例如通过ddx/ddy指令估算模板值的屏幕空间梯度实现亚像素级别的平滑过渡这属于高级技巧。在URP中Stencil不生效URP默认的Unlit Shader或Lit Shader可能没有开启或正确处理模板缓冲区。1. 使用URP专用的Shader Graph时确保在Master Node的“Advanced Options”中勾选了“Stencil”并正确设置参数。2. 使用ShaderLab编写时确保SubShader标签中包含RenderPipelineUniversalPipeline并且使用URP的核心函数库。最可靠的方法是复制URP自带的Lit/Unlit Shader模板进行修改。移动设备上效果异常或性能差1. 某些低端移动GPU对模板缓冲区的位宽支持有限可能低于8位。2. 多Pass导致的Overdraw过高。1. 避免使用过大的模板值如15尽量使用0-7的范围以增加兼容性。2. 在移动平台务必进行严格的性能剖析。考虑将复杂的多Pass效果合并或降级为更简单的单Pass方案。使用Unity的Frame Debugger和Profiler的GPU模块来分析Overdraw和渲染耗时。5.3 调试利器Frame Debugger与模板缓冲区可视化调试Stencil相关问题眼睛看代码往往不够必须借助工具。Unity Frame Debugger这是最强大的工具。逐帧、逐个Draw Call地查看渲染过程。你可以清晰地看到每个Draw Call前后颜色缓冲区、深度缓冲区和模板缓冲区的变化。通过它你可以确认模板写入Pass是否执行、写入的值是否正确、后续测试Pass是否按预期通过或失败。模板缓冲区可视化在Unity编辑器的Scene视图或Game视图中可以通过下拉菜单选择渲染模式为Overdraw或Mipmaps等其中有些渲染管线或工具包如某些第三方插件提供了直接可视化模板缓冲区的功能。如果没有可以自己编写一个简单的Shader将模板值作为颜色输出到屏幕这是最直接的调试方式。// 一个简单的模板值可视化调试Shader Shader Debug/StencilViewer { SubShader { Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct v2f { float4 pos : SV_POSITION; }; v2f vert (appdata_base v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); return o; } fixed4 frag (v2f i) : SV_Target { // 获取当前像素的模板值此语法需要Shader Model支持或通过其他方式传入 // 此处为概念演示实际获取方式更复杂。 // uint stencilVal ...; // 将模板值映射为颜色 // return fixed4((stencilVal 1)/1.0, ((stencilVal1) 1)/1.0, ((stencilVal2) 1)/1.0, 1); return fixed4(1,0,0,1); // 占位 } ENDCG } } }掌握Stencil意味着你掌握了在Unity中控制像素级渲染命运的钥匙。它从简单的UI遮罩到复杂的光照剔除、体积光渲染、高级贴花系统、甚至自定义的阴影技术中都有广泛应用。开始时可能会被它的抽象概念和调试难度劝退但一旦你理解了“写入-测试”这一核心范式并熟练使用Frame Debugger进行验证很多复杂的视觉效果问题都会迎刃而解。我的建议是从一个具体的小目标开始比如替换掉项目中的一个Mask亲手实现一遍踩一遍坑你对渲染管线的理解会深刻得多。