移动游戏性能优化通用技法

1. 前言
php

  不少年前就想将这些年工做中积累的优化经验撰写成文章,但懒癌缠身,迟迟未动手,近期总算潜下心写成文章。html

  涉及到具体优化技巧时,我会尽可能阐述原理和依据,让读者知其然也知其因此然。程序员

  要彻底读懂这篇文章,要求读者有必定的计算机语言/图形学/游戏引擎基础。但愿读者看完后能将本身的游戏性能优化到必定的高度,使得游戏的效果和效率在高/中/低端设备都能符合或超出产品的预期。算法

  为了方便描述,下面不少地方我会以以前在西山居作的一款RTS移动游戏项目(简称游戏Z)举例,以Unity为图例,可触类旁通用到其余商业或自研引擎中。编程

1.1 性能优化的重要性windows

  想必玩过游戏的人会常常感叹:游戏怎么那么卡?手机怎么那么烫?耗电怎么那么快?数组

  其实这些问题都归结于性能问题,性能优化对游戏的重要性不言而喻。缓存

  对新游戏项目,中前期可能一直在赶需求赶效果,忽略了性能优化的工做,到中后期性能瓶颈必然会凸显。若是前期作到有效组织资源,制定美术规范,完善开发流程和工具链,后期优化起来会顺手不少,少走不少弯路。性能优化

1.2 优化Tips网络

  不少年前,我刚大学毕业,就听到一位图形界的大神说过:优化无止境,优化的最高境界是不渲染。

  之前不能彻底明白他的话,以为有点夸张,但我作过几个游戏项目的性能优化工做以后,深有体会,这么多年过去,言犹在耳,应验了真香定律!

  优化的本质就是不渲染少渲染用更省的方法渲染

1.2.1 分清主次

  优化性能首先要找出性能瓶颈,对性能影响最大的地方先优化,接着对次影响的进行优化,以此类推。

  若是能遵照这条规则,优化效果和花费时间的曲线关系大体以下图:

  

  即前期的优化效果会很是明显,但随着时间的推移,花的时间愈来愈多,优化的效果反而逐渐放缓。这告诉咱们:

  1. 首先找出性能瓶颈,优化效果最明显;

  2. 优化无止境,后期优化效果和时间比下降,要适可而止。

1.2.2 善用分析工具

  工欲善其事必先利其器。善用分析工具能够快速定位出性能瓶颈,达到事半功倍的效果。

  性能分析工具请参看:1.3 辅助工具

1.2.3 平衡好性能和效果

  每一个游戏偏重点不同,有些游戏偏重效果而不太讲究性能,有些游戏偏重效率而牺牲效果,有些游戏二者需兼顾。

  从个人经验而言,大多数游戏开发者或者玩家,更偏重性能。举个例子,大多数人在玩吃鸡游戏时,为了帧率稳定和耗电慢一点,将全部的画质参数开至最低。

  若为性能故,效果皆可抛。

1.2.4 定制优化参考指标

  每一个项目在优化前,须要定制一个具体的指标参数,好比高中低端机跑在什么设备上,要达到什么样的帧数等等。

  游戏Z定的参考指标以下表:

  

  此标准是2017年上半年定的,如今能够酌情放宽参数。

  定好标准后,就以此为依据进行优化,参数指标达到后便可认为优化任务完成。

1.2.5 作好效果等级管理

  建议将等级管理逻辑抽象成单独的模块,增长QualityManager的角色,负责统筹管理和实现等级相关的逻辑。其它模块要只要调用这模块的接口,能够轻易实现差别化逻辑。

  此外,QualityManager能够收集游戏关键参数:fps/网络Ping值/流量,对外提供查询接口。

1.2.6 具体状况具体分析

   每一个游戏项目的具体状况不同,不可生搬硬套本文涉及的参数和方法,否正可能拔苗助长,花费了时间,性能没能很好地提上去。

1.3 辅助工具

  目前市面上分析工具不少,特性和适用的平台都不一样,下面就简单介绍经常使用的工具,具体使用方法另行搜索。

1.3.1 引擎分析工具

  1. Unity的Profiler

  

  Unity Profiler能够查看CPU/GPU/内存/音频/物理/网络等模块的具体消耗参数,是Unity游戏必备的性能分析工具。

  2. Unreal

  Unreal 3及以前的版本要先用命令行生成profiler文件,再经过UnrealFrontend加载生成的文件查看消耗数。

  Unreal 4提供了相似Unity的Profiler的窗口,更加便捷。

  更多参看:Unreal Profiler Tool Reference

1.3.2 IDE分析工具

  1. Visual Studio的性能探查器

  

  VS只可运行在windows平台,可对当前项目/指定的exe/运行的进程进行CPU和GPU性能采样并查看具体的消耗指标。

  2. XCode的Instruments

  

  XCode只可运行在Mac OS,可调试Mac OS和iOS的APP的CPU/GPU/内存等性能参数。

  3. Android Studio的Profiler

  

  Android Studio可运行在Windows和Mac OS,但只可分析Android的APP,参数包括CPU/内存/网络等。

1.3.3 GPU厂商工具

  几乎每一个GPU大厂都提供了调试自家产品的GPU分析工具。它们与引擎和IDE的工具最大的不一样点是:能够查看每次Draw Call的渲染状态/引用的资源/绘制的画面,以及其它独特参数。

  1. Tegra Graphics Debugger(NV)

  

  工具自己可运行在各个主流系统,也能够分析OpenGL和Vulkan,但只支持NV旗下的Tegra K1和X1系列GPU。

  2. Mali Graphics Debugger(Arm)

 

  相似于Tegra Graphics Debugger, 但全面支持OpenGL的各类版本,还支持Vulkan和OpenCL的调试。

  3. PVRTrace(Imagination Technology)

  只可调试使用Imagination Technology公司GPU的App。

  4. Adreno Profiler(高通)

  只可调试使用高通旗下GPU的App。

  5. PerfHud(NV)

  只支持PC程序,很是强大的GPU调试工具,但没法调试移动设备,须要依赖模拟器。

  6. PerfHud ES(NV)

  PerfHud移动版,支持移动设备调试, 须要下载整个CodeWorks for Android开发包。

1.3.4 其它第三方工具

  1. PIX(MS)

  只运行在windows平台,只支持DirectX的分析。

 

2. 资源优化

  病从口入,资源比如是入口,它们若出现问题,会引起一连串性能问题。相反,资源如果优化得好,后面全部章节的性能均可受益。这也是把资源优化的章节提到最前的缘由。

2.1 纹理优化

  纹理优化的目的是让它们占用的内存尽可能的小,那么纹理加载进内存后,大小计算公式以下:

    纹理内存大小(字节) = 纹理宽度 x 纹理高度 x 像素字节

    像素字节 = 像素通道数(R/G/B/A) x 通道大小(1字节/半字节)

  从上面公式能够看到,纹理加载进内存后的大小跟尺寸/像素通道数/通道大小都有关系,咱们就从它们着手优化,还能够经过提升复用率和合成图集达到优化的目的。

2.1.1 纹理尺寸

  美术常犯的一个错误是无论什么角色什么场景,都会给模型贴上很大尺寸的贴图,一般大于1024x1024。

  

  好比游戏Z是斜45度固定视角对战游戏,在画面中角色渲染所占的画面很小(约150x150),但美术在最初给全部角色模型都制做了1024x1024的贴图。

  根据项目实际状况,我将全部角色贴图都缩小至256x256,角色贴图占用缩小至1/16。

  除了角色贴图,武器/装备/特效/场景等等全部涉及的贴图都缩小至合适的大小。这里的合适大小是指渲染对象在画面中大多数状况下不可能达到的最大尺寸,这个尺寸最好保持2的N次方。

2.1.2 纹理通道

  通道优化的目的是下降像素所占的大小,能够经过如下方法达到目的:

  1. 去除Alpha通道。能够减小通道数量,适用于不须要Alpha混合或Alpha Test的角色和物件。

  2. 应用单通道图。也能够减小通道数量,好比灰度图,地形高度图,掩码图,Shader掩码图等等。

  3. 使用16位代替32位图。例如RGB444/RGBA4444就能够减小像素通道大小。

  4. 压缩贴图适应不一样的平台。例如:

    Windows能够压缩成DDS,其中DDS细分DX1~DX5共5种格式,每种应用场景略有不一样,但它们只能用于DirectX。

    Android能够压缩成ETC1(不带Alpha)或ETC2(可带Alpha)。

    iOS能够压缩成PVRTC格式。

  它们都是GPU直接支持的纹理格式,能够显著减小内存/显存/带宽的占用。

  5. 避免使用JPG/高压缩率的PNG/GIF等低质量格式。由于当前主流商业引擎在游戏发布过程当中,会自动压缩全部纹理,而保留原画质的纹理能够减小纹理压缩后的画质损失。

2.1.3 提升纹理复用率

  如下方法提升贴图复用率:

  1. 创建共享图库。将通用的元素放至共享库,例如按钮/进度条/背景/UI通用元素等。

  2. 用九宫格图代替大块背景图。九宫格在游戏开发中是比较常见的UI组件。

  3. 纹理元素经过变换可组合成复合纹理。例下图,上下左右对称的背景图能够用4张相同贴图实例经过旋转/翻转后得到。

  

  4. 九宫格+UI元素能够组合成很复杂但消耗相对较小的UI界面。

