Refs:
Unity3D UGUI系列之合批
UGUI学习 - 合批规则
UGUI合批源码分析及优化

前言

无论是我们的日常开发还是面试,UI 的合批永远是一个绕不开的话题。本文将分析 UGUI 中的合批机制,力图帮助大家以及笔者自己彻底搞清楚这一重要的概念。

批处理

批处理是渲染中常用的一种优化手段,它的核心思想是将多个绘制操作合并为一个绘制操作,从而减少 CPU 与 GPU 之间的通信次数(即 Draw Call),提高渲染效率。

在介绍批处理之前我们先看看一个普通的3D模型是怎么渲染出来的(详见 图形学补完计划(二):Rendering Pipeline):

  1. CPU 准备好这个模型的网格、用到的贴图和Shader,然后 GPU 将网格、贴图、Shader加载到显存里面。

  2. CPU 设置渲染状态。所谓设置渲染状态,就是 CPU 设置渲染网格时需要使用的使用的Shader,贴图等信息。

  3. CPU 设置完毕渲染状态后,向 GPU 发出渲染的指令,然后GPU才开始按照 2 中设置的 Shader 和贴图真正渲染这个模型,并把渲染后的结果层递到屏幕上。CPU 向 GPU 发出渲染指令的过程即为 Draw Call。

从上面的流程可以看出,每一个 3D 模型要被渲染都应该会经过一个完整的渲染流程,调用一次 Draw Call。如场景中有 10 个模型,那么 Draw Call 数量就应该是 10,但大多数情况下查看统计面板我们会发现并没有这么多的 Draw Call。为什么会这样呢? 这就是批处理的作用了。

image.png

批处理就是把渲染时使用相同材质(Shader)、相同贴图的3D模型的网格合并在一起,成为一个大网格,然后再调用一次Draw Call,直接渲染这一个合并后的网格。所以需要注意的是,一定要使用相同材质和相同贴图的模型才可以批处理,一个模型使用的材质或贴图与其他模型不同,那么 CPU 就得单独进行步骤 2 设置渲染状态,紧接着也就得单独进行步骤 3 调用 Draw Call,即无法进行批处理。

UGUI-7:Graphic 以及 UGUI-8:Text 中提到过,UGUI 中的图片与文字也由 Mesh 组成,因此 UI 元素的合批机制与上述的 3D 模型合批机制也是类似的。

在 UGUI 中,合批是以 Canvas 为单位的,即在同一个 Canvas 下符合合批条件的 UI 元素最终都会被 Batch 到同一个 Mesh 中。而在 Batch 前,UGUI 会根据这些 UI 元素的材质以及渲染顺序进行重排,在不改变渲染结果的前提下,尽可能将相同材质的 UI 元素合并在同一个 SubMesh 中,从而把 DrawCall 降到最低。而 Batch 的操作只会在 UI 元素发生变化时才进行(如Rebuild),且合成的 Mesh 越大,操作的耗时也就越大。

UGUI 合批流程

UGUI以Canvas为单位进行批次生成和渲染,Canvas可以嵌套包含Canvas。Batching的生成和合并在 Canvas::UpdateBatch() 中:

UpdateBatch

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
bool Canvas::UpdateBatch ()
{
float alpha = CalculateCanvasAlpha();
UpdateBatchOrder();
UI::GetCanvasManager().UpdateDirtyRenderers(this),

if(m_CanvasData.isDirty)
{
PROFILER_AUTO(gCanvasBuildBatch, this)
CreateBatchoutput (m_CanvasData.instructions.begin(), alpha > 0.0f ? m_CanvasData.instructions.size() : 0, alpha, m_BatchedData);
m_CanvasData.isDirty = false;

// TODO: 0nly do this is the batchedData.mesh changed
SetCachedMeshBoundsDirty():
}

int materialCount = 0;
std::map<UInt32, BatchOutput>::iterator iterator = m_BatchedData.begin();
while(iterator != m_BatchedData.end())
{
materialCount += iterator->second.materials.size();
++iterator;
}

return materialCount > 0;
}

