优化脚本
:本节将演示如何优化游戏使用的实际脚本和方法
Profiler 是老大
没有什么东西可以确保您的项目顺利运行.为了优化一个缓慢的项目,您必须分析找出占用了过多时间的特定违规者. 试图在没有分析的情况下进行优化,或者在没有完全理解分析器给出的结果的情况下进行优化,就像蒙着眼睛进行优化一样。
Internal mobile profiler
你可以使用 internal profiler 找出是什么过程让你的游戏变慢,是 physics, scripts,还是 rendering, 但是您不能深入到特定的脚本和方法中去寻找真正的拖慢运行速度的原因.然而,通过构建开关到您的游戏,启用和禁用某些功能,你可以找到具体是什么消耗了性能. 例如,如果你删除了敌方角色的AI脚本后运行帧率加倍,你就知道该脚本或它带入游戏的某些内容必须进行优化.唯一的问题是,在发现问题之前,您可能必须尝试许多不同的东西。
For more about profiling on mobile devices, see the profiling section.
优化的设计
试图从一开始就优化性能是有风险的,因为在浪费时间去做那些如果没有优化的话速度也会很快的东西和那些因为速度太慢而不得不被削减或替换的东西之间是有权衡的. 在这方面,需要直觉和硬件知识才能做出正确的决定,特别是因为每个游戏都是不同的,对于一个游戏来说至关重要的优化可能在另一个游戏中失败。
Object Pooling:对象池
对临时对象使用对象池比创建和销毁它们要快,因为它使内存分配更简单,并消除了动态内存分配开销和垃圾收集(GC)。
Memory Allocation:内存分配
简单解释什么是自动内存管理:
在Unity中编写的脚本使用自动内存管理. 几乎所有的脚本语言都这样做. 相反,低级语言如C和c++使用手动内存分配,程序员可以直接从内存地址读写,因此,他们有责任删除他们创建的每一个对象.例如,如果您在您的c++中创建对象,那么当您使用完它们时,您必须手动地释放它们占用的内存. 在脚本语言中,只要objectReference = null就足够了;
Note: 如果我有一个游戏对象变量 GameObject myGameObject;
or var myGameObject : __GameObject__;
, 为什么当我说myGameObject = null;时它没有被销毁?
- 游戏对象仍然被Unity引用, 因为Unity必须保持对它的引用,以便它被绘制,更新,etc. Calling
Destroy(myGameObject);
删除引用并删除对象。
但是如果你创建一个Unity不知道的对象, 例如,一个类的实例不继承任何东西(相反,大多数类或“脚本组件”继承MonoBehaviour) 然后将引用变量设为null, 实际发生的是对象丢失了,就你的脚本和Unity而言; 他们不能访问它,也永远不会再看到它,但它会留在内存中. 然后,一段时间后,垃圾收集器运行,并删除内存中任何没有在任何地方引用的内容.它之所以能够做到这一点,是因为在幕后,对每个内存块的引用数量被跟踪. 这就是脚本语言比c++慢的原因之一.
- Read more about Automatic Memory Management and the Garbage Collector.
如何避免分配内存
每次创建对象时,都会分配内存. 通常在代码中,您在创建对象时甚至都不知道它。
Debug.Log("boo" + "hoo");
创建一个对象
- 使用
System.String.Empty
而不是""
当处理大量字符串时
- Immediate Mode GUI (UnityGUI) 是缓慢的,当性能要求很严格时,不要使用OnGUI。
- 类和结构体之间的区别:
类是对象,表现为引用. 如果Foo是一个类
Foo foo = new Foo();
MyFunction(foo);
then MyFunction 会收到对在堆上分配的原始Foo对象的引用. 在MyFunction中对foo的任何修改在foo被引用的任何地方都是可见的。
如果Foo是一个struct
Foo foo = new Foo();
MyFunction(foo);
then MyFunction 会受到foo的拷贝. foo 从不在堆上分配,也从不回收垃圾. If MyFunction 修改它的foo拷贝,其他foo不受影响
- 长时间存在的对象应该是类, 短暂的对象应该是结构体. Vector3 就是结构体.如果它是一个类,那么一切都会慢很多。
为什么对象池更快
结果是这样的 大量使用实例化和销毁给垃圾收集器提供了大量工作, 这可能会导致游戏中的“障碍”. As the Automatic Memory Management page explains, 这里有一些其他的方法来解决围绕实例化和销毁的常见性能问题,比如在没有发生任何事情时手动触发垃圾收集器,或者经常触发它,这样就不会积累大量未使用的内存。
另一个原因是, 当一个prefab第一次被实例化时,有时需要将额外的东西加载到RAM中,或者纹理和网格需要上传到GPU. 这也会导致一个障碍,而对于对象池,这发生在关卡加载时,而不是游戏过程中。
想象一个木偶表演者,他有无数个木偶, 每次脚本要求一个角色出现时,他们都会得到一个新的木偶副本,每次角色离开舞台时,他们都会扔掉当前的副本.对象池相当于在演出开始前把所有的木偶从盒子里拿出来,当它们不应该是可见的时候就把它们留在舞台后面的桌子上.
为什么对象池可以更慢
一个问题是,池的创建减少了用于其他目的的堆内存量; 因此,如果继续在刚刚创建的池上分配内存,那么可能会更频繁地触发垃圾收集.不仅如此,每个收集都会更慢,因为收集所花费的时间会随着活动对象的数量增加而增加. 考虑到这些问题,很明显,如果分配的池太大,或者在一段时间内不需要它们包含的对象时保持池处于活动状态,那么性能将受到影响. 而且,许多类型的对象都不适合对象池,例如,游戏可能包含持续相当长的时间的法术效果,或者出现大量的敌人,但随着游戏的进展,这些敌人只能逐渐被杀死. 在这种情况下,对象池的性能开销远远超过其好处,因此不应该使用它。
Implementation
下面是一个简单的片头脚本的比较,一个使用实例化,一个使用对象池。
// 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++) {
// place the pile of unused objects somewhere far off the map
instances[i] = Instantiate(prefab, stackPosition, Quaternion.identity);
// disable by default, these objects are not active yet.
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;
} }
} }
Of course, for a large, complicated game, you will want to make a generic solution that works for all your prefabs.
另一个例子:硬币派对!
像粒子系统和自定义着色器这样的Unity组件可以用来创建一个惊人的效果,而不需要消耗脆弱的移动硬件.
想象一下,这种效果存在于一款2D横冲直撞的游戏中,游戏中有数不清的硬币会掉落、反弹和旋转. 硬币是动态照明点灯.我们想要捕捉到硬币上闪烁的光芒,让我们的游戏更令人印象深刻。
如果我们有强大的硬件,我们可以使用标准的方法来解决这个问题. 把每一枚硬币都做成一个物体,用垂直光、前进光或延迟光对物体进行着色,然后在顶部添加辉光作为图像效果,使反射明亮的硬币将光线射入周围区域。
但是移动硬件会在那么多的物体上阻塞,辉光效果是完全不可能的。那么我们该怎么做呢?
动画精灵粒子系统
如果你想要展示许多以相似方式移动的对象,并且这些对象永远不会被玩家仔细检查,你可以使用粒子系统在任何时候呈现大量的对象. 以下是这种技术的一些典型应用:
- 收藏品或硬币
- 飞行碎屑
- 成群结队的敌人
- 欢呼人们
- 数百枚炮弹或爆炸
有一个免费的编辑器扩展称为Sprite Packer,方便创建动画精灵粒子系统. 它将帧上的对象转换成texture,然后可以在粒子系统中创建粒子动画.对于我们的用例,我们将在旋转的硬币上使用它。
引用实现
Sprite Packer project 就是一个例子,它演示了这个问题的解决方案。.
它使用所有不同种类的资产来实现在低计算预算上的令人眼花缭乱的效果t:
- 一个控制脚本
- 由SpritePacker创建的特殊纹理
- 一个特殊的着色器,它与控制脚本和纹理紧密相连。
示例中包含一个readme文件,该文件试图解释系统为何以及如何工作,概述了用于确定需要哪些功能以及如何实现这些功能的过程。这是那个文件:
这个问题被定义为“屏幕上同时出现数百个旋转的、动态发光的、可收集的硬币”
天真的方法是实例化一堆硬币预制的副本,但是我们将使用粒子来渲染硬币。然而,这带来了一些我们必须克服的挑战。
- 视角是个问题,因为粒子没有视角.
- 我们假设相机保持右侧向上,硬币绕y轴旋转。
- 我们使用SpritePacker创建了带有动画纹理的硬币旋转的错觉.
- 这就引入了一个新的问题:旋转硬币的单调性,所有硬币都以相同的速度和方向旋转
- 我们自己跟踪旋转和生命周期,在脚本中解决旋转粒子的寿命
- 法线是个问题,因为粒子没有法线,我们需要实时照明。
- 在每个由Sprite Packer生成的动画帧中,为硬币的表面生成一个单一的法向量。
- 根据从上述列表中抓取的法向量,对脚本中的每个粒子进行Blinn-Phong照明
- 将结果作为颜色应用到粒子上。
- 在着色器中分别处理硬币的正面和硬币的边缘。引入了一个新问题:着色器如何知道边缘在哪里,它在边缘的什么部分?
- 不能使用UV,它们已经用于动画了
- 使用纹理贴图
- 需要y位置相对于硬币.
- Need binary “on face” vs “on rim”.
- 我们不想引入另一个纹理,更多的纹理读取,更多的纹理内存。
- 将需要的信息合并到一个通道中,并用它替换纹理的一个颜色通道。
- 现在我们的硬币颜色不对了!我们该怎么办?
- 使用着色器重建丢失的通道,作为两个剩余通道的组合。
- 假设我们想要从硬币上发光。后期处理对于移动设备来说太昂贵了。
- 创建另一个粒子系统 and给他一个柔和的,带有光晕的硬币动画
- 只有硬币颜色非常亮时才会发光。
- Reset glows every frame, only position ones with brightness > 0.
- 物理是一个问题, 收集硬币是个问题 -粒子的碰撞不是很好.
- 可以使用内置粒子碰撞?
- 相反,只是在脚本中写入了碰撞.
- 最后,我们还有一个问题——这个脚本做了很多工作,而且速度变慢了!
- 性能与活跃硬币的数量成线性比例.
- 限制硬币的数量. 这足以满足我们的目标:100枚硬币,2盏灯,在移动设备上运行非常快。
- 尝试进一步优化:
- 而不是计算每个硬币的照明, 把世界切成块,计算每个块中的每个旋转帧的光照条件。
- 使用一个以硬币的位置和旋转为索引的查找表 .
- 用双线性插值与位置增加保真度.
- 稀疏更新查找表,或完全静态查找表
- 用光探头还是这个?*使用正常贴图的粒子而不是在脚本中计算光照?
- 使用“显示法线”着色器烘焙法线的帧动画.
- 限制灯光数量。
- Fixes slow script problem.
管理数千个对象的技术
这些是特定的脚本优化,适用于涉及数百或数千个动态对象的情况。将这些技术应用到游戏中的每个脚本中是一个糟糕的想法; 它们应该作为在运行时处理大量对象或数据的大型脚本的工具和设计指南。
避免或最小化对大数据集的O(n2)操作
例如,考虑一个基本的排序算法。我有n个数,我想把它们从最小到最大排序。
void sort(int[] arr) {
int i, j, newValue;
for (i = 1; i < arr.Length; i++) {
// record
newValue = arr[i];
//shift everything that is larger to the right
j = i;
while (j > 0 && arr[j - 1] > newValue) {
arr[j] = arr[j - 1];
j--;
}
// place recorded value to the left of large values
arr[j] = newValue;
}
}
The important part is that there are two loops here, one inside the other.
for (i = 1; i < arr.Length; i++) {
...
j = i;
while (j > 0 && arr[j - 1] > newValue) {
...
j--;
}
}
假设我们给出了最坏的情况: 输入数字已排序, 但是顺序相反. 既然那样, 最里面的循环将运行j次.
游戏中一个O(n2)操作的例子是100个敌人,其中每个敌人的AI会考虑其他所有敌人的行动。将地图划分成单元格,将每个敌人的移动记录到最近的单元格中,然后让每个敌人采样到最近的几个单元格,这样可能会更快。这就是O(n)操作。
缓存引用,而不是执行不必要的搜索
假设你的游戏中有100个敌人,他们都向玩家移动。
// EnemyAI.js
var speed = 5.0;
function Update () {
transform.LookAt(GameObject.FindWithTag("Player").transform);
// this would be even worse:
//transform.LookAt(FindObjectOfType(Player).transform);
transform.position += transform.forward * speed * Time.deltaTime;
}
如果有足够多的机器人同时运行,那么速度可能会很慢。鲜为人知的事实:MonoBehaviour中的所有组件访问器,如transform、renderer和audio,都与对应的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, etc), Division, and Square Root 等所花费的时间大概是乘法的的100倍. (从大的方面来说,时间差很小,但是如果你每一帧调用它们数千次,就会增加时间).
最常见的情况是向量归一化,如果要反复对同一个向量进行归一化,可以考虑只对其进行一次归一化,然后缓存结果供以后使用。
如果你同时使用向量的长度和归一化它,通过向量乘以长度的倒数来得到归一化向量比使用 .normalized 属性更快
如果你在比较距离,你不需要比较实际距离. 你可以用距离的平方来比较 .sqrMagnitude属性并保存一两个平方根.
另一个,如果你一直除以常数c,你可以乘以倒数。先用1.0/c计算倒数
只是偶尔执行昂贵的操作,例如physic. raycast ()
如果你不得不做一些昂贵的事情,你可以通过减少这样做的次数和缓存结果来优化它。例如,考虑一个使用Raycast的投射脚本:
// Bullet.js
var speed = 5.0;
function FixedUpdate () {
var distanceThisFrame = speed * Time.fixedDeltaTime;
var hit : RaycastHit;
// every frame, we cast a ray forward from where we are to where we will be next frame
if(Physics.Raycast(transform.position, transform.forward, hit, distanceThisFrame)) {
// Do hit
} else {
transform.position += transform.forward * distanceThisFrame;
}
}
现在,我们可以用Update替换FixedUpdate,用deltaTime替换fixedDeltaTime,从而改进脚本. FixedUpdate指的是物理更新,它比帧更新更频繁. 但让我们更进一步,每n秒进行一次射线投射.n越小,时间分辨率越高,n越大,性能越好. . (延迟的出现,即玩家击中目标,但爆炸出现在n秒之前目标所在的地方,或玩家击中目标,但炮弹正好穿过).
// BulletOptimized.js
var speed = 5.0;
var interval = 0.4; // this is 'n', in seconds.
private var begin : Vector3;
private var timer = 0.0;
private var hasHit = false;
private var timeTillImpact = 0.0;
private var hit : RaycastHit;
// set up initial interval
function Start () {
begin = transform.position;
timer = interval+1;
}
function Update () {
// don't allow an interval smaller than the frame.
var usedInterval = interval;
if(Time.deltaTime > usedInterval) usedInterval = Time.deltaTime;
// every interval, we cast a ray forward from where we were at the start of this interval
// to where we will be at the start of the next interval
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;
// after the Raycast hit something, wait until the bullet has traveled
// about as far as the ray traveled to do the actual hit
if(hasHit && timer > timeTillImpact) {
// Do hit
} else {
transform.position += transform.forward * speed * Time.deltaTime;
}
}
最小化内部循环中的callstack开销
只是调用一个函数本身就有一点开销. 如果你想一帧调用上千次 x = Mathf.Abs(x),这样做可能更好 x = (x > 0 ? x : -x);
优化物理性能
Unity使用的NVIDIA PhysX物理引擎可以在移动设备上使用,但是硬件的性能限制在移动平台上比在台式机上更容易达到。
这里有一些调整物理性能以获得更好的手机性能的提示:-
- 增加 Fixed Timestep的时间,能够减少CPU的开销,但同时也牺牲了物理的准确行,通常情况下,较低的精度是可接受的折衷,以提高速度。
- 设置Time窗口的 Maximum Allowed Timestep 在8-10fps的范围,以限制在最坏的情况下花在物理上的时间。
- Mesh colliders 比较昂贵,通常在一个复杂的物体下挂载多个子物体来近似表示网格 ,子碰撞器将作为一个单一的复合碰撞器由父碰撞器上的刚体共同控制。
- wheel colliders 有很高的CPU开销