Refs:
https://zhuanlan.zhihu.com/p/643275125
https://www.cnblogs.com/cancantrbl/p/16076748.html
https://docs.unity.cn/cn/2018.4/Manual/class-Canvas.html

前言

Graphic 算是 UGUI 中最重要的组件之一了,它是所有 UI 组件的基类与基础,负责实现 UI 的绘制功能。在本篇中,我们将详细讲解 Graphic 的实现。

前置知识

在学习 Graphic 源码之前,需要对 Canvas 及渲染流程有一定的了解。

Canvas

画布(canvas)是 UI 组件的容器,所有 UI 元素都必须放置在画布中。这就好比是电影幕布,所有的影像都在幕布上呈现。Canvas 组件通过渲染器将UI元素绘制到屏幕上。它使用层级结构来管理UI元素的显示顺序,可以通过设置UI元素的层级来控制它们的显示顺序。Canvas组件还可以设置渲染模式,包括屏幕空间、世界空间和摄像机空间等。

若场景中没有画布,在创建控件时会自动创建画布。一个默认的 Canvas 会包含以下组件:

image.png

Canvas 的存在,让我们可以将一些通用的 UI 配置放到 Canvas 上来配置,比如:

  • Canvas 与游戏中其他物体是什么样的关系?(渲染模式)
  • 对于不同分辨率的屏幕,Canvas 如何进行适配?(画布缩放器 Canvas Scaler)

对于这些问题,画布及其关联组件都提供了多种配置项来适配不同的需求。

渲染模式

渲染模式定义了画布与场景中其他物体在渲染上的关系。传统上,渲染 UI 的效果就好像是直接在屏幕上绘制的简单图形设计。也就是说,没有摄像机观察 3D 空间的概念。Unity 便支持这种屏幕空间渲染方式,但也允许 UI 在场景中渲染为对象,具体取决于 Render Mode 属性的值。可用的模式包括 Screen Space - Overlay、Screen Space - Camera 和 World Space。

  • Screen Space - Overlay

    在此模式下,画布会进行缩放来适应屏幕,然后直接渲染而不参考场景或摄像机(即使场景中根本没有摄像机,也会渲染 UI)。如果更改屏幕的大小或分辨率,则 UI 将自动重新缩放进行适应。UI 将绘制在所有其他图形(例如摄像机视图)上。

    image.png

    注意:Screen Space - Overlay 画布需要存储在层级视图的顶级。如果未使用此设置,则 UI 可能会从视图中消失。这是一项内置的限制。请将 Screen Space - Overlay 画布保持在层级视图的顶级以便获得期望的结果。

  • Screen Space - Camera

    在此模式下,画布的渲染效果就好像是在摄像机前面一定距离的平面对象上绘制的效果。UI 在屏幕上的大小不随距离而变化,因为 UI 始终会重新缩放来准确适应摄像机视锥体。如果更改屏幕的大小或分辨率或更改摄像机视锥体,则 UI 将自动重新缩放进行适应。场景中比 UI 平面更靠近摄像机的所有 3D 对象都将在 UI 前面渲染,而平面后的对象将被遮挡。

    image.png

  • World Space

    在 World Space 渲染模式下呈现的 UI 好像是 3D 场景中的一个 Plane 对象。与前两种渲染模式不同,其屏幕的大小将取决于拍摄的角度和相机的距离。

    它是一个完全三维的 UI,也就是把 UI 也当成三维对象,例如摄像机离 UI 远了,其显示就会变小,近了就会变大。

    此模式将 UI 视为场景中的平面对象进行渲染。但是,与 Screen Space - Camera 模式不同,该平面不需要面对摄像机,可以根据喜好任意定向。画布的大小可以使用 RectTransform 来设置,但画布在屏幕上的大小将取决于摄像机的视角和距离。其他场景对象可以位于画布后面、穿透画布或位于画布前面。

    image.png

渲染模式的配置在 Canvas 组件的 Render Mode 选项中:

image.png

画布缩放器 Canvas Scaler

当画布在屏幕空间中渲染时,画布尺寸的大小会随着屏幕的大小和分辨率的变化而变化。这就是画布缩放器(Canvas Scaler)的作用,它可以根据屏幕的大小和分辨率来调整画布的大小,以确保 UI 元素在不同设备上具有一致的大小和布局。

画布缩放器(Canvas Scaler 组件),提供了三种缩放模式来适配不同的需求:

  1. Constant Pixel Size:在此模式下,UI 元素的大小将不受 Canvas 的缩放影响,而是保持固定的像素大小。这种模式适用于需要确保 UI 元素在不同设备上的大小保持一致的情况。

  2. Scale With Screen Size:在此模式下,UI 元素的大小将根据 Canvas 的缩放比例进行缩放,以适应不同分辨率的设备。这种模式适用于需要在不同分辨率的设备上呈现一致的 UI 布局和视觉效果的情况。

  3. Constant Physical Size:在此模式下,UI 元素的大小将根据屏幕的物理大小进行缩放,以确保 UI 元素在不同设备上具有相同的物理大小。这种模式适用于需要确保 UI 元素在不同设备上具有相同的物理大小的情况,例如在使用触摸屏幕的设备上。

更详细的说明可以参考 Unity 官方文档:Canvas Scaler

Renderer 的对比

UGUI 的渲染器是 Canvas Render (CR), 同样渲染2D物体的是 Sprite Render (SR)

相同点:

  • 都有一个渲染队列来处理透明物体,从后往前渲染
  • 都可以通过图集并合并渲染批次,减少drawcall

不同点:

  • Canvas Render 要与 Rect Transform 配合,必须在 Canvas 里使用,常用于 UI。Sprite Render 与 transform配合,常用于 gameplay
  • Canvas Render 基于矩形分隔的三角形网络,一张网格里最少有两个三角形(不同的image type, 三角形的个数也会不同),透明部分也占空间。Sprite Render 的三角网络较为复杂,能剔除透明部分

