Unity 遮罩“绘制”图

前言

A Mask is not a visible UI control but rather a way to modify the appearance of a control’s child elements. the parent will be visible.

Mask 是一个不可见的用于控制子元素遮罩显示的一个组件,在 Unity UI 中分别有 Mask 和 RectMask2D 两种类型的 Mask 组件实现遮罩效果;这两个组件都有各自的使用场景,下面就来分析 Unity 中是如何应用这两个组件来为一副图像“绘制”遮罩效果。

IMaskable、IMaterialModifier、IClippable 和 IClipper

在分析这两个 Mask 组件之前,先来分别认识一下 IMaskable、IMaterialModifier、IClippable 以及 IClipper 接口。

IMaskable

实现了 IMaskable 接口的组件都可以被用来实现遮罩效果,它有一个方法 RecalculateMasking 用来计算当前元素及其子元素的 Mask。

IMaterialModifier

IMaterialModifier 接口通常用于一个 Graphic 在渲染之前修改其 Material,它也有一个方法需要实现 GetModifiedMaterial,在这个方法中可以自定义修改渲染所用 Material 的实现方式。一个典型的使用就是 Graphic 在渲染时,会获取其对象上的所有的 IMaterialModifier,然后依次调用 GetModifiedMaterial 方法修改当前渲染所用的 Material。部分代码如下:

1
2
3
4
5
6
7
8
9
10
11
public virtual Material materialForRendering
{
get
{
GetComponents(typeof(IMaterialModifier), components);
var currentMat = material;
for (var i = 0; i < components.Count; i++)
currentMat = (components[i] as IMaterialModifier).GetModifiedMaterial(currentMat);
return currentMat;
}
}

IClipper

实现了这个接口的组件能够接收到 Canvas 更新时裁剪的回调,它有一个需要实现的方法 PerformClipping 用于执行裁剪,这个方法在 Graphic Layout 重建之后以及在 Graphic 渲染重建之前执行。

IClippable

实现了这个接口的组件其所在的 UI 元素能够被父元素裁剪,这个父元素需绑定实现了 IClipper 接口的组件。

Mask

Mask 类继承自 UIBehaviour 类,因此它具有 Unity 完整生命周期回调,同时它实现了 ICanvasRaycastFilter 接口,所以也可作为 Unity Event System 中事件分发射线检测对象,还实现了 IMaterialModifier 接口,这样在当前对象上的 Graphic 被渲染的时候,就会通过 Mask 类中的 GetModifiedMaterial 方法来修改渲染所用的材质。

为对象绑定 Mask 组件,必须在同一个对象上绑定一个 Graphic,这是因为 Mask 组件依赖 Graphic 组件来实现遮罩。将 Mask 组件绑定到对象上,如下:

可以看到在 Unity 编辑器中有一个可编辑属性 Show Mask Graphic,这个属性对应了 Mask 类里面的 m_ShowMaskGraphic 变量,用来标记在遮罩渲染中是否输出遮罩所在的 Graphic。

下面从 Mask 类源码出发,来分析整个 Mask 组件实现遮罩效果的流程。首先看看类中的主要的成员变量:

属性 描述
m_ShowMaskGraphic 标记在遮罩渲染中是否输出遮罩所在的 Graphic
m_Graphic Mask 关联的 Graphic
m_MaskMaterial 用于实现遮罩加入 Stencil Buffer 过后的 Material
m_UnmaskMaterial 用于取消遮罩恢复 Stencil Buffer 默认值过后的 Material
m_RectTransform 组件所在对象的 RectTransform

再来看看类中的相关方法:

showMaskGraphic get/set 方法

用来获取或设置 m_ShowMaskGraphic 变量,当设置这个变量时,如果设置值改变会触发当前组件所在的 Graphic 重新构建操作,如下:

1
2
3
4
5
6
7
8
9
10
set
{
if (m_ShowMaskGraphic == value)
return;
m_ShowMaskGraphic = value;
if (graphic != null)
// set material dirty & wait for rebuild ...
graphic.SetMaterialDirty();
}

OnEnable 方法

在组件 OnEnable 的时候,回调用如下代码:

1
2
3
4
5
6
7
if (graphic != null)
{
graphic.canvasRenderer.hasPopInstruction = true;
graphic.SetMaterialDirty();
}
MaskUtilities.NotifyStencilStateChanged(this);

