..

Unity 性能优化

Unity Profiler

Unity Profiler 是 Unity 引擎自带的性能分析工具,能够在运行时为大量的 Unity3D 子系统生成使用情况和统计报告,可以帮助开发者快速找到游戏的性能瓶颈。

一般情况下,有两种使用 Profiler 工具的方法,分别是指令注入和基准分析。

指令注入(Instrumentation)通过观察方法的调用行为,内存的分配来观察应用程序的内部工作情况。

基准分析(Benchmarking)涉及对应用程序执行表面级别的检测,在游戏运行于目标硬件期间收集一些基本数据,执行测试场景。进行基准分析的过程中,开发人员感兴趣的重要指标通常是渲染帧率(FPS),总体内存消耗和 CPU 活动的行为方式(寻找活动中较大的峰值),有时还有 CPU 或 GPU 的温度。

性能分析的最佳方法

性能优化的目标是使用基准分析来观察应用程序,寻找问题的实例,然后使用指令注入工具在代码中寻找问题根源的线索。

解决问题之前应先列一份任务清单以帮助开发人员专注于这个问题,并确保不会浪费时间来尝试实现任何可能的优化,而这些优化对于主要的性能瓶颈没有影响。

Unity 查找性能问题的通用步骤:

  1. 验证目标脚本是否存在于场景中
  2. 验证脚本在场景中出现的次数是否正确
  3. 验证事件的正确顺序
  4. 最小化正在进行的代码更改
  5. 最小化内部影响
  6. 最小化外部影响

📑 脚本策略

使用最快的GetComponent方法

在Unity目前的版本中有三种获取组件的方法,分别是 GetComponent<T>GetComponent(typeof(T))GetComponent(string)。第一个方法比第二个方法快一点,第三个方法显著慢于其他两个方法。

移除空的回调定义

Monobehaviour 组件在场景中第一次实例化时,引擎会将任何定义好的回调添加到一个函数指针列表中,并在需要的时刻调用这个列表。但这里需要注意的是,即使回调是空的,Unity也会将该回调添加到列表中,这将会在代码被调用时产生一定的额外开销,将浪费少量的CPU资源。

可以使用以下正则表达式来搜索出代码中空的回调。

1# 这里使用 Update 方法举例
2void\\s*Update\\s*?\\(\\s*?\\)\\s*?\\n*?\\(\\n*?\\s*?\\)

优化组件引用

如果可以,尽量避免反复去获取相同的组件引用。

例如以下方法,每次执行时都要重新获得五个组件的引用,这对于CPU而言不是很友好。如果这个方法是在 Update 里调用的,对于性能的影响还要更加严重。

更好的方法是在Awake中获取这些组件的引用并保存在类中,TakeDamage方法直接调用这些引用。

 1void TakeDamage()
 2{
 3    var rb = GetComponent<RigidBody>();
 4    var collider = GetComponent<Collider>();
 5    var ai = GetComponent<AIController>();
 6    var anim = GetComponent<Animator>();
 7
 8    if (GetComponent<Health>().health < 0)
 9    {
10        rb.enabled = false;
11        collider.enabled = false;
12        ai.enabled = false;
13        anim.SetTrigger("death");
14    }
15}

共享,而不是每个组件都单独获取数据

让多个对象共享某些计算的结果可以节省性能开销。例如读入的 Json 数据,或者是为一组 AI 计算行动路径。

Update、Coroutines 和 InvokeRepeating

在写代码时,新人很容易犯的错误就是在Update回调中以超出需要的频率重复调用某段代码。

举例来说,有一个 AI 寻路处理方法,它的功能是找出它要移动的目的地,当我们将它放入Update回调中时,它可能会占用过度的帧率预算。

1private void Update()
2{
3    ProcessAI();
4}

