UPL9-6——优化 UI 系统

使用更多 Canvas 组件

Canvas(画布)组件的基本工作原理

任何 UGUI 控件都必须挂在一个 Canvas 下,Unity 才会把它们作为 UI 元素渲染出来,Canvas 的本质作用:

  1. 收集子节点的 UI 元素(Graphic)信息
  2. 处理渲染顺序(Sorting、Camera、RenderMode)
  3. 管理重建,监听子物体的变动,决定哪些需要更新
  4. 批量生成 UI 顶点网格、更新材质信息
  5. 最终在渲染阶段合批并提交给 GPU

等等

​Canvas 内部维护了一个重建队列,当子元素发生变化时,它会标记自己为 “脏”(dirty)
在本帧渲染前统一执行重建(布局重建 / 图形重建 / 材质重建)

  • 布局重建:主要进行布局计算
  • 图形重建:主要进行网格生成(比如合并 UI 元素网格信息、前提是材质相同)
  • 材质重建:主要进行材质状态更新

所谓“画布污染”,就是有子元素打了脏标记,导致整个 Canvas 的顶点缓存需要刷新
如果 Canvas 很大,就会让大量 UI 元素一并重建,带来性能消耗

可能带来画布污染的情况:

  1. 修改 UI 元素的 RectTransform 相关属性,比如位置、缩放、角度等
  2. 自动布局组件的更新,比如 LayoutGoup​、ContentSizeFitter 等自动布局组件在元素变化时会重新计算
  3. 频繁改变 UI 元素父子关系、排列顺序、失活激活等
  4. 文本发生变化时,自动调整大小时
  5. 更改 UI 元素纹理、材质等
  6. 修改 UI 元素颜色、透明度等
  7. 禁用启用遮罩
  8. Shader 参数修改,比如文本边缘线、阴影效果等

等等

说人话:
一个 Canvas​ 统一管理它下面的所有子 UI 元素,若更新其中一个子元素会让整个 Canvas 进行重建,造成性能消耗

更多的 Canvas(画布)组件

为了避免 Canvas​ 的重建带来的性能消耗
我们可以把会频繁变化的 UI 元素单独放在一个小 Canvas​ 当中
避免一个小元素的更新导致整个大 Canvas 重建

我们可以把UI元素分成三大类

  • 静态:静态 UI 元素永远不会改变,比如背景图、说明文字、Logo、装饰性分割线等等
  • 偶尔动态:只会偶尔为了做出响应时而改变,比如 UI 按钮按下,拖动条,拥有金币数 等等
  • 持续动态:会持续甚至每帧发生变化的元素,比如战斗中血条流动动画、计时器、秒表文本、摇杆UI等等

通过这个分类,我们可以将 UI 元素进行 动静分离,将 UI 元素拆分到不同的画布中,高频变化区域独立 Canvas,从而达到节约性能的目的

举例说明:

大 Canvas:存在 500 个 UI(背景 + 血条 + 按钮…),血条变化 导致 整个 500 个 UI 重建

拆分后:

  • 大 Canvas 管理不变的内容(450 个 UI)
  • 小 Canvas 管理变化的血条(50 个 UI)

血条变化时,只重建 50 个 UI,大 Canvas 部分缓存复用不会重建,从而性能更好

注意:Canvas 并不是越多越好,它也存在一些潜在的缺点

  1. ​Canvas 会打断合批处理,会增加 DrawCall
  2. ​Canvas 本身需要进行排序、合批、DrawCall 提交等事情

如果拆的太碎,反而会因为本身的开销带来性能影响

禁用 Raycast Target

Raycast Target 是什么

UGUI 中每个 能显示的图形内容(Image​、Text​、RawImage​、TMP_Text 等等),都有一个布尔选项 Raycast Target(射线检测目标)

image

它决定了该 图形 是否作为 射线检测的目标,可以简单的把 Raycast Target 类比成 能不能被点中 的开关
勾选它,该对象可以被检测到选中;不勾选,该对象不会被检测

它的工作流程是:

  1. ​GraphicRaycaster​(射线检测组件)在处理输入事件(点击、拖拽、触摸)时,会从 EventSystem 发出一条射线
  2. 这条射线会检测 当前 Canvas​ 下所有 RaycastTarget = true 的 UI 元素
  3. 每个元素会计算射线是否落在它的矩形或多边形范围内
  4. 命中后,事件系统再决定触发哪个控件的(Button​、Toggle​、Scrollbar) 交互逻辑

