Unity UI粒子特效裁剪:自定义Shader解决遮罩穿透问题

Unity UI粒子特效裁剪:自定义Shader解决遮罩穿透问题
1. 项目概述与问题根源在Unity项目开发中尤其是制作UI界面时我们经常会遇到一个令人头疼的问题精心设计的粒子特效比如按钮点击火花、菜单展开的光晕、或者成就解锁时的庆祝粒子本该在UI层内优雅地播放结果却“穿”过了UI面板直接显示在了屏幕最上层把UI文字和按钮挡得严严实实。这个问题本质上是一个渲染排序Rendering Order和渲染队列Render Queue的“战争”。简单来说Unity的UI系统UGUI/UI Toolkit和粒子系统Particle System默认使用不同的渲染路径和材质。标准的UI Image、Text等元素使用的Shader其渲染队列Render Queue通常设置为“Transparent”值为3000并且UGUI有一套自己的层级排序Canvas Sort Order Sorting Layer。而Unity内置的粒子系统材质比如Standard Particles或Particles/Standard Unlit它们的渲染队列也通常是“Transparent”。当两者的渲染队列值相同或接近时决定谁在前谁在后的就是它们距离摄像机的深度Depth或者具体的渲染顺序设置。粒子系统通常由摄像机直接渲染而UI是渲染在屏幕空间的。在默认情况下粒子可能被当作一个普通的透明物体来处理其与UI的遮挡关系就会出现错乱。你可能会尝试调整粒子Renderer组件的“Sorting Layer”和“Order in Layer”或者调整Canvas的“Sort Order”但很多时候你会发现这些调整并不总是有效特别是当UI结构复杂、有多个Canvas或者使用了Mask组件时。这个问题的核心矛盾在于标准的粒子Shader没有设计去响应UI系统的矩形裁剪Rect Mask或遮罩Mask组件的裁剪区域。UI Mask组件是通过一个名为unity_GUIClipTexture的全局属性和一系列计算在Shader中对像素进行丢弃discard来实现裁剪的。而普通粒子Shader根本不会去采样和判断这个裁剪信息所以它会无视遮罩自顾自地渲染出来。因此最根本、最可靠的解决方案不是去调各种排序参数那只是治标而是为粒子系统定制一个专用的Shader。这个Shader需要能够“听懂”UI遮罩的指令在渲染时判断当前像素点是否在遮罩矩形区域内如果不在就乖乖地不显示即裁剪掉。这就是我们要动手实现的ParticleMask Shader。2. 核心思路与Shader选型2.1 技术方案对比面对“粒子被UI遮罩”的问题社区里流传着几种常见的“野路子”我们先分析一下为什么它们不是最优解调整渲染排序与图层这是最直观的想法。通过设置粒子Renderer和Canvas的Sorting Layer、Order in Layer试图强制规定谁在上谁在下。但这个方法极不稳定。当场景中有多个摄像机、使用了Render Texture、或者UI与3D场景混合时排序很容易失控。更重要的是它无法解决“裁剪”问题。遮罩Mask的目的是“挖洞”或“限定显示区域”而不是简单的“谁盖住谁”。调整排序只能改变叠加顺序无法让粒子在遮罩外不显示。使用第二个摄像机专门渲染UI层粒子思路是为UI单独创建一个摄像机只渲染UI层和粒子层然后通过调整摄像机深度来控制合成。这个方法比单纯调排序更可靠一些但带来了额外的性能开销多一个摄像机渲染和复杂度需要管理不同摄像机的Culling Mask和Clear Flags。对于移动平台或性能敏感的项目这不是一个优雅的方案。将粒子渲染到Render Texture然后作为UI显示这是一个非常强大的方法可以完全控制粒子的合成。将粒子系统渲染到一张纹理上然后将这张纹理赋给一个Raw Image放在UI中。这样粒子就完全变成了UI系统的一部分自然服从遮罩。但它的缺点是a) 性能开销大每帧渲染到纹理是一次额外的Draw Call和GPU读写b) 失去了粒子作为3D/2D对象的空间感比如与场景的交互c) 实现起来相对复杂。编写自定义粒子Shader本文方案这是从渲染底层解决问题的方案。我们编写一个特殊的Unlit粒子Shader在其中加入对UI遮罩裁剪信息的处理。这个方案的优点是高效只增加极少的Shader指令开销没有额外的Draw Call或渲染纹理。精准完美实现与UI元素相同的像素级裁剪效果。灵活粒子本身仍然是标准的Particle System组件可以保留所有物理、碰撞、子发射器等特性与场景的交互不受影响。一劳永逸写好一个Shader可以应用到所有需要被UI遮罩的粒子上通过材质球进行管理。显然第四种方案是兼顾效果、性能和工程整洁度的最佳选择。2.2 Shader类型与渲染管线适配Unity有内置渲染管线Built-in、通用渲染管线URP和高清渲染管线HDRP。不同的管线Shader的编写语法和内置变量略有不同。为了获得最广泛的适用性并且考虑到UI项目大多对画面风格化、性能有要求我们选择基于内置渲染管线Built-in Render Pipeline来编写一个Unlit无光照的粒子Shader。为什么是Unlit因为UI界面中的特效粒子绝大多数情况下不需要复杂的光照计算如漫反射、高光。它们通常是自发光Additive或者混合Blend效果颜色和透明度完全由纹理和顶点颜色决定。使用Unlit Shader可以大大简化计算提升运行效率。我们的目标Shader需要具备以下核心功能基础粒子渲染支持纹理_MainTex、顶点颜色v.color和透明度控制_TintColor。软粒子可选支持与深度纹理Camera Depth Texture比较实现粒子与场景几何体的平滑融合。UI遮罩裁剪核心功能读取Unity UI系统传递的裁剪参数对像素进行丢弃。灵活的混合模式支持常见的Additive叠加、Alpha Blend透明混合等模式。接下来我们就进入实战环节一步步构建这个Shader。3. ParticleMask Shader完整实现与逐行解析下面是我在实际项目中打磨后的一个完整、可用的ParticleMask Shader代码。我将它分块并加上详细注释你可以直接复制到Unity工程中的.shader文件里使用。// 定义Shader路径和名称在材质下拉菜单中会显示在 Custom/Particles 下 Shader Custom/Particles/ParticleMask Unlit { Properties { // 主纹理通常是粒子贴图 _MainTex (Particle Texture, 2D) white {} // 色调颜色会与纹理颜色和顶点颜色相乘 _TintColor (Tint Color, Color) (0.5, 0.5, 0.5, 0.5) // 软粒子系数用于控制基于深度差的边缘柔和度 _InvFade (Soft Particles Factor, Range(0.01, 3.0)) 1.0 // 一个开关决定是否启用UI遮罩裁剪 [Toggle(USE_UI_CLIP)] _UseUIClip (Use UI Clip, Float) 1 } SubShader { // 标签定义这是一个透明物体忽略投影是粒子类型 Tags { QueueTransparent IgnoreProjectorTrue RenderTypeTransparent PreviewTypePlane } // 关闭深度写入让粒子之间可以正确混合 Cull Off Lighting Off ZWrite Off // 使用经典的Alpha混合模式SrcAlpha * SrcColor OneMinusSrcAlpha * DstColor Blend SrcAlpha OneMinusSrcAlpha Pass { CGPROGRAM // 定义顶点和片段着色器函数名 #pragma vertex vert #pragma fragment frag // 编译目标Shader Model 3.0兼容性好 #pragma target 3.0 // 启用雾效对于某些场景粒子可能需要 #pragma multi_compile_fog // 启用软粒子所需的深度纹理 #pragma multi_compile_particles // 根据材质面板的开关编译不同版本的Shader #pragma multi_compile __ USE_UI_CLIP #include UnityCG.cginc // 包含UI裁剪计算所需的CGINC文件关键 #include UnityUI.cginc sampler2D _MainTex; float4 _MainTex_ST; // 纹理的缩放和偏移 fixed4 _TintColor; sampler2D_float _CameraDepthTexture; // 相机深度纹理用于软粒子 float _InvFade; // 从顶点着色器传递给片段着色器的数据结构 struct appdata_t { float4 vertex : POSITION; float4 color : COLOR; float2 texcoord : TEXCOORD0; // 如果需要在Shader中处理粒子系统的动画帧可以添加texcoord1 }; struct v2f { float4 vertex : SV_POSITION; fixed4 color : COLOR; float2 texcoord : TEXCOORD0; // 用于深度计算和雾效的插值器 #ifdef SOFTPARTICLES_ON float4 projPos : TEXCOORD1; #endif // 雾效插值器 UNITY_FOG_COORDS(2) // UI裁剪参数插值器关键 #ifdef USE_UI_CLIP float4 worldPosition : TEXCOORD3; #endif }; // 顶点着色器 v2f vert (appdata_t v) { v2f o; // 将模型空间顶点位置转换到裁剪空间屏幕空间 o.vertex UnityObjectToClipPos(v.vertex); // 传递纹理坐标并应用材质球上的缩放偏移Tiling Offset o.texcoord TRANSFORM_TEX(v.texcoord, _MainTex); // 传递顶点颜色并与_TintColor相乘。注意这里乘以2是Unity旧版粒子系统的惯例用于保持亮度。 o.color v.color * _TintColor * 2.0f; // 计算投影坐标用于后续从深度纹理中采样 #ifdef SOFTPARTICLES_ON o.projPos ComputeScreenPos(o.vertex); // 为了深度比较更精确这里提前进行透视除法除以w分量 COMPUTE_EYEDEPTH(o.projPos.z); #endif // 传递雾效因子 UNITY_TRANSFER_FOG(o, o.vertex); // 关键步骤计算并传递世界空间坐标用于UI裁剪判断 #ifdef USE_UI_CLIP o.worldPosition v.vertex; #endif return o; } // 片段像素着色器 fixed4 frag (v2f i) : SV_Target { // 1. 采样主纹理并与顶点颜色混合 fixed4 col tex2D(_MainTex, i.texcoord) * i.color; // 2. 软粒子处理如果启用 #ifdef SOFTPARTICLES_ON // 采样深度纹理获取当前像素在场景中的深度值 float sceneZ LinearEyeDepth(SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.projPos))); // 获取当前粒子自身的深度值 float partZ i.projPos.z; // 计算深度差并应用_InvFade系数进行平滑 float fade saturate(_InvFade * (sceneZ - partZ)); // 将深度淡入因子乘到最终颜色的透明度上 col.a * fade; #endif // 3. UI遮罩裁剪处理核心逻辑 #ifdef USE_UI_CLIP // 调用UnityUI.cginc中提供的裁剪函数。 // 参数世界坐标裁剪区域由UI系统自动传递的全局属性 // 这个函数会判断像素是否在任意一个激活的UI遮罩矩形内。 // 如果不在直接调用clip(-1)丢弃该像素不渲染。 clip(UnityGet2DClipping(i.worldPosition.xy, _ClipRect)); // 注意_ClipRect 是一个由Unity UI系统通过MaterialPropertyBlock自动设置给材质的四维向量。 // 它代表了当前最上层有效遮罩的矩形区域(x, y, width, height)。 #endif // 4. 应用雾效 UNITY_APPLY_FOG(i.fogCoord, col); // 5. 返回最终颜色 return col; } ENDCG } } // 如果上面的SubShader不支持当前显卡则使用一个最简单的回退Shader Fallback Particles/Standard Unlit }3.1 关键代码段深度解析这个Shader的核心在于#include UnityUI.cginc和clip(UnityGet2DClipping(...))这两处。UnityUI.cginc这是Unity官方为UI系统提供的CG包含文件。它内部定义了UnityGet2DClipping函数以及_ClipRect等全局属性。当你将一个材质赋给位于RectMask2D组件下的粒子渲染器时Unity UI系统会自动计算并更新_ClipRect的值到该材质上。UnityGet2DClipping(float2 position, float4 clipRect)这个函数是裁剪逻辑的封装。position需要被判断的点这里我们传入的是顶点在对象本地空间Object Space的xy坐标通过i.worldPosition.xy传递注意这里变量名是worldPosition但在我们的简单粒子Shader中它实际上就是未经变换的本地坐标这对于与UI Canvas在同一平面上的2D粒子是可行的。更严谨的做法是传递经过Canvas变换后的位置。clipRect裁剪矩形一个float4通常表示为(rect.x, rect.y, rect.x rect.width, rect.y rect.height)即左下角和右上角的坐标。函数内部会判断position是否在clipRect定义的矩形内。如果在返回一个大于0的值如果不在返回一个小于等于0的值。clip(value)这是HLSL/CG的内置函数。当value小于0时直接丢弃当前像素不进行任何后续渲染输出。这正是我们需要的“裁剪”效果。重要提示上面代码中传递的i.worldPosition.xy即原始的顶点本地坐标对于在UI Canvas内、与Canvas保持相同平面和缩放比例的2D粒子是有效的。如果你的粒子是3D的或者在场景中运动你需要将顶点位置变换到与UI遮罩相同的空间通常是Canvas的本地空间或屏幕空间再传递给裁剪函数。这需要你获取Canvas的变换矩阵并传入Shader实现起来更复杂。对于绝大多数UI特效粒子固定在UI元素上使用本地坐标是简单有效的。3.2 材质面板参数说明创建材质球并应用此Shader后你会看到如下参数Particle Texture粒子贴图支持Alpha通道。Tint Color色调与纹理和顶点颜色相乘。注意默认值(0.5,0.5,0.5,0.5)和乘以2的操作是为了兼容旧版粒子系统的颜色强度习惯。Soft Particles Factor软粒子系数。值越大粒子与场景物体交界处过渡越柔和。需要摄像机开启深度纹理模式Camera - Depth Texture Mode。Use UI Clip最重要的开关。必须勾选才能启用遮罩裁剪功能。你可以通过这个开关在同一个Shader中灵活控制哪些粒子材质需要受遮罩影响。4. 在项目中的完整使用流程有了Shader接下来就是把它用起来。这里是一个从零开始的步骤指南。4.1 创建与应用材质创建Shader文件在Unity项目的Assets文件夹下建议放在Shaders或Materials目录右键 - Create - Shader - Standard Surface Shader先创建再清空内容粘贴我们的代码。将其命名为ParticleMaskUnlit.shader。创建材质球右键 - Create - Material命名为Mat_ParticleMask。在Inspector面板将Shader选择为我们刚创建的Custom/Particles/ParticleMask Unlit。配置材质将你的粒子贴图拖到Particle Texture槽。调整Tint Color到你想要的颜色。确保Use UI Clip处于勾选状态。4.2 设置粒子系统创建粒子系统GameObject - Effects - Particle System。或者在一个空物体上添加Particle System组件。应用材质在粒子系统的Renderer模块展开最下方将Material设置为刚才创建的Mat_ParticleMask。调整渲染器设置Render Mode对于UI粒子通常选择Billboard始终面向相机或Stretched Billboard可用于制作速度线。Sorting Layer / Order in Layer虽然我们的Shader解决了裁剪问题但合理的排序可以避免与其他UI元素的深度冲突。建议将粒子与它所属的Canvas设置在同一个Sorting Layer并适当调整Order。4.3 集成到UI遮罩体系这是最关键的一步确保遮罩信息能传递到Shader。设置UI结构假设你有一个作为遮罩的UI面板。创建一个Canvas。在Canvas下创建一个Image或Panel作为遮罩容器为其添加Rect Mask 2D组件。这个组件会对其所有子物体进行裁剪。将你的粒子系统GameObject拖拽成为这个带有Rect Mask 2D的UI元素的子级。这是必须的因为Unity UI系统只会向子级物体的渲染器传递裁剪信息。验证与调试运行游戏。现在你的粒子应该被完美地限制在父级Rect Mask 2D定义的矩形区域内了。粒子飞到区域外时会立刻消失。你可以尝试移动或缩放带有Rect Mask 2D的父级UI粒子的可见区域会随之变化。如果裁剪无效请按以下顺序检查 a. 粒子材质球的Use UI Clip是否勾选 b. 粒子对象是否是Rect Mask 2D对象的直接子级或嵌套子级 c.Rect Mask 2D组件是否启用 d. 粒子Renderer的材质是否确实是我们的ParticleMaskUnlit材质4.4 性能优化与进阶技巧批处理Batching多个使用相同材质的粒子系统如果满足静态合批条件Unity会尝试将它们合并Draw Call。确保材质参数相同并考虑将多个小粒子系统合并。软粒子开销SOFTPARTICLES_ON需要摄像机渲染深度纹理这会带来一定的带宽开销。如果粒子不需要与3D场景融合例如纯UI界面可以在材质面板关闭Use UI Clip旁的软粒子选项通过Shader编译指令控制并确保摄像机不生成深度纹理。多遮罩与复杂形状Rect Mask 2D只能进行矩形裁剪。如果你需要圆形、不规则形状的遮罩可以考虑使用传统的Mask组件配合一张Alpha遮罩纹理。这时我们的Shader需要修改从_GUIClipTexture采样并判断Alpha值。这涉及到更复杂的UI Stencil缓冲机制实现起来会更复杂但原理相通——在片段着色器中根据遮罩信息clip像素。3D空间粒子如前所述如果粒子在3D空间运动需要将粒子的世界坐标或视图坐标转换到UI的裁剪空间。这通常需要以Uniform变量的形式向Shader传递Canvas的变换矩阵。这是一个更高级的话题需要根据具体项目需求定制。5. 常见问题排查与实战心得在实际使用中你可能会遇到一些“坑”。这里我把自己踩过的和常见的问题整理出来。5.1 问题速查表问题现象可能原因解决方案粒子完全不显示1. 材质Shader编译错误。2. 粒子Renderer模块被禁用。3. 粒子发射器Start Delay设置过长或Duration为0。4. 颜色或Alpha值全为0。1. 检查Console是否有Shader错误粉色材质。2. 检查粒子系统Renderer模块勾选框。3. 调整粒子系统主模块的Duration和Start Delay。4. 检查材质Tint Color和粒子Start Color的Alpha值。粒子显示但不受遮罩裁剪1. 材质未启用Use UI Clip。2. 粒子对象不是Rect Mask 2D对象的子级。3. 使用了错误的Shader或材质。4. 粒子系统Scale影响裁剪坐标计算。1. 勾选材质上的Use UI Clip。2. 将粒子对象拖拽为遮罩UI的子物体。3. 确认Renderer使用的材质是ParticleMask Unlit。4. 尝试在粒子系统的Renderer模块取消勾选Use Mesh Colors或确保粒子本地缩放为(1,1,1)。裁剪边缘有锯齿或闪烁1. 粒子位于裁剪边界由于精度问题导致clip判断不稳定。2. 软粒子与深度纹理计算在边界产生干扰。1. 这是一个常见问题。可以尝试在UnityGet2DClipping函数中增加一个很小的偏移bias例如clip(UnityGet2DClipping(i.worldPosition.xy, _ClipRect) - 0.001);但这属于Hack方法。2. 临时关闭软粒子功能看看是否改善。多个遮罩嵌套时裁剪异常UI系统在处理嵌套遮罩时_ClipRect可能传递的是最内层或最外层的矩形逻辑复杂。Unity UI的裁剪系统对嵌套遮罩的支持本身就有局限。对于复杂嵌套建议重新设计UI结构避免多层Rect Mask 2D嵌套或者考虑使用Mask组件基于Stencil Buffer而非Rect Mask 2D。在编辑器Scene视图正常Game视图不正常Game视图的摄像机设置可能与Scene视图不同。确保运行游戏时所用的摄像机尤其是UI Canvas的Render Mode对应的摄像机已经正确设置。如果是Screen Space - Camera模式检查指定的摄像机是否正确。5.2 实操心得与技巧材质实例化如果你有多个颜色不同的同类粒子效果比如不同颜色的按钮火花不要为每个颜色创建一个独立的材质球文件。应该在代码中或通过Animator动态修改材质的_TintColor属性。你可以使用MaterialPropertyBlock来高效地修改渲染器属性而不破坏材质实例的批处理。// 示例代码动态修改粒子颜色 MaterialPropertyBlock props new MaterialPropertyBlock(); particleSystemRenderer.GetPropertyBlock(props); // 获取现有的 props.SetColor(_TintColor, new Color(1f, 0.5f, 0f, 0.8f)); // 设置新颜色 particleSystemRenderer.SetPropertyBlock(props); // 应用回去Shader变体管理我们的Shader使用了#pragma multi_compile __ USE_UI_CLIP和#pragma multi_compile_particles。这意味着Unity会为这个Shader编译多个变体Variant启用/禁用UI裁剪、启用/禁用软粒子。这会导致Shader加载时占用更多内存。在项目后期如果确定所有粒子都需要裁剪可以考虑移除USE_UI_CLIP开关直接写死裁剪逻辑以减少变体数量。关于2D Sprite Mask如果你使用的是Unity的2D系统并且想让粒子被Sprite Mask裁剪原理是类似的但使用的Shader和属性不同。2DSprite Mask依赖于Stencil Buffer模板缓冲。你需要为粒子Shader添加Stencil读写操作使其与Sprite Mask的Stencil值匹配。这通常是编写一个Sprites/DefaultShader的变体并添加Stencil块。网上有大量关于“2D Particle Mask”的教程其核心是操作Stencil而非clip。性能监控在Profiler的Rendering区域观察使用自定义Shader后粒子的Draw Call和SetPass Call数量是否有异常增加。一个设计良好的Shader对批次合并应该是友好的。这个自制的ParticleMask Shader方案我已经在多个商业手游的UI特效中稳定使用。它彻底解决了UI粒子“溢出”的顽疾让特效设计师可以更自由地在UI框架内创作而无需程序员反复调整排序或采用笨重的Render Texture方案。掌握它你就拥有了在Unity UI中驾驭粒子特效的精准控制权。