一. Disable unused scripts and objects
场景中激活的物体或者脚本越多,开销越大。对于很多并没有产生作用的脚本和物体,可以隐藏掉从而提升性能,比如FPS游戏中视野外的部分。
1.Disabling objects by visibility
有时我们希望脚本和物体在不可见的时候能处于disabled状态。Unity在内部渲染时提供了Frustum Culling技术来裁剪摄像机视野外的部分,也提供了Occlusion culling来剔除被遮挡的部分。但是这两个技术只是渲染上的优化,并没有影响到物体上运行在cpu的脚本,比如AI脚本逻辑脚本都,都依然会运行产生开销,我们需要自己控制这些行为。
一个不错的解决方法是使用OnBecameVisibe()和OnBecameInvisible()函数。这俩个函数会在renderable物体变看见或者不可见时被调用,对于多个相机的场景,当物体被任意一个相机可见,就会调用OnBecameVisible;当物体在所有相机都不可见时,会调用OnBecameInvisible。
要注意的是这俩个函数必须和渲染管线打交道,所以必须要有个renderable component在物体上,比如MeshRender。
实现脚本的关启或者整个Gameobject是否enable的代码可以如下:
要注意的一点是对于隐藏掉的gameobject,就无法再被becameInvisible调用,所以一种解决方法是我们需要将脚本放在子物体上,将renderable 物体一直保持visible。
2.Disabling objects by distance
有时我们希望对于距离玩家足够远的脚本或物体变成disabled状态,一个很好的例子是AI巡逻功能,当离玩家很远时,可以保持Idle状态不进行AI处理。下边的代码是一个简单的示例:
3.Consider using distancesquared over distance
CPU计算开放运算的开销要远大于乘法,当调用Distance函数或者Vector3的magnitude函数时,都会进行开方运算。Vector3还提供了sqrMagnitude属性,这个数值是未开方的,这意味着使用它进行一些距离的判断在大多数情况下会得到同样的结果,但是开销却小很多。举个例子:
在绝大多数情况下,使用平方进行距离的判断会得到同样正确的结果,但是当需要精度极高时,会产生误差,因为使用平方会减少精度。
对于除了distance 外其他的开方操作,这个技巧同样适用,sqrMagnitude property就是Unity为我们提供的使用这个技巧的一个方式。
二. Minimize Deserialization behavior
Unity的序列化系统主要用于场景,prefabs,ScriptableObjects和各种各样的Asset类型。当这些object 类型被保存到硬盘中时,会序列化成YAML格式的文件,YAML可以在之后被反序列化回原始的类型。
当一个prefab或者场景被序列化时,它所有的gameobjects和脚本都将被序列化,包括private和protected类型的字段,所有的子物体及子物体上的脚本。
当应用被构建时,这些序列化的数据会被一起打包成一个大的二进制文件,从disk读取和反序列化这些数据相当慢,开销很大,会造成明显的性能开销。
当我们调用Resources.Load时,就会产生反序列化操作,一旦数据从disk加载到内存后,再次加载这个引用的数据就会很快。数据越大,加载越慢,比如UI prefab上层级结构越复杂,就会开销越大。第一次加载大的序列化数据时有可能产生很大的CPU开销,导致掉帧,接下来介绍几种可以降低这种反序列化方法的开销。
1.Reduce serialized object size
尽量减小序列化物体的大小,或者将它们拆分成更小的单元,使得它们能以更小的单元加载。Unity不支持嵌套prefab(最新的已经支持),UI prefabs是很好的优化对象,因为我们大多数情况下,在某一时刻我们并不需要整个UI,可以每次加载一些所需的。
2.Load serialized objects asynchronously
prefabs和其他序列化数据可以通过Resources.LoadAsync方法进行异步加载,这将会减轻主线程的负担。使用异步方法时,需要一些时间来处理,使得序列化object变成可用状态。
这种方式对于游戏一开始就立刻需要的prefabs不太合适,但是之后所有的prefabs都是很好的异步加载候选对象。
3.Keep previously loaded serialized objects in memory
一旦序列化object被载入到内存后,将会一直保持在内存中,可以通过instantiating复制更多的prefab。通过Resources.Unload,将会释放掉序列化object所占用的内存空间。
如果我们游戏的内存预算还很充足,可以考虑将序列化Object常驻内存中,这样可以在需要使用时不必每次都要从硬盘中读取,减少读取数据所带来的时间损耗,但是这种方案也给内存管理带来了风险,随着序列化数据的越来越多,所占用的内存将会越来越多,所以我们应该具体情况具体分析,在有需要的时候使用这个方式。
4.Move common data into ScriptableObjects
如果我们有很多不同的prefab,但是都带有包含了很多共享数据的脚本,比如游戏策划使用的数值比如速度,力量等,这些数据都将会被序列化到每一个使用它们的prefab中。对于这种,可以考虑将共享的数据通过ScriptableObject序列化成一个通用的数据,这将减少序列化数据的量以及可以明显的缩短加载的时间。
5.Load scenes additively and asynchronously
加载场景可以使用替换掉当前场景的方式,也可以使用增量加载的方式加载新增的内容到当前场景中,不卸载之前的场景。可以通过勾选SceneManager.LoadScene中的LoadSceneMode来启用这个功能。
加载场景还可以选择是异步加载还是同步加载,通常最好的方式是两种混合使用。
通过SceneManager.LoadScene可以进行同步加载,同步加载将会阻塞主线程直到所需的场景完全加载完毕。这通常使得用户体验很差。同步加载最好用在我们想让用户尽快操作或者没有时间去等待场景物体出现时,这通常被用在加载游戏的第一个场景或者返回到主菜单时。
通过SceneManager.LoadSceneAsync进行异步加载,可以让场景的加载在背后偷偷进行,用户没有明显的感知,可以有效提升用户体验。
值得注意的是场景并不等同于游戏的关卡,在大多数游戏中,玩家在某一时刻只在一个关卡中,但是Unity可以通过增量加载的方式支持多个场景同时被加载,每个场景只是关卡中的一部分。例如,我们可以在刚开始时加载第一个场景Scene-1-1a,当玩家接近下一个区域时,异步增量加载下一个场景Scene-1-1b,当玩家在关卡中游玩时重复这个操作。
想实现这个功能需要一套可以实时检查关卡中player位置的系统,当playe接近下个区域时,异步增量加载下个场景,但要注意的是异步加载需要一部分时间来处理,也就是下个场景中的物体需要一些帧数后才加载好,因此一定要保证在触发加载时有足够的时间提前量来让异步加载完成,来避免用户看到物体是突然出现在场景中的。
场景可以通过卸载来释放内存。卸载同样也可以有两种方式,同步卸载和异步卸载。卸载时要注意对于大场景,如果进行卸载就会卸载全部物体,如果想分部卸载,需要将原始场景切分成多个小场景。卸载时还要注意确保玩家确实看不到该场景的所有内容,否则玩家会看到物体突然消失的这种现象。另一个要注意的是卸载场景时会销毁物体释放很多内存,有可能触发GC,因此也需要对内存进行高效的管理来满足多场景加载的这种方案。
这种方案需要很强的场景设计,代码编写,测试等工作,但是对于用户体验的提升也是很显著的,平滑的场景区域过渡常常能收到用户和鉴赏家的赞赏,如果方案能使用得当,还可以大大提高运行时的性能表现,更加提升了用户体验。
三. Create a custom Update() layer
假设上千个MonoBehaviour脚本在场景一开始一起进行初始化,同时各自启动一个Coroutine来处理耗时500ms的AI运算任务,它们会在同一帧中触发,这很有可能产生CPU一个瞬时的巨大开销,随后cpu的开销会降低,等到下个AI运算循环时又会产生极大的CPU瞬时开销。
有三种解决方法:
(1)每次生成随机的时间去等待Coroutine触发
(2)将Coroutine的初始化时间点分散开来,来使得每一帧只有部分在处理
(3)用God Class来进行控制,将调用Update的责任丢给God Class,来限制每帧最多被调用的数量。
前两个方法非常有吸引力是因为非常简单,但是这些方法会有不少隐患和副作用。
最好的方式就是根本不要用Update,或者准确的说只用一次。Unity调用Update时会有很多副作用,它需要之前提到过的Native-Managed Bridge来完成,因此开销会比普通函数大很多,大概是1000倍。因此我们应该尽可能减少调用Native-Managed Bridge,自定义Update系统来替代调用Unity的Upate。
实际上很多Unity开发者很喜欢在项目之初就设计使用它们自己的Update系统,这可以让他们控制Update何时在系统中传播,控制菜单暂停,控制重要tasks的优先级等,比如发现在当前帧中cpu消耗超过了预算,可以将低优先级的任务在之后帧中再运行。
接下来我们实现一个简易的Update 系统:
1.IUpdateable接口,规定了实现该接口的类需要定义OnUpdate函数
2.UpdateableComponet,继承Monobehaviour以及IUpdateable接口,定义OnUpdate的virtural方法,以便继承类可以自定义实现该方法。
2.
3.注册Update系统
4.定义Initialize virtual函数,为继承类提供初始化的功能,避免继承类覆盖Start函数
5.用单例模式实现GameLogic Update系统
如果场景中有n个继承于UpdateableComponent的类,通过我们自定义的Update系统,可以将调用Native-Managed Bridge的次数从n次减少为1次,效率将大大提升。这个系统还可以扩展成提供优先级功能的系统,以及加入其他更多功能。
对于已经开发比较久的项目,加入自定义的Update系统会是一件复杂耗时的工作,但是带来的好处也是大大的,我们可以自己评估花些时间来进行这部分改造是否值得。
四. Summary
这一章提供了许多Unity中关于提升编码实践的方法,来提高性能。但是其中的一些技巧并不是什么时候都适用,有些时候工作流的顺畅与性能和设计同样重要,所以在决定使用哪些优化方法前,需要先思考这些牺牲是否值得