当前位置:   article > 正文

unity官方文档翻译 + 笔记 | 基于2019.3版本解决性能问题(Part 1优化脚本)

unity官方文档

unity官方文档翻译 + 笔记 | Fixing Performance Problems - 2019.3

基于2019.3版本解决性能问题(Part 1优化脚本)

本文翻自Unity官网:https://learn.unity.com/tutorial/fixing-performance-problems-2019-3-1#5e85ad9dedbc2a0021cb81aa

简介

当我们的游戏运行时,设备的中央处理器(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可能需要进行一些工作,将数据从托管运行时使用的格式转换为引擎代码所需的格式。这种转换被称为封送。再次强调,单个托管和引擎代码之间的调用开销并不特别昂贵,但重要的是我们理解存在这种开销。

代码性能不佳的原因

现在我们了解了当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]);
		}
	}
}
  • 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]);
		}
	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

这是一个简化的例子,但它说明了我们可以做出实际节省的地方。我们应该检查我们的代码,找出我们的循环结构不良的地方。考虑代码是否必须在每一帧都运行。Update()是Unity每帧运行一次的函数。Update()是一个方便的位置,用于放置需要频繁调用或需要对频繁变化做出响应的代码。然而,并不是所有的代码都需要在每一帧都运行。将代码从Update()中移出,使其只在需要时运行,是提高性能的好方法。

只在事物发生变化时运行代码

让我们看一个非常简单的例子,优化代码以便只在事物变化时运行。在下面的代码中,DisplayScore() 在 Update() 中被调用。然而,得分的值可能不会在每一帧都改变。这意味着我们在不必要地调用 DisplayScore()。

private int score;

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

