异名最近负责了一个微信小游戏的项目,在版本迭代间隙对游戏的性能调优进行了一次尝试。这个游戏是个打击类游戏,下面展示一下游戏的预览效果
引擎和小游戏都有提供一个性能面板,给开发者们暴露了以下几个性能指标:
Frame time(ms)每一帧的时间。《RAIL模型》建议在 10 毫秒或更短的时间内制作动画中的每一帧。从技术上讲,每帧的最大预算为 16 毫秒(1000 毫秒/每秒 60 帧≈16 毫秒),但是浏览器需要大约 6 毫秒才能渲染每帧,因此建议每帧 10 毫秒或者更短。
Framerate(FPS)帧率,也叫每秒传输帧数(FPS:Frames Per Second),是指画面每秒传输帧数,通俗来讲就是指动画或视频的画面数;每秒钟帧数越多,所显示的动作就会越流畅,举个例子电影的帧频是 24,也就是说 1s 需要播放 24 张图片,但是实际上在游戏过程中一般人能接受的最低 FPS 约为 30Hz。帧率也非越高越好,因为显卡处理能力=分辨率×刷新率,分辨率不变的情况下,帧频越高,GPU 处理的数据量也会激增,引起卡顿。同理,分辨率也不是越高越好。在某些终端的性能面板下也会展示这三个相关的参数:rt-fps:实时帧率;ex-fps:极限帧率;min-fps:最小帧率;
Draw call 。CPU 和 GPU 是并行工作的,它们之间存在一个命令缓冲区。当 CPU 需要调用图形编程接口的时候,就会往命令缓冲区里面增加命令,当 GPU 完成上一次渲染命令的时候,就会继续从命令缓冲区中执行下一条命令,命令缓冲区里面的命令有很多种,而drawcall
就是其中的一种。CPU 在提交drawcall
的时候需要处理很多东西,比如一些数据、状态、命令等等。有些渲染卡顿问题就是因为 GPU 渲染速度比drawcall
的提交速度快,可能上一次渲染完了,CPU 还在计算drawcall
,所以drawcall
的性能瓶颈在于 CPU。优化drawcall
最有效的方法合批渲染,就是把大量小的drawcall
合并成大的drawcall
,减少drawcall
的数量。
Tris 和 Verts 是渲染的三角面数以及顶点数,在 webgl 中只有三种基本图元,分别是点、线段和三角形,无论多么复杂的模型本质上都是由这三个基本图元绘制而来的,无论形状多么怪异,它们的本质都是由一个个顶点组成,GPU 将这些点用三角图元绘制成一个个的微小平面,再把这些三角网格互相连接,就能绘制出各种复杂的物体了;
一般来说模型的顶点和三角形数越低,模型的复杂度就会越低,所以这两个参数在 3D 模型中比较有参考意义,设计师在输出 3D 模型的时候一般都会帮忙去合并一下网格。但是在大部分情况下,我们都会认为性能瓶颈在drawcall
上,比如有两种情形,情景一是有 1000 个物体,每个物体的顶点数是 10,情景二是有 10 个物体,每个物体的顶点数是 1000,哪个情景的性能更好?首先我们要明白 GPU 的渲染速度是非常快的,渲染 10 个顶点组成的三角图元和 1000 个顶点组成的三角图元通常没啥区别,所以这两种情形中产生drawcall
更少的情景二性能更好。当然如果你在shader
里面对顶点做了一些特殊的处理,比如复杂的计算啥的,那就得权衡一下这两个指标的大小影响了
想要减少 drawcall 就要从影响渲染状态的因素入手,比如纹理图片、纹理的渲染模式、Blend
方式等等。但是在大部分项目中其实我们也不会有多大的需求去单独修改引擎的默认渲染参数,如果你改动了,那肯定是会打断合批的
一般情况下,在项目中降低drawcall
收益比最大的其实是就是利用引擎提供的静态合图和动态合图的功能。静态合图就是自动图集了,或者使用第三方的图集工具TexturePacker
,把资源中的散图进行合并,尽量让画面中的节点都使用一张图集,因为同一张图集的纹理状态都是一致的,所以能够达到渲染批次合并对纹理状态的要求;引擎中的动态合图有两种,一种作用对象是图片资源,是引擎默认启用的(但是小游戏中因为内存原因默认关闭),如果不希望使用就在资源面板中把Packable
勾掉,或者把全局的合图开关关掉cc.dynamicAtlasManager.enabled = false;
;一种是针对label
的,可以在label
的cache
模式中进行不同的模式切换。下面就介绍一下异名在这个项目中针对drawcall
做的一些处理和以及收益:
画面外的节点可以直接移除,drawcall
从 50 降到了 23:
把首页上三个label
的cache
模式改为bitmap
,首页上的drawcall
从 79 降低到 50。
有一些频繁更改的label
,当cache
模式改为char
的时候,在我的苹果手机上差别不大,但是在小伙伴的安卓手机上流畅度上升十分明显
在合并图集的时候需要根据画面的内容去做划分,尽量把同一个画面用的的图片资源打包成一个图集 。以游戏中的一个中后关卡为例(前面关卡的画面节点太少,差异不明显),drawcall
均值从 190 降到了 90,drawcall
峰值从 220 降到了 127。
在开发中的时候,异名会使用Chrome
的DevTools
,如果是在浏览器中排查性能问题需要屏蔽所有的浏览器插件,最好就是打开隐私模式来调试,因为插件在后台运行会造成干扰。但是在上线前异名会选择使用微信开发者工具的DevTools
来再查看一下性能,因为在浏览器的中跑的项目是调试模式,一来没有做合图,而来它也没有经过小游戏的编译,所以为了减少和最终项目效果的偏差,最终会以微信开发者工具中的 Performance 指标为最终的参考指标。
和手机终端环境相比,我们的电脑的 CPU 是很快的,为了尽可能模拟用户的终端硬件情况,我们首先需要对 CPU 做一下节流,例如我现在选中的6x slowdown
就会使我们本地 CPU 的运算速率比正常情况下降 6 倍。
这时候我们重新去生成录制结果,就可以发现面板上已经出现了醒目的红色告警信息了:
聚焦放大然后把轴线定位到每个小的告警信息处,可以在Summary
中看到浏览器给出的警告信息,我发现这里面的告警信息都是一样的,都是Recurring handler
,而且有规律地出现,可以通过Initiator
去查看重复出现的地方以及具体的执行代码:
虽然我们已经看到了代码执行的具体位置是requestAnimateFrame
,但是这个 api 调用不是我们的业务逻辑,而是引擎的封装调用,引擎的帧回调应该是用requestAnimateFrame
实现的,也就是说在update
的钩子里面可能存在重复调用的逻辑,在这里就需要进一步地去分析了。
我们需要根据Main
面板中火焰图也就是 JS 调用栈一步步去寻找并落实到具体的业务代码调用细节。如果能够定位到我们逻辑中的调用函数,马上对症下药就能解决了。
但是在异名的这个项目中Recurring handler
给到的信息很难定位,task
下面调用栈调用的都是引擎自身的渲染方法,当然横向去看调用的先后顺序的话,它在touchMove
事件后面运行,这是一个排查方向,需要去分析自己的代码运行调用。
如果像异名遇到的这种情况,控制台只能定位到一堆引擎的渲染函数,而不能很明确地定位到我们的具体业务逻辑中,异名会建议放一放,因为重复渲染的问题可能会在long task
拆分的过程中被 fix 掉。
可以通俗理解为一段执行时间很长的 js 逻辑就是长任务,"长任务"占用着主线程,即使我们的页面看上去准备好了,但是也不能响应用户的操作和点击等交互。至于这个执行时间多长才算是“长”呢?
《RAIL模型》建议我们每个任务最好都控制在 50 毫秒内,Chrome 在控制台中也给出了醒目的长任务提示:大型的脚本是长任务的主要原因,异名这里先举一个在项目中拆分长任务的简单的例子。异名的游戏中有个复活的逻辑,会在碰撞的回调中会处理一下血量和相关的掉血交互,然后当玩家血量耗尽就会唤起一个复活弹窗,这段逻辑就产生了一个long task
,耗时 55.74ms,火焰图如下:
仔细分析一下,我的碰撞的逻辑处理和唤起复活弹窗虽然是顺序调用,但是它们之间算是一个比较清晰的逻辑界线,而且askResurgence
这个函数是一个唤起 UI 弹窗的函数,既然产生了性能限制,我们就在这里做一个任务拆分:
// 用setTimeout包裹一下,把它放在下个宏任务执行
setTimeout(() => {
this.askResurgence();
})
这时候我们再看拆分后的火焰图,long task
标记已经消失了,本来一个长任务,被拆分成了 3 个任务(中间一个是 GC),而且三个任务的耗时相加和开始的长任务相比是折半了的。
异名通过排查梳理之后,发现一些连续的 UI 状态过渡都很容易造成长任务,异名的项目中还有好几处都是这种界线分明混合逻辑,当你遇到性能压力的时候或许可以像我一样做一下处理。
总结一下拆解大型脚本的时候,首先需要把大段的 js 逻辑重新梳理一遍,可以把一些能提前或者延后的状态,拆解到我们应用的空闲阶段去初始化或者变更,比如在首页就先把游戏过程中需要的数据加载进来,游戏过程的的逻辑中就不用再去加载这部分数据了。
但是这属于比较“宏观的”逻辑变更,大部分情况下我们的状态和逻辑变更都是很难提前或者延后的。我们还可以做一些时间颗粒度更小的逻辑拆分,那就是结合 js 的事件循环机制来处理我们的逻辑,像用Promise.then
或者setTimeout
去做一下任务延迟,甚至可以建立一个任务队列去做事件缓存等。
这篇《idle-until-urgent》有介绍到一些比较具体的拆解脚本措施。任务拆分是有风险的,无论是在应用的层面去提升或者延后逻辑,还是利用 js 的微任务或者宏任务去延后状态逻辑,都会有可能导致你的应用状态同步出现问题,所以在实操之后记得好好测试一下整个流程。
长任务还可以通过精简自身的逻辑来优化,像在一些循环中,如果可以做跳出判断自己是否有做;还有在一些地方你写的逻辑是否执行冗余或者无用,比如在异名的项目中这段交互逻辑
// 效果甚微的动画交互,干掉
this.node.setScale(1.2);
const frequency = getRandom(15, 40) / 100;
this.centerNode.runAction(
cc.sequence(
cc.scaleBy(frequency, 1.1),
cc.scaleTo(frequency, 1)
).repeatForever()
);
它只是一个呼吸变化,在实际的画面效果中,这种小变化其实是很微弱的,可以认为它是一个可有可无的动画逻辑。那我们在做性能优化的时候,就需要果断地把它删除,这些冗余的逻辑干掉之后,game logic
的数值就已经可以很明确地降低下来了。
目前还存在较明显的性能方面:「发热、敌人数量增多时容易卡顿」
发热是个比较综合的问题,一般来说 CPU 导致发热,降低 CPU 的工作会有效减少发热。卡顿则受到帧频和 drawcall 的影响比较大,除了上面提到的,还有以下这些优化手段
降低帧数:目前已动态设置帧频,游戏过程 60 帧,非游戏过程 30 帧
减少帧回调:目前 update 中还有很大的逻辑优化空间
减少内存使用:这块目前也有很大的优化空间,GC 回收,节点池,对象和节点复用、缓存等等,甚至包括一些贴图的引用释放等
drawcall 优化:其实还可以借助一些帧调试工具去进一步分析,项目的后期应该还会对drawcall
优化进行再深入一点的探索
最后阐清几个指标的含义:
1、ex-fps 不是平均帧率,是极限帧率,可以理解为在不受驱动帧率的限制下(大部分手机微 60fps),仅仅计算 js 运行耗时,可以达到的极限帧率。这个数字可以用于评估在满帧的前提下,运行性能是否有变化。
2、game logic 的时间代表 update 中逻辑的耗时
3、Renderer 的时间代表渲染的耗时
4、Frame time = Game Logic + Renderer
有一些优化的大原则,在此补充一下,即不要过早优化、最好的优化就是不用优化等等。优化是没什么万金油的,每个项目的性能瓶颈都不一样,需要根据项目的实际瓶颈,判断是该优化内存还是加载时间还是渲染时间等等。
以上就是异名在这两天针对性能优化做的一些尝试,后面项目迭代完成之后应该还会有一次针对性的优化尝试,到时候如果有别的收获再和大家分享。
因为异名这方面的经验比较缺乏,如果大家发现异名的实践方式有问题或者有遗漏的切入点,请帮忙指正。
如有侵权,请联系我。立刻删除。