渲染优化
合并图集(Atlas)
离散纹理资源会打断引擎的渲染合批优化,建议将常用资源合并成2048*2048、1024*2048或1024*1024等尺寸的图集(atlas)。
合并原则: 小图,如尺寸在256*256以内的纹理。 图标,如人物头像,资源(gold, mine,stone, etc.)图标,Button背景,九宫资源等。 公共,将在各个模块常用的纹理资源放一张公共纹理中,在整个游戏生命期内常驻内存。 玩法,将不同玩法模块的资源单独合并到相应的图集,不同玩法模块中的资源很少共用,以玩法模块为单位进行合并图集,即能达到自动合批的效果,又不至于占用太多内存资源。
消除透明结点
透明结点是指getOpacity() == 0
的结点,透明节点会增加drawcall的数量同时增加GPU的渲染负担(注:GPU并不会主动丢弃全透明的像素而不参与blend操作),有两种常用的方式来产生透明结点: 1. setOpacity(0)
,主动设置为透明。 2. 通过cc.Action
由引擎设置为透明。
消除透明结点的方法目前需要因地制宜,线上引擎无法自动处理,游戏开发同学可以使用Snapdragon Profiler及Nsight来分析当前场景内的透明物体。一般可用的优化方法有如下:
- 在
setOpacity(0)
的地方使用setVisible(false)
替代。有部分需求可能是在隐藏node后,同时需要接受点击事件,这样可以使用不参与渲染的node代替,如cc.Node和cc.Layer。 - 由
Action
导致的透明node,一般可能是使用了FadeOut/FadeTo
,此时可以将FadeOut/FadeTo
封装到一个Sequence
中,在action结束后,处理同上,例如:
local fadeout = cc.FadeOut:create(5)
local callfunc= cc.CallFunc:create(function()
-- 全透明后隐藏label
label:setVisible(false)
end)
local seq = cc.Sequence:create(fadeout, callfunc);
label:runAction(seq);
调整Node节点的加载/addChild顺序
目前cocos2d-x采用临近合并drawcall的策略,即只合并相临可合并的节点,影响合批的因素有: 相邻两个Node使用Shader不同。 采用的Shader使用了自定义uniform或MVP矩阵。 相邻两个Node使用了不同的纹理。 blend方式,不同的BlendFunc不能合并渲染。 * ClippingNode、ScrollView和Layer等不参与合并。
因此可以在不改变布局效果的情况下手动调整addChild的顺序,如果是使用CocosStudio制作的,也可以在编辑器中调整。
如上图所示,这是原有的ABCD的渲染的渲染方式,由于纹理的原因A/B/C/D并不能合并批次进行渲染。但是如果在效果上存在这样的关系,B和C两者在视觉上没有先后关系,即C不会覆盖到B,则可以手动调整节点的创建顺序,如下图:
则这样可以做到AC被合并,BD也可以被合并,即从最初的4个drawcall降低到2个drawcall。
注意,如果能将Tex1和Tex2合并到一张纹理上,则ABCD可以被合并到一个drawcall,这是最理想的情况。
纹理优化
缩小纹理尺寸
在不明显影响视觉效果情况下,缩小纹理尺寸可以达到如下效果,注意,这里说的不是atlas图集的大小:
- 减少包体,对小游戏来说,这是一个可重要的衡量标准。
- 减少加载时间,场景切换,UI加载等速度变快。
- 减少内存占用,在低端机上,内存很重要,特别是hago的兼容机型中会有一些内存很小的机器。
- 降低GPU带宽,带宽对手机GPU来说是稀缺资源,一般也是GPU耗电的元凶,小纹理(atla)具有更好的缓存局部性。
JPG陷阱
开发/美术同学往往会发现,将某些不透明的png大图转换成jpg格式后,文件大小骤降。此时需要注意,由于硬件并不支持jpg格式,与png一样,需要转换到RGB888的格式。这样在加载到游戏内存后,并没有降低内存的占用,并且在有些机器上,jpg的解码速度很慢,这需要开发同学多测试,大包体和解码速度上进行权衡。
具体的jpg解码速度,1680x1050尺寸纹理:
备注:由于Hago的cocos2d-x引擎不支持在android上使用jpg格式,因此也就不存在这个问题,统一使用png格式就好了。
压缩纹理
已制作好的资源,也可以在不改变纹理尺寸的情况下,使用压缩纹理来减少纹理的开销,但效果需要美术同学确认:
- 安卓上可以使用ETC1纹理,由于ETC1不支持alpha,因此需要使用单独的纹理来做为alpha。
- IOS上可以使用PVR,但PVR要求尺寸是POT(pow of two) ,使用并不灵活,但适合atlas图集(一般都是POT的)。
如有需要ETC1的方案,请联系[xieyukun@yy.com]
字体优化
使用自定义字体文件 FNT
适用于有限的字体的情况,比如,纯英文字符a-zA-Z0-9的情况,使用自定义字体往往可以达到最优的效果(这里不考虑包体大小的情况)。使用FNT能有助于合并渲染。
使用TTF字体
TTF字体的效果与FNT字体相当,但TTF字体库一般会比较大,包含的字符比较全,但往往需要针对不同国家进行单独处理。
在没有字体效果(如outline, shadow)的情况下,TTF字体也可以自动合并drawcall。
(少用)系统字体
如果可以,应当尽量减少系统字体的使用,每个不含相同内容的label或text,都会创建一个新的纹理,如果游戏中有频繁的字体变动,如时间,分数,聊天等都会创建大量的字体纹理,且不具有可复用性。
但系统字体的使用可以减少包体大小,减少维护成本,因此需权衡利弊。
减少字体效果的使用
字体效果的使用,往往会带来更多的drawcall,同时也会打断合并批次,如:
- outline, 两个drawcall来完成一次的文本渲染。
- shadow, 两个drawcall来完成一次的文本渲染。
- glow, 仅需要一次drawcall。
- shadow + outline, 三次drawcall。
序列帧动画
序列帧动画的优点是简单实用,美术制作也比较简单。
但是过度的序列帧动画会带来至少如下几个比较大的问题: 1. 包体变大。 2. 游戏占用内存变大。 3. 占用的GPU带宽变大。 4. 资源加载变慢。 5. 手机耗电增加。
因此需要适当控制序列帧动画的量,最好仅用于帧数较少和纹理较小的动画。对于具有复杂动作的角色之类使用骨骼动画来代替,如SpineAnimation,可以显著减少纹理的数量。
减少Spine动画的骨骼数量及顶点数量
大量的Spine动画会导致严重的游戏性能问题,这是由于每帧都要进行大量的骨骼更新和顶点蒙皮计算,这(位移,旋转,缩放)包含了大量复杂的数学运算(乘法)。
如果需要创建大量只具有少量动作的角色(怪物),可能使用序列帧动画,以适量的空间换取宝贵的计算时间。
适当的LOD(Level Of Detail),美术同学输出多套资源,根据机器性能来选择不同的资源。
优先使用Cocos2d-x的原生动画系统
Cocos2d-x引擎自身提供的相对比较简单的动画系统,如位移,缩放,旋转,变色,序列帧等,其优化如下:
- 简单,cocostudio下可视化编辑,所见即所得。
- 良好的兼容性,基本不会出现像spine的各种问题。
- 可控性好,可以对其节点进行各种操作,如替换纹理等。
九宫格纹理资源
九宫格纹理的目的是为了减少纹理大小,具体原理,可以网上查找相关资源。但是错误的九宫使用往往会起到相反的作用,美术同学(往往)可能不能正确判断是否需要使用九宫格,这时就有可能将一个非九宫的资源错误用成九宫格,这样会带来顶点个数从4个变成16个,三角形个数从二个变成18。如下图所示的两个Image,此些不需要九宫格。
少用Clipping操作
事实证明,ClippingNode/ClippingRect会引起严重的性能下降,这是因为每次Clipping操作都需要clear、读、写stencil,同时也会打断正常的drawcall合并操作。
如下几种情况的可替换方案:
- 如果只是静态裁剪纹理,如将方形图显示成圆形:直接提供相关的纹理,或使用RenderTarget/RenderTexture生成圆形纹理,后续直接使用RT。
- 如果对复杂节点进行clipping,但结点变化并不频繁:可以使用RenderTexture来生成一次,后续直接使用RT来显示即可。
逻辑优化
减少Lua函数调用
对于Lua脚本语言而言,语言本身并不会对简单函数进行内联操作。因此对于频繁调用的小函数,可以考虑在开发后期手动进行展开,以减少函数调用的开销。如人物某些属性的getter
方法。
通过缓存一些不变量,也可以达到减少函数调用的目的。
数学运算的优化
在CPU的执行中,除法和乘法会消耗更多的CPU周期,而加/减往往只需要一个时钟周期。可以在某些情况下牺牲一定的精度,可以换来更多的效率提升。例如,在一个自动捡宝石的玩法中,在主角靠近宝石时自动捡起宝石,无需玩家操作:
通常的做法是根据玩家与宝石的距离进行判断,其逻辑如下:
if (sqrt((px - sx)**2 + (py - sy)**2) < MIN_DISTANCE){
// TODO
}
这里用到了两次的指数运算和一次开方运算,在CPU中这些都是很费的操作,往往需要比乘除法更多的CPU时钟周期。一种简化的方式是:
xx = (px - sx) * (px - sx);
yy = (py - sy) * (py - sy);
if (xx + yy < MIN_DISTANCE_2){
// TODO
}
注意MIN_DISTANCE_2
是MIN_DISTANCE
的平方,作为一个常量,整个游戏期间只需要计算一次。 这是对第一种写法优化,消除了指数运算和开方运算,截止目前为止,所有的判断依然是精确的,但依然有两次乘法。如果对精度要求不高,可以允许一定误差的情况下,可以使用曼哈顿距离来做进一步的优化:
xx = abs(px - sx);
yy = abs(py - sy);
if (xx + yy < MIN_DISTANCE){
// TODO
}
这只是一种常见的特殊情况,并不适用于很多场景,而减少复杂的数学运算对手游而言确实很重要,例如网上有一些公开的快速开方,快速求距离等优化算法。
逻辑分块
继续回到刚刚提到的捡宝石的case,由于需要判断人物和宝石的距离来判断是否可以捡起,如果场景中有很多宝石,哪些宝石可以被捡起呢?当人物移动时遍历所有的宝石,这当然可以。但如果宝石非常多,几百,上千呢?每次让脚本loop这么多次,可能游戏会很卡,怎么办?
试试将场景划分成不同的逻辑block吧(block的尺寸大于可捡起宝石的最小距离)。每个block包含了该区域内的宝石索引,当人物移动时,只需要判断玩家周围最多9个block即可。
逻辑分块的功能当然不仅仅可以处理这种需求: 1. 延迟加载,超出屏幕或距离玩家太远的block可以延后加载。 2. 及时销毁,距离玩家太远的block也可以从场景树中移除,减轻内存压力和引擎/脚本更新压力。
延迟加载/异步回调
场景加载或某些玩法界面有时需要加载大量的Node和纹理资源,从而导致进行场景和打开界面比较缓慢(卡顿),这时可以使用延迟加载和异步回调来解决这些问题,以下有一些启发性的case:
- 显示前100(更多)名的玩家积分排行榜,如果在打开界面是一次创建如此多的item,打开界面一定会很卡。解决方法可以只创建10个或更少item,当划动列表时复用这些item来动态更新数据,也可以在划动时动态创建(或clone)相应的item。
- 在会显示很多玩家头像的界面中,可以使用纹理的异步加载,当纹理加载完成后,通过回调来创建相关的头像图标。这会有一种显示问题,就是界面打开后,会有图标陆续出来。同时也是增加逻辑的复杂度,如在资源加载完成之后,界面已关闭等异常情况。在使用之前,需要与策划/产品沟通,权衡。
- 在场景较大的玩法中,可以优先加载玩家周围的场景,将场景显示给玩家后,继续加载距离较远的场景。如果玩家只能慢慢走过去,这就留出了足够的时间来加载远处场景,但如果玩家可以瞬间移动到远处,这个策略就不再适用,或需要更多优化。
- 分帧加载,这也是延迟加载的一种具体实现方式,比如将100条item,按每帧创建5个,则可以在20帧内创建完毕,同时也不会让玩家觉得界面加载太慢。
减少每帧要做的逻辑
一般游戏是以60FPS或30FPS来运行的,如果有大量的每帧都是执行的逻辑,势必给客户端造成较大的性能压力。可以考虑将每帧的逻辑以定时器的形式扩大执行间隔(如0.1s一次),如AI,大部分的AI可能并不需要每帧去计算。
有些需求可能需要实时检查数据的变动,与其每帧去检查更新,使用事件机制往往会更具有效率和减少耦合,参考观察者模式。
缓存
缓存是提升游戏运行性能的一把利刃:
- 资源缓存:减轻IO压力,同时也能减轻资源加载时的卡顿现象。
- Node池: 各种Node的创建都是复杂,需要设置很多状态,有时也需要分配很多内存和显存。
- 脚本对象池:有些脚本对象的创建也是比较费时,如比较大的表及其初始化。
注意,缓存同样是一把双刃剑,不加节制的使用,可能会耗尽内存和显存,因此也需要一定的策略和废弃某些缓存项,如使用LRU
机制。
性能/设备分级
手机设备千差万别,不可能使用相同的逻辑来满足所有人的需求。即不能一味满足低端机玩家,而忽略高端机玩家都美和细节的追求,也不能只满足高端机玩家,而罔顾低端机玩家能玩这一最基本的需求。一个优秀的游戏会最大程度的照顾各类玩家的需求。
设备性能分级可以由不同的手段进行实现: 1. 设备列表的形式,这种方式最为准确,但工作量巨大,需要QA同学测试各种不同机型,给出详细分类列表,并且后续维护成本极高,时刻都有新设备面市。 2. 根据设备的主要指标进行衡量,如CPU型号,内存等,这种方式灵活,但不精确,在执行中往往最具可执行性,毕竟放眼世界,CPU/内存制造商就那么几家。 3. 1和2两种方式进行结合,甚至可以基于线上游戏的性能指数反馈来自动维护设备列表。
针对不同的等级(极致/高/中/低)来呈现不同的内容,俗称LOD (level of detail,细节等级),如少加载一些装饰性的元素,MMO游戏中少加载一些玩家等,当然,这些缺少的部分不应该影响到玩法本身。
动态帧率
帧率往往决定的了游戏的整体耗电程度,如在满帧的情况下60帧一定会比30帧耗电更快,如果游戏并不很追求丝滑的情况,30帧足以满足大多数人的需求。如果产品/策划同学一定要求60帧,那么可以考虑一些策略能在手机性能吃紧的情况下,将帧率动态调整到30帧。如果60FPS的游戏,在某些手机上只能到50帧以下,就可以这样做,因为这种情况下,已表明手机不堪重负,长此以往,系统会主动降频,致使更加雪上加霜。
在玩家被动挂机的情况下,也可以考虑降低帧率来节省电量。
Profiling
所有的优化应当建立在有理有据的基础上,2-8原则往往是一个好的参考,并且游戏中的热点往往不会太多,顺手的profiling工具往往能起到事半功倍的功效。在此推荐几款好的profiling工具:
- 手动打桩:原始但有效的手段,对疑似热点的逻辑进行打点测试,统计执行时长。
- Nsight:Nvidia出品,windows平台的渲染相关的profiling神器,要求独立N显卡,最好
GTX 1050+
吧。 - Snapdragon Profiler: 高通出品,仅支持高通平台,要求820之后的CPU,可以针对安卓手机平台进行Profiling。
- adb shell top: 可以直接查看安卓的进程运行情况,cpu, memory等:
Tasks: 732 total, 3 running, 727 sleeping, 0 stopped, 1 zombie
Mem: 5772888k total, 5588992k used, 183896k free, 127860k buffers
Swap: 2621436k total, 0k used, 2621436k free, 2539616k cached
800%cpu 77%user 4%nice 38%sys 664%idle 0%iow 6%irq 10%sirq 0%host
PID USER PR NI VIRT RES SHR S[%CPU] %MEM TIME+ ARGS
3987 u0_a151 10 -10 1.8G 147M 75M S 62.3 2.6 0:29.63 com.yy.runtime
- adb shell dumpsys:
dumpsys meminfo com.yy.runtime
polaris:/ $ dumpsys meminfo com.yy.runtime
Applications Memory Usage (in Kilobytes):
Uptime: 170706 Realtime: 170706
** MEMINFO in pid 3987 [com.yy.runtime] **
Pss Private Private SwapPss Heap Heap Heap
Total Dirty Clean Dirty Size Alloc Free
------ ------ ------ ------ ------ ------ ------
Native Heap 36976 35360 0 0 56448 49602 6845
Dalvik Heap 1357 1188 0 0 2589 1053 1536
Dalvik Other 910 904 0 0
Stack 60 60 0 0
Ashmem 4 0 0 0
Gfx dev 19032 18864 168 0
Other dev 21 0 20 0
.so mmap 17660 676 14496 0
.jar mmap 1 0 0 0
.apk mmap 277 0 8 0
.ttf mmap 175 0 96 0
.dex mmap 6345 4 3528 0
.oat mmap 711 0 36 0
.art mmap 5694 3944 52 0
Other mmap 32 4 0 0
EGL mtrack 21724 21724 0 0
GL mtrack 16356 16356 0 0
Unknown 29488 16972 12472 0
TOTAL 156823 116056 30876 0 59037 50655 8381