image.png

Canvas Render 会老老实实地为一个矩形的Sprite生成两个三角形拼成的矩形几何体

image.png

Sprite Render 会根据显示内容,裁剪掉元素中的大部分透明区域,最终生成的几何体可能会有比较复杂的顶点结构

一个DrawCall的渲染流程:

  1. CPU 发送 Draw Call 指令给 GPU;
  2. GPU 读取必要的数据到自己的显存;
  3. GPU 通过顶点着色器(vertex shader)等步骤将输入的几何体信息转化为像素点数据;
  4. 每个像素都通过片段着色器(fragment shader)处理后写入帧缓存;
  5. 当全部计算完成后,GPU 将帧缓存内容显示在屏幕上。

从上面的步骤可知,因为 SR 的顶点数据更复杂,在第一步和第二步的效率会比 CR 低,会有更多的vertex shader的计算;但 CR 会有更多的 fragment shader 的计算,因为是针对每个像素的计算,而 SR 会裁剪掉透明的部分,从而减少了大量的片段着色器运算,并降低了overdraw。

渲染层级

说完底层,我们再来看看UI渲染层级是怎么由哪些决定的。我们说的渲染层级高,意思就是会盖在物体上面,也是最后一个被渲染的那个。

渲染层级是由以下三个层级决定的,从高到低:

  1. 相机的 layer 和 depth:culling layer 可以决定相机能看到什么 layer,depth 越高的相机,其视野内能看到的所有物体渲染层级越高
image.png
  1. canvas 的 layer 和 order
  • Screen Space - Overlay: sort order 越大显示越前面

    image.png
  • Screen Spacce - Camera: order layer越大显示越前面;sorting layer越在下方的层显示越前面。

    image.png
  • World Space: 当UI为场景的一部分,即UI为场景的一部分,需要以3D形式展示。变量和camera screen space一样

    image.png
  1. 物体的 hierarchy 关系:物体越在下面,显示越在前面

Graphic

Graphic 是一个抽象类,继承自 UIBehaviour 并实现了 ICanvasElement 接口。这意味着由 Graphic 派生出的类也可以通过 CanvasUpdateRegistry 来将自己注册进 m_GraphicRebuildQueue 中。

Graphic.png

本节将要介绍的逻辑如上图所示。

成员变量

首先还是看看 Graphic 的成员变量,对于 Graphic 而言,最重要的还是用于渲染的数据成员,如材质、纹理、颜色、网格等。我们首先来看一下对于这些数据,Graphic 是如何处理的。

  1. Material
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static protected Material s_DefaultUI = null;

static public Material defaultGraphicMaterial
{
get
{
if (s_DefaultUI == null)
s_DefaultUI = Canvas.GetDefaultCanvasMaterial();
return s_DefaultUI;
}
}

public virtual Material defaultMaterial
{
get { return defaultGraphicMaterial; }
}

首先,Graphic 中定义了一个静态的 Material 类型的变量 s_DefaultUI,用于存储默认的 UI 材质,并利用 Canvas.GetDefaultCanvasMaterial() 方法来获取它。也就是我们在创建一个 Image 等 UI 组件时,指定的默认材质了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[FormerlySerializedAs("m_Mat")]
[SerializeField] protected Material m_Material;

public virtual Material material
{
get
{
return (m_Material != null) ? m_Material : defaultMaterial;
}
set
{
if (m_Material == value)
return;

m_Material = value;
SetMaterialDirty();
}
}

其次,提供了用户可以设置的材质 m_Material,如果用户没有设置材质,则使用默认材质 defaultMaterial。此外,在设置材质时,会调用 SetMaterialDirty() 方法对当前 Graphic 做脏标记,以进行重新渲染,这个方法会在下文分析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public virtual Material materialForRendering
{
get
{
var components = ListPool<Component>.Get();
GetComponents(typeof(IMaterialModifier), components);

var currentMat = material;
for (var i = 0; i < components.Count; i++)
currentMat = (components[i] as IMaterialModifier).GetModifiedMaterial(currentMat);
ListPool<Component>.Release(components);
return currentMat;
}
}

最后,materialForRendering 方法用于获取当前用于渲染的材质,它会遍历当前 Graphic 上的所有 IMaterialModifier 组件,对当前材质进行修改,最后返回修改后的材质。

值得注意的是:materialForRendering 是真正提交给 CanvasRenderer 的材质。因此,针对同一个 material,我们也可以通过设置不同的 IMaterialModifier 组件来实现不同的渲染效果。这是一种良好的解耦设计,使得 Graphic 的渲染效果可以通过不同的 IMaterialModifier 组件来定制。

  1. Texture

接着是 Texture,Graphic 中定义了一个静态的 Texture2D 类型的变量 s_WhiteTexture,用于储存默认的 UI 纹理,即白色纹理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static protected Texture2D s_WhiteTexture = null;

public virtual Texture mainTexture
{
get
{
return s_WhiteTexture;
}
}

protected override void OnEnable()
{
base.OnEnable();
CacheCanvas();
GraphicRegistry.RegisterGraphicForCanvas(canvas, this);

#if UNITY_EDITOR
GraphicRebuildTracker.TrackGraphic(this);
#endif
if (s_WhiteTexture == null)
s_WhiteTexture = Texture2D.whiteTexture;

SetAllDirty();
}

mainTexture 是真正提交给 CanvasRenderer 的纹理,这里我们可以看到,默认返回的就是白色纹理。且会在 OnEnable 方法中初始化 s_WhiteTexture。不过 mainTexture 本身是一个虚方法,因此我们可以在自定义的 Graphic 类中重写这个方法,来控制纹理的渲染。

