前言

上一篇 中我们提到了:Graphic 在渲染流程中分别使用到了 IMeshModifierIMaterialModifier 接口来实现对于网格以及材质的修改,本节我们就来分析一下 UGUI 中实现了这些接口的组件分别都起到了什么作用。

IMeshModifier

在 UGUI 中,实现了 IMeshModifier 接口的组件间的关系如下图所示:

IMeshModifier.png

Graphic 中的使用

首先来看看 IMeshModifierGraphic 中是如何使用的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void DoMeshGeneration()
{
if (rectTransform != null && rectTransform.rect.width >= 0 && rectTransform.rect.height >= 0)
OnPopulateMesh(s_VertexHelper);
else
s_VertexHelper.Clear(); // clear the vertex helper so invalid graphics dont draw.

var components = ListPool<Component>.Get();
GetComponents(typeof(IMeshModifier), components);

for (var i = 0; i < components.Count; i++)
((IMeshModifier)components[i]).ModifyMesh(s_VertexHelper);

ListPool<Component>.Release(components);

s_VertexHelper.FillMesh(workerMesh);
canvasRenderer.SetMesh(workerMesh);
}

DoMeshGeneration 方法中(即 UpdateGeometry 阶段), 会遍历当前物体上挂载的所有 IMeshModifier 组件,并调用其 ModifyMesh 方法来修改 s_VertexHelper,由于 s_VertexHelper 是引用传递,因此所有的修改都会累计到 s_VertexHelper 中。

BaseMeshEffect

BaseMeshEffect 是实现了 IMeshModifier 接口的基类,其余的 IMeshModifier 组件都是继承自该类,查看该类的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public abstract class BaseMeshEffect : UIBehaviour, IMeshModifier
{
[NonSerialized]
private Graphic m_Graphic;

/// <summary>
/// The graphic component that the Mesh Effect will aplly to.
/// </summary>
protected Graphic graphic
{
get
{
if (m_Graphic == null)
m_Graphic = GetComponent<Graphic>();

return m_Graphic;
}
}

// ...

public abstract void ModifyMesh(VertexHelper vh);
}

可以看出,该类也仅仅是一个抽象类,并没有实现 ModifyMesh 方法。但在该类中维护了一个 Graphic 类型的变量 m_Graphic,代表着目前的 BaseMeshEffect 所作用的 Graphic 组件。在 get 方法中也可以看到,会使用 GetComponent<Graphic>() 来获取到当前 BaseMeshEffect 所作用的 Graphic 对象。

Shadow

Shadow 组件用于为 Graphic 组件添加阴影效果,该组件继承自 BaseMeshEffect,并实现了 ModifyMesh 方法:

image.png

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
public class Shadow : BaseMeshEffect
{
[SerializeField]
private Color m_EffectColor = new Color(0f, 0f, 0f, 0.5f);

[SerializeField]
private Vector2 m_EffectDistance = new Vector2(1f, -1f);

[SerializeField]
private bool m_UseGraphicAlpha = true;

private const float kMaxEffectDistance = 600f;

public Color effectColor
{
get { return m_EffectColor; }
set
{
m_EffectColor = value;
if (graphic != null)
graphic.SetVerticesDirty();
}
}

public Vector2 effectDistance
{
get { return m_EffectDistance; }
set
{
if (value.x > kMaxEffectDistance)
value.x = kMaxEffectDistance;
if (value.x < -kMaxEffectDistance)
value.x = -kMaxEffectDistance;

if (value.y > kMaxEffectDistance)
value.y = kMaxEffectDistance;
if (value.y < -kMaxEffectDistance)
value.y = -kMaxEffectDistance;

if (m_EffectDistance == value)
return;

m_EffectDistance = value;

if (graphic != null)
graphic.SetVerticesDirty();
}
}

public bool useGraphicAlpha
{
get { return m_UseGraphicAlpha; }
set
{
m_UseGraphicAlpha = value;
if (graphic != null)
graphic.SetVerticesDirty();
}
}

// ...
}

