Unity3D UGUI 源码学习 MaskableGraphic

MaskableGraphic用于实现绘制相关的功能,并且相比于Graphic它支持被裁剪(clip)和被遮罩(mask)。是Graphic的衍生类,是ImageText等类的父类。

基类和实现的接口

衍生自Graphic,实现了IClippableIMaskableIMaterialModifier接口,逐一分析:

Graphic

承担基本的绘制相关的功能,如生成网格、使用材质等,并由Canvas控制绘制。相比Graphic,MaskableGraphic重写了一些方法,当被激活(active)或发生变化时,除了会将材质和结点的脏标(m_MaterialDirtym_VertsDirty)记置为true之外,还会将成员变量m_ShouldRecalculateStencil设为true,即需要重新计算模板,并且会根据情况在一定时机调用UpdateClipParent()更新用于对其实施裁剪的父结点信息。

IClippable

实现此接口的类可以被Clipper(如RectMask2D)裁减。此接口包括以下需要实现的方法:

  • RecalculateClipping() 当父级可裁剪对象发生变化时会调用,用于更新裁剪信息
  • SetClipRect() 设置可裁剪对象的裁剪区域
  • Cull() 对可裁剪的对象裁剪和剔除

IMaskable

实现此接口的类可以被Mask遮罩。此接口包含的需要实现的方法:

  • RecalculateMasking() 更新对当前对象和所有子结点对象的遮罩信息

IMaterialModifier

修改材质的接口,在被CanvasRenderer绘制之前,可以接受材质的修改。需要实现方法:

  • Material GetModifiedMaterial(Material baseMaterial) 传入修改前的材质,返回修改后的材质

用于裁剪的成员和方法

m_ParentMask

此成员是一个RectMask2D(实现了IClipper,是裁剪动作的实施者,后边会说这个类)。在调用UpdateClipParent()时(详见下个小标题),会调用静态的工具方法MaskUtilities.GetRectMaskForClippable 重新计算m_ParentMask,并且会将自身(IClippable)添加到实施裁减的父对象中(AddClippable),以接受其裁剪的动作。

UpdateClipParent()

获取并更新m_ParentMask

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private void UpdateClipParent()
{
var newParent = (maskable && IsActive()) ? MaskUtilities.GetRectMaskForClippable(this) : null;
// if the new parent is different OR is now inactive
if (m_ParentMask != null && (newParent != m_ParentMask || !newParent.IsActive()))
{
m_ParentMask.RemoveClippable(this);
UpdateCull(false);
}
// don't re-add it if the newparent is inactive
if (newParent != null && newParent.IsActive())
newParent.AddClippable(this);
m_ParentMask = newParent;
}

UpdateCull()

更新裁剪信息,告诉canvasRenderer是否要裁剪并且如果裁剪状态发生了变化会SetVerticesDirty()。代码如下:传入一个bool参数表示是否需要裁剪。在UpdateClipParent中如果判断出来不需要裁剪,会调用此方法并传入false,在Cull被调用时也会判断并调用此方法更新裁剪状态。

1
2
3
4
5
6
7
8
9
10
11
private void UpdateCull(bool cull)
{
var cullingChanged = canvasRenderer.cull != cull;
canvasRenderer.cull = cull;
if (cullingChanged)
{
m_OnCullStateChanged.Invoke(cull);
SetVerticesDirty();
}
}

裁剪相关的其它类

RectMask2D

继承UIBehaviour,实现了IClipper用于裁剪图像,实现了ICanvasRaycastFilter用于过滤UI的事件(这里先不做讨论)。主要的成员有:

  • m_ClipTargets 裁剪的目标,是一个IClippable的集合HashSet<IClippable>
  • m_Clippers 包括自身在内的所有的作用于该RectMask2D的裁剪者的列表,是一个List<RectMask2D>
  • m_ShouldRecalculateClipRects 是否需要重新计算裁剪矩形的状态值
  • m_ForceClip 强制执行裁剪。在执行裁剪时,即使当前裁剪矩形与旧的裁剪矩形相等也会为每个裁剪目标更新裁剪矩形。当增加或减少了裁减目标时,会将此值设为true

