UGUI源码剖析(6):遮罩的“魔法”与“算法”——从C#到Shader,彻底揭示Mask与RectMask2D的原理
UGUI源码剖析(第六章):遮罩的“魔法”与“算法”——从C#到Shader,彻底揭示Mask与RectMask2D的原理
在UGUI中,遮罩(Masking)是实现内容裁剪、创造视觉层次感的关键技术。然而,Unity为此提供了两种看似相似,但底层实现原理和性能表现却天差地别的组件:传统的Mask组件和更现代的RectMask2D。要真正理解它们的差异,我们必须深入到C#源码与Shader代码的交界处,去探寻这两种遮罩的“魔法”与“算法”究竟是如何实现的。
1. MaskableGraphic:参与遮罩的“资格”
一切遮罩故事的起点,都源于MaskableGraphic。这个Graphic的子类,通过实现IClippable, IMaskable, IMaterialModifier这三个关键接口,获得了同时响应两种不同遮罩系统的能力。它是所有可被遮罩元素的共同基类。
前置知识:揭开GPU“模板缓冲区”的神秘面纱
在深入Mask组件的“魔法”之前,我们必须先理解它的“魔力”来源——GPU的模板缓冲区(Stencil Buffer)。
-
1.1 什么是模板缓冲区?
想象一下,当GPU准备在屏幕上绘制一个像素时,它不仅有一个用来存放颜色的“画板”(颜色缓冲区),还有一个与之配套的、同样大小的、但看不见的“草稿纸”——这就是模板缓冲区。
这块“草稿纸”的特殊之处在于,它的每个“像素”位置,只能写入简单的整数(通常是0到255)。它不关心颜色,只关心数字。 -
1.2 模板测试:GPU的“门禁系统”
模板缓冲区最重要的作用,是作为一个**“门禁系统”。我们可以为任何一个准备渲染的物体,设定一套“通行规则”,这个过程就叫模板测试(Stencil Test)**。
这个“门禁”可以被配置成这样:“嘿,GPU!在绘制这个物体的某个像素之前,请先检查一下‘草稿纸’(模板缓冲区)上对应位置的数字。只有当这个数字等于我指定的‘通行码’(比如5)时,才允许你把这个像素的颜色画到‘画板’(颜色缓冲区)上。否则,就直接把它扔掉!”
-
1.3 模板操作:修改“草稿纸”
我们不仅可以“读取”草稿纸上的数字,还可以“修改”它。我们可以告诉GPU:“嘿,GPU!当你成功绘制完这个物体的某个像素后,请顺便把‘草稿纸’上对应位置的数字,替换成一个新的‘通行码’(比如6)。”
2. Mask组件:基于GPU模板缓冲区的“魔法”
Mask组件的原理,是一套纯粹发生在GPU端的、基于**模板缓冲区(Stencil Buffer)**的渲染“戏法”。它的核心,是动态地创建和管理一系列“特殊”的材质,来操纵GPU的渲染状态。
第一幕:Mask组件登台——在模板缓冲区上“画押”
当Mask组件自身需要被渲染时,它的GetModifiedMaterial方法会被调用。
// Mask.cs -> GetModifiedMaterial()
public virtual Material GetModifiedMaterial(Material baseMaterial)
{// ... 计算模板深度 stencilDepth ...// 请求一个“写模板”的材质var maskMaterial = StencilMaterial.Add(baseMaterial, ..., StencilOp.Replace, CompareFunction.Always, ...);return maskMaterial;
}
-
C#端的工作:Mask组件会向StencilMaterial这个“材质工坊”请求一个“写模板”的材质。这个请求,会将一系列参数(如模板ID _Stencil,比较函数 _StencilComp,操作 _StencilOp)传递给StencilMaterial。
-
StencilMaterial的工作:它会找到一个或创建一个新的材质实例,并将这些参数,通过SetFloat方法,设置到材质的Shader属性中。
-
Shader端的工作:这个被参数化过的材质,最终被CanvasRenderer提交给GPU。当我们查看UI/Default Shader的SubShader部分时,就能看到这些参数是如何被使用的:
// UI/Default.shader SubShader {Tags { ... }Stencil{Ref [_Stencil]Comp [_StencilComp]Pass [_StencilOp]ReadMask [_StencilReadMask]WriteMask [_StencilWriteMask]}// ... }
解读:Stencil { … }这个代码块,是配置GPU模板测试状态的关键。它告诉GPU:“在渲染这个物体(Mask自身)的像素时,请执行以下模板操作”。Mask组件传递的参数,最终会在这里生效。例如,Comp Always意味着无条件通过测试,Pass Replace意味着将通过测试的像素,在模板缓冲区中对应的位置,写入Ref(即_Stencil)的值。
结果:当Mask组件的Graphic被渲染后,它在屏幕上所覆盖的区域,其在GPU的模板缓冲区中的值,就被“画”上了一个特殊的标记。这就是“画押”。
第二幕:子元素登台——“对答案”
当Mask的子MaskableGraphic元素需要被渲染时,它也会调用GetModifiedMaterial。
// MaskableGraphic.cs -> GetModifiedMaterial()
public virtual Material GetModifiedMaterial(Material baseMaterial)
{// ...m_StencilValue = MaskUtilities.GetStencilDepth(...); // 获取应该匹配的模板IDif (m_StencilValue > 0){// 请求一个“读模板”的材质var maskMat = StencilMaterial.Add(baseMaterial, ..., CompareFunction.Equal, ...);return maskMat;}// ...
}
- C#端的工作:子元素会向StencilMaterial请求一个“读模板”的材质。这个材质的CompareFunction被设置为Equal(等于)。
- Shader端的工作:这个材质的Stencil块,会告诉GPU:“在渲染这个物体(子元素)的像素时,只有当模板缓冲区中对应像素的值,等于我指定的Ref(即_Stencil)值时,才允许这个像素被渲染。否则,丢弃它。”
结果:只有那些位于Mask组件“画押”区域内的子元素像素,才能通过模板测试,最终被显示在屏幕上。这就是“对答案”。
Mask的性能代价:Mask的“魔法”之所以昂贵,是因为它打断了Canvas的渲染批处理。Mask和它的每一个子元素,都可能因为使用了不同的、由StencilMaterial动态生成的材质实例,而无法被合并到同一个Draw Call中,从而导致Draw Call数量急剧增加。
3. RectMask2D:基于CPU与Shader协作的“算法”
RectMask2D则完全抛弃了GPU端的模板测试“魔法”,转而采用了一套纯粹在CPU端准备数据,并在GPU片元着色器中进行简单计算的“算法”。
第一幕:CPU端的准备工作——计算并传递_ClipRect
我们在之前的章节已经分析过RectMask2D在C#端的工作流程:
- 它在PerformClipping中,计算出最终的、叠加后的世界空间裁剪矩形clipRect。
- 它调用子MaskableGraphic的**SetClipRect(clipRect, validRect)**方法。
- MaskableGraphic的SetClipRect最终调用canvasRenderer.EnableRectClipping(clipRect)。
EnableRectClipping是一个extern方法,它会将这个clipRect矩形数据,传递给C++底层。最终,这个矩形数据会被设置到一个名为_ClipRect的全局Shader变量中。
第二幕:GPU端的执行——在片元着色器中“裁剪”
现在,我们来看UI/Default Shader的片元着色器(fragment shader)部分:
// UI/Default.shader -> Pass
fixed4 frag(v2f IN) : SV_Target
{half4 color = (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd) * IN.color;// 关键代码:当这个Shader变体被激活时,执行裁剪#ifdef UNITY_UI_CLIP_RECTcolor.a *= UnityGet2DClipping(IN.worldPosition.xy, _ClipRect);#endif#ifdef UNITY_UI_ALPHACLIPclip (color.a - 0.001);#endifreturn color;
}
而UnityGet2DClipping函数,定义在UnityUI.cginc中:
// UnityUI.cginc
inline float UnityGet2DClipping (in float2 position, in float4 clipRect)
{// step(a, x) 当x>=a时返回1,否则返回0// inside.x: 当 position.x 在 clipRect.x 和 clipRect.z 之间时为1// inside.y: 当 position.y 在 clipRect.y 和 clipRect.w 之间时为1float2 inside = step(clipRect.xy, position.xy) * step(position.xy, clipRect.zw);// 只有当x和y都在范围内时,结果才为1return inside.x * inside.y;
}
技术解读:
- Shader变体 (multi_compile_local): UI/Default Shader通过**#pragma multi_compile_local _ UNITY_UI_CLIP_RECT**,定义了两个变体:一个包含UNITY_UI_CLIP_RECT宏,一个不包含。当canvasRenderer.EnableRectClipping被调用时,Unity会为这个Graphic选择包含该宏的Shader变体进行渲染。
- 顶点着色器 (vert): vert函数会将每个顶点的模型空间位置(v.vertex),直接作为**世界空间位置(OUT.worldPosition)**传递给片元着色器。
- 片元着色器 (frag): frag函数是“算法”的最终执行者。
- 它首先计算出像素的原始颜色color。
- 然后,#ifdef UNITY_UI_CLIP_RECT内的代码块被激活。它调用UnityGet2DClipping,传入当前像素的世界坐标(IN.worldPosition.xy)和CPU端传递过来的裁剪矩形(_ClipRect)。
- UnityGet2DClipping函数通过两次step函数的巧妙组合,判断当前像素是否在_ClipRect定义的矩形区域内。如果在,返回1.0;如果不在,返回0.0。
- 最后,将这个0.0或1.0的结果,乘以像素的alpha通道 (color.a *= …)。
结果:对于所有在裁剪矩形之外的像素,它们的最终alpha值都变成了0,从而变得完全透明,实现了裁剪效果。
RectMask2D的性能优势:
- 不打断批处理:所有被RectMask2D影响的子元素,它们依然可以使用同一个UI/Default材质(只是激活了不同的Shader变体)。只要它们的纹理等其他参数允许,它们依然可以被完美地批处理。
- GPU开销极小:UnityGet2DClipping的计算量,对于现代GPU来说,几乎可以忽略不计。
总结:
通过深入C#与Shader的源码,我们彻底揭示了两种遮罩的本质区别:
- Mask 是一套重量级的、基于GPU状态切换(模板测试)的渲染“魔法”。它功能强大,支持任意形状,但其代价是高昂的Draw Call和对渲染批处理的破坏。
- RectMask2D 是一套轻量级的、CPU与GPU协作的裁剪“算法”。它在CPU端准备好裁剪矩形数据,然后在GPU片元着色器中,通过极小的计算开销,实现像素级的透明化处理。它性能卓越,不影响批处理,但其限制是仅支持矩形。
给开发者的最终建议:
在性能敏感的游戏UI开发中,选择的答案是清晰的。请将RectMask2D作为你的默认武器,用它的“算法”去高效地解决所有矩形裁剪的需求。而将Mask这把强大的“魔法剑”,珍藏起来,仅在万不得已、必须实现非矩形遮罩的时刻,才审慎地、有控制地拔出使用,并时刻警惕它对性能带来的影响。