首先,Shadow 组件中定义了三个变量用于控制阴影的效果,分别是 m_EffectColor(阴影颜色)、m_EffectDistance(阴影偏移)和 m_UseGraphicAlpha(是否使用 Graphic 的透明度)。在 ModifyMesh 方法中,会根据这三个变量来修改 VertexHelper 中的顶点信息,从而实现阴影效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
protected void ApplyShadowZeroAlloc(List<UIVertex> verts, Color32 color, int start, int end, float x, float y)
{
UIVertex vt;

var neededCapacity = verts.Count + end - start;
if (verts.Capacity < neededCapacity)
verts.Capacity = neededCapacity;

for (int i = start; i < end; ++i)
{
vt = verts[i];
verts.Add(vt);

Vector3 v = vt.position;
v.x += x;
v.y += y;
vt.position = v;
var newColor = color;
if (m_UseGraphicAlpha)
newColor.a = (byte)((newColor.a * verts[i].color.a) / 255);
vt.color = newColor;
verts[i] = vt;
}
}

ApplyShadowZeroAlloc 方法是实现阴影的主要方法,从逻辑中可以看出:阴影实际上的实现方式就是在指定的位置上多渲染了一倍的顶点,并且将颜色进行了调整。这样就实现了阴影的效果

此外,仔细研究我们会发现一个有趣的事实:新添加的顶点实际上占据了原始顶点的位置,而原始的顶点则被放在了顶点队列的最后。这是为什么呢?答案就是渲染的先后顺序问题,如果我们将新增加的阴影顶点放在队列末尾,那么在渲染时,阴影就会覆盖掉原始的图片,这显然不是我们想要的效果。因此,我们需要将阴影顶点放在原始顶点的前面,这样就能保证阴影在原始图片的下方。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
protected void ApplyShadow(List<UIVertex> verts, Color32 color, int start, int end, float x, float y)
{
ApplyShadowZeroAlloc(verts, color, start, end, x, y);
}

public override void ModifyMesh(VertexHelper vh)
{
if (!IsActive())
return;

var output = ListPool<UIVertex>.Get();
vh.GetUIVertexStream(output);

ApplyShadow(output, effectColor, 0, output.Count, effectDistance.x, effectDistance.y);
vh.Clear();
vh.AddUIVertexTriangleStream(output);
ListPool<UIVertex>.Release(output);
}

ModifyMesh 方法中,会调用 ApplyShadow 方法来实现阴影的效果,最后将修改后的顶点信息重新填充到 VertexHelper 中。

添加阴影后的效果如下:

image.png

Outline

Outline 组件用于为 Graphic 组件添加描边效果,该组件继承自 Shadow,并且也实现了 ModifyMesh 方法:

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
public class Outline : Shadow
{
protected Outline()
{}

public override void ModifyMesh(VertexHelper vh)
{
if (!IsActive())
return;

var verts = ListPool<UIVertex>.Get();
vh.GetUIVertexStream(verts);

var neededCpacity = verts.Count * 5;
if (verts.Capacity < neededCpacity)
verts.Capacity = neededCpacity;

var start = 0;
var end = verts.Count;
ApplyShadowZeroAlloc(verts, effectColor, start, verts.Count, effectDistance.x, effectDistance.y);

start = end;
end = verts.Count;
ApplyShadowZeroAlloc(verts, effectColor, start, verts.Count, effectDistance.x, -effectDistance.y);

start = end;
end = verts.Count;
ApplyShadowZeroAlloc(verts, effectColor, start, verts.Count, -effectDistance.x, effectDistance.y);

start = end;
end = verts.Count;
ApplyShadowZeroAlloc(verts, effectColor, start, verts.Count, -effectDistance.x, -effectDistance.y);

vh.Clear();
vh.AddUIVertexTriangleStream(verts);
ListPool<UIVertex>.Release(verts);
}
}

描边的实现原理与阴影类似,分别在四个方向上共添加了四倍的顶点,从而实现了描边的效果。这里描边的处理也与阴影相同,由于都调用了 ApplyShadowZeroAlloc 方法,因此描边的顶点也会被放在原始顶点的前面,从而不会覆盖原始图片。

添加描边后的效果如下:

image.png

可以看到,由于这里使用的 Alpha 值不为 1,因此描边有一些露馅,但也证明了描边确实是通过向四个方向移动而实现的。

PositionAsUV1

PositionAsUV1 组件用于将顶点的位置信息映射到 UV1 通道上。老实说,笔者并不知道这个组件有什么用,可能在渲染时会用到吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class PositionAsUV1 : BaseMeshEffect
{
protected PositionAsUV1()
{}

public override void ModifyMesh(VertexHelper vh)
{
UIVertex vert = new UIVertex();
for (int i = 0; i < vh.currentVertCount; i++)
{
vh.PopulateUIVertex(ref vert, i);
vert.uv1 = new Vector2(vert.position.x, vert.position.y);
vh.SetUIVertex(vert, i);
}
}
}