为什么要禁用 Raycast Target

勾选 Raycast Target 可能带来的性能问题

  1. 检测内容太多

    默认所有 Image​、Text 等图形控件的 Raycast Target(射线检测目标)都是开启的
    即使它只是一个装饰图,也要被遍历,遍历就会存在开销

  2. 每帧检测

    射线检测每次输入(移动 / 点击 / 拖拽)都要做,可能需要每帧都执行,检测就会带来开销

  3. 计算成本

    每个目标要进行矩形碰撞或更复杂的遮罩判断(Mask​、RectMask2D),子元素越多,计算成本越高

因此,如果一个不需要进行交互的 UI 对象,开启 Raycast Target 就会增加开销
通过禁用它可以有效的提高性能,降低开销,主要带来的好处是:

  1. 事件系统在遍历时直接跳过它,减少遍历开销
  2. 避免了无意义的射线计算,减少计算开销

使用建议:

  1. 一定要禁用的

    背景图、装饰图标、分割线、阴影、UI 框架图、纯装饰性、说明性文字

  2. 必须保留的

    ​Button​、Toggle​、Slider​、ScrollRect 可交互区域或控件

通过禁用 Canvas(画布)组件来隐藏 UI 元素

在 UGUI 中,有以下几种方式可以隐藏 UI 元素

  1. 通过 SetActive 方法 设置 UI 元素依附对象的激活失活状态

    会触发 Canvas 重建,恢复时也要重建,开销较大

  2. 通过失活 UI 元素的 图形组件

    该元素不渲染,但仍会导致所在 Canvas 重建

  3. 通过 CanvasGroup 组件的整体改变透明度为 0

  4. 通过组件颜色 Color 属性改变透明度为 0

    通过 CanvasGroup​ 或 Color 属性设置为透明 仍然会生成网格、送入渲染流程;
    GPU 依然在画,只是全透明

  5. 通过将 Canvas(画布)组件失活

    只禁用这个 Canvas​ 的渲染器
    整个 Canvas 下 UI 元素 不参与渲染和事件检测

从以上方案来考虑
如果想要隐藏某些 UI 控件时,可以通过失活其所属 Canvas(画布)达到目的
并且好处是可以直接跳过渲染和事件检测,避免无意义的重建和渲染!

注意:通过禁用 Canvas​(画布)的方式去隐藏 UI 元素的前提是 动静分离、多 Canvas​ 合理分配
否则禁用一个 Canvas 可能会让很多不相关 UI 一起消失

避免在 UI 使用 Animator

如果在 UI 中使用 Animator​ 组件来播放一些 UI 动画
​Animator​ 会在每帧根据动画状态机计算 Transform 或其他属性变化

比如:

  1. 位置、旋转、缩放
  2. 颜色、图片变换

等等

通过之前的学习我们知道,如果一个 Canvas​(画布)下 UI 元素发生变换会产生画布污染
画布污染就意味着会造成 Canvas​(画布)重建,也就说 Animator​ 动画组件会为 UGUI 带来更多的性能开销
并且 Animator 本质是状态机驱动,每帧都会运行状态逻辑,会有更多开销

如果 UI 界面上一定需要动态效果,可以采用以下方案

  1. 利用 DoTween 插件做缓动效果
  2. 自己实现动态效果,比如利用协程或 Lerp 实现插值动画,控制更新频率和时机,避免每帧无意义更新
  3. 用 Shader 实现动态效果,把计算交给 GPU,比如进度条流光、动态血条更新、按钮高亮等
  4. 动静分离进行拆分,将动态效果分离到一个专门的 Canvas 下,减少重建开销

为世界空间画布定义摄像机

什么是世界空间画布(Canvas)

所谓世界空间画布,就是将 Canvas 中的 Render Mode(渲染模式)参数,设置为 World Space(世界空间)的画布

image

一般在以下情况可能使用这种 UI 模式

  1. VR / AR 项目中

    • UI 元素希望在 3D 世界中和场景物体混合显示
    • VR 中常见的 悬浮菜单、手腕面板
    • 场景内的交互 UI,比如 箱子、门、机关上方的 交互提示
    • 贴在墙上的按钮、开关 UI

    等等

  2. 游戏项目中

    玩家或 NPC 头顶的 血条 、 名字 、 Buff 图标

    等等

总体而言
UI 必须是 3D 世界的一部分,而不是固定在屏幕上的 2D 界面时
就可能会用到 世界空间画布(Canvas)

