深刻浅出聊优化:从Draw Calls到GC


前言:

刚开始写这篇文章的时候选了一个很土的题目。。。《Unity3D优化全解析》。由于这是一篇临时起意才写的文章,并且陈述的都是既有的事实,于是给本身“文(dou)学(bi)”加工留下的余地就少了不少。但又以为这块是不得不提的一个地方,平时见到不少人对此处也给予了忽略了事,须要时才去网上扒一些只言片语的资料。也恰逢年前,寻思着周末认真写点东西遇到节假日没准也没什么人读,因此索性就写了这篇临时的文章。题目很土,由于用了指向性很明确的“Unity3D”,让人少了遐(瞎)想的空间,同时用了“高大全”这样的构词法,也让匹夫有成为众矢之的的可能。。。因此最后仍是改为了如今各位看到的题目。话很少说,下面就开始正文~正所谓“草蛇灰线,伏脉千里”。那我们首先~~~~~~web

看看优化须要从哪里着手?

匹夫印象里遇到的童靴,提Unity3D项目优化则必提DrawCall,这天然没错,但也有很很差影响。由于这会给人一个错误的认识:所谓的优化就是把DrawCall弄的比较低就对了。编程

对优化有这种第一印象的人不在少数,drawcall的确是一个很重要的指标,但绝非所有。为了让各位和匹夫能达成尽量多的共识,匹夫首先介绍一下本文可能会涉及到的几个概念,以后会提出优化所涉及的三大方面:数组

  • drawcall是啥?其实就是对底层图形程序(好比:OpenGL ES)接口的调用,以在屏幕上画出东西。因此,是谁去调用这些接口呢?CPU。
  • fragment是啥?常常有人说vf啥的,vertex咱们都知道是顶点,那fragment是啥呢?说它以前须要先说一下像素,像素各位应该都知道吧?像素是构成数码影像的基本单元呀。那fragment呢?是有可能成为像素的东西。啥叫有可能?就是最终会不会被画出来不必定,是潜在的像素。这会涉及到谁呢?GPU。
  • batching是啥?都知道批处理是干吗的吧?没错,将批处理以前须要不少次调用(drawcall)的物体合并,以后只须要调用一次底层图形程序的接口就行。听上去这简直就是优化的终极方案啊!可是,理想是美好的,世界是残酷的,一些不足以后咱们再细聊。
  • 内存的分配:记住,除了Unity3D本身的内存损耗。咱们但是还带着Mono呢啊,还有托管的那一套东西呢。更别说你一激动,又引入了本身的几个dll。这些都是内存开销上须要考虑到的。

好啦,文中的几个概念提早讲清楚了,其实各位也能看的出来匹夫接下来要说的匹夫关注的优化时须要注意的方面:缓存

因此,这篇文章也会按照CPU---->GPU---->内存的顺序进行。性能优化

CPU的方面的优化:

上文中说了,drawcall影响的是CPU的效率,并且也是最知名的一个优化点。可是除了drawcall以外,还有哪些因素也会影响到CPU的效率呢?让咱们一一列出暂时能想获得的:ide

  • DrawCalls
  • 物理组件(Physics)
  • GC(什么?GC不是处理内存问题的嘛?匹夫你不要骗我啊!不过,匹夫也要提醒一句,GC是用来处理内存的,可是是谁使用GC去处理内存的呢?)
  • 固然,还有代码质量

DrawCalls:

前面说过了,DrawCall是CPU调用底层图形接口。好比有上千个物体,每个的渲染都须要去调用一次底层接口,而每一次的调用CPU都须要作不少工做,那么CPU必然不堪重负。可是对于GPU来讲,图形处理的工做量是同样的。因此对DrawCall的优化,主要就是为了尽可能解放CPU在调用图形接口上的开销。因此针对drawcall咱们主要的思路就是每一个物体尽可能减小渲染次数,多个物体最好一块儿渲染。因此,按照这个思路就有了如下几个方案:函数

  1. 使用Draw Call Batching,也就是描绘调用批处理。Unity在运行时能够将一些物体进行合并,从而用一个描绘调用来渲染他们。具体下面会介绍。
  2. 经过把纹理打包成图集来尽可能减小材质的使用。
  3. 尽可能少的使用反光啦,阴影啦之类的,由于那会使物体屡次渲染。