UpdateBatch() 主要流程如下:

  1. 计算 Canvas alpha,包括父 Canvas 和嵌套 Canvas(alpha=0不生成合并批次)。

  2. UI 层次结构发生变化时,更新 Batch 顺序,对 Canvas 下所有UI元素(CanvasRenderer)按 UI 层次结构深度优先排序,生成 UI Instructions。

  3. 更新所有需要同步数据的 renderer UI 数据,包括 vertex, color, material, transform, rect, depth(按UI层次结构深度优先排序的深度)等。非活动(IsActive() == false)且不强制更新的UI元素,将不同步数据。

  4. Canvas 数据更新时(m_CanvasData.isDirty,如 UI层次结构改变,同步关键数据,Canvas.Awake等),计算 UI Instructions 的 depth 并排序、生成 Batch。

Mask 的特殊处理

UGUI-8:IMeshModifier & IMaterialModifier 中我们提到了利用模板缓冲区进行遮罩的 Mask 组件,该组件的渲染过程较为特殊,在 Canvas::UpdateBatchOrder() 中可以看到如下的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void Canvas::UpdateBatchOrder()
{
if (m_CanvasData.orderIsDirty)
{
int nestedDepth = 0;
dynamic_array<CanvasRenderer*> renderers(kMemTempAlloc);
GetComponentsInChildren<CanvasRenderer>(GetGameObject(), renderers);

ClearCanvasData(m_CanvasData);
ClearNestedCanvasDepths(m_CanvasData);

// stack to store a reference to a renderer that added mask
// this renderer will be inserted a second time when we unmask
// but will be rendered with the configured unmask material
dynamic_array<CanvasRenderer*> maskStack(kMemTempAlloc);
for (int i = 0; i < (int)renderers.size(); i++)
{
// ...
}
}
}

即对于一个 Mask 而言,会额外调用两次 DrawCall,第一次用于绘制 Stencil Mask 以用于模板测试,第二次则用于移除 Stencil MaskMask 内的元素在满足合批条件的情况下可以合批;Mask 外的元素和 Mask 内的元素则始终无法合批。

Depth 的计算

遍历所有 UI 元素(已深度优先排序),对当前每一个 UI 元素 CurrentUI,如果不渲染,CurrentUI.depth = -1,如果渲染该 UI 且底下没有其他 UI 元素与其相交(rect.Intersects()),其 CurrentUI.depth = 0

如果 CurrentUI 下面只有一个的需要渲染的 UI 元素 LowerUI 与其相交,且可以与 CurrentUI 合批(即材质与纹理相同),则 CurrentUI.depth = LowerUI.depth,否则,CurrentUI.depth = LowerUI.depth + 1;

如果 CurrentUI 下面叠了多个元素,这些元素的最大层是 MaxLowerDepth,如果有多个元素的层都是 MaxLowerDepth,那么 CurrentUI 和下面的元素是无法合批的;如果只有一个元素的层是 MaxLowerDepth,并且这个元素和 CurrentUI 的材质、纹理相同,那么它们就能合批。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
for (int j = jStart; j < jEnd; j++)
{
if (output[j].depth == -1)
continue;

// among all the lower blocks we are looking for and intersecting element with the highest depth
const Rectf& lowerRect = rects[j];
if (rect.Intersects(lowerRect))
{
if (CanBatch(uiInstruction[i], uiInstruction[j]))
highestIntersectingDepth = std::max(output[j].depth, highestIntersectingDepth);
else
{
const int lowerBlockDepth = output[j].depth;
highestIntersectingDepth = std::max(lowerBlockDepth + 1, highestIntersectingDepth);
}
}
}

在 Depth 计算算法中,由于要遍历所有 UI 元素和已计算的底层 UI 元素(平方复杂度),源码中使用分组计算包围盒矩形的方法加快计算,即 16 个UI元素为一组计算 Group Rect,检查是否与底层UI元素相交时,先计算是否与底层Group相交,如果相交再与Group中的元素做判定

