[译文]Unity性能优化—脚本最佳实践

原文地址: Fixing Performance Problems – 2019.3

引言

当游戏在移动设备上运行的时候,游戏的每一帧可能需要CPU执行上百万个指令;为了保持平稳的帧率,CPU必须在规定时间内执行完相应的指令,一旦CPU无法及时执行所有指令,我们游戏就会变的卡顿

很多问题会导致 CPU 需要执行密集指令,比如:为渲染做准备工作、进行超复杂的物理运算或者太多的动画回调;本文主要探讨的是:我们编写的脚本导致的 CPU 性能问题。我们将学习脚本是如何转换成 CPU 指令,哪些代码会让 CPU 执行过多的指令,以及如何修复这些代码

诊断代码中的问题

CPU 超负荷运行的直接现象就是游戏不够流畅(帧频不稳)。然而其他性能问题也可能导致类似的现象。如果我们游戏有类似症状,我们首先想到的是要利用Unity 的 Profiler 来分析是不是因为 CPU 超负荷运行引起的;如果是那么进一步分析到底因为代码问题引起的;还是因为物理计算或者动画太多引起的(如何使用Profiler分析性能问题,请移步到:Diagnosing Performance Problems tutorial.

Unity构建和运行游戏

     为什么我们的代码可能造成性能问题呢 ? 我们首先需要知道当构建游戏的时候发生了什么事情,了解幕后发生的事情有助于我们提高游戏性能。构建游戏的时候,Unity 会把游戏运行需要的所有东西打包到对应设备能运行的程序内。众所周知,CPU 只能执行机器代码(原生代码:native code),不能执行一些高级语言编写的代码(比如: C#)。第一步:Unity 会将我们编写的高级语言转换成其他语言;这个转换过程我们称之为 编译( compiling ) , Unity 会把我们写的 C# 代码编译成CIL(译者注:这一步是通过 Mono 实现的),CIL 是一种很容易编译成不同原生代码的通用中间语言。第二步: CIL 然后被编译为目标设备对应的原生代码; 它发生在我们构建游戏时(静态编译,AOT )或代码运行之前(即时编译,JIT )

源代码与编译代码之间的关系

代码在未编译之前称为”源代码”,源代码直接决定了编译后的原生代码的结构和内容,结构良好且高效的源代码将生成结构良好且高效的已编译代码

有些 CPU 指令的执行时间比其他指令长。比如: 计算平方根比计算两个数相乘耗时更长。实际上 CPU 执行这两个指令的时间都非常短暂,但是它们之间相对来说还是有快慢之分

一些在源代码中看起来非常简单的操作,编译之后的原生代码会非常复杂。比如将一个元素插入列表,执行此操作所需要的指令要比按索引从数组中访问元素多得多

理解了以上两点,即便你对一些原生代码底层如果工作不是很熟悉,但是可以通过源代码和编译以后的代码之间的联系编写高性能的源代码

Unity引擎代码与脚本代码在运行时通讯

用 C# 编写的脚本代码与Unity引擎核心代码的运行方式略有不同。Unity 引擎的内核代码是用C++编写的,并且已经被编译成原生代码。这个编译后的引擎代码包含在我们安装的Unity程序中

源代码被编译成 CIL 以后,称为托管代码。当托管代码被编译成原生代码时,它与托管运行时集成。托管运行时负责内存管理和安全检查,以确保代码中的错误只是会导致异常,而不会造成设备崩溃

当数据从托管代码传递回引擎代码时,CPU 可能需要将数据从托管运行时使用的格式转换为引擎代码所需的格式,这种转换称为编组(marshlling)。托管代码和引擎代码之间的任何单个调用的开销都不是很大,但是我们必须知道这里是有开销的

代码性能不佳的原因

现在我们已经了解了 Unity 构建和运行游戏时代码会发生什么,我们也知道,我们的代码之所以性能很低,那是因为它们导致了 CPU 执行了密集指令

第一种可能是我们的代码很浪费或者结构很差。常见的例子可能是:当一个函数只需要被调用一次时,它却被重复调无数次

第二种可能是我们的代码看起来结构良好,但是对其他代码进行不必要的大量调用。这方面的一个例子可能是导致托管代码和引擎代码之间不必要的重复通讯从而造成 CPU 开销

第三种可能是我们的代码的没有任何问题,但是在不需要调用它的时候它依然被调用(一个例子是:怪物自动寻敌代码,当主角离怪物足够远的时候,这段代码应该停止执行)

最后一种可能是我们的代码需求太高了。这个我们只能重新设计游戏以降低它对性能的要求。实现这种优化超出了本文的范围,因为它非常依赖于游戏本身,但是阅读这篇文章并考虑如何使我们的游戏尽可能地拥有高性能仍然会对我们有好处

提高代码性能

一旦确定我们游戏中的性能问题是由于代码造成的,我们就应该仔细想想怎么解决这些问题。优化臃肿的函数似乎是个好的开始,当然也有可能这个函数为了实现这个功能已经是最优写法了,再去改动需要花费的代价太大(有可能导致功能异常或者带来新的bug)。我们可以在一个被数百个游戏对象所引用的脚本中创造一个小的效率节约,那么这就会给我来很可观的性能提升。此外,可能为了提高 CPU 的性能,会导致更多的内存消耗或者将工作量分担给了 GPU

尽量将代码移出循环

循环是个老生常谈的低效率代码之一,嵌套循环更甚。如果它们是被包含在Update 这类每一帧都会被调用的函数中,那么效率更低。在下面的示例中,无论条件是否满足,for 循环每一帧都被执行,这是非常糟糕的代码

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() 是每一帧都会被调用的函数,将代码移出 Update() 在需要时才运行,这是提高性能的一种好办法,而且也是最容易实现的办法之一

只有情况发生改变时才调用代码

让我们来看一个非常简单的例子(只在状态发生变化时才调用它)。在下面的代码中,在 Update() 中调用 DisplayScore() 。事实上分数的值不可能每一帧都发生变化,这意味着,我们可以将它移出 Update()

private int score;

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

void Update()
{
	DisplayScore(score);
}

通过一个简单的更改,我们现在确保只有在 Score 的值发生改变时才调用DisplayScore()

private int score;

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

每隔X帧执行代码

如果代码需要频繁运行并且不能被事件触发,那其实也并不意味着每一帧都需要调用,可以每隔X帧调用一次

void Update()
{
	ExampleExpensiveFunction();
}

我们每3帧调用一次这段代码就行了,在下面的代码中,我们使用模运算来确保该函数每隔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();
	}
}

