Unity 性能优化:脚本优化

使用最快的GetComponent方法

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

移除空的回调定义

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

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

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

优化组件引用

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

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

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

void TakeDamage()
{
var rb = GetComponent<RigidBody>();
var collider = GetComponent<Collider>();
var ai = GetComponent<AIController>();
var anim = GetComponent<Animator>();

if (GetComponent<Health>().health < 0)
{
rb.enabled = false;
collider.enabled = false;
ai.enabled = false;
anim.SetTrigger("death");
}
}

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

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

Update、Coroutines 和 InvokeRepeating

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

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

private void Update()
{
ProcessAI();
}

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

private float m_AirProcessDelay = 0.2f;
private float m_Timer = 0.01f;

private void Update()
{
m_Timer += Time.deltaTime;
if (m_Timer > m_AirProcessDelay)
{
ProcessAI();
m_timer -= m_AirProcessDelay;
}
}

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

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

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

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

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 方法避免这种情况,它的功能与常见的判空相同,同时运行速度是原本的两倍。

if (gameObject != null)
{
}

if (!System.Object.ReferenceEqual(gameObject, null))
{
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

禁用未使用的脚本和对象

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

通过可见性禁用对象

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

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

通过距离禁用对象

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

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

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

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

// 使用距离
float distance = (transform.position - other.tranform.position).Distance();

if (distance < targetDistance)
{
}

// 使用距离的平方
float distance = (transform.position - other.tranform.position).sqrMagnitude;

if (distance < (targetDistance * targetDistance))
{
}

Note:

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

平衡CPU使用率

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

解决方案有如下三种:

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

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

重写 Update

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

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

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