1.尽可能将代码移出循环和Update
编写不当的循环是效率低下的常见情况,尤其是循环嵌套时或循环处于非常频繁运行的情况下,比如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中执行
2.仅在情况发生变化时运行代码
让我们看一个非常简单的示例,该示例优化代码,使其仅在情况发生变化时运行。在以下代码中,Update() 中调用了 DisplayScore()。然而,分数的值可能不会随着每一帧而改变。这意味着我们不必要地调用 DisplayScore()。
Private int score;public void IncrementScore(int incrementBy){score += incrementBy;}void Update(){DisplayScore(score);}
通过简单的更改,我们现在确保仅当分数值发生更改时才调用 DisplayScore()。
Private int score;public void IncrementScore(int incrementBy){score += incrementBy;DisplayScore(score);}
同样,上面的例子是故意简化的,但原理很清楚。如果我们在整个代码中应用这种方法,我们也许能够节省 CPU 资源。
3.每 [x] 帧运行代码
如果代码需要频繁运行并且不能由事件触发,这并不意味着它需要运行每一帧。在这些情况下,我们可以选择每 [x] 帧运行代码。
在这个示例代码中,一个昂贵的函数每帧运行一次。
void Update(){ExampleExpensiveFunction();}
事实上,每 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 % 1 == 1) { AnotherExampleExpensiveFunction(); }}
4.缓存组件引用
如果我们的代码重复调用返回结果的昂贵函数,然后丢弃这些结果,这可能是优化的机会。存储和重用对这些结果的引用可以更有效。这种技术称为缓存。
在Unity中,通常调用GetComponent()来访问组件。在下面的示例中,我们在 Update() 中调用 GetComponent() 来访问渲染器组件,然后再将其传递给另一个函数。该代码可以工作,但由于重复调用 GetComponent(),效率低下。
void Update(){Renderer myRenderer = GetComponent<Renderer>();ExampleFunction(myRenderer);}
以下代码仅调用 GetComponent() 一次,因为该函数的结果已被缓存。缓存的结果可以在 Update() 中重用,而无需进一步调用 GetComponent()。
private Renderer myRenderer;void Start(){myRenderer = GetComponent<Renderer>();}void Update(){ExampleFunction(myRenderer);}
我们应该检查我们的代码是否存在频繁调用返回结果的函数的情况。我们可以通过使用缓存来降低这些调用的成本。
5.使用正确的数据结构
我们如何构建数据会对我们的代码执行产生重大影响。没有一种数据结构适合所有情况,因此为了在游戏中获得最佳性能,我们需要为每个任务使用正确的数据结构。
为了正确决定使用哪种数据结构,我们需要了解不同数据结构的优点和缺点,并仔细考虑我们希望代码做什么。我们可能有数千个元素需要每帧迭代一次,或者我们可能有少量元素需要经常添加和删除。这些不同的问题将通过不同的数据结构得到最好的解决。
在这里做出正确的决定取决于我们对该主题的了解。如果这是一个新的知识领域,最好的起点就是学习大 O 表示法。大 O 表示法是讨论算法复杂性的方式,理解这一点将有助于我们比较不同的数据结构。本文是该主题的清晰且适合初学者的指南。然后我们可以更多地了解可用的数据结构,并对它们进行比较,以找到针对不同问题的正确数据解决方案。此 MSDN C# 集合和数据结构指南提供了有关选择适当数据结构的一般指导,并提供了更深入文档的链接。
关于数据结构的单一选择不太可能对我们的游戏产生很大的影响。然而,在涉及大量此类集合的数据驱动游戏中,这些选择的结果确实可以累加。了解算法的复杂性以及不同数据结构的优缺点将帮助我们创建性能良好的代码。
6.在频繁创建销毁对象的场合使用对象池
实例化和销毁一个对象通常比停用和重新激活它的成本更高。如果对象包含启动代码(例如在 Awake() 或 Start() 函数中调用 GetComponent()),则尤其如此。如果我们需要生成并处理同一对象的许多副本,例如射击游戏中的子弹,那么我们可能会从对象池中受益。
对象池是一种技术,其中对象不是创建和销毁对象的实例,而是暂时停用,然后根据需要回收和重新激活。尽管对象池是一种众所周知的内存使用管理技术,但它也可以作为减少 CPU 过度使用的有用技术。
7.避免昂贵的 Unity API 调用
本节列出一些比较消耗性能的API和其原因,建议用其他API或者方式替代其使用
SendMessage()
SendMessage() 和 BroadcastMessage() 是非常灵活的函数,几乎不需要了解项目的结构,并且可以非常快速地实现。因此,这些函数对于原型设计或初学者级别的脚本编写非常有用。然而,它们的使用极其昂贵。这是因为这些函数利用了反射。反射是指代码在运行时而不是编译时检查自身并做出决策的术语。使用反射的代码比不使用反射的代码会给 CPU 带来更多的工作量。
建议仅将 SendMessage() 和 BroadcastMessage() 用于原型设计,并尽可能使用其他函数。例如,如果我们知道要在哪个组件上调用函数,则应该直接引用该组件并以这种方式调用函数。如果我们不知道要在哪个组件上调用函数,我们可以考虑使用事件或委托。
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,我们应该尽可能缓存它。
8.使用最快的方法获取组件
GetComponent()方法有一些变体,它们的性能消耗不同。3个可用的重载版本是GetComponent(string)、GetComponent<T>()和GetComponent(typeof(T)。目前性能最快的是GetComponent<T>()这个变体。
9.移除空的事件函数
空的事件函数比如Start()、Update()、FixedUpdate()、Awake()等会产生CPU的开销,这些函数每次被调用时都需要引擎代码和源码之间的通信。除此之外,Unity 在调用这些函数之前还会执行一些安全检查。安全检查可确保游戏对象处于有效状态、未被破坏等。对于任何单个调用来说,这个开销都不是特别大,但在具有数千个 MonoBehaviours 的游戏中,它可能会增加。因此应移除空的事件函数。
10.使用携程可减少大部分帧的性能损失
先来看一个例子,每隔0.2秒执行一个方法,Update()版:
private float _delay = 0.2f; private float _timer = 0.0f; void Update() { _timer += timer.deltaTime; if (_delay < _timer) { workFunc(); _timer -= _delay; } }
携程版:
void Start() { StartCoroutine(Coroutine()); } IEnumerator Coroutine() { while(true) { WorkFunc(); yield return new WaitForSeconds(0.2f); } }
两种写法虽然实现了相同的效果,一眼看去没什么区别,但是在性能上有所区分:携程的写法可减少对大多数帧性能的影响。
11.使用更快的GameObject空引用检查
事实证明,对GameObject执行空引用检查会导致一些不必要的性能开销。
if (gameObject != null){ //do something }
另一种方法是System.Object.ReferenceEquals(),它产生功能相当的输出,其运行速度大约是原来的两倍。
if (! System.Object.ReferenceEquals(gameObject, null)){ //do something }
12.避免从GameObject中检索字符串属性
应避免使用gameObject.name和gameObject.tag,使用gameObject.CompareTag("tag")可提升性能,减少内存分配和垃圾回收。
13.使用距离的平方而不是距离
CPU比较擅长将浮点数相乘,但是不擅长计算它们的平方根。
Vector2.magnitude 和 Vector3.magnitude 都是这样的示例,因为它们都涉及平方根计算。此外,Vector2.Distance 和 Vector3.Distance 在幕后使用magnitude。
如果我们的游戏广泛且非常频繁地使用magnitude或Distance(),则我们可以使用 Vector2.sqrMagnitude 和 Vector3.sqrMagnitude 来避免相对昂贵的平方根计算。同样,替换单个调用只会导致很小的差异,但在足够大的范围内,可能会节省可观的性能。
14.通过可见性禁用对象
Unity带有内置的渲染功能,以避免渲染对玩家的相机视图不可见的对象(被称为"视锥剔除"),避免渲染隐藏在其他对象后面的对象(遮挡剔除),但这些只是渲染层面的优化,它不会影响在CPU上执行任务的组件、脚本和游戏逻辑,开发人员必须自己控制这种行为。
解决这个问题的方法是使用OnBecameVisible()和OnBecameInvisible()回调。如果没有相机可以看到gameObject,就调用OnBecameVisible();如果至少有一个相机可以看到gameObject,就调用OnBecameInvisible()。
由于可见性必须与渲染管线通信,因此gameObject必须附加一个可渲染的组件,比如MeshRenderer或SkinnedMeshRenderer。
下面示例开启/禁用独立组件:
void OnBecameVisible() { enabled = true; } void OnBecameInvisible() { enabled = false; }
下面示例开启/禁用gameObject:
void OnBecameVisible() { gameObject.SetActive(true); } void OnBecameInvisible() { gameObject.SetActive(false); }
15.通过距离禁用对象
游戏中的怪物经常是在远处可以看到它们,离玩家较远时,处于空闲状态,玩家走近后才执行怪物的脚本逻辑。下面通过一个携程演示这种做法:
[SerializeField] GameObject _target; [SerializeField] float _maxDistance; [SerializeField] int _coroutineFrameDelay; void Start(){ StartCoroutine(DisableAtDistance()); } IEnumerator DisableAtDistance(){ while(true) { float distSqrd = (transform.position - _target.transform.position).sqrMagnitude; if (distSqrd < _maxDistance * _maxDistance) { enable = true; } else { enable = false; } for (int i = 0; i < _coroutineFrameDelay; ++i){ yield return new WaitForEndOfFrame(); } } }
将玩家角色分配给Inspector中_target字段,在_maxDistance中定义最大距离,在_coroutineFrameDelay中定义检测频率。