如果我们想要提高游戏的性能,可以尝试减少该方法的调用频率,如果效果并没有因为调用的次数减少而变得不可接受的差,那么这部分的性能我们就节省了下来。

 1private float m_AirProcessDelay = 0.2f;
 2private float m_Timer = 0.01f;
 3
 4private void Update()
 5{
 6    m_Timer += Time.deltaTime;
 7    if (m_Timer > m_AirProcessDelay)
 8    {
 9        ProcessAI();
10        m_timer -= m_AirProcessDelay;
11    }
12}

当然,我们也可以采用协程的方式编写该代码,这样可以让我们更简单的延迟调用。

使用协程的主要好处是,它的调用频率完全由开发控制,在时间没到之前它将一直处于空闲状态,从而减少对大多数帧的性能影响。

但协程与标准的方法调用相比,启用协程会带来额外的开销成本(大约是标准的方法调用的三倍),而且还会需要额外的内存来存储方法的执行状态。

因此如果使用协程,需要确保降低频率的好处大于此成本。

Note:

一旦初始化,协程的运行独立于 MonoBehaviour 组件中 Update回调的出发,不管组件是否禁用,都将继续调用协程。因此如果执行大量的GameObject构建和析构操作,协程可能会显得很笨拙。

当包含了协程的 GameObject 变为不活动时,在该 GameObject 身上运行的协程将全部终止。

如果协程的调用可以被简化为一个 while 循环,则可以考虑使用 InvokeRepeating 代替,它的建立更加简单,并且开销更小。

Note:

InvokeRepeating 和协程之间的一个重要区别是,InvokeRepeating 完全独立于 MonoBehaviour 和 GameObject 的状态,关闭组件或者让 GameObject 失活都无法停止 InvokeRepeating 。如果想要停止 InvokeRepeating ,有两种方法。

  1. 调用 CancelInvoke:它将停止由给定的 Monobehaviour 发起的所有 InvokeRepeating
  2. 销毁 Monobehaviour 或者其 GameObject。

优化空引用检查

GameObject 和 Monobehaviour 是特殊的对象,它同时存在于 托管代码 与 本机代码 中,数据会在这两个内存空间中移动,每次移动都将导致额外的 CPU 开销和可能的额外内存分配。

这种效果通常被称为跨越本机-托管的桥接,发生时有时候会触发 GC,许多方法都会意外地触发这种额外的开销,对 GameObject 的空引用检查就是其中之一。

可以使用 System.Object.ReferenceEqual 方法避免这种情况,它的功能与常见的判空相同,同时运行速度是原本的两倍。

1if (gameObject != null)
2{
3}
4
5if (!System.Object.ReferenceEqual(gameObject, null))
6{
7}

避免从 GameObject 中检索字符串属性

从对象中检索字符串属性本应不增加内存成本,但是从 GameObject 中检索字符串属性会触发跨越本机-托管的桥接。

GameObject 的 name 属性和 tag 属性都受此影响,因此最好只在性能无关紧要的地方使用它们。

如果非要使用,对于 tag 属性而言,最好使用 CompareTag 方法。这是 GameObject 提供的比较方法,它可以避免跨越本机-托管的桥接。

直接使用字符串 比较 tag 属性 1000w 次需要 2000ms,并需要 400ms 进行垃圾回收,总共是 2400ms。

使用 CompareTag 比较 1000w 次仅需要 1000ms,并且不需要进行垃圾回收。

Tip:向 CompareTag 传递字符串文本时不会导致运行时的内存分配,因为硬编码的字符串将在应用程序初始化时被分配,运行时只是引用它们。

使用合适的数据结构

软件开发中的一个常见的性能问题是简单地为了便利而使用不适当的数据结构来解决问题。最常用的两种数据结构是 List 和 Dictionary。

如果希望遍历一组对象,最好使用列表,因为他实际上是一个 Array,数据之间在内存中紧闭排布,因此迭代导致的缓存丢失最小。

如果两个对象相互关联且希望快速获取、插入或删除这些关联,最好使用字典。