此外,从注释中也可以看到关于合批的一些考量,Unity 会尝试将 UI 元素进行合批,以提高性能,因此在实现自定义 Graphic 时,最好使用图集来减少绘制调用。

  1. Color

Graphic 中对于颜色的处理相较于材质和纹理要简单一些,只是定义了一个 Color 类型的变量 m_Color,用于存储颜色信息。

1
2
3
[SerializeField] private Color m_Color = Color.white;

public virtual Color color { get { return m_Color; } set { if (SetPropertyUtility.SetColor(ref m_Color, value)) SetVerticesDirty(); } }

color 属性也是最终交付给 CanvasRenderer 的颜色信息,当颜色发生变化时,会调用 SetVerticesDirty() 方法对当前 Graphic 做脏标记,以进行重新渲染。

  1. Mesh

最后是 Mesh,Mesh 用于储存 Graphic 的顶点信息,包括顶点的位置以及三角形的索引等。Graphic 使用 VertexHelper 类用于辅助生成 Mesh 信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[NonSerialized] protected static Mesh s_Mesh;

[NonSerialized] private static readonly VertexHelper s_VertexHelper = new VertexHelper();

[NonSerialized] protected Mesh m_CachedMesh;

protected static Mesh workerMesh
{
get
{
if (s_Mesh == null)
{
s_Mesh = new Mesh();
s_Mesh.name = "Shared UI Mesh";
s_Mesh.hideFlags = HideFlags.HideAndDontSave;
}
return s_Mesh;
}
}

Graphic 真正交给 CanvasRenderer 用于渲染的是 workerMesh,在默认的的渲染流程中,会通过 s_VertexHelper 生成网格信息并赋予 workerMesh,然后交给 CanvasRenderer 进行渲染。

小结

到这里我们就基本了解了 Graphic 是如何处理渲染所需的数据的,Graphic 分别将 materialForRendering、mainTexture、color 与 workerMesh 交给 CanvasRenderer 进行渲染。虽然 Unity 的渲染管线只开放到将数据提交给 CanvasRenderer 这一步,但 Graphic 中大部分与渲染相关的数据都是可以通过重写方法来定制的,还是赋予了开发者很大的自定义空间。

渲染流程

虽然 CanvasRenderer 的底层我们无法查看,但 UGUI 本身的渲染流程是可以通过 Graphic 的源码来了解的。上文提到,Graphic 也实现了 ICanvasElement 接口,因此也必然实现了相关的方法,通过这些方法我们就可以了解 Graphic 的渲染流程。

ICanvasElement 相关方法

Graphic 中共实现了以下的 ICanvasElement 接口方法:

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
public virtual void Rebuild(CanvasUpdate update)
{
if (canvasRenderer == null || canvasRenderer.cull)
return;

switch (update)
{
case CanvasUpdate.PreRender:
if (m_VertsDirty)
{
UpdateGeometry();
m_VertsDirty = false;
}
if (m_MaterialDirty)
{
UpdateMaterial();
m_MaterialDirty = false;
}
break;
}
}

public virtual void LayoutComplete()
{}

public virtual void GraphicUpdateComplete()
{}

其中,Rebuild 方法是最重要的一个方法,我们都知道 CanvasUpdateRegistry 会在每一帧中逐阶段地调用 m_LayoutRebuildQueue 以及 m_GraphicRebuildQueue 中的 ICanvasElement 对象的 Rebuild 方法。因此,最重要的更新逻辑一定在 Rebuild 方法中。

Graphic 中,Rebuild 方法只在 CanvasUpdate.PreRender 阶段起作用,主要进行了两步操作:1. 更新几何信息;2. 更新材质信息。这两步操作分别由 UpdateGeometry 和 UpdateMaterial 方法完成。

至于其他接口方法,LayoutComplete 和 GraphicUpdateComplete 方法都是空实现,而 IsDestroyed 方法则没有实现。毕竟 Graphic 只是一个抽象类,确实不应该实现这个方法。

UpdateGeometry

UpdateGeometry 方法用于更新 Graphic 的几何信息,即将 VertexHelper 生成的网格信息填充到 workerMesh 中,再交由 CanvasRenderer 处理。这里涉及到了许多辅助类,都会在下文进行单独讲解,目前还是仅梳理渲染逻辑。

1
2
3
4
5
6
7
8
9
10
11
protected virtual void UpdateGeometry()
{
if (useLegacyMeshGeneration)
{
DoLegacyMeshGeneration();
}
else
{
DoMeshGeneration();
}
}

在 UpdateGeometry 方法中,首先会根据 useLegacyMeshGeneration 的值来决定使用新的还是旧的网格生成方法,看样子是有一些兼容性的考虑。这里我们只分析 DoMeshGeneration 方法。

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 首先会调用 OnPopulateMesh 方法将顶点信息更新至 s_VertexHelper 中,然后遍历当前 Graphic 上的所有 IMeshModifier 组件,对 s_VertexHelper 中的顶点信息进行修改。最后,将修改完成的顶点信息填充至 workerMesh 中,并交由 CanvasRenderer 处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected virtual void OnPopulateMesh(VertexHelper vh)
{
var r = GetPixelAdjustedRect();
var v = new Vector4(r.x, r.y, r.x + r.width, r.y + r.height);

Color32 color32 = color;
vh.Clear();
vh.AddVert(new Vector3(v.x, v.y), color32, new Vector2(0f, 0f));
vh.AddVert(new Vector3(v.x, v.w), color32, new Vector2(0f, 1f));
vh.AddVert(new Vector3(v.z, v.w), color32, new Vector2(1f, 1f));
vh.AddVert(new Vector3(v.z, v.y), color32, new Vector2(1f, 0f));

vh.AddTriangle(0, 1, 2);
vh.AddTriangle(2, 3, 0);
}

