Unity优化方向——在Unity游戏中优化脚本(译)
Posted gozili
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Unity优化方向——在Unity游戏中优化脚本(译)相关的知识,希望对你有一定的参考价值。
介绍
当我们的游戏运行时,我们设备的中央处理器(CPU)执行指令。游戏的每一帧都需要执行数百万的CPU指令。为了保持平滑的帧率,CPU必须在规定的时间内执行指令。当CPU不能及时执行所有指令时,我们的游戏可能会变慢、卡顿。
许多事情会导致CPU有太多的工作要做。例如,要求很高的渲染代码、过于复杂的物理模拟或太多的动画回调。本文只关注其中一个原因:我们在脚本中编写的代码导致的CPU性能问题。
在本文中,我们将了解如何将脚本转换为CPU指令,什么会导致脚本为CPU生成过多的工作,以及如何修复脚本中的代码导致的性能问题。
诊断代码中的问题
对CPU的过度需求导致的性能问题可以表现为低帧速率、不稳定性能或间接性冻结。然而,其他问题也会引起类似的症状。如果我们的游戏有这样的性能问题,我们必须做的第一件事就是使用Unity的Profiler窗口来确定我们的问题是否是由于CPU不能及时完成它们的任务。一旦我们确定了这一点,我们就必须确定用户脚本是否是问题的原因、或者问题是由游戏的其他部分引起的:例如复杂的物理或动画。
要了解如何使用Unity的Profiler窗口来查找性能问题的原因,请参阅这个教程。
简单介绍Unity是如何构建和运行我们的游戏的
要理解为什么我们的代码不能很好地执行,我们首先需要理解当Unity 构建我们的游戏时会发生什么。了解游戏背后发生的事情将有助于我们做出关于如何提高游戏性能的明智决定。
构建过程
当我们构建游戏时,Unity会将运行游戏所需的所有内容打包到一个可以由目标设备执行的程序中。CPU只能运行用非常简单的语言编写的代码,如机器码或本地代码;它们不能运行用更复杂的语言编写的代码,比如C#。这意味着Unity必须将我们的代码翻译成其他语言。这个翻译过程成为编译。
第一步,Unity首先将我们的脚本编译成一种叫做公共中间语言(CIL)的语言。CIL是一种很容易编译成各种不同的本地代码的语言。CIL然后被编译为我们特定目标设备的本地代码。第二步发生在我们构建游戏时(即提前编译或AOT编译),或在目标设备上,即代码运行之前(即即时编译或称JIT编译)。我们的游戏使用AOT还是JIT编译通常取决于目标硬件。
我们编写的代码和编译的代码之间的关系
尚未编译的代码称为源代码。我们编写的源代码决定了已编译代码的结构和内容。在大多数情况下,结构良好和高效的源代码将产生结构良好和高效的编译代码。然而,了解一些本地代码对我们很有用,这样我们就可以更好地理解为什么一些源代码会被编译成更高效的本地代码。
首先,一些CPU指令执行起来比其他指令要花费更多的时间。计算平方根就是一个例子。这个计算的时间和两个数字相乘要长。单个快CPU指令和单个慢CPU指令之间的区别确实非常小,但是理解这一点是很有用的,从根本上说,有些指令比其他指令要快。
我们需要了解的下一件事是,在源代码中看起来非常简单的一些操作在编译成本机代码时可能会异常复杂。一个例子是将一个元素插入到一个列表中。执行这个操作需要更多的指令,例如,通过索引从数组中访问元素。同样,当我们考虑一个单独的示例时,我们所说的时间花费非常少,但是重要的是要理解某些操作会导致比其他操作需要更多的指令。
理解这些思想将帮助我们理解为什么有些代码比其他代码执行得更好,即使两个示例做的事情非常相似。即使是对于底层事物如何工作的没有更深的理解,也能帮助我们编写出性能良好的游戏源代码。
Unity引擎代码和脚本代码之间的运行时通信
用C#编写的脚本与组成Unity引擎的代码的运行方式略有不同,理解这一点很有用。Unity引擎的大部分核心功能是用C++编写的,并且已经被编译成本地代码。这个编译的引擎代码是我们安装Unity时所安装的一部分。
被编译成CIL的代码,如源代码,称为托管代码。当托管代码被编译成本地代码时,它与托管运行时集成。托管运行时负责自动内存管理和安全检查,以确保代码中的错误只是会导致异常,而不是设备崩溃。
当CPU在运行引擎代码和托管代码之间转换时,必须进行设置这些安全检查的工作。当将数据从托管代码传递回引擎代码时,CPU可能需要将数据从托管运行时使用的格式转换为引擎代码所需的格式。这种转换称为编组(marshlling)。同样,托管代码和引擎代码之间的任何单个调用的开销都不是特别大,但是我们必须了解这种开销的存在。
代码执行不佳的原因
现在我们已经了解了Unity构建和运行游戏时代码会发生什么变化,我们也能够理解当我们的代码表现不佳时,那是因为它在运行时为CPU创建了太多的工作。让我们为此考虑一下不同的原因。
第一种可能是我们的代码很浪费或者结构很差。这方面的一个例子可能是,当同一个函数只能调用一次时,它会重复调用这个函数。本文将介绍几个常见的结构不良的示例,并展示示例解决方案。
第二种可能是,我们的代码看起来结构良好,但是对其他代码进行不必要的大量调用。这方面的一个例子可能是导致托管代码和引擎代码之间不必要调用的代码。本文将给出一些Unity API调用的例子,这些调用可能会出乎意料地昂贵性能耗费,建议使用更有效的替代方法。
下一种可能性是,我们的代码是有效的,但是在不需要时调用它。一个例子是,模拟敌人视线的代码。代码本身可能执行得很好,但是当玩家离敌人很远的时候运行这些代码是非常浪费的。本文包含一些技术示例,这些技术可以帮助我们编写只在需要时运行的代码。
最后一种可能是我们的代码需求太高了。这方面的一个例子是一个非常详细的模拟,其中大量的代理使用复杂的人工智能。如果我们已经用尽了其他可能性并尽可能地优化了这段代码,那么我们可能只需要重新设计我们的游戏以降低它的要求:例如,模拟元素而不是计算它们。实现这种优化超出了本文的范围,因为它非常依赖于游戏本身,但是阅读这篇文章并考虑如何使我们的游戏尽可能地拥有高性能仍然会对我们有好处。
改进代码的性能
一旦我们确定游戏中的性能问题是由代码引起的,我们就必须仔细考虑如何解决这些问题。优化一个要求很高的函数似乎是一个很好的起点,但也有可能这个函数本身就已经是最优的了,而且从本质上来说代价是昂贵的。而不是改变这个功能,我们可以在一个被数百个游戏对象所使用的脚本中创造一个小的效率节约,这将给我们带来更有用的性能提升。此外,提高代码的CPU性能可能会付出代价:更改可能增加内存的使用或将工作转移到GPU。
由于这些原因,本文不是一组简单的步骤。相反,本文是一系列改进代码性能的建议,并提供了应用这些建议的示例。对于所有的性能优化,没有硬性和快速的规则。最重要的是对我们的游戏进行剖析,理解问题的 本质,尝试不同的解决方案,并衡量我们改变的结果。
编写高效的代码
编写有效的代码并对齐进行合理的结构化可以提高游戏的性能。虽然所展示的示例是在Unity游戏的上下文中,但是这些通用的最佳实践建议并不特定于Unity项目或Unity API调用。
尽可能将代码移出循环
循环是效率低下的常见地方,尤其是在嵌套循环时。如果它们处于一个非常频繁运行的循环中,特别是在我们的游戏中许多游戏对象上都能找到这段代码,那么低效率便会不断累积。
在下面的简单示例中,无论是否满足条件,每次调用Update()时,我哦们的代码都会遍历循环。
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()是一个由Unity在每一帧中运行一次的函数。Update()是放置需要频繁调用的代码或必须响应频繁更改的代码的位置。然而,并非所有这些代码都需要每一帧都运行。将代码移出Update(),使其只在需要时运行,这是提高性能的好方法。
只有在情况发生时才运行代码
让我们看一个非常简单的优化代码的例子,使它只在发生变化时才运行。在下面的代码中,在Update()中调用DisplayScore()。然而,分数的值可能不会随着每一帧而改变。这意味着我们不必要调用DisplayScore()。
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);
}
同样,上面的例子是故意简化的,但原理是清晰的。如果我们在整个代码中应用这种方法,我们可能能够节省CPU资源。
每隔[x]帧运行代码
如果代码需要频繁运行并且不能被事件触发,那并不意味着它需要每一帧都运行。在这些情况下,我们可以选择在每隔[x]帧运行代码。
在这个例子中,一个昂贵的函数每帧运行一次。
void Update()
{
ExampleExpensiveFunction();
}
事实上,我们每3帧运行这段代码一次就足够了。在下面的代码中,我们使用模运算符来确保昂贵的函数只在每隔3帧运行一次。
private int interval = 3;
void Update()
{
if (Time.frameCount % interval == 0)
{
ExampleExpensiveFunction();
}
}
这种技术的另一个好处是很容易在不同的帧之间分散开销很大的代码,从而避免达到性能开销峰值。在下面的示例中,每3帧调用一次每个函数,而且从不在同一帧上调用。
private int interval = 3;
void Update()
{
if (Time.frameCount % interval == 0)
{
ExampleExpensiveFunction();
}
else if (Time.frameCount % interval == 1)
{
AnotherExampleExpensiveFunction();
}
使用缓存机制
如果我们的代码反复调用返回结果的昂贵函数,然后丢弃这些结果,这可能是一个优化的机会。存储和重用对这些结果的引用会更有效。这种技术称为缓存。
在Unity中,通常是调用GetComponent()来访问组件。在下面的示例中,我们在Update()中调用GetComponent()来将Renderer组件传递给另一个函数之前访问它。这段代码可以工作,但是由于重复的GetComponent()调用,它的效率很低。
void Update()
{
Renderer myRenderer = GetComponent<Renderer>();
ExampleFunction(myRenderer);
}
下面的代码只调用GetComponent()一次,因为函数的结果被缓存。缓存的结果可以在Update()中重用,而无需进一步调用GetComponent()。
private Renderer myRenderer;
void Start()
{
myRenderer = GetComponent<Renderer>();
}
void Update()
{
ExampleFunction(myRenderer);
}
对于频繁调用返回结果的函数的情况,我们应该检查代码。我们可以通过使用缓存来降低这些调用的成本。
使用正确的数据结构
我们如何构造数据对代码的执行有很大的影响。没有一种单一的数据结构是适用于所有情况的,所以为了在游戏中获得最好的性能,我们需要为每个任务使用正确的数据结构。
为了正确地决定使用哪种数据结构,我们需要了解不同数据结构的优缺点,并仔细考虑我们希望代码做什么。我们可能有数千个元素需要每一帧中迭代一次,或者我们可能有少量的元素需要频繁地添加和删除。这些不同的问题最好由不同的数据结构来解决。
在这里做出正确的决定取决于我们对这门学科的知识。如果对于你来说这是一个新的知识领域,最好的起点是学习大O符号。大O符号是讨论算法复杂性的,理解这一点将有助于我们比较不同的数据结构。这篇文章是一个清晰的入门指南。然后我们可以更多地了解可用的数据结构,并对它们进行比较,以找到针对不同问题的正确数据解决方案。MSDN在C#中的集合和数据结构指南提供了选择适当数据结构的一般指导,并提供了指向更深入文档的链接。
关于数据结构的单一选择不太可能对我们的游戏产生重大影响。然而,在一个包含大量此类集合的数据驱动游戏中,这些选择的结果实际上是可以累加的。理解算法的复杂度以及不同数据结构的优缺点将有助于我们创建性能良好的代码。
尽量减少垃圾回收的影响
垃圾回收是Unity管理内存的一部分。代码使用内存的方式决定了垃圾回收的频率和CPU成本,因此了解垃圾回收的工作方式非常重要。这篇深入讨论了垃圾回收的主题,并提供了几种不同的策略来最小化其影响。
使用对象池
实例化和销毁一个对象通常比停用和重新激活一个对象花费更多。如果对象包含启动代码,例如在Awake()或Start()函数中调用GetComponent(),则更是如此。如果我们需要生成和处理相同对象的多个副本,比如射击游戏中的子弹,那么我们可能会收益于对象池。
对象池是一种技术,它不是创建和销毁对象的实例,而是临时停用对象,然后根据需要回收和重新激活。尽管对象池作为一种管理内存使用的技术广为人知,但作为一种减少过度使用CPU的技术,它也很有用。
关于对象池的完整使用指南超出了本文的范围,但它确实是一种有用的技术,值得学习。Unity网站上有关于在Unity中实现对象池的应用的教程。
避免昂贵的Unity API的调用
有时,我们的代码对其他函数或api的调用可能会出乎意料地昂贵。这可能有很多原因,实际上,看起来像变量的东西可能是包含额外的代码、触发事件或从托管代码调用引擎代码的访问器(accessor)。
在这一节中,我们将看到一些Unity API调用的例子,这些调用的代价比看起来的要高。我们将考虑如何减少或避免这些成本。这些例子展示了成本的不同潜在原因,建议的解决方案可以应用于其他类似的情况。
有一点很重要,那就是没有我们应该避免的Unity API调用列表。每个API调用在某些情况下有用,而在其他情况下用处不大。在任何情况下,我们都必须仔细分析我们的游戏,找出导致开销昂贵的原因,并仔细考虑如何以最适合我们游戏的方式解决问题。
SendMessage()
SendMessage()和BroadcastMessage()是非常灵活的函数,它们几乎不需要了解项目的结构,而且实现起来非常快。因此,这些函数对于原型或初学者来说非常有用。然而,它们的使用非常耗费性能。这是因为这些函数使用了反射。反射是指代码在运行时对自身进行检查和决策的术语。使用反射的代码比不使用反射的代码为CPU带来更多的工作。
建议仅将SendMessage()和BroadcastMessage()用于原型,并尽可能使用其他函数。例如,如果我们知道要在哪个组件上调用函数,我们应该直接引用该组件并以这种方式调用函数。如果我们不知道希望在哪个组件上调用函数们可以考虑使用事件或委托。
Find()
Find()和相关的函数功能强大,但是开销很大。这些函数需要Unityt遍历内存中的每个GameObject和组件。这意味着在小的、简单的项目中不需要特别关注效率,但是随着项目复杂性的增加,它们的使用成本会越来越高。
最好不经常使用Find()和类似的函数,并尽可能缓存结果。一些简单的技术可以帮助我们减少在代码中使用Find(),包括在可能的情况下使用Inspector面板设置对对象的引用,或者创建脚本来管理对经常搜索的内容的引用。
Transform
设置转换的位置或旋转将导致内部OnTransformChanged事件传播到该转换的所有子节点。这意味着设置一个转换的位置和旋转值相对比较昂贵,特别是在有许多子元素的转换中。
为了限制这些内部事件的数量,我们应该避免设置这些属性的值,这是不必要的。例如,我们可以执行一个计算来设置转换的x位置,然后在Update()中执行另一个计算来设置转换z的位置。在本例中,我们应该考虑将转换的位置复制到Vector3,在该Vector3上执行所需的计算,然后将转换的位置设置为Vector3的值。这只会导致一个OnTransformChanged事件。
Transform.position是导致幕后计算的访问器的一个示例。这可以与Transform.localPosition进行对比。localPosition的值存储在转换和调用转换中。localPosition只返回这个值。但是,每次调用transform.position时都会计算转换的直接坐标。
如果我们的代码经常使用Transform.position,我们用Transform.localPosition去替换。这将导致更少的CPU指令,并可能最终提高性能。如果我们经常使用Transform.position,我们应该在可能的地方缓存它。
Update()
Update(),LateUpdate()和其他事件函数看起来像简单的函数,但是它们有隐藏的开销。每次调用这些函数,都需要在引擎代码和托管代码之间进行通信。除此之外,Unity在调用这些函数之前还进行了一些安全检查。安全检查确保GameObject处于有效状态,没有被销毁,等等。这种开销对于任何单个调用都不是特别大,但它可以在拥有数千个Monobehaviors中累计起来。
因此,空Update()调用可能特别浪费。我们可以猜想,因为函数是空的,并且我们的代码不包含对它的直接调用,所以空函数不会运行。事实并非如此:在幕后,即使Update()函数的主体为空,这些安全检查和本机调用仍然会发生。为了避免浪费CPU时间,我们应该确保游戏不包含空的Update()调用。
如果我们的游戏有许多激活的Monobehavior带有Update()调用,我们可以通过不同的代码结构来减少这种开销。这篇关于这个主题的文章更详细地讨论了这个主题。
Vector2 和Vector3
我们知道一些操作只会导致比其他操作更多的CPU指令。向量数学操作就是一个例子:它们比浮点或整数数学操作更复杂。尽管两次这样的计算所花费的时间实际差异很小,但是在足够大的范围内,这样的操作可能影响性能。
在数学运算中使用Unity的Vector2和Vector3结构是很常见和方便的,特别是在处理转换时。如果我们在代码中频繁地执行Vector2和Vector3数学操作,例如在Update()中的嵌套循环对大量游戏对象执行这些操作,我们很可能会给CPU带来不必要的工作。在这些情况下,我们可以通过执行int或float计算来节省性能。
在文章的前面,我们了解到执行平方根计算所需的CPU指令比简单乘法所需的CPU指令要慢。Vector2.Magnitude和Vector3.Magnitude就是一个例子,因为它们都设计平方根计算。此外还有Vector2.Distance和Vector3.Distance被背后使用了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的CullingGroup API允许我们连接到Unity的LOD系统来优化我们的代码。CullingGroup API的手册页面包含了几个在我们的游戏中如何使用它的例子。一如既往,我们应该测试、分析并为我们的游戏找到正确的解决方案。
总结
我们已经了解了在Unity游戏构建和运行时我们编写的代码会发生什么变化,为什么我们的代码会导致性能问题,以及如何最小化昂贵代码对游戏的影响。我们已经了解了代码中导致性能问题的一些常见原因,并考虑了一些不同的解决方案。使用这些知识和分析工具,我们现在应该能够诊断、理解和修复与游戏中的代码相关的性能问题。
以上是关于Unity优化方向——在Unity游戏中优化脚本(译)的主要内容,如果未能解决你的问题,请参考以下文章
Unity优化篇 | Unity脚本代码优化策略,快速获取 游戏对象 和 组件 的方法文末送书