实现的逻辑也比较简单,就是遍历所有的顶点,将顶点的位置信息映射到 UV1 通道上。

小结

IMeshModifier 接口的实现类主要是用于修改 VertexHelper 中的顶点信息,从而实现一些特殊的效果,如阴影、描边等。这主要是通过向 VertexHelper 中添加额外的顶点来实现的,其中阴影是向指定方向添加一倍的顶点,而描边则是向四个方向添加四倍的顶点。

IMeshModifier.png

IMaterialModifier

不同于 IMeshModifier 的清晰结构与简单功能。IMaterialModifier 与 UGUI 中一个非常重要的内容强耦合:遮罩(Mask)。遮罩用于限制子元素的可见区域,使得我们可以实现一些特殊的效果,如圆形头像、进度条等。

在讨论 UGUI 如何实现遮罩之前我们可以思考一下:在渲染管线中,如何实现遮罩功能?一种最直观的想法就是利用 “测试与混合(Test & Blending)” 阶段了。渲染管线的内容可以参考 Rendering Pipeline

测试阶段按照测试的先后顺序包括了裁剪测试、Alpha 测试、模板测试以及深度测试。在测试阶段,所有未经过测试的片段(Fragment)都会被丢弃,而通过测试的片段才会成为最终的像素(Pixel)。利用这一特性,我们可以在测试阶段对片段进行一些特殊的处理,从而实现遮罩的效果。

UGUI 中也正是这样处理的。

在 UGUI 中,遮罩主要有两种实现方式:MaskRectMask2D,两种组件均可完成遮罩的效果,但实现遮罩的原理不同。Mask 组件作用在模板测试阶段,通过修改材质而间接地修改 Stencil Buffer 中的值来实现遮罩效果;而 RectMask2D 组件作用在裁剪测试阶段,通过设置 CanvasRenderer 的属性来将超出遮罩区域的片段直接丢弃。

IMaterialModifier 与这些组件的关系如下图所示,有一些本文不会分析到的组件仅做简单表示:

IMaterialModifier.png

可以看出,以 IMaterialModifier 为起点,延伸出了 Mask 这种通过修改材质(实际是利用 Stencil Buffer)来实现遮罩效果的组件。其次,以 IClippable 为起点,也延伸出了 RectMask2D 这种通过设置 CanvasRenderer 的裁剪功能而实现遮罩效果的组件。

同时,作为 Image 以及 RawImage 的基类,MaskableGraphic 同时实现了两种遮罩接口。因此对于 Image 这种组件来说,既可以通过 Mask 来实现遮罩效果,也可以通过 RectMask2D 来实现遮罩效果。

本节主要分析 Mask 的实现,其他的遮罩组件会在下一篇文章中进行分析。

Stencil Buffer

在介绍 Mask 的实现之前,我们需要先了解一下 Stencil Buffer

模版缓冲区(stencil buffer),是在 OpenGL 三维绘图等计算机图像硬件中常见的除颜色缓冲区(color buffer或frame buffer)、深度缓冲区(depth buffer 或 z buffer )、累积缓冲区(accumulation buffer)之外另一种数据缓冲。模版缓冲区通常是每像素 8 位。该值可以写入、递增或递减。后续绘制调用可以根据该值进行测试,以确定在运行像素着色器之前是否应丢弃像素。

通常会将模板缓冲区用于特殊效果,例如 UI Mask、门户或镜子。此外,在渲染硬阴影或者构造型实体几何 (CSG: constructive solid geometry) 时,有时会使用模板缓冲区。更详细的介绍可以参考 ShaderLab 命令:模板

在 Unity 的渲染管线中,模板测试阶段有多种参数可以配置,以下列举一些常用的参数:

1
2
3
4
5
6
7
8
Stencil {
Ref <ref>
ReadMask <readMask>
WriteMask <writeMask>
Comp <comparisonOperation>
Pass <passOperation>
// ...
}
  • ref:整数;范围为 0 到 255;默认值为 0

    用于设置模板测试的参考值。GPU 使用在 compareOperation 中定义的操作将模板缓冲区的当前内容与此值进行比较。此值使用 readMaskwriteMask 进行遮罩,具体取决于进行的是读取操作还是写入操作。如果 Pass、Fail 或 ZFail 的值为 Replace,则 GPU 也可以将此值写入模板缓冲区。

    总之就是一个八位的数字,代表着将要写入到模板缓冲区的值。

  • readMask:整数;范围为 0 到 255;默认值为 255

    用于设置模板缓冲区的读取掩码。GPU 会将此值与 Ref 进行按位与操作,以确定要读取的位。如果 ReadMask 的某个位为 0,则不会读取该位。默认情况下,所有位都会读取。

  • writeMask:整数;范围为 0 到 255;默认值为 255

    用于设置模板缓冲区的写入掩码。GPU 会将此值与 Ref 进行按位与操作,以确定要写入缓冲区的位。如果 WriteMask 的某个位为 0,则不会写入该位。默认情况下,所有位都会写入。

  • comparisonOperation:枚举;默认值为 Always

    用于设置模板测试的比较操作。GPU 会使用此操作将 Ref 与模板缓冲区的当前值进行比较。如果比较成功,则会执行 Pass 操作。如果比较失败,则会执行 Fail 操作。在 Unity 中,对于该枚举类型的定义如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    public enum CompareFunction
    {
    Disabled,

    Never,

    Less,

    Equal,

    LessEqual,

    Greater,

    NotEqual,

    GreaterEqual,

    Always
    }

    有等于、小于、大于等比较操作,默认情况下是 Always,即总是通过模板测试。如果设置为 Disabled,则会禁用模板测试。

  • passOperation:枚举;默认值为 Keep

    用于设置模板测试通过时的操作。在 Unity 中,对于该枚举类型的定义如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public enum StencilOp
    {
    Keep,

    Zero,

    Replace,

    IncrementSaturate,

    DecrementSaturate,

    Invert,

    IncrementWrap,

    DecrementWrap
    }

    有保持、替换、增加、减少等操作。默认情况下是 Keep,即保持原有的值。如果设置为 Replace,则会将 Ref 的值写入到模板缓冲区(需要结合 WriteMask 使用)。

我们可以打开一个默认的 UI 材质文件,来看看 Unity 是如何配置模板测试的:

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
// Compiled-UI-Default.shader