OnPopulateMesh 方法是一个虚方法,用于生成 Graphic 的顶点信息。默认的实现非常简单,如同上面对于 CanvasRenderer 的分析一样,这里仅仅是根据 RectTransfrom 的大小生成了一个矩形的网格并分割为两个三角形,效果如下图所示:

3e1b9caa964bbd6db28fb335f9780541.png

OnPopulateMesh 方法是可以被重写的,因此我们可以通过重写这个方法来实现自定义的顶点信息生成逻辑。

UpdateMaterial

UpdateMaterial 方法用于更新 Graphic 的材质信息,包括了材质与纹理两种数据。

1
2
3
4
5
6
7
8
9
protected virtual void UpdateMaterial()
{
if (!IsActive())
return;

canvasRenderer.materialCount = 1;
canvasRenderer.SetMaterial(materialForRendering, 0);
canvasRenderer.SetTexture(mainTexture);
}

UpdateMaterial 中是直接将 materialForRendering 与 mainTexture 交由 CanvasRenderer 处理,这里没有涉及到其他的逻辑。值得一提的是,网格与材质的修改时机并不相同,针对材质也有相应的 IMaterialModifier 接口,但已经在获得 materialForRendering 时处理过了,因此在 UpdateMaterial 中不再处理。

脏标记

在 Graphic 的渲染过程中也大量使用了脏标记。在上述的 Rebuild 方法中,会先判断 m_VertsDirty 是否为 true,如果是则调用 UpdateGeometry 方法,并且更新之后会重新将 m_VertsDirty 置为 false。m_MaterialDirty 同理。

这里我们再来分析一下脏标记的设置流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public virtual void SetVerticesDirty()
{
if (!IsActive())
return;

m_VertsDirty = true;
CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);

if (m_OnDirtyVertsCallback != null)
m_OnDirtyVertsCallback();
}

public virtual void SetMaterialDirty()
{
if (!IsActive())
return;

m_MaterialDirty = true;
CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);

if (m_OnDirtyMaterialCallback != null)
m_OnDirtyMaterialCallback();
}

分别由 SetVerticesDirty 和 SetMaterialDirty 方法来设置顶点及材质的脏标记,这两个方法都会将当前 Graphic 重新注册进 CanvasUpdateRegistry 的 m_GraphicRebuildQueue 中,以在下一帧的 CanvasUpdate.PreRender 阶段重新渲染。

此外还定义了 m_OnDirtyVertsCallback 和 m_OnDirtyMaterialCallback 两个回调,这两个回调可以在外部设置,用于在设置脏标记时执行一些额外的逻辑。

1
2
3
4
5
6
7
8
9
10
public virtual void SetLayoutDirty()
{
if (!IsActive())
return;

LayoutRebuilder.MarkLayoutForRebuild(rectTransform);

if (m_OnDirtyLayoutCallback != null)
m_OnDirtyLayoutCallback();
}

除了渲染流程的脏标记外,Graphic 还提供了 SetLayoutDirty 方法用于设置布局的脏标记。单独的 Graphic 一般不会涉及到布局的问题,但如果物体上还挂载了其他的布局相关组件,那么就可以通过这个方法来更新布局。

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
public virtual void SetAllDirty()
{
// Optimization: Graphic layout doesn't need recalculation if
// the underlying Sprite is the same size with the same texture.
// (e.g. Sprite sheet texture animation)

if (m_SkipLayoutUpdate)
{
m_SkipLayoutUpdate = false;
}
else
{
SetLayoutDirty();
}

if (m_SkipMaterialUpdate)
{
m_SkipMaterialUpdate = false;
}
else
{
SetMaterialDirty();
}

SetVerticesDirty();
}

SetAllDirty 方法则是用于一次性设置所有的脏标记,表示整个 Graphic 的布局、材质以及顶点信息都需要重新渲染。由于分别调用了 SetxxxDirty 方法,因此所有注册的回调都会被执行。

下面就来看看什么地方会调用这些脏标记的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public virtual Material material
{
get
{
return (m_Material != null) ? m_Material : defaultMaterial;
}
set
{
if (m_Material == value)
return;

m_Material = value;
SetMaterialDirty();
}
}

public virtual Color color { get { return m_Color; } set { if (SetPropertyUtility.SetColor(ref m_Color, value)) SetVerticesDirty(); } }

首先,在材质以及颜色发生变化时,会调用 SetMaterialDirty 和 SetVerticesDirty 方法,分别对材质和顶点信息进行脏标记。

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
protected override void OnRectTransformDimensionsChange()
{
if (gameObject.activeInHierarchy)
{
// prevent double dirtying...
if (CanvasUpdateRegistry.IsRebuildingLayout())
SetVerticesDirty();
else
{
SetVerticesDirty();
SetLayoutDirty();
}
}
}

protected override void OnBeforeTransformParentChanged()
{
GraphicRegistry.UnregisterGraphicForCanvas(canvas, this);
LayoutRebuilder.MarkLayoutForRebuild(rectTransform);
}

protected override void OnTransformParentChanged()
{
base.OnTransformParentChanged();

m_Canvas = null;

if (!IsActive())
return;

CacheCanvas();
GraphicRegistry.RegisterGraphicForCanvas(canvas, this);
SetAllDirty();
}

protected override void OnDidApplyAnimationProperties()
{
SetAllDirty();
}

此外就是 UIBehaviour 中定义的一些回调方法,在层级结构发生变化、应用动画属性等情况下,也会调用 SetAllDirty 方法,表示整个 Graphic 都需要重新渲染。

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
protected override void OnEnable()
{
base.OnEnable();
CacheCanvas();
GraphicRegistry.RegisterGraphicForCanvas(canvas, this);

#if UNITY_EDITOR
GraphicRebuildTracker.TrackGraphic(this);
#endif
if (s_WhiteTexture == null)
s_WhiteTexture = Texture2D.whiteTexture;

SetAllDirty();
}

protected override void Reset()
{
SetAllDirty();
}