从代码中可以看出,首先设置了 Graphic 所在 CanvasRender 的 hasPopInstruction 值为 true,并标记 Graphic 可以被重新构建;紧接着调用了 MaskUtilities 类的 NotifyStencilStateChanged 方法通知当前对象的所有绑定了实现了 IMaskable 接口组件的子元素计算它们的遮罩,MaskUtilities 类是实现遮罩效果常用的一个工具类,里面的方法在用到的时候会具体分析,先来看看 NotifyStencilStateChanged 方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void NotifyStencilStateChanged(Component mask)
{
// other code ...
mask.GetComponentsInChildren(components);
for (var i = 0; i < components.Count; i++)
{
// is IMaskable
var toNotify = components[i] as IMaskable;
if (toNotify != null)
toNotify.RecalculateMasking();
}
// other code ...
}

代码很简单,通过遍历子元素上绑定的实现了 IMaskable 接口的组件,依次调用 RecalculateMasking 方法来计算遮罩相关数据。

OnDisable 方法

  1. 在 OnDisable 方法中,首先所在 Graphic 调用了 SetMaterialDirty 方法标记 Graphic 可以被重新构建;

  2. StencilMaterial 类是一个辅助类,管理用于实现遮罩效果的 Stencil Materil,其中具体的方法在用到时具体分析;第二步就是调用 StencilMaterial 类的 Remove 方法依次移除 m_MaskMaterialm_UnmaskMaterial

  3. 最后又一次调用 MaskUtilities 类的 NotifyStencilStateChanged 方法通知当前对象的所有绑定了实现了 IMaskable 接口组件的子元素再次计算它们的遮罩数据。

IsRaycastLocationValid 方法

这是 ICanvasRaycastFilter 接口实现的一个方法,用于 Unity Event System 中事件分发射线检测过程,在这个方法内部实现最终调用了 RectTransformUtility 类的静态方法 RectangleContainsScreenPoint 来确定当前所在的 RectTransform 是否包含了所指的屏幕上的点。

GetModifiedMaterial 方法

最后就是核心方法 GetModifiedMaterial,它是实现遮罩的关键方法之一。

首先回顾一下一个 Graphic 重新构建的过程: 当可被重新构建标记之后,在 Canvas 的渲染过程中调用了 Graphic 的 Rebuild 方法,然后调用 UpdateMaterial 方法设置渲染 Graphic 用的 Material,此时获取 Material 如下:

1
2
3
4
5
6
7
8
get
{
GetComponents(typeof(IMaterialModifier), components);
var currentMat = material;
for (var i = 0; i < components.Count; i++)
currentMat = (components[i] as IMaterialModifier).GetModifiedMaterial(currentMat);
return currentMat;
}

Mask 需要和 Graphic 组件(具体来说是 MaskableGraphic 组件)一起绑定在同一个对象上才能发挥作用。当这个 MaskableGraphic 重新构建时,上面的代码会被调用用来获取渲染所用的材质,代码中遍历了当前对象上绑定的实现了 IMaterialModifier 接口的组件,然后调用其 GetModifiedMaterial 方法修改渲染用的 Material;这样的话 MaskableGraphic 和 Mask 组件的 GetModifiedMaterial 方法都会依次被调用。

下面就来看看这两个组件中对 GetModifiedMaterial 方法的实现吧!

  1. MaskableGraphic 类中的 GetModifiedMaterial 方法

一个 MaskableGraphic 可以同 Mask 组件一起绑定(如 Image 组件所在对象上绑定一个 Mask 组件)来实现遮罩效果,也可以作为一个单独的组件绑定在某个对象上,比如创建 Unity UI 中的 Image(Image 类继承自 MaskableGraphic)元素。在这两种不同情况下,GetModifiedMaterial 方法发挥的作用也不一样,具体来看看代码:

1
2
3
4
5
6
7
8
var toUse = baseMaterial;
if (m_ShouldRecalculateStencil)
{
var rootCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
m_StencilValue = maskable ? MaskUtilities.GetStencilDepth(transform, rootCanvas) : 0;
m_ShouldRecalculateStencil = false;
}

首先判断当前 MaskableGraphic 是否需要计算模板数据,需要就调用 MaskUtilities 类的 GetStencilDepth 计算模板深度 m_StencilValueGetStencilDepth 方法代码很简单,就是遍历计算对象的父元素看是否有绑定 Mask 组件,如果则 m_StencilValue 模板深度值加一。