Shader "UI/Default" {
Properties {
[PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" { }
_Color ("Tint", Color) = (1.000000,1.000000,1.000000,1.000000)
_StencilComp ("Stencil Comparison", Float) = 8.000000
_Stencil ("Stencil ID", Float) = 0.000000
_StencilOp ("Stencil Operation", Float) = 0.000000
_StencilWriteMask ("Stencil Write Mask", Float) = 255.000000
_StencilReadMask ("Stencil Read Mask", Float) = 255.000000
_ColorMask ("Color Mask", Float) = 15.000000
[Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip ("Use Alpha Clip", Float) = 0.000000
}
SubShader {
Tags { "QUEUE"="Transparent" "IGNOREPROJECTOR"="true" "RenderType"="Transparent" "CanUseSpriteAtlas"="true" "PreviewType"="Plane" }


// Stats for Vertex shader:
// d3d11: 19 math
// Stats for Fragment shader:
// d3d11: 9 avg math (6..13), 1 texture
Pass {
Name "Default"
Tags { "QUEUE"="Transparent" "IGNOREPROJECTOR"="true" "RenderType"="Transparent" "CanUseSpriteAtlas"="true" "PreviewType"="Plane" }
ZTest [unity_GUIZTestMode]
ZWrite Off
Cull Off
// 模板测试
Stencil {
Ref [_Stencil]
ReadMask [_StencilReadMask]
WriteMask [_StencilWriteMask]
Comp [_StencilComp]
Pass [_StencilOp]
}
Blend One OneMinusSrcAlpha
ColorMask [_ColorMask]

// ...
}
}
}

可以看出,Unity 在材质中设置了与模板测试相关的参数,并在 Stencil 块中进行了配置。可以看出:材质的 Stencil ID 参数即为 Ref 值,而 Stencil Operation 参数代表通过了模板测试后的操作

此外,还有一个不属于 Stencil 块的参数 Color Mask,该参数用于设置颜色掩码,用于控制颜色的写入。默认情况下,颜色掩码为 15,即 RGBA 都会写入。如果设置为 0,则不会写入任何颜色。这一参数也会在 Mask 组件中用到。

Mask 的使用

为什么这里要先说 Mask 的使用呢,原因是如果对于 UGUI 设置 Stencil Buffer 的过程没有直观的了解,很难理解 Mask 的代码究竟在干什么。因此,这里用一个涵盖了各种参数的例子来说明 Stencil Buffer 中各个参数的作用,这样在后续的分析中就能更好地理解 Mask 的实现。

我们先在场景中创建一个 Image 并添加一个 Mask 组件,为了方便区分,将 Image 的颜色设置为蓝色:

image.png

查看该组件的 Shader 属性:

image.png

可以看到,Unity 为 Mask 组件设置了一些默认的 Stencil Buffer 参数,其中 Stencil ID1Stencil Comp8(即 Always),Stencil Op2(即 Replace),Stencil Write Mask255Stencil Read Mask255

所表达的意思即为:该 Image 总是通过模板测试,通过后会将 Stencil ID 的值 1 写入到模板缓冲区中。

结合模板缓冲区默认为 0,则此时的模板缓冲区如下:

Mask.png

我们接着为蓝色图片添加一个子物体,并设置其颜色为红色:

image.png

可以看到,超出蓝色图片的部分的确被遮罩了,这便是 Mask 在起作用。同样查看该红色图片的 Shader 属性:

image.png

可以看出,该红色图片的 Stencil ID1Stencil Comp3(即 Equal),Stencil Op0(即 Keep),Stencil Write Mask0Stencil Read Mask1

这就比较有意思了。结合前两条信息以及 ReadMask 我们会发现,在这种设置下,红色图片将只能在 Stencil Buffer 中的值为 1 的地方通过模板测试ReadMask1保证了子物体读取到的恰好是父物体写入的缓冲区位数,这样就实现了遮罩的效果。
并且由于 KeepWrite Mask 的设置,红色图片并不会对模板缓冲区进行写入操作。因此,单独的 Image 组件并不会修改模板缓冲区的值

那如果我们给红色图片也添加一个 Mask 组件呢?

添加了 Mask 组件后,红色图片的 Shader 属性发生了一些微妙的变化:

image.png

首先,Stencil Comp3(即 Equal),而 StencilID && ReadMask == 1,保证了红色图片依然只会在 Stencil Buffer 中的值为 1 的地方通过模板测试;其次,Stencil Operation2(即 Replace),StencilID && WriteMask == 3,这表明红色图片会将 3 写入到通过模板测试的位置中。

此时的模板缓冲区如下:

Mask.png

如果我们再添加一个绿色的子物体呢?

image.png

想必大家也能够猜到绿色图片的 Shader 属性了:

image.png

同样地,也只是在 Stencil Buffer 中的值为 3 的地方通过模板测试,而不对 Stencil Buffer 进行写入操作。

同样为绿色图片添加一个 Mask 组件,其 Shader 属性如下:

image.png

不出意料地,通过各种参数的相互计算,该图片会将 7 写入到原本为 3 的通过模板测试的位置中。此时的模板缓冲区如下:

Mask.png

到这里,我们就能基本理解 Mask 实现遮罩的过程了:将子物体通过模板测试的平面范围利用 StencilIDReadMaskWriteMask 等一系列参数限制在父物体的范围内,从而实现遮罩的效果。在有多级 Mask 的情况下,StencilID 的值会以 2n12^n - 1 的形式递增(从 1 开始计算),这样的设置是为了方便 ReadMaskWriteMask 发挥作用。

对于 Mask 的进一步探究

虽然通过上述的例子我们已经能够理解 Mask 的实现原理,但默认的 Shader 参数并不能修改,如何 DIY 出相同的效果呢?

由于 Mask 是通过修改材质属性中与模板测试相关的参数来实现的,因此我们可以通过生成具有不同参数的材质来实现相同的效果。我们新建一个材质并将 Shader 设置为 UI/Default,然后就会惊喜地发现,Stencil 的相关参数居然可以自由设置了!

image.png

我们在场景中新添加一个图片,并将其材质设置为我们刚刚生成的材质:

image.png

再如法炮制地添加一个子物体并创建新的材质,就会得到与 Mask 相同的效果。

下面就说一下通过这种方法笔者发现地一些有趣的现象:

  1. ReadMask 的真实作用

    由于渲染管线这一部分依然不是开源的,因此 Unity 内部对于 Stencil Buffer 的处理我们无法得知。但通过上述的操作我们可以发现,ReadMask 的作用并不是简单地与 StencilID 进行按位与操作以得到真实的测试值,而是会将模板缓冲区的值也和 ReadMask 进行按位与操作,即最终是 StencilID && ReadMaskStencilBuffer && ReadMask 进行比较。举例说明:

    将蓝色图片(父物体)的参数设置为如下:

    image.png

    红色图片(子物体)的参数设置为如下:

    image.png

    按照上述的分析,子物体的 StencilID && ReadMask == 110 && 001 == 0,因此红色图片应当只在模板缓冲区为 0 的地方才会渲染,而真实情况则为:

    image.png

    可以看到,在模板缓冲区为 2 的地方也会渲染红色图片,因此合理的猜测是 StencilBuffer && ReadMask == 010 && 001 == 0,所以这一部分也通过了模板测试。

    想要进一步验证可以将蓝色图片(父物体)的 StencilID 设置为 3,此时 StencilBuffer && ReadMask == 011 && 001 == 1,不能通过模板测试,因此红色图片只会在模板缓冲区为 0 的背景区域渲染。

    image.png

  2. ColorMask 的作用

    上文提到了在 UI 的默认材质中有一个 ColorMask 参数,用于控制颜色的写入。实际上 ColorWriteMask 也是一个枚举类,定义如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public enum ColorWriteMask
    {
    Alpha = 1,

    Blue = 2,

    Green = 4,

    Red = 8,

    All = 0xF
    }

    按照四位二进制数的顺序,那么从高到低位刚好分别代表 RGBA 四个通道。比如我们将 ColorMask 设置为 10,同时父物体颜色设置为白色。这样就只有 BR 通道会被渲染,从而呈现出紫色的效果。

    image.png

    同样地,如果我们将 ColorMask 设置为 0,则不会渲染任何颜色。

Mask 的实现

在理解了 Stencil Buffer 的机制以及 Mask 的使用后,就该来看看 Mask 的实现了。

成员变量

这里挑几个重要的成员变量来看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[SerializeField]
private bool m_ShowMaskGraphic = true;

/// <summary>
/// Show the graphic that is associated with the Mask render area.
/// </summary>
public bool showMaskGraphic
{
get { return m_ShowMaskGraphic; }
set
{
if (m_ShowMaskGraphic == value)
return;

m_ShowMaskGraphic = value;
if (graphic != null)
graphic.SetMaterialDirty();
}
}

showMaskGraphic 用于控制是否显示遮罩的图形,如果设置为 false,则不会显示遮罩本身(即父物体),只会显示子物体。这里也可以看到,在设置 showMaskGraphic 时会触发 graphic.SetMaterialDirty(),用于重新渲染图片。

1
2
3
4
5
6
7
8
9
10
[NonSerialized]
private Graphic m_Graphic;

/// <summary>
/// The graphic associated with the Mask.
/// </summary>
public Graphic graphic
{
get { return m_Graphic ?? (m_Graphic = GetComponent<Graphic>()); }
}

graphic 就是 Mask 组件的操作对象了,Mask 组件就是通过改变 graphic 材质的方式来实现遮罩的效果。因此这里需要注意:Mask 组件是依赖于 Graphic 组件的。这一点和后续会介绍的 RectMask2D 不同。

1
2
3
4
5
[NonSerialized]
private Material m_MaskMaterial;

[NonSerialized]
private Material m_UnmaskMaterial;

这两个变量维护的便是实现 Mask 效果的材质了。是的,Mask 组件不仅需要负责遮罩,还需要负责取消遮罩。这样才能在 Mask 被禁用时恢复原来的效果。

GetModifiedMaterial

在对于 Graphic 的分析中我们了解到,Graphic 组件会调用 IMaterialModifier.GetModifiedMaterial() 方法来获取修改后的材质。因此 Mask 生效的地方也一定在这个函数中,查看该函数的实现:

1
2
3
4
5
6
7
8
9
10
public virtual Material GetModifiedMaterial(Material baseMaterial)
{
if (!MaskEnabled())
return baseMaterial;

var rootSortCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
var stencilDepth = MaskUtilities.GetStencilDepth(transform, rootSortCanvas);

// ...
}

首先还是使用到了 MaskUtilities 辅助类中的两个方法,来获取当前 Mask 的深度,这里的深度指的正是上述例子中 Mask 的层级。

来看一下这两个函数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static Transform FindRootSortOverrideCanvas(Transform start)
{
var canvasList = ListPool<Canvas>.Get();
start.GetComponentsInParent(false, canvasList);
Canvas canvas = null;

for (int i = 0; i < canvasList.Count; ++i)
{
canvas = canvasList[i];

// We found the canvas we want to use break
if (canvas.overrideSorting)
break;
}
ListPool<Canvas>.Release(canvasList);

return canvas != null ? canvas.transform : null;
}

FindRootSortOverrideCanvas 会找到当前 Mask 所属的最顶层 Canvas 或第一个开启了 overrideSortingCanvas(该 Canvas 独立于其他 Canvas 的渲染顺序)。

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;
}

GetStencilDepth 会计算当前 Mask 的深度,即当前 MaskrootSortCanvas 之间有多少个其他的 Mask,从上面的例子中我们也可以看出,Mask 会根据深度的不同来设置不同的 StencilID,这样就能实现多级 Mask 的效果。

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

int desiredStencilBit = 1 << stencilDepth;

这里限制了 Mask 的深度不得超过 8这是因为 Stencil Buffer 一般只有 8,并且 UGUI 自带的 2n12^n - 1 的设置方式也限制了 Mask 最多叠加 8 层。 desiredStencilBit 便是 2(n1)2^{(n - 1)} 的值(程序中从 0 开始计数),再经过一点简单的位运算即可得到真正的 StencilID

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;
}