protected override void OnValidate()
{
base.OnValidate();
SetAllDirty();
}

最后就是在一些生命周期方法中的调用,这里就不再赘述。

小结

至此,我们已经了解了 Graphic 的渲染流程,Graphic 在 ICanvasElement.Rebuild 方法中针对 CanvasUpdate.PreRender 阶段进行了几何信息和材质信息的更新,分别由 UpdateGeometry 和 UpdateMaterial 方法完成。UpdateGeometry 方法通过 VertexHelper 生成网格信息,并通过 CanvasRenderer 进行渲染;UpdateMaterial 方法则直接将材质及纹理信息交由 CanvasRenderer 处理。此外,Graphic 在渲染过程中也大量使用了脏标记,通过 SetVerticesDirty、SetMaterialDirty、SetLayoutDirty 等方法来标记需要重新渲染的信息。

杂项

除主要的渲染流程外,Graphic 中还有一些其他的方法用于处理其他逻辑,这里一并分析。

GraphicRegistry

在 Graphic 的许多生命周期函数中,都涉及到了 GraphicRegistry 的调用,这是一个单例类,用于管理 Canvas 中的 Graphic 对象。

1
private readonly Dictionary<Canvas, IndexedSet<Graphic>> m_Graphics = new Dictionary<Canvas, IndexedSet<Graphic>>();

GraphicRegistry 在类中维护了一个 Dictionary,用于存储 Canvas 与 Graphic 的映射关系。当一个 Graphic 对象被一个 Canvas 所接管时,就会调用 RegisterGraphicForCanvas 方法,将 Graphic 对象注册进 m_Graphics 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/// <summary>
/// Store a link between the given canvas and graphic in the registry.
/// </summary>
/// <param name="c">The canvas the graphic will be associated to</param>
/// <param name="graphic">The graphic in question.</param>
public static void RegisterGraphicForCanvas(Canvas c, Graphic graphic)
{
if (c == null)
return;

IndexedSet<Graphic> graphics;
instance.m_Graphics.TryGetValue(c, out graphics);

if (graphics != null)
{
graphics.AddUnique(graphic);
return;
}

// Dont need to AddUnique as we know its the only item in the list
graphics = new IndexedSet<Graphic>();
graphics.Add(graphic);
instance.m_Graphics.Add(c, graphics);
}

在 Graphic 的 OnEnable、OnTransformParentChanged 等方法中,都会调用 RegisterGraphicForCanvas 方法,建立起当前 Graphic 与 Canvas 的关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// <summary>
/// Deregister the given Graphic from a Canvas.
/// </summary>
/// <param name="c">The canvas that should be associated with the graphic</param>
/// <param name="graphic">The graphic to remove.</param>
public static void UnregisterGraphicForCanvas(Canvas c, Graphic graphic)
{
if (c == null)
return;

IndexedSet<Graphic> graphics;
if (instance.m_Graphics.TryGetValue(c, out graphics))
{
graphics.Remove(graphic);

if (graphics.Count == 0)
instance.m_Graphics.Remove(c);
}
}

同理,UnregisterGraphicForCanvas 方法则是用于解除 Graphic 与 Canvas 的关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static readonly List<Graphic> s_EmptyList = new List<Graphic>();

/// <summary>
/// Get the list of associated graphics that are registered to a canvas.
/// </summary>
/// <param name="canvas">The canvas whose Graphics we are looking for</param>
/// <returns>The list of all Graphics for the given Canvas.</returns>
public static IList<Graphic> GetGraphicsForCanvas(Canvas canvas)
{
IndexedSet<Graphic> graphics;
if (instance.m_Graphics.TryGetValue(canvas, out graphics))
return graphics;

return s_EmptyList;
}

此外,还提供了 GetGraphicsForCanvas 方法,用于获取一个 Canvas 下的所有 Graphic 对象。

Canvas & CanvasRenderer 相关

Graphic 中定义了一些方法用于维护当前物体所属的 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
29
30
31
32
33
34
35
[NonSerialized] private Canvas m_Canvas;

public Canvas canvas
{
get
{
if (m_Canvas == null)
CacheCanvas();
return m_Canvas;
}
}

private void CacheCanvas()
{
var list = ListPool<Canvas>.Get();
gameObject.GetComponentsInParent(false, list);
if (list.Count > 0)
{
// Find the first active and enabled canvas.
for (int i = 0; i < list.Count; ++i)
{
if (list[i].isActiveAndEnabled)
{
m_Canvas = list[i];
break;
}
}
}
else
{
m_Canvas = null;
}

ListPool<Canvas>.Release(list);
}

CacheCanvas 方法用于获取当前 Graphic 所属的 Canvas 对象,通过 GetComponentsInParent 方法获取父物体中的所有 Canvas 组件,然后遍历找到第一个激活且启用的 Canvas 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
[NonSerialized] private CanvasRenderer m_CanvasRenderer;

public CanvasRenderer canvasRenderer
{
get
{
if (ReferenceEquals(m_CanvasRenderer, null))
{
m_CanvasRenderer = GetComponent<CanvasRenderer>();
}
return m_CanvasRenderer;
}
}

canvasRenderer 方法用于获取当前 Graphic 的 CanvasRenderer 组件,这里直接通过 GetComponent 方法获取,因为 Graphic 模块必然要依赖于一个 CanvasRenderer 组件。

Raycast