缓存变量

我们通常调用 GetComponent() 来访问组件。在下面的示例中,我们在Update() 中调用GetComponent() 来获取 Renderer 组件,并将它传给ExampleFunction() 使用。这段代码可以工作,但是它的效率很低

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

下面的代码只调用 GetComponent() 一次,因为结果被变量myRenderer缓存。变量可以在 Update() 中重用,而无需调用 GetComponent() 重新获取

private Renderer myRenderer;

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

void Update()
{
	ExampleFunction(myRenderer);
}


对于频繁调用返回结果的函数的情况,我们应该检查代码,最好通过使用缓存来降低这些调用

使用正确的数据结构

为了正确地决定使用哪种数据结构,我们需要了解不同数据结构的优缺点,并仔细考虑我们希望代码做什么。我们可能有数千个元素需要每一帧中迭代一次,或者我们可能有少量的元素需要频繁地添加和删除。这些不同的问题最好由不同的数据结构来解决。 如果数据结构对你来说是一个新的知识领域,可以参考:数据结构

尽量减少垃圾回收的影响

垃圾回收是 Unity 管理内存的一部分。我们的代码使用内存的方式决定了垃圾收集的频率和 CPU 成本,因此理解垃圾回收是如何工作的非常重要。在下一篇文章中,我们将深入讨论垃圾回收,并提供几种不同的策略来最小化垃圾收集的影响

使用对象池