为世界空间画布(Canvas)显示定义摄像机

当 Canvas 的模式是 World Space 时,如果不设置它的 Event Camera 参数,并且所有摄像机都设置的会渲染 UI 层的话
那么 UI 元素就可能被多个摄像机渲染,从而带来性能浪费

我们的项目中经常可能存在多个摄像机,比如 主相机、小地图相机、后处理相机、预渲染摄像机 等等

如果这些相机没有进行专门的设置,可能都会渲染UI层
那带来的问题就是:同一份 UI 可能会被多次渲染,从而浪费性能
它可能带来的性能开销有:

  • CPU => 会重复提交 DrawCall 等
  • GPU => 重复进行像素填充 等

因此,我们应该养成良好的习惯,为世界空间画布(Canvas)显示的定义关联摄像机,明确设置 Event Camera
并且 让其他不需要渲染 UI 的相机在 Culling Mask (剔除遮罩) 中排除 UI 层
这样就可以有效解决该问题,减少我们的性能浪费!

不要使用透明度组件隐藏组件

在 UGUI 中,有以下几种方式可以隐藏 UI 元素

  1. 通过 SetActive 方法 设置 UI 元素依附对象的激活失活状态

    会触发 Canvas 重建,恢复时也要重建,开销较大

  2. 通过失活 UI 元素的 图形组件

    该元素不渲染,但仍会导致所在 Canvas 重建

  3. 通过 CanvasGroup 组件的整体改变透明度为 0

  4. 通过组件颜色 Color 属性改变透明度为 0

    通过 CanvasGroup​ 或 Color 属性设置为透明 仍然会生成网格、送入渲染流程;
    GPU 依然在画,只是全透明

  5. 通过将 Canvas(画布)组件失活

    只禁用这个 Canvas​ 的渲染器,整个 Canvas 下 UI 元素 不参与渲染和事件检测

在上文通过禁用 Canvas​(画布)组件来隐藏 UI 元素中,
就强调了通过禁用 Canvas 组件的方式隐藏组件是相对最节约性能的方式
这里再来强调一次不要使用透明度隐藏组件的相关内容

为什么不要使用透明度隐藏组件

主要原因
透明度隐藏依旧会触发渲染

  1. 生成网格

    UI 元素依然会生成顶点数据

  2. 提交 DrawCall

    ​CanvasRenderer 还是会把看似隐藏的 UI 元素打包提交给 GPU

  3. GPU 渲染

    GPU 照常进行光栅化、像素着色,只是最终混合结果是透明像素
    也就是说,GPU 做了同样的计算,只是输出颜色的透明通道为 0,看不到而已

  4. 事件检测

    如果 RaycastTarget = true,透明 UI 还会继续参与射线检测,CPU 也有性能浪费

所以如果通过透明度隐藏元素,看不见 ≠ 不渲染,它只是画了一个看不见的内容

原因:

  1. CPU消耗依旧
  2. GPU消耗依旧
  3. 事件检测消耗依旧

如何尽量避免使用透明度隐藏组件

如果是想要直接隐藏对象

  • 最佳选择:

    禁用 Canvas 组件

  • 备用选择:

    1. 失活 UI 图形组件(Image​、RawImage​、Text 等)
    2. 失活依附的 GameObject 对象

如果需要淡入淡出消失效果,可以在透明度为 0 淡出结束后,将其直接隐藏

优化 ScrollRect

ScrollRect 带来的性能开销

​ScrollRect 是 UGUI 中一个 可滚动的 UI 容器,常用于列表、背包、排行榜 等功能

它的工作流程大概如下所述:

  1. 有一个 显示区域(Viewport​)和一个 实际内容节点(Content)
  2. ​Content 下放了很多子元素(背包格子、排行榜玩家信息等等)
  3. 滚动时,ScrollRect​ 会移动 Content​ 的位置,驱动子元素的 RectTransform 变化
  4. 因为 RectTransform​ 在变,Canvas​ 会认为子元素脏了(dirty),触发 Canvas 重建

因此,当 Content 下子元素很多时(几百几千个),哪怕屏幕只显示几十个,滚动时所有元素都会被算一次布局和渲染

因此,由于 ScrollRect 的本身工作机制,当子元素太多时,就会带来一些无意义的性能开销

优化 ScrollRect 核心思想

核心优化思想:只保留 在屏幕可见的元素,其他元素不参与渲染

它的核心优化思路就是

  1. 利用缓存池复用对象,只显示可见范围内的元素
  2. 使用自定义布局,不使用 Unity 自带的布局组件

除此之外,我们应该把 ScrollRect​ 相关内容单独放入一个独立的 Canvas 中,达到动静分离的目的

ScrollRect 遮罩优化

用 RectMask2D​ 替换 Mask​ 遮罩,当 ScrollRect​ 的裁剪区域只是矩形的情况下,完全可以用 RectMask2D​ 来替代 Mask​ 遮罩,
因为 RectMask2D​ 的性能开销相对 Mask​ 要低很多,当非矩形时再使用 Mask

使用 UIText 或 自定义非渲染UI脚本 进行全屏交互

常见的全屏交互方式

在进行项目开发时,我们经常会有全屏交互层

用于拦截点击,屏蔽底层 UI(后方内容不能点击,只能点击前方内容,一般称为 模态窗口)或 制作类似全屏按钮功能(点击屏幕触发流程)
我们常见的方式有:

  1. 透明 Image

    在 Canvas​ 下放一个 Image​,拉满全屏,颜色设为几乎透明,开启 RaycastTarget

  2. 空 Text​ 或 TMP_Text

    用一个没有文字显示的文本控件,拉满全屏,开启 RaycastTarget

  3. ​Button

    全屏按钮

等等

这些方法的主要目的,都是用来将输入事件拦截,不再往下方传递

使用 UIText 进行全屏交互

在这些方法中,如果我们只是为了让后面层的 UI 不能被点击,那么我们推荐使用 空 Text​ 或 TMP_Text 的方式
因为它的性能表现相对较好,不需要进行额外渲染工作

注意:如果你的背景需要类似半透明效果,那么这种方式不太适用

自定义脚本进行全屏交互

如果担心使用 UIText 会导致用途混淆,可以自定义一个 UI 脚本,用于响应事件但不绘制任何网格

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

[RequireComponent(typeof(CanvasRenderer))]
public class EmptyRaycast : MaskableGraphic
{
protected EmptyRaycast()
{
useLegacyMeshGeneration = false;
}

public override void SetMaterialDirty() { }

public override void SetVerticesDirty() { }

protected override void OnPopulateMesh(VertexHelper toFill)
{
toFill.Clear();
}
}

更多方案

  1. 打图集(降低 DrawCall)

    将 UI 图片元素打图集,从而保证元素使用材质相同,达到批处理作用

    注意:布局时,不同图集之间元素分层显示,避免打断合批处理

  2. 少用自带布局组件

    UGUI 中提供自动布局组件,比如:

    • ​ContentSizeFitter(内容适配器)
    • ​HorizontalLayoutGroup​ / VerticalLayoutGroup(水平、垂直布局组)
    • ​AspectRatioFitter(宽高比适配器)

    等等

    建议少用或者不用这些组件,布局相关逻辑自己根据需求实现,可以有效控制更新时机,布局方式

  3. UI 元素关闭 Mipmap 功能

    对于大部分情况下,只要不是 3D UI 元素,存在距离变化
    可以关闭 Mipmap 功能

  4. 图集压缩

    UI 图集可以选择合适的 Unity 自带压缩格式
    比如移动端使用 ASTC 或 ETC2,可以有效降低内存并保持清晰度

  5. 合理使用九宫格拉伸

    只有需要保持比例进行 9 宫格缩放的UI元素再启用该功能,避免不必要的开销

  6. 减少 OverDraw

    尽量少让 UI 元素重叠显示,并且减少半透明效果的使用
    但是 UI 界面基本上是个元素堆叠而成的,因此很难减少重叠

    我们可以使用以下方案避免:

    1. 合并背景层,把一些装饰性元素直接和背景图做在一起

    2. 避免大面积半透明遮罩,比如不使用半透明实现暗化效果

      而是直接使用一张暗化图片代替,或是使用实时模糊的 Shader

    等等

Unity 官方优化文档

  1. 优化 Unity 用户界面:https://learn.unity.com/tutorial/optimizing-unity-ui
  2. Unity UI 优化技巧:https://unity.com/cn/how-to/unity-ui-optimization-tips

UGUI 源码

下载地址:https://github.com/Unity-Technologies/uGUI

我们可以探究 UGUI 源码,可以给我们带来以下好处:

  1. 理解 UGUI 工作原理
  2. 基于原理制定优化策略
  3. 学习借鉴 UGUI 设计思想
  4. 提升调试能力
  5. 便于定制和拓展功能
  6. 提升就业竞争力

等等