优化 Unity 游戏项目的脚本(上)

作者:转载小公举
2019-09-12
16 21 3

本文转载自 Unity 官方平台,已获得授权。

原作者:Ondřej Kofroň

本文将由捷克独立游戏工作室 Lonely Vertex 的开发工程师 Ondřej Kofroň,分享 C# 脚本的一系列优化方法,并提供改进 Unity 游戏性能的最佳实践。 

在开发游戏时,我们遇到了游戏过程中偶尔出现延迟的问题。在使用 Unity 性能分析器进行分析后,我们问题主要来源于:未优化的着色器和未优化的 C# 脚本

本文将主要介绍如何优化 Unity 游戏项目的 C# 脚本的方法。

寻找问题

Unity 性能分析器是寻找造成卡顿脚本的最佳方法。我强烈建议直接在设备上对游戏进行性能分析,而不是在编辑器中进行性能分析。

本文中分享的游戏项目面向 iOS,所以我们需要连接设备。如下图所示,设置 Build Settings,然后性能分析器就会自动连接。

如果我们在网上搜索“Unity 中的偶发卡顿”或类似关键词,你可能会发现大多数人建议重点处理垃圾回收。

每当停止使用某些对象即类的实例后,便会产生垃圾,然后会不定时运行 Unity 垃圾回收器来清理垃圾,取消分配相关的内存,这样的工作会占据大量时间,造成帧率降低现象。

如何在性能分析器中找到导致垃圾分配的脚本?

我们选中 CPU Usage 部分,如下选择 Hierarchy 视图,单击 GC Alloc,从而根据 GC Alloc 的情况进行排序。

下图为垃圾回收的 Profiler 设置。

我们的目标是在游戏场景中,使 GC Alloc 栏的所有数值都为 0。一个不错的做法是根据 Time ms(即执行时间)排序记录,然后优化脚本,使其尽可能使用较少时间。

这对我们而言极为重要,因为游戏中有一个组件包含一个大型 for 循环,该循环要使用很多时间来执行,而且我们目前无法处理好这个循环,所以优化所有脚本的执行时间非常必要,我们需要为耗时较长的 for 循环节省一些执行时间,同时保持 60fps。

根据性能分析的结果,我们将优化过程分为两个部分:

  • 处理垃圾回收
  • 减少执行时间

第一部分:处理垃圾回收

我们将关注处理垃圾回收的过程。这些内容是每位开发者都应该掌握的基础知识,也是我们平时对同步和合并请求,进行代码评审的重要部分。


规则 1:不在 Update 方法中创建新对象

理想情况下,开发者在 UpdateFixedUpdateLateUpdate 方法中不应该使用 New 关键字,而是应该使用已有对象。

有时新对象创建过程会隐藏在一些 Unity 内部方法中,所以过程并不明显。


规则 2:一次创建,多次重用

这条规则的意思是:要在 Start 方法和 Awake 方法中分配所有内容。这条规则和第一条类似,其实只是从 Update 方法移除 new 关键字的另一种方式。

开发者应该从 Update 方法移除有以下行为的代码:

  • 创建新实例
  • 寻找任意游戏对象

然后,将这些代码移动到 Start 方法或 Awake 方法中。

下面是我们进行的改动示例。

我们可以在 Start 方法分配 List 列表,在需要时使用 Clear 函数清空列表,并在任何位置重用这些内容。

//未优化的代码
private List<GameObject> objectsList;void Update()
{
    objectsList = new List<GameObject>();
    objectsList.Add(......)
}

//优化后的代码
private List<GameObject> objectsList;void Start()
{
    objectsList = new List<GameObject>();
}

void Update()
{
    objectsList.Clear();
    objectsList.Add(......)
}

进行存储和重用引用的过程如下面代码所示。

//未优化的代码
void Update()
{
    var levelObstacles = FindObjectsOfType<Obstacle>();
    foreach(var obstacle in levelObstacles) { ....... }
}

//优化后的代码
private Object[] levelObstacles;


void Start()
{
    levelObstacles = FindObjectsOfType<Obstacle>();
}

