提升 HTML5 画布性能

Boris Smus
Boris Smus

简介

HTML5 Canvas 最初是 Apple 的一项实验,是 Web 上最受支持的 2D 即时模式图形标准。现在,许多开发者都依赖于它来制作各种多媒体项目、可视化内容和游戏。但是,随着我们构建的应用的复杂程度不断增加,开发者在无意中触及了性能墙。 有关优化画布性能的知识有很多,但并不系统。本文旨在将其中的部分内容整合到一个更易于开发者理解的资源中。本文介绍了适用于所有计算机图形环境的基本优化,以及随着画布实现的改进而可能会发生变化的画布专用技术。具体而言,随着浏览器供应商实现画布 GPU 加速,本文中介绍的一些性能技术可能会发挥更小的影响。我们会在适当的地方注明这一点。请注意,本文不会介绍 HTML5 Canvas 的使用。为此,请参阅 HTML5Rocks 上的这些与画布相关的文章“深入了解 HTML5”网站上的此章节MDN Canvas 教程。

性能测试

为了应对 HTML5 画布快速变化的领域,JSPerf (jsperf.com) 测试会验证每项建议的优化是否仍然有效。JSPerf 是一款可供开发者编写 JavaScript 性能测试的 Web 应用。每个测试都侧重于您尝试实现的结果(例如清除画布),并包含可实现同一结果的多种方法。JSPerf 会在短时间内尽可能多地运行每种方法,并提供具有统计意义的每秒迭代次数。得分越高越好!访问 JSPerf 性能测试页面的用户可以在其浏览器上运行测试,并让 JSPerf 将标准化测试结果存储在 Browserscope (browserscope.org) 上。由于本文中的优化技巧有 JSPerf 结果作为依据,因此您可以随时返回查看有关该技巧是否仍然适用的最新信息。我编写了一个小型辅助应用,用于将这些结果呈现为图表,并嵌入在本文中。

本文中的所有性能结果均以浏览器版本为键。这其实是一种限制,因为我们不知道浏览器在哪种操作系统上运行,更重要的是,在运行性能测试时,HTML5 画布是否经过了硬件加速。您可以通过在地址栏中访问 about:gpu 来了解 Chrome 的 HTML5 画布是否启用了硬件加速。

预渲染到屏幕外画布

如果您要在多个帧中向屏幕重新绘制类似的图元(编写游戏时经常会出现这种情况),则可以通过预渲染场景的大部分来显著提升性能。预渲染是指使用单独的屏幕外画布(或画布)来渲染临时图片,然后将屏幕外画布渲染回可见画布。例如,假设您要以每秒 60 帧的速度重新绘制正在奔跑的马里奥。您可以在每一帧上重新绘制马里奥的帽子、胡须和“M”,也可以在运行动画之前预渲染马里奥。 不预渲染:

// canvas, context are defined
function render() {
  drawMario(context);
  requestAnimationFrame(render);
}

预渲染:

var m_canvas = document.createElement('canvas');
m_canvas.width = 64;
m_canvas.height = 64;
var m_context = m_canvas.getContext('2d');
drawMario(m_context);

function render() {
  context.drawImage(m_canvas, 0, 0);
  requestAnimationFrame(render);
}

请注意 requestAnimationFrame 的用法,后续部分将对此进行详细介绍。

当渲染操作(上例中的 drawMario)成本高昂时,此技术尤为有效。一个很好的例子是文本渲染,这是一项非常耗费资源的操作。

但是,“宽松预渲染”测试用例的性能不佳。预渲染时,请务必确保临时画布紧密贴合您要绘制的图片,否则,离屏渲染带来的性能提升将会被将一个大画布复制到另一个画布所带来的性能损失抵消(具体取决于源目标大小)。上述测试中的紧凑画布只是更小:

can2.width = 100;
can2.height = 40;

与松散的配置相比,紧密的配置可带来更好的性能:

can3.width = 300;
can3.height = 100;

批量调用画布

由于绘制是一项耗时操作,因此使用一组长命令加载绘制状态机,然后将所有命令转储到视频缓冲区,会更高效。