有时候,数据结构通常需要同时处理两种情况,快速找出哪个对象映射到另一个对象,同时还能遍历组。在这种情况下我们可以同时使用 List 和 Dictionary,虽然这将导致消耗额外的内存,但是这种开销相对于迭代 Dictionary 来说是更容易接受的。

避免在运行时修改 Transform 的父节点

Unity中,共享父元素的 Transform 按顺序存储在预先分配的内存缓冲区的内存中,并在 Hierarchy 窗口中根据父元素下面的深度进行排序。

这种数据结构允许在整个组中进行更快的迭代,这对于物理和动画等多个子系统非常有用。

但是缺点是,如果将一个 GameObject 的父对象指定为另一个对象,父对象必须将新的子对象放入预先分配的内存缓冲区中,并根据新的深度对所有这些 Transform 进行排序。如果缓冲区没有足够的空间,还必须扩展缓冲区,以便以深度优先的顺序容纳新的子对象及其所有的子对象。如果 GameObject 的结构比较复杂,那么该操作需要耗费一些时间来完成。

当然,也可以提前给 Transform 分配一个更大的缓冲区,这样就可以避免在运行时扩展缓冲区,减少不必要的内存分配。

避免在运行时使用 Find 和 SendMessage 方法

GameObject.Find 方法和 SendMessage 方法的开销非常大,应该不惜一切代价避免在运行时使用。

依赖使用这两种方法通常是非常糟糕的设计,意味着缺乏使用 Unity 和 C# 的经验,或者仅仅是由于原型开发期间的懒惰。

虽然在开发中我们常说不要提前优化,但由于这两个方法的性能损耗实在太大,以至于需要打破这个原则。

我们可以采用以下几种方式来替代这两个方法:

  1. 将引用提前分配给预先存在的对象
  2. 静态类
  3. 单例组件
  4. 全局消息传递系统

将引用提前分配给预先存在的对象

方法一(将引用提前分配给预先存在的对象)在软件开发中有许多争议,因为它破坏了封装性,使得任何标记为私有的字段都公共字段一样处理。

在某些情况下,例如你在代码里将一个私有变量标记为 SerializeField,然后在 Inspector 面板为这个私有变量赋值,最后在代码中删除 SerializeField 标记。然后在运行时,你会发现在 Inspector 面板时所赋的值仍然存在。

这种无法被定位的赋值会导致一些极难定位的问题,更不要说由于在 Inspector 面板中填写数值导致在涉及多人协作时的 meta 文件冲突了。

从我个人角度上来说,我会尽量避免在 Inspector 面板设定数值。在代码中中,或者是在 Excel、CSV、Json、Text 等文本配置文件中修改值是一个更安全,也更容易排查问题的方式。

静态类

任何的全局管理器类在软件工程领域都是不受欢迎的,部分原因是因为“管理器”的名称很模糊,没有说明它应该做什么,但更主要的原因是很难调试。

对管理器的更改可以在运行期间的任何时间和任何地点发生,并且它自身还维护着其他系统所依赖的状态信息,同时许多类都可能包含对它的直接方法调用或间接方法调用(不排除有些方法中使用了字符串硬编码进行Invoke),如果未来想要修改它,则需要对系统中几乎每一个类进行修改。

当然,即使有着这些缺陷,静态类仍然是迄今为止最容易理解和实现的解决方案。

单例组件

静态类很难和与 Unity 相关的功能交互,不能直接利用 MonoBehaviour 的特性,比如事件回调、协程、分层设计和预制块。