对于第一层的情况,Mask 会将 StencilID 设置为 1,并且始终通过模板测试。这里使用了 StencilMaterial 辅助类来生成指定模板配置的材质。这个类的实现这里就不再赘述了,只需要知道能够生成指定模板配置的材质即可。

此外,也可以看出 m_ShowMaskGraphic 实际上是通过 ColorWriteMask 来控制遮罩的显隐。

需要注意的是,除了 m_MaskMaterial 外,MaskGetModifiedMaterial() 中还生成了一个 m_UnmaskMaterial,这个材质用于 Shader 的第二个 Pass 中,作用是将 Stencil Buffer 中的值恢复为修改之前。在此处是将 Stencil Buffer 中的值设置为 0,而在多层级的情况下则会将 Stencil Buffer 中的值设置为上一层的 Mask 所设置的值。详见 UGUI 番外之合批

1
2
3
4
5
6
7
8
9
10
11
12
13
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);

对于多层的情况就稍微复杂一些,由上面的例子我们知道:对于某一层 mask,如 StencilID7,那么在进行模板测试时需要与父物体范围内的 StencilBuffer 值进行比较(此时为 3)。因此需要设置 readMask3,以便在该掩码的作用下得到 StencilID && readMask == StecilBuffer && readMask == 3 的效果。同时,在写入时需要确保 StencilID 的每一位都被写入,因此最简单的 writeMask 便是 7