2.1.4 纹理图集

  图集就是一堆小尺寸纹理元素合成的纹理贴图(以下图)。

  

  图集能够下降IO加载次数,也能够减小Draw Calls(详见4.2 Batch合批)。

  但也有反作用,一是可能超出设备支持的最大尺寸,二是可能出现大片空白像素(以下图)。

  

  对于反作用一,能够限制图集的最大尺寸(一般不要超过2048x2048),分拆成多张图集。

  对于反作用二,能够针对性地调整纹理元素的布局或尺寸,使得合成的图集尽量占满有效像素。

  适合生成图集的资源有:UI界面,道具图标,角色头像,技能图标,序列帧,特效等等。

  若是是Unity引擎,能够用SpritePacker很方便地生成和预览图集。

  若是是自研引擎,能够用TexturePacker的命令行工具合成。

2.2 UI

2.2.1 UI图集

  全部UI元素都生成图集,并且确保每一个界面生成单独的图集,这样能够在界面销毁时能够及时释放UI纹理。

  要尽可能确保每一个界面只引用到本身的图集和共享库图集,避免引用到其它界面的图集。

  

  上图所示,界面A引用界面A图集和共享图集是容许的,但尽可能不要引用界面B等其它图集。

  但实际在游戏开发过程当中,很难保证美术作到这一点,一般存在如下问题:

  1. 若是界面A确实要用到界面B图集的某个元素,怎么办?

  参考解决方法:要看被引用元素的通用度,若是只是界面A和B在用,能够将被引用元素拷贝到界面A图集下;若是其它界面也会引用到,就能够将它移到共享图库。

  2. 有些UI纹理很大且不少界面都有用到,若是放在共享图库会致使共享图库急剧膨胀,怎么办?

  参考解决方法:大尺寸纹理建议用九宫格+细节图,或经过组合的方式来代替。

  3. 如何保证美术制做的UI只引用到自身图集和共享图集?

  参考解决方法:实现批处理检查工具,找出每一个UI界面引用到的图集列表,引用的图集超过2个即是不合格。

2.2.2 UI层次

  因为UI元素不少,主流商业引擎都会对它们合批以减小Draw Calls,但合批优化是有条件的:

  1. 使用相同的材质。使用引擎默认UI材质+UI图集能够知足这个条件。

  2. 绘制顺序是连续的。UI的绘制顺序一般就是在场景中的节点顺序。

  下图有4张UI图片,但它们都用了系统默认材质,都是共享图集的元素,而且它们在场景中的顺序也是相连的,因此知足合批优化的条件,最终SetPass Calls(Draw Calls)是1。

  

  然而,在实际制做UI过程当中,常常会破坏UI合批优化的条件:

  1. 同个界面每每会引用自身图集和共享图集,破坏优化条件1。

  2. 界面一般都带有文本,而文本一般是引擎自动生成的另一张或若干张图集,破坏优化条件1。

  3. UI节点层次混乱,不一样图集的元素相互交叉,破坏优化条件2。

  4. 部分UI元素使用了自定义材质或Shader,破坏优化条件1。

  分析了缘由,在UI制做时,就要尽可能避免这些状况发生。

2.2.3 UI的其它优化

  1. 禁用MipMaps。MipMaps的原理是根据绘制对象在绘制空间的大小选取合适的纹理层级,它会增长30%的内存/显存开销。而UI一般都是等长等宽的,跟摄像机距离无关,因此要禁用UI的MipMaps。

  2. 保持UI纹理的原始尺寸。缩放一般会带来额外的开销,并且会使UI变模糊,下降画质。

  3. 避免使用大尺寸的背景图。大背景图耗内存,一般还不能共用。可用九宫格+细节图组合而成。

2.3 字体

  说字体是性能的杀手绝不为过。

  游戏使用的字体通常是ttf格式,单个ttf字库少则5~10M,多则10~20M。

  在文本绘制前,引擎会将字库加载进内存,占用较大的内存空间;在文本绘制时,引擎会在内存中开辟若干张纹理图集缓存字体纹理。

  每一个字至少要两个三角形,如果有阴影/描边/发光等效果,三角形数量扩大数倍之多(以下图)。

   

  能够经过如下建议优化字体的性能:

  1. 控制字体文件数量。除了系统默认字体,自定义字体控制在1~2个为宜。

  2. 少用字体的阴影/描边/发光等效果。

  3. 剔除字库中无用的字形。能够借助FontSubsetGUI或FontPruner给字库瘦身。

    

  字库瘦身更多参看这里

2.4 模型

  模型特别是带有骨骼动画的模型在性能消耗中占据很是大的比重,它们会显著增长CPU/GPU/内存/显存的负担。因此,模型的优化尤其重要。

  模型涉及的数据比较多,包含了顶点/索引/材质等,而顶点又可能包含pos/color/uv/normal/tagent/skin等数据,咱们能够从这些数据着手优化。

  

  上图的模型顶点包含了pos/color/uv/normal/skin等数据。

2.4.1 模型数据

  模型的顶点数据一般包含pos/uv,但color/normal/skin等数据视不一样类型的物件区分对待。好比,对于静态物体,能够去除skin;若是无需顶点变色,则能够去除color。

  模型内pos.xyz/uv.xy/color.rgba等等数据默认用32位浮点数存储,它们的数据表示范围远远超出大多数游戏的应用场景,能够将它们压缩至16位浮点数。

  模型的索引数据存储了三角形引用的顶点序号,默认用32位unsigned int存储,但绝大多数模型不可能超出16位unsigned short的范围,故用16位整型足矣。

  Unity引擎在Model的Import面板可设置优化参数(下图)。

  

2.4.2 模型辅助工具

  美术制做出的模型一般是高精度模型,虽然效果好,但每每在中低端机不须要这么高的精度,这时候就要借助一些工具进行优化。

  下面主要介绍Unity的工具,其它引擎应该有相似的工具。

  MeshBaker:模型合并插件,能够对多个模型合成一个模型,从而减小模型个数,下降Draw Calls。多用于静态物体合并,好比场景和地面静态物体。

  SimpleLOD:模型减面库,能够离线或运行时给模型进行减面优化,也能够方便地作成批处理工具。

2.4.3 模型美术规范

  一个模型尽可能只用一个材质,材质使用的贴图大小要合理,太大浪费内存,过小画质会模糊。

  建模时,剔除模型内部等不可见的顶点和三角面,合并重叠或相邻的顶点,减小模型的顶点数和面数。为防止美术制做的模型精度太高,有必要对模型的顶点和面数作限制。

  若模型带骨骼动画,需对骨骼数量作限制,单个模型的骨骼数量最好限定70个之内,否正不少低端设备没法支持GPU蒙皮。对模型进行分类,重要模型骨骼数能够多一些,次重要或不重要的更少骨骼数。

  游戏Z对各种模型的限制以下表:

  

2.5 场景

  地形如果不复杂(好比王者荣耀/LOL的战斗场景),尽可能不用Terrain,用简易模型代替,地表细节可用一张纹理表示,地表纹理取合适大小,一般不超过1024x1024。

  地形网格和地面静态物体去掉阴影,若是某些物件确实须要阴影,可让美术在制做地表纹理时加上阴影。

  地形有不少不可见的地方,能够删减那里的模型网格。

  地面一般有不少装饰物和特效,要关注它们的面数等规格是否超出了限制。

  一个场景只用一个平行光,实时像素光不要超过一个。用Lighting Map代替实时光,Lighting Map纹理尺寸不宜太大。

  对场景的面数和物体数量作限制。使用合批工具离线将地表类似的静态物体合并,减小场景复杂度。

  对场景使用画质分级策略,好比低画质下,用最低模的场景,隐藏场景特效等。

  地表若有导航网格,导航网格能够采用更精简的模型,复杂的边缘能够简化成简单的几何多边形。

  

  上图展现的是游戏Z的一个场景,虽然地表物体比较复杂,但地形网格(绿线所示)很简单,用少许的三角面表示了复杂的地表构造。

2.6 粒子

  粒子特效也是性能的一个大杀手,主要体如今:

  1. 每帧计算量大。涉及发射器/效果器/曲线插值等,耗费CPU性能。

  2. 频繁操做内存。粒子在生命周期里,实例持续不断地建立/删除,即使有缓存机制下,依然避免不了内存的频繁读取和碎片化。

  3. 每帧更新数据到GPU。从Lock顶点Buffer到写入数据到GPU,会引起线程等待,也加剧CPU到GPU带宽的负担。

  4. 增长大量Draw Calls。粒子特效一般五花八样,使用不少材质,致使引擎没法合批优化,大量增长绘制次数。

  5. 致使Overdraw(过绘制)。粒子通常会开启Alpha Blend,在同屏粒子多的状况下,会形成严重的Overdraw。

  

  上图展现的是游戏Z主界面的过绘制状况,越白表明过绘制越严重,能够看出用了粒子的地方,颜色趋向白色。

  即然粒子致使性能严重降低,首先得从资源上着手优化和规范。具体方式有:

  1. 优化粒子属性。关闭阴影,关闭光照;若能够去掉纹理的Alpha通道,并关闭Alpha Blend和Alpha Test;

  2. 禁用粒子的高级特效。如模型粒子/模型发射器/粒子碰撞体等。

  3. 用最少的粒子效果器。关闭没必要要的粒子效果器,采用简单的方式代替。

  4. 控制粒子的材质数量。一个特效一般包含了若干个粒子系统,它们尽可能使用内置材质,使用相同的材质实例。

  5. 控制粒子的尺寸和贴图大小。能够粒子的尺寸,能够减缓过绘制,控制贴图大小,能够减小带宽和提升渲染性能。

  6. 制定粒子特效美术规范。下面是游戏Z的粒子规范。

粒子美术规范

 单个粒子的发射数量不超过50个。
 减小粒子的尺寸,面积越大就会消耗更多的性能。
 粒子贴图必须是2的N次方,尽可能控制64x64之内,极少许128x128或256x256,最大不超过256x256。
 尽量去掉粒子贴图的Alpha通道。
 尽可能不用Alpha Test。
 尽可能使用已有的材质,提升合并渲染的优化几率。
 材质优先用Mobile目录下的材质。
 尽量不用模型作粒子,若是使用,要控制模型面数在100之内,最大粒子数在5之内。
 单个特效渲染数据限制:
   小型特效(如受击特效、Buff特效)的面数和顶点数在80之内,贴图在64*64之内,材质数2个之内。
   中型特效(如技能特效)的面数和顶点数在150之内,贴图在128*128之内,材质数4个之内。
   大型特效(如全局特效、大火球)的面数和顶点数在300之内,贴图在256*256之内,材质数6个之内。

2.7 材质

  材质若控制很差,会破坏引擎的合批优化,提升渲染消耗。因此在项目前期,就有必要对材质作管理和规范。

  1. 充分使用引擎内置材质。引擎内置材质能知足基本材质需求,并且一般作了优化,因此首选内置材质。若是是移动游戏,通常引擎也有移动版的材质库。

  2. 创建共享的自定义材质库。若是引擎内置材质不知足项目需求,就须要经过添加自定义材质来解决。对于自定义材质,要妥善管理,对它们进行分类,按规律命名。这样对材质共享和管理都大有裨益。

  3. 按期检查新加入的材质是否与已有的重复。若是有类似的材质,则删除重复的。这个工做能够由主程或主美执行,也能够经过工具协助检查。

2.8 制定美术规范

  制定美术规范时,需与主美/主策/制做人协商,结合项目具体状况,给出合理的美术规范参数,并撰写成文档。

  定好规范后,有必要定时检查项目里的全部美术规范是否符合规范,揪出不符合的资源,让美术修改。

  检查美术是否合规,能够写批处理工具,提升效率。

  特效/场景/角色资源若不能批量处理成高中低配版本,就建议美术为各个画质等级制做不一样的资源。

 

3. CPU优化

  性能优化最主要的一部分工做是CPU,CPU性能优化好了,离目标就成功了一半。

3.1 缓存计算结果

  缓存计算是空间换时间的经典应用,它适用于那些耗费大量CPU计算而计算结果无需每帧变化的逻辑。

  实现伪代码:

std::map<KeyType, ValueType> _cache; ValueType Calculate(KeyType key) { // 先尝试从缓存中获取结果,有就直接返回。
    if (_cache.count(key) > 0) { return _cache[key]; } // 缓存不存在,执行真正的计算,并缓存计算结果。
    ValueType res = DoCalculation(key); _cache[key] = res; return res; }

  适用场景举例:

  1. 复杂数学计算。Sin/Cos/Pow/Sqrt等运算要花费必定计算量,若是是第一次计算,能够将结果缓存起来,下次遇到相同的计算,直接从缓存中取值。

  2. 物理模拟结果。物体的物理模拟过程耗费大量计算,但有些物体模拟完以后就处于静止状态,能够将它以前的模拟结果存下来,防止每帧更新计算。现代主流商业引擎都支持这种优化。

  3. 光照贴图。光照贴图是离线将场景的静态光影计算并缓存成贴图,渲染时只须要采样光照贴图的颜色,极大下降了光照计算复杂度。

  4. 搜索结果。例如场景节点搜索,场景节点通常采用树形结构,若是查找的节点很深,将显著增长遍历次数,此时颇有必要将查找结果缓存起来。

  5. 逻辑模块复杂的计算。游戏的逻辑模块,涉及到复杂的计算均可尝试用缓存法下降CPU负担。

3.2 预处理

  缓存法利用空间换时间的思想,会增长内存开销;而预处理是将时间转移的思想,它并不会增长内存消耗。

  将须要花费大量时间加载或运算的逻辑,在启动程序后/加载场景时/切换界面前/进入战斗前等时机预先计算或加载,避免渲染时因CPU负载太高出现帧率波动或卡顿现象。

3.3 限帧法

  限帧法简单粗暴,但效果显著,是常见的一种优化手段。

  限制频率的对象能够是World.Update,物理模拟,粒子计算,角色AI处理,角色状态更新等等。

  限帧能够经过如下方法实现:

  1. 计数法。用一个变量记录更新次数,每累计到某个数才执行更新。

int _frameCounter = 0
void Update(float time) { _frameCounter += 1;   // 更新频率降为每10帧一次。
  if (_frameCounter % 10 == 0)   {     DoUpdate();      // 执行真正的更新
    _frameCounter = 0;  // 重置计数器
  } }

  2. 计时器。利用Timer机制触发,每隔固定时间触发一次更新。

  3. 协程。协程是运行于主线程的伪线程,但能够模拟异步操做,没有多线程的反作用。故而也能够用于限帧操做。

  4. 事件触发。每帧查询状态改为事件触发,也是游戏经常使用的一种优化手段,用来限帧也很是有效。

3.4 主次法

  主次法跟LOD技法有殊途同归之妙。思路也是将物件按重要程度划分为高中低级别,而后不一样级别采用不一样复杂度的效果或计算。

  这种思路在游戏中能够普遍应用,基本全部消耗高的逻辑或模块均可以采用这个技法。例如:

  1. 画质等级。将游戏分为若干等级,画质最高到最低采用不一样的渲染技术或资源,区别对待。

  2. 资源等级。场景/特效/灯光/物理效果/导航等等模块均可以根据画质等级或物件等级对应不一样级别的资源,总体上能够减小消耗,又能兼顾画质效果。

  3. 角色分级。主角英雄/Boss等和小怪物/NPC区分开来,前者更新频率更高,AI行为更复杂更智能,然后者采用简单效果或降频更新。

3.5 多线程

  

  上面这张搞笑动图相信不少人都看过,它形象生动地代表了当今设备多核常态化但主核承担大部分工做忙到“吐血”而其它核在打酱油的囧态。

  在游戏开发中,咱们能够将一些逻辑经过建立线程的方式独立出去,交给其它CPU核心处理,以缓解上面提到的现象。

  可独立成线程处理的模块:

  1. 文件IO。创建一个IO线程,定时检查文件队列是否有数据,如有便启动IO加载,直至文件队列为空,又回到空闲轮询状态。主线程就不会觉得文件IO而处于等待状态。

  2. 骨骼动画。若是同屏动画角色多,将占据大量CPU计算。可建立一个或多个线程处理骨骼动画计算,加速渲染流程。但随之而来的是同步问题。

  3. 粒子计算。粒子的多线程跟骨骼动画相似,也存在同步问题。

  4. 渲染线程。将渲染抽离出一个线程,主要是解决CPU与GPU相互等待的问题。常见的一种作法是创建一个渲染线程,按期去查询渲染队列是否有数据,若是有就提交至GPU进行绘制。

  5. 音视频编解码。若是游戏有涉及音频频播放,而它们又占据了较大的消耗,那么开辟独立的线程处理音视频编解码是有必要的。

  6. 加密解密。加密解密涉及的算法一般较复杂,占用较多CPU性能,并且经常伴随着文件IO或网络IO,因此此时很是有必要将它们交给独立的线程处理。

  7. 网络IO。目前大多数游戏都会开辟一个线程专门收发网络数据,以免网络处理影响主线程。

  值得注意的是,线程切换会带来额外开销,同步和死锁问题也会提升逻辑复杂度和调试难度,是悬在程序员头上的一把大刀。

3.6 引擎模块

  引擎内部最耗CPU性能的模块一般有:骨骼动画/粒子计算/物理模拟/导航网格/相机裁剪和渲染等等。

3.6.1 动画

  下降动画采用频率。

  减小关键帧数据。

  缓存动画的插值结果。

  用简单曲线插值(线性插值)代替复杂插值(贝塞尔曲线)。

  判断动画所附对象的可见性,如不可见,则不更新动画。

  控制同屏动画的个数。

  不一样画质加载不一样级别的动画资源。

3.6.2 物理

  静态物理只用静态碰撞体。

  下降物理模拟频率。

  禁用复杂的碰撞体(如模型碰撞体),取而代之的是用若干个简单几何碰撞体组合。

  若物体不可见,则关闭物理模拟。

  控制同屏物理物体个数。

  对物体的重要程度作分级,重要性低的角色采用简单物理效果或者删除物理效果。

  Raycast射线检查虽好用,但性能消耗也高,要尽可能下降视频频率,防止重复调用。

3.6.3 粒子

  粒子资源优化:详见2.6。

  部分粒子特效考虑转成序列帧渲染。

  考虑是否可用缓存/预加载/预计算的优化方式。

  对特效的重要程度分级,不重要的粒子不计算或者降频。

  若GPU的负担比CPU小,能够将粒子更新计算移至GPU端。

  粒子物体可见性判断,不可见则不计算和渲染。

3.6.4 导航

  简化导航网格,用最少的面数表达复杂地面的导航构造,好比用平面代替地面凹凸不平的路面。(详见2.5)

  寻路计算复杂,逻辑上须控制调用频率,避免扎堆寻路。

3.7 逻辑优化

  逻辑的消耗也是CPU负担高的罪魁祸首,主要体如今AI/算法/脚本等等模块。

3.7.1 AI优化

  为了简化玩家操做和让怪物更加拟人化,目前大多数游戏都加入了AI功能,而AI行为树(下图)是实现AI的关键。

  

  可是正常状况下,AI行为树要每帧更新,每次需从根节点搜寻,一直找到合适的节点才更新,最坏的状况会遍历整棵树。

  下面有几种方法优化AI行为树:

  1. 缓存当前Action节点路径。即将当前正在更新的节点和其父亲节点都缓存起来,下次要更新时优先这个路径搜寻,避免遍历整颗树。若是当前Action节点是持续性的,则这段时间内无需搜索节点,直接更新该节点便可。

  2. 下降AI的更新频率。即强制下降AI频率达到减负的目的,另外还能够尝试主次法/摊帧法/动态调帧法优化AI的更新。

3.7.2 算法优化

  思路是找出最耗CPU的算法或逻辑,优化之。

  1. 空间换时间。利用预排序/预处理/缓存/动态规划等等思路换取CPU的性能。

  2. 选取更快的算法。属于数据结构和算法的范畴,思路是将O(n2)下降成O(n)或O(logn),具体能够参看《算法导论》《游戏编程算法与技巧》《游戏核心算法编程内幕》等书籍。

3.7.3 脚本优化

  一般引擎底层是用C++等Native语言实现,而脚本用动态语言(Java/C#/Lua/Python)实现,它们中间隔着一层厚重的模拟器或封装层。Unity引擎与C#之间的关系以下图:

  

  Unity在打包游戏时会经过Mono将C#代码生成IL中间语言,若是是iOS平台,还会经过IL2CPP生成C++代码。简单点说,Unity引擎核心和C#等脚本语言的交互要经过Mono厚重的中间层。由此产生了额外的开销,致使脚本语言运行效率低下。

  能够经过如下一些方法下降脚本的开销:

  1. 删除脚本内的空回调。即使脚本对象的回调函数为空,但也会产生引擎核心与脚本层的开销。

  2. 脚本对象若是引用其余对象,能够在初始化时缓存。

class Tester { private Object _obj = null; void init() { _obj = FindObject("MyObjectName"); // 初始化时先找到物体。
 } void update() { if (_obj) { _obj.update(); // 帧内直接访问。
 } } }

  3. 帧更新/循环语句内避免产生堆的临时对象。能够将临时对象移至循环语句外,或声明成类的成员,在初始化时赋值。

  4. 利用可见性回调。在可见性回调内作禁止/恢复比较耗时的操做。

  5. 字符串接很容易引发临时对象,需警戒。可采用更高效的拼接方式,如C#的StringBuilder。

3.7.4 条件测试

  条件测试主要用于耗时的调用优化,将每帧必然更新的操做,加入各类条件检查,以减小耗时操做的几率。

void Update() { DoCalculation(); // 耗时操做
} // 改为:
void Update() { // 加入各类条件测试
    if (_dirty && _visible && _moved && _timeInterval>0.1) { DoCalculation(); } }

3.7.5 避免重复

  游戏通常涉及的模块众多,角色状态机复杂,触发事件多且杂,每每会在同一帧内屡次调用同一个耗时API,引起额外的开销。

  能够经过条件测试,时间间隔,Log输出,调用栈调试等方法解决这个问题。

 

4. 渲染优化

  渲染优化的目的是减小Draw Calls,减小渲染状态切换开销,下降显存占用,下降带宽和GPU负担。

  在讲解渲染优化以前,先了解渲染性能消耗点。

  1. Draw Call数量。

  Draw Call有些引擎也称为SetPass Call。一个Draw Call就是游戏调用OpenGL/D3D等图形渲染的绘制API一次(如OpenGL的glDrawArray和glDrawElements)

  一次Draw Call完整地跑完了整个渲染管线(下图),期间要涉及的数据/状态/计算不少,绘制前会先建立各类GPU数据,还可能每帧更新这些数据,数据更新又涉及到带宽。

  

  因此,每帧Draw Call数量是衡量渲染性能的关键指标。

  2. 渲染状态切换。

  每次Draw Call前会对图形渲染层设置一系列的渲染状态,如是否开启深度测试/是否开启Alpha Test/是否开启Alpha Blend等等。这些状态经过图形渲染的驱动层最终应用到GPU中(下图)。

  

  从上图能够看到,应用程序(游戏)发送的渲染指令,会通过OpenGL/DirectX等图形层和显卡驱动层,最终才能应用到GPU硬件。

  因为当代显卡驱动作了不少工做:状态管理/容错处理/逻辑计算/显存管理等等,属于重度封装,会消耗较多性能。

  因此,尽量减小状态切换,是优化渲染性能的重要措施。

  3. 带宽负载。

  此处的带宽是指CPU通过主板总线传输数据到GPU的能力,单位一般是GB/s。固然GPU也可经过总线传输数据到CPU,但传输能力远远低于CPU到GPU。

  

  上图所示,CPU和GPU经过PCI-e总线相连,它们之间的传输能力是有上限的,这个上限就是带宽。若是绘制须要传输的数据大于带宽(即带宽负载太高),就会出现画面卡顿/跳帧/撕裂/延迟/黑屏等等各类异常。

  4. 显存占用。

  显存即显卡的内存,是集成在GPU内部的专用内存。一般用于存储顶点/索引/纹理/各类Buffer等数据。

  若是游戏显存占用太高,便会出现显存分配失败,致使画面异常甚至程序崩溃。

  5. GPU计算量。

  现代显卡基本都支持可编程渲染管线,涉及Vertex Shader/Geometry Shader/Fragment Shader(下图),还涉及光栅化/片元操做。因此,若是Shader过于复杂或者片元过多,会极大提升GPU计算量,下降渲染性能。

  

  了解渲染具体开销后,就能够用下面的方法着手优化。

4.1 合批(Batch)

  合批(Batch)是将若干个模型合成一个模型,从而能够只调用一次Draw Call的优化手段。合批解决的是Draw Call数量问题。

  合批的条件是全部被合的全部模型都引用同一个材质,否正没法正常合批。

4.1.1 离线合批(Offline Batch)

  离线合批就是在游戏运行前,先用工具把相关资源作合批处理,以减轻引擎实时合批的负担。

  适合离线合批的是静态模型和场景物件。如场景地表装饰面:石头/砖块等等。

  离线合批方式有:

  1. 美术利用专业建模工具合批。如3D Max/Maya等。

  2. 利用引擎插件或工具。如Unity的插件MeshBaker和DrawCallMinimizer,能够将静态物体进行合批。

  3. 自制离线合批工具。若是第三方插件没法知足项目需求,就要程序专门实现离线合批工具。

4.1.2 实时合批(Runtime Batch)

  不一样于离线合批,实时合批是游戏引擎在游戏运行期完成的。Unity引擎分为静态合批和动态合批。

  1. 静态合批(Static Batch)

  符合静态合批的条件有两个:一是模型有Static标记(即物体是静态的,不能有移动/动画/物理等),二是引用同一个材质实例。

  为了提升静态合批的几率,尽量将场景物件设为静态,而且相似的物件引用相同的材质。

  2. 动态合批(Dynamic Batch)

  动态合批是针对能够运动的模型,但有更苛刻的要求,例如Unity要求:

  • 模型少于300个顶点,少于900个顶点属性。
  • 不能有镜像Transform。
  • 使用同一材质实例(注意:是实例,相同的材质不一样的实例,也是不行的)。
  • 使用相同的光照图(Lightmaps)。
  • 不能用多Pass Shader。

4.1.3 合批反作用

  合批优化虽能下降Draw Calls,但也有反作用:

  1. 增长CPU消耗。须要消耗CPU计算将多个模型合成一个,还涉及材质排序和搜集等操做。

  2. 增长内存。须要额外开辟内存存储合成的模型。

  3. 合成的模型顶点数有限制。移动游戏一般用16位索引,若合成的模型超出16位无符号整数的范围,渲染会出现异常。

4.2 渲染状态优化

4.2.1 状态缓存

  在引擎侧,能够使用状态缓存减小渲染管线的切换。伪代码:

class RenderStateCache { public: void InitRenderStates(); { for (RenderStateType t=RenderStateType.begin; t<RenderStateType.end; ++i) { _renderStateCache[t] = GetRenderStateFromDevice(t); } } void SetRenderState(RenderStateType state, RenderStateValue value) { // 若是要设置的状态和当前缓存的同样,则忽略。
        if (_renderStateCache.count(state) > 0 && _renderStateCache[state] == value) { return; } _renderStateCache[state] = value; SetRenderStateToDevice(state, value); } private: std::map<RenderStateType, RenderStateValue> _renderStateCache; };

4.2.2 渲染状态建议

  1. 少用Alpha Blend。开启Alpha Blend了通常会关闭深度测试,没法利用深度测试剔除多余片元,致使片元数量增长,形成过绘制。因此要尽可能少用。

  2. 禁用Alpha Test。现代部分移动端GPU采用了特殊的渲染优化方式,如PowerVR采用Tile Based Deferred Rendering方式(下图右),而Alpha Test会破坏Early-Z优化技术,可用Alpha Blend代替。更多看这里

  

  3. 开启背面裁剪。背面裁剪能够将背向摄像机的面片剔除,减小顶点和片元的数据量。

  4. 开启MipMaps。开启后,渲染时会自动根据画面尺寸选择合适大小的纹理,从而下降带宽,也能够下降锯齿,提升画质效果。但UI界面不能开启,缘由见2.2.3。

  5. 关闭雾。只在固定管线适用。

  6. 少用抗锯齿。图形API内置的抗锯齿一般会增长纹理采样次数数倍之多(下图),因此要慎用。

  

4.3 控制绘制顺序

  控制模型绘制顺序的目的是充分利用深度测试,减小片元后续操做。特别是Early-Z技术的引入,此法效果更明显。

  绘制顺序是:先绘制已作好排序的不透明物体,再绘制Alpha Tested物体,最后渲染透明物体。

  伪代码:

void Render() { // 1. 绘制不透明物体
    SortOpaqueObjectsInViewSpace(); // 对不透明物体进行排序,须在相机空间,离相机近的排在前面。
    DrawOpaqueObjects();            // 绘制不透明物体,离相机近的先绘制。 // 2. 绘制Alpha测试物体
    SortAlphaTestedObjectsInViewSpace(); // 对Alpha测试物体进行排序,须在相机空间,离相机近的排在前面。
    DrawAlphaTestedObjects();          // 绘制Alpha测试物体,离相机近的先绘制。 // 3. 绘制透明物体(注意:绘制顺序跟不透明物体恰好相反)
    SortTransparentObjectsInViewSpace(); // 对不透明物体进行排序,须在相机空间,离相机远的排在前面。
    DrawTransparentObjects();            // 绘制不透明物体,离相机远的先绘制。
}

4.4 多线程渲染

  在单线程渲染架构中,CPU性能消耗太高会影响GPU的渲染帧率,反之,GPU渲染过慢也会让CPU一直处于等待状态。

  多线程渲染就是为了解决CPU和GPU相互等待的问题。

  以Metal/Vulkan等架构出现为界限,将它们分红两个阶段。

4.4.1 软件级多线程渲染

  早期的图形API和硬件架构都不支持多线程渲染,此阶段多线程渲染能作的优化比较受限,只能将渲染提交独立成一个线程,使之不会卡逻辑线程。

  开源图形渲染引擎OGRE的多线程渲染实现方式有两种:

  1. Middle-level Multithread。

  每一个渲染物体都有两份实例,主线程改变其中一份数据,在下一帧给渲染线程使用。(下图)

  

  这种方式实现很复杂,要维护物体的两份实例,也不容易在多核CPU作扩展,不能充分发挥多核CPU的优点。

  2. Low-level Multithread。

  

  这种实现方式是将渲染物体的顶点等数据拷贝一份,逻辑线程修改其中一份数据,下一帧给渲染线程使用。

  除了以上两种方案外,能够给逻辑线程的若干逻辑(如Update/粒子/动画)开辟多个线程(下图),并行计算,缩短总体处理时间。

  

4.4.2 硬件级多线程渲染

  近几年,Metal/Vulkan图形架构横空出世,基于硬件级别的多线程渲染的时代终于到来。它们的特色:

  1. 轻量化的驱动层。

  OpenGL的API和驱动作了不少逻辑封装,用状态机的方式实现渲染(下图左)。而Metal/Vulkan与之不一样的是,在驱动层只作少许的工做,为应用程序提供直接访问GPU硬件的接口,属于轻量级封装(下图右)。

   

  从API架构上看,Metal/Vulkan的性能已胜出一大筹。

  2. 支持硬件级的多线程渲染。

  Metal/Vulkan支持并行渲染指令,方便CPU各个线程各自提交渲染指令和数据。

  下图展现的是其中一种渲染方式,由多个线程建立不一样的绘制命令,再由单独的线程管理渲染命令队列,统一提交给GPU绘制。

  

  因为图形API已经支持多线程渲染指令提交,再结合上一节讲到的若干方案,将如虎添翼,渲染性能也会发生质的提高。

  目前主流商业引擎已经支持Metal/Vulkan,Unity2018.3已经支持Metal/Vulkan:

  

  Unity在Rendering设置面板能够开启多线程渲染:

  

4.5 光照模型(Lighting/Illumination Model)

4.5.1 Flat Shading(平面着色)

  根据表面法向量计算光照,并应用到整个面片上。速度最快,效果最差,容易暴露物体的多边形本质(下图)。

  

4.5.2 Gouraud Shading(高洛德着色)

  根据顶点法向量计算光照,再用插值计算出整个面的光照。效果比Flat shading稍好,但高光部分有瑕疵,过渡不够天然(下图)。

  

  可结合Phong Shading作优化,高光弱时用Gouraud Shading,高光强时用Phong Shading,可平衡效果和效率。

4.5.3 Lambert Shading(兰伯特着色)

  物体表面向各个方向等强度的反射光,这种等同地向各个方向散射的现象称为光的漫反射。

  Lambert定律:反射光线的强度与表面法线和光源方向之间的夹角成正比(下图)。它是一种理想的漫反射模型,但着色效果比高洛德要平滑。

  

  计算公式:

4.5.4 Half Lambert Shading(半兰伯特着色)

  Lambert着色有个缺陷,就是背面受光少,常常处理死黑状态,与受光面反差太大(下图左)。

  因而其中的一种改进方案诞生了,它就是Half Lambert Shading,它渲染的画面明暗关系没那么强烈,过渡更加天然(下图右)。

  

  计算公式:

  其中a和b是常数,一般都取0.5,并且a+b = 1.0。

4.5.5 Phong Shading(冯氏着色)

  Phong着色将光照分红自发光(Emissive)/环境光(Ambient)/漫反射(Diffuse)/高光(Specular)四个部分,每一个部分独自计算光照贡献量。是当前普遍应用的一种光照模型。

  

  其中高光计算公式:

  Phong着色效果以下:

  

4.5.6 Blinn-Phong Shading

  因为Phong模型要用到反射矢量r,而r计算比较耗时(下图),故有了Blinn Phong。

  

  Blinn Phong是Phong的一个改进,作法是摒弃反射矢量r,引入l和v的中间矢量h,而后利用n和h的夹角进行计算。

  

  高光计算公式:

  它渲染出的高光范围更大(下图),真实感不如Phong着色,但胜在效率更高。

  

 4.5.7 光照模型的选择

  从性能上作比较:Flat > Gouraud > Lambert > Half Lambert > Blinn-Phong > Phong。

  但画质效果恰好相反,因此每一个游戏需根据具体需求作选择。也能够采用分级策略,高中低画质分别采用不一样的光照模型。

4.6 渲染路径(Rendering Path)

4.6.1 经典顶点光(Legacy Vertex Lit)

  严格来讲,它也是前向渲染的一种,但有些引擎(如Unity)将它单独抽离出来。因为光照计算在顶点,因此效果和消耗跟4.5.2 Gouraud Shading相似,是早期GPU使用较多的一种渲染方式。

4.6.2 前向渲染(Forward Rendering)

  前向渲染是传统的一种渲染方式,受到普遍的硬件支持。它渲染的思路就是按照渲染管线的流程一步步渲染,最终将颜色绘制到Render Target(下图)。

  

  它的消耗跟物体数量和灯光数量有关,是O(Nobject * Nlight)的关系,对于灯光数量较多的场景,显得力不从心。

  光照计算伪代码:

Color color = Color.black; for each(light in lights) { for each(object in objectsEffectedByLight) { color += object.color * light.color; } }

  有些引擎(如Unity)在灯光数量多的状况下,会作一些优化:对全部灯光按亮度进行排序,将最亮的那部分灯光作逐像素计算,中间的一部分作逐顶点计算,排在后面的用球谐函数(SH,Spherical Harmonics)模拟。(见下图)

  

4.6.3 延迟渲染(Deferred Shading)

   延迟渲染的精髓在于将灯光计算延后,与场景物体数量解耦。

  具体作法是:先将全部物体渲染一遍,但不计算光照,将物体渲染后的像素数据(Position/Normal/DiffuseColor和其余参数)存于各自的GBuffer;而后,利用这些数据采用后处理方式作光照计算。(下图)

  

  实现伪代码:

// 第一遍:渲染物体不带光照的数据,存于各自GBuffer。
for each(object in objects) { RenderObjectDataToGBuffers(object); } // 第二遍:将全部光照对全部像素作计算。
for each(light in lights) { Color color = Color.black; for each(pixel in pixels) { color += CalculateLightColor(light, pixelDatas); } WriteColorToFinalRenderTarget(color); }

  因为最耗时的光照计算延迟到后处理阶段,因此跟场景的物体数量解耦,只跟Render Targe尺寸相关,复杂度是O(Nlight * Wrendertarget * Hrendertarget)。

  延迟渲染并无在低端设备支持,它要求OpenGL ES 3.0以上,多渲染纹理以及更多的显存和带宽。

4.6.4 基于瓦片的延迟渲染(Tile-Based Deferred Rendering,TBDR)

  针对Deferred Shading的缺点,出现了一种改进方案,它就是Tile-Based Deferred Rendering。此种渲染方式已普遍应用于GPU图形渲染架构中。

  实现思路:

  1. 将渲染纹理分红一个个小块(Tile),一般是32x32。

  2. 根据Tile内的Depth计算出其Bounding Box。

  3. 判断Tile的Bounding Box和Light是否求交。

  4. 摒弃不相交的Light,获得对Tile有做用的Light列表。

  5. 遍历全部Tile,计算每一个Tile有做用的Llight列表的光照。

4.6.5 渲染路径总结

  前面已经描述了各个方式的优缺点,下面详细列出它们的性能消耗及平台要求。

  

  此外,还有Forward+,Physically Based Rendering(PBR),Legacy Deferred Rendering(Unity)等渲染方式,这里不详细描述,有兴趣的能够找资料了解。

4.7 场景管理和遮挡剔除

4.7.1 场景管理

  场景管理的是在游戏场景内全部具备空间属性的物体。目的是为了快速查找物体,减小物体更新,加快物理碰撞,以及渲染的遮挡剔除。

  常见的场景管理方式有二叉空间分割树(BSP)/四叉树(平面空间)/八叉树(三维空间)/入口(Portail)。

   

  上图左展现的是四叉树,图右展现的是八叉树。具体的原理和实现方式这里不描述,有兴趣的另行搜索。

4.7.2 遮挡剔除(Occlusion Culling)

  遮挡剔除技术是将不在相机视截体内的物体进行剔除,不送入渲染管线处理,从而减小不少渲染物体。

   

  上图左是没有启用遮挡剔除的场景,右图是启用剔除后的场景,能够看出,超过一半的物体被剔除渲染,优化效果很是明显。

  与场景管理(4.5)的方式结合,能够实现快速剔除算法。

  Unity的遮挡剔除须要设置遮挡体(Occluder)和被遮挡体(Occludee)。详见这里

4.8 阴影

  阴影的实现方式有不少种,消耗和效果各异。

4.8.1 贴图阴影

  贴图的方式最简单,作法是制做一张阴影纹理,放到物体脚下(下图),跟随物体一块儿运动。

  

  贴图阴影渲染很是简单,只须要两个三角面,适用于低端机型。

  若是地面是起伏不平的,贴图会被地面遮挡,能够将阴影贴图用贴花技术紧贴地面。

  但贴花依然有可能跟其它动态物体发生异常遮挡。

4.8.2 Projector(投射阴影)

  Projector技术是预先指定光源位置和截头体(Frustum),而后算出物体在其它物体的投影。(下图)

  

  它能够将物体投影到任意平面上,但跟贴图阴影同样不能表达被投影物体的轮廓。适用于中等画质效果。

4.8.3 Shadow Map(阴影图)

  

  阴影图技术是将物体放入灯光空间(上图)渲染,获得灯光空间的深度图(也称阴影图),而后在正常渲染时只要让某个片元在灯光空间的深度与阴影图作比较,就可判断出该片元是否处在阴影之中(下图)。

  

  Shadow Map技术能够渲染物体在任意平面的投影,渲染的效果最接近真实世界(下图),但性能消耗会高不少,它会增长Draw Calls,增长显存占用。一般适用于高端机型或重要角色。

  

4.8.4 阴影的分级策略

  ShadowMap效果最好,但最耗性能,而贴图方式恰好相反。

  这就要根据项目具体状况作出分级策略,针对不一样画质不一样重要程度的物体采用不一样的阴影渲染方式。下表是游戏Z的分级策略。

  

4.9 带宽优化

  带宽优化的目的是减小CPU与GPU之间的数据传输。

4.9.1 LOD(Level Of Detail)

  LOD即细节层次,根据物体在画面的大小选用不一样级别的资源,以减小渲染和带宽的消耗。

  LOD在图形渲染中应用普遍,适用的对象有模型LOD,地表LOD,材质LOD,植被/树LOD,灯光LOD,纹理LOD(MipMaps)等等。

  下图所示的是物体离相机愈来愈远,采用不一样LOD的物体,其中LOD0精度最高,LOD2精度最低。

  

  Unity引擎是经过LODGroup组件实现模型LOD的。(下图)

  

4.9.2 GPU Instance

  GPU Instance技术应用于绘制相同Mesh的多个实例,每一个实例均可以有独自的参数(如Color/Position/Scale等)。这种技术能够减小带宽,只需传入一份Mesh数据,就能够绘制任意多个实例。

  经常使用于绘制建筑,地表装饰物,粒子,草,树,植被等等。

  

  上图所示的场景共有1900多个小球,但Batch数只有24,这就是开启了GPU Instance的效果。但它对平台有必定要求:

  

4.9.3 GPU Skin

  CPU Skin最基本的角色动画实现方式,它能够方便地实现很复杂的动画操做,如融合/渐隐/组合/串接等等。但它的缺点也显而易见,占用大量CPU计算性能,难以并行计算,每帧需传送模型顶点数据到GPU,增长带宽负载。

  GPU Skin的作法是将骨骼矩阵列表做为Uniform传入Shader,而后在Vertex Shader中对模型顶点进行蒙皮计算。它能够并行计算,减轻CPU负载,此外,因为每帧传入GPU的数据是骨骼矩阵,不是顶点数据,极大下降了带宽负载。

  固然,GPU Skin也有缺陷。它会提升GPU负载,最高骨骼数每每受限于平台(如OpenGL ES 2.0不能超过250个Vector4数据量),并且也不利于作复杂的动画操做。

  Unity引擎能够在PlayerSettings面板开启GPU skin(下图)。

  

  此外,GPU Skin还能够结合GPU Instance技术,以渲染大量相同角色的骨骼动画。详见这里

4.9.4 GPU粒子

  GPU粒子的优缺点和GPU Skin相似,支持粒子的并行运算,减小带宽负载,但它一样难以实现粒子的高级特性(软粒子/碰撞等)。

  Unity对模型粒子作了特殊优化,支持GPU Instancing(下图)。

  

4.9.5 正确使用Buffer标记

  OpenGL的Buffer标记有如下几种:

GL_STREAM_DRAW
GL_STREAM_READ
GL_STREAM_COPY

GL_STATIC_DRAW
GL_STATIC_READ
GL_STATIC_COPY

GL_DYNAMIC_DRAW
GL_DYNAMIC_READ
GL_DYNAMIC_COPY

  1. DRAW:Buffer数据将会被送往GPU进行绘制。

  2. READ:Buffer数据会被CPU应用程序读取。

  3. COPY:Buffer数据会被用于绘制和读取。

  4. STATIC:一次修改,屡次使用。可用于静态模型顶点数据。

  5. DYNAMIC:屡次修改,屡次使用。可用于带动画的模型顶点数据,粒子系统的数据。

  6. STREAM:屡次修改,一次使用。可用于特殊场合,好比编辑器数据。

  Buffer的标记各有用途,因此选择合适的类型能够减小带宽负载和显存占用。

4.10 Shader优化

  1. 避免使用耗时的数学运算。如pow,exp,log,sin,cos,tan等等。

  2. 使用更低精度的浮点数。OpenGL ES的浮点数有三种精度:highp(32位浮点), mediump(16位浮点), lowp(8位浮点),不少计算不须要高精度,能够改为低精度浮点。

precision mediump float; // Defines precision for float and float-derived (vector/matrix) types.
uniform lowp sampler2D sampler; // Texture2D() result is lowp.
varying lowp vec4 color; varying vec2 texCoord; // Uses default mediump precision.

  3. 禁用discard操做。缘由见4.2.2。

  4. 避免重复计算。

precision mediump float; float a = 0.9; float b = 0.6; varying vec4 vColor; void main() { gl_FragColor = vColor * a * b; // a * b每一个像素都会计算,致使冗余的消耗。
}

  5. 向量延迟计算。

highp float f0, f1; highp vec4 v0, v1; v0 = (v1 * f0) * f1; // v1和f0计算后返回一个向量,再和f1计算,多了一次向量计算。 // 改为:
v0 = v1 * (f0 * f1); // 先计算两个浮点数,这样只需跟向量计算一次。

  6. 充分利用向量份量掩码。

highp vec4 v0; highp vec4 v1; highp vec4 v2; v2.xz = v0 * v1; // v2只用了xz份量,比v2 = v0 * v1的写法要快。

  7. 避免计算数组下标。在shader使用动态下标会致使较大的开销。

  8. 警戒动态纹理采样(Dynamic Texture Lookup,也叫Dependent Texture Read)。也就是说在shader中,纹理坐标作了更改,就是动态纹理采样(也称依赖式纹理读取)。在OpenGL ES 2.0的架构下,动态纹理采样会出现较大的性能问题;3.0则没有这问题。详细看这里

varying vec2 vTexCoord; uniform sampler2D textureSampler; void main() { vec2 modifiedTexCoord = vec2(1.0 - vTexCoord.x, 1.0 - vTexCoord.y); // 纹理坐标改变了
    gl_FragColor = texture2D(textureSampler, modifiedTexCoord);    // 触发了Dynamic Texture Lookup/Dependent Texture Read。
}

  9. 避免临时变量。

  10. 避免使用for等循环语句。能够尝试展开。

  11. 尽可能将Pixel Shader计算移到Vertex Shader。例如像素光改为顶点光。

  12. 将跟顶点或像素无关的计算移到CPU,而后经过uniform传进来。

  13. 分级策略。不一样画质不一样平台采用不一样复杂度的算法。

4.11 UI优化

  UI除了资源优化(2.2)以外,能够在渲染上作一些优化措施。

  1. 避免不一样图集的控件交叉。交叉会增长draw calls,因此要避免。

  2. 动态区域和静态区域分离。即文字/道具等动态控件放在同一层,而其它静态的控件尽可能放至同一层,能够提升合批的几率。

4.12 其它渲染优化

4.12.1 避免后处理

  后处理是场景物体渲染完成后,对渲染纹理作逐像素处理,以便实现各类全屏效果,包含如下效果:

  • 抗锯齿:Anti-aliasing (FXAA & TAA)
  • 环境光散射:Ambient Occlusion
  • 屏幕空间反射:Screen Space Reflection
  • 雾:Fog
  • 景深:Depth of Field
  • 运动模糊:Motion Blur
  • 人眼调节:Eye Adaptation
  • 发光:Bloom
  • 颜色校订:Color Grading
  • 颜色查找表:User Lut
  • 色差:Chromatic Aberration
  • 颗粒:Grain
  • 暗角:Vignette
  • 噪点:Dithering

  Unity的后处理栈:

  

  虽而后处理能够渲染出很是多的很酷很真实的效果,可是消耗也不容小觑。

  特别是在移动端,因为移动设备硬件架构的特殊设计,会致使更为严重的性能问题,主要缘由是:

  1. 更慢的依赖式纹理读取(slower dependent texture reads)。关于依赖式纹理读取的解释看这里

  2. 缺乏硬件特性(missing hardware features)。

  3. 额外的渲染纹理解析消耗(extra render target resolve costs)。

  因此移动游戏要尽可能避免使用后处理。

4.12.2 降分辨率

  降分辨率是最粗暴最有效的提高渲染性能的方法。

  因为当前不少智能设备分辨率都是超高清,动辄2K以上,但CPU/GPU却跟不上,若是使用原始屏幕分辨率,就会出现严重的卡帧/掉帧现象。

  一般能够将屏幕分辨率降到一半,这样渲染纹理/深度Buffer/纹理等等数据均可以缩减到原来的1/4,极大下降了CPU/GPU/带宽各项指标的消耗。

 

5. 内存优化

  内存优化目的是加快IO,防止卡主线程,防止频繁操做(建立/删除)内存,避免内存碎片化和占用太高。

5.1 缓存法

  与CPU的缓存计算相似,思路是将须要重复建立的对象缓存起来,销毁时将它放入缓存列表,再次建立时优先从缓存列表中读取。

  实现伪代码:

Array<Object> _objectCache; Object CreateObject() { // 先尝试从缓存中获取对象
    if (_objectCache.size() > 0) { return _objectCache.pop_back(); } return new Object(); } void DestroyObject(Object obj) { _objectCache.push_back(obj); // 删除时将其放入缓存列表。
}

  缓存法能够下降内存的建立/删除频率,避免碎片化。

  经常使用于数量多且建立频繁的物体,如小兵,NPC,血条,特效,道具,各种图标等等。

5.2 内存池

  内存池技术是现代主流引擎的标配,目的是避免内存碎片化,加速内存分配和管理。

  实现思想一般是由引擎预先建立一块较大的内存(也可动态调整),这块内存经过有效的数据结构和算法策略,统一管理小块内存的分配和回收,并为逻辑层提供内存相关的操做接口。

  内存池实现的方式不少,各有优劣,不一而足。下图是其中的一种实现方式:

  

  分配的内存分为四个部分:第1部分是内存池结构体信息;第2部分是内存映射表;第3部分是内存Chunk缓冲区;第4部分是可分配内存区。更多参看这里

5.3 资源管理器

  资源管理器是将全部须要用到的文件资源统一管理起来,统一建立,加载,释放,回收等,为的是提升复用率,减小资源冗余和内存开销,也是现代引擎必备的一个模块。

  假如没有资源管理器,势必会形成资源的冗余,同一份资源可能存在不少分内存数据(下图)。

  

  上图所示中,每一个模型(Model)引用了一份网格(Mesh)内存数据,3个模型实例就有3份Mesh内存数据,形成Mesh内存资源的冗余。

  而有了资源管理器的统一管理,全部引用到文件资源的实例都指向了同一分内存数据(下图),避免了内存资源冗余,下降内存和IO消耗。

  

  资源管理器的实现比较简单,主要是运用模板将物体类型抽象出来,而后每一个物体类型用一个map<filePath, objectData>的表存储。具体实现这里不累述。

5.4 控制GC

  GC是Garbage Collect的简称,意为垃圾回收,是游戏引擎中采用必定策略回收内存池或托管堆里的无用内存和缓存区无用对象的一种技术。

  GC机制就是防止内存占用过多太久,是一种自动调节内存占用的经常使用技法。

  GC的触发通常分为两种:

  1. 引擎触发。通常是时间间隔到了,或者内存占有量到了某个阈值,引擎便会触发GC。

  2. 用户调用。一般引擎也提供了API给游戏应用,以便逻辑层能够控制GC的时机。例如Unity的GC.Collect()接口能够触发GC操做。

  可是触发GC须要遍历内存池/托管堆/各种缓存表,还可能引起内存碎片整理操做,因此它须要耗费必定的CPU性能,是引发掉帧和卡顿的罪魁祸首之一。

  那么,咱们就须要在逻辑层采用一些方法,避免触发GC,或者减小触发GC的处理时间。

  经常使用的方法:

  1. 避免频繁建立/删除。这个好理解,频繁建立删除对象,会引发不少内存碎片和无用对象,增长触发GC的概率和时间。

  2. 帧更新内尽可能避免临时对象和建立内存。

  3. for/while等循环内避免避免临时对象和建立内存。

  4. 尽可能避免申请大块内存。申请大块内存会致使内存暴涨,提高GC的概率。

  5. 避免内存泄漏。这个须要每一个技术人员的职业技能和觉悟,也能够经过一些辅助工具检查内存泄漏,详见1.3。

  6. 主动调用GC。好比在进入战斗先后,切换场景先后,切换主要界面先后调用GC,能够必定程度上减小内存占用,避免掉帧/卡顿。

5.5 逻辑优化

  逻辑优化的目标是尽可能避免无用的内存操做,防止内存泄漏,尽快释放内存,减小全局变量的使用,关注第三方库的内存消耗。

 

6. 卡顿优化

  相信不少研发者或玩家,都遇到这种状况:游戏大部时间运行都很流畅,但在战斗的某些时刻或者打开某些界面会卡一下,甚至卡好久。这个现象就是卡顿。

  引起卡顿的缘由有不少,但主要有:

  1. 突发大量IO。

  2. 短时大量内存操做。

  3. 渲染物体忽然暴涨。

  4. 触发GC。

  5. 加载资源量多的场景或界面。

  6. 触发过多过复杂的逻辑。

  避免或者缓解卡顿的技法也是围绕以上缘由展开。

6.1 降帧法

  跟3.3的方法相似,经过强制下降更新频率,减缓卡顿的时间。

6.2 摊帧法

  摊帧法就是原本须要在同一帧处理的逻辑分为若干份,分摊到若干帧去处理,从而缓解同一帧的处理时间,减缓卡顿现象。

  例如,原本在同一帧须要建立10个小兵,这个极可能会引起卡顿,那么能够每帧只建立2个,分摊到5帧建立完。

  适用此法的还有资源的加载,AI的更新,物理的更新,耗时逻辑的处理等等。

  此外,还能够用预处理(3.2),主次法(3.4)来避免卡顿。

6.3 限制数量法

  若是降帧法,摊帧法,预处理,主次法都没法解决现象,卡顿缘由又恰好是由于物体数量过多,那么限制数量就很是有必要了。

  作法就很是简单,当场景内建立某种物体(角色,特效,血条等)的数量到底最大值时,便强制再也不建立。

  此法可能会引发逻辑的一些错误和很差的游戏体验,需谨慎使用和处理。

6.4 逻辑优化

  若是卡顿是逻辑过于复杂引发的,就须要针对性地优化逻辑。每一个项目的逻辑不同,这里没法给出具体的优化措施。

6.5 IO优化

  因IO慢引发主线程等待,从而致使游戏卡顿的现象很是广泛,下面有一些经常使用的优化技法。

6.5.1 预加载

  将耗时的IO提早到某个时刻(游戏启动时,场景加载时,进入主界面时等)加载,好比有些角色资源大,能够在加载战斗场景时提早加载,以避免战斗过程当中卡顿。

6.5.2 异步加载

  将IO异步化,以免卡主线程。此技法应用很是广泛了,再也不累述。

6.5.3 压缩资源

  将原本零散的文件压缩成单个文件,或者对大文件利用必定算法(如哈夫曼编码)压缩,减小文件大小。这样也能够下降IO时间。

  固然,压缩资源也有反作用,需占用多一分内存,解压缩过程也要耗费额外的CPU。

6.5.4 多级缓存

  咱们都知道CPU的频率是最高的,目前家用PC的主频可达3.2GHz甚至更高,CPU内有L1~L3缓存,它们速度略有差异;内存的存取速度远低于CPU,通常是2~3GHz,约是CPU的1/10。硬盘存取速度又远低于内存,广泛是0.1Gb/s,远低于内存读取速度。而网络更慢,目前即使是光纤,也不过0.02Gb/s。

  一般咱们能操控的是内存/磁盘和网络的数据,因此只要关注它们的速度,它们的速度关系大体以下图。

  

  因此,多级缓存策略应运而生。

  作法跟缓存法相似,只是多了层磁盘缓存,实现伪代码:

map<string, ObjectType> _memoryCache; ObjectType CreateObject(string objectPath) { // 1. 先尝试从内存缓存中读取,有就直接返回。
    if (_memoryCache.count(objectPath) > 0) { return _memoryCache[objectPath]; } ObjectType obj = NULL; // 2. 再尝试从磁盘加载。
    if (FileExisted(objectPath)) { obj = LoadObjectFromFile(objectPath); _memoryCache[objectPath] = obj; return obj; } // 3. 最后才从网络下载
 DownloadObjectFromNet(objectPath); obj = LoadObjectFromFile(objectPath); _memoryCache[objectPath] = obj; return obj; }

6.5.5 控制Log

  游戏的Log一般会隔一段时间存档,若是逻辑处理很差,极可能引起卡顿。好比,每帧输出大量调试log,会引起频繁存档。

  游戏Z在早期,也曾发生卡顿现象,后来经Profiler分析发现是Log存档引起的。

  因此,有必要对Log作出一些优化。

  1. 避免帧更新输出Log。防止Log数据迅速膨胀引发频繁存档或增长存档时间。

  2. 改进Log存档机制。能够适当改进Log存档机制,好比每隔多少时间存档一次,或者Log数据到达必定量级触发。

  3. 创建Log等级。能够将Log分为Info,Warning,Error几个级别,不重要的log不存档。

  4. 异步存档。将存档Log的逻辑防止单独的线程,防止卡主线程。

  5. 避免无用的log。这就要在逻辑层控制log输出,避免无效的log。

6.5.6 JSON代替XML

  游戏数据存储通常有两种:二进制和文本格式。二进制格式数据量最小,但可读性和扩展性差,适合存储模型/纹理/字体/音频等数据。文本格式的特色跟二进制恰好相反,适合存储配置信息。

  最多见的文本格式有JSON和XML两种,其中JSON对比XML有诸多优势:

  1. 数据量少。表达一样的数据,JSON格式能够比XML少40%(见下)。

<?xml version="1.0" encoding="utf-8" ?>
<country>
  <name>中国</name>
  <province>
    <name>福建</name>
    <citys>
      <city>福州</city>
      <city>南平</city>
    </citys>    
  </province>
  <province>
    <name>广东</name>
    <citys>
      <city>广州</city>
      <city>深圳</city>
      <city>梅州</city>
    </citys>   
  </province>
</country>
{ name: "中国", provinces: [   { name: "福建", citys: { city: ["福州", "南平"]} },   { name: "广东", citys: { city: ["广州", "深圳", "梅州"]} } ] }

  2. 可读性更佳。上面两段分别是XML和JSON表达相同的数据,谁可读性更佳一目了然。

  3. 更快的解析。JSON由于数据量更小,IO也会更快,解析速度固然也更快。

  每一个游戏都有大量逻辑数据须要存档,好比角色信息,技能信息,场景信息,配置信息等等。这些数据若是适合用文本格式存储,首选JSON无疑。

6.6 使用进度条

  若是上面那些章节都没法解决卡顿现象,能够尝试使用进度条。

  思路是将卡顿逻辑抽离出来,分红若干阶段(step),每完成一个step,给一帧时间刷新UI进度条。固然也能够用异步方式实现。

  伪代码:

HandleStep1(); RefreshProgressBar(1 / n); WaitForNextFrame(); HandleStep2(); RefreshProgressBar(2 / n); WaitForNextFrame(); ... HandleStepN(); RefreshProgressBar(1); WaitForNextFrame();

 

7. 耗电优化

  游戏耗电和游戏卡并没有必然联系,有些游戏在某些设备上虽然运行很流畅,但发现耗电很厉害,玩了不到半个小时,电量已经出现警报。

  游戏耗电的缘由主要是由于:CPU占用广泛高,内存操做频繁,磁盘IO频繁,渲染消耗广泛高,致使带宽负载和GPU消耗高。

  前面介绍的章节基本能够下降耗电,也是优化耗电的必要措施。尤为是如下章节对耗电优化更明显:

2. 资源优化

3.3 限帧法

3.4 主次法

3.6 引擎模块优化

4 渲染优化

5.4 控制GC

6. 5 IO优化

  除了以上章节,还能够用动态调整帧率和画质的优化技法。

7.1 动态调整限帧

  游戏逻辑一般能够获取当前设备的电量,若能够,则每隔一段时间获取一次电量信息,能够统计出单位耗电量,若是发现单位时间耗电量太高,而游戏帧率又很高(好比大于50),能够主动下降帧率(好比30)。

7.2 动态调整画质

  作法跟动态限帧相似,只是调整的是画质等级,而不是帧率。固然也能够一块儿结合使用。

 

8. 网络优化

  网络优化的目的是让网络包更小,响应更及时,消耗更少流量,不卡主线程。

8.1 减小无用字段

  网络包中一般包含了不少信息,诸如角色位置,朝向,状态等。

  若是是2.5D游戏,则位置z份量能够弃掉;朝向只在xz平面上,因此只须要发送RotationY。

  经过这种减小无用字段,能够必定程度上下降网络包大小。

8.2 下降字段精度

  一般逻辑里的不少信息都是4字节,包括角色位置,朝向,技能或Buff信息等。但不少时候,这些信息不可能达到4字节数的最大值,能够压缩至2字节甚至1字节。

  好比,一样是位置,场景的尺寸一般在2字节数的表示范围内(-32512~32512),能够将位置的x/y/z压缩至2字节发送。一样地,朝向RotationY能够2字节表示。

8.3 避免重复发送

  游戏网络模块须有效限制部分协议在短期内重复发送,例如玩家在短期内按了不少次抽奖按钮。

  因此须要一种机制来限制。好比能够在网络协议定义时,加个标记,代表该协议不能在某个时间段内重复发送。

8.4 网络异步化

  开辟独立的线程处理收发网络协议包,是游戏常见的优化手段,能够避免与主线程相互等待。

8.5 压缩无效字节

  压缩无效字节是指经过一种方式剔除每一个字段内高位全0的数据。

  好比角色等级50,若是用int32表示,是00000000 00000000 00000000 ‭00110010‬,高位3个字节全是0,能够压缩至1字节。

  这里有一种压缩字节的方法,跟utf8编码方式相似。

 utf8的编码方式(x表明有效位):

1字节(最大有效位7)  :0xxxxxxx 
2字节(最大有效位11):110xxxxx 10xxxxxx 
3字节(最大有效位16):1110xxxx 10xxxxxx 10xxxxxx 
4字节(最大有效位21):11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 
5字节(最大有效位26):111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 
6字节(最大有效位31):1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 

  好比角色等级50,有效位是6,用1字节便够了,压缩成00110010‬(红色是压缩位标记)。

  若是是数字1000,int32表示为00000000 00000000 0000‭0011 11101000‬,有效位是10,须要2字节编码,压缩后是11001111 10101000‬(红色是压缩位标记)。

  采用这种压缩方式,广泛能够用1~2个字节取代4字节数据,压缩效果比较明显。

8.6 压缩协议包

  8.5压缩的是字段内数据,每一个数据包其实有不少相同的数字,能够用目前主流的压缩方法再对网络包作一次压缩。

  游戏最经常使用的压缩方法是zlib开源库,还能够用lz4方法。具体实现能够另外寻资料,这里不详述。

 

9. 总结

  看到这里,基本也就结束本文内容了,但愿此文能给各位游戏开发这带来实质的优化技法或者新的思路。

  固然还须要说一下性能优化常见的一些误区。

9.1 优化误区

9.1.1 文件小的图片占用内存也小

  有些人觉得图片文件小,它占用的内存也会小,因此有些人将图片转成高压缩率的jpg格式。

  那么这个见解和作法是否稳当呢?

  根据2.1章节,能够看出图片占用的内存大小跟图片的尺寸和像素格式相关,跟文件格式不要紧。

  因此,图片转成jpg只是压缩了文件大小,但并不能下降内存开销!

9.1.2 合批的数据越大越好

  有人会认为即然合批可以下降渲染消耗,是否是让合批后的数据越大越好,以便更多地下降Draw Call呢?

  答案是否认的。

  缘由有二:

  1. 移动游戏的模型索引一般作了优化,只用16位表示,也就是说若是合批后的顶点数超过65025,便会越界,致使渲染异常。

  2. 太大的数据量可能没法充分利用LOD,遮挡剔除等技术,致使过多的数据送入GPU,反而增长带宽和GPU消耗。

9.1.3 片元等同于像素

  片元(fragment)是GPU内部的几何体光栅化后造成的最小表示单元,它通过一系列片元操做(alpha测试,深度测试,模板测试等)后,才可能最终写入渲染纹理成为像素(pixel)。

  因此,片元不是像素,但有几率成为像素。

 

 

================ 全文终 ================

 

 

特别说明:

1. 部分图片来自网络,侵删。

2. 欢迎各路大神提供更多优化技法,我会归入合理建议,并将提议者列入感谢名单。

  感谢名单:

  暂无

3. 欢迎分享本文连接。

4. 未经本人赞成,禁止转载。

 

 

参考文献:

《OpenGL编程指南(第四版)》

《OpenGL ES 2.0编程指南》

《计算机图形学(第三版)》

《实时计算机图形学(第二版)》

OpenGL ES 2.0 Reference Pages

Unity3D Docs Manual

Unity3D Optimization

Understanding optimization in Unity

Performance Optimization for Mobile Devices

General Performance Tips

Unity 5 Game Optimization

50 Tips for Working with Unity (Best Practices)

移动游戏图形渲染分析工具

OpenGL渲染管线详细版

多线程渲染

游戏引擎多线程模型渲染解决方案

实时渲染中经常使用的几种Rendering Path

Shader中经常使用的光照模型

WWDC 2018:写给 OpenGL 开发者们的 Metal 开发指南

Lighting in vertex and fragment shader

OpenGLInsights-TileBasedArchitectures

基于GPU Skin的骨骼动画Instance的实现

Post Process Effects on Mobile Platforms

Best Practices for Shaders

内存池设计和原理

相关文章
相关标签/搜索