一个常见的解决方案是实现一个类似于单例的组件,它提供静态方法来授予全局的访问权,在任何给定的时间只允许 MonoBehaviour 的一个实例存在。

 1public class MonoSingleton : MonoBehaviour
 2{
 3    private static MonoSingleton mInstance;
 4    private Dictionary<string, Coroutine> mActiveCoroutines = new();
 5
 6    public static MonoSingleton Instance
 7    {
 8        get
 9        {
10            if (mInstance == null)
11            {
12                var objs = FindObjectsByType<MonoSingleton>(FindObjectsSortMode.None);
13                if (objs.Length > 0)
14                {
15                    mInstance = objs[0];
16                }
17
18                if (mInstance == null)
19                {
20                    var singletonObject = new GameObject();
21                    mInstance = singletonObject.AddComponent<MonoSingleton>();
22                    singletonObject.name = "MonoSingleton";
23                }
24            }
25
26            return mInstance;
27        }
28    }
29
30    private void Awake()
31    {
32        if (mInstance == null)
33        {
34            mInstance = this;
35            DontDestroyOnLoad(gameObject);
36        }
37        else if (mInstance != this)
38        {
39            Destroy(gameObject);
40        }
41    }
42}

全局消息传递系统

解决对象间通信的最后一种建议采用的方法是实现一个全局的消息传递系统,任何对象都可以访问该系统,并将消息通过该系统发送给任何可能对监听特定类型的消息感兴趣的对象。

禁用未使用的脚本和对象

在构建大型的、开放的游戏时,如果每个游戏对象的脚本都要实时被调用,那么对性能造成的影响将是毁灭性的。许多的游戏对象都在玩家的视野之外,对于玩家的游玩没有任何的影响,反而会拖累游戏性能,因此在不需要的时候,暂时将它们关掉或许是更好的选择。

通过可见性禁用对象

为游戏对象的脚本上添加 OnBecameVisible 和 OnBecameInvisible 的回调。

Note: 可见性回调必须与渲染管线通信,因此 GameObject 必须附加一个可渲染的组件,也必须保证希望接受可见性回调的组件也与可渲染对象连接在同一个 GameObject 上。

通过距离禁用对象

简单的实现是使用一个协程定期检查 玩家与当前游戏物体的距离。

使用距离的平方而不是距离

CPU 比较擅长浮点数相乘而不擅长计算平方根,而每次使用 magnitude 属性或者 Distance 方法要求 Vector3 计算距离时,其实都是在进行平方根运算,这会消耗大量的 CPU 资源。

更好的方案是使用距离的平方来判断游戏对象之间的距离,Vector3 也提供了 sqrMagnitude 属性,该值便是距离的平方。

 1// 使用距离
 2float distance = (transform.position - other.tranform.position).Distance();
 3
 4if (distance < targetDistance)
 5{
 6}
 7
 8// 使用距离的平方
 9float distance = (transform.position - other.tranform.position).sqrMagnitude;
10
11if (distance < (targetDistance * targetDistance))
12{
13}

Note:

两个方法的结果几乎相同,只是由于浮点数本身的特性,可能会损失小数点很多位之后的一点点精度。当我们不需要如此精确的精度时,损失一点点的精度换取更高的性能是可以接受的。

最小化反序列化行为

Unity 的序列化系统主要用于场景、预制件、ScriptableObjects 和各类资产类型中。这些资产往往以文本或者二进制的形式保存在磁盘中。

然而,在运行时从磁盘读取和反序列化数据相对而言是一个非常慢的过程,因此所有的反序列化活动都会产生显著的性能成本。

这种反序列化在调用 Resources.Load 方法时发生,虽然一旦数据加载到内存中后,以后重新加载相同的引用速度会快得多,但是第一次访问时是必须从磁盘加载的。

我们可以通过以下四种方式来解决这个问题:

  1. 拆分序列化对象:例如将一个 UI 面板拆分为多个小部件,这些小部件只在点击的时候才加载。这样就减少了加载基础 UI 面板时的体积。
  2. 异步加载序列化对象。
  3. 在内存中保存之前加载的序列化对象。
  4. 将公共数据存入相同的序列化对象。

创建自定义的 Update 层

平衡CPU使用率

当有许多的 Monobehaviour 脚本在开始场景一起被初始化,一起启动了协程,每隔 500ms 处理一次 AI 任务时,它们极有可能在同一帧触发,导致 CPU 的使用率在一段时间内出现一个巨大的峰值。

