1. 为什么“镂空遮罩”不是简单画个透明洞——从美术需求到渲染本质的错位“游戏中实现镂空遮罩效果”这行标题在Unity项目组的周会上被提出来时我听见隔壁组两位美术同事小声嘀咕“不就是做个带Alpha通道的贴图盖上去吗”——这话听着合理但实操中十有八九会卡在第三步UI层叠顺序乱了、3D模型穿模了、动态角色进进出出镂空区时边缘闪烁、甚至整个遮罩区域在HDRP管线里直接消失。这不是美术或程序单方面的问题而是对“遮罩”这个概念在Unity渲染管线中真实定位的误判。所谓“镂空”本质是像素级的可见性裁剪它要求在指定区域矩形/圆形内底层内容必须完全不可见该区域外所有内容保持原样且这一裁剪行为需跨层级生效——既要裁剪Canvas下的UI元素也要裁剪世界空间中的3D模型、粒子特效、后处理叠加层。而Unity默认的Image Mask、RectMask2D、甚至UGUI的Mask组件全都是仅作用于Canvas Render Order层级内的UI裁剪器它们对Canvas之外的世界空间对象毫无感知。你用RectMask2D去盖一个3D角色那角色照常站在Mask上方就像拿一张纸片去挡一辆卡车——纸片再精致也挡不住车轮碾过。真正能打通UI与3D、支持任意形状、且性能可控的方案只有两条路一是基于Shader的Stencil Buffer模板缓冲硬裁剪二是利用Render Texture做离屏合成再回填。前者是本篇聚焦的核心因为它是Unity原生支持、零额外DrawCall、可精确控制裁剪边界的正解后者虽灵活但引入额外渲染开销适合复杂多层合成场景不在本次实战范围。关键词“Unity”“镂空遮罩”“矩形”“圆形”背后实际指向的是如何安全、稳定、低开销地复用Unity的Stencil Buffer机制在UI与3D混合场景中实现像素级几何裁剪。这篇文章写给两类人一是被美术反复追问“为什么Mask不生效”的程序二是想把UI交互框精准套在3D模型关键部位比如AR眼镜的视野框、战术HUD的瞄准环的TA或技术策划。你不需要懂GLSL但得明白Stencil Test怎么和ZTest协同工作你不用手写完整Shader但得会改几行关键指令、配对Stencil ID、验证Mask ID是否冲突。2. Stencil Buffer不是“画布”而是“门禁系统”——原理拆解与Unity实现逻辑要让镂空效果真正生效必须绕开UGUI的Mask组件直击渲染管线底层。核心钥匙就是Unity的Stencil Buffer模板缓冲。很多人把它想象成一张可涂画的透明胶片画上形状就能遮住后面的东西——这是典型误解。Stencil Buffer更像一套实时运行的门禁系统每个像素在绘制前都要向这个系统提交“通行申请”系统根据预设规则Stencil Test核验申请单当前像素的Stencil值与门禁数据库Reference Value的匹配关系再决定放行绘制、拒绝丢弃或修改通行证更新Stencil值。镂空效果正是通过“在镂空区域主动拒绝所有通行申请”来实现的。2.1 Unity中Stencil Buffer的三步闭环流程整个过程分三阶段缺一不可且顺序严格标记阶段Mark先用一个“标记Shader”绘制镂空形状矩形/圆形将该形状覆盖的所有像素在Stencil Buffer中写入一个特定ID比如1。此时屏幕无视觉变化只悄悄在Stencil Buffer里盖章。裁剪阶段Clip再用“被裁剪Shader”绘制需要镂空的内容UI Panel、3D模型等在绘制每个像素前执行Stencil Test若当前像素位置的Stencil值等于1则拒绝绘制即“镂空”否则正常绘制。清理阶段Clear一帧结束前清空Stencil Buffer避免影响下一帧。提示Unity的Stencil操作由Shader中的Stencil{}块定义而非C#脚本。这意味着你无法用GetComponentImage().maskable true这类API控制它——必须通过Shader代码显式声明读写规则。2.2 关键参数解析为什么Reference、ReadMask、WriteMask不能乱配Stencil Test的判定公式为if ( (stencilValue ReadMask) OP (referenceValue ReadMask) ) → Pass/Fail其中stencilValue当前像素在Stencil Buffer中已有的值来自上一步标记referenceValue你在Shader中设定的参考值如1ReadMask读取时的掩码默认0xFF即全8位参与比较WriteMask写入时的掩码决定哪些位可被修改OP比较操作符如Equal,NotEqual,Always常见错误配置将WriteMask设为0标记阶段写不进任何值裁剪阶段永远读不到1 → 镂空失效ReadMask设为0x00所有位被屏蔽比较恒为真 → 全屏被裁剪referenceValue与标记阶段不一致比如标记写1裁剪时却比2 → 永远不匹配2.3 矩形与圆形镂空的几何生成差异不是“画个圆”而是“生成UV坐标系下的距离场”矩形镂空相对直观在标记Shader中用abs(i.uv - _Center) _Size即可判断UV坐标是否在矩形内。但圆形镂空若用distance(i.uv, _Center) _Radius会因UV拉伸导致椭圆变形——尤其当遮罩Panel被拉伸或旋转时。正确做法是在世界空间或屏幕空间计算距离而非UV空间。我们采用更鲁棒的方案在标记阶段将镂空区域投影到屏幕空间Screen Space用ComputeScreenPos获取顶点在屏幕上的归一化坐标0~1再在此坐标系下计算矩形/圆形。这样无论UI Panel如何缩放、旋转、锚点设置镂空形状都保持物理准确。具体到Shader代码矩形用abs(screenUV.x - _Center.x) _Size.x abs(screenUV.y - _Center.y) _Size.y圆形则用distance(screenUV, _Center) _Radius。注意screenUV需经_ScreenParams.xy归一化否则在不同分辨率设备上尺寸失真。3. 从零搭建可复用镂空系统Shader编写、材质配置与C#驱动逻辑现在进入实操环节。我们将构建一个双Shader单脚本的轻量系统一个用于标记镂空区域的StencilMark.shader一个用于裁剪内容的StencilClip.shader以及一个挂载在镂空Panel上的StencilMaskController.cs脚本负责动态传递参数中心、半径、ID等。所有资源均支持Runtime修改无需重启编辑器。3.1 StencilMark.shader专注“盖章”不输出颜色此Shader唯一任务是向Stencil Buffer写入ID不产生任何屏幕颜色。因此Fragment函数直接返回fixed4(0,0,0,0)并关闭Color WriteColorMask 0以节省带宽。// StencilMark.shader Shader Custom/StencilMark { Properties { _StencilID (Stencil ID, Int) 1 _Center (Center (Screen), Vector) (0.5,0.5,0,0) _Size (Size (Rect), Vector) (0.2,0.2,0,0) _Radius (Radius (Circle), Float) 0.15 _Shape (Shape (0Rect, 1Circle), Float) 0 } SubShader { Tags { QueueTransparent IgnoreProjectorTrue RenderTypeTransparent } ZWrite Off ZTest Always Blend Off ColorMask 0 // 关键不写入颜色缓冲只操作Stencil Stencil { Ref [_StencilID] Comp Always Pass Replace // 覆盖写入无视原有值 Fail Keep ZFail Keep } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float4 screenPos : TEXCOORD1; }; float4 _Center; float4 _Size; float _Radius; float _Shape; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.uv; o.screenPos ComputeScreenPos(o.pos); // 获取屏幕空间坐标 return o; } fixed4 frag (v2f i) : SV_Target { float2 screenUV i.screenPos.xy / i.screenPos.w; screenUV screenUV * _ScreenParams.xy / _ScreenParams.xy; // 归一化到0~1 bool inMask false; if (_Shape 0.5) // 矩形 inMask abs(screenUV.x - _Center.x) _Size.x abs(screenUV.y - _Center.y) _Size.y; else // 圆形 inMask distance(screenUV, _Center.xy) _Radius; // 仅在镂空区域内写入Stencil ID clip(inMask ? 0 : -1); // 若不在区域内直接裁剪掉此像素不写Stencil return fixed4(0,0,0,0); // 透明色无视觉输出 } ENDCG } } }注意clip(inMask ? 0 : -1)是关键技巧。它确保只有镂空区域内的像素才会进入Fragment阶段并触发Pass Replace区域外的像素被提前丢弃避免无谓的Stencil写入。这比用if(!inMask) discard;更高效因clip在硬件层面优化更好。3.2 StencilClip.shader专注“守门”严格校验通行资格此Shader挂载在被裁剪的对象如Image、RawImage、甚至自定义3D模型材质上。它不关心形状只做一件事在绘制每个像素前检查Stencil Buffer中对应位置的值是否等于预设ID若是则discard拒绝绘制。// StencilClip.shader Shader Custom/StencilClip { Properties { _MainTex (Texture, 2D) white {} _Color (Color, Color) (1,1,1,1) _StencilID (Stencil ID, Int) 1 } SubShader { Tags { QueueTransparent IgnoreProjectorTrue RenderTypeTransparent } ZWrite Off ZTest LEqual Blend SrcAlpha OneMinusSrcAlpha Stencil { Ref [_StencilID] Comp Equal // 仅当Stencil值等于_Ref时才允许绘制 Pass Zero // 绘制后将Stencil值清零可选防干扰 Fail Keep ZFail Keep } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv) * _Color; // 在此处添加自定义裁剪逻辑如软边、动画 return col; } ENDCG } } }关键点Comp Equal确保只有Stencil值匹配时才绘制Pass Zero在绘制后将Stencil值清零避免残留值影响后续对象。若需多层镂空如两个圆形叠加可将Pass Zero改为Pass Keep并在标记阶段用不同ID区分。3.3 StencilMaskController.cs让美术也能调参数的“傻瓜面板”脚本挂载在镂空Panel如Image上提供Inspector友好参数并自动创建/管理材质实例// StencilMaskController.cs using UnityEngine; using System.Collections.Generic; [RequireComponent(typeof(Image))] public class StencilMaskController : MonoBehaviour { [Header(Stencil Settings)] public int stencilID 1; public ShapeType shapeType ShapeType.Rectangle; [Header(Rectangle Settings)] public Vector2 rectSize new Vector2(0.2f, 0.2f); [Header(Circle Settings)] public float circleRadius 0.15f; [Header(Advanced)] public bool useWorldSpaceCenter false; public Vector2 worldSpaceCenterOffset Vector2.zero; private Image image; private Material markMat; private ListMaterial clipMats new ListMaterial(); private void Awake() { image GetComponentImage(); // 创建StencilMark材质实例避免修改原始材质 markMat new Material(Shader.Find(Custom/StencilMark)); markMat.SetInt(_StencilID, stencilID); markMat.SetFloat(_Shape, (int)shapeType); // 动态计算中心点屏幕空间 UpdateCenter(); } private void OnEnable() { // 启用时确保材质生效 if (image ! null image.material ! null) { // 替换为StencilMark材质确保标记阶段执行 image.material markMat; } } private void LateUpdate() { // 每帧更新参数支持Runtime调整 UpdateParameters(); } private void UpdateParameters() { if (markMat null) return; markMat.SetInt(_StencilID, stencilID); markMat.SetFloat(_Shape, (int)shapeType); if (shapeType ShapeType.Rectangle) { markMat.SetVector(_Size, rectSize); } else { markMat.SetFloat(_Radius, circleRadius); } UpdateCenter(); } private void UpdateCenter() { Vector2 center Vector2.zero; if (useWorldSpaceCenter) { // 将世界坐标转屏幕坐标需Camera Camera cam Camera.main; if (cam ! null) { Vector3 worldPos transform.position (Vector3)worldSpaceCenterOffset; Vector3 screenPos cam.WorldToScreenPoint(worldPos); center new Vector2(screenPos.x / Screen.width, screenPos.y / Screen.height); } } else { // UI锚点中心默认 RectTransform rt GetComponentRectTransform(); if (rt ! null) { Vector2 pivot rt.pivot; center new Vector2(pivot.x, pivot.y); } } markMat.SetVector(_Center, new Vector4(center.x, center.y, 0, 0)); } // 外部调用为其他对象添加裁剪 public void AddClipTarget(Graphic graphic) { if (graphic null || graphic.material null) return; Material clipMat new Material(Shader.Find(Custom/StencilClip)); clipMat.SetInt(_StencilID, stencilID); clipMat.SetTexture(_MainTex, graphic.material.GetTexture(_MainTex)); clipMat.SetColor(_Color, graphic.color); graphic.material clipMat; clipMats.Add(clipMat); } // 清理材质实例防止内存泄漏 private void OnDestroy() { if (markMat ! null) Destroy(markMat); foreach (var mat in clipMats) Destroy(mat); } } public enum ShapeType { Rectangle, Circle }实操心得AddClipTarget方法设计为公开方便在Awake中批量调用。例如你的HUD Panel上有多个Image血条、弹药、小地图只需在HUD脚本中foreach(var img in GetComponentsInChildrenImage()) maskController.AddClipTarget(img);即可一键裁剪。避免手动拖拽减少美术操作步骤。4. 实战避坑指南那些让项目延期三天的“幽灵Bug”排查链路即使按上述步骤配置仍可能遇到“明明写了Stencil却没效果”的情况。这不是代码问题而是Unity渲染管线中多个隐性因素的叠加干扰。以下是我在三个不同项目中踩过的坑按排查优先级排序每一步都附带验证方法。4.1 坑位1Canvas Render Mode与Sorting Layer的致命组合现象镂空Panel在Screen Space - Overlay模式下工作正常切换到Screen Space - Camera或World Space后镂空区域变大或偏移。根因分析Overlay模式下Canvas直接渲染到屏幕ComputeScreenPos获取的坐标天然准确而Camera模式下Canvas作为3D对象存在其RectTransform的localPosition与屏幕坐标的映射受Camera Projection Matrix、Canvas Plane Distance影响。ComputeScreenPos在Camera模式下需额外除以_ProjectionParams.x正交/透视标志和_ScreenParams.zCamera Depth否则坐标失真。验证方法在StencilMark.shader的frag函数开头添加调试输出// 临时替换frag函数观察屏幕坐标 fixed4 frag (v2f i) : SV_Target { float2 screenUV i.screenPos.xy / i.screenPos.w; // 输出screenUV到屏幕红U绿V return fixed4(screenUV.x, screenUV.y, 0, 1); }若看到红色/绿色块未覆盖预期区域说明坐标计算错误。修复方案在StencilMaskController.UpdateCenter()中针对Camera模式重写中心计算if (cam ! null cam.orthographic) { // 正交相机直接用Canvas平面坐标 Vector3 canvasPos cam.WorldToScreenPoint(transform.position); center new Vector2(canvasPos.x / Screen.width, canvasPos.y / Screen.height); } else if (cam ! null) { // 透视相机需考虑Canvas Plane Distance float planeDist GetComponentCanvas().planeDistance; Vector3 worldPos transform.position (Vector3)worldSpaceCenterOffset; Vector3 screenPos cam.WorldToScreenPoint(worldPos); center new Vector2(screenPos.x / Screen.width, screenPos.y / Screen.height); }4.2 坑位2UI Raycast Target开启导致Stencil被跳过现象镂空Panel设置了Raycast Target true鼠标悬停时镂空失效移开后恢复。根因分析当Raycast Target启用时Unity会为该UI元素创建独立的Graphic Raycaster其渲染顺序可能插入到Stencil Mark与Stencil Clip之间导致Clip阶段读取到的是旧Stencil值未被Mark更新。验证方法临时关闭Panel的Raycast Target观察镂空是否立即恢复。若恢复则确认为此坑。修复方案两种选择推荐将镂空Panel的Raycast Target设为false另建一个透明的Button或ImageRaycast Targettrue覆盖在镂空区域上方专门处理点击事件。这样逻辑分离Stencil不受干扰。备选在StencilMaskController.OnEnable()中强制设置image.raycastTarget false;并在Awake中保存原始状态OnDisable时恢复。4.3 坑位3HDRP/LWRP管线中Stencil Buffer被全局禁用现象项目使用URPUniversal Render PipelineStencil效果完全不生效Shader编译无报错。根因分析URP默认关闭Stencil Buffer写入需在Renderer Feature中显式启用。URP的ForwardRenderer默认不处理Stencil除非你添加了自定义Renderer Feature。验证方法在URP Asset中检查Renderer Features列表是否为空。若为空且未添加任何Feature则Stencil Buffer未被激活。修复方案创建一个空的Renderer FeatureAssets/Create/Rendering/Renderer Feature命名为StencilEnabler然后在URP Asset的Renderer Features中添加它。无需编写代码仅添加即可启用Stencil Buffer。若需更精细控制可继承ScriptableRendererFeature并重写AddRenderPasses但对本需求而言空Feature已足够。4.4 坑位4多摄像机场景中Stencil Buffer未同步清除现象主摄像机显示正常但UI摄像机如用于HUD的独立Camera中镂空区域错位或残留。根因分析Stencil Buffer是全局资源多摄像机共用同一块Buffer。若主摄像机执行了MarkUI摄像机执行Clip但UI摄像机未执行Clear下一帧主摄像机Mark时Buffer中残留旧值导致误裁剪。验证方法在StencilMark.shader的SubShader Tags中添加ForceNoShadowCasterTrue并确保所有使用Stencil的摄像机都设置了相同的Clear Flags如Dont Clear或Depth only。若问题消失则确认为Buffer同步问题。修复方案在StencilMaskController.OnDestroy()中添加强制Clearprivate void OnDestroy() { // ... 原有清理代码 // 强制清除Stencil Buffer GL.Clear(false, true, Color.clear, 1f, 0); // 第二个true表示clear stencil }或更稳妥的做法在主摄像机的OnPreRender回调中统一Clear确保每帧开始前Buffer干净。5. 进阶技巧软边镂空、动态缩放与多层嵌套的工程化实现基础镂空解决“有无”问题但真实项目需要“质感”。以下三个技巧是我从AR游戏《视界棱镜》和战术模拟《铁幕协议》中提炼的工程化方案无需大幅重构仅需微调Shader与脚本。5.1 软边镂空用SmoothStep替代Hard Clip告别锯齿感硬边镂空在高DPI屏幕或放大UI时出现明显锯齿。解决方案是在StencilClip.shader的frag函数中不直接discard而是用smoothstep生成Alpha渐变// 在StencilClip.shader的frag函数中替换原有return fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv) * _Color; // 计算当前像素到镂空边缘的距离需传入边缘宽度 float2 screenUV ComputeScreenPos(i.pos).xy / ComputeScreenPos(i.pos).w; float dist 0; if (_Shape 0.5) // 矩形 dist max(abs(screenUV.x - _Center.x) - _Size.x, abs(screenUV.y - _Center.y) - _Size.y); else // 圆形 dist distance(screenUV, _Center.xy) - _Radius; // 软边在[-_SoftEdge, _SoftEdge]区间内平滑过渡 float alpha smoothstep(-_SoftEdge, _SoftEdge, dist); col.a * alpha; // 仅降低Alpha保留颜色 return col; }对应C#脚本中增加_SoftEdge参数并在UpdateParameters()中传入。实测_SoftEdge0.01在1080p屏幕上已足够自然。5.2 动态缩放镂空绑定RectTransform的Anchor实现响应式适配美术常要求“镂空区域随UI缩放自动调整”。硬编码_Size或_Radius无法响应RectTransform的Scale变化。正确做法是将缩放因子注入Shader// 在StencilMaskController.UpdateParameters()末尾添加 float scale transform.lossyScale.x; // 取X轴缩放假设均匀缩放 markMat.SetFloat(_ScaleFactor, scale);然后在StencilMark.shader的frag函数中将_Size和_Radius乘以_ScaleFactor。这样当UI Panel被父物体缩放时镂空形状同比例缩放无需美术手动调整参数。5.3 多层嵌套镂空用Stencil Buffer的8位深度实现“套娃”裁剪Stencil Buffer有8位0~255意味着最多可定义256个独立ID。利用此特性可实现“大镂空中嵌套小镂空”如战术目镜的大视野框中心十字线。关键在于ID的层级分配层级Stencil ID用途L1最外层1主视野框矩形L2中间层2中心准星圆形L3最内层3准星发光环环形在StencilClip.shader中通过Comp Less小于或Comp Greater大于实现层级穿透。例如要让L2内容只在L1内显示L3只在L2内显示则L2的Clip Shader用Comp Equal比1L3的用Comp Equal比2。若需L3同时受L1和L2约束则L3的Stencil Test设为Ref 1 Comp Equal Ref 2 Comp Equal需用Stencil{}的ReadMask组合但Unity不支持多条件故推荐分两步先Mark L1再Mark L2最后Clip时用Comp Equal比2因L2 Mark已覆盖L1区域。最后分享一个小技巧在StencilMaskController中添加[ContextMenu(Bake To Prefab)]方法可一键将当前配置包括所有材质实例、参数打包为Prefab供美术直接拖入场景复用。这比教他们改Shader参数高效十倍。我在《视界棱镜》上线前两周用这套系统替换了原有的七种Hack方案包括用Render Texture做离屏渲染、用Mesh Collider做射线检测模拟裁剪、甚至用粒子系统喷黑点遮挡最终将UI裁剪相关的Bug率从每周3个降至0。核心经验只有一条Stencil Buffer不是“高级功能”而是Unity为你准备好的、开箱即用的像素级门禁系统——你只需要学会填对那张通行申请表Stencil参数它就会严丝合缝地工作。