若当前 MaskableGraphic 上绑定有 Mask 组件,那么当前方法也就执行完成了,返回的修改后的材质就是传入的材质(这种情况是作为 Mask 发挥作用的 Graphic 对象);

如果没有绑定 Mask 组件,那么会执行以下代码计算修改材质(这种情况常见场景是一个需要被遮罩的 Image,且位于 Mask 组件所在的对象之下):

1
2
3
4
5
6
7
8
Mask maskComponent = GetComponent<Mask>();
if (m_StencilValue > 0 && (maskComponent == null || !maskComponent.IsActive()))
{
var maskMat = StencilMaterial.Add(toUse, (1 << m_StencilValue) - 1, StencilOp.Keep, CompareFunction.Equal, ColorWriteMask.All, (1 << m_StencilValue) - 1, 0);
StencilMaterial.Remove(m_MaskMaterial);
m_MaskMaterial = maskMat;
toUse = m_MaskMaterial;
}

从代码中可以看出,如果当前 MaskableGraphic 所在对象没有绑定 Mask 组件(或处于未激活状态)且 m_StencilValue 大于 0(其父元素中有 Mask 组件),就调用 StencilMaterial 类的 Add 方法修改传入的材质,这个方法流程如下:

  • 首先检测当前渲染所用的 Shader 是否定义了用于模板测试的相关变量,如未定义则返回当前 Shder 不作任何修改;

  • 如果模板测试所用变量都定义了,遍历 MaskableGraphic 类中对于当前需求材质缓存,若从缓存中找到对应材质直接返回这个材质;

  • 否则就根据当前渲染的材质生成一个新的材质,这个新的材质包含模板测试相关的属性,并缓存。

第三步主要是设置了材质的 Shader 中模板测试相关数据,对应关系如下:

Shader variable Define parameter value description
Ref(Stencil) _Stencil (1 << m_StencilValue) - 1 模板测试参考值
Pass(Stencil) _StencilOp StencilOp.Keep 模板测试(和深度测试)通过后,根据此值对模板缓冲值进行处理
Comp(Stencil) _StencilComp CompareFunction.Equal 模板测试参考值和模板缓冲值比较函数
ReadMask(Stencil) _StencilReadMask (1 << m_StencilValue) - 1 对模板测试参考值和模板缓冲值进行按位与操作后再使用
WriteMask(Stencil) _StencilWriteMask 0 写入模板缓冲值时对这个值按位与之后再写入
ColorMask(Pass) _ColorMask ColorWriteMask.All 输出通道
UNITY_UI_ALPHACLIP _UseUIAlphaClip (_StencilOp != StencilOp.Keep && writeMask > 0) ? 1 : 0 Shader 中是否使用透明度裁剪

这样就得到了修改之后包含模板测试的材质,如果设置成功了模板测试的相关数据,Unity 视图中会呈现出来,如下图:

上面模板测试条件为:

\[ StencilBufferValue \& ReadMask = StencilRef \& ReadMask \]

满足上述条件的像素就会通过测试,否则该像素不会被渲染。模板测试(和深度测试)通过后,模板缓冲值不会作任何处理(因为此处设置的 Pass 为 StencilOp.Keep)。通过这段分析我们知道,位于 Mask 组件所在对象下的 MaskableGraphic,如果所用 Shader 满足条件则会进行模板测试(默认渲染 UI 的 Shader 就是满足这个条件的,稍后会分析这个 Shader)。

  1. Mask 类中的 GetModifiedMaterial 方法

另一个GetModifiedMaterial 方法就在 Mask 类中。上面讲到渲染前 MaskableGraphic 和 Mask 类中的这个方法依次被调用来修改材质, 但是与 Mask 相呼应的 MaskableGraphic 的GetModifiedMaterial 方法并未对当前渲染材质做任何修改,所以主要修改任务就来到了 Mask 中。下面就来看看其实现:

首先计算模板深度值,并限制了最大模板深度不能超过 8 层,如下:

1
2
3
4
5
6
7
var rootSortCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
var stencilDepth = MaskUtilities.GetStencilDepth(transform, rootSortCanvas);
if (stencilDepth >= 8)
{
Debug.LogWarning("Attempting to use a stencil mask with depth > 8", gameObject);
return baseMaterial;
}

紧接着如果当前 Mask 所在的模板深度为 0(父元素中没有任何 Mask 组件),使用如下代码修改材质的 Shader:

1
2
3
4
5
6
7
8
9
10
11
var maskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Replace, CompareFunction.Always, m_ShowMaskGraphic ? ColorWriteMask.All : 0);
StencilMaterial.Remove(m_MaskMaterial);
m_MaskMaterial = maskMaterial;
var unmaskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Zero, CompareFunction.Always, 0);
StencilMaterial.Remove(m_UnmaskMaterial);
m_UnmaskMaterial = unmaskMaterial;
graphic.canvasRenderer.popMaterialCount = 1;
graphic.canvasRenderer.SetPopMaterial(m_UnmaskMaterial, 0);
return m_MaskMaterial;

上面的代码中,首先依旧是调用 StencilMaterial 类的 Add 方法获取设置模板测试相关数据后的材质;其中模板测试条件是所有来到模板测试的像素全部通过,且对应的模板缓冲值都被设置为 1;

在修改完渲染所用材质之后,还更新了一个名为 m_UnmaskMaterial 的材质,其模板测试条件是所有来到模板测试的像素全部通过,且对应的模板缓冲值都被设置为 0,这个材质被 CanvasRender 通过调用 SetPopMaterial 方法设置到 CanvasRender 中,用于当前 Mask 所在对象的全部子元素渲染完成之后,重置模板缓冲区(这里会增加一个 drawCall)。

这两个材质的模板测试参数 ReadMask 和 WriteMask 都被设置为了 255,默认不影响模板测试参考值的读取以及模板缓冲值的读取和写入。如下图:

回顾一下前面讲到的内容,当 MaskableGraphic 作为 Mask 组件所在对象的子元素时,若此时这个 Mask 组件对象的模板深度值为 0,那么 MaskableGraphic 的模板深度值就是 1(渲染 Shader 中模板测试的参考值也是 1);由于父元素在子元素之前渲染(Unity UI 渲染顺序决定),在 MaskableGraphic 渲染之时模板缓冲区某些像素对应的模板缓冲值已经被置为 1,而另一些可能是默认值 0,此时 MaskableGraphic 的像素就只有在模板缓冲值是 1 的地方才能通过测试进而有可能被渲染出来。

看完了 Mask 所在的模板深度为 0的情况,再来看看 Mask 嵌套的问题。此时更新材质代码如下:

1
2
3
4
5
6
7
8
9
10
11
int desiredStencilBit = 1 << stencilDepth;
var maskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit | (desiredStencilBit - 1), StencilOp.Replace, CompareFunction.Equal, m_ShowMaskGraphic ? ColorWriteMask.All : 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
// other code ...
m_MaskMaterial = maskMaterial2;
var unmaskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit - 1, StencilOp.Replace, CompareFunction.Equal, 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
m_UnmaskMaterial = unmaskMaterial2;
// other code ...

同样的这里计算了 m_MaskMaterialm_UnmaskMaterial 两个材质,与前面计算过程类似,只是对应模板测试的参数有点不同。

对于 Stencil 参数的设置,来看看这个对比表格:

Build Stencil Stencil Ref Stencil Op Stencil Comp ReadMask WriteMask
Normal MaskableGraphic (1 << m_StencilValue) - 1 Keep Equal (1 << m_StencilValue) - 1 0
Top Level Mask 1 Replace Always 255 255
Top Level UnMask 1 Zero Always 255 255
Other Level Mask (1 << stencilDepth) | (1 << stencilDepth)- 1 Replace Equal (1 << stencilDepth)- 1 (1 << stencilDepth) | (1 << stencilDepth)- 1
Other Level UnMask (1 << stencilDepth)- 1 Replace Equal (1 << stencilDepth)- 1 (1 << stencilDepth) | (1 << stencilDepth)- 1

其中 m_StencilValue 为其组件所在的模板深度值

从表格中可以看出,当 Mask 所在组件的模板深度值大于 0 时其材质 Shader 模板测试参数的计算方式以及同其他情况下参数的对比。下面通过一个 Mask 嵌套的示例来分析整个流程,如下图:

其中 MaskPanel (0) 和 MaskPanel (1) 都是绑定了 Mask 组件的对象,MaskableGraphic 是一个普通的 Image,根据上面分析的过程可以知道这三个对象所在的模板深度 m_StencilValue 从上到下分别为 0、1 和 2。通过上面的表格能计算出渲染时模板测试的各个参数,得到如下表格:

Build Stencil Stencil value Stencil Ref Stencil Op Stencil Comp ReadMask WriteMask
MaskPanel (0) - Mask 0 1 Replace Always 255 255
MaskPanel (0) - UnMask 0 1 Zero Always 255 255
MaskPanel (1) - Mask 1 3 Replace Equal 1 3
MaskPanel (1) - UnMask 1 1 Replace Equal 1 3
MaskableGraphic 2 3 Keep Equal 3 0

从表格中可以总结以下几点:

  • 从第二层(Stencil value 为 1)开始往下,每层渲染模板测试的 ReadMask 都是上一层的模板测试参考值,这是为了模板测试时能够完整的读取到之前 Stencil buffer 的值;

  • 从第二层(Stencil value 为 1)开始往下(不包括非 Mask 的 UI 元素),其模板测试参数 WriteMask 都和模板测试参考值相同,这是为了能在模板测试通过时能够将当前模板测试参考值完整写入到 Stencil buffer 中;

  • 从第二层(Stencil value 为 1)开始往下(不包括非 Mask 的 UI 元素),用于 UnMask 的模板测试参数 ReadMask 都和模板测试参考值相同。这是因为在重置 Stencil buffer 的时候是从下往上一个接一个 Mask 所在的 Shader 来渲染进行,此时模板测试参数 ReadMask 和模板测试参考值相同能够保证模板测试通过且让 Stencil buffer 的值恢复到模板测试参考值;

  • 从第二层(Stencil value 为 1)开始往下(不包括非 Mask 的 UI 元素),用于 UnMask 的模板测试参数 WriteMask 与当前层 Mask 时模板测试参考值相同,用于 UnMask 的模板测试参考值与上一层 Mask 时模板测试参考值相同,综合这两个条件,在重置当前层的 Stencil buffer 的时候就能够将模板测试缓冲区的值重置到上一层 Mask 时模板测试参考值。

接着从上往下依次开始渲染,假设现在渲染的一块像素区域如下:

上图中每一个格子对应一个像素,初始状态每个像素上的 Stencil Buffer 值为 0,现在第一个 Mask 所在的 UI 元素 MaskPanel (0) 开始渲染(使用 UI-Default.shader),渲染完成之后各个像素的 Stencil Buffer 值如下图:

从上图中可以看出,有一部分 Stencil Buffer 值如之前分析的那样被设置成了 1,但是不应该是根据模板测试参数所有的 Stencil Buffer 值都被设置成 1 吗?这里是因为在使用 UI-Default.shader 的时候,在模板测试之前会进行 Alpha 测试,部分像素在那一步就没有通过,因此还没来得及到模板测试就被 discard 了,所以会有部分像素的 Stencil Buffer 值为 0。

注意看上图中那些 Stencil Buffer 值被置为 1 的像素其颜色也输出了,这里 Mask 所在 Graphic 颜色能否输出就是之前所说的 Mask 类的 m_ShowMaskGraphic 决定的,当这个值被设置为 true,Shader 中的 ColorMask 会被设置为 ColorWriteMask.All,从而能输出所有颜色通道值,否则m_ShowMaskGraphic 被设置为 false 将不会有任何颜色通道值输出。

下面再来看看第二层 Mask 所在 MaskPanel (1) 的渲染,结果如下图:

计算过程大致如下: 将当前模板测试参考值和 Stencil Buffer 值都与 ReadMask 按位与操作后,如果这两个结果值相等则通过模板测试,紧接着若是深度测试也通过则将当前模板测试参考值与 WriteMask 进行按位与操作,得到的值写入 Stencil Buffer。

通过上述的过程,原来 Stencil Buffer 值为 1 的像素被设置成了 3;像素的颜色值能否输出也和自身 Mask 组件上的 m_ShowMaskGraphic 相关,若输出颜色通道值则最后像素颜色值根据 Shader 中 Blend 的设置(UI-Default.shader 默认 Blend SrcAlpha OneMinusSrcAlpha)进行混合。

最后来看看 MaskableGraphic 的渲染,结果如下图:

到这一步,就很简单了。如上图所示,Stencil Buffer 值都没有发生改变,但是像素颜色发生了变化,这一渲染中模板测试的过程如下: 当前模板测试参考值是 3,而上一步仅部分 Stencil Buffer 值计算设置为 3,所以在这部分像素模板测试通过,像素颜色通道值通过混合后输出为新的值;而上一步 Stencil Buffer 值为 0 的像素这里模板测试失败,因此被 discard 无法渲染,至此一个 MaskableGraphic 遮罩效果就实现了。