Draw Call Batching

首先咱们要先理解为什么2个没有使用相同材质的物体即便使用批处理,也没法实现Draw Call数量的降低和性能上的提高。post

由于被“批处理”的2个物体的网格模型须要使用相同材质的目的,在于其纹理是相同的,这样才能够实现同时渲染的目的。于是保证材质相同,是为了保证被渲染的纹理相同。性能

所以,为了将2个纹理不一样的材质合二为一,咱们就须要进行上面列出的第二步,将纹理打包成图集。具体到合二为一这种状况,就是将2个纹理合成一个纹理。这样咱们就能够只用一个材质来代替以前的2个材质了。

而Draw Call Batching自己,也还会细分为2种。

Static Batching 静态批处理

看名字,猜使用的情景。

静态?那就是不动的咯。还有呢?额,听上去状态也不会改变,没有“生命”,好比山山石石,楼房校舍啥的。那和什么比较相似呢?嗯,聪明的各位必定以为和场景的属性很像吧!因此咱们的场景彷佛就能够采用这种方式来减小draw call了。

那么写个定义:只要这些物体不移动,而且拥有相同的材质,静态批处理就容许引擎对任意大小的几何物体进行批处理操做来下降描绘调用。

那要如何使用静态批来减小Draw Call呢?你只须要明确指出哪些物体是静止的,而且在游戏中永远不会移动、旋转和缩放。想完成这一步,你只须要在检测器(Inspector)中将Static复选框打勾便可,以下图所示:



至于效果如何呢?

举个例子:新建4个物体,分别是Cube,Sphere, Capsule, Cylinder,它们有不一样的网格模型,可是也有相同的材质(Default-Diffuse)。

首先,咱们不指定它们是static的。Draw Call的次数是4次,如图:



咱们如今将它们4个物体都设为static,在来运行一下:



如图,Draw Call的次数变成了1,而Saved by batching的次数变成了3。

静态批处理的好处不少,其中之一就是与下面要说的动态批处理相比,约束要少不少。因此通常推荐的是draw call的静态批处理来减小draw call的次数。那么接下来,咱们就继续聊聊draw call的动态批处理。

Dynamic Batching 动态批处理

有阴就有阳,有静就有动,因此聊完了静态批处理,确定跟着就要说说动态批处理了。首先要明确一点,Unity3D的draw call动态批处理机制是引擎自动进行的,无需像静态批处理那样手动设置static。咱们举一个动态实例化prefab的例子,若是动态物体共享相同的材质,则引擎会自动对draw call优化,也就是使用批处理。首先,咱们将一个cube作成prefab,而后再实例化500次,看看draw call的数量。

for(int i = 0; i < 500; i++)
{
    GameObject cube;
    cube = GameObject.Instantiate(prefab) as GameObject;
}

draw call的数量:


能够看到draw call的数量为1,而 saved by batching的数量是499。而这个过程当中,咱们除了实例化建立物体以外什么都没作。不错,unity3d引擎为咱们自动处理了这种状况。

可是有不少童靴也遇到这种状况,就是我也是从prefab实例化建立的物体,为什么个人draw call依然很高呢?这就是匹夫上文说的,draw call的动态批处理存在着不少约束。下面匹夫就演示一下,针对cube这样一个简单的物体的建立,若是稍有不慎就会形成draw call飞涨的状况吧。

咱们一样是建立500个物体,不一样的是其中的100个物体,每一个物体的大小都不一样,也就是Scale不一样。

复制代码
for(int i = 0; i < 500; i++)
{
    GameObject cube;
    cube = GameObject.Instantiate(prefab) as GameObject;
    if(i / 100 == 0)
    {
        cube.transform.localScale = new Vector3(2 + i, 2 + i, 2 + i);
    }
}
复制代码

draw call的数量:


咱们看到draw call的数量上升到了101次,而saved by batching的数量也降低到了399。各位看官能够看到,仅仅是一个简单的cube的建立,若是scale不一样,居然也不会去作批处理优化。这仅仅是动态批处理机制的一种约束,那咱们总结一下动态批处理的约束,各位也许也能从中找到为什么动态批处理在本身的项目中不起做用的缘由:

  1. 批处理动态物体须要在每一个顶点上进行必定的开销,因此动态批处理仅支持小于900顶点的网格物体。
  2. 若是你的着色器使用顶点位置,法线和UV值三种属性,那么你只能批处理300顶点如下的物体;若是你的着色器须要使用顶点位置,法线,UV0,UV1和切向量,那你只能批处理180顶点如下的物体。
  3. 不要使用缩放。分别拥有缩放大小(1,1,1) 和(2,2,2)的两个物体将不会进行批处理。
  4. 统一缩放的物体不会与非统一缩放的物体进行批处理。
  5. 使用缩放尺度(1,1,1) 和 (1,2,1)的两个物体将不会进行批处理,可是使用缩放尺度(1,2,1) 和(1,3,1)的两个物体将能够进行批处理。
  6. 使用不一样材质的实例化物体(instance)将会致使批处理失败。
  7. 拥有lightmap的物体含有额外(隐藏)的材质属性,好比:lightmap的偏移和缩放系数等。因此,拥有lightmap的物体将不会进行批处理(除非他们指向lightmap的同一部分)。
  8. 多通道的shader会妨碍批处理操做。好比,几乎unity中全部的着色器在前向渲染中都支持多个光源,并为它们有效地开辟多个通道。
  9. 预设体的实例会自动地使用相同的网格模型和材质

因此,尽可能使用静态的批处理。

物理组件

曾几什么时候,匹夫在作一个策略类游戏的时候须要在单元格上排兵布阵,而要侦测到哪一个兵站在哪一个格子匹夫选择使用了射线,因为士兵单位不少,并且为了精确每一帧都会执行检测,那时候CPU的负担叫一个惨不忍睹。后来匹夫果断放弃了这种作法,而且对物理组件产生了心理的阴影。

这里匹夫只提2点匹夫感受比较重要的优化措施:

1.设置一个合适的Fixed Timestep。设置的位置如图:

 

那何谓“合适”呢?首先咱们要搞明白Fixed Timestep和物理组件的关系。物理组件,或者说游戏中模拟各类物理效果的组件,最重要的是什么呢?计算啊。对,须要经过计算才能将真实的物理效果展示在虚拟的游戏中。那么Fixed Timestep这货就是和物理计算有关的啦。因此,若计算的频率过高,天然会影响到CPU的开销。同时,若计算频率达不到游戏设计时的要求,有会影响到功能的实现,因此如何抉择须要各位具体分析,选择一个合适的值。

2.就是不要使用网格碰撞器(mesh collider):为啥?由于实在是太复杂了。网格碰撞器利用一个网格资源并在其上构建碰撞器。对于复杂网状模型上的碰撞检测,它要比应用原型碰撞器精确的多。标记为凸起的(Convex )的网格碰撞器才可以和其余网格碰撞器发生碰撞。各位上网搜一下mesh collider的图片,天然就会明白了。咱们的手机游戏天然无需这种性价比不高的东西。

固然,从性能优化的角度考虑,物理组件能少用仍是少用为好。

处理内存,却让CPU受伤的GC

在CPU的部分聊GC,感受是否是怪怪的?其实小匹夫不这么以为,虽然GC是用来处理内存的,但的确增长的是CPU的开销。所以它的确能达到释放内存的效果,但代价更加沉重,会加剧CPU的负担,所以对于GC的优化目标就是尽可能少的触发GC。

首先咱们要明确所谓的GC是Mono运行时的机制,而非Unity3D游戏引擎的机制,因此GC也主要是针对Mono的对象来讲的,而它管理的也是Mono的托管堆。 搞清楚这一点,你也就明白了GC不是用来处理引擎的assets(纹理啦,音效啦等等)的内存释放的,由于U3D引擎也有本身的内存堆而不是和Mono一块儿使用所谓的托管堆。

