[Unity] 简述性能优化1

最近的手头的项目终于告一段落,闲暇时间里抽空看了下 Unity 的优化教程,决定自己梳理下内容写篇笔记,一来可以加深印象,再一点也可以锻炼下自己的表述能力(常年国外生活真的会严重影响汉语表达能力😅 )。

因为这篇文章不是原创,仅仅是对原文的主要内容进行了删减翻译和添加了一些个人观点,因此欢迎转载。码子不容易,转载请务必注明如下信息:

作者:图林根の 文章来源: https://www.mahong.me/archives/523

本篇文章面向有一定基础的 Unity 初学者,虽然不是原创,也不是首发,但应该是能在网络上找到最系统最详细的文章之一了(自己先夸一下自己~~)我会尽可能用最简单直白的方式来阐述。

这篇文章主要来自 Unity 的这篇 官方教程,加上自己对内容的理解,强烈推荐英文好的朋友直接阅读原文。因为文章篇幅实在过长,故拆分为三部分。该篇文章不仅仅介绍是系统优化的方法,而且还涉及到了不少背后的知识,对我们了解 Unity 的运行原理机制大有帮助,十分值得阅读。

由于个人知识水平有限,外加有些单词不是很容易翻译,如果有误,欢迎指出~

在我们的 Unity 项目或是游戏运行过程中,如果运行的程序足够复杂,仅仅是一秒钟,都需要处理十分庞大的数据,这时候如果 CPU 或 GPU 的性能出现瓶颈,就会出现例如运行缓慢,卡顿等情况,严重影响用户体验。这时候就需要我们对项目进行整体的优化,来减少 CPU 或是 GPU 的运行压力,从而保证项目可以流畅的运行。关于性能优化,可以从以下三个方面展开(一共三篇文章):

  • 代码的优化 (CPU)
  • 内存垃圾回收的优化 (CPU)
  • 渲染图形的优化 (CPU & GPU)

1. 诊断问题

在项目优化开始前,我们应该首先诊断出到底是哪一个环节最耗费性能,才能针对性的进行系统优化。造成项目运行效率地下的原因有很多,例如编写的代码(注:某些场合下又称作 脚本,不过一般脚本特指我们编写的 cs文件,而代码更多指的是 cs 脚本文件里面的内容)运行起来需要大量计算,或者说 Shader 的原因,或者需要计算复杂的物体碰、亦或是需要模拟繁琐的动画等等因素。

一般来讲,如果显示帧率过低,性能不稳定,或者是出现画面定格的情况,那么很有可能是出在 CPU 方面上。当然这只是初步判断,要详细寻找这些影响性能的因素可以通过 Unity 的 Profile (cmd +7 / ctrl +7)来具体分析,一旦确定问题所在,就可以采取对应措施来进行系统优化。关于 Profile 的使用这里就不展开说明了,读者可以自行查阅 其它教程资料

2. 代码优化

2.1 代码的编译和运行

能影响到 CPU 运行的因素有许多种,例如其中编写代码等质量好坏是其中的主要影响因素。要想知道代码是如何影响性能,就需要了解在一个场景( Scene)运行时,它的背后是如何运作的。由于 CPU 只能执行编译(compiling)后的 机器语言(machine code/native code),我们书写的 C# 源代码并不会直接被 CPU 所执行,所以 Unity 在这里承担了一个代码转换和语言解释的工作,它首先会把 C# 代码首先转换成 通用中间语言 (Common Intermediate Language,CIL),最后第二步再解释成本地的机器语言。从通用中间语言转换到本地语言的过程可能会发生在程序编译 或者 运行(在代码执行之前)的时候。在编译过程中的转换被称作 提前编译(Ahead of Time Compilation, ATC),在运行过程中的转换被称作 实时编译/动态编译(Just in Time Compilation,JIT)。这两种编译的方法取决于运行设备,我们无法准确控制。

2.2 我们书写的代码和编译后的代码

对于 CPU 来说,执行不同指令 CPU 运行的时间是不同的。比如开方运算所需要的运行时间要远大于两个数的乘法运算,虽然我们在书写代码的时候并没有觉得它们的区别十分明显。另一点需要注意的就是,一行普通的代码看起来十分容易实现,但是却很有可能被编译成多条指令,然后才会被 CPU 执行。例如在 List 里面插入一个元素,这个指令就需要分解成多个不同的指令才能执行。虽然该插入指令并不会耗费许多时间,但是相对一条指令来说,它仍然是 “耗时” 的,这里想表达的意思是,只有了解了这背后的运行机制才能让我们更加游刃有余的来处理优化问题。