前面分析 Mask 类的 GetModifiedMaterial 方法时讲到过,这个方法除了修改用于 Mask 的材质,还会修改用于 UnMask 的材质,下面就来看看 UnMask 的过程。

首先是 UI 元素 MaskPanel (1) UnMask 时渲染情况,如下图:

从上图中可以看出,根据 MaskPanel (1) 元素 UnMask 所用 Shader 的模板测试相关参数和条件方式,将 Stencil Buffer 值为 3 的像素的 Stencil Buffer 值更新为了 1,这个过程如下:

UnMask 所在的 Shader 的模板参考值以及 Stencil Buffer 值与当前计算所用的 ReadMask 进行按位与操作,若结果相等则模板测试通过(假设深度测试也通过),那么将当前使用的模板测试参考值和 WriteMask 进行按位与操作,得到的值写入 Stencil Buffer。

需要注意的是,UnMask 渲染流程中像素的颜色值没有发生任何变化,这是因为所有的 UnMask 使用材质的 Shader 中的 ColorMask 参数被设置为 0,因此不会输出任何颜色通道值。

然后是 UI 元素 MaskPanel (0) UnMask 时渲染情况,如下图:

这个过程和上一步类似,只是使用的模板测试参数和计算方式不一样。经过 UnMask 流程最终所有像素对应的 Stencil buffer 值全部重置为 0。这里再强调一下,这里每一次 UnMask 都会增加一个 drawCall,因此项目中尽可能注意少用或者不用 Mask 实现遮罩。如下图是 Frame Debug 中 MaskPanel (1) 和 MaskPanel (0) 两个元素 UnMask 渲染时参数:

Unity UI 默认 Shader