其次咱们要搞清楚什么东西会被分配到托管堆上?不错咯,就是引用类型咯。好比类的实例,字符串,数组等等。而做为int,float,包括结构体struct其实都是值类型,它们会被分配在堆栈上而非堆上。因此咱们关注的对象无外乎就是类实例,字符串,数组这些了。

那么GC何时会触发呢?两种状况:

  1. 首先固然是咱们的堆的内存不足时,会自动调用GC。
  2. 其次呢,做为编程人员,咱们本身也能够手动的调用GC。

因此为了达到优化CPU的目的,咱们就不能频繁的触发GC。而上文也说了GC处理的是托管堆,而不是Unity3D引擎的那些资源,因此GC的优化说白了也就是代码的优化。那么匹夫以为有如下几点是须要注意的:

  1. 字符串链接的处理。由于将两个字符串链接的过程,实际上是生成一个新的字符串的过程。而以前的旧的字符串天然而然就成为了垃圾。而做为引用类型的字符串,其空间是在堆上分配的,被弃置的旧的字符串的空间会被GC当作垃圾回收。
  2. 尽可能不要使用foreach,而是使用for。foreach其实会涉及到迭代器的使用,而据传说每一次循环所产生的迭代器会带来24 Bytes的垃圾。那么循环10次就是240Bytes。
  3. 不要直接访问gameobject的tag属性。好比if (go.tag == “human”)最好换成if (go.CompareTag (“human”))。由于访问物体的tag属性会在堆上额外的分配空间。若是在循环中这么处理,留下的垃圾就可想而知了。
  4. 使用“池”,以实现空间的重复利用。
  5. 最好不用LINQ的命令,由于它们会分配临时的空间,一样也是GC收集的目标。并且我很讨厌LINQ的一点就是它有可能在某些状况下没法很好的进行AOT编译。好比“OrderBy”会生成内部的泛型类“OrderedEnumerable”。这在AOT编译时是没法进行的,由于它只是在OrderBy的方法中才使用。因此若是你使用了OrderBy,那么在IOS平台上也许会报错。

代码?脚本?

聊到代码这个话题,也许有人会以为匹夫画蛇添足。由于代码质量因人而异,很难像上面提到的几点,有一个明确的评判标准。也是,公写公有理,婆写婆有理。可是匹夫这里要提到的所谓代码质量是基于一个前提的:Unity3D是用C++写的,而咱们的代码是用C#做为脚原本写的,那么问题就来了~脚本和底层的交互开销是否须要考虑呢?也就是说,咱们用Unity3D写游戏的“游戏脚本语言”,也就是C#是由mono运行时托管的。而功能是底层引擎的C++实现的,“游戏脚本”中的功能实现都离不开对底层代码的调用。那么这部分的开销,咱们应该如何优化呢?

  1. 以物体的Transform组件为例,咱们应该只访问一次,以后就将它的引用保留,而非每次使用都去访问。这里有人作过一个小实验,就是对比经过方法GetComponent<Transform>()获取Transform组件, 经过MonoBehavor的transform属性去取,以及保留引用以后再去访问所须要的时间:
    • GetComponent = 619ms
    • Monobehaviour = 60ms
    • CachedMB = 8ms
    • Manual Cache = 3ms

   2.如上所述,最好不要频繁使用GetComponent,尤为是在循环中。

   3.善于使用OnBecameVisible()和OnBecameVisible(),来控制物体的update()函数的执行以减小开销。

   4.使用内建的数组,好比用Vector3.zero而不是new Vector(0, 0, 0);

   5.对于方法的参数的优化:善于使用ref关键字。值类型的参数,是经过将实参的值复制到形参,来实现按值传递到方法,也就是咱们一般说的按值传递。复制嘛,总会让人感受很笨重。好比Matrix4x4这样比较复杂的值类型,若是直接复制一份新的,反而不如将值类型的引用传递给方法做为参数。

