UI 基础
Canvas(画布),和名字一样,是 UI 绘制的地方,Unity 的渲染系统用其来提供一个可绘制的分层几何。负责将 UI 几何合批成适合的网格,提交绘制命令给 Unity 的图形系统,这整个过程叫做 rebatch 或者 batch build。当 Canvas 其子节点下包含 Canvas Renderer 的节点需要进行 rebatch 的时候,就会被标记为脏。
Sub-canvas 是嵌套在 Canvas 里的 Canvas,它与其父节点是隔离的,Sub-Canvas 的 rebuild 不会逼迫父节点重建几何,反之亦然,不过还是存在一些边界情况。。比如父节点变化导致子节点大小变化。
Graphic 是 unity 的 ui C# 基类,大部分 unity ui 都是 通过继承 MaskableGraphic 类来实现的,后者比前者多了可遮罩(Maskbale)的能力。该类让 ui 描述并创建出自身需要绘制的网格。
Layout 组件为 ui 提供了布局管理能力,也就是控制 RectTransform 的 位置和大小。它不会受到 Graphics 的影响。
什么是 Rebuild、Rebatch
Graphic 与 Layout 都依赖于 CanvasUpdateRegistry 类。它会定位 Graphic 与 Layout 是否需要更新并加入更新队列,在所在 Canvas 的 willRenderCanvases 事件被触发时对队列中的对象执行真正的更新。这些更新被称作 rebuild。 不要和上面的 rebatch 弄混了。
Batch building 的过程,Canvas 会将其 UI 元素生成的 mesh 组合并生成合适的绘制命令给 Unity 渲染系统。并且过程的结果会被缓存并重用,直到 Canvas 重新被标记为脏。这会在组合的网格发生变化时发生。Canvas 会根据子节点里带有 Canvas Renderer 组件的 ui 来生成 网格,但是不包括 Sub-Canvas 的子节点,也就是说每个 Canvas 单独负责自身的 Batch building。Batch building 的过程会对根据深度、重叠测试、材质等条件对各个 Mesh 进行排序、分组、合并,这个过程是多线程的,在移动端(核心少)与桌面端(核心多)会呈现相当大的差异。Rebatch 是有多线程的加持的,而 Rebuild 是在主线程的。
Rebuild 的过程就是布局与网格(这里指的不是最终 rebatch 生成的网格,而是 Graphic 子类或者说 ui 生成的网格)重新计算的过程。 CanvasUpdateRegistry 会维护若干队列,里面有标记了 Layout 或者 Graphic 脏标记的 ui 节点,在 CanvasUpdateRegistry 中,最重要的方法是 PerformUpdate。每当 Canvas 组件调用 WillRenderCanvases 事件时,就会调用此方法。此事件每帧调用一次。
PerformUpdate 执行流程:
- 被标记为 Dirty 状态的 Layout 通过 ICanvasElement.Rebuild 方法请求重建布局。
- 所有裁剪组件(例如 Mask),对需要被裁剪的组件进行剔除。这在 ClippingRegistry.Cull 中执行。
- 被标记了 Dirty 状态的 Graphic 请求重建他们的图形元素。
这样所有 UI 节点的网格、材质、贴图就确定了。之后 Canvas 会从 Canvas Render 中取出这些东西,并重新生成网格(绘制顺序实际上是在这里确定的)与绘制命令交给渲染系统(也就是 rebatch)。
对于 Layout 和 Graphic 的重建,过程分为多个部分。布局重建分为三个部分(预布局、布局和后期布局),而图形重建分为两个部分(预渲染和后期预渲染)。细节可以直接看 源码 。
Layout 的 Rebuild
要重新计算包含一个或多个 Layout 的适当位置和大小,必须以适当的层级顺序进行布局计算,因为在 GameObject 在 Hierarchy 中靠近根的 Layout,很可能会影响嵌套其中的子物体的 Layout 的位置和大小,所以必须优先计算。
为了做到这一点,UGUI 把被标记为 Dirty 状态的 Layout 按照它们在 Hierarchy 中的层级对它们进行排序。Hierarchy 中较高的项(即父变换较少的项)将移动到列表的前面。
然后这些排序后的 Layout 列表会请求重建它们的布局,这过程是那些 UI 元素被 Layout 真正改变位置和大小的过程。
Graphic 的 Rebuild
当 Graphic 进行 Rebuild 时,UGUI 将控制权转交给 ICanvasElement 接口的 Rebuild 方法。Graphic 类实现了这个接口,并在 Rebuild 过程的预渲染阶段执行两个不同的重建步骤。
- 如果顶点数据已标记为 Dirty 状态(如组件的矩形变换更改大小时),则重建网格。
- 如果材质数据已标记为 Dirty 状态(如组件的材质或纹理发生改变时),则将更新附着的画布渲染器的材质。
Graphic 的 Rebuild 不需要按特定顺序遍历 Graphic 组件列表,也不需要任何排序操作。
Rebuild 是怎么被触发的
Graphic 对象一共有两种脏标记,以下我只写一些常见引起脏标记行为
顶点被标记为脏
- 当修改 fillAmount 的数值时
- 修改了 RectTransform
- 修改 Image 的 color
- 禁用或启用 SetActive(两个都会标记为脏)
- 设置 Image 的 SetNativeSize(两个都会标记为脏)
- 替换 Sprite(两个都会标记为脏)
材质被标记为脏
- 替换材质
- 修改 RectTransform
ILayoutGroup(ScrollRect,LayoutGroup)对象发生脏标记
布局脏标记
- Graphic 对象(Image)的禁用或激活
- Text 对象的字体大小修改或布局修改
Rebatch 是怎么被触发的
当 Canvas 下有 Mesh 发生改变时,Rebuild 通常都会引发 Rebatch,UI 的位置移动也会触发 Rebatch。如:
- SetActive
- Transform 属性变化
- Graphic 的 Color 属性变化(改 Mesh 顶点色)
- Text 文本内容变化
- Depth 发生变化
UI Canvas 的 rebuild 性能消耗和优化
Canvas 的 rebuild 产生性能问题的原因主要有两种
- Canvas 下 ui 元素过多,导致大量 ui 元素在合批过程的 分析、排序开销过大,这个过程的复杂度是大于 O(n) 的,所以会 ui 元素数目影响很大。
- Canvas 频繁地被标记为脏,即使 Canvas 只有很少的变动,但还是会刷新从而花费用于刷新
所以要尽可能地将会频繁刷新的 ui 拆分成多个 Canvas,因为哪怕 Canvas 下一个 ui 元素的变动都会导致整个 Canvas 的 重新合批(这里说得是合批(batch),单个 ui 元素的变动(指会对 Cannvasvas Rednder 产生影响,无论是材质、贴图、网格)一般不会导致其他 ui 的 rebuild)。不过也不能过多,因为 Canvas 相互之间是不会合批的。“动静分离”是 Unity Canvas 拆分的一个指导依据,将频繁刷新的部分,比如进度条,还有长时间静态的部分,比如各种背景,拆成两部分,这样前者的刷新就不会影响后者。
Rebatch 是在 Canvas.BuildBatch 函数中进行,而在 Unity 5.2 版本后,已经对 Canvas.BuildBatch 做了优化,优化后使用子线程进行计算,已经很大程度缓解了主线程的压力,目前来说动静分离并没有那么需要关注了。
上面讲到在两个 ui 元素 之间 插入 不可合批(拥有不同渲染状态,材质或者贴图不同)的且与它们 重叠 的 ui 元素,就会打断 ui 的合批,他们会被拆成三份网格,从而增加 drawcall。因为排序是按照 hierarchy 下的顺序来的,所以可以通过调整层级顺序来解决。此外也可以通过调整 ui 大小来恢复合批,或者将贴图打成一张图集从而能共享渲染状态而合批。这三种方法分别是通过消除 插入元素之间 、重叠 、不可合批 这三个条件来达到合批的,总之只要知道打断合批或者说成功合批的条件,就能针对性地进行优化。
如何根据 Profiler 数据分析优化
- Canvas.BuildBatch 或者 Canvas:: UpdateBatches 占用过多 CPU 时间,说明 batch build 过度,需要考虑拆分成多个 Canvas。
- 填充率过高,则考虑优化 overdraw。
- WillRenderCanvas 大部分时间花在 IndexedSet_Sort 或者 CanvasUpdateRegistry_SortLayoutList 方法上(代表了过多的 Layout rebuild,不一定只是这两方法,具体看源码),则考虑减少布局组件的使用,UI Profiler 中 Layout 也是同理。
- UI Profiler 中 Graphics 或者 PopulateMesh 之类的方法开销占比大,则说明 Graphic 的 rebuild 是性能热点。这个要结合具体组件分析。
- 如果发现 Canvas.SendWillRenderCanvases 每帧都被调用,那就要找找是什么导致了 Canvas 的频繁 rebuild,考虑通过拆分 Canvas 来缓解。