提升 HTML5 画布性能

鲍里斯·萨姆斯
Boris Smus

简介

HTML5 画布一开始是 Apple 的一项实验,是网络上支持最广泛的 2D 即时模式图形标准。现在,许多开发者都依靠它来开发各种多媒体项目、可视化图表和游戏。但是,随着我们构建的应用的复杂性增加,开发者会在无意中遇到性能墙。关于优化画布性能的知识有很多,但并不系统。本文旨在将其中的部分文章整合到更易于开发者理解的资源中。本文介绍了适用于所有计算机图形环境的基本优化,以及画布专用技术(这些技术可能会随着画布实现的改进而发生变化)。特别是,随着浏览器供应商实现画布 GPU 加速,上述部分性能技术的影响可能会逐渐降低。我们将在适当情况下注明这一点。请注意,本文不讨论 HTML5 画布的使用。为此,请参阅与 HTML5Rocks 相关的画布相关文章“深入了解 HTML5 网站”的这一章MDN 画布教程。

性能测试

为了适应日新月异的 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 任天堂模拟器演讲所示。

为复杂场景使用多个分层画布

如前所述,绘制大型图像的成本很高,应尽可能避免。除了使用其他画布进行屏幕外渲染之外(如预渲染部分所示),我们还可以使用相互层叠的画布。通过在前景画布中使用透明度,我们可以依靠 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 中使用 canvas.width 重置技巧 14 的速度要快得多

请谨慎对待这条提示,因为它很大程度上取决于底层画布实现,并且随时可能更改。如需了解详情,请参阅 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)。

请注意,一旦画布实现经过 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 实验广告素材 JS,获取灵感。

参考编号