好啦,CPU的部分匹夫以为到此就介绍的差很少了。下面就简单聊聊其实匹夫并非十分熟悉的部分,GPU的优化。

GPU的优化

GPU与CPU不一样,因此侧重点天然也不同。GPU的瓶颈主要存在在以下的方面:

  1. 填充率,能够简单的理解为图形处理单元每秒渲染的像素数量。
  2. 像素的复杂度,好比动态阴影,光照,复杂的shader等等
  3. 几何体的复杂度(顶点数量)
  4. 固然还有GPU的显存带宽

那么针对以上4点,其实仔细分析咱们就能够发现,影响的GPU性能的无非就是2大方面,一方面是顶点数量过多,像素计算过于复杂。另外一方面就是GPU的显存带宽。那么针锋相对的两方面举措也就十分明显了。

  1. 少顶点数量,简化计算复杂度。
  2. 缩图片,以适应显存带宽。

减小绘制的数目

那么第一个方面的优化也就是减小顶点数量,简化复杂度,具体的举措就总结以下了:

  • 保持材质的数目尽量少。这使得Unity更容易进行批处理。
  • 使用纹理图集(一张大贴图里包含了不少子贴图)来代替一系列单独的小贴图。它们能够更快地被加载,具备不多的状态转换,并且批处理更友好。
  • 若是使用了纹理图集和共享材质,使用Renderer.sharedMaterial 来代替Renderer.material 。
  • 使用光照纹理(lightmap)而非实时灯光。
  • 使用LOD,好处就是对那些离得远,看不清的物体的细节能够忽略。
  • 遮挡剔除(Occlusion culling)
  • 使用mobile版的shader。由于简单。

优化显存带宽

第二个方向呢?压缩图片,减少显存带宽的压力。

  • OpenGL ES 2.0使用ETC1格式压缩等等,在打包设置那里都有。
  • 使用mipmap。

MipMap

这里匹夫要着重介绍一下MipMap究竟是啥。由于有人说过MipMap会占用内存呀,但为什么又会优化显存带宽呢?那就不得不从MipMap是什么开始聊起。一张图其实就能解决这个疑问。


上面是一个mipmap 如何储存的例子,左边的主图伴有一系列逐层缩小的备份小图

是否是很一目了然呢?Mipmap中每个层级的小图都是主图的一个特定比例的缩小细节的复制品。由于存了主图和它的那些缩小的复制品,因此内存占用会比以前大。可是为什么又优化了显存带宽呢?由于能够根据实际状况,选择适合的小图来渲染。因此,虽然会消耗一些内存,可是为了图片渲染的质量(比压缩要好),这种方式也是推荐的。

内存的优化

既然要聊Unity3D运行时候的内存优化,那咱们天然首先要知道Unity3D游戏引擎是如何分配内存的。大概能够分红三大部分:

  1. Unity3D内部的内存
  2. Mono的托管内存
  3. 若干咱们本身引入的DLL或者第三方DLL所须要的内存。

第3类不是咱们关注的重点,因此接下来咱们会分别来看一下Unity3D内部内存Mono托管内存,最后还将分析一个官网上Assetbundle的案例来讲明内存的管理。

Unity3D内部内存

Unity3D的内部内存都会存放一些什么呢?各位想想,除了用代码来驱动逻辑,一个游戏还须要什么呢?对,各类资源。因此简单总结一下Unity3D内部内存存放的东西吧:

  • 资源:纹理、网格、音频等等
  • GameObject和各类组件。
  • 引擎内部逻辑须要的内存:渲染器,物理系统,粒子系统等等

Mono托管内存

由于咱们的游戏脚本是用C#写的,同时还要跨平台,因此带着一个Mono的托管环境显然必须的。那么Mono的托管内存天然就不得不放到内存的优化范畴中进行考虑。那么咱们所说的Mono托管内存中存放的东西和Unity3D内部内存中存放的东西究竟有何不一样呢?其实Mono的内存分配就是很传统的运行时内存的分配了:

  • 值类型:int型啦,float型啦,结构体struct啦,bool啦之类的。它们都存放在堆栈上(注意额,不是堆因此不涉及GC)。
  • 引用类型:其实能够狭义的理解为各类类的实例。好比游戏脚本中对游戏引擎各类控件的封装。其实很好理解,C#中确定要有对应的类去对应游戏引擎中的控件。那么这部分就是C#中的封装。因为是在堆上分配,因此会涉及到GC。