解决方案有如下三种:

  1. 每次计时器过期或协程触发时,生成一个随机的等待时间。
  2. 将协程的初始化分散到各个帧中,这样每帧中只会启动少量的协程初始化。
  3. 将调用更新的职责传递给某个 God 类,该类对每帧的调用数量进行限制。

当然,需要警惕的是,剧烈的设计变更可能带来许多危险和意想不到的副作用。

重写 Update

优化的一个好的方法是根本不使用 Update,或者更准确地说,只使用一次。

当 Unity 调用如 Update 的函数回调时,它都要经过跨越本机-托管的桥接,这会造成一定的性能损耗,如果场景中的游戏对象较多,那么耗费在这个过程中的损耗将是一个相当惊人的数字。执行一千个单独的 Update 回调的成本比执行一个 Update 的回调调用一千个常规方法的成本高。

因此,让一个 God 类的 Monobehaviour 使用它自己的 Update 回调来调用自定义组件使用的自定义更新样式来更新系统,可以最小化 Update 需要跨越桥接的频率。

图形优化

界面优化

拆分画布

画布组件的主要任务是管理在层级窗口中绘制 UI 元素的网格,并发出渲染这些元素所需的 Draw Call。另一个重要的作用是将网格合并进行批处理以降低 Draw Call 数。

当画布或其子对象发生变动时,画布需要为所有的 UI 对象重新生成网格,然后重新发送 Draw Call,这被称为画布污染(更改 UI 元素的颜色不会污染画布)。

UI 元素的改变导致 CPU 使用率大幅上升,通常是由于在单个画布中构建了过多的 UI 元素,这将导致 UI 元素更改时画布需要重新生成的网格过多。

这种情况一般都可以使用更多的画布来解决,将 UI 拆分为多个画布,将工作的负载分开,简化单个画布所需的任务。

动静结合

在拆分 UI 时,可以按照静态、偶尔动态、连续动态三个标准分离 UI 元素。

  • 静态:永远也不会改变的 UI 元素,例如背景图片。
  • 偶尔动态:只在做出响应时更改,例如 UI 按钮。
  • 连续动态:元素会定期更新,例如滚动的轮播图。

移除 Raycast Target

UI 元素具有 Raycast Target 选项,允许元素通过单击、触摸和其他用户的行为进行交互。当用户行为事件发生时,GraphicsRaycast 组件将执行像素到边界框检查以确定与之交互的是哪个元素。禁用不需要交互的元素的 Raycast Target 选项,就可以减少 GraphicsRaycast 所需要检查的数量,从而提高性能。

不要使用 Animator

Unity 的 Animator 组件与 UGUI 的搭配完全就是灾难,当使用 Animator 制作 UI 动画时,每一帧 Animator 都会改变 UI 元素的属性导致画布网格需要重新绘制,这将带来巨大的开销。

因此在开发中应该避免使用 Animator。

正确的隐藏 UI 元素

对于 UI 元素,即使将 alpha 值设置为 0 ,也依然会被画布提交到 Draw Call,正确的做法应该是更改 UI 元素的 isActive 属性。或者是使用 CanvasGroup 组件来控制其下所有元素的 alpha 值,当 CanvasGroup 的 alpha 值被设置为 0时,将自动清除其子对象,不会发出任何的 Draw Call。

使用空的 UI Text 元素进行全屏交互

在强制玩家必须处理弹出窗口才能进入下一步时,通常会激活一个很大的、透明的可交互元素覆盖整个屏幕避免玩家能够点击弹窗后面的 UI 元素。

这通常会使用 UI Image 来完成,但在弹窗不需要背景图的时候,其实可以使用一个没有定义字体、也没有填充文本的 Text 元素用于屏蔽交互。它不需要生成任何的渲染信息,性能开销相较于将 Image 的透明度调低的方案来说是非常低的。

物理优化

There is nothing new under the sun.