Unity 补完计划(四):UGUI-8:IMeshModifier & IMaterialModifier
前言
在 上一篇 中我们提到了:Graphic
在渲染流程中分别使用到了 IMeshModifier
和 IMaterialModifier
接口来实现对于网格以及材质的修改,本节我们就来分析一下 UGUI 中实现了这些接口的组件分别都起到了什么作用。
IMeshModifier
在 UGUI 中,实现了 IMeshModifier
接口的组件间的关系如下图所示:
在 Graphic
中的使用
首先来看看 IMeshModifier
在 Graphic
中是如何使用的:
1 | private void DoMeshGeneration() |
在 DoMeshGeneration
方法中(即 UpdateGeometry
阶段), 会遍历当前物体上挂载的所有 IMeshModifier
组件,并调用其 ModifyMesh
方法来修改 s_VertexHelper
,由于 s_VertexHelper
是引用传递,因此所有的修改都会累计到 s_VertexHelper
中。
BaseMeshEffect
BaseMeshEffect
是实现了 IMeshModifier
接口的基类,其余的 IMeshModifier
组件都是继承自该类,查看该类的实现:
1 | public abstract class BaseMeshEffect : UIBehaviour, IMeshModifier |
可以看出,该类也仅仅是一个抽象类,并没有实现 ModifyMesh
方法。但在该类中维护了一个 Graphic
类型的变量 m_Graphic
,代表着目前的 BaseMeshEffect
所作用的 Graphic
组件。在 get
方法中也可以看到,会使用 GetComponent<Graphic>()
来获取到当前 BaseMeshEffect
所作用的 Graphic
对象。
Shadow
Shadow
组件用于为 Graphic
组件添加阴影效果,该组件继承自 BaseMeshEffect
,并实现了 ModifyMesh
方法:
1 | public class Shadow : BaseMeshEffect |
首先,Shadow
组件中定义了三个变量用于控制阴影的效果,分别是 m_EffectColor
(阴影颜色)、m_EffectDistance
(阴影偏移)和 m_UseGraphicAlpha
(是否使用 Graphic
的透明度)。在 ModifyMesh
方法中,会根据这三个变量来修改 VertexHelper
中的顶点信息,从而实现阴影效果。
1 | protected void ApplyShadowZeroAlloc(List<UIVertex> verts, Color32 color, int start, int end, float x, float y) |
ApplyShadowZeroAlloc
方法是实现阴影的主要方法,从逻辑中可以看出:阴影实际上的实现方式就是在指定的位置上多渲染了一倍的顶点,并且将颜色进行了调整。这样就实现了阴影的效果。
此外,仔细研究我们会发现一个有趣的事实:新添加的顶点实际上占据了原始顶点的位置,而原始的顶点则被放在了顶点队列的最后。这是为什么呢?答案就是渲染的先后顺序问题,如果我们将新增加的阴影顶点放在队列末尾,那么在渲染时,阴影就会覆盖掉原始的图片,这显然不是我们想要的效果。因此,我们需要将阴影顶点放在原始顶点的前面,这样就能保证阴影在原始图片的下方。
1 | protected void ApplyShadow(List<UIVertex> verts, Color32 color, int start, int end, float x, float y) |
在 ModifyMesh
方法中,会调用 ApplyShadow
方法来实现阴影的效果,最后将修改后的顶点信息重新填充到 VertexHelper
中。
添加阴影后的效果如下:
Outline
Outline
组件用于为 Graphic
组件添加描边效果,该组件继承自 Shadow
,并且也实现了 ModifyMesh
方法:
1 | public class Outline : Shadow |
描边的实现原理与阴影类似,分别在四个方向上共添加了四倍的顶点,从而实现了描边的效果。这里描边的处理也与阴影相同,由于都调用了 ApplyShadowZeroAlloc
方法,因此描边的顶点也会被放在原始顶点的前面,从而不会覆盖原始图片。
添加描边后的效果如下:
可以看到,由于这里使用的 Alpha
值不为 1
,因此描边有一些露馅,但也证明了描边确实是通过向四个方向移动而实现的。
PositionAsUV1
PositionAsUV1
组件用于将顶点的位置信息映射到 UV1
通道上。老实说,笔者并不知道这个组件有什么用,可能在渲染时会用到吧。
1 | public class PositionAsUV1 : BaseMeshEffect |
实现的逻辑也比较简单,就是遍历所有的顶点,将顶点的位置信息映射到 UV1
通道上。
小结
IMeshModifier
接口的实现类主要是用于修改 VertexHelper
中的顶点信息,从而实现一些特殊的效果,如阴影、描边等。这主要是通过向 VertexHelper
中添加额外的顶点来实现的,其中阴影是向指定方向添加一倍的顶点,而描边则是向四个方向添加四倍的顶点。
IMaterialModifier
不同于 IMeshModifier
的清晰结构与简单功能。IMaterialModifier
与 UGUI 中一个非常重要的内容强耦合:遮罩(Mask)。遮罩用于限制子元素的可见区域,使得我们可以实现一些特殊的效果,如圆形头像、进度条等。
在讨论 UGUI 如何实现遮罩之前我们可以思考一下:在渲染管线中,如何实现遮罩功能?一种最直观的想法就是利用 “测试与混合(Test & Blending)” 阶段了。渲染管线的内容可以参考 Rendering Pipeline。
测试阶段按照测试的先后顺序包括了裁剪测试、Alpha 测试、模板测试以及深度测试。在测试阶段,所有未经过测试的片段(Fragment)都会被丢弃,而通过测试的片段才会成为最终的像素(Pixel)。利用这一特性,我们可以在测试阶段对片段进行一些特殊的处理,从而实现遮罩的效果。
UGUI 中也正是这样处理的。
在 UGUI 中,遮罩主要有两种实现方式:Mask
和 RectMask2D
,两种组件均可完成遮罩的效果,但实现遮罩的原理不同。Mask
组件作用在模板测试阶段,通过修改材质而间接地修改 Stencil Buffer
中的值来实现遮罩效果;而 RectMask2D
组件作用在裁剪测试阶段,通过设置 CanvasRenderer
的属性来将超出遮罩区域的片段直接丢弃。
IMaterialModifier
与这些组件的关系如下图所示,有一些本文不会分析到的组件仅做简单表示:

可以看出,以 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 | Stencil { |
-
ref
:整数;范围为 0 到 255;默认值为 0用于设置模板测试的参考值。GPU 使用在
compareOperation
中定义的操作将模板缓冲区的当前内容与此值进行比较。此值使用readMask
或writeMask
进行遮罩,具体取决于进行的是读取操作还是写入操作。如果 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
20public 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
18public enum StencilOp
{
Keep,
Zero,
Replace,
IncrementSaturate,
DecrementSaturate,
Invert,
IncrementWrap,
DecrementWrap
}有保持、替换、增加、减少等操作。默认情况下是
Keep
,即保持原有的值。如果设置为Replace
,则会将Ref
的值写入到模板缓冲区(需要结合WriteMask
使用)。
我们可以打开一个默认的 UI 材质文件,来看看 Unity 是如何配置模板测试的:
1 | // Compiled-UI-Default.shader |
可以看出,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
的颜色设置为蓝色:
查看该组件的 Shader 属性:
可以看到,Unity 为 Mask
组件设置了一些默认的 Stencil Buffer
参数,其中 Stencil ID
为 1
,Stencil Comp
为 8
(即 Always
),Stencil Op
为 2
(即 Replace
),Stencil Write Mask
为 255
,Stencil Read Mask
为 255
。
所表达的意思即为:该 Image
总是通过模板测试,通过后会将 Stencil ID
的值 1
写入到模板缓冲区中。
结合模板缓冲区默认为 0
,则此时的模板缓冲区如下:

我们接着为蓝色图片添加一个子物体,并设置其颜色为红色:
可以看到,超出蓝色图片的部分的确被遮罩了,这便是 Mask
在起作用。同样查看该红色图片的 Shader 属性:
可以看出,该红色图片的 Stencil ID
为 1
,Stencil Comp
为 3
(即 Equal
),Stencil Op
为 0
(即 Keep
),Stencil Write Mask
为 0
,Stencil Read Mask
为 1
。
这就比较有意思了。结合前两条信息以及 ReadMask
我们会发现,在这种设置下,红色图片将只能在 Stencil Buffer
中的值为 1
的地方通过模板测试,ReadMask
为 1
则保证了子物体读取到的恰好是父物体写入的缓冲区位数,这样就实现了遮罩的效果。
并且由于 Keep
及 Write Mask
的设置,红色图片并不会对模板缓冲区进行写入操作。因此,单独的 Image
组件并不会修改模板缓冲区的值。
那如果我们给红色图片也添加一个 Mask
组件呢?
添加了 Mask
组件后,红色图片的 Shader 属性发生了一些微妙的变化:
首先,Stencil Comp
为 3
(即 Equal
),而 StencilID && ReadMask == 1
,保证了红色图片依然只会在 Stencil Buffer
中的值为 1
的地方通过模板测试;其次,Stencil Operation
为 2
(即 Replace
),StencilID && WriteMask == 3
,这表明红色图片会将 3
写入到通过模板测试的位置中。
此时的模板缓冲区如下:

如果我们再添加一个绿色的子物体呢?
想必大家也能够猜到绿色图片的 Shader 属性了:
同样地,也只是在 Stencil Buffer
中的值为 3
的地方通过模板测试,而不对 Stencil Buffer
进行写入操作。
同样为绿色图片添加一个 Mask
组件,其 Shader 属性如下:
不出意料地,通过各种参数的相互计算,该图片会将 7
写入到原本为 3
的通过模板测试的位置中。此时的模板缓冲区如下:

到这里,我们就能基本理解 Mask
实现遮罩的过程了:将子物体通过模板测试的平面范围利用 StencilID
、ReadMask
、WriteMask
等一系列参数限制在父物体的范围内,从而实现遮罩的效果。在有多级 Mask
的情况下,StencilID
的值会以 的形式递增(从 1
开始计算),这样的设置是为了方便 ReadMask
和 WriteMask
发挥作用。
对于 Mask 的进一步探究
虽然通过上述的例子我们已经能够理解 Mask
的实现原理,但默认的 Shader 参数并不能修改,如何 DIY 出相同的效果呢?
由于 Mask
是通过修改材质属性中与模板测试相关的参数来实现的,因此我们可以通过生成具有不同参数的材质来实现相同的效果。我们新建一个材质并将 Shader 设置为 UI/Default
,然后就会惊喜地发现,Stencil
的相关参数居然可以自由设置了!
我们在场景中新添加一个图片,并将其材质设置为我们刚刚生成的材质:
再如法炮制地添加一个子物体并创建新的材质,就会得到与 Mask
相同的效果。
下面就说一下通过这种方法笔者发现地一些有趣的现象:
-
ReadMask 的真实作用
由于渲染管线这一部分依然不是开源的,因此 Unity 内部对于
Stencil Buffer
的处理我们无法得知。但通过上述的操作我们可以发现,ReadMask
的作用并不是简单地与StencilID
进行按位与操作以得到真实的测试值,而是会将模板缓冲区的值也和ReadMask
进行按位与操作,即最终是StencilID && ReadMask
与StencilBuffer && ReadMask
进行比较。举例说明:将蓝色图片(父物体)的参数设置为如下:
红色图片(子物体)的参数设置为如下:
按照上述的分析,子物体的
StencilID && ReadMask == 110 && 001 == 0
,因此红色图片应当只在模板缓冲区为0
的地方才会渲染,而真实情况则为:可以看到,在模板缓冲区为
2
的地方也会渲染红色图片,因此合理的猜测是StencilBuffer && ReadMask == 010 && 001 == 0
,所以这一部分也通过了模板测试。想要进一步验证可以将蓝色图片(父物体)的
StencilID
设置为3
,此时StencilBuffer && ReadMask == 011 && 001 == 1
,不能通过模板测试,因此红色图片只会在模板缓冲区为0
的背景区域渲染。 -
ColorMask 的作用
上文提到了在 UI 的默认材质中有一个
ColorMask
参数,用于控制颜色的写入。实际上ColorWriteMask
也是一个枚举类,定义如下:1
2
3
4
5
6
7
8
9
10
11
12public enum ColorWriteMask
{
Alpha = 1,
Blue = 2,
Green = 4,
Red = 8,
All = 0xF
}按照四位二进制数的顺序,那么从高到低位刚好分别代表
RGBA
四个通道。比如我们将ColorMask
设置为10
,同时父物体颜色设置为白色。这样就只有BR
通道会被渲染,从而呈现出紫色的效果。同样地,如果我们将
ColorMask
设置为0
,则不会渲染任何颜色。
Mask 的实现
在理解了 Stencil Buffer
的机制以及 Mask
的使用后,就该来看看 Mask
的实现了。
成员变量
这里挑几个重要的成员变量来看一下:
1 | [ ] |
showMaskGraphic
用于控制是否显示遮罩的图形,如果设置为 false
,则不会显示遮罩本身(即父物体),只会显示子物体。这里也可以看到,在设置 showMaskGraphic
时会触发 graphic.SetMaterialDirty()
,用于重新渲染图片。
1 | [ ] |
graphic
就是 Mask
组件的操作对象了,Mask
组件就是通过改变 graphic
材质的方式来实现遮罩的效果。因此这里需要注意:Mask
组件是依赖于 Graphic
组件的。这一点和后续会介绍的 RectMask2D
不同。
1 | [ ] |
这两个变量维护的便是实现 Mask
效果的材质了。是的,Mask
组件不仅需要负责遮罩,还需要负责取消遮罩。这样才能在 Mask
被禁用时恢复原来的效果。
GetModifiedMaterial
在对于 Graphic
的分析中我们了解到,Graphic
组件会调用 IMaterialModifier.GetModifiedMaterial()
方法来获取修改后的材质。因此 Mask
生效的地方也一定在这个函数中,查看该函数的实现:
1 | public virtual Material GetModifiedMaterial(Material baseMaterial) |
首先还是使用到了 MaskUtilities
辅助类中的两个方法,来获取当前 Mask
的深度,这里的深度指的正是上述例子中 Mask
的层级。
来看一下这两个函数的实现:
1 | public static Transform FindRootSortOverrideCanvas(Transform start) |
FindRootSortOverrideCanvas
会找到当前 Mask
所属的最顶层 Canvas
或第一个开启了 overrideSorting
的 Canvas
(该 Canvas
独立于其他 Canvas
的渲染顺序)。
1 | public static int GetStencilDepth(Transform transform, Transform stopAfter) |
GetStencilDepth
会计算当前 Mask
的深度,即当前 Mask
与 rootSortCanvas
之间有多少个其他的 Mask
,从上面的例子中我们也可以看出,Mask
会根据深度的不同来设置不同的 StencilID
,这样就能实现多级 Mask
的效果。
1 | if (stencilDepth >= 8) |
这里限制了 Mask
的深度不得超过 8
,这是因为 Stencil Buffer
一般只有 8
位,并且 UGUI 自带的 的设置方式也限制了 Mask
最多叠加 8
层。 desiredStencilBit
便是 的值(程序中从 0
开始计数),再经过一点简单的位运算即可得到真正的 StencilID
。
1 | if (desiredStencilBit == 1) |
对于第一层的情况,Mask
会将 StencilID
设置为 1
,并且始终通过模板测试。这里使用了 StencilMaterial
辅助类来生成指定模板配置的材质。这个类的实现这里就不再赘述了,只需要知道能够生成指定模板配置的材质即可。
此外,也可以看出 m_ShowMaskGraphic
实际上是通过 ColorWriteMask
来控制遮罩的显隐。
需要注意的是,除了
m_MaskMaterial
外,Mask
在GetModifiedMaterial()
中还生成了一个m_UnmaskMaterial
,这个材质用于 Shader 的第二个 Pass 中,作用是将Stencil Buffer
中的值恢复为修改之前。在此处是将Stencil Buffer
中的值设置为0
,而在多层级的情况下则会将Stencil Buffer
中的值设置为上一层的Mask
所设置的值。详见 UGUI 番外之合批
1 | var maskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit | (desiredStencilBit - 1), |
对于多层的情况就稍微复杂一些,由上面的例子我们知道:对于某一层 mask,如 StencilID
为 7
,那么在进行模板测试时需要与父物体范围内的 StencilBuffer
值进行比较(此时为 3
)。因此需要设置 readMask
为 3
,以便在该掩码的作用下得到 StencilID && readMask == StecilBuffer && readMask == 3
的效果。同时,在写入时需要确保 StencilID
的每一位都被写入,因此最简单的 writeMask
便是 7
。
不失一般性地,对于第 n
层的 Mask
(按照程序逻辑从 0
开始计数),StencilID
为 ,readMask
为 (这一数值等于父物体的 StencilID
),writeMask
为 。这便是这段代码的逻辑。
总结
本文主要分析了 Graphic
在渲染过程中用到的两个接口:IMeshModifier
与 IMaterialModifier
。IMeshModifier
主要用于修改 VertexHelper
中的顶点信息,从而实现阴影、描边等效果;IMaterialModifier
则主要用于修改材质的参数,从而实现遮罩等效果。二者分别作用在渲染管线的 Vertex Shader
与 Fragment Shader
阶段,是 UGUI 中实现特殊效果的重要接口。
IMeshModifier
对于顶点的修改都较为简单,主要是对顶点的位置以及颜色的修改。而 IMaterialModifier
则是 UGUI 中遮罩的一种实现方式,涉及到较多的其他类。Mask
是实现了 IMaterialModifier
接口的组件之一,通过修改 Stencil Buffer
中的值来实现遮罩效果。在 Mask
的实现中,通过 StencilID
、ReadMask
、WriteMask
等参数的设置,可以实现多级遮罩的效果。遮罩还包括了以 RectMask2D
为代表的另一种实现方式,这将在下一篇文章中进行分析。