2.3 Unity 源代码与书写代码之间的通信

有一点需要明白的是,运行我们编写的 C# 代码和运行 Unity 的核心代码是有略微不同的。Unity 引擎的大多数核心功能都是由 C++ 编写的,这些引擎代码 (Unity engine code)的一部分,在安装 Unity 的时候就已经被编译成本地机器代码 (native code)而直接保存了下来。Unity 的源代码在这个编译的过程中,同样需要经历先编译成 CIL,然后再被编译成本地机器代码的这个过程。最初的 Unity 源代码被编译成 CIL后的代码可以称为 托管代码 Managed Code,当这个托管代码最后被编译成本地机器代码的时候,它会加入称作是 托管运行? Managed Runtime (个人理解是为了实现一个类似于检查器的功能),这个 Managed Runtime 会处理诸如内存管理,安全检查等可能会引发异常的状况,以确保 bug 不会出现。这个添加 Managed Runtime 的转换的过程被称作 Marshaling,这一过程虽然并不耗时,但是我们仍然需要明白它是有时间消耗的。

Figure 2.3 Code 转换机制

2.4 影响代码运行的因素

如果我们已经明确地知道(例如通过 Profile 查看出来),是编写的代码造成了性能损耗,那么可能造成的原因有如下几点:

  1. 首先很有可能是因为代码的编写质量不高造成的,例如代码冗余,结构复杂等,这时候就需要对代码进行优化和改善。
  2. 虽然代码编写质量很高,但是却调用了更为耗费时间的方法。例如图片 2.3 里从 Engine Code 转换到 Managed Code 的过程,调用了更为 “耗时” 的方法,在后面会介绍相应的解决办法。
  3. 代码在不该被调用的时候被调用。例如一个绘制敌人视线的代码,如果该敌人并不呈现在视野(摄像机)里面,那么这段代码就不应该被执行。
  4. 硬件性能不足造成的原因。一个具体的例子就是程序项目中需要执行大量的代理人(Agent)的 AI 计算,用于敌人路线规划,人物追踪等操作。如果代码已经不能再进一步优化,我们可以尝试使用其它技巧,例如使用其它 “虚假” 物体来替代敌人,或是暂用 AI 计算,执行某些特定的路径方法。当然,这些方法跟实际具体的项目有关,还需要有相当的经验才可以进一步完善代码质量。

2.5 代码优化的方法

代码优化并不一定会整体提高我们项目的执行效率,因为它很可能把一部分本来是需要 CPU 工作的内容转移到了 GPU,虽然 CPU 压力减少,但是同时 GPU 的压力却不断增大,从而导致整体运行效率进一步降低。因此,我们需要权衡利弊,来决定是否要执行代码优化,而不是按部就班,固守沉默的非要执行代码优化。所以我们必须要明白的就是,性能优化没有一个统一的执行标准,它需要我们不断的进行尝试(例如使用 Profile),最终取一个最优解来优化性能,可以说它是一个不断试错的方法。

我们需要了前面介绍完铺垫,终于可以开始正是进行代码优化了。下面的例子主要核心就是,尽量不要让代码在 Update 里面执行,因为在 Update 里面的方法每一帧都会调用,如果实在无法避免,那就尽量让每 N (N>1)帧执行一次,从而降低该方法在 Update 里面的执行次数

2.5.1 尽量让代码在循环外执行

执行代码循环不是非常的高效,尤其是循环代码的套嵌。如果这时候循环代码又在许多的 GameObject 执行,那么会更加影响项目的运行效率。

void Update()
{
	for(int i = 0; i < myArray.Length; i++)
	{
		if(exampleBool)
		{
			ExampleFunction(myArray[i]);
		}
	}
}

可以改成下面这种,把判断条件放到循环之外

void Update()
{
	if(exampleBool)
	{
		for(int i = 0; i < myArray.Length; i++)
		{
			ExampleFunction(myArray[i]);
		}
	}
}

虽然这个改动看起来非常简单,但是不要忘记的是,在 Update 里面执行的函数是每一帧都要执行的,如果 for 循环在每一帧都要调用,它会浪费掉 CPU 大量不必要的开销。

2.5.2 当变化时才执行代码

private int score;

public void IncrementScore(int incrementBy)
{
	score += incrementBy;
}

void Update()
{
	DisplayScore(score);
}

上面的代码展示了一个如何显示分数的例子,我们应该在分数变化的时候才调用这个更改分数的方法,而不应该在每一帧调用,所以上面的例子可以改为下面这样:

private int score;

public void IncrementScore(int incrementBy)
{
	score += incrementBy;
	DisplayScore(score);
}

这样一来,当分数改变的时候,我们仅仅需要调用一次 IncrementScore 方法即可实现分数的更改。

2.5.3 使用每N帧来执行代码一次

如果无法避免在 Update 里面调用方法,那么可以尝试每 N(N>1)帧来调用该方法。

void Update()
{
	ExampleExpensiveFunction();
}

我们可以改写一下,让方法 ExampleExpensiveFunction 每3帧运行一次

private int interval = 3;

void Update()
{
	if(Time.frameCount % interval == 0)
	{
		ExampleExpensiveFunction();
	}
}

或者让两个不同方法交替执行

private int interval = 3;

void Update()
{
	if(Time.frameCount % interval == 0)
	{
		ExampleExpensiveFunction();
	}
	else if(Time.frameCount % 1 == 1)
	{
		AnotherExampleExpensiveFunction();
	}
}

2.5.4 使用缓存 (Caching)

如果一个方法多次运行,而且最后的返回值会在某一时刻弃用,那么我们便可以在循环外创建一个变量,来存储返回值存储并加以复用,这种方法便成为 “缓存”。

void Update()
{
	Renderer myRenderer = GetComponent<Renderer>();
	ExampleFunction(myRenderer);
}

例如上面的代码,每一帧中都会调用 GetComponent 方法来寻找 Renderer,并在内存中重新创建一个新的 Renderer ,在循环结束后,这个新创建的 Renderer 便会销毁。这个新建-销毁的过程就会造成了性能损耗。我们可以通过下面这个方法来改善运行效率,避免了在每一帧创建新 Renderer

private Renderer myRenderer;

void Start()
{
	myRenderer = GetComponent<Renderer>();
}

void Update()
{
	ExampleFunction(myRenderer);
}

2.5.5 使用正确的数据结构(Data Structure)

使用何种类型的数据结构会显著影响到编写代码的运行效率,可惜的是并没有一个万能的数据结构来适用每一种情境,这里给出两个不同的用例:

  1. 每一帧都需要成执行成百上千次的迭代运算
  2. 不断的对许多物体进行创建和删除

为了判断使用哪种数据结构,就需要我们掌握每一种数据结构的优缺点,例如采取 大O符号(Big O Notation,不是数字0,是字母O,order的意思)的方法来决定最后到底使用哪种来应用到我们的项目中。关于大O符号方法,本质计算方法的时间复杂度,掌握 分期和最坏情况,例如O(1)表示仅需要一次计算,O(N^2)表示需要N的2次方时间等。如下图所示,假如 x 轴表示 GameObject 的个数, y 轴表示的是运算时间,那么当 GameObject 的个数大于 x0 的时候, f(x) 方法即为最优解,反之蓝色的 cg(x) 为最佳。更详细的资料可以查阅微软MSDN关于 集合和数据结构 的文章,该篇文章可以让我们更详细了解到不同数据结构的复杂度。

当输入大于 X0 的时候,f(x) < cg(x), 图片来源:wiki百科

2.5.6 善用对象池 (Object Pooling)

创建和销毁一个物体所消耗的时间要大于停用或激活显示一个物体。如果这个物体上面还挂在其它 C# 文件,这个时间消耗会更加明显。一个很经典的例子就是在射击类游戏中,我们需要不断的为武器创建大量子弹,如果需要创建的子弹满足一定数量,这时候我们使用对象池,它带来的收益将会相当明显。因为它并没有创建新子弹,而是在程序刚开始的时候已经创建了足够数量的子弹(本质上是已经分配好了内存),然后在运行过程中不断的重复激活和停用这些子弹。关于对象池,可以把每一个需要显示的新子弹想像成池子里面的海洋球,这些海洋球被池子里的孩童们不断的丢来丢去,但是海洋球的总量却没有增加和减少。如果使用对象池的话,就好比孩童们每一次想丢海洋球,就要去池边亲自拿一个,这样是是非常浪费时间的,当然也许有的朋友会说,我只想丢一个球怎么办,那么就可以回到上面的 2.5.5章节,采用大O算法来判断时间成本了。

2.5.7 尽量避免调用耗时的 Unity API