而Mono托管堆中的那些封装的对象,除了在在Mono托管堆上分配封装类实例化以后所须要的内存以外,还会牵扯到其背后对应的游戏引擎内部控件在Unity3D内部内存上的分配。

举一个例子:

一个在.cs脚本中声明的WWW类型的对象www,Mono会在Mono托管堆上为www分配它所须要的内存。同时,这个实例对象背后的所表明的引擎资源所须要的内存也须要被分配。

一个WWW实例背后的资源:

  • 压缩的文件
  • 解压缩所需的缓存
  • 解压缩以后的文件

如图:


那么下面就举一个AssetBundle的例子:

Assetbundle的内存处理

如下载Assetbundle为例子,聊一下内存的分配。匹夫从官网的手册上找到了一个使用Assetbundle的情景以下:

复制代码
IEnumerator DownloadAndCache (){
        // Wait for the Caching system to be ready
        while (!Caching.ready)
            yield return null;

        // Load the AssetBundle file from Cache if it exists with the same version or download and store it in the cache
        using(WWW www = WWW.LoadFromCacheOrDownload (BundleURL, version)){
            yield return www; //WWW是第1部分 if (www.error != null)
                throw new Exception("WWW download had an error:" + www.error);
            AssetBundle bundle = www.assetBundle;//AssetBundle是第2部分
            if (AssetName == "")
                Instantiate(bundle.mainAsset);//实例化是第3部分
            else
                Instantiate(bundle.Load(AssetName));
                    // Unload the AssetBundles compressed contents to conserve memory
                    bundle.Unload(false);

        } // memory is freed from the web stream (www.Dispose() gets called implicitly)
    }
}
复制代码

内存分配的三个部分匹夫已经在代码中标识了出来:

  1. Web Stream:包括了压缩的文件,解压所需的缓存,以及解压后的文件。
  2. AssetBundle:Web Stream中的文件的映射,或者说引用。
  3. 实例化以后的对象就是引擎的各类资源文件了,会在内存中建立出来。

那就分别解析一下:

WWW www = WWW.LoadFromCacheOrDownload (BundleURL, version)
  1. 将压缩的文件读入内存中
  2. 建立解压所需的缓存
  3. 将文件解压,解压后的文件进入内存
  4. 关闭掉为解压建立的缓存
AssetBundle bundle = www.assetBundle;
  1. AssetBundle此时至关于一个桥梁,从Web Stream解压后的文件到最后实例化建立的对象之间的桥梁。
  2. 因此AssetBundle实质上是Web Stream解压后的文件中各个对象的映射。而非真实的对象。
  3. 实际的资源还存在Web Stream中,因此此时要保留Web Stream。
Instantiate(bundle.mainAsset);
  1. 经过AssetBundle获取资源,实例化对象

最后各位可能看到了官网中的这个例子使用了:

using(WWW www = WWW.LoadFromCacheOrDownload (BundleURL, version)){
}

这种using的用法。这种用法其实就是为了在使用完Web Stream以后,将内存释放掉的。由于WWW也继承了idispose的接口,因此可使用using的这种用法。其实至关于最后执行了:

//删除Web Stream
www.Dispose();

OK,Web Stream被删除掉了。那还有谁呢?对Assetbundle。那么使用

//删除AssetBundle
bundle.Unload(false);

ok,写到这里就先打住啦。写的有点超了。有点赶也有点临时,往后在补充编辑。

若是各位看官以为文章写得还好,那么就容小匹夫跪求各位给点个“推荐”,谢啦~

装模做样的声明一下:本博文章若非特殊注明皆为原创,若需转载请保留原文连接http://www.cnblogs.com/murongxiaopifu/p/4284988.html)及做者信息慕容小匹夫

相关文章
相关标签/搜索