游戏运行时,设备的中央处理器(CPU)会执行指令。我们游戏的每一帧都需要执行数以百万计的这些CPU指令。为了保持平稳的帧速率,CPU必须在设定的时间内执行其指令。当CPU无法及时执行完所有指令时,我们的游戏可能会变慢,卡顿或者卡死。
许多事情都会导致CPU要做太多的工作。例如,要求苛刻的渲染代码,过于复杂的物理模拟或过多的动画回调。本文仅关注以上原因中的一种:即,由我们在脚本中编写的代码引起的CPU性能问题。
由于对CPU的过多请求而导致的性能问题可能表现为帧速率低,性能不稳定或间歇性卡顿。但是,其他问题也可能导致类似的症状。如果我们的游戏遇到这样的性能问题,我们要做的第一件事就是使用Unity的Profiler窗口来确定我们的性能问题是否是由于CPU无法及时完成其任务所致。一旦确定了这一点,就必须确定用户脚本是否是问题的原因,还是问题是由游戏的其他部分(例如,复杂的物理原理或动画)引起的。
要了解如何使用Unity的Profiler窗口查找性能问题的原因,请阅读“诊断性能问题”教程。
要了解为什么我们的代码可能无法很好地执行,我们首先需要了解Unity构建游戏时发生了什么。了解幕后发生的事情将有助于我们就如何改善游戏性能做出明智的决定。
在构建游戏时,Unity将运行游戏所需的所有内容打包到可以由目标设备执行的程序中。CPU只能运行以非常简单的语言(称为机器代码 machine code 或本机代码 native code)编写的代码。他们无法运行以更复杂的语言(如C#)编写的代码。这意味着Unity必须将我们的代码翻译成其他语言。此翻译过程称为编译。
Unity首先将我们的脚本编译成一种称为通用中间语言(CIL)的语言。CIL是一种易于编译成各种不同的本机代码语言的语言。然后将CIL编译为适用于我们特定目标设备的本机代码。这第二步发生在我们构建游戏(称为提前编译或AOT编译)时,或者发生在目标设备本身上,即在代码运行之前(称为即时编译或JIT编译)。我们的游戏使用AOT还是JIT编译通常取决于目标硬件。
尚未编译的代码称为源代码。我们编写的源代码确定了编译后代码的结构和内容。
在大多数情况下,结构良好且高效的源代码将导致结构良好且高效的已编译代码。但是,了解一些本机代码对我们来说还是很有用的,这样我们可以更好地理解为什么某些源代码可以编译成更高效的本机代码。
首先,某些CPU指令要比其他指令花费更多时间。一个例子是计算平方根。例如,与将两个数字相乘相比,此计算花费的CPU时间更多。单条快速CPU指令和单条慢速CPU指令之间的差异对我们人类来说确实微乎其微,但是从根本上来说,对我们了解某些指令比其他指令更快是有用的。
我们需要了解的下一件事是,在源代码中看起来非常简单的某些操作在编译为代码时可能会异常复杂。这样的一个示例是将元素插入列表。例如,与通过索引从数组访问元素相比,执行此操作需要更多的指令。同样,当我们考虑一个单独的示例时,这个单独的示例尽管运行起来总体上多出不了多少时间,但重要的是要了解某些操作比其他操作导致更多的指令。
理解这些思想将有助于我们理解为什么某些代码的性能要优于其他代码,即使两个示例所做的事情都非常相似。甚至对事物在低水平如何运作的有限背景了解也可以帮助我们编写性能良好的游戏。
对于我们来说,了解用C#编写的脚本与构成Unity Engine大部分代码的运行方式稍有不同是很有用的。Unity Engine的大多数核心功能都是用C ++编写的,并且已经被编译为本地代码。这些已编译的引擎代码是安装Unity时所安装的内容的一部分。
编译到CIL的代码(例如我们的源代码)被称为托管代码。将托管代码编译为本机代码后,它将与称为托管运行时的内容集成在一起。托管运行时负责自动内存管理和安全检查等工作,以确保我们代码中的错误将导致异常而不是设备崩溃。
当CPU在正在运行的引擎代码和托管代码之间转换时,必须完成设置这些安全检查的工作。将数据从托管代码传递回引擎代码时,CPU可能需要做一些工作,以将数据从托管运行时使用的格式转换为引擎代码所需的格式。这种转换称为编组。同样,托管代码和引擎代码之间的任何单个调用所产生的开销也不是特别昂贵,但是重要的是,我们必须了解这一成本的存在。
现在我们了解了Unity在构建和运行游戏时代码发生了什么,我们可以理解,当我们的代码性能不佳时,是因为它在运行时给CPU带来了太多工作。让我们思考一下造成这种情况的不同原因。
第一种可能性是我们的代码只是单纯的浪费或结构不良。这样的一个例子可能是代码中,当一个函数只能进行一次调用时,它会重复进行同一函数的调用。本文将介绍一些结构较差的常见示例,并提供示例解决方案。
第二种可能性是我们的代码看起来结构合理,但是对其他代码的调用却不必要地昂贵。这样的示例可能是导致托管代码与引擎代码之间不必要调用的代码。本文将提供示例Unity API调用的示例,这些示例调用的开销可能出乎意料,并建议使用更有效的替代方法。
下一个可能性是我们的代码高效,但是在不需要时被调用。例如,可以模拟敌人视线的代码。代码本身可能表现良好,但是当玩家离敌人很远时,运行该代码是浪费的。本文包含一些技术示例,这些技术可以帮助我们编写仅在需要时运行的代码。
最终的可能性是我们的代码太过苛刻了。例如,非常详细的模拟,其中大量代理使用复杂的AI。如果我们已经穷尽了其他可能性并尽可能地优化了此代码,那么我们可能只需要重新设计游戏以使其要求不高:例如,伪装模拟元素而不是计算它们。实现这种优化超出了本文的讨论范围,因为它极大地依赖于游戏本身,但是阅读本文并考虑如何使我们的游戏尽可能发挥性能仍将使我们受益。
一旦确定游戏性能问题归因于我们的代码,就必须仔细考虑如何解决这些问题。优化要求苛刻的功能似乎是一个不错的起点,但可能是所讨论的功能已经达到了最佳状态,并且本质上很昂贵。除了可以修改函数之外,我们可以在数百个GameObjects使用的脚本中节省少量的效率,从而使性能提高得多。此外,提高代码的CPU性能可能是有代价的:更改可能会增加内存使用或将工作分担给GPU。
由于这些原因,本文不是要遵循的一组简单步骤。相反,本文提供了一系列改进代码性能的建议,并举例说明了可以应用这些建议的情况。与所有性能优化一样,没有硬性规定。最重要的事情是概要介绍我们的游戏,了解问题的性质,尝试使用不同的解决方案并衡量变更的结果。
编写高效的代码并明智地进行构造可以改善我们游戏的性能。虽然显示的示例是在Unity游戏的上下文中进行的,但这些常规最佳实践建议并非特定于某个Unity项目或Unity API调用。
循环是效率低下的常见地方,特别是在嵌套时。如果效率低下的情况经常发生在经常运行的循环中,尤其是在我们的游戏中许多GameObjects上都找到了此代码的情况下,效率低下的情况就会加起来。
在下面的简单示例中,无论是否满足条件,每次调用 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]帧运行一次代码。
在此示例代码中,昂贵的功能每帧运行一次。
void Update()
{
ExampleExpensiveFunction();
}
实际上,这足以满足我们每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使用率的技术。
关于对象池的完整指南不在本文讨论范围之内,但这是一种非常有用的技术,值得学习。This tutorial on object pooling on the Unity Learn site是在Unity中实现对象池系统的重要指南。
有时,我们的代码对其他函数或API的调用可能会出乎意料地昂贵。可能有很多原因。看起来像变量的东西实际上可以是访问器。)它包含附加代码,触发事件或从托管代码到引擎代码进行调用。
在本节中,我们将介绍一些Unity API调用的示例,这些示例的成本比可能要高得多。我们将考虑如何减少或避免这些成本。这些示例说明了造成成本的不同根本原因,并且建议的解决方案可以应用于其他类似情况。
重要的是要理解,没有应该避免的Unity API调用列表。每个API调用在某些情况下可能很有用,而在其他情况下则没有用。在所有情况下,我们都必须仔细分析我们的游戏,找出导致代码昂贵的原因,并仔细考虑如何以最适合我们游戏的方式解决问题。
SendMessage()
和 BroadcastMessage()
是非常灵活的函数,几乎不需要了解项目的结构,并且执行起来非常快。因此,这些功能对于原型设计或初学者级脚本编写非常有用。但是,它们使用起来非常昂贵。这是因为这些功能利用了反射。反射是指代码在运行时而不是在编译时检查并对其做出决定的术语。使用反射的代码比不使用反射的代码为CPU带来更多的工作。
建议仅将 SendMessage()
和 BroadcastMessage()
用于原型设计,并尽可能使用其他功能。例如,如果我们知道要在哪个组件上调用函数,则应直接引用该组件并以这种方式调用该函数。如果我们不知道要调用哪个组件,可以考虑使用事件或委托。
Find()
和相关功能强大但昂贵。这些功能需要Unity遍历内存中的每个GameObject和Component。这意味着在小型,简单项目中对它们的要求不是特别高,但随着项目复杂性的增加,使用起来会变得更加昂贵。
最好不经常使用 Find()
和类似函数,并尽可能地缓存结果。一些简单的技术可以帮助我们减少在代码中使用 Find()
的方法,包括在可能的情况下使用“检查器”面板设置对对象的引用,或创建用于管理对常用搜索对象的引用的脚本。
设置变换的位置或旋转会导致内部 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()
,LateUpdate()
和其他事件函数看起来像简单的函数,但是它们具有隐藏的开销。这些函数每次在引擎代码和托管代码之间都需要通信。除此之外,Unity在调用这些函数之前会执行许多安全检查。安全检查可确保GameObject处于有效状态,未被销毁等。对于任何一次调用,此开销并不是特别大,但是在具有成千上万个MonoBehaviours的游戏中,它可能会加起来。
因此,空的 Update()
调用可能特别浪费。我们可以假设由于该函数为空,并且我们的代码不包含对其的直接调用,因此该空函数将不会运行。事实并非如此:在幕后,即使 Update()
函数的主体为空,这些安全检查和本机调用仍然会发生。为避免浪费CPU时间,我们应确保我们的游戏不包含空的 Update()
调用。
如果我们的游戏中有大量活跃的MonoBehaviours在调用 Update()
,那么我们可能会从结构不同的代码中受益,以减少这种开销。有关此主题的Unity博客文章对此主题进行了更详细的介绍。
我们知道,某些操作只是比其他操作导致更多的CPU指令。向量数学运算就是一个例子:它们比浮点或整数运算复杂得多。尽管两次这样的计算所花费的时间实际差异很小,但是在足够的规模下,这样的操作会影响性能。
使用Unity的Vector2和Vector3结构进行数学运算是常见且方便的,尤其是在处理转换时。如果我们在代码中执行许多频繁的Vector2和Vector3数学运算,例如在许多GameObjects的 ``Update()` 中的嵌套循环中,则很可能会为CPU创建不必要的工作。在这些情况下,我们可以通过执行int或float运算来节省性能。
在本文的前面,我们了解到执行平方根计算所需的CPU指令要比用于简单乘法的指令慢。Vector2.magnitude和Vector3.magnitude都是示例,因为它们都涉及平方根计算。此外,Vector2.Distance和Vector3.Distance在幕后使用magnitude。
如果我们的游戏广泛且频繁地使用幅度或距离,则可能可以改为使用Vector2.sqrMagnitude和Vector3.sqrMagnitude来避免相对昂贵的平方根计算。同样,替换单个调用只会导致很小的差异,但是在足够大的规模下,可能可以节省有用的性能。
Camera.main是一个方便的Unity API调用,它返回对第一个启用的Camera组件的引用,该组件用“ Main Camera”标记。这是另一个看起来像变量但实际上是访问器的示例。在这种情况下,访问器在幕后调用类似于 Find()
的内部函数。因此,Camera.main与 Find()
存在相同的问题:它会搜索内存中的所有GameObjects和Components,并且使用起来非常昂贵。
为了避免这种潜在的昂贵调用,我们应该缓存Camera.main的结果,或者完全避免使用它,并手动管理对我们摄像机的引用。
我们考虑了一些常见的Unity API调用示例,这些调用可能会出乎意料地昂贵,并了解了造成此开销的各种原因。但是,这绝不是提高Unity API调用效率的详尽方法列表。This article on performance in Unity 这篇文章广泛介绍了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()
之类的函数,或更详细的光线投射。最佳实现很大程度上取决于我们的游戏,因此实验和性能分析至关重要。
细节级别(也称为LOD , Level of detail)是另一种常见的渲染优化技术。使用详细的网格和纹理以最逼真的方式渲染距离播放器最近的对象。远处的对象使用不太详细的网格和纹理。我们的代码可以使用类似的方法。例如,我们可能有一个敌人使用AI脚本来确定其行为。这种行为的一部分可能涉及昂贵的操作,以确定它可以看到和听到的内容,以及它应如何响应此输入。我们可以使用详细程度系统,根据敌人与玩家的距离来启用和禁用这些昂贵的操作。在具有许多此类敌人的场景中,如果仅最近的敌人执行最昂贵的操作,我们可以节省大量性能。
Unity的 CullingGroup API使我们能够加入Unity的LOD系统来优化代码。CullingGroup API的“手册”页面包含几个示例,说明了如何在我们的游戏中使用它。与以往一样,我们应该测试,描述并找到适合我们游戏的解决方案。
我们已经了解了在构建和运行Unity游戏时编写的代码将如何处理,为什么我们的代码会导致性能问题,以及如何最大程度地减少昂贵的游戏影响。我们已经在代码中了解了许多导致性能问题的常见原因,并考虑了几种不同的解决方案。利用这些知识和配置工具,我们现在应该能够诊断,理解和修复与游戏代码相关的性能问题。
当我们的游戏运行时,它使用内存来存储数据。当不再需要此数据时,将释放存储该数据的内存,以便可以重用它。垃圾是用于存储数据但不再使用的内存术语。垃圾回收是使该内存可再次使用的过程的名称。
Unity使用垃圾回收作为其管理内存的一部分。如果垃圾回收发生得太频繁或有太多工作要做,我们的游戏性能可能会很差,这意味着垃圾回收是性能问题的常见原因。
在本文中,我们将学习垃圾收集的工作原理,何时进行垃圾收集以及如何有效地使用内存,以最大程度地减少垃圾收集对游戏的影响。
由垃圾收集引起的性能问题可能表现为帧速率低,性能不稳定或间歇性冻结。但是,其他问题也可能导致类似的症状。如果我们的游戏遇到这样的性能问题,我们应该做的第一件事就是使用Unity的Profiler窗口来确定我们看到的问题是否实际上是由于垃圾收集引起的。
要了解如何使用Profiler窗口查找性能问题的原因,请参考本教程。
要了解垃圾收集的工作原理以及何时发生,我们必须首先了解Unity中的内存使用情况。首先,我们必须了解,在运行自己的核心引擎代码以及运行在脚本中编写的代码时,Unity使用不同的方法。
运行Unity自己的核心Unity Engine代码时的Unity管理内存的方式称为手动内存管理。这意味着核心引擎代码必须明确说明如何使用内存。手动内存管理不使用垃圾回收,因此本文将不对其进行进一步介绍。
运行我们的代码时的Unity管理内存的方式称为自动内存管理。这意味着我们的代码无需明确告诉Unity如何详细管理内存。Unity会为我们解决这个问题。
在最基本的级别上,Unity中的自动内存管理工作如下:
现在我们了解了事件的流程,下面让我们仔细看看栈分配和释放与堆分配和释放的区别。
栈分配和取消分配快速而简单。这是因为栈仅用于在短时间内存储少量数据。分配和解除分配始终以可预测的顺序发生,并且具有可预测的大小。
栈的工作方式类似于栈数据类型:它是元素的简单集合,在这种情况下是内存块,其中元素只能按照严格的顺序添加和删除。这种简单性和严格性使其变得如此之快:将变量存储在栈中时,只需从栈的“末端”分配其内存即可。当栈变量超出范围时,用于存储该变量的内存将立即返回到栈以供重用。
堆分配比栈分配要复杂得多。这是因为堆可用于存储长期和短期数据以及许多不同类型和大小的数据。分配和解除分配并非总是以可预测的顺序发生,并且可能需要大小不同的内存块。
创建堆变量后,将执行以下步骤:
堆分配可能很慢,尤其是在必须运行垃圾回收器并且必须扩展堆的情况下。
当堆变量超出范围时,用于存储该变量的内存不会立即释放。仅当垃圾回收器运行时,才会释放未使用的堆内存。
每次运行垃圾回收器时,都会发生以下步骤:
垃圾回收可能是一项昂贵的操作。堆上的对象越多,它必须执行的工作就越多,而在我们的代码中引用的对象越多,则它必须执行的工作就越多。
导致垃圾回收器运行的三件事:
垃圾回收可能是经常进行的操作。每当无法从可用堆内存中完成堆分配时,就会触发垃圾回收器,这意味着频繁的堆分配和释放可能导致频繁的垃圾回收。
既然我们了解了垃圾回收在Unity的内存管理中所扮演的角色,那么我们可以考虑可能发生的问题的类型。
最明显的问题是垃圾回收器可能需要花费大量时间才能运行。如果垃圾回收器在堆上有很多对象和/或要检查的对象引用很多,那么检查所有这些对象的过程可能会很慢。这可能会导致我们的游戏停顿或运行缓慢。
另一个问题是垃圾回收器可能会在不方便的时间运行。如果CPU在我们的游戏中对性能至关重要的部分中已经在努力工作,那么即使垃圾回收产生的少量额外开销也可能导致我们的帧速率下降并且性能发生明显变化。
另一个不太明显的问题是堆碎片。当从堆中分配内存时,它会从可用空间中分出不同大小的块,具体取决于必须存储的数据大小。当这些内存块返回到堆时,堆可以分成许多小的空闲块,这些空闲块由分配的块分隔。这意味着尽管可用内存总量可能很高,但由于没有一个现有的内存块足够大,因此我们无法在不运行垃圾回收器和/或扩展堆的情况下分配较大的内存块。
零散的堆有两个后果。首先是我们游戏的内存使用量将超过需要的内存使用量,其次是垃圾回收器将更频繁地运行。有关堆碎片化的更详细讨论,请参见有关性能的Unity最佳实践指南。
如果我们知道垃圾回收会导致游戏中出现问题,那么我们需要知道代码的哪些部分正在生成垃圾。当堆变量超出范围时会生成垃圾,因此首先我们需要知道是什么导致变量在堆上分配。
在Unity中,值类型的局部变量分配在栈上,其他所有内容分配在堆上。如果不确定在Unity中值和引用类型之间的区别,请参阅本教程。以下代码是栈分配的示例,因为变量localInt既是本地的,也是值类型的。该函数完成运行后,将立即从栈中释放为该变量分配的内存。
void ExampleFunction()
{
int localInt = 5;
}
以下代码是堆分配的示例,因为变量localList是本地的,但引用类型。当垃圾收集器运行时,为该变量分配的内存将被释放。
void ExampleFunction()
{
List localList = new List();
}
我们可以在Profiler窗口中看到代码在何处创建堆分配。
选择了CPU使用情况探查器后,我们可以选择任意框架以在Profiler窗口的底部查看有关该框架的CPU使用情况数据。数据列之一称为GC分配。此列显示在该帧中进行的堆分配。如果选择列标题,则可以根据此统计信息对数据进行排序,从而很容易查看游戏中哪些函数导致了最多的堆分配。
一旦知道哪个函数导致堆分配,就可以检查该函数。一旦知道函数中的哪些代码导致产生垃圾,就可以决定如何解决此问题并最大程度地减少垃圾产生量。
广义上讲,我们可以通过三种方式减少垃圾回收对游戏的影响:
考虑到这一点,以下三种策略将对我们有帮助:
让我们研究一些技术,这些技术可以帮助我们减少代码产生的垃圾数量。
如果我们的代码反复调用导致堆分配的函数,然后丢弃结果,则会创建不必要的垃圾。相反,我们应该存储对这些对象的引用并重用它们。这种技术称为缓存。在下面的示例中,代码在每次调用时都会导致堆分配。这是因为创建了一个新数组。
void OnTriggerEnter(Collider other)
{
Renderer[] allRenderers = FindObjectsOfType<Renderer>();
ExampleFunction(allRenderers);
}
以下代码仅导致一次堆分配,因为创建并填充了阵列一次然后将其缓存了。缓存的数组可以一次又一次地重用,而不会产生更多的垃圾。
private Renderer[] allRenderers;
void Start()
{
allRenderers = FindObjectsOfType<Renderer>();
}
void OnTriggerEnter(Collider other)
{
ExampleFunction(allRenderers);
}
如果必须在MonoBehaviour中分配堆内存,最糟糕的地方是经常运行的函数。例如,Update()
和 LateUpdate()
每帧调用一次,因此,如果我们的代码在此处生成垃圾,它将很快累加。我们应该考虑在可能的情况下在 Start()
或 Awake()
中缓存对对象的引用,或者确保导致分配的代码仅在需要时运行。
让我们来看一个非常简单的移动代码示例,该代码仅在情况发生变化时才运行。在下面的代码中,每次调用 Update()
时都会调用导致分配的函数,从而经常创建垃圾:
void Update()
{
ExampleGarbageGeneratingFunction(transform.position.x);
}
通过简单的更改,我们现在确保仅在transform.position.x的值更改时才调用分配函数。现在,我们仅在必要时进行堆分配,而不是在每个帧中进行分配。
private float previousTransformPositionX;
void Update()
{
float transformPositionX = transform.position.x;
if (transformPositionX != previousTransformPositionX)
{
ExampleGarbageGeneratingFunction(transformPositionX);
previousTransformPositionX = transformPositionX;
}
}
减少 Update()
中生成的垃圾的另一种技术是使用计时器。当我们有一些代码生成必须定期运行的垃圾(但不一定要在每个帧上运行)时,这非常适合。
在以下示例代码中,生成垃圾的函数每帧运行一次:
void Update()
{
ExampleGarbageGeneratingFunction();
}
在下面的代码中,我们使用计时器来确保生成垃圾的函数每秒运行一次。
private float timeSinceLastCalled;
private float delay = 1f;
void Update()
{
timeSinceLastCalled += Time.deltaTime;
if (timeSinceLastCalled > delay)
{
ExampleGarbageGeneratingFunction();
timeSinceLastCalled = 0f;
}
}
对频繁运行的代码进行这样的细微更改,可以大大减少产生的垃圾数量。
创建新集合会导致在堆上进行分配。如果发现在代码中多次创建新的集合,则应缓存对该集合的引用,并使用 Clear()
清空其内容,而不是重复调用new。在以下示例中,每次使用new时都会进行新的堆分配。
void Update()
{
List myList = new List();
PopulateList(myList);
}
在以下示例中,仅当创建集合或必须在后台调整集合的大小时才进行分配。这大大减少了产生的垃圾量。
private List myList = new List();
void Update()
{
myList.Clear();
PopulateList(myList);
}
即使我们减少脚本中的分配,但是如果在运行时创建和销毁许多对象,我们仍然可能会遇到垃圾回收问题。对象池是一种可以通过重用对象而不是重复创建和销毁对象来减少分配和释放的技术。对象池在游戏中被广泛使用,最适合于我们频繁生成和销毁相似对象的情况;例如,用枪射击子弹时。
关于对象池的完整指南不在本文讨论范围之内,但这是一种非常有用的技术,值得学习。 This tutorial on object pooling on the Unity Learn site 是在Unity中实现对象池系统的重要指南。
我们知道,值类型的局部变量分配在堆栈上,其他所有内容都分配在堆上。但是,在很多情况下,堆分配可能会让我们感到惊讶。让我们看一下不必要的堆分配的一些常见原因,并考虑如何最好地减少这些分配。
在C#中,字符串似乎是引用类型,而不是值类型,即使它们似乎保留字符串的“值”。这意味着创建和丢弃字符串会创建垃圾。由于许多代码中通常都使用字符串,因此这种垃圾确实会加起来。
C#中的字符串也是不可变的,这意味着它们的值在首次创建后就无法更改。每次我们操作一个字符串(例如,使用+运算符连接两个字符串)时,Unity都会使用更新后的值创建一个新字符串,并丢弃旧字符串。这会产生垃圾。
我们可以遵循一些简单的规则将字符串中的垃圾最小化。让我们考虑这些规则,然后看一个如何应用它们的示例。
我们来看一个示例代码,该示例通过无效使用字符串而产生不必要的垃圾。在下面的代码中,我们通过将字符串“ TIME:”与浮动计时器的值组合在一起,为 Update()
中的 string 显示创建了一个字符串,这会产生不必要的垃圾。
public Text timerText;
private float timer;
void Update()
{
timer += Time.deltaTime;
timerText.text = "TIME:" + timer.ToString();
}
在下面的示例中,我们已经进行了很多改进。我们将单词“ TIME:”放在单独的Text组件中,然后在 Start()
中设置其值。这意味着在 Update()
中,我们不再需要组合字符串。这大大减少了产生的垃圾量。
public Text timerHeaderText;
public Text timerValueText;
private float timer;
void Start()
{
timerHeaderText.text = "TIME:";
}
void Update()
{
timerValueText.text = timer.toString();
}
请务必注意,无论何时我们调用自己未编写的代码,无论是在Unity本身还是在插件中,都可能产生垃圾。一些Unity函数调用会创建堆分配,因此应谨慎使用,以避免产生不必要的垃圾。
没有应避免的功能列表。每个功能在某些情况下可能有用,而在其他情况下则没有那么有用。与以往一样,最好仔细分析我们的游戏,确定在何处创建垃圾并仔细考虑如何处理。在某些情况下,缓存函数的结果可能是明智的。在其他情况下,最好不频繁调用该函数;在其他情况下,最好将我们的代码重构为使用其他函数。话虽如此,我们来看几个导致堆分配的Unity函数的常见示例,并考虑如何最好地处理它们。
每次我们访问返回数组的Unity函数时,都会创建一个新数组并将其作为返回值传递给我们。这种现象并不总是很明显或无法预期,尤其是在该函数是访问器时(例如Mesh.normals)。在下面的代码中,为循环的每次迭代创建一个新的数组。
void ExampleFunction()
{
for (int i = 0; i < myMesh.normals.Length; i++)
{
Vector3 normal = myMesh.normals[i];
}
}
在这种情况下,减少分配很容易:我们可以简单地缓存对数组的引用。当我们这样做时,只会创建一个数组,并且相应地减少了创建的垃圾量。以下代码演示了这一点。在这种情况下,我们在循环运行之前调用Mesh.normals并缓存引用,以便仅创建一个数组。
void ExampleFunction()
{
Vector3[] meshNormals = myMesh.normals;
for (int i = 0; i < meshNormals.Length; i++)
{
Vector3 normal = meshNormals[i];
}
}
可以在函数GameObject.name或GameObject.tag中找到堆分配的另一个意外原因。这两个都是返回新字符串的访问器,这意味着调用这些函数将产生垃圾。缓存值可能有用,但是在这种情况下,我们可以使用一个相关的Unity函数。要针对某个值检查GameObject的标签而不会产生垃圾,我们可以使用 GameObject.CompareTag()
。在以下示例代码中,通过对GameObject.tag的调用创建了垃圾:
private string playerTag = "Player";
void OnTriggerEnter(Collider other)
{
bool isPlayer = other.gameObject.tag == playerTag;
}
如果我们使用 GameObject.CompareTag()
,则此函数不再生成任何垃圾:
private string playerTag = "Player";
void OnTriggerEnter(Collider other)
{
bool isPlayer = other.gameObject.CompareTag(playerTag);
}
GameObject.CompareTag 不是唯一的;许多Unity函数调用具有其他版本,这些版本不会导致堆分配。例如,我们可以使用 Input.GetTouch()
和 Input.touchCount
代替 Input.touches
,或者使用 Physics.SphereCastNonAlloc()
代替 Physics.SphereCastAll()
。
装箱是指使用值类型的变量代替引用类型的变量时发生的情况。当我们将值类型变量(例如ints或floats)传递给具有对象参数(例如Object.Equals())的函数时,通常会发生装箱。例如,函数String.Format()需要一个字符串和一个对象参数。当我们传递一个字符串和一个int时,必须将int装箱。因此,以下代码包含装箱示例:
void ExampleFunction()
{
int cost = 5;
string displayString = String.Format("Price: {0} gold", cost);
}
由于幕后发生的事情,装箱会产生垃圾。将值类型的变量装箱后,Unity在堆上创建一个临时System.Object来包装值类型的变量。System.Object是一个引用类型的变量,因此,当该临时对象被处置时,将创建垃圾。
装箱是不必要的堆分配的极其常见的原因。即使我们不在代码中直接装箱变量,也可能会使用导致装箱的插件,也可能发生在其他功能的幕后。最佳做法是尽可能避免装箱,并删除所有导致装箱的函数调用。
调用 StartCoroutine()
会产生少量垃圾,因为Unity必须创建用于管理协程的实例的类。考虑到这一点,当我们的游戏是交互式的并且性能是一个问题时,应该限制对 StartCoroutine()
的调用。为了减少以这种方式创建的垃圾,必须提前启动必须在性能关键时刻运行的协程,并且在使用可能包含对 StartCoroutine()
的延迟调用的嵌套协程时,我们应格外小心。
协程中的yield语句本身不会创建堆分配;但是,我们在yield语句中传递的值可能会创建不必要的堆分配。例如,以下代码创建垃圾:
yield return 0;
这段代码会产生垃圾,因为装箱的值为0的int。在这种情况下,如果我们希望仅等待一帧而不会引起任何堆分配,那么最好的方法是使用以下代码:
yield return null;
协程的另一个常见错误是在多次产生相同值时使用new。例如,以下代码将在每次循环迭代时创建然后处置WaitForSeconds对象:
while (!isComplete)
{
yield return new WaitForSeconds(1f);
}
如果我们缓存并重用WaitForSeconds对象,则将创建更少的垃圾。以下代码作为示例显示:
WaitForSeconds delay = new WaitForSeconds(1f);
while (!isComplete)
{
yield return delay;
}
如果我们的代码由于协程而产生大量垃圾,我们可能希望考虑重构我们的代码以使用协程以外的东西。重构代码是一个复杂的主题,每个项目都是唯一的,但是我们可能需要牢记一些协程的常见替代方法。例如,如果我们主要使用协程来管理时间,则我们可能希望在 Update()
函数中简单地跟踪时间。如果我们主要使用协程来控制游戏中事物发生的顺序,我们可能希望创建某种消息传递系统以允许对象进行通信。没有一种方法可以适合所有情况,但是要记住,在代码中通常有不止一种方法可以实现相同的目的。
在5.5之前的Unity版本中,每次循环结束时,遍历除数组之外的任何内容的foreach循环都会生成垃圾。这是由于幕后发生装箱造成的。循环开始时在堆上分配一个System.Object,循环结束时将其丢弃。此问题已在Unity 5.5中修复。例如,在5.5之前的Unity版本中,以下代码中的循环会生成垃圾:
void ExampleFunction(List listOfInts)
{
foreach (int currentInt in listOfInts)
{
DoSomething(currentInt);
}
}
如果我们无法升级我们的Unity版本,则有解决此问题的简单方法。for和while循环不会在后台造成装箱,因此不会产生任何垃圾。当遍历不是数组的集合时,我们应该赞成使用它们。以下代码中的循环不会生成垃圾:
void ExampleFunction(List listOfInts)
{
for (int i = 0; i < listOfInts.Count; i ++)
{
int currentInt = listOfInts[i];
DoSomething(currentInt);
}
}
无论是引用匿名方法还是命名方法,对函数的引用都是Unity中引用类型的变量。它们将导致堆分配。将匿名方法转换为闭包(其中匿名方法在创建时可以访问作用域中的变量)会大大增加内存使用量和堆分配数量。
函数引用和闭包如何分配内存的确切细节因平台和编译器设置而异,但是,如果要考虑垃圾回收,则最好在游戏过程中尽量减少使用函数引用和闭包。有关性能的Unity最佳实践指南详细介绍了该主题的技术细节。
LINQ和正则表达式都会由于在后台发生装箱而生成垃圾。最好的做法是在性能方面必须有所考虑时避免使用它们。同样,有关性能的Unity最佳实践指南详细介绍了该主题的技术细节。
我们的代码的结构方式可能会影响垃圾回收。即使我们的代码没有创建堆分配,它也可能会增加垃圾回收器的工作量。
我们的代码不必要地增加垃圾回收器工作量的一种情况是,要求代码检查不需要检查的内容。结构是值类型的变量,但是如果我们有一个包含引用类型的变量的结构,则垃圾回收器必须检查整个结构。如果我们有大量的这些结构,那么这会为垃圾回收器增加很多工作。
在此示例中,该结构包含一个字符串,该字符串是引用类型的。现在,垃圾回收器在运行时必须检查整个结构数组。
public struct ItemData
{
public string name;
public int cost;
public Vector3 position;
}
private ItemData[] itemData;
在此示例中,我们将数据存储在单独的数组中。当垃圾收集器运行时,它只需要检查字符串数组即可忽略其他数组。这减少了垃圾收集器必须执行的工作。
private string[] itemNames;
private int[] itemCosts;
private Vector3[] itemPositions;
我们的代码不必要地增加垃圾回收器工作量的另一种情况是拥有不必要的对象引用。当垃圾收集器搜索对堆上对象的引用时,它必须检查代码中的每个当前对象引用。即使我们不减少堆中的对象总数,代码中对象引用的减少也意味着要做的工作更少。
在此示例中,我们有一个填充对话框的类。用户查看对话框后,将显示另一个对话框。我们的代码包含对应该显示的DialogData的下一个实例的引用,这意味着垃圾回收器必须在操作过程中检查此引用:
public class DialogData
{
private DialogData nextDialog;
public DialogData GetNextDialog()
{
return nextDialog;
}
}
在这里,我们对代码进行了重组,以使其返回用于查找DialogData的下一个实例的标识符,而不是实例本身。这不是对象引用,因此不会增加垃圾收集器所花费的时间。
public class DialogData
{
private int nextDialogID;
public int GetNextDialogID()
{
return nextDialogID;
}
}
单独来看,这个例子很简单。但是,如果我们的游戏包含许多持有对其他对象的引用的对象,则可以通过以这种方式重组代码来显着降低堆的复杂性。
最后,我们不妨自己触发垃圾回收。如果我们知道堆内存已分配但不再使用(例如,如果我们的代码在加载资产时生成了垃圾),并且知道垃圾收集冻结不会影响播放器(例如,在加载屏幕时仍在显示),我们可以使用以下代码请求垃圾回收:
System.GC.Collect();
这将迫使垃圾收集器运行,并在方便的时候释放未使用的内存。
我们已经了解了垃圾回收在Unity中的工作原理,垃圾回收为什么会导致性能问题以及如何最大程度地减少垃圾回收对我们游戏的影响。利用这些知识和配置工具,我们可以解决与垃圾回收相关的性能问题,并构建游戏结构,以便它们有效地管理内存。
下面的链接提供了有关本文涵盖的主题的更多信息。
Unity中的内存管理和垃圾回收
Unity Manual: Understanding Optimization in Unity
Unity Manual: Understanding Automatic Memory Management
Gamasutra: C# Memory Management for Unity Developers by Wendelin Reich
Gamasutra: C# memory and performance tips for Unity by Robert Zubek
Gamasutra: Reducing memory allocations to avoid Garbage Collection on Unity by Grhyll JDD
Gamasutra: Unity Garbage Collection Tips and Tricks by Megan Hughes
Boxing
MSDN: Boxing and Unboxing (C# Programming Guide)
对象池
Unity Learn: Object Pooling Tutorial
Wikipedia: Object Pool Pattern
Strings
Best Practices for Using Strings in the .NET Framework
在本文中,我们将学习Unity渲染框架时幕后发生的情况,渲染时可能发生的性能问题以及如何解决与渲染相关的性能问题。
在阅读本文之前,至关重要的是要了解,没有一种适合所有大小的方法可以改善渲染性能。渲染性能受我们游戏中许多因素的影响,并且还高度依赖于我们游戏所运行的硬件和操作系统。要记住的最重要的事情是,我们通过研究,试验和严格分析试验结果来解决性能问题。
本文包含有关最常见的渲染性能问题的信息,以及有关如何解决这些问题的建议以及进一步阅读的链接。我们的游戏可能有一个问题-或问题的组合-此处未涵盖。但是,本文仍然可以帮助我们理解问题,并为我们提供有效搜索解决方案的知识和词汇。
在开始之前,让我们快速简单地了解一下Unity渲染框架时发生的情况。了解事件的流向和事物的正确术语将有助于我们理解,研究和努力解决性能问题。
注意:在本文中,我们将使用“对象”一词来表示可以在我们的游戏中渲染的对象。具有Renderer组件的任何GameObject将被称为对象。
在最基本的层次上,渲染可以描述如下:
现在,让我们仔细看看会发生什么。我们将在本文的后面部分更详细地介绍每个步骤,但是现在让我们熟悉所用的词语,并了解CPU和GPU在渲染中扮演的不同角色。
通常用于描述渲染的短语是渲染管道,可以尝试在脑海中想象这样一个形象,会对后面的理解非常有用。高效的渲染就是保持信息畅通无阻。
对于渲染的每一帧,CPU都会执行以下工作:
对于每一个包含绘图调用的批处理,CPU现在必须执行以下操作:
同时,GPU执行以下工作:
现在我们了解了Unity渲染框架时发生的情况,让我们考虑一下渲染时可能发生的问题。
关于渲染,最重要的要了解的是:CPU和GPU都必须完成其所有任务才能渲染帧。如果这些任务中的任何一项花费太长时间才能完成,则将导致帧渲染延迟。
渲染问题有两个根本原因。第一类问题是由于管道效率低下引起的。当渲染管线中的一个或多个步骤花费太长时间来完成,从而中断了数据的平稳流动时,就会发生效率低下的管线。管道内的低效率被称为瓶颈。第二种问题是由于简单地试图通过管道推送过多数据而引起的。即使是最有效的管道,也限制了在一个帧中可以处理多少数据。
当我们的游戏由于执行CPU的渲染所需的时间太长而无法渲染帧时,我们的游戏就是所谓的CPU绑定。当我们的游戏由于执行GPU渲染任务花费的时间太长而无法渲染帧时,我们的游戏就是所谓的GPU绑定。
在进行任何更改之前,使用性能分析工具了解性能问题的原因至关重要。不同的问题需要不同的解决方案。衡量所做的每项变更的效果也很重要;解决性能问题是一种平衡,而提高性能的一个方面可能会对另一方面产生负面影响。
我们将使用两种工具来帮助我们理解和解决渲染性能问题:Profiler窗口和Frame Debugger。这两个工具都内置在Unity中。
Profiler窗口使我们可以查看有关游戏性能的实时数据。我们可以使用Profiler窗口查看有关游戏许多方面的数据,包括内存使用情况,渲染管道和用户脚本的性能。
如果您还不熟悉使用Profiler窗口,则Unity手册的此页是不错的介绍,本教程将详细介绍如何使用它。
帧调试器使我们可以逐步查看帧的渲染方式。使用帧调试器,我们可以查看详细信息,例如在每个绘制调用期间绘制的内容,每个绘制调用的着色器属性以及发送到GPU的事件的顺序。这些信息有助于我们了解游戏的呈现方式以及可以在哪里提高性能。
如果您还不熟悉使用帧调试器,那么Unity手册的这一页是非常有用的指南,它可以指导您进行操作,并且本教程视频将介绍如何使用它。
在尝试改善游戏的渲染性能之前,必须确保由于渲染问题我们的游戏运行缓慢。如果导致问题的真正原因是过于复杂的用户脚本,则没有必要尝试优化渲染性能!如果不确定性能问题是否与渲染有关,则应遵循本教程。
一旦确定问题与渲染有关,就必须了解我们的游戏是受CPU限制还是受GPU限制。这些不同的问题需要不同的解决方案,因此至关重要的是,在尝试解决问题之前,我们必须先了解问题的原因。如果您不确定自己的游戏是CPU绑定还是GPU绑定,则应遵循本教程。如果我们确定我们的问题与渲染有关,
并且知道我们的游戏是受CPU限制还是受GPU限制,那么我们就可以继续阅读。
概括地说,为了渲染帧必须由CPU执行的工作分为三类:
这些广泛的类别包含许多单独的任务,并且这些任务可以跨多个线程执行。线程允许单独的任务同时发生。一个线程执行一项任务时,另一线程可以执行完全独立的任务。这意味着可以更快地完成工作。将渲染任务分配到单独的线程中时,这称为多线程渲染。
Unity的渲染过程涉及三种线程:主线程,渲染线程和工作线程。主线程是我们游戏中大多数CPU任务的发生地,其中包括一些渲染任务。渲染线程是将命令发送到GPU的专用线程。每个工作线程执行一个任务,例如剔除或网格蒙皮。哪个线程执行哪些任务取决于我们游戏的设置和游戏运行的硬件。例如,目标硬件拥有的CPU核心越多,可以产生更多的工作线程。因此,在目标硬件上配置我们的游戏非常重要。我们的游戏在不同设备上的表现可能会大不相同。
由于多线程渲染是复杂的且依赖于硬件,因此在尝试提高性能之前,我们必须了解哪些任务导致游戏受到CPU限制。如果我们的游戏运行缓慢是因为在一个线程上执行清除操作花费的时间太长,那么这将无法帮助我们减少在另一个线程上向GPU发送命令所花费的时间。
注意:并非所有平台都支持多线程渲染。在撰写本文时,WebGL不支持此功能。在不支持多线程渲染的平台上,所有CPU任务都在同一线程上执行。如果我们在这样的平台上受CPU约束,那么优化任何CPU工作都会提高CPU性能。如果我们的游戏是这种情况,我们应该阅读以下所有部分,并考虑哪些最适合我们的游戏的优化。
“Player Settings”中的“Graphics jobs”选项确定Unity是否使用工作线程来执行渲染任务,否则这些渲染任务将在主线程上完成,在某些情况下,将在渲染线程上完成。在具有此功能的平台上,它可以显着提高性能。如果我们希望使用此功能,则应该在启用和不启用“图形”作业的情况下对游戏进行概要分析,并观察其对性能的影响。
我们可以使用Profiler窗口来确定哪些任务导致我们的游戏受到CPU限制。本教程显示了如何确定问题所在。
现在我们了解了导致我们的游戏受CPU限制的任务,让我们看一些常见的问题及其解决方案。
将命令发送到GPU所花费的时间是游戏受CPU限制的最常见原因。尽管在某些平台(例如,PlayStation 4)上,此任务可以在大多数平台上的渲染线程上执行,但可以由工作线程执行。
向GPU发送命令时发生的最昂贵的操作是SetPass调用。如果我们的游戏由于向GPU发送命令而受到CPU限制,则减少SetPass调用次数可能是提高性能的最佳方法。
我们可以在Unity的Profiler窗口的Rendering Profiler中查看发送了多少SetPass调用和批处理。在性能下降之前可以发送的SetPass调用的数量在很大程度上取决于目标硬件。与移动设备相比,高端PC可以在性能下降之前发送更多的SetPass呼叫。
SetPass调用的数量及其与批处理数量的关系取决于几个因素,我们将在本文后面详细介绍这些主题。但是,通常情况是:
如果减少批次数量并不能减少SetPass调用的数量,则仍然可以单独改善性能。这是因为即使包含相同数量的网格数据,CPU也可以比几个批次更有效地处理一个批次。广泛地讲,有三种减少批处理和SetPass调用数量的方法。我们将更深入地探讨以下每个方面:
不同的技术将适用于不同的游戏,因此我们应该考虑所有这些选项,确定哪些选项可以在我们的游戏中进行实验。
减少必须呈现的对象数是减少批处理和SetPass调用数的最简单方法。我们可以使用几种技术来减少渲染对象的数量。
实时照明,阴影和反射为游戏增添了很多真实感,但代价可能非常昂贵。使用这些功能可能导致对象被多次渲染,从而极大地影响性能。
这些功能的确切影响取决于我们为游戏选择的渲染路径。渲染路径是绘制场景时执行计算顺序的术语,渲染路径之间的主要区别在于它们处理实时光,阴影和反射的方式。通常,如果我们的游戏在高端硬件上运行并使用大量实时光,阴影和反射,则延迟渲染可能是一个更好的选择。如果我们的游戏在低端硬件上运行并且不使用这些功能,则正向渲染可能更适合。但是,这是一个非常复杂的问题,如果我们希望利用实时的灯光,阴影和反射,最好研究主题和实验。Unity手册的此页面提供了有关Unity中可用的不同渲染路径的更多信息,并且是有用的起点。本教程包含有关Unity中照明主题的有用信息。
无论选择哪种渲染路径,实时灯光,阴影和反射的使用都会影响我们的游戏性能,因此了解如何对其进行优化非常重要。
当满足某些条件时,批处理可以包含多个对象的数据。为了有资格进行批处理,对象必须:
批处理合格的对象可以提高性能,尽管与所有优化技术一样,我们必须仔细分析以确保批处理成本不超过性能提升。
批处理合格对象有几种不同的技术:
剔除,收集将要绘制的对象上的数据,将这些数据分为几批并生成GPU命令都可以促进CPU绑定。这些任务将在主线程或单个工作线程上执行,具体取决于我们游戏的设置和目标硬件。
当我们通过使用称为骨骼动画的技术使网格变形来对网格进行动画处理时,将使用SkinnedMeshRenderers。最常用于动画角色。与渲染蒙皮网格物体渲染相关的任务通常会在主线程或单个工作线程上执行,具体取决于我们游戏的设置和目标硬件。
渲染蒙皮的网格可能是一项昂贵的操作。如果我们在Profiler窗口中看到渲染蒙皮的网格正在使我们的游戏受到CPU限制,那么我们可以尝试一些改善性能的方法:
请务必了解,许多与渲染无关的CPU任务都是在主线程上执行的。这意味着,如果我们在主线程上绑定了CPU,则可以通过减少花在与渲染无关的任务上的CPU时间来提高性能。
例如,我们的游戏可能在游戏的某个时刻在主线程上执行了昂贵的渲染操作和昂贵的用户脚本操作,这使我们受到了CPU的限制。如果我们在不损失视觉保真度的情况下尽可能地优化了渲染操作,则有可能我们可以减少自己脚本的CPU成本来提高性能。
如果我们的游戏绑定GPU,那么要做的第一件事就是找出导致GPU瓶颈的原因。GPU的性能通常受填充率的限制,尤其是在移动设备上,但内存带宽和顶点处理也是要考虑的问题。让我们检查所有这些问题,并了解导致问题的原因,诊断方法和解决方法。
填充率是指GPU每秒可渲染到屏幕的像素数。如果我们的游戏受到填充率的限制,这意味着我们的游戏试图在每帧上绘制比GPU可以处理的像素更多的像素。
检查填充率是否导致我们的游戏受GPU限制很简单:
如果填充率是导致问题的原因,则有几种方法可以帮助我们解决问题。
内存带宽是指GPU可以读写其专用内存的速率。如果我们的游戏受到内存带宽的限制,这通常意味着我们使用的纹理太大,GPU无法快速处理。
要检查内存带宽是否有问题,我们可以执行以下操作:
如果内存带宽是我们的问题,则需要减少游戏中纹理内存的使用。同样,最适合每种游戏的技术会有所不同,但是有几种方法可以优化纹理。
顶点处理是指GPU必须执行的工作才能渲染网格中的每个顶点。顶点处理的成本受到两方面的影响:必须渲染的顶点数和必须在每个顶点上执行的操作数。
如果我们的游戏受GPU限制,并且我们确定游戏不受填充率或内存带宽的限制,则很可能是顶点处理引起了问题。在这种情况下,尝试减少GPU必须执行的顶点处理量可能会提高性能。
我们可以考虑采用几种方法来减少顶点数量或在每个顶点上执行的操作数量。
我们已经了解了渲染在Unity中的工作原理,渲染时会发生什么问题以及如何提高游戏的渲染性能。利用这些知识和配置工具,我们可以解决与渲染和结构化游戏有关的性能问题,从而使游戏拥有流畅而有效的渲染流程。
下面的链接提供了有关本文涵盖的主题的更多信息。
Unity Learn: Optimizing Unity UI
Unity Knowledge Base: Why is my static batching breaking or otherwise not working as expected?
Fabian Giesen: A trip through the graphics pipeline
Simon Schreibt: Render hell
Gamasutra: How to choose between Forward or Deferred rendering paths in Unity
Gamasutra: Batching independently moving GameObjects into a single mesh to reduce draw calls
FlameBait Games: Optimizing SkinnedMeshRenderers for Unity 5
Pencil Square Games: Reducing draw calls (also named SetPass calls) in Unity 5