void Update()
{
	DisplayScore(score);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

通过简单的修改,我们现在确保只在得分值发生变化时才调用 DisplayScore()。

private int score;

public void IncrementScore(int incrementBy)
{
	score += incrementBy;
	DisplayScore(score);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

再次强调,上述示例是故意简化的,但原理是清楚的。如果我们在整个代码中应用这种方法,才能够节省CPU资源。

每[x]帧运行代码

如果需要频繁运行代码并且无法由事件触发,则不意味着它需要在每个帧上运行。在这些情况下,我们可以选择每隔 [x] 帧运行代码。在这个例子中,一个消耗高的函数每帧都会运行。

void Update()
{
	ExampleExpensiveFunction();
}
  • 1
  • 2
  • 3
  • 4

实际上,为了满足我们的需求,我们只需要每3帧运行一次这段代码就足够了。在下面的代码中,我们使用取模运算符来确保高消耗的函数只在每3帧运行一次。

private int interval = 3;

void Update()
{
	if(Time.frameCount % interval == 0)
	{
		ExampleExpensiveFunction();
	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

这种技巧的另一个好处是,可以非常容易地将耗时的代码分散到不同的帧上,避免突发性的性能波动。在下面的例子中,每个函数每 3 帧调用一次,且不会在同一帧上调用。

private int interval = 3;

void Update()
{
	if(Time.frameCount % interval == 0)
	{
		ExampleExpensiveFunction();
	}
	else if(Time.frameCount % 1 == 1)
	{
		AnotherExampleExpensiveFunction();
	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
使用缓存

如果我们的代码重复调用昂贵的函数并且丢弃这些结果,那么这可能是一个优化的机会。存储和重复使用这些结果的引用可以更高效地进行操作。这种技术被称为缓存。在Unity中,通常使用GetComponent()来访问组件。在下面的例子中,我们在Update()中调用GetComponent()来访问一个Renderer组件,然后将其传递给另一个函数。这段代码能够正常工作,但由于重复的GetComponent()调用而效率较低。

void Update()
{
	Renderer myRenderer = GetComponent<Renderer>();
	ExampleFunction(myRenderer);
}
  • 1
  • 2
  • 3
  • 4
  • 5

下面的代码只调用一次GetComponent(),因为函数的结果被缓存起来。缓存的结果可以在Update()中重复使用,而无需进一步调用GetComponent()。

private Renderer myRenderer;

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

void Update()
{
	ExampleFunction(myRenderer);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

我们应该检查我们的代码,找出频繁调用返回结果的函数的情况。通过使用缓存,我们可能可以减少这些调用的开销。

使用正确的数据结构

我们如何组织数据对代码的性能有着重要影响。没有一种单一的数据结构适用于所有情况,因此为了在我们的游戏中获得最佳性能,我们需要针对每个任务使用合适的数据结构。为了做出正确的决策,我们需要了解不同数据结构的优缺点,并仔细考虑我们的代码的需求。我们可能有成千上万个元素每帧需要遍历,或者只有少数元素需要频繁添加和删除。这些不同的问题将需要不同的数据结构来解决。在这里做出正确的决策取决于我们对该领域的了解。如果这是一个新的知识领域,最好的起点是学习Big O Notation。大O符号是用来讨论算法复杂度的,了解它将帮助我们比较不同的数据结构。这个文章是讲解大O符号的适合初学者的指南。然后,我们可以进一步了解可用的数据结构,并将它们与问题进行比较,找到适合不同问题的数据解决方案。这份C#中集合和数据结构的MSDN指南提供了选择适当数据结构的一般指导,并提供了更深入的文档链接。单个关于数据结构的选择不太可能对我们的游戏产生重大影响。然而,在涉及大量这类集合的数据驱动型游戏中,这些选择的结果确实会累积起来。对算法复杂度和不同数据结构的优缺点的理解将帮助我们编写性能良好的代码。

最小化垃圾回收的影响

垃圾回收是Unity在内存管理过程中的一部分操作。我们的代码如何使用内存决定了垃圾回收的频率和CPU开销,因此我们需要了解垃圾回收的工作原理。接下来,我们将深入探讨垃圾回收的主题,并提供几种不同的策略来最小化其影响。

使用对象池

实例化和销毁对象通常比停用和重新启用对象更耗费资源。特别是如果对象包含启动代码,例如在 Awake() 或 Start() 函数中调用 GetComponent()。如果我们需要生成和销毁许多相同对象的副本,例如射击游戏中的子弹,那么使用对象池可能会带来好处。对象池是一种技术,它不会创建和销毁对象实例,而是在需要时暂时停用、回收和重新启用对象。对象池被广泛用作管理内存使用的技术,同时也可以用作减少过多CPU使用的技术。本文无法详尽介绍对象池的全部内容,但它是一种非常有用的技术值得学习。

避免使用昂贵的Unity API调用

有时,我们的代码调用其他函数或API时可能会出乎意料地昂贵。造成这种情况可能有很多原因。看起来像一个变量的东西实际上可能是一个包含额外代码的访问器,会触发事件或从托管代码调用引擎代码。

在本节中,我们将看几个Unity API调用的示例,它们的成本可能比它们表面上看起来的要高。我们将考虑如何减少或避免这些成本。这些示例展示了造成成本的不同潜在原因,并且建议的解决方案可以应用于其他类似的情况。

重要的是要理解,没有一份应该避免使用的Unity API调用列表。每个API调用在某些情况下可能有用,在其他情况下可能没有那么有用。在所有情况下,我们必须仔细地对游戏进行性能分析,找出成本高的代码的原因,并仔细考虑如何以对游戏最有利的方式解决问题。

  • SendMessage()

    SendMessage() 和 BroadcastMessage() 是非常灵活的函数,对项目结构的了解要求较少,并且非常快速实现。因此,在原型设计或初级脚本编写阶段非常有用。然而,它们的使用成本非常高昂。这是因为这些函数使用了反射机制。反射是指代码在运行时而不是编译时检查自身并做出决策的过程。使用反射的代码会给CPU带来比不使用反射的代码更多的工作量。

    建议仅在原型设计阶段使用SendMessage() 和 BroadcastMessage(),在可能的情况下使用其他函数。例如,如果我们知道要调用函数的组件,应直接引用该组件并以此方式调用函数。如果我们不知道要调用函数的组件,可以考虑使用事件或委托。

  • Find()

    Find()和相关函数功能强大但开销较大。这些函数需要Unity遍历内存中的每个GameObject和Component。这意味着在小型简单项目中它们不会特别消耗资源,但随着项目复杂性的增加,它们的使用成本也会增加。

    最好尽量减少对Find()和类似函数的使用,并在可能的情况下缓存结果。一些简单的技巧可以帮助我们减少在代码中使用Find(),包括在可能的情况下使用Inspector面板设置对象引用,或创建管理常常需要搜索的内容引用的脚本。

  • Transform

    设置Transform的位置或旋转会触发一个内部的OnTransformChanged事件,传播到该Transform的所有子对象。这意味着设置Transform的位置和旋转值相对较耗费资源,特别是在具有许多子对象的Transform中。

    为了减少这些内部事件的数量,我们应该尽量避免频繁设置这些属性的值。例如,我们可能在Update()函数中执行一次计算来设置Transform的x位置,然后再执行另一次计算来设置其z位置。在这种情况下,我们应该考虑将Transform的位置复制到一个Vector3中,在该Vector3上进行所需的计算,然后将Transform的位置设置为该Vector3的值。这样只会触发一次OnTransformChanged事件。

    Transform.position是一个计算背后进行的访问器的例子。这可以与Transform.localPosition进行对比。localPosition的值存储在Transform中,调用Transform.localPosition只是返回该值。然而,每次调用Transform.position时都会计算出Transform的世界位置。

    如果我们的代码频繁使用Transform.position,并且可以使用Transform.localPosition替代它,这将减少CPU指令的数量,从而可能提高性能。如果我们经常使用Transform.position,应尽可能进行缓存。

  • Update()

    Update()、LateUpdate()和其他事件函数看起来像是简单的函数,但它们有隐藏的开销。每次调用这些函数时,它们需要在引擎代码和托管代码之间进行通信。除此之外,Unity还在调用这些函数之前进行了一系列的安全检查。这些安全检查确保GameObject处于有效状态,未被销毁等等。对于单个调用来说,这种开销并不特别大,但在具有数千个MonoBehaviour的游戏中,这些开销会累积起来。

    因此,空的Update()调用可能特别浪费资源。我们可能会认为,因为函数是空的,我们的代码中没有直接调用它,那么空的函数就不会运行。但事实并非如此:在幕后,这些安全检查和本地调用仍然会发生,即使Update()函数的主体是空的。为了避免浪费CPU时间,我们应该确保我们的游戏中不包含空的Update()调用。

    如果我们的游戏中有很多带有Update()调用的活动MonoBehaviour,我们可能会从以不同的方式组织我们的代码来减少这种开销中受益。这篇Unity博客文章对这个主题进行了更详细的阐述。

  • Vector2 and Vector3

    我们知道,某些操作所需的CPU指令比其他操作多。向量数学运算就是一个例子:它们比浮点数或整数运算更复杂。虽然两个这样的计算所需的时间差异微乎其微,但在足够大的规模下,这些操作可能会影响性能。

    在处理变换时,通常使用Unity的Vector2和Vector3结构体进行数学运算非常常见和方便。如果我们的代码中频繁进行许多Vector2和Vector3数学运算,例如在Update()的嵌套循环中处理大量GameObject,我们可能会给CPU带来不必要的工作。在这些情况下,我们可以尝试使用int或float计算来节省性能。

    在本文的早些时候,我们了解到执行平方根计算所需的CPU指令比简单的乘法等操作要慢。Vector2.magnitude和Vector3.magnitude都是这样的例子,因为它们都涉及平方根计算。此外,Vector2.Distance和Vector3.Distance在幕后也使用了magnitude。

    如果我们的游戏广泛且频繁地使用magnitude或Distance,可能可以通过使用Vector2.sqrMagnitude和Vector3.sqrMagnitude来避免相对昂贵的平方根计算。再次强调,替换单个调用只会产生微小的差异,但在足够大的规模下,可能能够实现有用的性能节省。

  • Camera.main

    Camera.main是一个方便的Unity API调用,它返回一个引用,指向标记为"Main Camera"的第一个启用的Camera组件。这是另一个看起来像变量但实际上是一个访问器的例子。在这种情况下,访问器在幕后调用了一个类似于Find()的内部函数。因此,Camera.main存在与Find()相同的问题:它需要在内存中搜索所有的GameObject和Component,并且使用起来可能非常昂贵。

    为了避免这种潜在的昂贵调用,我们应该要么缓存Camera.main的结果,要么完全避免使用它,并手动管理对摄像机的引用。

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

    我们已经介绍了一些常见的Unity API调用的例子,这些调用可能会导致意外的性能开销,并了解了这些开销背后的不同原因。然而,这并不是一个详尽无遗的提高Unity API调用效率的方法列表。这篇关于Unity性能的文章是一份广泛的优化指南,其中包含了许多其他有用的Unity API优化方法。此外,官方文档深入探讨了超出这篇相对高级和适合初学者的文章范围的进一步优化方法。

只在需要运行时才运行代码

在编程中有一句话:“最快的代码是不运行的代码”。解决性能问题的最有效方式往往不是使用高级技术,而是简单地删除不必要的代码。让我们来看几个例子,看看在哪些地方我们可以进行这种优化。

剔除(Culling)

Unity中包含用于检查对象是否在相机的视椎体内的代码。如果对象不在相机的视椎体内,与渲染这些对象相关的代码将不会运行。这个过程被称为视椎体剔除(frustum culling)。

我们可以对我们脚本中的代码采取类似的方法。如果我们有一段与对象的可视状态相关的代码,当对象不在玩家的视野中时,我们可能不需要执行这段代码。在一个复杂的场景中,这样做可以节省相当多的性能。

以下是一个简化的示例代码,其中包含一个巡逻敌人的示例。每当调用Update()时,控制这个敌人的脚本会调用两个示例函数:一个与移动敌人有关,一个与其可视状态有关。

void Update()
{
	UpdateTransformPosition();
	UpdateAnimations();
}
  • 1
  • 2
  • 3
  • 4
  • 5

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

private Renderer myRenderer;

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

void Update()
{
    UpdateTransformPosition();

    if (myRenderer.isVisible)
    {
        UpateAnimations();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

在玩家看不到的情况下禁用代码可以通过几种方式实现。如果我们知道场景中的某些对象在游戏的特定时刻不可见,我们可以手动禁用它们。当我们不确定并且需要计算可见性时,我们可以使用粗略的计算(例如,检查对象是否在玩家后方),使用OnBecameInvisible()和OnBecameVisible()这样的函数,或者进行更详细的射线检测。最佳实现非常依赖于我们的游戏,实验和性能分析至关重要。

细节级别(Level of Detail,LOD)

细节级别(Level of Detail,LOD)是另一种常见的渲染优化技术。离玩家最近的物体使用详细的网格和纹理进行完整呈现,而远处的物体则使用较低细节的网格和纹理。我们也可以在代码中采用类似的方法。例如,我们可能有一个带有AI脚本的敌人,用于确定其行为。其中一部分行为可能涉及昂贵的操作,用于确定它能够看到和听到什么,并根据这些输入做出反应。我们可以使用细节级别系统根据敌人与玩家的距离启用或禁用这些昂贵的操作。在拥有许多这些敌人的场景中,只有最近的敌人执行最昂贵的操作可以大大提高性能。

Unity的CullingGroup API允许我们与Unity的LOD系统进行集成,优化我们的代码。CullingGroup API的手册页面包含了一些在游戏中使用它的示例。如同往常一样,我们应该进行测试、分析,并找到适合我们游戏的正确解决方案。

通过学习,我们了解了在构建和运行Unity游戏时代码的工作方式,以及为何代码会导致性能问题,以及如何减少对游戏性能的影响。我们了解了一些代码性能问题的常见原因,并考虑了一些不同的解决方案。借助这些知识和性能分析工具,我们现在应该能够诊断、理解和修复与游戏中的代码相关的性能问题了。

声明:本文内容由网友自发贡献,转载请注明出处:【wpsshop】
推荐阅读
相关标签
  

闽ICP备14008679号