Canvas 性能篇
Canvas 想必前端同学们都不陌生,它是 HTML5 新增的「画布」元素,允许我们使用 JavaScript 来绘制图形。目前,所有的主流浏览器都支持 Canvas。
Canvas 最常见的用途是渲染动画。渲染动画的基本原理,无非是反复地擦除和重绘。为了动画的流畅,留给我渲染一帧的时间,只有短短的 16ms。在这 16ms 中,我不仅需要处理一些游戏逻辑,计算每个对象的位置、状态,还需要把它们都画出来。如果消耗的时间稍稍多了一些,用户就会感受到「卡顿」。所以,在编写动画(和游戏)的时候,我无时无刻不担忧着动画的性能,唯恐对某个 API 的调用过于频繁,导致渲染的耗时延长。
计算与渲染
把动画的一帧渲染出来,需要经过以下步骤:
- 计算:处理动画或者游戏逻辑,计算每个对象的状态,不涉及 DOM 操作(当然也包含对 Canvas 上下文的操作)。
- 渲染:把真正把对象绘制出来。
2.1 JavaScript 调用 DOM API(包括 Canvas API)以进行渲染。
2.2浏览器(通常是另一个渲染线程)把渲染后的结果呈现在屏幕上的过程。
- 之前曾说过,留给我们渲染每一帧的时间只有 16ms。然而,其实我们所做的只是上述的步骤中的 1 和 2.1,而步骤 2.2 则是浏览器在另一个线程(至少几乎所有现代浏览器是这样的)里完成的。动画流畅的真实前提是,以上所有工作都在 16ms 中完成,所以 JavaScript 层面消耗的时间最好控制在 10ms 以内。
- 虽然我们知道,通常情况下,渲染比计算的开销大很多(3~4 个量级)。除非我们用到了一些复杂度很高的算法,因此计算环节的优化没有必要深究。
- 我们需要深入研究的,是如何优化渲染的性能。而优化渲染性能的总体思路很简单,归纳为以下几点:
- 在每一帧中,尽可能减少调用渲染相关 API 的次数(通常是以计算的复杂化为代价的)。
- 在每一帧中,尽可能调用那些渲染开销较低的 API。
- 在每一帧中,尽可能以「导致渲染开销较低」的方式调用渲染相关 API。
Canvas 上下文是状态机
Canvas API 都在其上下文对象context
上调用。
var context = canvasElement.getContext('2d');
context
是一个状态机。你可以改变 context
的若干状态,而几乎所有的渲染操作,最终的效果与 context
本身的状态有关系。比如,调用 strokeRect
绘制的矩形边框,边框宽度取决于 context
的状态 lineWidth
,而后者是之前设置的。
context.lineWidth = 5; context.strokeColor = 'rgba(1, 0.5, 0.5, 1)'; context.strokeRect(100, 100, 80, 80);
context.lineWidth
赋值的开销远远大于对一个普通对象赋值的开销,你会作如何感想。
context.lineWidth = 5
时,浏览器会需要立刻地做一些事情,这样你下次调用诸如 stroke
或 strokeRect
等 API 时,画出来的线就正好是 5 个像素宽了(不难想象,这也是一种优化,否则,这些事情就要等到下次 stroke
之前做,更加会影响性能)。
我尝试执行以下赋值操作 106 次,得到的结果是:对一个普通对象的属性赋值只消耗了 3ms,而对 context
的属性赋值则消耗了 40ms。值得注意的是,如果你赋的值是非法的,浏览器还需要一些额外时间来处理非法输入,正如第三/四种情形所示,消耗了 140ms 甚至更多。
somePlainObject.lineWidth = 5; // 3ms (10^6 times) context.lineWidth = 5; // 40ms context.lineWidth = 'Hello World!'; // 140ms context.lineWidth = {}; // 600ms
|
context
而言,对不同属性的赋值开销也是不同的。lineWidth
只是开销较小的一类。下面整理了为 context
的一些其他的属性赋值的开销,如下所示。属性 | 开销 | 开销(非法赋值) |
---|---|---|
line[Width/Join/Cap] | 40+ | 100+ |
[fill/stroke]Style | 100+ | 200+ |
font | 1000+ | 1000+ |
text[Align/Baseline] | 60+ | 100+ |
shadow[Blur/OffsetX] | 40+ | 100+ |
shadowColor | 280+ | 400+ |
context
状态的开销已经算比较小了,毕竟我们还没有真正开始绘制操作。我们需要了解,改变 context
的属性并非是完全无代价的。我们可以通过适当地安排调用绘图 API 的顺序,降低 context
状态改变的频率。分层 Canvas
分层 Canvas 在几乎任何动画区域较大,动画较复杂的情形下都是非常有必要的。分层 Canvas 能够大大降低完全不必要的渲染性能开销。分层渲染的思想被广泛用于图形相关的领域:从古老的皮影戏、套色印刷术,到现代电影/游戏工业,虚拟现实领域,等等。而分层 Canvas 只是分层渲染思想在 Canvas 动画上最最基本的应用而已。- 对于 Canvas 而言,能够在每层 Canvas 上保持不同的重绘频率已经是最大的好处了。然而,分层思想所解决的问题远不止如此。
使用上,分层 Canvas 也很简单。我们需要做的,仅仅是生成多个 Canvas 实例,把它们重叠放置,每个 Canvas 使用不同的 z-index 来定义堆叠的次序。然后仅在需要绘制该层的时候(也许是「永不」)进行重绘。
var contextBackground = canvasBackground.getContext('2d'); var contextForeground = canvasForeground.getContext('2d'); function render(){ drawForeground(contextForeground); if(needUpdateBackground){ drawBackground(contextBackground); } requestAnimationFrame(render); } |
绘制图像
目前,Canvas 中使用到最多的 API,非 drawImage
莫属了。(当然也有例外,你如果要用 Canvas 写图表,自然是半句也不会用到了)。
drawImage
方法的格式如下所示:
context.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
|
canvas的drawImage方法参数详解
数据源与绘制的性能
由于我们具备「把图片中的某一部分绘制到 Canvas 上」的能力,所以很多时候,我们会把多个游戏对象放在一张图片里面,以减少请求数量。这通常被称为「精灵图」。然而,这实际上存在着一些潜在的性能问题。我发现,使用drawImage
绘制同样大小的区域,数据源是一张和绘制区域尺寸相仿的图片的情形,比起数据源是一张较大图片(我们只是把数据扣下来了而已)的情形,前者的开销要小一些。可以认为,两者相差的开销正是「裁剪」这一个操作的开销。
- 我尝试绘制 104 次一块 320x180 的矩形区域,如果数据源是一张 320x180 的图片,花费了 40ms,而如果数据源是一张 800x800 图片中裁剪出来的 320x180 的区域,需要花费 70ms。
虽然看上去开销相差并不多,但是 drawImage
是最常用的 API 之一,我认为还是有必要进行优化的。优化的思路是,将「裁剪」这一步骤事先做好,保存起来,每一帧中仅绘制不裁剪。具体的,在「离屏绘制」一节中再详述。
视野之外的绘制
有时候,Canvas 只是游戏世界的一个「窗口」,如果我们在每一帧中,都把整个世界全部画出来,势必就会有很多东西画到 Canvas 外面去了,同样调用了绘制 API,但是并没有任何效果。我们知道,判断对象是否在 Canvas 中会有额外的计算开销(比如需要对游戏角色的全局模型矩阵求逆,以分解出对象的世界坐标,这并不是一笔特别廉价的开销),而且也会增加代码的复杂程度,所以关键是,是否值得。
我做了一个实验,绘制一张 320x180 的图片 104 次,当我每次都绘制在 Canvas 内部时,消耗了 40ms,而每次都绘制在 Canvas 外时,仅消耗了 8ms。大家可以掂量一下,考虑到计算的开销与绘制的开销相差 2~3 个数量级,我认为通过计算来过滤掉哪些画布外的对象,仍然是很有必要的。
离屏绘制
上一节提到,绘制同样的一块区域,如果数据源是尺寸相仿的一张图片,那么性能会比较好,而如果数据源是一张大图上的一部分,性能就会比较差,因为每一次绘制还包含了裁剪工作。也许,我们可以先把待绘制的区域裁剪好,保存起来,这样每次绘制时就能轻松很多。
drawImage
方法的第一个参数不仅可以接收 Image
对象,也可以接收另一个 Canvas
对象。而且,使用 Canvas
对象绘制的开销与使用 Image
对象的开销几乎完全一致。我们只需要实现将对象绘制在一个未插入页面的 Canvas
中,然后每一帧使用这个 Canvas
来绘制。
// 在离屏 canvas 上绘制 var canvasOffscreen = document.createElement('canvas'); canvasOffscreen.width = dw; canvasOffscreen.height = dh; canvasOffscreen.getContext('2d').drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh); // 在绘制每一帧的时候,绘制这个图形 context.drawImage(canvasOffscreen, x, y);
|
drawImage
绘制而成,或者根本不是图片,而是使用路径绘制出的矢量形状,那么离屏绘制还能帮你把这些操作简化为一次
drawImage
调用。
- 第一次看到
getImageData
和putImageData
这一对 API,我有一种错觉,它们简直就是为了上面这个场景而设计的。前者可以将某个 Canvas 上的某一块区域保存为ImageData
对象,后者可以将ImageData
对象重新绘制到 Canvas 上面去。但实际上,putImageData
是一项开销极为巨大的操作,它根本就不适合在每一帧里面去调用。
小结
正文就到这里,最后我们来稍微总结一下,在大部分情况下,需要遵循的「最佳实践」。
- 将渲染阶段的开销转嫁到计算阶段之上。
- 使用多个分层的 Canvas 绘制复杂场景。
- 不要频繁设置绘图上下文的 font 属性。
- 不在动画中使用 putImageData 方法。
- 通过计算和判断,避免无谓的绘制操作。
- 将固定的内容预先绘制在离屏 Canvas 上以提高性能。
原文参考:Canvas 最佳实践(性能篇)