不失一般性地,对于第 n 层的 Mask(按照程序逻辑从 0 开始计数),StencilID2(n+1)12^{(n + 1)} - 1readMask2n12^n - 1(这一数值等于父物体的 StencilID),writeMask2(n+1)12^{(n + 1)} - 1。这便是这段代码的逻辑。

总结

本文主要分析了 Graphic 在渲染过程中用到的两个接口:IMeshModifierIMaterialModifierIMeshModifier 主要用于修改 VertexHelper 中的顶点信息,从而实现阴影、描边等效果;IMaterialModifier 则主要用于修改材质的参数,从而实现遮罩等效果。二者分别作用在渲染管线的 Vertex ShaderFragment Shader 阶段,是 UGUI 中实现特殊效果的重要接口。

IMeshModifier 对于顶点的修改都较为简单,主要是对顶点的位置以及颜色的修改。而 IMaterialModifier 则是 UGUI 中遮罩的一种实现方式,涉及到较多的其他类。Mask 是实现了 IMaterialModifier 接口的组件之一,通过修改 Stencil Buffer 中的值来实现遮罩效果。在 Mask 的实现中,通过 StencilIDReadMaskWriteMask 等参数的设置,可以实现多级遮罩的效果。遮罩还包括了以 RectMask2D 为代表的另一种实现方式,这将在下一篇文章中进行分析。