主要的方法有:

  • AddClippable(...) 用于向m_ClipTargets中增加要裁剪的对象

  • RemoveClippable(...) 移除要裁剪的目标对象

  • PerformClipping()执行裁减的动作:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    public virtual void PerformClipping()
    {
    // if the parents are changed
    // or something similar we
    // do a recalculate here
    if (m_ShouldRecalculateClipRects)
    {
    MaskUtilities.GetRectMasksForClip(this, m_Clippers);
    m_ShouldRecalculateClipRects = false;
    }
    // get the compound rects from
    // the clippers that are valid
    bool validRect = true;
    Rect clipRect = Clipping.FindCullAndClipWorldRect(m_Clippers, out validRect);
    if (clipRect != m_LastClipRectCanvasSpace || m_ForceClip)
    {
    foreach (IClippable clipTarget in m_ClipTargets)
    clipTarget.SetClipRect(clipRect, validRect);
    m_LastClipRectCanvasSpace = clipRect;
    m_LastValidClipRect = validRect;
    }
    foreach (IClippable clipTarget in m_ClipTargets)
    clipTarget.Cull(m_LastClipRectCanvasSpace, m_LastValidClipRect);
    }

    m_ClipTargets中的各个裁剪目标对象,设置裁剪矩形,并执行Cull方法。

MaskUtilities

裁剪和遮罩相关的工具类,包含了一系列的静态方法,其中部分与裁剪相关的方法:

  • IsDescendantOrSelf 判断传入的两个Transform对象,是否后者是前者的子节点(后代结点)或者两个节点是同一个节点
  • Notify2DMaskStateChanged 告知指定组件之下的所有IClippable需要重新计算裁剪,即调用其RecalculateClipping()方法
  • GetRectMaskForClippable 获取传入的IClippable所对应的RectMask2D对象,首先会获取所有的父节点上的RectMask2D对象,然后根据一些条件进行判断,直到获取到正确的RectMask2D对象并将其返回
  • GetRectMasksForClip 获取对传入的RectMask2D应用裁剪的所有RectMask2D对象(包括其自身),存入RectMask2Dm_Clippers中,与GetRectMaskForClippable类似,也是首先获取所有父节点上的RectMask2D然后再逐个判断筛选

Clipping

  • FindCullAndClipWorldRect 获取用于裁剪的矩形,并通过一个out参数指明当前裁剪是否有效,当相交区域不存在时该值为false
  • RectIntersect 私有方法,得到两个矩形相交部分的矩形

用于遮罩的成员和方法

m_ShouldRecalculateStencil

标记是否需要重新计算模板值(m_StencilValue)。当被标记为true时,会在GetModifiedMaterial时重新计算模板值:

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

这里涉及到MaskUtilities中的这两个静态的工具方法:

  • FindRootSortOverrideCanvas 获取顶层Canvas或overrideSorting的Canvas
  • GetStencilDepth 获取传入的元素的模板深度值

m_StencilValue

一个整数成员变量,字面意思是模板值,前边说到了更新计算它的方法,即MaskUtilities.GetStencilDepth(...),其实是计算了从该结点到其对应的顶部Canvas或overrideSorting的Canvas之间处于激活状态的Mask的数量,就是m_StencilValue的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public static int GetStencilDepth(Transform transform, Transform stopAfter)
{
var depth = 0;
if (transform == stopAfter)
return depth;
var t = transform.parent;
var components = ListPool<Mask>.Get();
while (t != null)
{
t.GetComponents<Mask>(components);
for (var i = 0; i < components.Count; ++i)
{
if (components[i] != null && components[i].MaskEnabled() && components[i].graphic.IsActive())
{
++depth;
break;
}
}
if (t == stopAfter)
break;
t = t.parent;
}
ListPool<Mask>.Release(components);
return depth;
}

使用m_StencilValue的地方是要根据一个传入的材质生成新的材质:

1
2
3
// ...
var maskMat = StencilMaterial.Add(toUse, (1 << m_StencilValue) - 1, StencilOp.Keep, CompareFunction.Equal, ColorWriteMask.All, (1 << m_StencilValue) - 1, 0);
// ...

上边这个是GetModifiedMaterial()中的部分代码,也就是获取修改后的材质,调用的是下边这个方法,可以对照一下每个参数都是干什么的:

1
public static Material Add(Material baseMat, int stencilID, StencilOp operation, CompareFunction compareFunction, ColorWriteMask colorWriteMask, int readMask, int writeMask){...}

StencilMaterial.Add(...)方法向传入的材质添加了一些模板信息(即后边的一长串参数),返回设置好了模板参数的新的材质(StencilMaterial内部做了一些优化,对于相同的模板参数会共用同一个材质对象)这个m_StencilValue通过一个简单的变换1 << m_StencilValue) - 1得到一个新的整数值,这个值在后续的使用中称为stencilID,在StencilMaterial.Add(...)中我们看到有以下代码:

1
2
3
4
5
6
7
8
// ...
newEnt.customMat.SetInt("_Stencil", stencilID);
newEnt.customMat.SetInt("_StencilOp", (int)operation);
newEnt.customMat.SetInt("_StencilComp", (int)compareFunction);
newEnt.customMat.SetInt("_StencilReadMask", readMask);
newEnt.customMat.SetInt("_StencilWriteMask", writeMask);
newEnt.customMat.SetInt("_ColorMask", (int)colorWriteMask);
// ...

对应的,在UI默认的shader中有:

1
2
3
4
5
6
7
8
9
10
11
12
13
// ...
Stencil
{
Ref [_Stencil]
Comp [_StencilComp]
Pass [_StencilOp]
ReadMask [_StencilReadMask]
WriteMask [_StencilWriteMask]
}
ColorMask [_ColorMask]
// ...

m_MaskMaterial

保存了一个修改后的材质的引用,在生成新的材质时可以方便地将旧的材质移除,详见下一个标题。

GetModifiedMaterial()

实现IMaterialModifier接口的方法,在绘制之前对修改材质:

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 virtual Material GetModifiedMaterial(Material baseMaterial)
{
var toUse = baseMaterial;
if (m_ShouldRecalculateStencil)
{
var rootCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
m_StencilValue = maskable ? MaskUtilities.GetStencilDepth(transform, rootCanvas) : 0;
m_ShouldRecalculateStencil = false;
}
// if we have a enabled Mask component then it will
// generate the mask material. This is an optimisation
// it adds some coupling between components though :(
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;
}
return toUse;
}

遮罩的实现还涉及到一些其它的类。被遮罩的图像如何绘制,除了之前修改的stencil参数之外,还要取决于模板缓冲区中的值。刚才的代码中可以看到,MaskableGraphic对模板值的写入操作是keep,那我们来看Mask

Mask

Mask也实现了IMaterialModifier接口,直击要害,我们来看GetModifiedMaterial

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/// Stencil calculation time!
public virtual Material GetModifiedMaterial(Material baseMaterial)
{
if (!MaskEnabled())
return baseMaterial;
var rootSortCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
var stencilDepth = MaskUtilities.GetStencilDepth(transform, rootSortCanvas);
if (stencilDepth >= 8)
{
Debug.LogError("Attempting to use a stencil mask with depth > 8", gameObject);
return baseMaterial;
}
int desiredStencilBit = 1 << stencilDepth;
// if we are at the first level...
// we want to destroy what is there
if (desiredStencilBit == 1)
{
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;
}
//otherwise we need to be a bit smarter and set some read / write masks
var maskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit | (desiredStencilBit - 1), StencilOp.Replace, CompareFunction.Equal, m_ShowMaskGraphic ? ColorWriteMask.All : 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
StencilMaterial.Remove(m_MaskMaterial);
m_MaskMaterial = maskMaterial2;
graphic.canvasRenderer.hasPopInstruction = true;
var unmaskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit - 1, StencilOp.Replace, CompareFunction.Equal, 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
StencilMaterial.Remove(m_UnmaskMaterial);
m_UnmaskMaterial = unmaskMaterial2;
graphic.canvasRenderer.popMaterialCount = 1;
graphic.canvasRenderer.SetPopMaterial(m_UnmaskMaterial, 0);
return m_MaskMaterial;
}

这里进行的操作与MaskableGraphic很相似,获取stencilDepth(不同之处在于Mask如果有超过8层的Mask遮罩会抛出错误并返回原始的材质)。在获取到深度之后有:

1
int desiredStencilBit = 1 << stencilDepth;

接下来处理遮罩材质和非遮罩材质,会产生两种材质,Mask自身使用maskMaterial,而非遮罩的材质会交给canvasRenderer

1
2
3
4
5
// ...
var maskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit | (desiredStencilBit - 1), StencilOp.Replace, CompareFunction.Equal, m_ShowMaskGraphic ? ColorWriteMask.All : 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
// ...
var unmaskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit - 1, StencilOp.Replace, CompareFunction.Equal, 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
// ...

再对照一下:

1
public static Material Add(Material baseMat, int stencilID, StencilOp operation, CompareFunction compareFunction, ColorWriteMask colorWriteMask, int readMask, int writeMask){...}

Mask用于遮罩的材质的模板值计算方法为desiredStencilBit | (desiredStencilBit - 1),这意味着模板深度为x的MaskableGraphic,与模板深度为x-1的Mask,有相同的模板值。绘制Mask时会将模板值以replace的操作形式写入模板缓冲区,从而实现被遮罩图像的绘制。

REFERENCE

https://bitbucket.org/Unity-Technologies/ui

https://docs.unity3d.com/ScriptReference/UI.MaskableGraphic.html

https://blog.csdn.net/ecidevilin/article/details/52555253