Raycast 是一个虚方法,用于判断一个给定的点是否能够被射线检测到,这部分的内容涉及到 GraphicRaycaster,会在后续章节更详细讲解。

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
public virtual bool Raycast(Vector2 sp, Camera eventCamera)
{
if (!isActiveAndEnabled)
return false;

var t = transform;
var components = ListPool<Component>.Get();

bool ignoreParentGroups = false;
bool continueTraversal = true;

while (t != null)
{
t.GetComponents(components);
for (var i = 0; i < components.Count; i++)
{
var canvas = components[i] as Canvas;
if (canvas != null && canvas.overrideSorting)
continueTraversal = false;

var filter = components[i] as ICanvasRaycastFilter;

if (filter == null)
continue;

var raycastValid = true;

var group = components[i] as CanvasGroup;
if (group != null)
{
if (ignoreParentGroups == false && group.ignoreParentGroups)
{
ignoreParentGroups = true;
raycastValid = filter.IsRaycastLocationValid(sp, eventCamera);
}
else if (!ignoreParentGroups)
raycastValid = filter.IsRaycastLocationValid(sp, eventCamera);
}
else
{
raycastValid = filter.IsRaycastLocationValid(sp, eventCamera);
}

if (!raycastValid)
{
ListPool<Component>.Release(components);
return false;
}
}
t = continueTraversal ? t.parent : null;
}
ListPool<Component>.Release(components);
return true;
}

这里的主要逻辑就是遍历当前物体上的所有 ICanvasRaycastFilter 组件,再调用 filter.IsRaycastLocationValid 方法来判断当前点是否可以被射线检测到。ICanvasRaycastFilter 本身也是不开放的,因此没法继续深入分析。

除了 ICanvasRaycastFilter 的处理外,函数还处理了 CanvasGroup 的情况。如果 CanvasGroup 本身设置了 ignoreParentGroups,则会忽略父级的 CanvasGroup 的设置。

CanvasGroup

CanvasGroup 可以影响该组 UI 元素的部分性质,而不需要费力的对该组 UI 下的每个元素进行逐一地调整。CanvasGroup 同时作用于该组件 UI 下的全部元素。

image.png

  • Alpha : 该组UI元素的透明度。注:每个UI最终的透明度是由此值和自身的alpha数值相乘得到。

  • Interactable : 是否需要交互(勾选的则是可交互),同时作用于该组全部UI元素。

  • Blcok Raycasts : 是否可以接收图形射线的检测(勾选则接受检测)。注:不适用于Physics.Raycast.。

  • Ignore Parent Group : 是否需要忽略父级对象中的CanvasGroup的设置。(勾选则忽略)

辅助类

Graphic 的整个渲染流程中,还涉及到了一些辅助类,如 VertexHelper、IMeshModifier、IMaterialModifier 等。

VertexHelper

VertexHelper 是一个用于辅助生成 Mesh 数据的类,Graphic 中多处使用了 VertexHelper 来处理顶点信息。

UIVertex

在介绍 VertexHelper 之前,还是先来看一下 Unity 中为 UI 的一个顶点定义的结构体 UIVertex。

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
public struct UIVertex
{
public Vector3 position;

public Vector3 normal;

public Vector4 tangent;

public Color32 color;

public Vector2 uv0;

public Vector2 uv1;

public Vector2 uv2;

public Vector2 uv3;

private static readonly Color32 s_DefaultColor = new Color32(byte.MaxValue, byte.MaxValue, byte.MaxValue, byte.MaxValue);

private static readonly Vector4 s_DefaultTangent = new Vector4(1f, 0f, 0f, -1f);

public static UIVertex simpleVert = new UIVertex
{
position = Vector3.zero,
normal = Vector3.back,
tangent = s_DefaultTangent,
color = s_DefaultColor,
uv0 = Vector2.zero,
uv1 = Vector2.zero,
uv2 = Vector2.zero,
uv3 = Vector2.zero
};
}

可以看出,一个 UI 的顶点信息包括了位置、法线、切线、颜色以及四组 UV 坐标。UV 坐标一般用于纹理映射,而法线和切线则用于光照计算。虽然在 UIVertex 中定义了四组 UV 坐标,但一般在渲染时只需要用第一个 UV 数据,其他的 UV 数据一般用于特殊的效果。

VertexHelper 的成员变量

回到 VertexHelper 中,首先还是看一下 VertexHelper 的成员变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class VertexHelper : IDisposable
{
private List<Vector3> m_Positions;
private List<Color32> m_Colors;
private List<Vector2> m_Uv0S;
private List<Vector2> m_Uv1S;
private List<Vector2> m_Uv2S;
private List<Vector2> m_Uv3S;
private List<Vector3> m_Normals;
private List<Vector4> m_Tangents;
private List<int> m_Indices;

private static readonly Vector4 s_DefaultTangent = new Vector4(1.0f, 0.0f, 0.0f, -1.0f);
private static readonly Vector3 s_DefaultNormal = Vector3.back;

private bool m_ListsInitalized = false;

// ...
}

可以看出,VertexHelper 中的储存的顶点数据基本就可以理解为 List<UIVertex>,多出的 m_Indices 则是用于储存三角形的索引信息。利用这两部分数据就可以生成一个完整的 Mesh 了。

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
25
26
27
28
29
30
31
32
33
34
public VertexHelper()
{}

public VertexHelper(Mesh m)
{
InitializeListIfRequired();

m_Positions.AddRange(m.vertices);
m_Colors.AddRange(m.colors32);
m_Uv0S.AddRange(m.uv);
m_Uv1S.AddRange(m.uv2);
m_Uv2S.AddRange(m.uv3);
m_Uv3S.AddRange(m.uv4);
m_Normals.AddRange(m.normals);
m_Tangents.AddRange(m.tangents);
m_Indices.AddRange(m.GetIndices(0));
}

private void InitializeListIfRequired()
{
if (!m_ListsInitalized)
{
m_Positions = ListPool<Vector3>.Get();
m_Colors = ListPool<Color32>.Get();
m_Uv0S = ListPool<Vector2>.Get();
m_Uv1S = ListPool<Vector2>.Get();
m_Uv2S = ListPool<Vector2>.Get();
m_Uv3S = ListPool<Vector2>.Get();
m_Normals = ListPool<Vector3>.Get();
m_Tangents = ListPool<Vector4>.Get();
m_Indices = ListPool<int>.Get();
m_ListsInitalized = true;
}
}

VertexHelper 提供了两个构造函数,一个是默认构造函数,一个是通过 Mesh 对象来初始化的构造函数。在初始化时,会调用 InitializeListIfRequired 方法来初始化顶点数据。这里所分配的列标均通过 ListPool 来获取。

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
public class VertexHelper : IDisposable
{

/// <summary>
/// Cleanup allocated memory.
/// </summary>
public void Dispose()
{
if (m_ListsInitalized)
{
ListPool<Vector3>.Release(m_Positions);
ListPool<Color32>.Release(m_Colors);
ListPool<Vector2>.Release(m_Uv0S);
ListPool<Vector2>.Release(m_Uv1S);
ListPool<Vector2>.Release(m_Uv2S);
ListPool<Vector2>.Release(m_Uv3S);
ListPool<Vector3>.Release(m_Normals);
ListPool<Vector4>.Release(m_Tangents);
ListPool<int>.Release(m_Indices);

m_Positions = null;
m_Colors = null;
m_Uv0S = null;
m_Uv1S = null;
m_Uv2S = null;
m_Uv3S = null;
m_Normals = null;
m_Tangents = null;
m_Indices = null;

m_ListsInitalized = false;
}
}

// ...
}

细心的读者可能会发现,VertexHelper 实现了 IDisposable 接口,这是因为 VertexHelper 中使用了 ListPool 来获取内存,因此需要在使用完毕后释放内存。Dispose 方法中就是释放内存的逻辑。关于 C# 中的 IDisposable 接口,可以参考 C# 的 GC。

由于 VertexHelper 实现了 Dispose 方法,因此在使用时必须显式调用 Dispose 方法或使用 using 语句来释放内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// <summary>
/// Clear all vertices from the stream.
/// </summary>
public void Clear()
{
// Only clear if we have our lists created.
if (m_ListsInitalized)
{
m_Positions.Clear();
m_Colors.Clear();
m_Uv0S.Clear();
m_Uv1S.Clear();
m_Uv2S.Clear();
m_Uv3S.Clear();
m_Normals.Clear();
m_Tangents.Clear();
m_Indices.Clear();
}
}

Clear 方法用于清空所有的顶点数据,可以用于提交完数据后的重置。

VertexHelper 的顶点操作

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
25
26
27
28
public void AddVert(Vector3 position, Color32 color, Vector2 uv0, Vector2 uv1, Vector2 uv2, Vector2 uv3, Vector3 normal, Vector4 tangent)
{
InitializeListIfRequired();

m_Positions.Add(position);
m_Colors.Add(color);
m_Uv0S.Add(uv0);
m_Uv1S.Add(uv1);
m_Uv2S.Add(uv2);
m_Uv3S.Add(uv3);
m_Normals.Add(normal);
m_Tangents.Add(tangent);
}

public void AddVert(Vector3 position, Color32 color, Vector2 uv0, Vector2 uv1, Vector3 normal, Vector4 tangent)
{
AddVert(position, color, uv0, uv1, Vector2.zero, Vector2.zero, normal, tangent);
}

public void AddVert(Vector3 position, Color32 color, Vector2 uv0)
{
AddVert(position, color, uv0, Vector2.zero, s_DefaultNormal, s_DefaultTangent);
}

public void AddVert(UIVertex v)
{
AddVert(v.position, v.color, v.uv0, v.uv1, v.normal, v.tangent);
}

AddVert 方法用于向 VertexHelper 中添加一个顶点,有多种版本的重载可以选择。这里的顶点信息就是 UIVertex 结构体中的信息。

1
2
3
4
5
6
7
8
public void AddTriangle(int idx0, int idx1, int idx2)
{
InitializeListIfRequired();

m_Indices.Add(idx0);
m_Indices.Add(idx1);
m_Indices.Add(idx2);
}

AddTriangle 方法用于向 VertexHelper 中添加一个三角形,传入的参数是三个顶点的索引。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/// <summary>
/// Fill the given mesh with the stream data.
/// </summary>
public void FillMesh(Mesh mesh)
{
InitializeListIfRequired();

mesh.Clear();

if (m_Positions.Count >= 65000)
throw new ArgumentException("Mesh can not have more than 65000 vertices");

mesh.SetVertices(m_Positions);
mesh.SetColors(m_Colors);
mesh.SetUVs(0, m_Uv0S);
mesh.SetUVs(1, m_Uv1S);
mesh.SetUVs(2, m_Uv2S);
mesh.SetUVs(3, m_Uv3S);
mesh.SetNormals(m_Normals);
mesh.SetTangents(m_Tangents);
mesh.SetTriangles(m_Indices, 0);
mesh.RecalculateBounds();
}

FillMesh 方法用于将 VertexHelper 中的顶点数据填充至 Mesh 对象中。这里实际的实现也非常简单,就是调用 Mesh 对象的相应方法来填充数据。需要注意的是,Mesh 对象的顶点数量不能超过 65000,因此在填充数据前会进行检查。此外, Mesh 中这些方法的实现也是不开放的,因此我们只能分析到这里。

实际上,利用上述的三个方法,我们就可以生成一个完整的 Mesh 了。首先利用 AddVert 方法添加顶点,然后利用 AddTriangle 将顶点连接成三角形,最后调用 FillMesh 方法将数据填充至 Mesh 对象中。

而 Graphic 中也正是这么做的,如 OnPopulateMesh 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected virtual void OnPopulateMesh(VertexHelper vh)
{
var r = GetPixelAdjustedRect();
var v = new Vector4(r.x, r.y, r.x + r.width, r.y + r.height);

Color32 color32 = color;
vh.Clear();
vh.AddVert(new Vector3(v.x, v.y), color32, new Vector2(0f, 0f));
vh.AddVert(new Vector3(v.x, v.w), color32, new Vector2(0f, 1f));
vh.AddVert(new Vector3(v.z, v.w), color32, new Vector2(1f, 1f));
vh.AddVert(new Vector3(v.z, v.y), color32, new Vector2(1f, 0f));

vh.AddTriangle(0, 1, 2);
vh.AddTriangle(2, 3, 0);
}