例如,在绘制多条线条时,创建一个包含所有线条的路径并通过单次绘制调用绘制该路径,会更高效。换句话说,而不是绘制单独的线条:

for (var i = 0; i < points.length - 1; i++) {
  var p1 = points[i];
  var p2 = points[i+1];
  context.beginPath();
  context.moveTo(p1.x, p1.y);
  context.lineTo(p2.x, p2.y);
  context.stroke();
}

通过绘制单个多段线,我们可以获得更好的性能:

context.beginPath();
for (var i = 0; i < points.length - 1; i++) {
  var p1 = points[i];
  var p2 = points[i+1];
  context.moveTo(p1.x, p1.y);
  context.lineTo(p2.x, p2.y);
}
context.stroke();

这同样适用于 HTML5 画布。例如,在绘制复杂路径时,最好将所有点放入路径中,而不是单独渲染线段 (jsperf)。

但请注意,使用 Canvas 时,此规则有一个重要的例外情况:如果绘制所需对象所涉及的基元具有较小的边界框(例如,水平线和垂直线),则分别渲染它们实际上可能更高效 (jsperf)。

避免不必要的画布状态更改

HTML5 画布元素是在状态机之上实现的,该状态机会跟踪填充和描边样式等内容,以及构成当前路径的先前点。在尝试优化图形性能时,很容易只关注图形渲染。不过,操控状态机也可能会产生性能开销。例如,如果您使用多种填充颜色来渲染场景,则按颜色渲染的开销要低于按画布上的放置位置渲染。如需渲染条纹图案,您可以渲染条纹、更改颜色、渲染下一个条纹等:

for (var i = 0; i < STRIPES; i++) {
  context.fillStyle = (i % 2 ? COLOR1 : COLOR2);
  context.fillRect(i * GAP, 0, GAP, 480);
}

或者,先渲染所有奇数条纹,然后再渲染所有偶数条纹:

context.fillStyle = COLOR1;
for (var i = 0; i < STRIPES/2; i++) {
  context.fillRect((i*2) * GAP, 0, GAP, 480);
}
context.fillStyle = COLOR2;
for (var i = 0; i < STRIPES/2; i++) {
  context.fillRect((i*2+1) * GAP, 0, GAP, 480);
}

正如预期的那样,由于更改状态机的开销较大,交错方法的速度较慢。

仅渲染屏幕差异,而非整个新状态

正如预期的那样,在屏幕上渲染的内容越少,成本就越低。如果重新绘制之间只有增量差异,只需绘制差异即可显著提升性能。换句话说,在绘制之前,请勿清除整个屏幕:

context.fillRect(0, 0, canvas.width, canvas.height);

跟踪绘制的边界框,并仅清除该边界框。

context.fillRect(last.x, last.y, last.width, last.height);

如果您熟悉计算机图形,可能也知道这种技术称为“重新绘制区域”,其中会保存之前渲染的边界框,然后在每次渲染时清除该边界框。此技术也适用于基于像素的渲染上下文,如这篇 JavaScript Nintendo 模拟器演讲所示。

针对复杂场景使用多层画布

如前所述,绘制大图片的成本较高,应尽可能避免。除了使用另一个画布在屏幕外进行渲染之外(如预渲染部分所示),我们还可以使用叠加的画布。通过在前景画布中使用透明度,我们可以依靠 GPU 在渲染时将 alpha 合成在一起。您可以按如下方式进行设置,将两个绝对定位的画布叠加在一起。

<canvas id="bg" width="640" height="480" style="position: absolute; z-index: 0">
</canvas>
<canvas id="fg" width="640" height="480" style="position: absolute; z-index: 1">
</canvas>

与仅使用一个画布相比,这样做的优势在于,当我们绘制或清除前景画布时,不会修改背景。如果您的游戏或多媒体应用可分为前景和背景,请考虑在单独的画布上呈现这些内容,以显著提升性能。

您通常可以利用人类感知的不完美性,只渲染一次背景,或者以比前景(可能会占据用户的大部分注意力)更慢的速度渲染背景。例如,您可以在每次渲染时渲染前景,但只渲染每 N 帧一次的背景。另请注意,如果您的应用在这种结构下运行得更好,这种方法可很好地推广到任意数量的复合画布。

