游戏渲染优化
Pin Young Lv9

背景:从 PC 端游到 H5 小游戏,从一点一滴的内存到叹为观止的算法,游戏的性能一直是重点关注的话题。优秀的性能不仅能保证流畅的用户体验,也决定着复杂的动效和场景的上限。所以我做了一次 Phaser 渲染性能优化方面的分享,本文是对这次分享的记录和总结,将会从 Pixi 的渲染机制入手来进行游戏优化。在本文的最后,会通过一个游戏开发中常见的组件进行实战优化。

Pixi 渲染机制

Phaser 内部使用的是 Pixi v2 的一个自定义版本用于渲染。为了快速得渲染多个精灵,Pixi v2 支持在 WebGL 下进行批次渲染(sprite batch),工作流程如下:

  1. 每一帧,Pixi 都会从显示列表(display list)的最顶层也就是 stage 对象开始向下遍历找寻显示对象(display object),每找到一个显示对象,Pixi就会看看它的 BaseTexture,通过这个属性可以探查到所关联的图片,然后就会将这个 texture 绑定到 GPU,一个新的批次(batch)就开始了。

  2. Pixi 上传了显示对象的顶点信息后会继续向下找寻对象,如果下一个显示对象使用的是同一个 BaseTexure 那么完事大吉,因为这样就不会去绑定一个新的 texture 了,Pixi 会将这个精灵的信息加到当前的这个批次中,然后继续找下去,直到:

  • Pixi 找到的某个精灵有着和批次不同的 BaseTexture。
  • 这个批次中填满了 2000 个元素。

在这两种情况下,这个批次就会被冲刷(flush)。冲刷就是把所有的 texture 和顶点信息发送给 WebGL 并且调起一次 draw call 来绘制这些精灵。随后这一批次的数据就会被清空。

在此之后,下一批次就开始了。绑定到 GPU,加到批次中,冲刷,绘制,循环往复,直到遍历完整个显示列表。

这个过程是每帧都会执行的,值得一提的是这个遍历是深度优先的。

使用 Texture Atlas

draw call 是最耗时的操作,想必大家都想尽力较少 draw call 次数。所以 texture atlas 显得无比重要。所有共享同一个 atlas 的不同部分小图的精灵不会导致批次被冲刷,因为他们背后的那张图片是同一张,共享一个 atlas 的精灵只会被绑定到一批中,然后一起绘制。

当然,这是有 GPU 限制的。A9 GPU 的 iPhone 6S最大支持 4096 像素 x 4096 像素,至于 PC 上的 GPU 则能支持更大的。如果超过了这个大小限制,多数浏览器不会显示任何任何东西。

关于 draw call的一点说明

每次 draw call 所花费的时间,目前没有找到有效的探查的方法。我从 fps 来侧面看一下 draw call 的影响。
当在我电脑上同屏绘制 200 个图片时,每帧让他们的位置加一个像素,绘制了 202 次,fps 为 39 ~ 60, 而将其 cacheAsBitmap,绘制为 3 次,fps 稳定在 60。可以间接说明 draw call 和每帧的渲染时间是直接正相关的。同时根据 fireDebug 标绿来看,drawCall的影响是最大的。

使用 setTexturePriority

setTexturePriority 是 PIXI.WebGLRenderer 的一个方法。这个方法可以接受一个数组,这个数组的每一项应该是指向 Phaser.Cache 内的图片的,一旦调用了这个函数,这些图片就不会被分批,他们会在一个批次中被冲刷。比如如果要接连渲染两个 baseTexture 为 A 和 B 的精灵,一般来说 A 加到批次中后,Pixi 接着检索到了 B,那么A所在的批次就应该被冲刷一次,然后 B 重新加到一个新的批次中。但是如果调用了以下代码:

1
game.renderer.setTexturePriority([A, B]);

那么 B 就会加到 A 的批次中,而不会引起引起冲刷。

当然这个函数是有限制的,因为当代 GPU 的限制,一般来说这个数组最多支持 16 个,这个最大值具体可由 maxTextures 得知。同时 currentBatchedTextures 属性能告诉我们哪些 texture 是一批次的。除此之外,我们还可以通过手动改变 BaseTexture.textureIndex 来达到同样的效果。

这个函数不是默认启用的,我们可以在创建游戏的时候启用它,将渲染模式选为 WEBGL_MULTI。

1
var game = new Phaser.Game(800, 600, Phaser.WEBGL_MULTI);

或者是将 multiTexture 设置为 true。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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. 误区 1: 能自己画的东西就自己画。
    起初在做前端的时候,为了减少记载的资源体积,一定是能用 css 画的就自己画的。但是在 WebGL_MULTI 模式下则不一定。因为我们自己绘制一个 Graphics 会打断一个批次,这样会增加 draw call,尤其是图形,图片混杂的场景,自己画会是得不偿失的。所以需要在资源体积和性能之间做一个权衡。

  2. 误区 2: 按照功能合图。
    如果按照模块化的理念,这样无疑是利于维护的。但是弊端就是无法使用 Pixi 强劲的批次渲染。尤其是两张大图上的小图在场景中相互交错的情况,这时常常会引起几十上百次的 draw call,这就没有利用好批次渲染的强大效率。

实战

调试工具

我使用的是火狐浏览器的 fireDebug,打开其控制台,选择 canvas 就可到以截取一帧。我们可以从调试信息中得知,调用了多少次 draw call 和 GPU 交互等等。在显示的调试代码中,我们可以看到标绿的行是最耗时的,比如 drawElements,clear 函数等等。同时下方的序列帧可以看到每一步绘制的对象。

一个普通的场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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 中显示的渲染次序,一个图标,然后一行文字,然后再一个图标,再一行文字,很明显便是文字打断了图标的批次。考虑到我们的渲染批次原理,第一个想到的优化便是将图片放到一个批次里,或者合图,然后先绘制图标,再去绘制文字。所以,代码改写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 此处不演示合图,合图也可以达到相同的效果
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,当然,是有解决办法的。

位图字体(bitmap font)

位图字体是可以自行选择字体,字号,渐变,阴影等的自定义的字体,在 WebGL 下有良好的性能,具体不在这里详述。在这里最重要的一点是,位图字体是可以作为材质加到批次中的。这样所有的文字和图标都会在一个批次中,从而文字就不会打断这个批次了。
代码如下:

1
2
3
4
5
6
7
8
9
10
11
// 加载位图字体
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 以内都是一次绘制完的。我们对于这个场景的优化,也就到达了终点

多余的两次 draw call

我们可以看到,即使我们的场景是一次就绘制好了,依然调用了 3 次 draw call,这是因为 Phaser 内部的 2 次调用。第一个是 clearBeforeRender,Phaser 默认会在每一帧开始前,清除所有的像素,这是一次 draw call,而另一个则是 Phaser 自己的 debug 发送的一个 texture。

如果有大背景图的画可以让 Phaser 不进行 clearBeforeRender。

1
game.clearBeforeRender = false;

如果不需要 debug,比如生产环境,我们也可以把 debug 禁用掉。

1
2
3
4
5
6
var game = new Phaser.Game({
width: 500,
height: 500,
renderer: Phaser.WEBGL_MULTI,
enableDebug: false
});

再次打开 fireDebug,draw call 次数是惊人的 1 次。

最后

以上便是我的分享内容了,其实了解了渲染的机制原理,再去做优化便是有理有据了。大家可以在自己的项目初期就考虑到绘制的性能,按照绘制顺序来组织显示对象。谢谢。