背景:从 PC 端游到 H5 小游戏,从一点一滴的内存到叹为观止的算法,游戏的性能一直是重点关注的话题。优秀的性能不仅能保证流畅的用户体验,也决定着复杂的动效和场景的上限。所以我做了一次 Phaser 渲染性能优化方面的分享,本文是对这次分享的记录和总结,将会从 Pixi 的渲染机制入手来进行游戏优化。在本文的最后,会通过一个游戏开发中常见的组件进行实战优化。
Phaser 内部使用的是 Pixi v2 的一个自定义版本用于渲染。为了快速得渲染多个精灵,Pixi v2 支持在 WebGL 下进行批次渲染(sprite batch),工作流程如下:
在这两种情况下,这个批次就会被冲刷(flush)。冲刷就是把所有的 texture 和顶点信息发送给 WebGL 并且调起一次 draw call 来绘制这些精灵。随后这一批次的数据就会被清空。
在此之后,下一批次就开始了。绑定到 GPU,加到批次中,冲刷,绘制,循环往复,直到遍历完整个显示列表。
这个过程是每帧都会执行的,值得一提的是这个遍历是深度优先的。
draw call 是最耗时的操作,想必大家都想尽力较少 draw call 次数。所以 texture atlas 显得无比重要。所有共享同一个 atlas 的不同部分小图的精灵不会导致批次被冲刷,因为他们背后的那张图片是同一张,共享一个 atlas 的精灵只会被绑定到一批中,然后一起绘制。
当然,这是有 GPU 限制的。A9 GPU 的 iPhone 6S最大支持 4096 像素 x 4096 像素,至于 PC 上的 GPU 则能支持更大的。如果超过了这个大小限制,多数浏览器不会显示任何任何东西。
每次 draw call 所花费的时间,目前没有找到有效的探查的方法。我从 fps 来侧面看一下 draw call 的影响。 当在我电脑上同屏绘制 200 个图片时,每帧让他们的位置加一个像素,绘制了 202 次,fps 为 39 ~ 60, 而将其 cacheAsBitmap,绘制为 3 次,fps 稳定在 60。可以间接说明 draw call 和每帧的渲染时间是直接正相关的。同时根据 fireDebug 标绿来看,drawCall的影响是最大的。
setTexturePriority 是 PIXI.WebGLRenderer 的一个方法。这个方法可以接受一个数组,这个数组的每一项应该是指向 Phaser.Cache 内的图片的,一旦调用了这个函数,这些图片就不会被分批,他们会在一个批次中被冲刷。比如如果要接连渲染两个 baseTexture 为 A 和 B 的精灵,一般来说 A 加到批次中后,Pixi 接着检索到了 B,那么A所在的批次就应该被冲刷一次,然后 B 重新加到一个新的批次中。但是如果调用了以下代码:
game.renderer.setTexturePriority([A, B]);
那么 B 就会加到 A 的批次中,而不会引起引起冲刷。
当然这个函数是有限制的,因为当代 GPU 的限制,一般来说这个数组最多支持 16 个,这个最大值具体可由 maxTextures 得知。同时 currentBatchedTextures 属性能告诉我们哪些 texture 是一批次的。除此之外,我们还可以通过手动改变 BaseTexture.textureIndex 来达到同样的效果。
这个函数不是默认启用的,我们可以在创建游戏的时候启用它,将渲染模式选为 WEBGL_MULTI。
var game = new Phaser.Game(800, 600, Phaser.WEBGL_MULTI);
或者是将 multiTexture 设置为 true。
var config = {
width: 800,
height: 600,
renderer: Phaser.AUTO,
antialias: true,
multiTexture: true,
state: {
preload: preload,
create: create,
update: update
}
};
var game = new Phaser.Game(config);
在游戏开发过程中,有几个常见的误区。了解清楚内部渲染机制后,也就一目了然了。
误区 1: 能自己画的东西就自己画。
起初在做前端的时候,为了减少记载的资源体积,一定是能用 css 画的就自己画的。但是在 WebGL_MULTI 模式下则不一定。因为我们自己绘制一个 Graphics 会打断一个批次,这样会增加 draw call,尤其是图形,图片混杂的场景,自己画会是得不偿失的。所以需要在资源体积和性能之间做一个权衡。误区 2: 按照功能合图。
如果按照模块化的理念,这样无疑是利于维护的。但是弊端就是无法使用 Pixi 强劲的批次渲染。尤其是两张大图上的小图在场景中相互交错的情况,这时常常会引起几十上百次的 draw call,这就没有利用好批次渲染的强大效率。我使用的是火狐浏览器的 fireDebug,打开其控制台,选择 canvas 就可到以截取一帧。我们可以从调试信息中得知,调用了多少次 draw call 和 GPU 交互等等。在显示的调试代码中,我们可以看到标绿的行是最耗时的,比如 drawElements,clear 函数等等。同时下方的序列帧可以看到每一步绘制的对象。
var Boot = function (game) {
};
Boot.prototype = {
preload: function () {
this.stage.backgroundColor = '#aaa';
this.load.image('optionsHall', './optionsHall.png');
this.load.image('optionsMark', './optionsMark.png');
this.load.image('optionsMusic', './optionsMusic.png');
},
create: function () {
var lists = [
{ icon: 'optionsHall', text: '游戏大厅'},
{ icon: 'optionsMark', text: '开始关闭游戏'},
{ icon: 'optionsMusic', text: '声音选项'},
];
lists.forEach(({ icon, text }, index) => {
this.add.image(200, index * 50 + 70, icon);
this.add.text(200 + 50, index * 50 + 70, text, { fill: 'red' });
});
}
}
var game = new Phaser.Game({
width: 500,
height: 500,
renderer: Phaser.AUTO
});
game.state.add('Boot', Boot);
game.state.start('Boot');
这段代码首先在 preload 阶段加载了三个图标。接着来到 create 阶段,首先定义了一个 lists 用来放信息,然后对这个 lists 进行遍历,先画一个图标,然后写一段文字。这是一个非常普通而常见的场景,小到下拉框,大到结果展示,都会出现这样的场景。我们打开 fireDebug,发现掉起了 8 次 draw call。虽然看起来还能接受,但是这只是三个的情况,当这个列表越来越多,比如 20 条的时候,就会掉起 42 次 draw call。 所幸的是,我们是可以优化的。
我们可以看到在 fireDebug 中显示的渲染次序,一个图标,然后一行文字,然后再一个图标,再一行文字,很明显便是文字打断了图标的批次。考虑到我们的渲染批次原理,第一个想到的优化便是将图片放到一个批次里,或者合图,然后先绘制图标,再去绘制文字。所以,代码改写如下:
// 此处不演示合图,合图也可以达到相同的效果
this.game.renderer.setTexturePriority(['optionsHall', 'optionsMark', 'optionsMusic']);
// ......
// 拆分绘制,先绘制图标,再绘制文字
lists.forEach((list, index) => {
this.add.image(50, index * 50 + 60, list.icon);
});
lists.forEach((list, index) => {
this.add.text(110, index * 50 + 63, list.text, { fill: '#abcdef', fontWeight: 'normal'});
});
// ......
// 开启 WebGL_MULTI
var game = new Phaser.Game({
width: 500,
height: 500,
renderer: Phaser.WebGL_MULTI
});
再次打开 fireDebug,可以看到只绘制了 6 次。
但是仔细一想,这只是减少了图片的绘制次数,但是文字的次数分文未少啊,比如 20 条记录,依然需要23次 draw call,当然,是有解决办法的。
位图字体是可以自行选择字体,字号,渐变,阴影等的自定义的字体,在 WebGL 下有良好的性能,具体不在这里详述。在这里最重要的一点是,位图字体是可以作为材质加到批次中的。这样所有的文字和图标都会在一个批次中,从而文字就不会打断这个批次了。 代码如下:
// 加载位图字体
this.load.bitmapFont('font', './drawCall_0.png', './drawCall.fnt');
// ......
// 加入到批次中
this.game.cache.getBitmapFont('font').base.textureIndex = enabled.length + 1;
// 使用位图字体而不是普通文本
lists.forEach(({ icon, text }, index) => {
this.add.image(200, index * 50 + 70, icon);
this.add.bitmapText(200 , index * 50 + 70, 'font', text, 50);
});
打开 fireDebug 我们可以看到只绘制离开仅仅 3 次,而且更重要的是,不管是 20 条还是 200 条,其绘制次数依然只有 3 次。因为同一批次,只要在 2000 以内都是一次绘制完的。我们对于这个场景的优化,也就到达了终点
我们可以看到,即使我们的场景是一次就绘制好了,依然调用了 3 次 draw call,这是因为 Phaser 内部的 2 次调用。第一个是 clearBeforeRender,Phaser 默认会在每一帧开始前,清除所有的像素,这是一次 draw call,而另一个则是 Phaser 自己的 debug 发送的一个 texture。
如果有大背景图的画可以让 Phaser 不进行 clearBeforeRender。
game.clearBeforeRender = false;
如果不需要 debug,比如生产环境,我们也可以把 debug 禁用掉。
var game = new Phaser.Game({
width: 500,
height: 500,
renderer: Phaser.WEBGL_MULTI,
enableDebug: false
});
再次打开 fireDebug,draw call 次数是惊人的 1 次。
以上便是我的分享内容了,其实了解了渲染的机制原理,再去做优化便是有理有据了。大家可以在自己的项目初期就考虑到绘制的性能,按照绘制顺序来组织显示对象。谢谢。