避免使用 shadowBlur 效果

与许多其他图形环境一样,HTML5 画布允许开发者模糊处理基元,但此操作的开销可能非常高:

context.shadowOffsetX = 5;
context.shadowOffsetY = 5;
context.shadowBlur = 4;
context.shadowColor = 'rgba(255, 0, 0, 0.5)';
context.fillRect(20, 20, 150, 100);

了解清空画布的各种方法

由于 HTML5 画布是一种即时模式绘制范式,因此需要在每一帧中显式重新绘制场景。因此,清除画布对于 HTML5 画布应用和游戏来说是一项非常重要的操作。如避免画布状态更改部分所述,清除整个画布通常不受欢迎,但如果您必须执行此操作,则有两种方法:调用 context.clearRect(0, 0, width, height) 或使用特定于画布的黑客方法:canvas.width = canvas.width。在撰写本文时,clearRect 通常比宽度重置版本的性能更出色,但在某些情况下,在 Chrome 14 中使用 canvas.width 重置黑客方法的速度会明显更快

请谨慎使用此提示,因为它在很大程度上取决于底层画布实现,并且很容易发生变化。如需了解详情,请参阅 Simon Sarris 关于清除画布的内容一文。

避免使用浮点坐标

HTML5 画布支持亚像素渲染,并且无法关闭。如果您使用非整数坐标进行绘制,系统会自动使用抗锯齿功能来尝试使线条平滑。以下是视觉效果,摘自 Seb Lee-Delisle 的这篇关于子像素画布性能的文章

子像素

如果平滑的精灵不是您想要的效果,使用 Math.floorMath.round 将坐标转换为整数会更快得多(jsperf):

如需将浮点坐标转换为整数,您可以使用多种巧妙的技巧,其中效果最佳的技巧是将一半加到目标数上,然后对结果执行按位运算以消除小数部分。

// With a bitwise or.
rounded = (0.5 + somenum) | 0;
// A double bitwise not.
rounded = ~~ (0.5 + somenum);
// Finally, a left bitwise shift.
rounded = (0.5 + somenum) << 0;

完整的性能细分信息可在此处找到 (jsperf)。

请注意,一旦 Canvas 实现采用 GPU 加速,能够快速渲染非整数坐标,这种优化就不再重要了。

使用 requestAnimationFrame 优化动画

相对较新的 requestAnimationFrame API 是实现浏览器中交互式应用的推荐方法。您可以礼貌地请求浏览器调用渲染例程,并在浏览器可用时调用该例程,而不是命令浏览器以特定的固定滴答速率进行渲染。一个不错的副作用是,如果网页不在前台,浏览器会足够聪明地不进行渲染。requestAnimationFrame 回调的目标回调速率为 60 FPS,但无法保证达到该速率,因此您需要跟踪自上次渲染以来经过了多少时间。这可能如下所示:

var x = 100;
var y = 100;
var lastRender = Date.now();
function render() {
  var delta = Date.now() - lastRender;
  x += delta;
  y += delta;
  context.fillRect(x, y, W, H);
  requestAnimationFrame(render);
}
render();

请注意,这种 requestAnimationFrame 用法适用于画布以及 WebGL 等其他渲染技术。在撰写本文时,此 API 仅适用于 Chrome、Safari 和 Firefox,因此您应使用此 shim

大多数移动画布实现速度缓慢

我们来谈谈移动设备。遗憾的是,在撰写本文时,只有搭载 Safari 5.1 的 iOS 5.0 Beta 版支持 GPU 加速的移动画布实现。没有 GPU 加速功能,移动浏览器的 CPU 通常不够强大,无法运行基于画布的现代应用。与桌面设备相比,上述许多 JSPerf 测试在移动设备上的性能要差一个数量级,这会大大限制您能够成功运行的跨设备应用类型。

总结

总的来说,本文介绍了一系列实用的优化技术,可帮助您开发高性能的基于 HTML5 画布的项目。现在,您已经学到了一些新知识,接下来就优化您的精彩创作吧。或者,如果您目前没有要优化的游戏或应用,不妨查看 Chrome 实验Creative JS 以获取灵感。

参考