摘要
一旦您在游戏中发现了性能问题,您应该如何着手修复它?本教程讨论了脚本、垃圾收集和图形渲染的一些常见问题和优化技术。
1 1.优化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 指令和单个慢速 CPU 指令之间的差异确实非常小,但从根本上理解某些指令比其他指令更快这一点对我们很有用。接下来我们需要了解的是,一些在源代码中看起来非常简单的操作在编译为代码时可能会非常复杂。这方面的一个例子是将一个元素插入到列表中。比起按索引访问数组中的元素,执行此操作需要更多的指令。同样,当我们考虑单个示例时,我们讨论的时间很少,但重要的是要了解某些操作会导致比其他操作更多的指令。理解这些想法将有助于我们理解为什么某些代码比其他代码执行得更好,即使两个例子做的事情非常相似。即使对事物在底层如何运作的背景了解有限,也可以帮助我们编写性能良好的游戏。
Unity 引擎代码和我们的脚本代码之间的运行时通信
了解我们用 C# 编写的脚本的运行方式与构成大部分 Unity 引擎的代码的运行方式略有不同,这对我们很有用。Unity 引擎的大部分核心功能都是用 C++ 编写的,并且已经编译为本地代码。这个编译后的引擎代码是我们安装Unity时安装的一部分。编译成CIL的代码,比如我们的源代码,被称为managed code 。当托管代码被编译为本机代码时,它与称为托管运行时的东西集成在一起. 托管运行时负责诸如自动内存管理和安全检查之类的事情,以确保我们代码中的错误会导致异常而不是设备崩溃。当 CPU 在运行的引擎代码和托管代码之间转换时,必须完成工作以设置这些安全检查。当将数据从托管代码传回引擎代码时,CPU 可能需要将数据从托管运行时使用的格式转换为引擎代码所需的格式。这种转换称为编组。同样,托管代码和引擎代码之间的任何单个调用的开销并不是特别昂贵,但重要的是我们要了解这种成本的存在。
代码性能低下的原因
现在我们了解了当 Unity 构建和运行我们的游戏时我们的代码发生了什么,我们可以理解当我们的代码执行不佳时,这是因为它在运行时为 CPU 创造了太多的工作。让我们考虑一下造成这种情况的不同原因。第一种可能是我们的代码只是浪费或结构不佳。这方面的一个例子可能是重复调用同一个函数的代码,而它只能调用一次。本文将涵盖几个结构不良的常见示例并展示示例解决方案。第二种可能性是我们的代码看起来结构良好,但对其他代码进行了不必要的昂贵调用。这方面的一个例子可能是导致托管代码和引擎代码之间不必要调用的代码。本文将给出可能会意外昂贵的 Unity API 调用示例,使用建议的更有效的替代方案。下一个可能性是我们的代码是有效的,但它在不需要的时候被调用。这方面的一个例子可能是模拟敌人视线的代码。代码本身可能表现良好,但是当玩家离敌人很远时运行这段代码就很浪费了。本文包含的技术示例可以帮助我们编写仅在需要时运行的代码。最后一种可能是我们的代码要求太高。这方面的一个例子可能是一个非常详细的模拟,其中大量代理正在使用复杂的 AI。如果我们已经用尽其他可能性并尽可能多地优化此代码,那么我们可能只需要重新设计我们的游戏以降低其要求:例如,伪造我们模拟的元素而不是计算它们。
提高我们代码的性能
一旦我们确定游戏中的性能问题是由我们的代码引起的,我们就必须仔细考虑如何解决这些问题。优化要求苛刻的功能似乎是一个不错的起点,但可能是所讨论的功能已经达到了最佳状态,并且本质上就是昂贵的。我们可以在数百个游戏对象使用的脚本中节省一点效率,而不是更改该功能,从而为我们提供更有用的性能提升。此外,提高我们代码的 CPU 性能可能会付出代价:更改可能会增加内存使用量或将工作卸载到 GPU。出于这些原因,本文不是一组可遵循的简单步骤。相反,本文是一系列改进代码性能的建议,以及可以应用这些建议的情况示例。与所有性能优化一样,没有硬性规定。要做的最重要的事情是分析我们的游戏,了解问题的本质,尝试不同的解决方案并衡量我们改变的结果。
编写高效的代码
编写高效的代码并明智地构建它可以提高我们游戏的性能。虽然显示的示例是在 Unity 游戏的上下文中,但这些一般最佳实践建议并不特定于 Unity 项目或 Unity API 调用。
尽可能将代码移出循环
循环是低效率发生的常见现象,尤其是当它们嵌套时。如果它们在一个运行非常频繁的循环中,效率低下确实会增加,特别是如果在我们游戏中的许多游戏对象上找到这段代码。在下面的简单示例中,我们的代码在每次调用Update()时迭代循环,无论是否满足条件。
1
2
3
4
5
6
7
8
9
10
void Update()
{
for(int i = 0; i < myArray.Length; i++)
{
if(exampleBool)
{
ExampleFunction(myArray[i]);
}
}
}
通过简单的更改,代码仅在满足条件时才遍历循环。
1
2
3
4
5
6
7
8
9
10
void Update()
{
if(exampleBool)
{
for(int i = 0; i < myArray.Length; i++)
{
ExampleFunction(myArray[i]);
}
}
}
这是一个简化的示例,但它说明了我们可以实现的真正节省。我们应该检查我们的代码,找出循环结构不当的地方。考虑代码是否必须每一帧都运行。Update()是 Unity 每帧运行一次的函数。Update()是放置需要经常调用的代码或必须响应频繁更改的代码的方便位置。但是,并非所有这些代码都需要运行每一帧。将代码从Update()中移出,使其仅在需要时运行,这是提高性能的好方法。
仅在事情发生变化时运行代码
让我们看一个非常简单的优化代码的例子,以便它只在事情发生变化时运行。在以下代码中,DisplayScore()在Update()中被调用。然而,分数的值可能不会随着每一帧而改变。这意味着我们不必要地调用DisplayScore() 。
1
2
3
4
5
6
7
8
private int score;
public void IncrementScore(int incrementBy)
{
score += incrementBy;
DisplayScore(score);
}
还是那句话,上面的例子有意简化,但原理很清楚。如果我们在整个代码中应用这种方法,我们就可以节省 CPU 资源。
每 [x] 帧运行一次代码
如果代码需要频繁运行并且不能被事件触发,那并不意味着它需要每一帧都运行。在这些情况下,我们可以选择每 [x] 帧运行一次代码。在此示例代码中,一个昂贵的函数每帧运行一次。
1
2
3
4
void Update()
{
ExampleExpensiveFunction();
}
事实上,每 3 帧运行一次这段代码就足以满足我们的需求。在下面的代码中,我们使用取模运算符来确保昂贵的函数仅在每三帧运行一次。
1
2
3
4
5
6
7
8
9
private int interval = 3;
void Update()
{
if(Time.frameCount % interval == 0)
{
ExampleExpensiveFunction();
}
}
这种技术的另一个好处是可以很容易地将昂贵的代码分散到不同的帧中,从而避免尖峰。在下面的示例中,每个函数每 3 帧调用一次,并且永远不会在同一帧上调用。
1
2
3
4
5
6
7
8
9
10
11
12
13
private int interval = 3;
void Update()
{
if(Time.frameCount % interval == 0)
{
ExampleExpensiveFunction();
}
else if(Time.frameCount % 1 == 1)
{
AnotherExampleExpensiveFunction();
}
}
使用缓存
如果我们的代码重复调用返回结果然后丢弃这些结果的昂贵函数,这可能是优化的机会。存储和重用对这些结果的引用可以更有效。这种技术称为缓存。在 Unity 中,通常调用GetComponent()来访问组件。在下面的示例中,我们在将渲染器组件传递给另一个函数之前调用Update()中的GetComponent()来访问它。此代码有效,但由于重复调用GetComponent()而效率低下。
1
2
3
4
5
6
void Update()
{
Renderer myRenderer = GetComponent<Renderer>();
ExampleFunction(myRenderer);
}
以下代码仅调用GetComponent()一次,因为函数的结果已缓存。缓存的结果可以在Update()中重复使用,而无需进一步调用GetComponent() 。
1
2
3
4
5
6
7
8
9
10
11
12
private Renderer myRenderer;
void Start()
{
myRenderer = GetComponent<Renderer>();
}
void Update()
{
ExampleFunction(myRenderer);
}
我们应该检查我们的代码,以了解我们频繁调用返回结果的函数的情况。我们可以通过使用缓存来降低这些调用的成本。
使用正确的数据结构
我们如何构造数据会对代码的执行方式产生重大影响。没有适合所有情况的单一数据结构,因此为了在我们的游戏中获得最佳性能,我们需要为每项工作使用正确的数据结构。为了正确决定使用哪种数据结构,我们需要了解不同数据结构的优点和缺点,并仔细考虑我们希望我们的代码做什么。我们可能有数千个元素需要每帧迭代一次,或者我们可能有少量元素需要经常添加和删除。这些不同的问题将通过不同的数据结构得到最好的解决。在这里做出正确的决定取决于我们对该主题的了解。如果这是一个新的知识领域,最好的起点是了解 大 O 符号。Big O Notation 是讨论算法复杂性的方式,理解这一点将有助于我们比较不同的数据结构。本文是该主题的清晰且适合初学者的指南。然后我们可以更多地了解我们可用的数据结构,并比较它们以找到针对不同问题的正确数据解决方案。此 MSDN 指南,介绍 C# 中的集合和数据结构给出了关于选择合适的数据结构的一般指导,并提供了指向更深入文档的链接。关于数据结构的单一选择不太可能对我们的游戏产生很大影响。然而,在涉及大量此类集合的数据驱动游戏中,这些选择的结果确实可以累加起来。了解算法的复杂性以及不同数据结构的优缺点将有助于我们创建性能良好的代码。
最小化垃圾收集的影响
垃圾收集是作为 Unity 管理内存的一部分发生的操作。我们的代码使用内存的方式决定了垃圾收集的频率和 CPU 成本,因此了解垃圾收集的工作原理很重要。在下一步中,我们将深入讨论垃圾收集的主题,并提供几种不同的最小化其影响的策略。
使用对象池
实例化和销毁对象通常比停用和重新激活对象的成本更高。如果对象包含启动代码,例如在Awake()或Start()函数中调用GetComponent(),则尤其如此。如果我们需要生成和处理同一对象的多个副本,例如射击游戏中的子弹,那么我们可能会受益于对象池 .对象池是一种技术,它不是创建和销毁对象的实例,而是暂时停用对象,然后根据需要回收和重新激活。尽管众所周知,对象池是一种管理内存使用的技术,但它也可以用作减少 CPU 过度使用的技术。对象池的完整指南不在本文讨论范围之内,但它确实是一种非常有用的技术,值得学习. Unity Learn 站点上有关对象池的教程是在 Unity 中实现对象池系统的重要指南。
避免昂贵的 Unity API 调用
有时我们的代码对其他函数或 API 的调用可能会出乎意料地昂贵。这可能有很多原因。看起来像变量的东西实际上可能是访问器。 包含额外代码、触发事件或从托管代码调用引擎代码。在本节中,我们将查看几个 Unity API 调用的示例,这些调用的成本比表面上看的要高。我们将考虑如何减少或避免这些成本。这些示例展示了成本的不同根本原因,建议的解决方案可以应用于其他类似情况。重要的是要了解没有我们应该避免的 Unity API 调用列表。每个 API 调用在某些情况下可能有用,而在其他情况下则不太有用。在所有情况下,我们都必须仔细分析我们的游戏,找出代码成本高昂的原因,并仔细考虑如何以最适合我们游戏的方式解决问题。
SendMessage()
SendMessage()和BroadcastMessage()是非常灵活的函数,几乎不需要了解项目的结构并且可以非常快速地实现。因此,这些函数对于原型设计或初级脚本编写非常有用。然而,它们使用起来非常昂贵。这是因为这些函数利用了反射。反射是指代码在运行时而不是编译时检查自身并做出决定的术语。使用反射的代码比不使用反射的代码对 CPU 的工作要多得多。建议SendMessage()和BroadcastMessage() 仅用于原型制作,并尽可能使用其他功能。例如,如果我们知道要在哪个组件上调用函数,我们应该直接引用该组件并以这种方式调用函数。如果我们不知道我们希望在哪个组件上调用函数,我们可以考虑使用Events或Delegates。
Find()
Find()和相关函数功能强大但价格昂贵。这些函数需要 Unity 遍历内存中的每个游戏对象和组件。这意味着它们在小型、简单的项目中的要求并不特别高,但随着项目复杂性的增加,使用起来会变得更加昂贵。最好不要经常使用Find()和类似函数,并尽可能缓存结果。一些可以帮助我们减少在代码中使用Find()的简单技术包括在可能的情况下使用检查器面板设置对对象的引用,或者创建管理对通常搜索的内容的引用的脚本。
Transform
设置变换的位置或旋转会导致内部OnTransformChanged事件传播到该变换的所有子级。这意味着设置变换的位置和旋转值相对昂贵,尤其是在有很多子变换的变换中。为了限制这些内部事件的数量,我们应该避免不必要地频繁设置这些属性的值。例如,我们可能会执行一个计算来设置变换的x位置,然后执行另一个计算来设置它在Update()中的z位置. 在此示例中,我们应该考虑将变换的位置复制到 Vector3,对该 Vector3 执行所需的计算,然后将变换的位置设置为该 Vector3 的值。这将导致只有一个OnTransformChanged事件。Transform.position是导致幕后计算的访问器示例。这可以与Transform.localPosition形成对比。localPosition的值存储在转换中,调用Transform.localPosition仅返回该值。但是,每次我们调用Transform.position时都会计算变换的世界位置。如果我们的代码频繁使用Transform.position我们可以使用Transform.localPosition代替它,这将导致更少的 CPU 指令,并可能最终提高性能。如果我们经常使用Transform.position ,我们应该尽可能缓存它。
Update()
Update() 、LateUpdate()和其他事件函数看起来像简单的函数,但它们有隐藏的开销。这些函数每次被调用时都需要在引擎代码和托管代码之间进行通信。除此之外,Unity 在调用这些函数之前会进行一些安全检查。安全检查确保 GameObject 处于有效状态,未被销毁等。这种开销对于任何单个调用来说都不是特别大,但它可以在具有数千个 MonoBehaviours 的游戏中加起来。因此,空Update() 调用可能特别浪费。我们可以假设,因为该函数是空的并且我们的代码不包含对它的直接调用,所以空函数将不会运行。事实并非如此:在幕后,即使Update()函数的主体为空,这些安全检查和本机调用仍然会发生。为了避免浪费 CPU 时间,我们应该确保我们的游戏不包含空的Update()调用。如果我们的游戏有大量带有Update()调用的活动 MonoBehaviour,我们可能会受益于以不同方式构建代码以减少这种开销。这篇关于该主题的 Unity 博客文章更详细地介绍了该主题。
Vector2 and Vector3
我们知道有些操作只会导致比其他操作更多的 CPU 指令。矢量数学运算就是这样的一个例子:它们只是比浮点数或整数数学运算更复杂。尽管两次此类计算所用时间的实际差异很小,但在足够大的规模下,此类操作会影响性能。使用 Unity 的 Vector2 和 Vector3 结构进行数学运算很常见且方便,尤其是在处理变换时。如果我们在代码中执行许多频繁的 Vector2 和 Vector3 数学运算,例如在Update() 的嵌套循环中 在很多游戏对象上,我们很可能会为 CPU 创建不必要的工作。在这些情况下,我们可以通过执行 int 或 float 计算来节省性能。在本文前面,我们了解到执行平方根计算所需的 CPU 指令比用于简单乘法的指令慢. Vector2.magnitude和Vector3.magnitude都是这样的例子,因为它们都涉及平方根计算。此外,Vector2.Distance和Vector3.Distance在幕后使用幅度。如果我们的游戏广泛且非常频繁地使用幅度或距离,我们可以通过使用Vector2.sqrMagnitude和Vector3.sqrMagnitude来避免相对昂贵的平方根计算。同样,替换单个调用只会产生微小的差异,但在足够大的范围内,可能会节省有用的性能。
Camera.main
Camera.main是一个方便的 Unity API 调用,它返回对第一个启用的标有“主相机”的相机组件的引用。这是另一个看起来像变量但实际上是附件的例子。在这种情况下,访问器在幕后调用类似于Find() 的因此, Camera.main遇到与Find():它搜索内存中的所有游戏对象和组件,使用起来可能非常昂贵。为了避免这种潜在的昂贵调用,我们应该缓存Camera.main或者完全避免使用它并手动管理对我们相机的引用。
其他 Unity API 调用和进一步优化
我们考虑了几个可能会产生意外成本的常见 Unity API 调用示例,并了解了这种成本背后的不同原因。然而,这绝不是提高 Unity API 调用效率的所有方法的详尽列表。这篇关于 Unity 性能的文章是 Unity 优化的广泛指南,其中包含许多我们可能会发现有用的其他 Unity API 优化。此外,该文章相当深入地介绍了超出这篇相对高级且对初学者友好的文章范围之外的进一步优化。
仅在需要运行时运行代码
编程中有一句话:“最快的代码是不会运行的代码”。通常,解决性能问题最有效的方法不是使用高级技术:它只是删除不需要的代码。让我们看几个例子,看看我们可以在哪里进行这种节省。
剔除
Unity 包含检查对象是否在相机视锥体内的代码。如果它们不在相机的视锥体内,则与渲染这些对象相关的代码不会运行。这个术语是截锥体剔除。我们可以对脚本中的代码采用类似的方法。如果我们有一个与对象的视觉状态相关的代码,当玩家看不到该对象时,我们可能不需要执行这段代码。在具有许多对象的复杂场景中,这可以显着节省性能。在下面的简化示例代码中,我们有一个巡逻敌人的示例。每次调用Update()时,控制该敌人的脚本都会调用两个示例函数:一个与移动敌人相关,一个与其视觉状态相关。
1
2
3
4
5
void Update()
{
UpdateTransformPosition();
UpdateAnimations();
}
在下面的代码中,我们现在检查敌人的渲染器是否在任何摄像机的视锥体内。与敌人视觉状态相关的代码仅在敌人可见时运行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private Renderer myRenderer;
void Start()
{
myRenderer = GetComponent<Renderer>();
}
void Update()
{
UpdateTransformPosition();
if (myRenderer.isVisible)
{
UpateAnimations();
}
}
当玩家看不到东西时禁用代码可以通过几种方式实现。如果我们知道场景中的某些对象在游戏的特定点不可见,我们可以手动禁用它们。当我们不太确定并需要计算可见性时,我们可以使用粗略的计算(例如,检查玩家身后的物体)、 OnBecameInvisible()和OnBecameVisible()等函数,或更详细的光线投射。最佳实施在很大程度上取决于我们的游戏,实验和分析是必不可少的。
详细程度
细节级别,也称为LOD ,是另一种常见的渲染优化技术。使用详细的网格和纹理以完全保真度渲染离玩家最近的对象。远处的物体使用不太详细的网格和纹理。我们的代码可以使用类似的方法。例如,我们可能有一个敌人,其 AI 脚本决定了它的行为。这种行为的一部分可能涉及昂贵的操作,以确定它可以看到和听到什么,以及它应该如何对此输入做出反应。我们可以使用一个详细级别系统来根据敌人与玩家的距离来启用和禁用这些昂贵的操作。在有许多这样的敌人的场景中,如果只有最近的敌人执行最昂贵的操作,我们可以节省相当多的性能。Unity 的CullingGroup API 允许我们连接到 Unity 的 LOD 系统以优化我们的代码。CullingGroup API 的手册页包含几个示例,说明如何在我们的游戏中使用它。一如既往,我们应该为我们的游戏进行测试、分析和找到正确的解决方案。我们已经了解了在我们的 Unity 游戏构建和运行时我们编写的代码会发生什么,为什么我们的代码会导致性能问题以及如何将影响降到最低我们游戏的昂贵。我们已经了解了代码中性能问题的一些常见原因,并考虑了一些不同的解决方案。使用这些知识和我们的分析工具,我们现在应该能够诊断、理解和修复与游戏代码相关的性能问题。
2.优化Unity游戏中的垃圾回收
当我们的游戏运行时,它使用内存来存储数据。当不再需要此数据时,存储该数据的内存将被释放,以便可以重复使用。垃圾是指已留出用于存储数据但不再使用的内存术语。垃圾收集是使该内存再次可用以供重用的进程的名称。 Unity 使用垃圾回收作为它管理内存的一部分。如果垃圾收集发生得太频繁或有太多工作要做,我们的游戏可能会表现不佳,这意味着垃圾收集是性能问题的常见原因。 在本文中,我们将了解垃圾收集的工作原理、垃圾收集发生的时间以及如何有效地使用内存,从而最大限度地减少垃圾收集对我们游戏的影响。
诊断垃圾收集问题
垃圾收集引起的性能问题可能表现为低帧率、不稳定的性能或间歇性冻结。但是,其他问题可能会导致类似的症状。如果我们的游戏有这样的性能问题,我们应该做的第一件事就是使用 Unity 的 Profiler 窗口来确定我们看到的问题是否实际上是由垃圾回收引起的。 要了解如何使用 Profiler 窗口查找性能问题的原因,请按照本教程进行操作。
Unity内存管理简介
要了解垃圾回收的工作原理以及何时发生,我们必须首先了解 Unity 中的内存使用情况。首先,我们必须了解 Unity 在运行自己的核心引擎代码和运行我们在脚本中编写的代码时使用不同的方法。 Unity 在运行自己的核心 Unity Engine 代码时管理内存的方式称为手动内存管理。这意味着核心引擎代码必须明确说明内存的使用方式。手动内存管理不使用垃圾收集,本文不会进一步介绍。 Unity 在运行我们的代码时管理内存的方式称为自动内存管理。这意味着我们的代码不需要明确地告诉 Unity 如何以详细的方式管理内存。Unity 为我们解决了这个问题。 在最基本的层面上,Unity 中的自动内存管理是这样工作的: Unity 可以访问两个内存池:堆栈和堆(也称为托管堆。堆栈用于短期存储小块数据,堆用于长期存储大块数据. 创建变量时,Unity 会从堆栈或堆中请求一块内存。 只要变量在范围内(我们的代码仍然可以访问),分配给它的内存就会一直使用。我们说这块内存已经分配好了。我们将保存在栈内存中的变量描述为栈上的对象,将保存在堆内存中的变量描述为堆上的对象。 当变量超出范围时,不再需要内存并且可以将其返回到它来自的池中。当内存返回到它的池中时,我们说内存已被释放。一旦它引用的变量超出范围,堆栈中的内存就会被释放。然而,堆中的内存此时并未释放,并保持已分配状态,即使它引用的变量超出范围也是如此。 垃圾收集器识别并释放未使用的堆内存。垃圾收集器定期运行以清理堆。 现在我们了解了事件的流程,让我们仔细看看如何进行与堆分配和解除分配不同的堆栈分配和解除分配。
在堆栈分配和释放期间会发生什么?
堆栈分配和解除分配既快速又简单。这是因为堆栈仅用于在短时间内存储小数据。分配和取消分配总是以可预测的顺序发生并且具有可预测的大小。 堆栈的工作方式类似于堆栈数据类型:它是元素的简单集合,在本例中为内存块,其中元素只能按照严格的顺序添加和删除。这种简单性和严格性使得它如此快速:当一个变量存储在堆栈中时,它的内存只是从堆栈的“末端”分配。当堆栈变量超出范围时,用于存储该变量的内存立即返回到堆栈以供重用。
堆分配期间会发生什么?
堆分配比堆栈分配复杂得多。这是因为堆可用于存储长期和短期数据,以及许多不同类型和大小的数据。分配和释放并不总是以可预测的顺序发生,并且可能需要非常不同大小的内存块。 创建堆变量时,将执行以下步骤: Unity 必须检查堆中是否有足够的空闲内存。如果堆中有足够的空闲内存,则为变量分配内存。 如果堆中没有足够的可用内存,Unity 会触发垃圾收集器以尝试释放未使用的堆内存。这可能是一个缓慢的操作。如果堆中现在有足够的空闲内存,则为变量分配内存。 如果垃圾回收后堆中没有足够的可用内存,Unity 会增加堆中的内存量。这可能是一个缓慢的操作。然后分配变量的内存。 堆分配可能很慢,尤其是在必须运行垃圾收集器并且必须扩展堆的情况下。
垃圾收集期间会发生什么?
当堆变量超出范围时,用于存储它的内存不会立即释放。未使用的堆内存仅在垃圾收集器运行时被释放。 每次垃圾收集器运行时,都会发生以下步骤:
- 垃圾收集器检查堆上的每个对象。
- 垃圾收集器搜索所有当前对象引用以确定堆上的对象是否仍在范围内。
- 任何不再在范围内的对象都被标记为删除。
- 标记的对象被删除,分配给它们的内存返回到堆中。 垃圾收集可能是一项昂贵的操作。堆上的对象越多,它必须做的工作就越多,我们代码中的对象引用越多,它必须做的工作就越多
垃圾收集可能是一项昂贵的操作。堆上的对象越多,它必须做的工作就越多,我们代码中的对象引用越多,它必须做的工作就越多。
什么时候进行垃圾回收?
- 三种情况会导致垃圾收集器运行:
- 每当请求堆分配而无法使用堆中的空闲内存来满足时,垃圾收集器就会运行。
- 垃圾收集器会不时自动运行(尽管频率因平台而异)。 可以强制垃圾收集器手动运行。
垃圾收集可能是一个频繁的操作。每当无法从可用堆内存中完成堆分配时,就会触发垃圾收集器,这意味着频繁的堆分配和释放会导致频繁的垃圾收集。
垃圾回收的问题
现在我们了解了垃圾收集在 Unity 中的内存管理中所扮演的角色,我们可以考虑可能出现的问题类型。
最明显的问题是垃圾收集器可能需要相当长的时间才能运行。如果垃圾收集器在堆上有很多对象和/或需要检查很多对象引用,那么检查所有这些对象的过程可能会很慢。这可能会导致我们的游戏卡顿或运行缓慢。
另一个问题是垃圾收集器可能会在不方便的时间运行。如果 CPU 已经在我们游戏的性能关键部分努力工作,即使垃圾收集产生的少量额外开销也会导致我们的帧速率下降和性能显着变化。 另一个不太明显的问题是堆碎片。当从堆中分配内存时,根据必须存储的数据大小,它会以不同大小的块的形式从空闲空间中获取。当这些内存块返回堆时,堆会被分成许多小的空闲块,这些块由分配的块分隔开。这意味着尽管可用内存总量可能很高,但我们无法在不运行垃圾收集器和/或扩展堆的情况下分配大内存块,因为现有块都不够大。
碎片堆有两个后果。第一个是我们游戏的内存使用量会比需要的高,第二个是垃圾收集器会更频繁地运行。有关堆碎片的更详细讨论,请参阅有关性能的 Unity 最佳实践指南。
查找堆分配
如果我们知道垃圾收集会导致我们的游戏出现问题,我们就需要知道代码的哪些部分正在产生垃圾。当堆变量超出范围时会产生垃圾,因此首先,我们需要知道是什么原因导致在堆上分配变量。
栈和堆上分配了什么?
在 Unity 中,值类型的局部变量分配在堆栈上,其他所有内容都分配在堆上。以下代码是堆栈分配的示例,因为变量localInt既是本地变量又是值类型变量。为该变量分配的内存将在该函数运行完成后立即从堆栈中释放。
1
2
3
4
void ExampleFunction()
{
int localInt = 5;
}
以下代码是堆分配的示例,因为变量 localList 是本地的但引用类型。为这个变量分配的内存将在垃圾收集器运行时被释放。
1
2
3
4
void ExampleFunction()
{
List localList = new List();
}
使用 Profiler 窗口查找堆分配
我们可以使用 Profiler 窗口查看我们的代码在哪里创建堆分配。您可以通过转到Window > Analysis > Profiler来访问该窗口(图 01 )。
选择 CPU 使用率分析器后,我们可以选择任何帧以在 Profiler 窗口底部查看有关该帧的 CPU 使用率数据。其中一列数据称为 GC 分配。此列显示在该帧中进行的堆分配。如果我们选择列标题,我们可以根据此统计数据对数据进行排序,从而轻松查看我们游戏中的哪些函数导致了最多的堆分配。一旦我们知道哪个函数导致堆分配,我们就可以检查该函数。 一旦我们知道函数中的哪些代码导致产生垃圾,我们就可以决定如何解决这个问题并最大限度地减少产生的垃圾量。
减少垃圾收集的影响
从广义上讲,我们可以通过三种方式减少垃圾回收对我们游戏的影响:
- 我们可以减少垃圾收集器运行的时间。
- 我们可以降低垃圾收集器运行的频率。
- 我们可以故意触发垃圾收集器,使其在对性能要求不高的时间运行,例如在加载屏幕期间
考虑到这一点,这里有三种策略可以帮助我们:
- 我们可以组织我们的游戏,这样我们就有更少的堆分配和更少的对象引用。堆上更少的对象和更少的检查引用意味着当垃圾收集被触发时,它需要更少的时间来运行。
- 我们可以减少堆分配和释放的频率,尤其是在性能关键时刻。更少的分配和释放意味着更少的触发垃圾收集的机会。这也降低了堆碎片的风险。
- 我们可以尝试对垃圾收集和堆扩展进行计时,以便它们在可预测和方便的时间发生。这是一种更困难且不太可靠的方法,但是当用作整体内存管理策略的一部分时可以减少垃圾收集的影响。
减少产生的垃圾量
让我们研究一些有助于我们减少代码生成的垃圾量的技术。
缓存
如果我们的代码重复调用导致堆分配的函数,然后丢弃结果,就会产生不必要的垃圾。相反,我们应该存储对这些对象的引用并重用它们。这种技术称为缓存。 在下面的示例中,代码每次调用时都会导致堆分配。这是因为创建了一个新数组。