Unity URP自定义模板测试:突破UI Mask限制,实现任意形状遮罩
1. 项目概述为什么我们需要超越Unity UI Mask在Unity URP管线中做UI特效或者实现一些复杂的遮罩效果时很多开发者第一时间想到的就是UI Mask组件。它简单、易用拖上去就能让子物体只在父节点的矩形区域内显示。但当你需要实现一个非矩形的、动态的、或者带有复杂边缘比如圆角、星形、甚至自定义形状的遮罩时UI Mask就显得力不从心了。更不用说当你的场景中同时存在大量UI Mask时Draw Call的合并会被无情打断性能开销直线上升。这时一个更底层、更强大、也更灵活的工具就该登场了模板测试。模板测试是GPU渲染管线中的一个固定功能阶段它允许你基于一个独立的“模板缓冲区”来决定每个像素是否应该被绘制。你可以把它想象成一个“数字模具”——先在缓冲区里“刻”出一个形状然后后续的绘制只在这个“模具”范围内生效。相比UI Mask自定义模板测试不仅能实现任意形状的遮罩还能实现更复杂的逻辑比如多重遮罩、镂空、外发光裁剪等并且通过合理的Shader设计可以更好地与URP的批处理机制兼容。然而在URP管线中自定义模板测试尤其是与UI系统结合时坑点不少。Unity内置的UI Shader已经有一套自己的模板测试逻辑URP的渲染顺序和Buffer处理也与内置管线有所不同。直接照搬内置管线的写法很可能导致模板值混乱、遮罩失效或者出现诡异的渲染错误。这篇文章我将结合我多次踩坑的经验为你梳理在URP中从零开始实现一套自定义模板测试的完整流程并重点分享那些官方文档里不会写的避坑指南。2. 核心原理模板测试是如何工作的在深入实操之前我们必须先理解模板测试的基本原理。这能帮你从根本上理解后续的每一步配置而不是死记硬背代码。2.1 模板缓冲区与渲染管线模板缓冲区是一块与屏幕分辨率一致、每个像素通常存储一个8位整数的内存区域。它独立于颜色缓冲区和深度缓冲区。GPU的渲染流程可以简化为顶点着色 - 片元着色 - 模板测试 - 深度测试 - 颜色混合。注意模板测试发生在片元着色器之后深度测试之前。这意味着一个像素即使通过了片元着色器计算出了颜色也可能在模板测试阶段被直接丢弃根本不会参与后续的深度比较和颜色写入。这正是我们实现遮罩的物理基础。2.2 Stencil命令参数详解在Unity ShaderLab中我们通过Stencil{}块来控制模板测试。其核心参数可以分为两大类测试配置和写入操作。参数类型默认值作用解析Ref整数 (0-255)0参考值。这是你设定的一个基准值用于和缓冲区中的值进行比较。ReadMask整数 (0-255)255读取掩码。在比较前会先将Ref和缓冲区值StencilBufferValue分别与这个掩码进行按位与()操作然后再比较。常用于使用特定位进行复杂逻辑判断。WriteMask整数 (0-255)255写入掩码。控制哪些位可以被写入缓冲区。例如WriteMask 127表示只允许修改低7位最高位保持不变。Comp比较函数Always比较函数。定义(Ref ReadMask)与(StencilBufferValue ReadMask)的比较规则。Pass操作函数Keep测试通过且深度测试通过时对缓冲区值执行的操作。Fail操作函数Keep模板测试失败时对缓冲区值执行的操作。ZFail操作函数Keep模板测试通过但深度测试失败时对缓冲区值执行的操作。比较函数 (Comp)是关键它决定了当前像素能否通过测试。常用选项有Always: 总是通过用于写入模板。Never: 总是不通过。Equal: 等于时才通过用于读取模板。NotEqual: 不等于时通过。操作函数决定了如何修改缓冲区中的值Keep: 保持原值不变。Replace: 用Ref值替换当前值。Zero: 将值设为0。IncrSat / DecrSat: 增加/减少1但会饱和在0-255之间。IncrWrap / DecrWrap: 增加/减少1但会循环25510, 0-1255。核心理解一个完整的模板遮罩流程通常需要两个或两组Shader配合。Mask Writer (写入者)第一个被渲染的物体其Shader的Comp设为AlwaysPass设为Replace或其他写入操作。它的任务是在模板缓冲区中“刻”下形状写入特定的Ref值。Masked Content (被遮罩内容)后续渲染的物体其Shader的Comp设为Equal或其他比较条件Pass设为Keep。它们只会在模板缓冲区值与自己的Ref值匹配的区域被绘制。2.3 URP与内置管线的关键差异这是第一个大坑。在内置渲染管线中UI的渲染顺序相对独立模板缓冲区的生命周期管理也较为简单。但在URP中渲染队列与Render FeatureURP使用可编程的渲染管线通过RenderObjects等Render Feature可以更精细地控制渲染顺序和状态。这意味着你需要明确知道你的Mask Writer和Masked Content分别在哪个渲染阶段执行。Camera的Clear Flags模板缓冲区是附着在Camera上的。如果Camera的Clear Flags设置为Skybox或Solid Color那么每一帧开始时颜色和深度缓冲区会被清除但模板缓冲区默认不会被清除这可能导致上一帧的模板数据残留干扰本帧的渲染。通常你需要确保在合适的时机如每帧开始渲染不透明物体前清除模板缓冲区。UI Renderer的覆盖URP的Universal Renderer Data资产中包含对UI的渲染设置。默认的UI Shader可能已经包含了模板测试逻辑你的自定义Shader需要与之兼容避免冲突。3. 实战在URP中创建自定义模板遮罩理论说再多不如动手。我们来实现一个经典案例创建一个圆形遮罩只有在这个圆形区域内的UI元素如图片、文字才显示。3.1 第一步创建并配置Mask Writer Shader这个Shader的任务是渲染一个圆形区域并将该区域对应的模板缓冲区像素值设为1。创建Unlit Shader在Project窗口右键 - Create - Shader - Unlit Shader。命名为URP_StencilMaskWriter。编写Shader代码用以下代码替换全部内容。关键看Stencil块和Fragment函数中的裁剪逻辑。Shader Custom/URP_StencilMaskWriter { Properties { _Center (Center (XY), Vector) (0.5, 0.5, 0, 0) _Radius (Radius, Range(0, 0.5)) 0.25 _Feather (Feather, Range(0, 0.1)) 0.01 } SubShader { Tags { Queue Geometry-100 // 在普通几何体之前渲染确保先写入模板 RenderType Opaque RenderPipeline UniversalPipeline } Pass { Name MaskWriter Cull Off // 双面渲染 ZWrite Off // 我们不关心这个Mask物体的深度只关心模板 ColorMask 0 // 关键不写入任何颜色只操作模板缓冲区 HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; }; CBUFFER_START(UnityPerMaterial) float2 _Center; float _Radius; float _Feather; CBUFFER_END Varyings vert(Attributes IN) { Varyings OUT; VertexPositionInputs positionInputs GetVertexPositionInputs(IN.positionOS.xyz); OUT.positionHCS positionInputs.positionCS; OUT.uv IN.uv; return OUT; } half4 frag(Varyings IN) : SV_Target { // 计算当前片元到圆心的距离 float2 center _Center; float distanceToCenter length(IN.uv - center); // 平滑边缘处理抗锯齿 half alpha smoothstep(_Radius _Feather, _Radius - _Feather, distanceToCenter); // 如果片元在圆外直接丢弃clip会终止片元处理模板操作也不会执行 // 但因为我们用了ColorMask 0且模板测试总是通过所以即使不丢弃也不会写入颜色。 // 这里丢弃是为了更精确地控制模板写入区域避免写入圆外像素。 clip(alpha - 0.001); return half4(0,0,0,0); // 颜色被ColorMask 0屏蔽写什么都无所谓 } ENDHLSL } } // 核心模板状态配置 Stencil { Ref 1 // 我们将要写入的模板值 Comp Always // 总是通过测试因为我们就是要写入 Pass Replace // 通过后用Ref值(1)替换缓冲区原有值 // Fail Keep // 测试失败时的操作默认Keep此处可省略 // ZFail Keep // 深度测试失败时的操作默认Keep此处可省略 } }关键点解析ColorMask 0这是最容易被忽略但至关重要的指令。它告诉GPU这个Pass不向颜色缓冲区写入任何颜色R、G、B、A通道全关。这样这个Mask物体本身在屏幕上就完全不可见不会干扰最终画面但它依然会进行深度和模板测试并执行模板写入操作。这是实现“隐形遮罩”的标准做法。ZWrite Off因为我们不关心这个遮罩物体的深度关闭深度写入可以避免它影响后续物体的深度测试。Queue设置为Geometry-100确保它在所有普通不透明物体包括后续的被遮罩UI之前渲染先“刻”好模板。Stencil块位置注意Stencil块是写在SubShader层级而不是Pass内部的。这意味着这个SubShader下的所有Pass都共享这个模板状态。我们只有一个Pass所以没问题。3.2 第二步创建被遮罩的UI Shader Graph对于UI元素我们通常使用Shader Graph来制作更直观。我们要创建一个UI Shader它只会在模板值为1的区域绘制。创建Shader Graph右键 - Create - Shader Graph - URP - Unlit Shader Graph。命名为URP_UI_StencilMasked。配置Graph设置在Graph Inspector中将Graph Settings-Material的Surface Type设置为TransparentBlend Mode设置为Alpha。这是UI的典型设置。在Active Targets中确保勾选了Universal并且SM Compatibility至少支持SM4.5。构建节点网络添加一个Sample Texture 2D节点连接到Fragment的Base Color。这是UI图片的纹理采样。添加一个Stencil节点在Universal分类下。这是URP Shader Graph中配置模板测试的专用节点。配置Stencil节点Ref: 1 (必须和Mask Writer写入的值一致)Read Mask: 255 (读取所有位)Write Mask: 0 (关键被遮罩物体通常不应该再修改模板缓冲区)Comp: Equal (只有当模板缓冲区值等于Ref(1)时才通过测试)Pass: Keep (通过测试后保持模板值不变)Fail: KeepZFail: Keep将Stencil节点的输出端口连接到Fragment的Stencil输入端口。保存并创建Material保存Shader Graph然后基于它创建一个新的Material比如Mat_UI_Masked。实操心得在Shader Graph中使用Stencil节点时务必检查生成的代码。有时Graph的预览和实际效果可能有差异。你可以点击Graph窗口的Show Generated Code来查看最终的HLSL代码确认Stencil{}块是否正确生成。3.3 第三步在场景中组装与测试设置摄像机确保你的主摄像机使用了URP渲染器。在Project Settings - Graphics中指定你的URP Asset。检查摄像机的Clear Flags如果需要每帧清除模板可以保持默认Depth或设置为Dont Clear但通过其他方式管理。一个稳妥的做法是在URP Renderer中添加一个Render ObjectsFeature来在渲染UI前清除模板。创建Mask物体在UI Canvas下创建一个空GameObject命名为StencilMask。为其添加一个Image组件但将Source Image留空。将之前创建的URP_StencilMaskWriterShader拖给这个Image组件的Material属性。或者创建一个新的Material使用这个Shader然后赋给Image。调整Material属性中的_Center和_Radius在Scene视图中你应该能看到一个圆形的线框Gizmo这代表了模板写入的区域。由于ColorMask 0它在Game视图不可见。创建被遮罩的UI在StencilMask物体下创建一个子物体比如一个Image或TextMeshPro文本。将之前创建的Mat_UI_MaskedMaterial赋给这个子物体的Image组件如果是Image。为这个子物体添加一个Mask组件不千万不要传统的UI Mask组件会与我们的自定义模板测试冲突。直接移除或确保不添加任何Mask组件。运行测试运行游戏。你应该看到子物体的内容图片或文字只显示在圆形StencilMask物体定义的区域内。尝试移动子物体你会发现即使它部分超出圆形区域超出的部分也不会被绘制。4. 避坑指南与高级技巧到这里基础功能已经实现了。但实际项目远比这复杂下面是我踩过坑后总结出的关键点。4.1 坑点一模板值冲突与污染问题场景中有多个不同的自定义模板遮罩或者UI元素叠加时模板值互相干扰导致遮罩错乱。根因所有物体都共用同一个8位模板缓冲区。如果Mask Writer A写了值1Mask Writer B也写了值1那么它们就共享了同一个“模具”这通常不是我们想要的。解决方案建立一套模板值管理规范。分区使用将0-255的模板值划分为不同的区间用于不同的逻辑。例如0: 默认/未使用区域。1-10: 保留给全局UI遮罩如弹窗背景模糊的裁剪区域。11-50: 用于游戏内HUD的复杂形状遮罩。51-100: 用于3D物体的屏幕空间遮罩特效。101-150: 用于编辑器工具或调试视图。151-255: 保留。使用WriteMask进行位操作如果你需要更精细的控制可以使用WriteMask和ReadMask。例如你可以用低4位表示一种遮罩类型高4位表示另一种状态。通过位与()、位或(|)操作来组合判断。// 示例使用第3位值4作为“高亮”标记第0位值1作为“基础遮罩” Stencil { Ref 5 // 二进制 0101即同时设置了第0位和第2位 ReadMask 5 // 只读取第0位和第2位 WriteMask 5 // 只写入第0位和第2位 Comp Equal // 当缓冲区的第0位和第2位与Ref匹配时通过 Pass Keep }4.2 坑点二渲染顺序与相机清除问题遮罩时有时无或者上一帧的遮罩残留到了下一帧。根因渲染顺序错误或者模板缓冲区没有在正确时机被清除。解决方案严格控制渲染队列确保Mask Writer的渲染队列 (Queue) 早于所有被遮罩的内容。对于UI通常使用Geometry队列。可以通过设置Queue Geometry-250让Mask更早渲染。使用URP Renderer Features管理清除这是最推荐的方法。创建一个Render ObjectsFeature将其Event设置为Before Rendering Transparents如果你在透明物体前做UI遮罩或Before Rendering Post Processing。在该Feature的Filters中不添加任何Layer表示全局生效然后在Override中开启Depth和Stencil并设置Clear动作。这样可以在渲染阶段的精确时间点清除模板缓冲区。手动控制相机清除如果上述方法复杂一个简单粗暴但有效的方法是将摄像机的Clear Flags设置为Depth并确保你的Mask Writer在每一帧都最先被渲染通过设置极低的渲染队列值。但这种方法在复杂场景中可能不够灵活。4.3 坑点三与URP内置UI Shader的兼容性问题使用自定义模板Shader后UI的点击事件、合批、混合模式出现异常。根因Unity的UI系统uGUI和EventSystem对Shader有特定要求。完全替换UI默认Shader可能会破坏这些功能。解决方案继承并修改而非完全重写。不要从零开始写一个UI Shader。最好的起点是URP提供的Universal Render Pipeline/2D/Sprite-Lit-Default或更简单的Unlit变体或者直接复制UI/Default的源码如果可用。在你的自定义UI Shader中保留原有的Properties、Tags特别是QueueTransparent IgnoreProjectorTrue RenderTypeTransparent和基本的顶点/片元着色结构。只修改Stencil{}块并确保ColorMask、Blend、ZTest、ZWrite等状态与UI标准一致。通常UI是Blend SrcAlpha OneMinusSrcAlphaZWrite OffZTest [LEqual]或Always。测试点击事件确保你的Shader修改没有影响UI元素的深度测试否则EventSystem可能无法正确检测到点击。4.4 高级技巧实现动态、可交互的遮罩静态遮罩只是开始。我们经常需要遮罩区域能动态变化如圆形进度条、雷达扫描效果或者能与用户交互如拖动一个窗口窗口内的内容被裁剪。动态遮罩关键在于Mask Writer的Shader参数可以动态修改。例如要实现一个扇形填充进度你可以在Mask Writer的Shader中暴露一个_FillAngle属性然后在C#脚本中通过MaterialPropertyBlock或直接修改Material的SetFloat来动态控制。// C# 脚本示例控制圆形遮罩的半径 using UnityEngine; using UnityEngine.UI; public class DynamicStencilMask : MonoBehaviour { public Image maskImage; // 使用URP_StencilMaskWriter Shader的Image public float radiusSpeed 0.5f; private Material maskMaterial; private float currentRadius 0f; void Start() { if (maskImage ! null) { // 重要使用MaterialPropertyBlock以避免实例化Material // 但UI Image组件更常用materialForRendering maskMaterial maskImage.materialForRendering; } } void Update() { if (maskMaterial ! null) { currentRadius Mathf.PingPong(Time.time * radiusSpeed, 0.5f); maskMaterial.SetFloat(_Radius, currentRadius); } } }可交互遮罩例如实现一个可拖动的“放大镜”UI放大镜玻璃区域显示下层内容的放大视图。这需要一个Mask Writer放大镜的玻璃形状其位置跟随鼠标或触摸。一个摄像机Camera或渲染纹理RenderTexture专门渲染被放大的区域。一个RawImage使用被遮罩的Shader显示步骤2中的RenderTexture并应用步骤1的模板遮罩。这种组合技将模板测试的威力发挥到了极致实现了屏幕空间内的精确内容裁剪。5. 性能优化与调试技巧自定义模板测试很强大但滥用也会带来性能问题。性能考量额外的GPU状态切换每个不同的Stencil配置都可能导致一次Draw Call的Batch被打破。尽量复用相同的模板配置。Overdraw模板测试本身开销很小但被遮罩的内容如果层级很多可能会在遮罩区域外产生大量Overdraw片元着色器执行后被模板测试丢弃。优化被遮罩物体的几何体避免不必要的重叠。清除操作频繁清除整个模板缓冲区尤其是全屏清除有开销。如果可能尽量将模板操作限制在屏幕的局部区域。调试技巧 当遮罩不工作时按以下步骤排查检查模板值在Frame Debugger中可以查看模板缓冲区的可视化。在Unity编辑器中打开Frame Debugger窗口在渲染事件列表中找到你的Mask Writer和被遮罩物体的Draw Call。点击后在右侧的Render Target视图下拉菜单中选择Stencil即可看到当前帧的模板缓冲区状态。白色区域代表模板值非零。这是最直接的调试手段。检查渲染顺序在Frame Debugger中确认Mask Writer的绘制确实发生在被遮罩物体之前。检查Shader编译错误在Console窗口查看是否有Shader编译警告或错误。一个常见的错误是Stencil命令在SubShader和Pass中重复定义或位置错误。简化测试创建一个最简单的场景一个纯色背景一个Mask物体一个被遮罩的方块。排除其他复杂因素的干扰。查看生成代码对于Shader Graph务必使用Show Generated Code功能确认Stencil{}块被正确插入到了HLSL代码的相应位置。自定义模板测试是URP中一项进阶但极其有用的技能。它打破了UI Mask的矩形枷锁为你打开了任意形状UI、高级特效和复杂渲染逻辑的大门。理解其原理谨慎管理模板状态善用调试工具你就能在性能和效果之间找到最佳平衡点创造出令人惊艳的视觉体验。记住模板缓冲区就像一块画布上的蒙版你的Shader就是画笔规则由你定义。