实例化和销毁一个对象通常比停用和重新激活一个对象花费更多。如果对象包含启动代码,例如在 Awake() 或 Start() 函数中调用GetComponent(),则更是如此。如果我们需要生成和处理多个相同对象,比如射击游戏中的子弹,那么使用对象池就非常有必要

对象池是一种技术,它不是创建和销毁对象的实例,而是临时停用对象,然后根据需要回收和重新激活。尽管对象池作为一种管理内存使用的技术广为人知,但作为一种减少过度使用CPU的技术,它也很有用。Unity教程上有关于在Unity中实现对象池的应用的内容

避免昂贵的Unity API的调用

有时,我们的代码对其他函数或 api 的调用可能会出乎意料性能消耗。因为有些看起来像变量的东西可能包含额外的代码、触发事件或从托管代码调用引擎代码的访问器(accessor)

在这一节中,我们将看一些 Unity API 调用的例子,这些调用的代价比看起来的要高。这些例子展示了成本的不同潜在原因,建议的解决方案可以应用于其他类似的情况

很遗憾,我无法列出哪些 Unity API 你绝对禁止调用。因为每个 API 有时候你必须使用,而且合理使用的时候开销并不会很大。在任何情况下,我们都必须仔细分析我们的游戏,找出导致开销昂贵的原因,并仔细考虑如何以最适合我们游戏的方式解决问题。

SendMessage()

SendMessage() 和 BroadcastMessage() 是非常灵活的函数,它们几乎不需要了解项目的结构,而且实现起来非常快。因此,这些函数对于原型或初学者来说非常有用。然而,它们是非常耗费性能的 API。这是因为这些函数使用了反射

建议仅将 SendMessage() 和 BroadcastMessage()用于快速出Demo的时期,并尽可能使用其他函数代替。例如,如果我们知道要在哪个组件上调用某个函数,我们应该直接引用该组件并直接调用该函数;如果我们不知道在哪个组件上调用该函数,那么可以考虑使用事件或委托

Find()

Find() 和相关的函数功能强大,但是开销也很大。这些函数需要 Unity 遍历内存中的每个 GameObject 和组件。这意味着在小项目中不需要特别关注效率,但是随着项目复杂性增加,它们的使用成本会越来越高

Transform

设置 transform 位置或旋转将导致内部 OnTransformChanged 事件传播到该 transform 的所有子节点。这意味着设置一个 transform 位置和旋转值相对比较昂贵,特别是在有许多子元素的转换中

为了限制这些内部事件的数量,我们应该避免设置这些属性的值。例如,我们不应该单独设置 transform 的 x,y,z的值。而是将 x,y,z的值放入 vector3中,然后将 vector3的值赋给 transform,这只会导致一个 OnTransformChanged 事件

如果代码经常使用Transform.position,应该用Transform.localPosition替换。这将导致更少的CPU指令,并可能最终提高性能。如果我们经常使用Transform.position,我们应该在可能的地方缓存它

Update()

Update(),LateUpdate() 和其他事件函数看起来像简单的函数,但是它们有隐藏的开销。每次调用这些函数,都需要在引擎代码和托管代码之间进行通信。除此之外,Unity在调用这些函数之前还进行了一些安全检查。安全检查确保GameObject处于有效状态,没有被销毁等等

因此,空 Update() 调用可能特别浪费。你可能猜想,因为函数是空的,并且我们的代码不包含对它的直接调用,所以空函数不会运行。事实并非如此:在幕后,即使 Update() 函数的主体为空,这些安全检查和本机调用仍然会发生。为了避免浪费CPU时间,我们应该确保项目中不包含空的Update()调用(译者注:可以写个脚本,在打包的时候执行该脚本,检测到了空Update()函数,自动删除它)

关于Update(),这篇文章带来了深入的探讨:10000 Update() calls

Vector2 和 Vector3

我们知道一些操作会导致比其他操作更多的CPU指令。向量数学操作就是一个例子:它们比浮点或整数操作更复杂。尽管两次这样的计算所花费的时间实际差异很小,但是在足够大的范围内,这样的操作可能影响性能

