Unity UI 优化学习总结(转)

Unity 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的影响。

Graphci 与 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 进行排序、分组、合并,这个过程是多线程的,在移动端(核心少)与桌面端(核心多)会呈现相当大的差异。

Rebuild 的过程就是布局与网格(这里指的不是最终 rebatch 生成的网格,而是 Graphic 子类或者说 ui 生成的网格)重新计算的过程。CanvasUpdateRegistry 会维护若干队列,里面有标记了 Layout 或者 Graphic 脏标记的 ui 节点,在 PerformUpdate 方法里依次对各个队列节点作如下处理

  1. Layout 进行更新(会改变节点 RectTransform ,该组件会在 Graphic 更新时被引用)
  2. 进行裁剪(官方文档这里举例说 Mask,但事实上内置ui实现了 IClipper 接口的只有 RectMask2D,Mask 是通过更新 stencil buffer然后ui都会测试的原理来实现的)
  3. 然后对 Graphic 进行更新(更新节点上的 Canvas Render 网格、材质、贴图)

这样所有 ui 节点的网格、材质、贴图就确定了。之后 Canvas 会从 Canvas Render 中取出这些东西,并重新生成网格(绘制顺序实际上是在这里确定的)与绘制命令交给渲染系统(也就是 rebatch)。事实上上述可以进一步细分成prelayoy、layout、postlayout等等。细节可以直接看 源码 。

Profile UI 性能热点

任何的性能优化,第一步都是剖析性能热点,常见的 ui 性能热点有

  • GPU 填充率过高
  • Canvas batch 的 Rebuild 花费时间过长
  • 过多的 Canvas batch Rebuild
  • 顶点生成过多

Unity 的 UI 性能剖析工具,有两种,一种是编辑器自带的,一种是第三方外部工具,第三方工具往往结果更准确,但是有平台、编译模式等等限制,这里只说 Unity 内置剖析工具。

可以观察在 Unity Profiler 的 CPU Usage 里 PostLateUpdate.PlayerUpdate 下 UIEvent.WillRenderCanvas 的时间片占比来确定当前 UI 的 CPU开销。

SendWillRenderCanvas 即为 rebuild 的过程,Canvas 则为 batch build 的过程。也可以更省事,直接看 profiler 里的 UI 与 UI Detail 查看 rebuild Layout 与 Render 的开销,还有就是 Canvas 的自身或者累计的(即包括 Sub-Canvas) 的 batch 次数、顶点与GameObject 次数,甚至还有合批中断原因。还会显示触发的ui事件。不过 batch viewer(也就是profiler 下面那一坨)只能在编辑器运行模式下使用。

此外还可以使用 GPU profiler 查看 Transparent 部分开销来确定 UI 的 GPU 使用情况,从而排查填充率,确定 overdraw 等问题。

除了使用 Profiler 之外,还可以选择使用 Unity Frame Debugger(帧调试器),它会完整地显示某一帧的渲染过程,ui 当然也在其中,而且与 Profiler 不同,在非编辑器运行模式下也能使用。ui 的 drawCall 所在位置会受 Canvas render Mode 的影响, overlay 的模式会显示在 Canvas.RenderOverlays 组,否则则会在相关联的摄影机 Render.TransparentGeometry 组下。可以看出 ui 是在透明队列中绘制的,也就是说要按照深度从后往前绘制才能满足透明的需求。所以 Canvas 在 batch 的过程需要按深度排序 Canavs render,之后会检查拥有相同材质贴图(也就是有相同渲染状态,这种可以用一个drawCall 搞定)的 Canavs render,检查他们之间是否有不同材质且与他们发生重叠的 Canavs render。如果有则无法将它们合批,要拆成两个 batch,原因是如果这时候强行把他们合批,都无法满足绘制透明物体需要从后往前绘制的需求,会导致不正确的透明绘制结果。此外这里还要注意 hierarchy 中的顺序也是排序的依据或者说深度。

收集到结果之后就可以开始分析啦~~

  • 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 来缓解。

填充率、Canvas、输入

填充率

UI 节省 GPU 片元管线的负载的方法主要集中在两点

  • 减少复杂片元着色器的复杂度
  • 减少像素的采样数目