上面基本上分析完了遮罩实现的流程,部分 Shader 的知识点也有分析,下面再来看看 Unity UI 默认 Shader 代码,首先是属性定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Properties
{
[PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {}
_Color ("Tint", Color) = (1,1,1,1)
_StencilComp ("Stencil Comparison", Float) = 8
_Stencil ("Stencil ID", Float) = 0
_StencilOp ("Stencil Operation", Float) = 0
_StencilWriteMask ("Stencil Write Mask", Float) = 255
_StencilReadMask ("Stencil Read Mask", Float) = 255
_ColorMask ("Color Mask", Float) = 15
[Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip ("Use Alpha Clip", Float) = 0
}

上面的代码中除了定义常规的纹理、颜色等属性之外,还有用于模板测试的一些变量。 接下来看看模板测试的代码,如下:

1
2
3
4
5
6
7
8
Stencil
{
Ref [_Stencil]
Comp [_StencilComp]
Pass [_StencilOp]
ReadMask [_StencilReadMask]
WriteMask [_StencilWriteMask]
}

代码中主要定义了模板测试参考值 Ref、参考值与 Stencil buffer 值比较函数 Comp、模板测试(和深度测试)通过对 Stencil buffer 值的处理方式 Pass、写 Stencil buffer 值时的写入(按位与)掩码、模板测试参考值和 Stencil buffer 值读取(按位与)掩码 ReadMask

接着就是渲染管线部分配置,如下:

1
2
3
4
5
6
Cull Off
Lighting Off
ZWrite Off
ZTest [unity_GUIZTestMode]
Blend SrcAlpha OneMinusSrcAlpha
ColorMask [_ColorMask]

这里设置了 ZTest 方式、混合模式、ColorMask 等配置。

然后是顶点着色器和片元着色器的代码:

1
2
3
4
5
6
7
8
9
10
v2f vert(appdata_t v)
{
v2f OUT;
// other code ...
OUT.worldPosition = v.vertex;
OUT.vertex = UnityObjectToClipPos(OUT.worldPosition);
OUT.texcoord = TRANSFORM_TEX(v.texcoord, _MainTex);
OUT.color = v.color * _Color;
return OUT;
}

顶点着色器代码很简单,主要就是顶点变换、UV 坐标校正以及顶点颜色计算。下面主要来看看片元着色器部分代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fixed4 frag(v2f IN) : SV_Target
{
half4 color = (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd) * IN.color;
#ifdef UNITY_UI_CLIP_RECT
color.a *= UnityGet2DClipping(IN.worldPosition.xy, _ClipRect);
#endif
#ifdef UNITY_UI_ALPHACLIP
clip (color.a - 0.001);
#endif
return color;
}

代码中首先对纹理进行采样,然后有分两种情况对像素进行处理:

  • 如果是 UNITY_UI_CLIP_RECT 就使用 UnityGet2DClipping 函数判断当前点是否在遮罩区域 _ClipRect 内,如果在这个方法就返回 1,否则返回 0;所以最终输出颜色的 Alpha 也就跟当前像素是否在遮罩区域类有关,在遮罩区域内 Alpha 值保持原来的值输出,否则 Alpha 值为 0。这种方式用于 RectMask2D 遮罩使用,下面会分析到。

  • UNITY_UI_ALPHACLIP 这种方式就是我们分析的 Mask 遮罩实现的关键之一。Mask 组件所在的 MaskableGraphic 渲染时,如果某个像素纹理采样计算之后得到的 Alpha 值小于 0.001,那么在这里会使用 clip 函数 discard 这个像素,这也就是为什么之前分析的时候讲到可能会有部分像素所在的 Stencil buffer 值为初始值 0 的原因。这样也就实现了用纹理 Alpha 值来控制遮罩。

RectMask2D

RectMask2D 和 Mask 相似,其主要区别在于 RectMask2D 直接实现矩形遮罩效果(将子元素显示区域控制在一个矩形框内)。

RectMask2D 类继承自 UIBehaviour 类,同样它具有 Unity 完整生命周期回调,同时它实现了 ICanvasRaycastFilter 接口,所以也可作为 Unity Event System 中事件分发射线检测对象,还实现了 IClipper 接口用于更新裁剪相关信息。下面就来分析一下 RectMask2D 类的代码。

OnEnable 方法

还是从 OnEnable 方法开始,首先调用 ClipperRegistry 类 Register 方法将自己注册,这样在 Canvas 执行更新时,自身就能收到回调从而调用 PerformClipping 方法;紧接着调用 MaskUtilities 类的 Notify2DMaskStateChanged 方法通知子元素重新计算裁剪相关的参数信息。

比如一个 MaskableGeaphic 元素位于 RectMask2D 下(MaskableGeaphic 实现了 IClippable 接口),当调用 Notify2DMaskStateChanged 方法时 MaskableGeaphic 的 RecalculateClipping 方法会被回调,从而 MaskableGeaphic 的父 RectMask2D 元素会被更新(具体是 MaskableGeaphic 的 RecalculateClipping 方法又调用其自身的 UpdateClipParent 方法来寻找最合适的父 RectMask2D 元素,找到后调用之前父 RectMask2D 的 RemoveClippable 方法将自身移除,然后调用新父 RectMask2D 的 AddClippable 将自身添加到 RectMask2D 中)。

OnDisable 方法

这个方法首先清除相关数据,然后从 ClipperRegistry 中反注册自己,最后再次调用 MaskUtilities 类的 Notify2DMaskStateChanged 方法通知子元素重新计算裁剪相关的参数信息。

IsRaycastLocationValid 方法

这个方法和 Mask 中的相同,这里不再分析。

AddClippable 方法

将一个 IClippable 添加到自身的跟踪列表中,用于后续的裁剪计算等。

RemoveClippable 方法

从自身的 IClippable 跟踪列表中移除一个 IClippable,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public void RemoveClippable(IClippable clippable)
{
// other code ...
clippable.SetClipRect(new Rect(), false);
MaskableGraphic maskable = clippable as MaskableGraphic;
if (maskable == null)
m_ClipTargets.Remove(clippable);
else
m_MaskableTargets.Remove(maskable);
// other code ...
}

从代码中可以看出在移除之前,首先调用了 IClippable 的 SetClipRect 方法使得这个 IClippable 不再计算 Rect 裁剪。比如 MaskableGeaphic 的 SetClipRect 就调用了 canvasRenderer.DisableRectClipping() 来禁用裁剪。

PerformClipping 方法

当 Canvas 执行更新 ClipperRegistry 类的 Cull 方法在 Layout Rebuild 之后、在 Graphic Rebuild 之前被调用,从而 RectMask2D 的 PerformClipping 被回调。下面就来分析这个方法的执行过程:

首先如果 m_ShouldRecalculateClipRectstrue,那么执行如下代码计算当前 RectMask2D 所在元素的所有父 RectMask2D 元素(包括自身):

1
MaskUtilities.GetRectMasksForClip(this, m_Clippers);

接着调用 Clipping 类的 FindCullAndClipWorldRect 方法计算裁剪的矩形区域 clipRect。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static Rect FindCullAndClipWorldRect(List<RectMask2D> rectMaskParents, out bool validRect)
{
// no RectMask2D return empty Rect, validRect is 'false'
Rect current = rectMaskParents[0].canvasRect;
float xMin = current.xMin;
float xMax = current.xMax;
float yMin = current.yMin;
float yMax = current.yMax;
for (var i = 1; i < rectMaskParents.Count; ++i)
{
current = rectMaskParents[i].canvasRect;
if (xMin < current.xMin) xMin = current.xMin;
if (yMin < current.yMin) yMin = current.yMin;
if (xMax > current.xMax) xMax = current.xMax;
if (yMax > current.yMax) yMax = current.yMax;
}
validRect = xMax > xMin && yMax > yMin;
if (validRect)
return new Rect(xMin, yMin, xMax - xMin, yMax - yMin);
else
return new Rect();
}

上面的代码很简单,就是遍历上一步寻找到的所有的 RectMask2D 元素,然后通过调用 RectangularVertexClipper 的 GetCanvasRect 方法获取这个 RectMask2D 元素在 Canvas 空间下的 Rect,最终保留所有 RectMask2D 的重叠区域来作为矩形裁剪区域(如果是合法的) clipRect

接着判断上一步计算得到的重叠矩形裁剪区域 clipRect 是否和根 Canvas 所在矩形区域重叠,如果 Canvas Render Mode 是 RenderMode.ScreenSpaceXXclipRect 和根 Canvas 所在矩形区域不重叠,那么将不会渲染其内容。代码如下:

1
2
3
4
5
6
7
8
9
RenderMode renderMode = Canvas.rootCanvas.renderMode;
bool maskIsCulled = (renderMode == RenderMode.ScreenSpaceCamera || renderMode == RenderMode.ScreenSpaceOverlay) && !clipRect.Overlaps(rootCanvasRect, true);
if (maskIsCulled)
{
// set an invalid rect to allow calls to avoid some processing ...
clipRect = Rect.zero;
validRect = false;
}

最后就设置 IClippable 跟踪列表中子元素的裁剪区域。

  • 如果当前计算的 clipRect 和上次的裁剪矩形区域不相等,那么执行以下代码:
1
2
3
4
5
6
7
8
9
10
foreach (IClippable clipTarget in m_ClipTargets)
{
clipTarget.SetClipRect(clipRect, validRect);
}
foreach (MaskableGraphic maskableTarget in m_MaskableTargets)
{
maskableTarget.SetClipRect(clipRect, validRect);
maskableTarget.Cull(clipRect, validRect);
}

上面的代码中调用 IClippable 的 SetClipRect 方法设置 ClipRect。这里以 MaskableGraphic 为例,最终调用的代码是 canvasRenderer.EnableRectClipping(clipRect),也就是渲染这个 MaskableGraphic 的 CanvasRender 会启用 Rect Clip 并设置渲染 Shader 中的 _ClipRect 变量的值,这样当渲染执行的时候就能够实现矩形遮罩效果。

对于 MaskableGraphic 还有一步,就是调用其 Cull 方法更新 CanvasRender 的 cull 值。

  • 如果当前计算的 clipRect 和上次的裁剪矩形区域相等但是 m_ForceCliptrue,那么强制调用 SetClipRect 方法设置 ClipRect。这里有一点小不同是对于最后 MaskableGraphic 的 Cull 方法的调用,仅在当前 UI 元素有任何影响几何更新的变动时(CanvasRender 的 hasMoved 被标记为 true) Cull 方法才会被调用。

  • 最后如果上面两种情况都不满足,那么就只会对绑定了 MaskableGraphic 组件的子元素执行 Cull 方法更新 CanvasRender 的 cull 值(这个元素所在 CanvasRender 的 hasMoved 需标记为 true)。

至此 RectMask2D 实现遮罩的流程代码也就分析完了。

分析完 RectMask2D 的重要代码部分,可以看出其和 Mask 的不同之处在于 RectMask2D 并未使用模板测试来计算遮罩,因此相比之下 RectMask2D 会少一个 drawCall,因此相比使用 Mask 性能会好一些。但是 RectMask2D 实现遮罩效果被限制在一个矩形区域内,相比 Mask 遮罩就显得没有那么灵活,所以具体使用何种方式实现遮罩视项目需求而定。

参考