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. 提升就业竞争力

等等