因为实际项目大部分的时候使用的都是 unity 的标准 ui 材质,所以主要的问题还是出在后者,也就是填充率过高上,ui占据过多的屏幕,或者合批数目太多都会导致过多的 overdraw 从而产生填充率高的问题。有如下解决方法

  • 隐藏不可见ui,因为项目里经常有全屏覆盖的ui,而且隐藏 Canvas 很简单,所以这个是相当简单实在的方法,不过要注意的是单纯控制透明度为0的确能“隐藏”ui,但是 ui 的绘制开销不会变。但是 ui 是通过 Graphics(勾选了 Raycast Target)来找到响应目标的,所以如果有只想响应点击但是不想显示的情况,可以通过调整透明度隐藏,然后勾选 Canvas Render 上的 Cull Transparent Mesh 来达到裁剪完全透明 ui 的目的。
  • 简化ui结构,尽量让美术把图整体切出来,这样相当于人力“合批”了 ui,从而消除了 overdraw,需要整体改变色调不要通过叠图混合颜色的方式来做,要用材质或者控制顶点色来搞。那种为了屏幕适配或者合理分层的节点而没有显示作用的节点要搞成空节点(即不带 Graphic 的节点)。
  • 关闭不可见的摄影机,这个不仅可以用于 UI 的优化,大部分游戏除了 ui 还会有实际场景,当有覆盖全屏的 ui 时,将那些不可见图形(ui 或者 3d 场景)的输出摄影机关了,相当于把他们的绘制开销完全砍了,可以简单直观有效地减小 GPU 压力 以及 drawCall,甚至有时对于非全屏ui,也可以通过截图(可能还要模糊)铺满背景的方式来硬怼出全屏ui,从而可以关闭摄影机。这种优化和上面隐藏 ui 的优化根据时一样的,但是应用范围更广。
  • 使用简单的shader,Unity 的默认 ui shader 为了通用使用了 stencil buffer 之类的东西,可以针对应用场景将 shader 替换成更简单的自定义 shader,除了采样 sprite 啥事不做之类的。

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 拆分的一个指导依据,将频繁刷新的部分,比如进度条,还有长时间静态的部分,比如各种背景,拆成两部分,这样前者的刷新就不会影响后者。

上面讲到在两个 ui 元素之间插入不可合批(拥有不同渲染状态,材质或者贴图不同)的且与它们重叠的 ui 元素,就会打断 ui 的合批,他们会被拆成三份网格,从而增加 drawcall。因为排序是按照 hierarchy 下的顺序来的,所以可以通过调整层级顺序来解决。此外也可以通过调整 ui 大小来恢复合批,或者将贴图打成一张图集从而能共享渲染状态而合批。这三种方法分别是通过消除 插入元素之间 重叠 不可合批 这三个条件来达到合批的,总之只要知道打断合批或者说成功合批的条件,就能针对性地进行优化。

可以通过 profiler 或者帧调试器来确定合批打断原因,具体方法上文已提及,此处不赘述。

Unity UI 的 输入与 Raycasting

Unity 的 UI 输入流程是 EventSystem 接受各类来自输入模块的 event,然后传递给各个 Graphics Raycaster,然后 Graphics Raycaster 找到其下所有勾选了 RayCaster Target 的且 active 的 Graphics,然后检测输入坐标是否命中了 ui 的 RectTransform 描述的节点,是的话,沿着层级向上遍历,对实现了 ICanvasRaycastFilter 的节点检查命中,如果命中就加入列表,知道没有父节点或者走到了勾选了 overrideSorting 的 CanvasGroup, 最后将列表按照深度从前到后排序(与渲染相反),如果 Blocking Objects 不是 None,那么深度位于ui 前的非 ui 且会相应点击的物体也会加入列表,从而遮挡住 ui 的点击。如果没有,那么列表种第一个检测为命中的 ui 就会响应输入事件。详情还是看官方源码 。

从上述过程可以看出开销的关键在于减少列表的长度, 如果不需要相应点击的 ui,就不要勾选 Raycast Target,RectTranform 不要无谓地拉太大等等,总之就是可以将不需要的 Graphic 从响应目标列表移除的手段的都可以达到优化的目的。

其他优化技术与建议

还有一些会导致难以维护或者有其他副作用的优化手段。

  • 利用 RectTransform 代替 Layout 来实现简单的布局需求。
  • 隐藏 Canvas(Canvas 组件而不是 Canvas 所在节点) 而不是具体 ui 节点,这样需要显示回来的时候不需要 rebuild Canvas。
  • 直接修改ui源码(一定要慎重考虑,仔细分析性能热点是否真的需要改源码而不是其他手段来解决)

总结

总的来说,UGUI 这套东西还是建立在 Unity 的基本渲染系统上,batch build 之类的优化点只要熟悉 Unity 的动态合批机制就不难搞定。不过在基本功能上添加了很多类似于布局、输入处理之类方便 ui 开发的特性。可以看出很多地方对性能不是很友好,比如所有 ui 都当成透明的来处理,很容易导致overdraw。不过不能站着说话不腰疼,至少这是一套有相当完成度的系统,性能方面的问题也可以有很多解决方案的,不至于束手无策,而且 Unity 官方还开放了一部分源码,更有利于性能分析与优化,可惜还是没有把 Canvas 合批的代码开放。

原文地址:https://zhuanlan.zhihu.com/p/266997416