本部分将演示如何优化游戏中使用的实际脚本和方法,并详细介绍为什么能够进行这些优化,以及为什么在某些情况下应用这些优化能带来好处。
无法通过核对表之类的工具确保项目稳定运行,因为不存在这样的工具。要优化速度缓慢的项目,必须进行性能分析以找出占用时间不成比例的特定违规者。试图在不进行性能分析的情况下进行优化,或者在没有完全理解性能分析器提供的结果的情况下进行优化,就像试图戴着眼罩盲目进行优化一样。
您可以使用内部性能分析器来确定哪种过程(是物理组件、脚本还是渲染)在拖慢游戏速度,但无法深入查看具体脚本和方法来找出实际违规者。但是,通过在游戏中构建启用和禁用某些功能的开关,可显著缩小最严重违规者的范围。例如,如果删除敌人角色的 AI 脚本后帧率加倍,表明必须优化脚本或脚本向游戏添加的功能。唯一的问题是,在发现问题之前,可能需要进行大量不同的尝试。
有关在移动设备上进行性能分析的更多信息,请参阅性能分析部分。
从一开始就尝试开发快速运行的功能是有风险的,因为必须权衡到底是要将时间用于开发不优化也能同样快速运行的功能,还是开发以后因为运行速度过慢而需要削减或替换的功能。在这方面需要直觉和硬件知识来做出正确的决定,特别是因为每个游戏都不相同,对于一个游戏而言可能是一个关键的优化,可能在另一个游戏中完全失效。
在优化脚本方法的介绍中,我们将对象池作为良好游戏表现与良好代码设计之间实现交集的一个例子。对短暂对象使用对象池比创建再销毁这些对象更快,因为这种情况下的内存分配更简单,并可避免动态内存分配开销和垃圾收集 (GC)。
在 Unity 中编写的脚本使用自动内存管理功能。几乎所有的脚本语言都如此。相反,诸如 C 和 C++ 之类的低级语言使用手动内存分配,允许程序员直接从内存地址读取和写入,因此由他们负责删除自己创建的每个对象。例如,如果使用 C++ 创建对象,必须在用完对象后手动取消分配对象占用的内存。在脚本语言中,只需执行 objectReference = null;
注意:如果有一个游戏对象变量,如 GameObject myGameObject;
或 var myGameObject : GameObject;
,为什么执行 myGameObject = null;
时不能将其销毁呢?
Destroy(myGameObject);
可删除该引用和该对象。但是,如果您创建一个 Unity 不了解的对象,例如,一个没有继承源的类实例(相反,大多数类或“脚本组件”继承自 MonoBehaviour),然后将该对象的引用变量设置为 null,那么实际发生的情况是,从脚本和 Unity 的角度看,对象会丢失;它们无法访问该对象,也永远不会再看到对象,但对象会留在内存中。然后,一段时间过后,垃圾回收器运行,并删除内存中所有未被引用的对象。之所以能够做到这一点,是因为每个内存块的引用数量都会在幕后被跟踪。这就是脚本语言比 C++ 慢的原因之一。
每次创建对象时,都会分配内存。通常在代码中创建对象时甚至不知道内存分配过程的存在。
Debug.Log("boo" + "hoo");
将创建对象。
System.String.Empty
代替 ""
。类是对象且表现为引用。如果 Foo 是一个类,且
Foo foo = new Foo();
MyFunction(foo);
则 MyFunction 将接收对堆上分配的原始 Foo 对象的引用。MyFunction 内 foo 的任何更改都将在引用 foo 的任何位置可见。
类是数据且表现如此。如果 Foo 是一个结构,且
Foo foo = new Foo();
MyFunction(foo);
则 MyFunction 将收到 foo 的副本。系统绝不会在堆上分配 foo,也不会对其进行垃圾回收。如果 MyFunction 修改了 foo 的副本,则另一个 foo 不受影响。
这种机制的最终结果是 Instantiate 和 Destroy 的大量使用让垃圾回收器有很多工作要做,而这可能会导致游戏运行期间出现“顿挫”。正如自动内存管理页面所述,还有其他方法可以解决关于 Instantiate 和 Destroy 的常见性能顿挫问题,例如在没有任何操作时手动触发垃圾回收器,或者经常触发垃圾回收器以确保绝不会大量积压未使用的内存。
另一个原因是,第一次实例化特定的预制件时,有时需要将额外的内容加载到 RAM 中,或者需要将纹理和网格上传到 GPU。这种情况下也可能导致顿挫,如果使用对象池,该问题会在关卡加载时而不是在游戏过程中发生。
想象一下,假如一个木偶操作者有一个无限容量的木偶盒,每当剧本要求某个角色出现时,他们就会从盒子里取出木偶的新复制品,每当角色退出舞台时,他们就抛掉当前的复制品。对象池相当于在节目开始之前将所有木偶从盒子里取出,只要希望木偶不出现在舞台上,就将它们留在舞台后面的桌子上。
一个问题是对象池的创建会减少可用于其他目的的堆内存量;因此,如果继续在刚创建的池之上分配内存,可能会更频繁地触发垃圾收集。不仅如此,每次收集都会变慢,因为收集过程所花费的时间会随着活动对象的数量而增加。考虑到这些问题后,显而易见的结论是,如果分配的对象池太大,或者在对象池包含的对象在一段时间内都不需要的情况下让对象池保持活动状态,则性能将受到影响。此外,许多类型的对象不适合用于对象池。例如,游戏可能包括持续相当长时间的魔法效果,或者大量出现但仅在游戏进行时才逐渐被杀死的敌人。在此类情况下,对象池的性能开销大大超过了优势,因此不应使用这种机制。
下面简单地并排比较了一个简单飞弹的脚本;一种方案使用初始化,另一种方案使用对象池。
// GunWithInstantiate.js // GunWithObjectPooling.js
#pragma strict #pragma strict
var prefab : ProjectileWithInstantiate; var prefab : ProjectileWithObjectPooling;
var maximumInstanceCount = 10;
var power = 10.0; var power = 10.0;
private var instances : ProjectileWithObjectPooling[];
static var stackPosition = Vector3(-9999, -9999, -9999);
function Start () {
instances = new ProjectileWithObjectPooling[maximumInstanceCount];
for(var i = 0; i < maximumInstanceCount; i++) {
// 将一堆未使用的对象放在离贴图很远的位置
instances[i] = Instantiate(prefab, stackPosition, Quaternion.identity);
// 默认情况下禁用,这些对象尚未激活。
instances[i].enabled = false;
}
}
function Update () { function Update () {
if(Input.GetButtonDown("Fire1")) { if(Input.GetButtonDown("Fire1")) {
var instance : ProjectileWithInstantiate = var instance : ProjectileWithObjectPooling = GetNextAvailiableInstance();
Instantiate(prefab, transform.position, transform.rotation); if(instance != null) {
instance.velocity = transform.forward * power; instance.Initialize(transform, power);
} }
} }
}
function GetNextAvailiableInstance () : ProjectileWithObjectPooling {
for(var i = 0; i < maximumInstanceCount; i++) {
if(!instances[i].enabled) return instances[i];
}
return null;
}
// ProjectileWithInstantiate.js // ProjectileWithObjectPooling.js
#pragma strict #pragma strict
var gravity = 10.0; var gravity = 10.0;
var drag = 0.01; var drag = 0.01;
var lifetime = 10.0; var lifetime = 10.0;
var velocity : Vector3; var velocity : Vector3;
private var timer = 0.0; private var timer = 0.0;
function Initialize(parent : Transform, speed : float) {
transform.position = parent.position;
transform.rotation = parent.rotation;
velocity = parent.forward * speed;
timer = 0;
enabled = true;
}
function Update () { function Update () {
velocity -= velocity * drag * Time.deltaTime; velocity -= velocity * drag * Time.deltaTime;
velocity -= Vector3.up * gravity * Time.deltaTime; velocity -= Vector3.up * gravity * Time.deltaTime;
transform.position += velocity * Time.deltaTime; transform.position += velocity * Time.deltaTime;
timer += Time.deltaTime; timer += Time.deltaTime;
if(timer > lifetime) { if(timer > lifetime) {
transform.position = GunWithObjectPooling.stackPosition;
Destroy(gameObject); enabled = false;
} }
} }
当然,对于大型的复杂游戏,您需要找到适用于所有预制件的通用解决方案。
在脚本方法部分中给出的“数百个正在旋转、动态光照的可收集硬币一次性出现在屏幕上”示例将用于演示如何使用脚本代码、诸如粒子系统之类的 Unity 组件以及自定义着色器来创建令人惊叹的效果,而不会对薄弱的移动端硬件产生负担。
假如这种效果存在于 2D 横向卷轴游戏的背景下;游戏中有大量硬币掉落、反弹和旋转。这些硬币由点光源提供动态光照。我们希望捕捉硬币闪光效果,让游戏更令人印象深刻。
如果我们有强大的硬件,可使用标准方法来解决这一问题。将每个硬币设置为一个对象,用顶点光照、前向光照或延迟光照对该对象进行着色,然后在此基础上添加发光作为图像效果,从而使明亮反光的硬币将光线照射到周围区域。
但是,移动端硬件无法应对如此之多的对象,并且发光效果完全不可能。那么我们该怎么办?
如果想显示许多以相似方式移动并且玩家永远无法仔细查看的对象,也许可以使用粒子系统立即渲染大量对象。以下是这种技术的一些常规应用:
有一个名为 Sprite Packer 的免费编辑器扩展程序可帮助您轻松创建动画精灵粒子系统。该扩展程序可将对象的帧渲染为纹理,然后可以将纹理用作粒子系统上的动画精灵图集。对于我们的用例,我们会在旋转硬币上使用该纹理。
Sprite Packer 项目中包含的一个示例演示了这一具体问题的解决方案。
该方案使用一组各种不同类型的资源以较低的计算预算成本实现炫目的效果:
该示例附带的一个自述文件试图阐述系统的有效性和工作方式,概述了如何确定的所需功能以及功能的实现方式。此文件如下:
问题的定义为“数百个正在旋转、动态光照的可收集硬币一次性出现在屏幕上”。
一种简单的方法是实例化硬币预制件的大量副本,但我们将改用粒子来渲染硬币。然而,这种做法会引入许多必须克服的挑战。
此示例的最终目标或“寓意”在于,如果游戏确实需要某种功能,但试图通过传统手段实现该功能时会导致滞后,这种情况下并不意味着该功能是不可能实现的,而只是意味着您必须在自己的系统上做出某种努力来大幅提高运行速度。
下面介绍了一些特定的脚本优化方式,适用于涉及数百甚至数千个动态对象的情况。将这些技巧应用到游戏中的每个脚本是一种糟糕的想法;而应该在编写运行时处理大量对象或数据的大型脚本时,将这些技巧保留作为工具和设计准则。
在计算机科学中,O(n) 表示运算顺序,指的是必须对该运算进行评估的次数随着应用于对象的数量 (n) 增加而增加的规律。
例如,考虑一种基本的排序算法。我有 n 个数字,并希望从最小到最大排序。
void sort(int[] arr) {
int i, j, newValue;
for (i = 1; i < arr.Length; i++) {
// 记录
newValue = arr[i];
//将所有更大的值移到右侧
j = i;
while (j > 0 && arr[j - 1] > newValue) {
arr[j] = arr[j - 1];
j--;
}
// 将记录的值放在大值的左侧
arr[j] = newValue;
}
}
重要之处在于这里有两个循环,一个循环位于另一个循环之内。
for (i = 1; i < arr.Length; i++) {
...
j = i;
while (j > 0 && arr[j - 1] > newValue) {
...
j--;
}
}
假设我们给出这种算法最糟糕的情况:输入数字是按相反顺序排序的。在这种情况下,最里面的循环将运行 j 次。平均而言,当 i 从 1 变为 arr.Length–1,j 将是 arr.Length/2。从 O(n) 的角度看,arr.Length 是我们的 n,因此,总的来说,最里面的循环运行 nn/2* 次,即 n2/2** 次。但以 O(n) 来表示时,我们去掉 1/2 之类的所有常量,因为我们想讨论运算次数的增加规律,而不是实际的运算次数。所以,该算法为 O(n2)。如果数据集很大,运算的顺序很重要,因为运算次数可能呈指数级暴增。
O(n2) 运算在游戏中的一个示例为:假设有 100 个敌人,每个敌人的 AI 都需要考虑每个其他敌人的移动情况。如果将地图划分为单元格,将每个敌人的移动记录到最近的单元格中,然后让每个敌人对最近的几个单元格进行采样,可能速度会更快。这将是 O(n) 运算。
假设游戏中有 100 个敌人,他们都朝着玩家移动。
// EnemyAI.js
var speed = 5.0;
function Update () {
transform.LookAt(GameObject.FindWithTag("Player").transform);
// 这将更加糟糕:
//transform.LookAt(FindObjectOfType(Player).transform);
transform.position += transform.forward * speed * Time.deltaTime;
}
如果同时运行的数量足够多,可能会很慢。鲜为人知的一个事实:MonoBehaviour 中的所有组件访问者(比如变换、渲染器和音频)都等同于它们的 GetComponent(Transform) 对应项,它们实际上有点慢。GameObject.FindWithTag 已经过优化,但在某些情况下(例如,在内部循环中或在许多实例上运行的脚本上),此脚本可能有点慢。
以下是一个更好的脚本版本。
// EnemyAI.js
var speed = 5.0;
private var myTransform : Transform;
private var playerTransform : Transform;
function Start () {
myTransform = transform;
playerTransform = GameObject.FindWithTag("Player").transform;
}
function Update () {
myTransform.LookAt(playerTransform);
myTransform.position += myTransform.forward * speed * Time.deltaTime;
}
超越函数(Mathf.Sin、Mathf.Pow 等)、除法和平方根都需要大约 100 倍于乘法的时间。(从宏观上来说,时间不足挂齿,但如果每帧调用这些函数几千次,结果便会累加起来)。
这种情况最常见的例子是矢量归一化。如果要反复对相同的矢量进行归一化,请考虑对其进行一次性归一化,然后将结果缓存起来供稍后使用。
如果既要使用矢量的长度,也要对其进行归一化,则将矢量乘以长度的倒数而不是使用 .normalized 属性来获得归一化矢量的做法会更快。
如果要比较距离,无需比较实际距离。取而代之的做法是使用 .sqrMagnitude 属性比较距离的平方,然后保存一个或两个平方根。
另一种情况,如果要用一个常数 c 反复做除法,可改用乘以倒数的方式。首先通过 1.0/c 计算倒数。
如果必须执行一些高成本的运算,为了对此进行优化,也许可以适当降低执行频率并将结果缓存起来。例如,假设有一个使用 Raycast 的飞弹脚本:
// Bullet.js
var speed = 5.0;
function FixedUpdate () {
var distanceThisFrame = speed * Time.fixedDeltaTime;
var hit : RaycastHit;
// 对于每一帧,我们都会将射线从我们所处的位置向前投射到下一帧的位置
if(Physics.Raycast(transform.position, transform.forward, hit, distanceThisFrame)) {
// 击中目标
} else {
transform.position += transform.forward * distanceThisFrame;
}
}
我们可以将 FixedUpdate 替换为 Update,并将 fixedDeltaTime 替换为 deltaTime,立刻实现脚本的改进。FixedUpdate 表示物理更新,其发生频率高于帧更新。但是,让我们更进一步,每隔 n 秒进行一次射线投射。较小的 n 将提供更大的时间分辨率,而较大的 n 提供更好的性能。在发生时间锯齿之前,目标越大越慢,n 就越大。(时间锯齿表示出现延迟:玩家击中目标,但爆炸出现时目标已过了 n 秒,或者玩家击中目标,但飞弹直接穿过)。
// BulletOptimized.js
var speed = 5.0;
var interval = 0.4; // 这是"n",以秒为单位。
private var begin : Vector3;
private var timer = 0.0;
private var hasHit = false;
private var timeTillImpact = 0.0;
private var hit : RaycastHit;
// 设置初始时间间隔
function Start () {
begin = transform.position;
timer = interval+1;
}
function Update () {
// 不允许小于帧的时间间隔。
var usedInterval = interval;
if(Time.deltaTime > usedInterval) usedInterval = Time.deltaTime;
// 在每个间隔,我们都会将射线从我们在这一个时间间隔开始时的位置向前投射到
// 下一个时间间隔将要开始时的位置
if(!hasHit && timer >= usedInterval) {
timer = 0;
var distanceThisInterval = speed * usedInterval;
if(Physics.Raycast(begin, transform.forward, hit, distanceThisInterval)) {
hasHit = true;
if(speed != 0) timeTillImpact = hit.distance / speed;
}
begin += transform.forward * distanceThisInterval;
}
timer += Time.deltaTime;
// 在 Raycast 照射到某个目标之后,等待子弹行程
// 大致达到射线行程以进行实际击中
if(hasHit && timer > timeTillImpact) {
// 击中目标
} else {
transform.position += transform.forward * speed * Time.deltaTime;
}
}
调用一个函数的过程本身就有一点开销。如果每帧进行数千次 x = Mathf.Abs(x) 之类的调用,直接执行 x = (x > 0 ? x : -x); 可能会更好。
Unity 使用的 NVIDIA PhysX 物理引擎可在移动端使用,但在移动平台上比在桌面平台上更容易达到硬件性能限制。
可参考以下一些提示信息来调整物理组件以便在移动端获得更好性能: