Unity 项目优化解析(一)

Unity3D项目优化必提DrawCall,这自然没错,但也有很不好的影响。因为这会给人一个错误的认识:所谓的优化就是把DrawCall弄的比较低就对了。

对优化有这种第一印象的人不在少数,DrawCall的确是一个很重要的指标,但绝非全部。
首先介绍一下本文可能涉及到的几个概念,之后会提出优化所涉及的三大方面:

1. DrawCall是什么?
* 其实就是对底层图形程序(比如:OpenGL ES)接口的调用,以在上画出东西。所以,是谁去掉用这些接口呢?是CPU
2.fragment是什么?
* 经常有人说vf啥的,vertex我们都知道是顶点,那fragment是啥呢?说它之前需要先说一下像素,像素就是构成数码影响的基本单位呀。fragment呢?是有可能成为像素的东西。啥叫有可能呢?就是最终是否被画出来不一定,是潜在的对象。这回涉及到谁呢?当然是GPU
3. batching是什么?
* 是将批处理之前需要很多次掉用(DrawCall)的物体的合并,之后只需要调用一次底层程序的接口就行。听上去简直就是终极优化的方案啊!!但是,理想是美好的,事实是残酷的,一些不足之处我们稍后再说
4. 内存分配:记住,除了Unity3D自己的内存消耗。我们可是带着Mono呢,还有托管的那一套东西呢。别说你一激动,又引入了自己的几个dll。这些都是内存开销上需要考虑到的。

优化需要注意到的几个方面:

* CPU方面
* GPU方面
* 内存方面

一、CPU方面的优化:

上文说了,DrawCall影响的是CPU的效率,而且也是最知名的一个优化点。但是除了DrawCall之外,还有那些因素会影响 CPU的效率呢?我们暂时列举下列能想到的:

* DrawCalls
* 物理组件(Physics)
* GC()
* 代码和脚本的质量

1. DrawCalls:
前面说了,DrawCall是CPU调用底层图形的接口。比如有千个物体,每一个渲染都需要去掉用一次底层的接口,而每一次调用CPU都需要做很多工作,那么CPU必然不堪重负。但是对于GPU来说,图形处理的工作量是一样的。所以对DrawCall的优化,主要就是为了尽量解放CPU在调用图形接口上的开销。所以针对DrawCall我们主要的思路就是每个物体尽量减少渲染的次数,多个物体最好一起渲染。所以,按照这个思路就有一下几个解决方案:

(1). 使用Draw Call Batching,也就是描绘调用批处理。Unity在运行的 时候可以将一些物体进行合并,从而用一个描绘调用渲染他们。具体下面会介绍
(2). 通过把纹理打包成图集来尽量减少材质的使用。
(3). 尽量少的使用反光,阴影之类deep,因为那样子会是物体多次渲染。

Draw Call Batching

首先我们要理解为何2个没有使用相同材质的物体即使使用批处理,也无法实现DrawCall数量的下降和性能上的提升。

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

因此,为了将2个纹理不同的材质合二为一,我们就需要进行上面列出的第二步,将纹理打包成图集,具体到合二为一这种情况,就是将2个纹理和成为一个纹理。这样我们就可以只用一个材质来的替代之前的2个材质了。

而Draw Call Batching本身,也还是细分为2种

Static Batching 静态批处理
看名字,猜使用的情景。

自己定义下:就是只要物体不移动,并且拥有相同的材质,静态批处理就允许引擎对任意大小的几何物体进行批处理操作来降低描绘调用。

那么如何使用静态批处理来降低DrawCall呢?只需要明确指出哪些物体是静止的,并且在游戏中永远不会移动、旋转和缩放。想要完成这一步,只需要在检测器(Inspector)面板中将Static复选框打勾即可,如下图所示:
这里写图片描述


至于效果如何呢?
我们下面举个例子:新建4个物体,分别是 Cube,Sphere, Capsule, Cylinder, 它们有不同的网络模型,但是也有相同的材质(Default-Material)

首先,我们不指定它们是static的
这里写图片描述

现在我们将它们4个物体都设为static,在来运行一下:
这里写图片描述

静态批处理的好处很多,其中之一就是与下面说的动态批处理相比,约束要少很多,所以一般推荐的是DrawCall的静态批处理来减少DrawCall的次数。那么接下来,我们就说一说DrawCall的动态批处理。

Dynamic Batching 动态批处理

有阴就有阳,有静就有动,所以聊完了静态批处理,接下来就是动态批处理了。首先明确一点,Unity3D的DrawCall动态批处理机制是引擎自动进行的,无需像静态批处理那样手动设置Static。

下面我们举一个动态实例化prefab的例子,如果动态物体共享相同的材质,则引擎会自动对DrawCall进行优化,也就是使用批处理。

首先,我们将一个Cube做成prefab,然后在实例化500次,Scale(1,1,1).看看DrawCall的数量:
这里写图片描述

我们可以看到SetPass calls的数量为10,而Saved by batching的数量为866. 而这个过程中我们除了实例化创建物体之外什么都没做。这就说明Unity3D引擎会为我们自动处理这种情况。

但是也有时候会遇到这样的问题,就是我们也是从prefab实例化创建物体的,为何我的DrawCall依然很高呢?就是上面所说的,DrawCall的动态批处理依然存在着很多约束,稍有不慎就会造成DrawCall飞涨的情况。

接下来我们同样创建500个物体,不同的就是其中有100个物体,每个物体的大小不同,也就是Scale不同。
效果如图所示:

这里写图片描述
Batches(批次),Saved by batching(被批处理),SetPass calls 都有所增加

下面我们总结一些动态批处理的约束:

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. 使用不同材质,实例化(Instantiate)出来的物体,将会导致批处理失败。
7. 拥有 Lightmap 的物体含有额外(隐藏)的材质属性。比如:lightmap 的偏移和缩放系数 等等。所以 拥有lightmap的物体不会进行批处理,(除非它们指向lightmap的同一部分)。
8. 多通道的shader会妨碍批处理操作。比如,几乎unity中所有的着色器在前向渲染中都支持多个光源,并且它们有效的开辟了多个通道。
9. 预设体 的实例化会 自动的使用相同的网络模型和材质

综上所述,我们在项目中应该尽量使用静态的批处理。


2、物理组件(Physics)

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

这里提出两点 我感觉比较重要的优化措施:

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

这里写图片描述
这里写图片描述

首先我们要搞明白Fiexd Timestep 和 物理组件的关系。物理组件,或者说游戏中模拟各种物理效果的组件,最重要的是什么呢?答案是:计算。是需要通过计算才能将真实的物理效果展现在虚拟游戏当中。那么Fixed Timestep这货就是和物理计算有关的啦。所以,若计算的频率太高,自然会影响到CPU的开销。同时,若计算频率达不到游戏设计时的要求,又会影响到功能的实现,所以如何抉择需要根据情况具体分析,选择一个合适的值。

2.   还有就是尽量不要使用网格碰撞器(Mesh Collider)。因为实在是太复杂了。网格碰撞器利用一个网络资源并在其上构建碰撞器。对于复杂网状模型上的碰撞检测,它要比应用圆形碰撞器精确的多。标记为凸起的(Convex)的网格碰撞器才能够和其他网格碰撞器发生碰撞。

这里写图片描述

所以,从性能优化的角度考虑,物理组件能少用还是少用比较好。


3.GC

GC虽然是用来处理内存的,但的确增加的是CPU的开销(每次GetComponent 均会分配一定的GC Allocated(Garbage Collection Allocated 垃圾回收分配))。
因为它的确能到达释放内存的效果、但代价更加沉重,会加重CPU的负担,因此对于GC 优化的目标就是尽量少的促发GC。

首先我们要明确所谓的GC是Mono运行时的机制,而非Unity3D游戏引擎的机制, 所以GC 也主要是针对Mono对象来说的,而它管理的也是Mono的托管堆。搞清楚这一点,你就明白了GC不是用来处理引擎的Assets(纹理,音效等等)的内存释放的,因为Unity3D引擎,也有自己的内存堆而不是和Mono一起使用所谓的托管堆。

下面我们需要搞清楚什么东西会被分配到托管堆上面?答案是:引用类型!!!
比如类的实例,字符串,数组等等。而作为int,float,包括结构体struct其实都是值类型,它们会被分配在堆栈上而非堆上。所以我们关注的对象无外乎就是类的实例,字符串,数组这些。。

那么GC什么时候会触发呢?有两种情况:
1.首先当然是当我们的堆的内存不足时,会自动调用。
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(语句集成查询(Language Integrated Query)是一组用于C# 和 Visual Basic语言的扩展) 的命令,因为它们会分配临时的空间,同样也是GC收集的目标。而且我很讨厌 LINQ 的一点就是它有可能在某些情况下无法很好的进行AOT编译(所谓 AOT(Ahead of Time )是指在运行以前就把中间代码静态编译成本地代码,而JIT(Just int Time)则是运行时动态编译。)。比如“OrderBy”会生成内部的泛型类“OrderedEnumerable“。这是在AOT编译时是无法进行的,因为它只是在 OrderBy 的方法中才能使用。所以如果你使 用了 OrderBy,那么在IOS平台上也许会报错。

代码和脚本的质量:

Unity3D是用 C++写的,而我们的代码是用C#作为脚本来写的,那么问题来了。。脚本和底层的交互开销是否需要考虑呢??也就是说,我们用Unity3D写游戏的“游戏脚本语言”,也就是C#是由Mono运行时托管的。而功能是底层引擎的C++实现的,“游戏脚本”中的功能实现都离不开对底层代码的调用。那么这部分开销,我们应该如何优化呢?

1. 以物体的Transform为例,我们应该只访问一次,之后就将它的引用保留,而非每次使用都去访问它。这里有人做过一个小实验,就是对比通过方法GetComponent <Transform>() 获取 Transform组件,通过MonoBehavor的 transform 属性去取,以及保留引用之后再去访问所需要的时间:

* GetComponet = 619ms
* MonoBehaviour = 60ms
* CachedMB = 8ms
* Manual Cache = 3ms

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

1. 善于使用 OnBecameVisible() 和 OnBecameVisible()   来控制物体在 Update() 函数的执行以减少开销。
2. 使用内建的数据,比如用 Vector3.zero 而不是用 new Vector(0, 0, 0)
3. 对于方法参数的优化:善于使用ref关键字。值类型的参数,是通过将实参的值复制到形参,来实现按值传递到方法, 也就是我们通常说的按值传递。复制嘛,总会让人感觉很笨重,比如Matrix4x4 这样比较复杂的值类型,如果直接复制一份新的,反而不如将值类型的引用传递给方法作为参数。 

上面是对CPU部分的介绍,下面简单聊一聊GPU:

二、GPU方面的优化

GPU的瓶颈主要存在如下几个方面:

* 填充率,可以简单的理解为图形处理单元每秒渲染的像素数量。
* 像素的复杂度,比如动态阴影,光照,复杂的shader 等等
* 几何体的复杂度(顶点数量)
* 当然还有GPU的显存带宽

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

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

减少绘制的数目

那么第一方面的优化就是减少顶点数量,简化复杂程度,具体的举措就总结如下:

* 保持材质(Material)的数目尽可能少。这使得Unity更容易进行批处理
* 使用纹理图集(一张大贴图里面包含了很多子贴图)来替代一系列单独的小贴图。它们可以更快的被加载,具体很少的状态转换,而且批处理更友好。
* 如果使用纹理图集和共享材质,使用 Renderer.SharedMaterial 来代替 Renderer.Material
* 使用光照纹理 Lightmap   而非实时灯光
* 使用LOD,好处就是对那些离得远,看不清的物体细节可以忽略
* 使用遮挡剔除(Occlusion Culling)
* 使用 Mobile 版的 Shader  。因为简单。

优化显存带宽

压缩图片,减小显存带宽的压力。

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

MipMap

这里介绍一下MipMap 是啥。因为有人说过MipMap 会占用内存的,但是为何会优化显存带宽呢?
其实一张图片就能解释 MipMap 到底是什么?(MipMap 是一种电脑图形图像技术,用于在三维图像的二维代替物中达到立体感效应)
这里写图片描述

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

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


三、内存的优化
既然要聊Unity3D 运行时候的内存优化,那我们自然首先要知道Unity3D游戏引擎是如何分配内存的。大概可以分为三个部分:

* Unity3D内部的内存
* Mono的托管内存
* 若干我们自己引入的DLL 或者 第三方DLL 所需要的内存

最后一个部分不是我们关注的重点
所以接下来我们分别来看一下Unity3D内部内存和 Mono托管内存,最后还将分析一个 从官网上 AssetBundle 的案例来说明内存的管理。

1.Unity3D 内部内存

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

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

2.Mono托管内存

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

* 值类型:int,float,struct,bool之类的。他们都是存放在堆栈上面的
* 引用类型:其实可以狭义的理解为各种类的实例。比如游戏脚本中对游戏引擎各种控制的封装。其实很好理解,C#中 肯定要有对应的类去对应游戏引擎中的控件。那么这部分就是C#中的封装。由于是在堆上分配的,所以会涉及到GC。

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

举一个例子:

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

一个WWW实例背后的资源:

* 压缩的文件
* 解压缩所需的缓存
* 解压缩之后的文件

如图:
这里写图片描述
那么下面就举一个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(&quot;WWW download had an error:&quot; + www.error);
            AssetBundle bundle = www.assetBundle;//AssetBundle是第2部分
            if (AssetName == &quot;&quot;)
                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)
    }
}

内存分配的3个部分,我已经标记出来了:

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

通过 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);

感谢分享,最初文章出处: http://www.cnblogs.com/murongxiaopifu/p/4284988.html

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值