当前位置: 首页 > news >正文

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#端的工作流程:

  1. 它在PerformClipping中,计算出最终的、叠加后的世界空间裁剪矩形clipRect。
  2. 它调用子MaskableGraphic的**SetClipRect(clipRect, validRect)**方法。
  3. MaskableGraphicSetClipRect最终调用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函数是“算法”的最终执行者。
    1. 它首先计算出像素的原始颜色color。
    2. 然后,#ifdef UNITY_UI_CLIP_RECT内的代码块被激活。它调用UnityGet2DClipping,传入当前像素的世界坐标(IN.worldPosition.xy)和CPU端传递过来的裁剪矩形(_ClipRect)
    3. UnityGet2DClipping函数通过两次step函数的巧妙组合,判断当前像素是否在_ClipRect定义的矩形区域内。如果在,返回1.0;如果不在,返回0.0。
    4. 最后,将这个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这把强大的“魔法剑”,珍藏起来,仅在万不得已、必须实现非矩形遮罩的时刻,才审慎地、有控制地拔出使用,并时刻警惕它对性能带来的影响。

http://www.dtcms.com/a/328193.html

相关文章:

  • 13.深度学习——Minst手写数字识别
  • git config的配置全局或局部仓库的参数: local, global, system
  • java面试题储备4: 谈谈对es的理解
  • 【银行测试】外贸信托项目与电子资金项目(面试项目讲解)
  • Java面试题储备11: mysql优化全面讲一下,及你遇到的对应业务场景
  • 不废话,UE5极速云渲染操作方法
  • B.10.02.3-分布式一致性:电商业务场景下的理论与工程实践
  • 使用 RealSense D435 获取红外图像:完整 Python 脚本解析
  • 扣子空间深度解析
  • 堆排序以及实现
  • 飞算 JavaAI -智慧城市项目实践:从交通协同到应急响应的全链路技术革新
  • 【Go】Gin 超时中间件的坑:fatal error: concurrent map writes
  • FPGA即插即用Verilog驱动系列——UART串口接收
  • 医疗智慧大屏系统 - Flask + Vue实现
  • nextTick和setTimeout的区别
  • Docker概述与安装Dockerfile文件
  • k8s-scheduler 解析
  • 1小时 MySQL 数据库基础速通
  • log4cplus的功能是什么,我们如何来使用它?
  • 调整UOS在VMware中的分辨率
  • Linux系统启动过程详解
  • CTO 如何从“干活的人”转变成“带方向的人”?
  • 需求沟通会议如何组织
  • 云手机在电商行业中的作用
  • 知名车企门户漏洞或致攻击者远程解锁汽车并窃取数据
  • C++ 学习与 CLion 使用:(二)using namespace std 语句详解,以及 std 空间的标识符罗列
  • 消防安全预警系统助力安全生产
  • 【工作笔记】win11系统docker desktop配置国内mirror不生效解决方案汇总整理
  • `SHOW PROCESSLIST;` 返回列详解(含义 + 单位)
  • django celery 动态添加定时任务后不生效问题