在数学运算中使用 Unity 的 Vector2 和 Vector3 结构是很常见和方便的,特别是在处理 transform 时。如果我们在 Update() 中的嵌套循环对大量游戏对象执行这些操作,我们很可能会给 CPU 带来不必要的工作。在这些情况下,我们可以通过执行int或float计算来节省性能

在文章的前面,我们了解到执行平方根计算所需的 CPU 指令比简单乘法所需的CPU指令要多的多。Vector2.Magnitude 和Vector3.Magnitude 就是一个例子,因为它们都涉及平方根计算。如果我们的游戏广泛而频繁地使用 magnitude 或 Distance,那么我们就有可能使用Vector2.sqrMagnitude和Vector3.sqrMagnitude代替来避免相对昂贵的平方根计算

Camera.main

Camera.main是一个方便的 Unity API ,它返回对第一个被标记为“Main Camera” 的已激活相机的引用。这是另一个类似于变量但实际上是访问器(accessor)的例子。在这种情况下,访问器在幕后调用类似Find()的函数。因此Camera.main 面临着与 Find() 相同的问题:它搜索内存中的所有游戏对象和组件,并且使用起来非常耗费性能。为了避免这种潜在的昂贵调用,我们应该缓存Camera.main的结果,或者避免使用它,并手动管理对相机的引用

其他的Unity API调用和进一步优化

我们已经考虑了一些 Unity API 调用的常见例子,它们可能会带来意想不到的代价,并且了解这种代价背后的不同原因。然而,这并不是提高Unity API调用效率的全部方法。这里有一篇关于Unity性能优化的文章,它包含了许多我们可能会发现有用的其他Unity API优化

只在需要运行时运行代码

鲁迅说过:”最快的代码是没有运行的代码”。通常,解决性能问题的最有效方法不是使用高级技术而是删除不需要的代码

裁剪

Unity 包含代码来检查对象是否在摄像机视锥体内。如果它们不在摄像机的视锥体内,则渲染这些对象相关的代码不会运行。这里的术语是视锥体剔除

我们可以对脚本中的代码采取类似的方法。如果我们有与对象的可视状态相关的代码,当玩家看不到该对象时,我们可能不需要执行此代码。在有许多对象的复杂场景中,这可以节省大量的性能

在下面的简化示例代码中,我们有一个巡逻敌人的示例。每次调用Update()时,控制这个敌人的脚本都会调用两个示例函数:一个与移动敌人有关,一个与它的可视状态有关

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

在下面的代码中,我们现在检查敌人的渲染器是否在任何摄像机的视锥体内。与敌人的可视状态相关的代码仅在敌人可见时才运行

private Renderer myRenderer;

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

void Update()
{
    UpdateTransformPosition();

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

在玩家看不到的情况下禁用代码有几种方法。如果我们知道我们的场景中的某些对象在游戏中的某个特定点是不可见的,我们可以手动禁用它们。当我们不确定并且需要计算可见性时,我们可以使用粗略的计算(例如,检查玩家背后的对象)、OnBecameInvisible() 和 OnBecameVisible() 等函数,或者更详细的raycast。最好的实现在很大程度上取决于我们的游戏,而实验和分析是必不可少的。

LOD

LOD 是另一种常见的渲染优化技术。最接近玩家的物体使用更详细的网格和纹理以完全保真的方式渲染。远处的物体使用较少的细节网格和纹理。我们的代码也可以使用类似的方法。例如,我们可能有一个敌人的AI脚本决定了它的行为。这种行为的一部分可能涉及昂贵的操作。以确定它可以看到和听到什么,以及它应该如何对输入作出反应。我们可以使用一个详细级别的系统来启用和禁用这些昂贵的操作

总结

我们已经了解了在Unity游戏构建和运行时我们编写的代码会发生什么变化,为什么我们的代码会导致性能问题,以及如何最小化昂贵代码对游戏的影响。我们已经了解了代码中导致性能问题的一些常见原因,并考虑了一些不同的解决方案。使用这些知识和分析工具,我们现在应该能够诊断、理解和修复与游戏中的代码相关的性能问题

发表评论

电子邮件地址不会被公开。 必填项已用*标注