提升 HTML5 画布性能

Boris Smus
Boris Smus

简介

HTML5 画布最初是 Apple 的一项实验,现在是 Web 上 2D 即时模式图形最广泛支持的标准。现在,许多开发者都依赖它来开发各种多媒体项目、可视化效果和游戏。然而,随着我们构建的应用越来越复杂,开发者会不经意间遇到性能瓶颈。 关于优化画布性能,有很多零散的经验。本文旨在将其中一些内容整合到更易于开发者理解的资源中。本文包含适用于所有计算机图形环境的基本优化,以及特定于画布的技术,这些技术可能会随着画布实现的改进而发生变化。特别是,随着浏览器供应商实现画布 GPU 加速,本文讨论的一些性能技术的影响可能会减弱。我们会在适当的位置注明这一点。 请注意,本文不会深入探讨 HTML5 画布的使用。为此,请查看 HTML5Rocks 上这些与画布相关的文章、Dive into HTML5 网站上的这一章或 MDN 画布教程。

性能测试

为了应对快速变化的 HTML5 画布世界,JSPerf (jsperf.com) 测试会验证每项建议的优化是否仍然有效。JSPerf 是一个 Web 应用,可让开发者编写 JavaScript 性能测试。每项测试都侧重于您尝试实现的结果(例如,清除画布),并包含实现相同结果的多种方法。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 Canvas 应用和游戏而言,清除画布是一项至关重要的操作。如避免画布状态更改部分所述,通常不建议清除整个画布,但如果您必须这样做,则有两种方法:调用 context.clearRect(0, 0, width, height) 或使用画布专用的技巧:canvas.width = canvas.width;。在撰写本文时,clearRect 的性能通常优于宽度重置版本,但在某些情况下,在 Chrome 14 中使用 canvas.width 重置技巧的速度明显更快

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

避免使用浮点坐标

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

子像素

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

如需将浮点坐标转换为整数,您可以使用多种巧妙的技术,其中性能最高的技术包括:将目标数字加上 0.5,然后对结果执行按位运算以消除小数部分。

// 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)。

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

使用 requestAnimationFrame 优化动画

建议使用相对较新的 requestAnimationFrame API 在浏览器中实现交互式应用。您无需命令浏览器以特定的固定 tick 速率进行渲染,而是礼貌地请求浏览器调用您的渲染例程,并在浏览器可用时获得调用。还有一个好处是,如果网页不在前台,浏览器会智能地选择不进行渲染。 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 Experiments 和 Creative JS,从中汲取灵感。

参考