就是这么的朴实无华。

其他函数

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
25
26
27
public void PopulateUIVertex(ref UIVertex vertex, int i)
{
InitializeListIfRequired();

vertex.position = m_Positions[i];
vertex.color = m_Colors[i];
vertex.uv0 = m_Uv0S[i];
vertex.uv1 = m_Uv1S[i];
vertex.uv2 = m_Uv2S[i];
vertex.uv3 = m_Uv3S[i];
vertex.normal = m_Normals[i];
vertex.tangent = m_Tangents[i];
}

public void SetUIVertex(UIVertex vertex, int i)
{
InitializeListIfRequired();

m_Positions[i] = vertex.position;
m_Colors[i] = vertex.color;
m_Uv0S[i] = vertex.uv0;
m_Uv1S[i] = vertex.uv1;
m_Uv2S[i] = vertex.uv2;
m_Uv3S[i] = vertex.uv3;
m_Normals[i] = vertex.normal;
m_Tangents[i] = vertex.tangent;
}

PopulateUIVertex 方法用于将 VertexHelper 中的第 i 个顶点数据填充至 UIVertex 对象中,而 SetUIVertex 方法则是将 UIVertex 对象填充至 VertexHelper 中的第 i 个顶点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/// <summary>
/// Add a quad to the stream.
/// </summary>
/// <param name="verts">4 Vertices representing the quad.</param>
public void AddUIVertexQuad(UIVertex[] verts)
{
int startIndex = currentVertCount;

for (int i = 0; i < 4; i++)
AddVert(verts[i].position, verts[i].color, verts[i].uv0, verts[i].uv1, verts[i].normal, verts[i].tangent);

AddTriangle(startIndex, startIndex + 1, startIndex + 2);
AddTriangle(startIndex + 2, startIndex + 3, startIndex);
}

AddUIVertexQuad 方法用于直接添加一个四边形,传入的参数是四个 UIVertex 对象。函数中会自动将顶点连接成两个三角形。

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
/// <summary>
/// Add a stream of custom UIVertex and corresponding indices.
/// </summary>
/// <param name="verts">The custom stream of verts to add to the helpers internal data.</param>
/// <param name="indices">The custom stream of indices to add to the helpers internal data.</param>
public void AddUIVertexStream(List<UIVertex> verts, List<int> indices)
{
InitializeListIfRequired();

if (verts != null)
{
CanvasRenderer.AddUIVertexStream(verts, m_Positions, m_Colors, m_Uv0S, m_Uv1S, m_Uv2S, m_Uv3S, m_Normals, m_Tangents);
}

if (indices != null)
{
m_Indices.AddRange(indices);
}
}

/// <summary>
/// Add a list of triangles to the stream.
/// </summary>
/// <param name="verts">Vertices to add. Length should be divisible by 3.</param>
public void AddUIVertexTriangleStream(List<UIVertex> verts)
{
if (verts == null)
return;

InitializeListIfRequired();

CanvasRenderer.SplitUIVertexStreams(verts, m_Positions, m_Colors, m_Uv0S, m_Uv1S, m_Uv2S, m_Uv3S, m_Normals, m_Tangents, m_Indices);
}

/// <summary>
/// Create a stream of UI vertex (in triangles) from the stream.
/// </summary>
public void GetUIVertexStream(List<UIVertex> stream)
{
if (stream == null)
return;

InitializeListIfRequired();

CanvasRenderer.CreateUIVertexStream(stream, m_Positions, m_Colors, m_Uv0S, m_Uv1S, m_Uv2S, m_Uv3S, m_Normals, m_Tangents, m_Indices);
}

剩余的三个函数均与 CanvasRenderer 有关,由于没有开放源码。因此这里只能结合官方文档大致理解这些函数的功能。

  • AddUIVertexStream:用于向 CanvasRenderer 中添加一组 UIVertex 数据以用于后续的渲染。

  • AddUIVertexTriangleStream:用于向 CanvasRenderer 中添加一组三角形数据,这里的 UIVertex 数据是按照三角形的顺序排列的。

  • GetUIVertexStream:用于从 CanvasRenderer 中获取 UIVertex 数据,填充至 stream 中。

IMaterialModifier & IMeshModifier

IMaterialModifier 和 IMeshModifier 是两个接口,用于在 Graphic 的渲染过程中对材质和网格进行修改。

1
2
3
4
5
6
7
8
9
public interface IMaterialModifier
{
Material GetModifiedMaterial(Material baseMaterial);
}

public interface IMeshModifier
{
void ModifyMesh(Mesh mesh);
}

其中,IMaterialModifier 在 UGUI 中主要是完成一些 Mask 的操作,而 IMeshModifier 则主要用于实现阴影和描边等效果。实现这些接口的具体类我们放到下一章分析。

总结

至此,我们就几乎分析完了 UGUI 中 Graphic 类的实现。作为所有 UI 组件的基类,Graphic 主要实现的功能便是对 UI 的渲染。本篇也着重分析了 Graphic 的渲染流程。结合下图能够更清楚地理解 Graphic 的工作流程。

Graphic.png

值得注意的是,Graphic 中还涉及到一些内容本文并没有展开分析,比如GraphicRaycaster、IMaterialModifier、IMeshModifier 等内容,这些会在后续的文章中继续分析。此外,Graphic 还涉及到了一些与动画相关的功能,即 TweenRunner。由于这部分内容对于理解 UGUI 的原理来说并不重要,因此本系列文章不会分析。