void Update()
{
    foreach(var obstacle in levelObstacles) { ....... }
}

相同的规则也适用于 FindGameObjectsWithTag 等返回新数组的其它方法。


规则 3:小心处理字符串,避免字符串连接

在涉及到垃圾分配的时候,字符串要特别注意。即使是基本的字符串操作,也可能产生大量垃圾。这是为什么呢?

因为字符串是无法改变的数组。这意味着,如果要把两个字符串连接起来,我们会创建新数组,而旧数组会成为垃圾。所以我们可以使用 StringBuilder 避免或最小化这类垃圾分配。

下面是改进该过程的示例。

//未优化的代码
void Start()
{
    text = GetComponent<Text>();
}

void Update()
{
    text.text = "Player " + name + " has score " + score.toString();
}

//优化后的代码
void Start()
{
    text = GetComponent<Text>();
    builder = new StringBuilder(50);
}

void Update()
{
    //StringBuilder 为所有类型重载了 Append 方法
    builder.Length = 0;
    builder.Append("Player ");
    builder.Append(name);
    builder.Append(" has score ");
    builder.Append(score);
    text.text = builder.ToString();
}

示例中的原代码没什么问题,但仍有很大的改进空间。我们发现,几乎整个字符串都可以视为静态,所以我们把字符串分为两个部分,放到两个 UI.Text 对象中。

第一个对象只包含静态文字“Player “ + name + “ has score “ ,可以在 Start 方法中指定。第二个对象包含每帧更新的 Score 数值,我们要使静态字符串完全是静态的,在 Start 方法或 Awake 方法中生成这类字符串。

经过改进,代码已经很好了,但是调用 Int.ToString()Float.ToString() 等函数仍会有垃圾产生。

我们通过生成和预分配所有可能的字符串来解决该问题。这样可能听起来不是好方法,而且会消耗很多内存,但它完美满足了我们的需求,并彻底解决了这个问题。

我们最后得到可以使用索引直接访问的静态数组,从而获取表示数字的请求字符串。

public static readonly string[] NUMBERS_THREE_DECIMAL = {
        "000", "001", "002", "003", "004", "005", "006",..........


规则 4:缓存访问器返回的数值

这种方法可能很难使用,因为即使是简单的访问器也会产生垃圾。

//未优化的代码
void Update()
{
    gameObject.tag;
    //or
    //或
    gameObject.name;
}

尝试避免在 Update 方法中使用访问器,只在 Start 方法中调用一次访问器,并缓存返回的数值。

通常,建议不在 Update 方法中调用任何字符串访问器或数组访问器。在多数情况下,我们只需要在 Start 方法中获取一次引用。

下面是未优化访问器代码的两个常见示例。

//未优化的代码
void Update()
{
    //分配包含所有 touches 的新数组
    Input.touches[0];
}

//优化后的代码
void Update()
{
    Input.GetTouch(0);
}

//未优化的代码
void Update()
{
    //返回新的字符串(垃圾),然后对比2个字符串
    gameObject.Tag == "MyTag";
}

//优化后的代码
void Update()
{
    gameObject.CompareTag("MyTag");
}


规则 5:使用 NonAlloc 函数

对于特定 Unity 函数,我们可以找到不分配任何内存的替代函数。在我们的项目中,这些函数都和物理功能有关。我们在碰撞检测使用的函数如下。

Physics2D.CircleCast();

对于该函数,我们可以找到不分配任何内存的版本。

Physics2D.CircleCastNonAlloc();

许多其它函数都有类似的替代函数,因此请记得查看文档,了解函数是否有相应的 NonAlloc 版本。


规则 6:不要使用 LINQ

尽可能不要使用 LINQ。也就是说,不要在任何经常执行的代码中使用 LINQ。

虽然使用 LINQ 可以使代码更容易阅读,但在很多情况下,这类代码的性能和内存分配都非常糟糕。

规则 7:一次创建,多次重用(续)

这次我们要讲的是对象池,如果你不了解对象池,请阅读教程:

Object Pooling

访问

在我们的游戏中,有一种情况使用了对象池。我们有一个生成的关卡,里面充满了只在限定时间存在的障碍物,障碍物在玩家通过相应关卡部分后会消失。

在满足特定条件时,障碍物会从预制件进行实例化。相应的代码位于 Update 方法中,对于性能和执行时间而言,代码是非常低效的。

我们的解决方法是:生成 40 个障碍物组成的对象池,在需要时从对象池取用这些障碍物对象,在障碍物不再需要时,把障碍物对象返回到对象池。

规则 8:注意装箱过程

装箱过程会生成垃圾。什么是装箱过程呢?

最常见的装箱过程是将数值类型,例如 intfloatbool 等传递到需要 Object 类型参数的函数时,所发生的过程。

下面是我们要在项目中处理的装箱过程。

我们在项目实现了自定义通信系统。每个信息可以包含数量不限的数据。该数据存储在字典中,字典的定义如下。

Dictionary<string, object> data;

我们有设置函数(Setter),用来将数值设置到该字典中。

public Action SetAttribute(string attribute, object value)
{
    data[attribute] = value;
}

这里的装箱过程很明显,我们可以这样调用该函数。

SetAttribute("my_int_value", 12);

因此,这里的数值 12 进行装箱时,会产生垃圾。

我们的解决方法是:为每个基本类型使用单独的数据容器,之前的 Object 容器仅用于引用类型。

Dictionary<string, object> data;
Dictionary<string, bool> dataBool;
Dictionary<string, int> dataInt;
.......

为每个数据类型使用单独的设置函数。

SetBoolAttribute(string attribute, bool value)
SetIntAttribute(string attribute, int value)

然后实现所有设置函数,使它们调用相同的通用函数:

SetAttribute(ref Dictionary<string, T> dict, string attribute, T value)

这样装箱过程就被我们移除了。如果你想了解更多细节,请阅读下面的文档:

装箱和取消装箱

访问

规则 9:小心循环代码

这条规则类似第 1,2 条规则。尽可能把所有不必要代码从循环中去掉,从而取得更好的性能和内存分配。

我们要尝试避免在 Update 方法中使用循环,但如果有这个需要,我们至少要在这种循环中避免出现内存分配。因此,不仅是针对 Update 方法,我们也要在循环代码中遵循前 8 条规则。


规则 10:确保外部代码库不产生垃圾

如果发现部分垃圾由 Asset Store 资源商店下载的代码产生,我们有多个解决方法。但在我们进行逆向工程并调试前,请再次查看 Asset Store 资源商店的相应页面,代码库是否有进行更新。

在我们的项目中,我们使用的所有资源一直由资源的开发者进行维护,他们一直在进行性能更新,从而解决了我们的所有问题。

所以,一定要让项目使用的依赖保持更新。如果遇到没有维护的代码库,建议放弃这类代码库。

小结

由于篇幅限制,本文介绍了优化 Unity 游戏项目的 C#脚本的第一部分处理垃圾回收,下篇我们将分享如何将执行时间减少,敬请期待!

下载 Unity Connect APP,请点击此处。 观看部分 Unity 官方视频,请关注 B 站帐户:Unity 官方。

你可以访问 Unity 答疑专区留下你的问题,Unity 社区和官方团队帮你解答:

Unity 答疑专区

访问

获取订阅 Unity Pro/Plus

访问

近期点赞的会员

 分享这篇文章

您可能还会对这些文章感兴趣

参与此文章的讨论

  1. 虎山行 2019-09-12

    很好,对开发人员非常的有价值,以后要多多考虑优化的事情,开发阶段就养成好的习惯。

  2. Mortale 2019-09-12

    很好,对开发人员非常的有价值,以后要多多考虑优化的事情,开发阶段就养成好的习惯。

    最近由 Mortale 修改于:2019-09-12 17:11:57
  3. LouisLiu 2019-09-12

    很好,对开发人员非常的有价值,以后要多多考虑优化的事情,开发阶段就养成好的习惯。

您需要登录或者注册后才能发表评论

登录/注册