一、前言
做游戏经验比较丰富的人都知道,优化的好坏一直是一个游戏的评判标准之一,它直接影响着玩家们的游戏体验,优化一直是项目中开发周期比较长的一个点,也是开发者头疼的一个问题,要求掌握的知识点比较全面,经验也要求比较丰富。 这篇文章参考很多文章的知识点,加以总结与学习,从最基础的概念讲起,配合讲解各种优化技巧,希望大家可以在我的文章中学到一些东西。
二、正文
2-1、CPU优化
2-1-1、DrawCall优化
DrawCall一直都是老生常谈的问题了,为什么总是这个东西在消耗资源呢,这是个什么东西呢
(一)什么是DrawCall?
Drawcall是CPU向GPU发送绘制命令的接口调用。理论上每一个不同材质的物件需要渲染在屏幕上时,CPU都会调用图形API ( openGL or Diract3D ) 的Draw接口触发显卡进行绘制。
(二) 为什么优化Drawcall?
Drawcall对硬件和驱动而言,要求大量设置状态(使用哪些顶点、哪些shader等)和状态转换。而Drawcall最大的消耗在于:如果每次drawcall只提交少量的数据将导致CPU瓶颈,CPU无法将GPU填满。Drawcall对GPU的耗费在于硬件一直等待CPU提交数据,而无法得到有效利用。GPU大量的时间耗费在不断切换状态和正确性检测上。 GPU在Draw Call之间,为了防止前后Draw的依赖关系造成绘制错误或者资源竞用,一般会在Draw Call后Flush整个流水线,小粒度的Draw Call对GPU流水线来说是个很大的浪费。(这个问题在D3D老版本存在,在新版D3D11中得到改善。)实际上unity官方指出,Drawcall数量的降低并非重点,重点是减少批次的数量,Drawcall优化实际上是对批次数量的优化。
延伸阅读:
· why are draw calls expensive? — Stack Overflow
(三) 如何优化Drawcall?
在Unity中对Drawcall的优化有以下几个策略:Drawcall batching,合并打包图集,减少光照和阴影以及遮挡剔除和视锥剔除等。以下分别谈一下各个策略的优缺点。
Drawcall Batching
- 静态批次 Drawcall static batching
场景中的多个物件如果是不移动的(包括位置、缩放、旋转等),并且共享同一材质,比如地形、建筑、花盆等,那么可以选择采用静态批次。静态批次只需要在Inspector勾选static选项即可。静态批次需要注意的是,unity会将进行批次的多个对象合并成一个大的对象,也会导致内存损耗,有时候要避免太多对象静态批次造成的内存过高。这也表明,优化并非绝对做好某一方面,而是平衡各个硬件的瓶颈和效率,选择相对适中的方案。
- 动态批次 Drawcall dynamic batching
动态批次是运动的物件在unity中也可以进行批次渲染,动态批次不需要手动设置,是unity自动进行的,但是这里有诸多陷阱和约束,开发者需要遵守一定的限制条件才能享受动态批次的好处。
- 动态批次是逐顶点处理的,因此仅对少于900个顶点的mesh有效。如果shader使用了顶点位置,法线和UV那么仅支持低于300顶点的mesh,而如果shader使用了顶点位置,法线、UV0、UV1和切向量,则之多仅支持180顶点。
- 缩放对于批次是有影响的,这里涉及到一个统一缩放和非统一缩放的概念。统一缩放即为三轴同比例缩放,比如(1,1,1),(2,2,2)(5,5,5)... 非统一缩放即为三轴不同比例缩放,如(1,2,1)(2,1,1)(1,2,3)等等。
Unity对统一缩放的对象是不进行动态批次的,而对非同一缩放的对象是可以进行动态批次的。这里有点诡异,查阅了一些资料,解释如下:
对于非同一缩放的物件,unity将其mesh进行了复制,因此即便是从相同物件进行的非同一缩放的两个对象是两份mesh;对统一缩放的对象来说,unity不对mesh进行复制,而是使用同一mesh进行缩放,此时复制mesh来进行批次渲染是不值得的,但是对于非统一缩放的对象,既然已经复制了mesh(不是为了批次,而是其他原因决定复制mesh),那么进行批次是顺带实现的。
很多时候分辨率也是造成性能下降的原因,尤其是现在很多国内山寨机,除了分辨率高其他硬件简直一塌糊涂,而这恰恰中了游戏性能的两个瓶颈:过大的屏幕分辨率+糟糕的GPU。因此,我们可能需要对于特定机器进行分辨率的放缩。当然,这样会造成游戏效果的下降,但性能和画面之间永远是个需要权衡的话题。 在Unity中设置屏幕分辨率可以直接调用Screen.SetResolution。
(参考 Dynamic Batching and Scale ——unity3d answers )
- 使用了不同的材质,即便实质上是相同的(比如两个一模一样的材质),也不会进行批次。
- 拥有lightmap的物件含有额外的材质属性,比如lightmap偏移和缩放系数等,所以拥有lightmap的物件不能批次。
- 多通道的shader会妨碍批处理操作,接受实时阴影的物件无法批次。
- unity渲染是有顺序的,渲染排序有可能打断动态批次。
渲染顺序跟什么有关呢?
首先根据物件到摄像机的距离,进行远处物件先渲染近处物件后渲染。相同材质的物件尽量在一层,不要让不同材质的物件进入这一层。如果无法保证这一点,那么还有一种方法:修改shader中渲染队列值。即打开shader 将subshader中的tag{}中queue 修改为小于2500的值。
渲染队列小于等于2500时,unity认为其是不透明的,对于不同材质但z值相同对象,unity不对其进行排序,这样能保证相同材质的多个对象能是一个批次,不同材质的对象如果进入两个相同材质的对象之间,不会打破批次;
渲染队列大于2500时,unity会对不同材质的对象进行排序,此时如果不同材质的对象进入到两个相同材质的对象之间的话,会使相同材质的对象批次被打破。
批次先写到这,其实很多网上都有,不过有些没深入讲解,也有些没给出解决办法,我就使用每个方案时遇到的困难给出了自己的解决方案。其实批次还有不少研究的地方,之后想到了会继续更新。
如何控制渲染顺序?
需要控制绘制顺序,主要原因是为了最大限度的避免overdraws,也就是同一个位置的像素可以需要被绘制多变。在PC上,资源无限,为了得到最准确的渲染结果,绘制顺序可能是从后往前绘制不透明物体,然后再绘制透明物体进行混合。但在移动平台上,这种会造成大量overdraw的方式显然是不适合的,我们应该尽量从前往后绘制。从前往后绘制之所以可以减少overdraw,都是因为深度检验的功劳。
在Unity中,那些Shader中被设置为“Geometry” 队列的对象总是从前往后绘制的,而其他固定队列(如“Transparent”“Overla”等)的物体,则都是从后往前绘制的。这意味这,我们可以尽量把物体的队列设置为“Geometry” 。
而且,我们还可以充分利用Unity的队列来控制绘制顺序。例如,对于天空盒子来说,它几乎覆盖了所有的像素,而且我们知道它永远会在所有物体的后面,因此它的队列可以设置为“Geometry+1”。这样,就可以保证不会因为它而造成overdraws。
延伸阅读:
2-1-2、合并图集
其实合并图集也是利用了Unity的Drawcall batching。将多个纹理进行打包成图集是为了减少材质,这样多个对象共享一个材质,并进而使用同一个纹理和shader,触发unity的动态批次。图集打包工具有很多,Asset store中也可以搜到不少,比如Texture Packer Free 、 DrawCall Optimizer(收费) Mesh Baker Free 等等都可以将贴图打包合并。
但是合并图集也有缺点,合并贴图时应该注意选择同时出现在屏幕的对象贴图进行合并。如果不能做到这一点,那么合并图集可能起到反作用,即渲染一个对象需要加载过多无用贴图,造成内存占用率升高。我的项目这个方案也是采用之后又弃用的,因为归类同时出现在屏幕的贴图并非易事!
2-1-3、光照和阴影
实时光照和阴影可能增加Drawcall,带有光源计算的shader材质会因为光照产生多个Drawcall。使用灯光会打断Drawcall batching,尽量使用烘焙灯光贴图等技巧来实现灯光效果。
2-1-4、时刻警惕透明物体
而对于透明对象,由于它本身的特性(可以看之前关于Alpha Test和Alpha Blending的一篇文章)决定如果要得到正确的渲染效果,就必须从后往前渲染(这里不讨论使用深度的方法),而且抛弃了深度检验。这意味着,透明物体几乎一定会造成overdraws。如果我们不注意这一点,在一些机器上可能会造成严重的性能下面。例如,对于GUI对象来说,它们大多被设置成了半透明,如果屏幕中GUI占据的比例太多,而主摄像机又没有进行调整而是投影整个屏幕,那么GUI就会造成屏幕的大量overdraws。 因此,如果场景中大面积的透明对象,或者有很多层覆盖的多层透明对象(即便它们每个的面积可以都不大),或者是透明的粒子效果,在移动设备上也会造成大量的overdraws。这是应该尽量避免的。 对于上述GUI的这种情况,我们可以尽量减少窗口中GUI所占的面积。如果实在无能为力,我们可以把GUI绘制和三维场景的绘制交给不同的摄像机,而其中负责三维场景的摄像机的视角范围尽量不要和GUI重叠。对于其他情况,只能说,尽可能少用。当然这样会对游戏的美观度产生一定影响,因此我们可以在代码中对机器的性能进行判断,例如首先关闭所有的耗费性能的功能,如果发现这个机器表现非常良好,再尝试开启一些特效功能。
延伸阅读:
· Forward Rendering Path Details
· Light Troubleshooting and Performance
2-1-5、算法优化
- 降低算法的复杂度,了解常见的数据结构和算法
- 使用协程来避免多余的更新计算。
- 缓存耗时计算的中间值
- 缓存获取过的Componenet
- IMGUI只用与测试,发布时应该屏蔽
2-1-6、物理算法优化
- 尽可能少用刚体,不重要的物体可以自己写简易的模拟物理
- 尽可能减少直接的模型网格碰撞,应使用包围盒简易碰撞体替代
- 如果可以的话,增加fixedUpdate的运行间隔
- 使用高效的射线检测算法,并避免产生GC
2-1-7、物理效果优化
- 镜头
Clipping Planes Occlusion Culling 默认勾上了。但是没有任何效果 打开Window-Occlusion Culling 需要bake 一下 需要bake的东西,必须是Static Smallers Occluder 挡住后面的东西,优化做不好,就可能是负优化 在Scene场景,选中摄像机,可以设置Occlusion Culling是Edit或者Visualize。这个时候随着镜头的移动,镜头中的物体就会动态的显示了。 从屏幕上看到的点,都不会剔除掉的。 Unity 3专业版内置了一个强大的 Occlusion Culling 插件 Umbra免费的
- 光照
Bake and Probes 我们的目标就是降低:SetPass Calls Light 选则Bake光,使用静态光。出现色差 设置:Scenes In Build Player Setting 找到Color Space 有 Linear(不支持移动端) Gamma 缺点:移动的物体不会受到光照的影响。这个时候就需要创建光照探针了。Light-Light Probe Group.记录静态光照的效果。
- 碰撞
Collider尽可能简单 控制rigidbody数量 Rigidbody检查方式,检测间隔,Collision Detection 持续的。离散型的。
- CheckList
Simple checklist to make your game faster 对于PC建筑(取决于目标GPU)时,请记住下面的200K和3M顶点数每帧。 如果你使用内置着色器,从挑选的那些移动或熄灭类别。他们在非移动平台以及工作,但更复杂的着色器的简化和近似版本。
保持每个场景低的不同材料的数量,并共享不同的对象尽可能之间尽可能多的材料。 将Static非运动物体的属性,以允许像内部优化静态批次。 只有一个(最好是定向)pixel light影响几何体,而不是整数倍。
烘烤照明,而不是使用动态照明。 尽可能使用压缩纹理格式,以及超过32位纹理使用16位纹理。 避免使用雾在可能的情况。 使用遮挡剔除,以减少可见的几何图形的量和抽取呼叫中的有很多闭塞复杂静态场景的情况。闭塞记扑杀设计你的水平。
使用包厢到“假”遥远的几何体。 使用像素着色器或纹理组合搭配,而不是多遍方法有几个纹理。 使用half精度变量在可能的情况。
尽量减少使用复杂的数学运算,如的pow,sin并cos在像素着色器。 使用每个片段较少纹理。
2-1-8、GC优化
在游戏运行的时候,数据主要存储在内存中,当游戏的数据在不需要的时候,存储当前数据的内存就可以被回收以再次使用。内存垃圾是指当前废弃数据所占用的内存,垃圾回收(GC)是指将废弃的内存重新回收再次使用的过程。
Unity中将垃圾回收当作内存管理的一部分,如果游戏中废弃数据占用内存较大,则游戏的性能会受到极大影响,此时垃圾回收会成为游戏性能的一大障碍点。
2-1-9、垃圾回收机制
下面我们主要学习垃圾回收的机制,垃圾回收如何被触发以及如何提GC收效率来提高游戏的性能。
Unity内存管理机制简介
要想了解垃圾回收如何工作以及何时被触发,我们首先需要了解unity的内存管理机制。Unity主要采用自动内存管理的机制,开发时在代码中不需要详细地告诉unity如何进行内存管理,unity内部自身会进行内存管理。这和使用C++开发需要随时管理内存相比,有一定的优势,当然带来的劣势就是需要随时关注内存的增长,不要让游戏在手机上跑“飞”了。
unity的自动内存管理可以理解为以下几个部分:
1)unity内部有两个内存管理池:堆内存和堆栈内存。堆栈内存(stack)主要用来存储较小的和短暂的数据,堆内存(heap)主要用来存储较大的和存储时间较长的数据。
2)unity中的变量只会在堆栈或者堆内存上进行内存分配,变量要么存储在堆栈内存上,要么处于堆内存上。
3)只要变量处于激活状态,则其占用的内存会被标记为使用状态,则该部分的内存处于被分配的状态。
4)一旦变量不再激活,则其所占用的内存不再需要,该部分内存可以被回收到内存池中被再次使用,这样的操作就是内存回收。处于堆栈上的内存回收及其快速,处于堆上的内存并不是及时回收的,此时其对应的内存依然会被标记为使用状态。
5) 垃圾回收主要是指堆上的内存分配和回收,unity中会定时对堆内存进行GC操作。
在了解了GC的过程后,下面详细了解堆内存和堆栈内存的分配和回收机制的差别。
堆栈内存分配和回收机制
堆栈上的内存分配和回收十分快捷简单,因为堆栈上只会存储短暂的或者较小的变量。内存分配和回收都会以一种顺序和大小可控制的形式进行。
堆栈的运行方式就像stack: 其本质只是一个数据的集合,数据的进出都以一种固定的方式运行。正是这种简洁性和固定性使得堆栈的操作十分快捷。当数据被存储在堆栈上的时候,只需要简单地在其后进行扩展。当数据失效的时候,只需要将其从堆栈上移除。
堆内存分配和回收机制
堆内存上的内存分配和存储相对而言更加复杂,主要是堆内存上可以存储短期较小的数据,也可以存储各种类型和大小的数据。其上的内存分配和回收顺序并不可控,可能会要求分配不同大小的内存单元来存储数据。
堆上的变量在存储的时候,主要分为以下几步:
1)首先,unity检测是否有足够的闲置内存单元用来存储数据,如果有,则分配对应大小的内存单元;
2)如果没有足够的存储单元,unity会触发垃圾回收来释放不再被使用的堆内存。这步操作是一步缓慢的操作,如果垃圾回收后有足够大小的内存单元,则进行内存分配。
3)如果垃圾回收后并没有足够的内存单元,则unity会扩展堆内存的大小,这步操作会很缓慢,然后分配对应大小的内存单元给变量。
堆内存的分配有可能会变得十分缓慢,特别是在需要垃圾回收和堆内存需要扩展的情况下,通常需要减少这样的操作次数。
垃圾回收时的操作
当堆内存上一个变量不再处于激活状态的时候,其所占用的内存并不会立刻被回收,不再使用的内存只会在GC的时候才会被回收。
每次运行GC的时候,主要进行下面的操作:
1)GC会检查堆内存上的每个存储变量;
2)对每个变量会检测其引用是否处于激活状态;
3)如果变量的引用不再处于激活状态,则会被标记为可回收;
4)被标记的变量会被移除,其所占有的内存会被回收到堆内存上。
GC操作是一个极其耗费的操作,堆内存上的变量或者引用越多则其运行的操作会更多,耗费的时间越长。
何时会触发垃圾回收
主要有三个操作会触发垃圾回收:
1) 在堆内存上进行内存分配操作而内存不够的时候都会触发垃圾回收来利用闲置的内存;
2) GC会自动的触发,不同平台运行频率不一样;
3) GC可以被强制执行。
特别是在堆内存上进行内存分配时内存单元不足够的时候,GC会被频繁触发,这就意味着频繁在堆内存上进行内存分配和回收会触发频繁的GC操作。
GC操作带来的问题
在了解GC在unity内存管理中的作用后,我们需要考虑其带来的问题。最明显的问题是GC操作会需要大量的时间来运行,如果堆内存上有大量的变量或者引用需要检查,则检查的操作会十分缓慢,这就会使得游戏运行缓慢。其次GC可能会在关键时候运行,例如在CPU处于游戏的性能运行关键时刻,此时任何一个额外的操作都可能会带来极大的影响,使得游戏帧率下降。
另外一个GC带来的问题是堆内存的碎片划。当一个内存单元从堆内存上分配出来,其大小取决于其存储的变量的大小。当该内存被回收到堆内存上的时候,有可能使得堆内存被分割成碎片化的单元。也就是说堆内存总体可以使用的内存单元较大,但是单独的内存单元较小,在下次内存分配的时候不能找到合适大小的存储单元,这也会触发GC操作或者堆内存扩展操作。
堆内存碎片会造成两个结果,一个是游戏占用的内存会越来越大,一个是GC会更加频繁地被触发。
分析GC带来的问题
GC操作带来的问题主要表现为帧率运行低,性能间歇中断或者降低。如果游戏有这样的表现,则首先需要打开unity中的profiler window来确定是否是GC造成。
了解如何运用profiler window,可以参考此处,如果游戏确实是由GC造成的,可以继续阅读下面的内容。
##8. 分析堆内存的分配
如果GC造成游戏的性能问题,我们需要知道游戏中的哪部分代码会造成GC,内存垃圾在变量不再激活的时候产生,所以首先我们需要知道堆内存上分配的是什么变量。
堆内存和堆栈内存分配的变量类型
在Unity中,值类型变量都在堆栈上进行内存分配,其他类型的变量都在堆内存上分配。如果你不知道值类型和引用类型的差别,可以查看此处。
下面的代码可以用来理解值类型的分配和释放,其对应的变量在函数调用完后会立即回收:
void ExampleFunciton() { int localInt = 5; } 复制代码
对应的引用类型的参考代码如下,其对应的变量在GC的时候才回收:
void ExampleFunction() { List localList = new List(); } 复制代码
利用profiler window 来检测堆内存分配
我们可以在profier window中检查堆内存的分配操作:在CPU usage分析窗口中,我们可以检测任何一帧cpu的内存分配情况。其中一个选项是GC Alloc,通过分析其来定位是什么函数造成大量的堆内存分配操作。一旦定位该函数,我们就可以分析解决其造成问题的原因从而减少内存垃圾的产生。现在Unity5.5的版本,还提供了deep profiler的方式深度分析GC垃圾的产生。
降低GC的影响的方法
大体上来说,我们可以通过三种方法来降低GC的影响:
1)减少GC的运行次数;
2)减少单次GC的运行时间;
3)将GC的运行时间延迟,避免在关键时候触发,比如可以在场景加载的时候调用GC
似乎看起来很简单,基于此,我们可以采用三种策略:
1)对游戏进行重构,减少堆内存的分配和引用的分配。更少的变量和引用会减少GC操作中的检测个数从而提高GC的运行效率。
2)降低堆内存分配和回收的频率,尤其是在关键时刻。也就是说更少的事件触发GC操作,同时也降低堆内存的碎片化。
3)我们可以试着测量GC和堆内存扩展的时间,使其按照可预测的顺序执行。当然这样操作的难度极大,但是这会大大降低GC的影响。
减少内存垃圾的数量
减少内存垃圾主要可以通过一些方法来减少:
如果在代码中反复调用某些造成堆内存分配的函数但是其返回结果并没有使用,这就会造成不必要的内存垃圾,我们可以缓存这些变量来重复利用,这就是缓存。
例如下面的代码每次调用的时候就会造成堆内存分配,主要是每次都会分配一个新的数组:
void OnTriggerEnter(Collider other) { Renderer[] allRenderers = FindObjectsOfType<Renderer>(); ExampleFunction(allRenderers); } 复制代码
对比下面的代码,只会生产一个数组用来缓存数据,实现反复利用而不需要造成更多的内存垃圾:
private Renderer[] allRenderers; void Start() { allRenderers = FindObjectsOfType<Renderer>(); } void OnTriggerEnter(Collider other) { ExampleFunction(allRenderers); } 复制代码
不要在频繁调用的函数中反复进行堆内存分配
在MonoBehaviour中,如果我们需要进行堆内存分配,最坏的情况就是在其反复调用的函数中进行堆内存分配,例如Update()和LateUpdate()函数这种每帧都调用的函数,这会造成大量的内存垃圾。我们可以考虑在Start()或者Awake()函数中进行内存分配,这样可以减少内存垃圾。
下面的例子中,update函数会多次触发内存垃圾的产生:
void Update() { ExampleGarbageGenerationFunction(transform.position.x); } 复制代码
通过一个简单的改变,我们可以确保每次在x改变的时候才触发函数调用,这样避免每帧都进行堆内存分配:
private float previousTransformPositionX; void Update() { float transformPositionX = transform.position.x; if(transfromPositionX != previousTransformPositionX) { ExampleGarbageGenerationFunction(transformPositionX); previousTransformPositionX = trasnformPositionX; } } 复制代码
另外的一种方法是在update中采用计时器,特别是在运行有规律但是不需要每帧都运行的代码中,例如:
void Update() { ExampleGarbageGeneratiingFunction() } 复制代码
通过添加一个计时器,我们可以确保每隔1s才触发该函数一次:
private float timeSinceLastCalled; private float delay = 1f; void Update() { timSinceLastCalled += Time.deltaTime; if(timeSinceLastCalled > delay) { ExampleGarbageGenerationFunction(); timeSinceLastCalled = 0f; } } 复制代码
通过这样细小的改变,我们可以使得代码运行的更快同时减少内存垃圾的产生。
附: 不要忽略这一个方法,在最近的项目性能优化中,我经常采用这样的方法来优化游戏的性能,很多对于固定时间的事件回调函数中,如果每次都分配新的缓存,但是在操作完后并不释放,这样就会造成大量的内存垃圾,对于这样的缓存,最好的办法就是当前周期回调后执行清除或者标志为废弃。
清除链表
在堆内存上进行链表的分配的时候,如果该链表需要多次反复的分配,我们可以采用链表的clear函数来清空链表从而替代反复多次的创建分配链表。
void Update() { List myList = new List(); PopulateList(myList); } 复制代码
通过改进,我们可以将该链表只在第一次创建或者该链表必须重新设置的时候才进行堆内存分配,从而大大减少内存垃圾的产生:
private List myList = new List(); void Update() { myList.Clear(); PopulateList(myList); } 复制代码
对象池
即便我们在代码中尽可能地减少堆内存的分配行为,但是如果游戏有大量的对象需要产生和销毁依然会造成GC。对象池技术可以通过重复使用对象来降低堆内存的分配和回收频率。对象池在游戏中广泛的使用,特别是在游戏中需要频繁的创建和销毁相同的游戏对象的时候,例如枪的子弹这种会频繁生成和销毁的对象。
要详细的讲解对象池已经超出本文的范围,但是该技术值得我们深入的研究This tutorial on object pooling on the Unity Learn site对于对象池有详细深入的讲解。
附:对象池技术属于游戏中比较通用的技术,如果有闲余时间,大家可以学习一下这方面的知识。
造成不必要的堆内存分配的因素
我们已经知道值类型变量在堆栈上分配,其他的变量在堆内存上分配,但是任然有一些情况下的堆内存分配会让我们感到吃惊。下面让我们分析一些常见的不必要的堆内存分配行为并对其进行优化。
在c#中,字符串是引用类型变量而不是值类型变量,即使看起来它是存储字符串的值的。这就意味着字符串会造成一定的内存垃圾,由于代码中经常使用字符串,所以我们需要对其格外小心。
c#中的字符串是不可变更的,也就是说其内部的值在创建后是不可被变更的。每次在对字符串进行操作的时候(例如运用字符串的“加”操作),unity会新建一个字符串用来存储新的字符串,使得旧的字符串被废弃,这样就会造成内存垃圾。
我们可以采用以下的一些方法来最小化字符串的影响:
1)减少不必要的字符串的创建,如果一个字符串被多次利用,我们可以创建并缓存该字符串。
2)减少不必要的字符串操作,例如如果在Text组件中,有一部分字符串需要经常改变,但是其他部分不会,则我们可以将其分为两个部分的组件,对于不变的部分就设置为类似常量字符串即可,见下面的例子。
3)如果我们需要实时的创建字符串,我们可以采用StringBuilderClass来代替,StringBuilder专为不需要进行内存分配而设计,从而减少字符串产生的内存垃圾。
4)移除游戏中的Debug.Log()函数的代码,尽管该函数可能输出为空,对该函数的调用依然会执行,该函数会创建至少一个字符(空字符)的字符串。如果游戏中有大量的该函数的调用,这会造成内存垃圾的增加。
在下面的代码中,在Update函数中会进行一个string的操作,这样的操作就会造成不必要的内存垃圾:
public Text timerText; private float timer; void Update() { timer += Time.deltaTime; timerText.text = "Time:"+ timer.ToString(); } 复制代码
通过将字符串进行分隔,我们可以剔除字符串的加操作,从而减少不必要的内存垃圾:
public Text timerHeaderText; public Text timerValueText; private float timer; void Start() { timerHeaderText.text = "TIME:"; } void Update() { timerValueText.text = timer.ToString(); } 复制代码
2-1-10、 Unity3d函数的调用
在代码编程中,当我们调用不是我们自己编写的代码,无论是Unity自带的还是插件中的,我们都可能会产生内存垃圾。Unity的某些函数调用会产生内存垃圾,我们在使用的时候需要注意它的使用。
这儿没有明确的列表指出哪些函数需要注意,每个函数在不同的情况下有不同的使用,所以最好仔细地分析游戏,定位内存垃圾的产生原因以及如何解决问题。有时候缓存是一种有效的办法,有时候尽量降低函数的调用频率是一种办法,有时候用其他函数来重构代码是一种办法。现在来分析unity中常见的造成堆内存分配的函数调用。
在Unity中如果函数需要返回一个数组,则一个新的数组会被分配出来用作结果返回,这不容易被注意到,特别是如果该函数含有迭代器,下面的代码中对于每个迭代器都会产生一个新的数组:
void ExampleFunction() { for(int i=0; i < myMesh.normals.Length;i++) { Vector3 normal = myMesh.normals[i]; } } 复制代码
对于这样的问题,我们可以缓存一个数组的引用,这样只需要分配一个数组就可以实现相同的功能,从而减少内存垃圾的产生:
void ExampleFunction() { Vector3[] meshNormals = myMesh.normals; for(int i=0; i < meshNormals.Length;i++) { Vector3 normal = meshNormals[i]; } } 复制代码
此外另外的一个函数调用GameObject.name 或者 GameObject.tag也会造成预想不到的堆内存分配,这两个函数都会将结果存为新的字符串返回,这就会造成不必要的内存垃圾,对结果进行缓存是一种有效的办法,但是在Unity中都对应的有相关的函数来替代。对于比较gameObject的tag,可以采用GameObject.CompareTag()来替代。
在下面的代码中,调用gameobject.tag就会产生内存垃圾:
private string playerTag="Player"; void OnTriggerEnter(Collider other) { bool isPlayer = other.gameObject.tag == playerTag; } 复制代码
采用GameObject.CompareTag()可以避免内存垃圾的产生:
private string playerTag = "Player"; void OnTriggerEnter(Collider other) { bool isPlayer = other.gameObject.CompareTag(playerTag); } 复制代码
不只是GameObject.CompareTag,unity中许多其他的函数也可以避免内存垃圾的生成。比如我们可以用Input.GetTouch()和Input.touchCount()来代替Input.touches,或者用Physics.SphereCastNonAlloc()来代替Physics.SphereCastAll()。
2-1-11、装箱操作
装箱操作是指一个值类型变量被用作引用类型变量时候的内部变换过程,如果我们向带有对象类型参数的函数传入值类型,这就会触发装箱操作。比如String.Format()函数需要传入字符串和对象类型参数,如果传入字符串和int类型数据,就会触发装箱操作。如下面代码所示:
void ExampleFunction() { int cost = 5; string displayString = String.Format("Price:{0} gold",cost); } 复制代码
在Unity的装箱操作中,对于值类型会在堆内存上分配一个System.Object类型的引用来封装该值类型变量,其对应的缓存就会产生内存垃圾。装箱操作是非常普遍的一种产生内存垃圾的行为,即使代码中没有直接的对变量进行装箱操作,在插件或者其他的函数中也有可能会产生。最好的解决办法是尽可能的避免或者移除造成装箱操作的代码。
2-1-12、协程
调用 StartCoroutine()会产生少量的内存垃圾,因为unity会生成实体来管理协程。所以在游戏的关键时刻应该限制该函数的调用。基于此,任何在游戏关键时刻调用的协程都需要特别的注意,特别是包含延迟回调的协程。
yield在协程中不会产生堆内存分配,但是如果yield带有参数返回,则会造成不必要的内存垃圾,例如:
yield return 0; 复制代码
由于需要返回0,引发了装箱操作,所以会产生内存垃圾。这种情况下,为了避免内存垃圾,我们可以这样返回:
yield return null; 复制代码
另外一种对协程的错误使用是每次返回的时候都new同一个变量,例如:
while(!isComplete) { yield return new WaitForSeconds(1f); } 复制代码
我们可以采用缓存来避免这样的内存垃圾产生:
WaitForSeconds delay = new WaiForSeconds(1f); while(!isComplete) { yield return delay; } 复制代码
如果游戏中的协程产生了内存垃圾,我们可以考虑用其他的方式来替代协程。重构代码对于游戏而言十分复杂,但是对于协程而言我们也可以注意一些常见的操作,比如如果用协程来管理时间,最好在update函数中保持对时间的记录。如果用协程来控制游戏中事件的发生顺序,最好对于不同事件之间有一定的信息通信的方式。对于协程而言没有适合各种情况的方法,只有根据具体的代码来选择最好的解决办法。
2-1-12、foreach 循环
在unity5.5以前的版本中,在foreach的迭代中都会生成内存垃圾,主要来自于其后的装箱操作。每次在foreach迭代的时候,都会在堆内存上生产一个System.Object用来实现迭代循环操作。在unity5.5中解决了这个问题,比如,在unity5.5以前的版本中,用foreach实现循环:
void ExampleFunction(List listOfInts) { foreach(int currentInt in listOfInts) { DoSomething(currentInt); } } 复制代码
如果游戏工程不能升级到5.5以上,则可以用for或者while循环来解决这个问题,所以可以改为:
void ExampleFunction(List listOfInts) { for(int i=0; i < listOfInts.Count; i++) { int currentInt = listOfInts[i]; DoSomething(currentInt); } } 复制代码
foreach怎么产生垃圾的
研究过这个问题的人都应该知道,就是它会引起频繁的GC Alloc。也就是说,使用它之后,尤其在Update方法中频繁调用时,会快速产生小块垃圾内存,造成垃圾回收操作的提前到来,造成游戏间歇性的卡顿。 问题大家都知道,也都给出了建议,就是尽可能不要用。在start方法里倒无所谓,因为毕竟它只执行一次。Update方法一秒钟执行大概50-60次,这里就不要使用了。这个观点整体上是正确的,因为这样做毕竟避开了问题。 不过有一点点不是很方便的就是,foreach确实带来了很多便捷性的编码。尤其是结合了var之后,那么我们究竟还能不能使用它,能使用的话,应该注意哪些问题?带着这些问题,我做了以下的测试。
重现GC Alloc问题
首先,我写了一个简单的脚本来重现这个问题。 这个类中包括一个int数组,一个泛型参数为int的List。
代码如下:
using UnityEngine; using System.Collections; using System.Collections.Generic; public class ForeachTest : MonoBehaviour { int[] m_intArray; List<int> m_intList; ArrayList m_arryList; public void Start () { m_intArray = new int[2]; m_intList = new List<int>(); m_arryList = new ArrayList(); for (int i = 0; i < m_intArray.Length; i++) { m_intArray[i] = i; m_intList.Add(i); m_arryList.Add(i); } } void Update () { testIntListForeach(); } void testIntListForeach() { for (int i = 0; i < 1000; i++) { foreach (var iNum in m_intList) { } } } } 复制代码
应用于IntList的foreach
首先我们看应用于泛型List的情况,如下图:
这里确实是在产生GC Alloc,每帧产生39.1KB的新内存。我使用的Unity版本是64位的5.4.3f1,可能不同的版本产生的内存大小有些差别,但是产生新内存是不可避免的。
应用于IntList的GetEnumerator
接下来,我又做了另外一种尝试,就是用对等的方式写出同样的代码。将测试代码部分改成如下:
for (int i = 0; i < 1000; i++) { var iNum = m_intList.GetEnumerator(); while (iNum.MoveNext()) { } } 复制代码
原本以为,这个结果与上面的方式应该相同。不过结果出乎意料。
它并没产生任何的新内存。于是,我准备使用IL反编译器来了解它的GCAlloc是如何产生的。 我们知道,List是动态数组,是可以随时增长、删减的,而int[]这种形式,在C#里面被编译成Array的子类去执行。为了有更多的对比,我将foreach和GetEmulator也写一份同样的代码,应用于Int数组和ArrayList,先查看运行的结果,然后一起查看他们的IL代码。
应用于IntArray的foreach
for (int i = 0; i < 1000; i++) { foreach (var iNum in m_intArray) { } } 复制代码
结果是没有产生GC Alloc。
应用于IntArray的GetEnumerator
for (int i = 0; i < 1000; i++) { var iNum = m_intArray.GetEnumerator(); while (iNum.MoveNext()) { } } 复制代码
结果是这里也在产生GC Alloc,每帧产生31.3KB的新内存
应用于ArrayList的foreach
for (int i = 0; i < 1000; i++) { foreach (var iNum in m_intArray) { } } 复制代码
结果是这里也在产生GC Alloc,每帧产生23.4KB的新内存(在32位版Unity5.3.4f1测试)。
12.4.8 应用于ArrayList的GetEnumerator
for (int i = 0; i < 1000; i++) { var iNum = m_intArray.GetEnumerator(); while (iNum.MoveNext()) { } } 复制代码
GC Alloc产生情况小结
小结 | int[] (Array) | List< int > | ArrayList |
foreach | 不产生 | 产生 | 产生 |
GetEnumerator | 产生 | 不产生 | 产生 |
延伸阅读:【Unity优化】Unity中究竟能不能使用foreach?
2-1-13、函数引用
函数的引用,无论是指向匿名函数还是显式函数,在unity中都是引用类型变量,这都会在堆内存上进行分配。匿名函数的调用完成后都会增加内存的使用和堆内存的分配。具体函数的引用和终止都取决于操作平台和编译器设置,但是如果想减少GC最好减少函数的引用。
由于LINQ和常量表达式以装箱的方式实现,所以在使用的时候最好进行性能测试。
2-1-14、重构代码来减小GC的影响
即使我们减小了代码在堆内存上的分配操作,代码也会增加GC的工作量。最常见的增加GC工作量的方式是让其检查它不必检查的对象。struct是值类型的变量,但是如果struct中包含有引用类型的变量,那么GC就必须检测整个struct。如果这样的操作很多,那么GC的工作量就大大增加。在下面的例子中struct包含一个string,那么整个struct都必须在GC中被检查:
public struct ItemData { public string name; public int cost; public Vector3 position; } private ItemData[] itemData; 复制代码
我们可以将该struct拆分为多个数组的形式,从而减小GC的工作量:
private string[] itemNames; private int[] itemCosts; private Vector3[] itemPositions; 复制代码
另外一种在代码中增加GC工作量的方式是保存不必要的Object引用,在进行GC操作的时候会对堆内存上的object引用进行检查,越少的引用就意味着越少的检查工作量。在下面的例子中,当前的对话框中包含一个对下一个对话框引用,这就使得GC的时候会去检查下一个对象框:
public class DialogData { private DialogData nextDialog; public DialogData GetNextDialog() { return nextDialog; } } 复制代码
通过重构代码,我们可以返回下一个对话框实体的标记,而不是对话框实体本身,这样就没有多余的object引用,从而减少GC的工作量:
public class DialogData { private int nextDialogID; public int GetNextDialogID() { return nextDialogID; } } 复制代码
当然这个例子本身并不重要,但是如果我们的游戏中包含大量的含有对其他Object引用的object,我们可以考虑通过重构代码来减少GC的工作量。
2-1-15、定时执行GC操作
主动调用GC操作
如果我们知道堆内存在被分配后并没有被使用,我们希望可以主动地调用GC操作,或者在GC操作并不影响游戏体验的时候(例如场景切换的时候),我们可以主动的调用GC操作:
System.GC.Collect() 复制代码
通过主动的调用,我们可以主动驱使GC操作来回收堆内存。
2-1-16、脚本优化
- 常规循环 尽量少放在Update
- 变量的隐性调用 go.transform不推荐 (这是一种遍历查找组件的方式) 推荐GetComponent(Transform)(Markdown不支持应该是尖角括号)
- Gmaeobject.Find 推荐使用Tag查找 保存变量
- 多线程 协程处理,大量循环使用分携程执行,前边添加yield return设置执行顺序
IEnumerator Work() { //线程不安全 //StartCoroutine(MyIoWork1()); //StartCoroutine(MyIoWork2()); yield return StartCoroutine(MyIoWork1()); yield return StartCoroutine(MyIoWork2()); } IEnumerator MyIoWork1() { for (int i = 0; i < 1000; i++) { System.IO.File.Delete("c:\a.zip"); yield return null; } } IEnumerator MyIoWork2() { for (int i = 0; i < 1000; i++) { System.IO.File.Delete("c:\a.zip"); yield return null; } } 复制代码
- 数学 合理降低计算的精度
例子: 计算距离:Mathf.Sqrt 计算方向:Vector3.Angle Minimize use of complex mathematical operations such as pow, sin and cos in pixel shaders.
using UnityEngine; using System.Collections; public class TestMagnitude : MonoBehaviour { public Transform player1; public Transform player2; void Start() { Calc(); } void Calc() { float distance1 = (player1.position - transform.position).magnitude; float distance2 = (player2.position - transform.position).magnitude; Debug.Log("Player1 is closer than Player2:" + (distance1 < distance2).ToString()); float distance11 = (player1.position - transform.position).sqrMagnitude; float distance22 = (player2.position - transform.position).sqrMagnitude; Debug.Log("Player1 is closer than Player2:" + (distance1 < distance2).ToString()); } } 复制代码
using UnityEngine; using System.Collections; public class Compare : MonoBehaviour { public Transform player1; public Transform player2; // Use this for initialization void Start () { float angle = Vector3.Angle(player2.forward, player1.forward); float dot = Vector3.Dot(player2.forward,player1.forward); Debug.Log("Dot=" + dot +" Angle=" + angle); } // Update is called once per frame void Update () { } } 复制代码
- Object Pool 适用于频繁操作的对象
using UnityEngine; using System.Collections; using System.Collections.Generic; public class TestPool : MonoBehaviour { public Transform root; public GameObject prefab; public float loopCount; public float prefabCount; List<GameObject> objects; // Use this for initialization void Start() { objects = new List<GameObject>(); System.DateTime startTime = System.DateTime.Now; TestCaseWaitOutPool(); System.DateTime endTime = System.DateTime.Now; string totalMs = (endTime - startTime).TotalMilliseconds.ToString(); Debug.Log("Test case Without Pool take " + totalMs + "ms."); } // Update is called once per frame void Update() { } void TestCaseWaitOutPool() { for (int j = 0; j < loopCount; j++) { for (int i = 0; i < prefabCount; i++) { //create prefab GameObject go = Instantiate(prefab); //set parent go.transform.parent = root; //add to list objects.Add(go); } for (int i = 0; i < prefabCount; i++) { GameObject.Destroy(objects[i]); } //destory prefab objects.Clear(); } } } 复制代码
使用pool之后
using UnityEngine; using System.Collections; using System.Collections.Generic; public class TestPool : MonoBehaviour { public SimplePool pool; public Transform root; public GameObject prefab; public float loopCount; public float prefabCount; List<GameObject> objects; // Use this for initialization void Start() { objects = new List<GameObject>(); // with out pool System.DateTime startTime = System.DateTime.Now; TestCaseWaitOutPool(); System.DateTime endTime = System.DateTime.Now; string totalMs = (endTime - startTime).TotalMilliseconds.ToString(); Debug.Log("Test case Without Pool take " + totalMs + "ms."); // with pool System.DateTime poolstartTime = System.DateTime.Now; TestCaseWithPool(); System.DateTime poolendTime = System.DateTime.Now; string pooltotalMs = (poolendTime - poolstartTime).TotalMilliseconds.ToString(); Debug.Log("Test case With Pool take " + pooltotalMs + "ms."); } void TestCaseWaitOutPool() { for (int j = 0; j < loopCount; j++) { for (int i = 0; i < prefabCount; i++) { //create prefab GameObject go = Instantiate(prefab); //set parent go.transform.parent = root; //add to list objects.Add(go); } for (int i = 0; i < prefabCount; i++) { GameObject.Destroy(objects[i]); } //destory prefab objects.Clear(); } } void TestCaseWithPool() { for (int i = 0; i < loopCount; i++) { List<GameObject> objectsList = pool.GetObjects((int)prefabCount); pool.DestroyObjects(objectsList); } } } 复制代码
using UnityEngine; using System.Collections; using System.Collections.Generic; public class SimplePool : MonoBehaviour { public Transform root; public GameObject prefab; public int size; List<GameObject> pooled; void Start() { pooled = new List<GameObject>(); Prewarm(); } void Prewarm() { PoolObjects(size); } List<GameObject> PoolObjects(int _amount) { List<GameObject> newPooled = new List<GameObject>(); for (int i = 0; i < _amount; i++) { GameObject go = Instantiate(prefab); go.transform.parent = root; go.SetActive(false); newPooled.Add(go); } pooled.AddRange(newPooled); return newPooled; } public List<GameObject> GetObjects(int _amount) { List<GameObject> pooledObjects = pooled.FindAll(_go => !_go.activeSelf); if (pooledObjects.Count < _amount) { List<GameObject> newObjects = PoolObjects(_amount - pooledObjects.Count); pooledObjects.AddRange(newObjects); foreach (var go in pooledObjects) { go.SetActive(true); } return pooledObjects; } else { foreach (var go in pooledObjects) { go.SetActive(true); } return pooledObjects; } } public void DestroyObjects(List<GameObject> objects) { for (int i = 0; i < objects.Count; i++) { objects[i].SetActive(false); } } } 复制代码
Test case Without Pool take 226.1487ms. UnityEngine.Debug:Log(Object) TestPool:Start() (at Assets/TestPool.cs:26) Test case With Pool take 58.0407ms. UnityEngine.Debug:Log(Object) TestPool:Start() (at Assets/TestPool.cs:34)
时间从 226.1487ms 缩短到58.0407ms。这就是效率。
- 删除脚本中为空或不需要的默认方法,尽量少在Update中做事情,脚本不用时把它deactive。
- 如何找到需要优化的代码
- Total 与 Self
在 Unity-Window-Profiler Overview 里面的 Total(总的占用包括调用其他人的部分),Self(仅自身占用)
using UnityEngiusing System.Collections; public class TestTime : MonoBehaviour { public GameObject prefab; void Start() { //self System.Threading.Thread.Sleep(2000); Create(); // others } void Create() { for (int i = 0; i < 10000; i++) { GameObject go = GameObject.Instantiate(prefab); GameObject.Destroy(go); } } } 复制代码
2-1-17、文件优化
1.AssetBundle 创建 读取 设置Prefab。 AssetBundle New:env/go 路径自行设置
using UnityEngine; using System.Collections; using System; public class TestAssetBundle : MonoBehaviour { public string path; public string file; // Use this for initialization void Start () { StartCoroutine( Load()); } IEnumerator Load() { string _path = "file:///" + Application.dataPath + path; WWW www = WWW.LoadFromCacheOrDownload(_path,1); yield return www; AssetBundle bundle = www.assetBundle; AssetBundleRequest request = bundle.LoadAssetAsync(file); yield return request; GameObject prefab = request.asset as GameObject; Instantiate(prefab); //Clean bundle.Unload(false); www.Dispose(); } } 复制代码
2.移动端打包优化 缩减包体积 设置 Andriod player setting Optimlzation .NET2.0(完整版) .NET2.0 Subset(简化版) Stripping Leve Disabled .Strip Assembies. Strip Byte Code(一般用这个就可以了) .Use Micro mscorlib
1.momo version full subset 2.stripping level disabled strip bute code 3.媒体文件 图片 psd/png/jpg 音频 ogg/mp3/wav fbx 公用animationclip 复制代码
如何找到打包出的资源占比 先进行打包,打包完毕后查看控制台输出
打开文本日志,查看各资源的占比,确定优化的方向(此处是空场景测试)
3.跨平台开发效率优化
- 节省时间 utomate 自动化打包插件
- 显示面板改进 Odin - Inspector and Serializer
- DebugConsole Editor Console Pro