性能优化篇之脚本策略

这是我在《Unity游戏优化 (第2版)》看的,记录一下~

写Mono脚本的时候需要注意哪些问题呢?

大概从以下几个方面介绍:
1.访问组件
获取组件有三种方式

GetComponent(string)
GetComponent()
GetComponent(typeof(T))

其中:
泛型的最快,应该优先用这个
字符串的最慢,如非必要情况,绝对不要使用这个

另外:
需要经常访问的组件尽可能地来缓存
每次都会节省一些CPU开销,代价是少量内存消耗


2.函数回调
Unity最常用的回调是:Awake()、Start()、Update()、FixedUpdate()

Unity是如何调用这些函数的呢?
在场景中第一次实例化时,Unity会将任何定义好的回调函数添加到一个函数指针列表中,会在对应关键时刻调用这个列表
即使函数体是空的,也会挂接到这些回调中
如果有一堆空的函数分散的定义在整个代码库中,引擎会有少量的开销,浪费销量的CPU

导致的问题:
1.空的 Start() 定义会 导致对象的初始化变慢
当场景里有成千上万个对象时,只要通过 Object.instantiate()创建时,就会浪费CPU时间
2.空的Update() 也会导致每帧浪费大量的CPU周期,会严重影响帧率

对于Update() 函数,尽量避免一些不必要的操作:
1.反复计算很少或从不改变的值
2.太多的组件计算一个可以共享的结果
3.执行工作的频率远超必要值

最好一般都自己实现更新类:
1.避免了Unity的本机-托管桥接的高成本
2.可以自己控制更新的频率、更新的顺序等
3.可以实现暂停、冷却时间等等操作效果

有时候会在 Update() 里面调用以超出需要的频率重复调用某段代码

void Update()
{
    TaskA();
}

如果任务完成的频率低于没有明显缺陷的每一帧,那么可以通过减少调用频率来进行性能优化

float _delay = 0.2f;
float _timer = 0f;

void Update()
{
    _timer += Time.deltaTime;
    if( _timer > _delay )
    {
        TaskA();
        _timer = 0f;
    }
}

同样也可以使用 协程 来解决


3.协程

使用协程代替 Update计时器的
优点:
1.只调用指定的次数,在此之前一直处于空闲状态,从而减少对大多数帧的性能影响
缺点:
1.启动携程会带来额外开销(大概函数调用的三倍)
2.额外分配一些内存,将当前状态存储在内存中,直到下一次调用它
3.每次yield调用都会造成相同的开销成本

注意:
1.协程的运行独立于Mono组件中的Update回调,无论组件是否禁用,都将继续调用协程
2.协程在它 GameObject active=false 时候自动停止(无论使它还是它父节点),再次被设置为 true 时也不重新启动
3.协程很难调试,在栈上也不会有调用者

额外:
同样 InvokeRepeating() 也可以实现定时功能
其建立更简单,开销成本更小(小一丢丢吧)

InvokeRepearting("ProcessAI", 0f, _delay);

其完全独立于 Mono 和 GameObject 的状态的
停止的方法只有两个:
第一个是调用 CalcelInvoke()
第二个是直接销毁关联的 MonoBehaviour 或者 Gameobject


4.GameObject 和 Transform 的使用

a.检查Gameobject是空的几种方式

if(go == null) {}