因此,UI元素数目过多和层次结构过于复杂,会影响排序和 Batch 更新速度,合理规划 UI 元素数量和层次结构可以提高 UI 性能。

VisiableList 的处理

在所有 UI 元素的 depth 计算完毕后,会依次根据它们的 Depthmaterial IDtexture IDRendererOrder 进行排序,并剔除所有 depth = -1 的元素,得到 Batch 前的 UI 元素队列 VisibleList

最后,对 VisibleList 中相邻且可以合批(相同 material ID 与相同 texture ID 等)的 UI 元素进行合批,然后再生成相应的 mesh 进行绘制。

1
2
3
4
5
6
7
8
9
10
11
// first sort by depths
// if they're equal, sort by materials
if (a.depth != b.depth)
return a.depth < b.depth;
if (a.materialInstanceID != b.materialInstanceID)
return a.materialInstanceID < b.materialInstanceID;
if (a.textureInstanceID != b.textureInstanceID)
return a.textureInstanceID < b.textureInstanceID;

// all else being equal... sort by render order
return a.renderOrder < b.renderOrder;

这样,Canvas 就完成了 Batch 的生成和渲染。

需要注意的是,合批是将同一 Canvas 下多个 UI 的网格合并在一起,如果其中任何一个元素的材质、网格顶点、位置(Transform)甚至颜色发生改变,或者在该 Canvas 下动态创建或删除UI元素,都将导致该 Canvas 重新计算合批(需要注意的是仅仅会影响这一个 Canvas,子 Canvas 或父 Canvas 以及其他 Canvas 不会重新计算),重新生成新的网格,这个重新计算生成网格的过程被称为 Reuild。所以,这也是为什么做 UI 提倡动静分离(动态部分和静态部分分别用不同的Canvas),层级尽量减少(层级多了,重新计算更耗时)的原因。

🌰

下面以一些案例直观展示 UGUI 的合批机制:

简单情况的合批

在场景中创建一些默认材质与纹理的 Image,打开 StatsFrame Debugger 以及 Profiler,查看 Draw Call 数量。由于这些 Image 均为默认材质与纹理,因此满足合批条件。

image.png

可以发现 Batches 只有 1,即代表绘制所有的 Image 只发起了一次 Draw Call,也即进行了合批。

image.png

image.png

Frame Debugger 以及 Profiler 可以更清晰地看到绘制过程,即一次性将所有的 Image 全部绘制出。

不同材质或纹理

我们将第一张图片换为默认的 Text 组件,同样进行分析。由于文字与图片所使用的纹理是不同的,前者使用的是字体纹理,而默认的图片使用的是默认的白色纹理,因此二者无法进行合批。

image.png

可以发现 Batches 变为了 2,即代表绘制所有的 ImageText 需要发起两次 Draw Call,也即无法进行合批。

image.png

image.png

观察 Profiler 可以发现,TextImage 分别进行了绘制,且 Batch Break ReasonDifferent Texture,即二者的纹理不同。

除了合批被打断的原因之外还有一点值得注意,在上述的案例中,Text 实际上是在后续的 Image 组件之前的,应该先被渲染才对,然而实际上 Text 却是在 Image 之后被渲染,这是为什么呢?

其原因就是上一节中提到的 VisibleList 的排序。在 Canvas 的合批过程中,会对 VisibleList 中的元素按照 DepthMaterial IDTexture ID 以及 Renderer Order 进行排序,值越小的排在越前面。在这里的案例中,TextImageDepth 都为 0,且二者的材质都是 UI/Default,因此渲染的顺序就取决于 Texture ID 了,为上述的物体挂载如下的脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using UnityEngine;
using UnityEngine.UI;

[RequireComponent(typeof(Graphic))]
public class PrintShaderInfo : MonoBehaviour
{
Graphic graphic1;
Texture2D texture1;
Material material1;

private void Awake() {
graphic1 = GetComponent<Graphic>();
texture1 = graphic1.mainTexture as Texture2D;
material1 = graphic1.materialForRendering;
}

private void Start() {
Debug.Log($"Current GameObject: {gameObject.name}, " +
$"Current Texture ID: {texture1.GetInstanceID()}, " +
$"Current Material ID: {material1.GetInstanceID()}");
}
}

运行后可以发现如下的输出:

image.png

我们发现 ImageTexture ID 确实比 Text 的要小,且二者的 Material ID 也确实是相同的,因此 Image 会先被渲染。

Depth 的影响

UI 元素的 Depth 也会影响合批的结果,Depth 即取决于 UI 元素在 Canvas 下的层级以及与其相交的 UI 元素的材质和纹理等。我们逐一实验:

首先创建一些 Image,其中部分 Image 有交集。

image.png

可以发现,即使图像之间有交集,但由于它们使用的都是相同的材质与纹理,因此所有的图像 Depth 都为 0,因此它们可以进行合批。只会产生一个 Draw Call

接着,我们在这些图像之间插入一个 Text,并使得二者的网格相交。注意,一定要保证网格相交

image.png

我们会发现,Batches 的数量变为了 3,让我们从 VisibleList 排序的角度分析一下这个结果:

  1. 首先,白色图片与文本均在最下方,因此它们的 Depth 都为 0;虽然二者在 VisibleList 中相邻,但由于纹理不同,因此无法进行合批;我们需要两个 Draw Call 来分别绘制它们。

    image.png

    image.png

  2. 其次,橙色图片同时与白色图片以及文本相交,虽然与白色图片的材质与纹理都相同,但由于与文本相交,因此橙色图片自身的 Depth 为 1,无法与白色图片合批;同样地,绿色图片也与文本相交,因此 Depth 也为 1。但由于橙色与绿色图片的材质与纹理均相同,且二者的 Depth 也相同,因此它们可以进行合批。因此,我们需要第三个 Draw Call 来绘制橙色图片以及绿色图片。

    image.png

明白了 VisibleList 的排序规则,我们就能够更好地理解合批的过程。

Mask 以及 RectMask2D 对于合批的影响

两种遮罩分别对合批过程有着不同的影响,我们逐一实验:

Mask

创建一个 Mask,并在其中放置一些 Image

image.png

可以发现 Batches 直接变为了 3,这说明了两点:1. 一个 Mask 组件会额外产生两次 Draw Call;2. Mask 内部与外部的元素无法进行合批。

观察 Profiler 的渲染过程:

image.png

首先,调用一个 Draw Call 绘制 MaskStencil Mask

image.png

其次,调用第二个 Draw Call 绘制 Mask 中的内容,可以看到在绘制 Mask 内部的图像时进行了合批。

image.png

最后,使用第三个 Draw CallStencil Mask 移除。

可以发现两次合批被打断的原因都是因为 Different Material Instance,即三者的材质虽然相同,但却是不同的实例。这一点在研究完了 Mask 的原理后想必可以轻松理解:Mask.GetModifiedMaterial(),会生成两个材质实例,一个用于遮罩另一个用于清除模板缓冲区。虽然属于同一个材质但是对 Stencil Buffer 的设置不同;同时,Mask 内部的图像也会生成一个新的材质实例,设置仅在 Mask 范围内进行绘制。三者的材质实例都是不同的,因此无法进行合批。但 Mask 内部的图像如果材质与纹理相同则都能合批。

由上述的分析可以得知,Mask 会额外产生两次 Draw Call,且合批被打断的原因是因为生成了不同的材质实例。但也正是因为如此,多个相同的 Mask 内部元素能够进行合批

举例来说,我们复制多份上述的 Mask

image.png

会发现虽然图像数量变为了三倍,但 Batches 的数量却仍然是 3,这说明了多个相同的 Mask 能够进行合批。

image.png

image.png

image.png

查看 Profiler 也可以发现,在第二个 Batch 中同时处理了所有 Mask 内部的元素。

RectMask2D