在某些情况下,我们并不清楚我们调用的 Unity API 异常的耗时。这里面有很多原因,例如看起来是一个变量但实际上却是个访问器 (Accessor),而且这个访问器还包含了各种额外的代码和事件触发器(Event Trigger)。接下来的几个小章节会介绍如何避免或减少这些不必要的时间成本。可惜的是 Unity 官方并没有一份表格,来显示 Unity 每一个 API 调用的时间成本。某些 API 虽然很费时间,但也许在特定场合下却非常有用,因此重要的是理解 API,并加以分析,找出代码费时的重要原因才是性能优化的根本所在,这正是验证了授人以鱼不如授人以渔这句古话。

2.5.7.1 SendMessage()

SendMessage()BroadcastMessage() 这两个函数使用起来都非常灵活,可惜的是他们相对的会耗用更多时间,这是因为这些函数利用极其耗时的反射(Reflection) 功能。所谓的反射,是指计算机程序在运行时可以访问、检测和修改它本身状态或行为的一种能力。用比喻来说,反射就是程序在运行的时候能够“观察”并且修改自己的行为(来源:wiki)。这两个方法仅推荐在原型设计中使用,如果我们知道要在哪一个具体的组件(Component)上面调用方法,我们就可以直接引用这个组件,然后调用其特定方法即可。如果不知道需要调用的组件,可以采用代理(delegate)和事件(event)的方法。

2.5.7.2 Find()

Find() 具有很强大的功能,可惜得是它仍然需要耗费不少时间,因为这个方法需要 Unity 遍历内存中的每一个 GameObject 和它的 组件(Component),如果随着项目中 GameObject 数量的增加,这个函数所需要的时间(复杂度)会大幅增加。因此如非不得已的情况,应该尽量避免使用该方法,例如设置在 Inspector 面板中直接添加所需组件的引用(拖拽相关的组件)。

2.5.7.3 Transform

设置物体的旋转和位置会触发 Unity 内部 OnTransformChanged 事件,并且该事件会传递到该物体的所有子集(Children),这就意味着,如果该物体有许多子集,设置旋转和位置同样就会是一件非常耗时的工作。在这种情况下,就应该避免过多得设置物体的位置。可以设想有这样一个情境,我们仅需要设置一个 GameObject 的 x 位置一次,然后又需要在 Update 里更新它的 position.z。在此事例中,我们应该首先考虑将变换的位置复制到 Vector3,然后对该 Vector3 进行所需的计算,最后将变换的位置设置为这个 Vector3 的值,这样只会调用一次 OnTransformChanged 事件。

个人感觉这里会产生歧义:

1. 在 Update 外面 new 一个 Vector3 变量来保存位置,然后在 Update 中更新该变量,最后再设置物体的位置为这个变量。这么做是避免了不断在 Update 里面创建销毁新变量,从而提高了运行效率,不过跟上下文内容不搭配。

2. 在 Update 方法外面设置这个物体的x位置,然后只是在 Update 里面对更新 z 变量,这样在 Update 里面减少了一次 OnTransformChanged 的调用。直译的话应该这点更倾向于这点,不过个人感觉,因为设置位置的总次数没变,每一次 Update 更新位置同样会触发 OnTransformChanged

此处的原文为: we should consider copying the transform’s position to a Vector3, performing the required calculations on that Vector3 and then setting the transform’s position to the value of that Vector3)。

另外一点, trasform.position 是一个 Accessor (前面提到过的访问器)可以理解为 Setter 和 Getter,背后都需要计算才能得到确切的数值。不同的是 transform.localPosition, 它直接存储了本地的位置信息,调用这个属性的话会直接得到结果,而不会再通过计算获得(注:类似于Swift 语言的储值属性和计算属性)。然而一个物体的 transform.position(世界坐标)在任何情况下都需要计算获得,如果我们能使用 transform.localPosition(本地坐标) 来替代世界坐标,那么就会显著的减少 CPU 压力,从而提高运行效率。如果实在无法避免经常性的调用 transform.position,那么可以使用 2.5.4 介绍的 使用缓存 来尽量优化我们的项目。

2.5.7.4 Update()