if( !System.Object.ReferenceEquals( go, null) {}

后者的速度大约是前者的两倍
这个方法不仅适用于GameObject还适用于其他对象

b.避免从GameObject中取出字符串属性
从对象中检索字符串属性与检索C#中任何其他的引用类型属性是相同的,不增加额外内存成本。
但是从GameObject中取出字符串属性是另一种意外跨越 本机-托管桥接 的微妙方式

受到影响的属性为 :name 和 tag
eg:下面的代码会在每次迭代中导致额外分配内存:

for(int i=0;i < count; i++)
    if(go.tag === "Player")
        //do something

Unity为其提供了一个 CompareTag() 方法,其完全避免了本机-托管的桥接

结果:
.tag会产生大量的GC,并且耗时也是其两倍
CompareTag()是推荐用的方法

name没有对应的比较方法,最好使用tag区分

c.避免修改Transform的父节点
在Unity早期版本中,Transform组件的引用通常是在内存中随机排列的
也就是在多个Transform迭代是相当慢的,会存在缓存丢失的可能

为啥这样做?
修改GameObject的父节点为另一个对象不会造成显著的性能下降,因为Transform操作起来像堆数据结构,插入和删除的速度相对较快

但是在Unity5.4以后,Transform组件的内存布局发生了很大的变化
Transform组件的父子关系操作起来更像动态数组,Unity尝试将所有共享相同的父元素 Transform 按顺序存储在预先分配的内存缓冲区中的内存中,并在 Hierarchy 窗口下的深度进行排序
这样做的好处就是可以进行更快的迭代,尤其是物理和动画系统。

也有很大的缺点:
1.如果将一个 GameObject 的父对象重新指定为另一个对象,父对象必须将新的子对象放入预先分配的内存缓冲区中,并根据新的深度对所有这些 Transform 排序
2.如果没有预先分配足够的空间来容纳新的子对象,就必须扩展缓冲区,以便以深度优先的顺序容纳新的子对象

通过 Object.Instantiate() 实例化新的 GameObject 时,其默认的父节点是 null,都放在 Hierarchy的根元素下
这里的所有元素同样也需要分配一个缓冲区来存储他当前的元素以及以后可能添加的元素
如果需要立即将Transform的元素重新修改为另一个元素,刚才的缓冲区就白分配了
尽可能使用参数,跳过缓冲区分配的步骤

d.缓存Transform的变化
Transform组件只存储与其父组件相关的数据
访问或者修改 Transform 组件的 position、rotation、scale 都会导致大量未预料到的矩阵乘法计算,从而通过其父节点的Transform 为对象生成正确的Transform
对象在 Hireachy 窗口越深,确定最终结果需要计算的就越多
(修改的同时也会向某些组件发信息[Collider、Rigidbody、Light、Camera等],因为这些组件也需要知道Transform的新值,并相应地更新)

如果使用localPostion、localRotation、localScale的相对成本就比较小
因为其直接存储在给定的 Transform 中,不需要任何的矩阵乘法

e.不要在同一帧中多次替换Transform的属性
每次赋值都会触发内部消息,其实这是完全没有必要的
只要存储一个内存成员变量,在这一帧的最后调用一次即可


5.对象间通信

避免在运行时调用 Find() 和 SendMessage() 方法
其代价非常昂贵,应该不惜一切代价避免使用
Find() 偶尔在游戏初始化的时候调用一次

可以通过自定义的事件系统来实现(这里就不说了)


6.数学计算
共享计算输出,让多个对象共享某些计算的结构
(比如:从文件读取数据,解析数据,AI寻路等等)
只计算一次结果,然后将结果分发给需要它的每个对象,以最小化重新计算的量


7.场景和预制体加载等反序列化
Untiy的序列化主要用于场景、预制件、ScriptObjects和各种资产类型(派生自ScriptObject)。
当其中一种对象类型保存到磁盘时,就是用 YAML (Yet Another markup Language,另一种标记语言)格式将其转换为文本文件,稍后可以将其反序列化成原始对象类型

当构建好了应用程序时,这些序列化的数据会捆绑在大型二进制数文件中,这些文件在Unity中被称为序列化文件
运行时从磁盘读取和反序列化数据是一个非常慢的过程,因此所有反序列化活动都有一定的性能成本

一般发生在资源加载时候(Resources.Load()),一旦数据从磁盘加载到内存中,以后重新加载相同的引用会快很多,但是在第一次访问的时候总是需要磁盘活动。

注意:
如果需要反序列化的数据集越大,此过程所需的时间就越长
预制件的每个组件都是序列化的,因此层次结构越深,需要反序列化的数据就越多(空的也算)

加载方式:
1.第一次加载很多,会造成CPU的显著峰值,会增加加载时间
2需要时候再加载,可能会掉帧

如何减小反序列化的成本呢?

a.减小序列化对象
可以把一个大的预制件分割成更小的几个预制件,然后一块一块的组合在一起

b.异步加载序列化对象
可以把从磁盘读取的任务转移到工作线程上,从而减轻主线程的负担

c.在内存中保存之前加载的序列化对象
一旦加载到内存中,复制下来,下一次加载就会很快
缺点就是需要大量的内存,是一种风险策略

d.将公共数据转移出去
如果很多预制件都有一些共享数据,可以提取到一个配置文件中,然后再加载使用它。

对于场景的资源的优化:
叠加、异步加载场景
1.同步加载场景,主线程将阻塞,直到指定的场景加载完成,用户体验非常糟糕
2.使用异步加载可以有效缓解,同时让玩家继续操作
3.也可以叠加逐渐加载(LoadSceneMode.Additive),让场景逐渐加载出来,并且不会对用户体验造成明显影响


8.使用合适的数据结构
常见的性能问题就是:
为了简单,但是用了不适当的数据结构来解决问题

a.如果希望遍历一组对象,最好使用列表(List)
其是动态数组,对象在内存中彼此相邻,迭代导致的缓存丢失最小
b.如果希望快速获取、插入或者删除,最好使用字典
其能通过hash快速映射?

如果有希望快速找出对象,同时还能遍历组应该咋办呢?
a.如果使用字典,然后再对它进行遍历
遍历的过程将会非常慢,因为必须检查字典中每个可能的散列,才能对其进行完全遍历
b.最好使用额外内存开销 维护一个字典和一个列表
虽然在插入删除有些麻烦,但在迭代的时候会产生明显的优势


9.禁用未使用的脚本和对象
如果场景特别大的话,Update() 回调越多 游戏性能也就越不差
(除了一些特别仿真的游戏)

a.通过可见性禁用对象
也就是在相机视图不可见的对象
Unity有一些选项可以做到(比如Animator的Culling Mode),但是仅仅只是渲染层的优化,不会影响CPU上执行人物的组件

那咋整呢?
可以通过 OnBecameVisible() 和 OnBecameInvisible() 回调
触发的前提就是必须与渲染管线通信,也就是在自身需要加一个可渲染的组件(MeshRenderer 或 SkinnedmeshRenderer
)

b.通过距离禁用对象
一般离玩家足够远的话, 玩家并不关注他们,此时可能希望禁用他们
只需自己判断距离即可

注意:
判断距离使用平方不是距离
CPU比较擅长将浮点数相乘,不擅长计算他们的平方根
如果使用 .magnitude 或者 Distance() 会造成大量的 CPU开销
可以使用sqrMagnitude属性代替。

你可能感兴趣的:(性能优化篇之脚本策略)