RectMask2D 遮罩的原理是直接设置了 canvasRendererclipRect,相比于 Mask 不会涉及复杂的模板缓冲区设置,对于合批的影响也有所不同。

我们创建一个 RectMask2D,并在其中放置一些 Image

image.png

可以发现 Batches 的数量仅有 2,说明一个 RectMask2D 只会产生一个额外的 Draw Call。同样查看 Profiler 的渲染过程:

image.png

image.png

合批被打断的原因为 Different Rect Clipping,这也正是 RectMask2D 的原理所在。同样地,因为这个原因,多个相同的 RectMask2D 内部元素无法合批

同样地,复制多份 RectMask2D

image.png

会发现 Batches 的数量居然变为了 4,查看 Profiler 的渲染过程:

image.png

image.png

image.png

image.png

可以发现,虽然 RectMask2D 本身依然能够合批处理,但多个 RectMask2D 之间无法合批,即每个 RectMask2D 在绘制内部元素时都会产生一个额外的 Draw Call。这是因为每个 RectMask2D 所对应的 ClipRect 是不同的,CPU 需要单独设置渲染状态。

遮罩之间相交的情况

在上面的例子中,我们研究了图片以及文本相互相交的情况,并且发现由于 Depth 的原因,会出现无法合批的情况。对于遮罩也是如此:当一个遮罩与另一个遮罩的内部元素相交时,也会出现无法合批的情况。这一点对于 Mask 以及 RectMask2D 都适用

并且需要注意的是,遮罩区域之外的元素虽然已经被裁剪,但依然会影响相交以及 Depth 的计算,从而影响合批。

如下面的两个例子,相交的两个 Mask 无法进行合批,从而产生了 6 个 Draw Call:

image.png

并且与 Mask 区域之外的元素相交也有相同的效果。

image.png

对于 RectMask2D 也是如此:

image.png

image.png

image.png

SpriteAtlas(图集) 对于合批的影响

UGUI-10:Image & RawImage 中,我们分析了使用 SpriteAtlas 的优势,
其中提到了 SpriteAtlas 对于合批的影响。这里我们再次总结一下:

我们将需要显示的 Sprite 打包进同一个 SpriteAtlas 中,会发现场景中的 Draw Call 数量确实为 1,即所有的 Sprite 都被合批到了同一个 Mesh 中。

image.png

此外,通过打印它们的 Texture ID 以及 Material ID,我们可以发现它们的 Texture ID 都是相同的。

image.png

也就是说,打包进同一个 SpriteAtlas 中的 Sprite 会被当成同一个纹理,因此它们可以进行合批。

此外,当我们使用由一张 Sprite 通过 Sprite Editor 切割出的多个 Sprite 时,也会发现它们的 Texture ID 是相同的,因此也可以进行合批。

image.png

image.png

利用合批机制优化 UI 界面

在了解了 UGUI 的合批机制后,我们可以总结出一些 UI 界面制作时的一些优化方法了:

  1. 将需要用到的 Sprite 打包进同一个 SpriteAtlas 中。

  2. 一些固定的 Text 可以考虑用图片代替。

  3. 避免频繁删除/增加 UI 对象, UI 层次结构变化会引起 Canvas 的更新(Rebuild)。

  4. 避免频繁更新 UI 元素的 VertexRectColorMaterialTexture 等数据,这样可能会引发 Canvas 重新计算 Batch。

  5. Mask 会增加至少两个 Draw Call,而 RectMask2D 会增加一个 Draw Call,但二者的合批机制不同,因此优化思路也不同:

    1. 当场景中的 Mask 内部元素材质纹理等相同时,使用 Mask 要优于 RectMask2D,因为 Mask 内部元素能够进行合批。
    2. 当场景中的 Mask 内部元素材质纹理等不同时,使用 RectMask2D 要优于 Mask,因为 Mask 会产生更多额外的 Draw Call
  6. ShadowOutline 等效果以及 Image 除了 Simple 以外的 Type 都会增加额外的顶点,不需要时尽量避免使用。