Update(), LateUpdate() 和其它的 事件函数(链接中Messages段的函数),虽然看起来十分容易,但是它们都在背后都隐藏了许多。这些函数都需要在每一帧中在 Unity 源代码和CIL托管代码(之前提到的 章节 2.3)之中进行通信。此外,Unity 仍然会在运行这些代码之前执行安全检查,用来来确保 GameObject 是已经激活,而不是出于一个已经被销毁的状态。虽然一次的调用并不会造成很大的开销,但是如果在上千个不同的脚本中运行,那带来的性能影响也不容忽视。因此,一个空的 Update() 函数同样会造成浪费,也许你会认为我在里面什么都没有写,但是这个 update 函数确确实实的在每一帧都运行了一遍。因此,如果我们确实不需要运行 update 函数,那么就请放心大胆的删掉它即可。(可以看下这篇 更有意思的文章

2.5.7.5 Vector2 和 Vector3

向量的计算要比普通的数学计算(例如 int 和 float 计算)耗费更多的时间,虽然这种时间差距不是很明显,但是在海量的数据规模下,大量的向量计算仍然会影响系统的性能。例如前面的章节提到了平方根计算要比乘法指令慢一些,计算 magnitude(向量大小) 和 distance(向量距离) 的背后都需要平方根计算,所以应该尽可能的避免在 Update 中调用。

2.5.7.6 Camara.main

在我们的脚本中使用 Camera.main 来获取摄像机是非常方便的(其实我也经常这么用😅),其背后的原理是 Unity 寻找 tag 标签为 “MainCamera”的摄像机,这个 API 的调用其实功能是类似于 Find() ,一旦涉及到寻找遍历,就会带来不必要的性能损失,因为它会搜索内存中所有的 GameObject 和 Component,所以除非必须,应尽量避免使用。

2.5.7.7 其余的 API 调用和进一步的优化

前面讨论了一些常见的 API 调用例子,我们了解到某些 API 的调用所耗费的时间会出乎我们意外,但这些方法并不是全部的方法列表,仅仅是一些表面泛用的案例,如果读者想进一步的深度研究,可以参考 这篇文章 来深入学习。

2.5.8 仅在需要运行的时候时候执行代码

编程中有句谚语:“最快的代码是无法运行的代码”。 这里指的是,解决性能问题的最有效方法是不使用高级技术,而是简单地删除不需要的代码。 接下来让我们看几个例子,看看在它可以应用在哪里:

2.5.8.1 Culling (剔除)

在项目运行后, Unity 会检查物体是否存在于摄像机的视锥中,所谓的视锥,可以简单理解为摄像机的可视范围(犹如人眼)所围成的锥形空间。当物体不在视锥中,该物体会被剔除掉而不会被玩家看到。当我们有一个十分庞大繁琐的场景时,我们可以设置代码,让不在视锥的物体脚本停止运行。如下面的敌人巡逻例子,每次调用 Update 的时候,都会调用两个不同的方法,一个于敌人的移动有关,另一个于视觉状态相关。

void Update()
{
	UpdateTransformPosition();
	UpdateAnimations();
}

然后我们就可以稍微修改优化下上面的代码,让敌人仅当出于可见状态时,激活有关相关的视觉状态。

private Renderer myRenderer;

void Start()
{
    myRenderer = GetComponent<Renderer>();
}

void Update()
{
    UpdateTransformPosition();

    if (myRenderer.isVisible)
    {
        UpateAnimations();
    }
}

当敌人不可见时,仅用代码可以通过上面的方法来实现。如果我们知道场景中的某些对象在特定的位置不可见,那么我们可以手动将其禁用。又或者当敌人的可见性并不确定的时候,(例如检查是否敌人被在玩家背后)我们可以使用粗略计算的方法,诸如调用 OnBecameInvisible()OnBecameVisible() 方法,抑或者是发射一道光线,通过获取碰撞的返回结果(hit) 来判断物体的粗略位置。(注:还是之前提到的那句老话,具体问题具体分析,关于系统优化没有一个固定的方法)

2.5.8.2 Level of detail (LOD)

LOD 是一种非常常见的渲染技术,它的的字面意思可以理解成一个模型在导入到项目中具有高中低的细节(例如更精细的网格和纹理等),通过玩家与模型的距离或是达到其它阈值来呈现不同等级的细节,例如玩家与模型的距离越近,显示的细节就越多,距离玩家越远,仅需要显示大致轮廓即可。例如场景中的一个敌人,我们可以根据敌人与玩家的距离,来启用或是禁用一些耗时的操作,例如 AI 脚本计算,从而节省出大量的系统性能。 关于 LOD 的使用可以参考 Unity 中的 CullingGroup API 或是其它教程。

3. 总结

这一篇文章主要介绍了关于代码优化的一些简要方法,其中最核心的内容就主要为:

  1. 避免调用耗费时间 Unity API,以及重复新建销毁 GameObject
  2. 尽量不要再 Update 中调用方法,除非万不得已
  3. 代码仅运行在它需要执行的地方,避免在它看不见的时候同样运行

在有了一个大致了解后,下一篇文章会介绍垃圾回收的机制原理,让我们可以更深入的了解性能优化。

未完待续。。。