介绍
当我们游戏运行的死后,我们设备的[中央处理单元(cpu)]在执行指令。我们游戏的每一帧都需要执行数以百万的CPU指令。为了保持一个平滑的帧率(frame rate),CPU必须在一个设定的时间内执行指令。当CPU不能及时处理完所有指令,我们的游戏可能会变慢、卡顿或卡死(freeze)。
许多时间可以造成CPU有太多的工作要做。比如,可能包括高要求的渲染代码,过度复杂的物理模拟或太多的动画回调。这篇文章只关注其中一个原因:我们编写的脚本代码造成的CPU性能问题。
在这篇文章,我们将会学到我们的脚本是如何转换成CPU指令的和什么原因造成了脚本让CPU执行更多的工作以及如何修复脚本造成的性能问题。
分析代码问题
对CPU的过度需求导致的性能问题可以表现为帧率低、卡顿问题、卡死。其他问题也会造成类似的症状。如果我们游戏有类似的性能问题,首先我们必须要做的是确定我们的游戏性能问题是否CPU无法及时完成任务造成的。一旦我们确定了是CPU问题,哦我们必须确定是否是用户脚本造成的还是游戏其他部分造成的,如复杂的物理或动画。
学习使用Unity的Profiler工具查找性能问题,请看这篇文章
Unity构建和运行游戏的简介
为了理解我们的代码为什么可能不会很好的运行,我们首先要了解Unity构建我们的游戏的时候发生了什么。明白屏幕背后发生了什么,能帮我们在改善游戏性能的时候做出明智的决定。
构造过程(The build process)
当我们构造游戏的时候,Unity将来运行时需要的所有东西打包进一个可以运行在目标设备的程序上。CPU只能运行被称为最简单语言机器码machine code编写的代码或本机代码(native code)。它们不能运行更复杂语言编写的代码,如C#。这意味着Unity必须把我们的代码转换成另一种语言。这个转换的过程叫做编译(compiling)。
Unity首先把我们的脚本编译成一种叫做Common Intermediate Language (CIL)(通用中间层语言)。CIL是一种易于编译成各种不同的本机代码的语言。然后CIL为我们指定的目标设备编译成本机语言。第二步,不是发生在我们构造游戏的时候(被称为提前编译(a head of time compilation or AOT compilation))就是发生在代码运行之前(被称为及时编译(just in time compilation or JIT compilation))的目标设备上。我们游戏使用AOT或JIT编译通常依赖于目标硬件。
我们编写的代码和编译的代码之间的关系
还没有被编译的代码被成为源代码。我们编写的代码决定了编译代码的结构和内容。
多数情况下,结构良好、效率高的源码将导致结构良好、效率高的编译代码。了解一点本地代码,有助于我们更好的理解为什么一些源代码被编译成更搞笑的本机代码。
首先,一些CPU指令比其他指令花费更多的执行时间。比如计算平方根。这个计算比两个数相乘需要更多的时间。单个快的CPU指令和单个慢的CPU指令之间的差别真的是非常小的,但是这有助于我们从根本上明白,一些指令比其他指令快得多。
接着,我们需要明白的是,源码中一些看起来非常简单的操作当编译成代码时会出人意料地复杂。比如,插入一个元素到list列表中。执行此操作需要更多的指令,比如通过下标访问一个数组元素。同样,我们考虑一个单独的关于少量时间的例子,但重要的是要理解,有些操作比其他操作导致更多的指令。
理解这些思想有助于我们理解为什么某些代码比其他代码执行得更好,即使两个示例都做了类似的事情。了解一些底层的工作,将有助于我们写出执行得比较好的游戏。
Unity引擎代码与我们脚本代码的运行时通信(Run time communication)
我们编写的C#脚本与构成Unity引擎的代码运行方式有一些差别的,理解这一点很重要。大部分Unity引擎的核心功能都是用C++编写的,并且已经编译成了本机代码。这些编译的代码就是我们安装Unity时安装的一部分内容。
代码编程成CIL,比如我们的源代码,被成为托管代码(managed code)。当托管代码被编译成本机代码的时,会把叫做托管运行时(managed runtime)的东西集成在一起。这个托管运行时只关心比如自动内存管管理、安全检测之类的事情,以确保代码中的错误会导致异常而不是设备闪退。
当CPU在运行时引擎代码和托管代码之间的转换时,必须做设置安全检测之类的工作。当从托管代码传递数据给引擎代码的时候,CPU可能需要把托管运行时格式的数据转换成引擎代码所需的数据格式。这个转换的过程叫做marshalling。同样,托管代码和引擎代码这两个任何一个单独调用的开销都不是特别大,但重要的是我们理解这种成本存在。
代码执行不佳的原因
现在我们知道了当Unity构建和运行我们游戏的时候发生了什么,我们也可以理解当我们的代码执行不佳时,可能是因为在运行时为CPU创建了太多的工作。我们考虑一下这个问题的不同原因。
第一种可能性是我们的代码仅仅是浪费或结构不好。比如,一个函数在调用一次的时候重复调用了多次。这篇文章介绍了几个常见的结构不好的例子及解决方案。
第二种可能性是我们的代码结构良好,但是调用了其他耗时的不必要的函数。比如,这样的代码可能导致托管代码和引擎代码之间的不必要的函数调用。本文将给出Unity API调用的例子,这些调用可能会出乎意料的昂贵,建议的替代方案更高效。
最后一种可能性是我们的代码要求太高了。比如一个非常详细模拟,其中大量的代理(agent)使用了复杂的AI。如果我们排除了其他的可能性,并尽可能的优化这段代码,我们可能需要重新设计我们的游戏以减少代苛刻的代码要求,比如,伪造我们模拟的元素而不是计算它们。实现这种优化超出了本文的范围,因为它非常依赖游戏本身,但是阅读这篇文章和思考如何尽可能的优化我们游戏性能是有益的。
改善我们代码的性能
一旦我们确定了我们游戏的性能问题是由我们的代码造成,我们必须仔细思考如何解决这些问题。优化苛刻的函数看起来是一个好的开始,但这个被讨论的函数可能已经尽可能的优化了,而且本质上的确是昂贵的。我们可以在数百个游戏对象使用的脚本中进行一个小的效率节省,而不是改变那个函数,这会给我们带来更有用的性能提升。此外,改善我们代码的CPU性能可能会付出代价:更改可能会增加内存使用或把工作卸载到GPU上。
由于这些原因,本文不是一系列简单的步骤。本文反而是一系列关于改善我们游戏代码性能的建议,举例说明这些建议可以应用在那些地方。跟其他所有性能优化一样,没有一层不变的规则。最重要的是分析我们的游戏,理解问题的本质,尝试不通过的解决方案并测量我们变化的结果。
编写高效代码
编写高效代码和巧妙地架构代码可以提升游戏性能。虽然显示的是Unity游戏实例,但是这些最好的实践建议不仅仅针对特定的Unity项目或Unity API调用。
尽可能把代码从循环中移出来(Move code out of loops when possible)
循环是常见的效率低的地方,尤其是有嵌套的时候。如果是在一个非常频繁的循环中,那会更加低效,尤其是我们游戏中很多游戏物体都运行这个脚本的时候。
下面的简单例子中,我们的代码每次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()中移出来,只有当需要的时候才执行,这是一个提高性能的好方法。
Only run code when things change
让我们看一个非常简单的优化代码,只有变化的时候才运行。在下面的代码中DislayScore()在Update()调用。然而score的值并不是每帧都变化。这意味着我们不需要调用 DisplayScore()。
private int socre;
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资源。
Run code every [x] frames
如果代码需要频繁运行并且不能被事件触发,也不意味着要每帧执行。这种情况下,我们可以选择每间隔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();
}
}
使用缓存
如果我们的代码频繁调用那些有返回值的耗性能的函数,并且返回值用完就扔掉的,这可能是一个优化的机会。存储和重用这些结果的引用可能会更高效。这个技术叫做缓存(caching)。
在Unity,GetComponent()是一个常见的访问组件的函数。在下面的例子中,我们在Update()中调用GetComponent()访问渲染组件(Renderer component)并传递给其他函数。这段代码管用,但是重复调用GetComponent()导致低效。
void Update()
{
Renderer myRenderer = GetComponent<Renderer>();
ExampleFunction(myRenderer);
}
下面的代码中,GetComponent()只调用一次,因为这个函数的结果被缓存了。这个缓存的结果在Update()重用,而不需要再次调用GetComponent()。
private Renderer myRenderer;
void Start()
{
myRenderer = GetComponent<Renderer>();
}
void Update()
{
ExampleFunction(myRenderer);
}
我们应该检查我们代码中频繁调用并返回结果的函数。使用缓存是可能减少这些调用的消耗的。
使用正确的数据结构(Use the right data structure)
我们如何构造我们的数据对代码性能有很大的影响。没有一个数据结构可以适用于所有情况,在我们游戏中,要获得最佳的性能,我们需要在每个地方使用正确的数据结构。
为了正确地决定使用哪种数据结构,我们需要了解不同的数据结构的优点和缺点,并仔细思考我们代码要怎么写。我们可能每帧要迭代上千个元素,或我们需要在一个小数组中频繁操作添加、删除操作。这些不同的问题使用不同的数据结构可以得到最好的解决。
做出正确的决定取决于我们对问题的认识。如果说这是一个新的知识领域,那么最好的开始就是学习大O标记法(Big O Notation)。大O标记法是怎样论述算法复杂度的,明白这一点将有助于我们比较不同的数据结构。这篇文章是一个明确的对初学者友好的学习指南。我们可以学习到更多可用的数据结构,对比它们,并且为不同的问题选择合适的数据结构。这个MSDN指南提供了可选择的数据结构和提供了更深入的文档。
单个数据结构的选择不太可能对游戏有太大的影响。然而,在一个数据驱动的游戏中包含了这样的集合,每个这样的数据结构的选择对游戏性能有叠加的影响。理解算法的复杂度和不同的数据结构的优势和缺点对我们编写高效代码有好处。
尽可能减少垃圾回收(garbage collection)的影响
垃圾回收是Unity管理内存执行的一部分操作。我们代码使用的内存方式决定了GC的频率和GC时CPU的消耗,所以理解GC是如何工作的很重要。
这篇文章包含了垃圾回收深度主题,并提供了几种不同的策略以减少GC的影响。
使用对象池(Use object pooling)
通常情况下,实例化 (instantiate)和销毁(destory)一个对象比隐藏(deactivate)和激活(reactivate)一个对象的消耗得更多。尤其是当对象有启动代码的时候,如在Awake()或Start()调用GetComponent()函数。如果我们需要创建或销毁许多同一对象的拷贝时,比如射击游戏中的子弹,使用对象池会有很大的好处。
对象池是一种取代·创建和销毁实例化的物体·的技术,对象被临时隐藏(deactivated)和回收,当需要用到的时候重新激活(reactivated)。对象池虽然被认为是一种管理内存的技术,但同样是减少CPU过度使用的有用技术。
对象池的完整指南超出了本文的范围,但是这真的是一个非常有用的技术,值得学习。这篇文章是Unity实现对象池的一个很好的指南。
避免过度调用Unity API(Avoiding expensive calls to the Unity API)
有时我们的代码会使其他函数或API消耗特别大。这可能有很多原因。看起来像变量,实际上可能是一个访问器(accessor),它包含了附加代码、触发事件或从托管代码到引擎代码之间的调用。
在本节中,我们将会看到Unity API调用的几个示例,它们的消耗比它们看起来的要多。我们要思考如何减少或避免这些消耗。这些例子阐述了不同的根本原因造成的消耗,建议的解决方案适用于其他类似的情况。
这里并没有我们应该避免的Unity API列表,明白这一点很重要。每个API可能在一些场合很有用,在别的场合就没什么用。我们必须仔细分析我们的游戏,确定造成代码消耗的原因,并仔细思考如何解决这个问题对我们游戏才是最好的。
SendMessage()
SendMessage()和BroadcastMessage()是非常灵活的函数,不需要知道项目是如何架构的很简单就可以使用。这些函数对于原型开发(prototyping)和初学者脚本是非常有用的。然而,这些函数是非常消耗的。因为这些函数使用了反射(reflection)。反射是代码在运行时而不是编译时检查自身类型的一种术语。使用反射的代码比不使用反射的代码对CPU的工作量要大得多。
建议SendMessage()和BroadcstMessage()只在原型开发(prototyping)的时候使用,并在任何可能的地方使用其他函数。比如,我们知道要调用哪个组件的函数,我们应该直接引用组件并调用函数。如果我们不知道调用哪个组件的函数,我们应该考虑使用事件(Events)或委托(delegates)。
Find()
Find()和相关的函数功能强大的但是耗性能。这些函数需要Unity在内存中迭代每个游戏对象和组件。这这意味在小的,简单的项目中使用不会有太多的要求,但随着项目复杂度的增加,使用它们消耗会更大。
最好不要频繁是用Find()和类似的函数,尽可能缓存返回的结果。一些简单的方法可以帮助我们在代码中减少对Find()的使用,可能的情况下在检查器面板设置对对象的引用,或创建管理引用的脚本。
Transform
设置transform的位置或旋转会导致内部的OnTransformChanged时间传递给该transform的所有孩子节点。这意味着,设置transform的位置和旋转是一个比较昂贵的操作,尤其是当transform有很多孩子节点的时候。
为了限制这些内部事件的数量,我们应该高避免经常设置这些属性的值。比如,我们可能在Update()执行一个计算,设置了transform的x值,然后再设置transform的z值。在这个例子中,我们应该考虑把transform的position拷贝到Vector3中,然后在Vector3中执行运行,最后把这个Vector3设置到transform的position上。这就导致只有一个OntransformChanged事件。
Transform.position是这个访问会导致屏幕后面的运算。与Transform.localPosition形成鲜明对比。localPosition的值存储在transform上,调用Transform.localPosition只是简单的返回这个值。但我们每次调用Transform.position的时候transform的世界坐标都会计算一次。
如果我们的代码频繁使用Transform.position时,我们可以使用Transform.localPosition代替,这可能会减少CPU指令,改善性能。如果我们频繁使用Transform.position,我们应该尽可能缓存这个值。
Update()
Update(),LateUpdate()和其他事件函数看起来像个简单的函数,但他们有隐含的消耗。这些函数每次调用的时候,都会在引擎代码和托管代码之间进行通信。除此之外,Unity在调用这些函数之前还要进行大量的安全检测。安全检测确保游戏对象是有效的状态,没有被销毁等等。对于任何单个的调用来说,这种开销并不大,但当游戏中有上千个MonoBehaviours的时候,消耗会累加,这就非常耗性能了。
因此,空的Update()调用一个非常浪费的行为。我们假设,因为这个函数是空的,我们的代码中没有直接调用这个函数,所以这个空函数不会执行。事实并非如此:场景的背后,这些安全检测和本地调用仍然会发生,即使这些Update()函数体是空白的。为了避免浪费CPU时间,我们应该确保游戏代码中没有包含空的Update()调用。
如果我们游戏中有很多带有Update()函数的激活的MonoBehaviours时,我们可以用不同的方式架构代码以减少消耗。这篇文章包含进一步的细节和主题。
Vector2 and Vector3
我们知道一些简单的操作比其他操作需要更多的CPU指令。Vector数学操作就是这样一个例子:它们比浮点运算和整型运算复杂得多。虽然这这两种计算花费的时间实际差异很小,但是在足够大的规模下,这样的操作会影响性能。
很经常和方便的使用Unity的Vector2和Vector3进行数学运算,尤其是在使用transform的时候。如果我们代码中频繁使用Vector2和Vector3的数学操作,比如大量的游戏对象的Update()嵌套使用,我们可能会为CPU创建了不必要的工作。这种情况下,使用整型或浮点型计算来节省性能。
在文章的前面,我们学习了平方根的CPU指令比简单的乘法指令指令要慢。Vector2.magnitude和Vector3.magnitude都是这样的例子,因为它们涉及了平方根运算。另外,Vector2.Distance和Vector3.Distance最终使用的也是magnitude。
如果我们游戏中频繁使用了好性能的magnitude或Distance,我们可以尽可能使用消耗相对小的Vector2.sqrMagnitude和Vector3.sqrMagnitude代替。替换单个函数调用可能导致很细微的差别,但是足够大的规模下可以节省有用的性能。
Camera.main
Camera.main是一个方面的Unity API调用,它返回了第一个tag为"Main Camera"并且激活的摄像机组件。这是另一个看起来像变量,但实际上是访问器的例子。在这种情况下,访问器在屏幕后调用内部函数,类似Find()。Camera.main因此遭遇了和Find()一样的问题:在内存中搜索了所有的GameObjects和Components,这个使用可能会消耗大。
为了避免这些耗时的调用,我们应该要么缓存Camera.main结果,要么避免使用它,并手动管理我们摄像机的引用。
Other Unity API calls and further optimizations
我们已经思考了几种常见的Unity API调用异常消耗的例子,了解了这些消耗背后的不同原因。然而,这绝不是提高Unity API调用效率的详细清单。
这篇文章是一个广泛Unity优化指南,它包含了其他一些对我们有用的Unity API优化。这篇文章关于更多的优化有深度的思考。
只有需要的时候才运行代码
编程中有句俗语:最快的代码就是不运行的代码。节约性能最有效方法不是使用高级技巧,而是:首先删除不需要的代码。让我们看看下面的例子,看看我们能做什么来节约性能。
裁切(Culling)
Unity包含了检测物体是否在摄像机视椎内的代码。如果它们不在摄像机的视椎内,那这些物体的渲染相关的代码不会运行。这个术语叫视椎裁体切(frustum culling)。
我们可以对我们的脚本代码采取类似的方法。如果我们的代码和一个物体的显示状态(Visual state)有关,当玩家看不到游戏物体时,这段代码不需要运行。在一个多物体的复杂场景,这个方法可以节约相当大的性能开销。
下面的简化示例代码中,我们有一个巡逻敌人的例子。Updae()在每帧调用,这个脚本控制敌人两个示例功能:一个和移动相关,一个和显示状态相关。
void Update()
{
UpdateTransformPosition();
UpdateAnimations();
}
在接下来的代码中,我们检测敌人的渲染组件(renderer)是否在摄像机视椎内。这段跟敌人显示状态相关的代码只在敌人显示的时候运行。
private Renderer myRenderer;
void Start()
{
myRenderer = GetComponent<Renderer>();
}
vid Update()
{
UpdateTransformPosition();
if(myRenderer.isVisible)
{
UpdateAnimations();
}
}
当玩家看不到物体时,可以采取以下几个方法禁用代码。如果我们知道游戏中的某些物体在某些特定的地方是不可见的,我们可以手动禁用它们。当我们不太确定,并且需要计算能见度(visibility)的时候,我们可以使用粗略的计算(比如,检测物体是否在玩家后面),比如OnBecameInvisible()和OnBecameVisible(),或一个详细的光线投射(raycast)。最好的实现方法很大程度取决于我们的游戏,测试和分析是必要的。
细节层次(Level of detail)
细节层次也叫LOD,是另一个常用的渲染优化方法。物体靠近玩家时,使用精细的网格和图片以保真方式呈现。远的物体使用没那么精细的网格和图片。我们的代码也可以使用类似的方法。比如,我们可能有一个敌人,他身上挂载了一个决定他行为的AI脚本。部分行为操作消耗可能比较大,因为这确定了他可以看到和听到什么,以及它对输入做出什么反应。我们可以使用LOD系统根据这个敌人与玩家的距离来禁用或启用某些消耗大的操作。在一个很多敌人的场景中,如果最近的敌人才执行耗时的操作,那我们可以做出很大的性能优化。
Unity的CullingGroupAPI允许我们用钩子进入(hook into)Unity的LOD系统来优化我们的代码。这个CullingGroup API手册包含了几个如何在游戏中使用CullingGroup的例子。我们应该测试,分析然后找到适合我们游戏的解决方案。
总结
我们学习了当我们Unity构建和运行的时候代码会发生什么与造成我们带么性能问题的原因以及如何减少代码性能开销。我们学习了一些常见的代码性能问题,并且思考了几种不同的解决方案。使用这些知识和我们的分析工具,我们应该能够诊断、理解和修复我们游戏中代码相关的性能问题。
网友评论