HTML5 canvas のパフォーマンスの改善

HTML5 Rocks

はじめに

Apple の実験から始まった HTML5 canvas は、ウェブの 2D 即時モード グラフィックス(リンク先は英語)に対して最も広くサポートされている標準仕様です。この標準仕様は、さまざまなマルチメディア プロジェクト、視覚化、ゲームを実装するために多くのデベロッパーにとって今や不可欠なものとなっていますが、構築するアプリケーションが複雑になるにつれ、これまで予想していなかったパフォーマンスの壁が立ちはだかるようになりました。

canvas のパフォーマンスを最適化する方法については多くの知恵や工夫が生まれていますが、それらは断片的なものばかりです。この記事は、これらの断片を集めて体系化し、デベロッパー向けの読みやすい資料としてまとめることを目的としたものです。この記事では、あらゆるコンピュータ グラフィックス環境に使用できる基本的な最適化手法に加え、canvas 特有の手法を紹介します。canvas 特有の手法は、canvas の実装が改善されると変わっていく可能性があります。とりわけ、ブラウザに canvas GPU アクセラレーションが実装されるようになると、ここで紹介するパフォーマンスの最適化手法のいくつかはそれほど効果的でなくなることが考えられます。該当する部分については注記しています。

この記事では、HTML5 canvas の使用方法は説明しません。HTML5 canvas の使用方法について詳しくは、HTML5Rocks の canvas 関連記事Dive into HTML5 の章(英語)、MDN Canvas チュートリアル をご覧ください。

パフォーマンス テスト

HTML5 canvas を取り巻く状況は刻々と変化します。そのため、JSPerfjsperf.com(英語))のテストによって、ここで提案している最適化手法の 1 つ 1 つが現在有効かどうかを確認することができます。JSPerf は、デベロッパーが JavaScript パフォーマンスのテスト コードを入力できるウェブ アプリケーションです。それぞれのテストでは、達成しようとしている結果(canvas をクリアするなど)に対し、その結果を達成する複数の方法を比較できます。JSPerf ではそれぞれの方法が短時間で可能な限り何度も実行され、1 秒あたりの反復回数として統計上有意な数が返されます。このスコアが高いほどパフォーマンスが高いことになります。

JSPerf パフォーマンス テスト ページには、普段使っているブラウザでアクセスしてテストを実行できます。JSPerf により、標準化されたテスト結果が Browserscopebrowserscope.org(英語))に格納されます。この記事で紹介している最適化手法は JSPerf でのテスト結果によって裏付けられているため、この記事に戻って最新情報をチェックし、手法が現在でも有効かどうかをご確認ください。記事の随所には、それぞれの結果をグラフで表示する小さなヘルパー アプリケーション(リンク先は英語)を埋め込んでいます。

この記事では、すべてのパフォーマンス テスト結果をブラウザのバージョン別に示しています。パフォーマンス テスト実行時にブラウザが動作していた OS の種類、さらに重要なこととして HTML5 canvas にハードウェア アクセラレーションが適用されていたかどうかについてまでは考慮していません。Chrome の HTML5 canvas にハードウェア アクセラレーションが適用されているかどうかは、アドレスバーに「about:gpu」と入力すると確認できます。

画面領域外の canvas にプリレンダリングする

ゲームの作成時によく行われることですが、複数フレームからなる画面に同じようなプリミティブを再描画する場合は、シーンの大きな部分をプリレンダリングすると高いパフォーマンスが得られます。プリレンダリングとは、画面領域外に別途用意した canvas に一時的な画像をレンダリングしておき、その画面領域外の canvas を可視領域にレンダリングすることをいいます。この手法は、コンピュータ グラフィックスの世界ではディスプレイ リスト(リンク先は英語)とも呼ばれています。

たとえば、60 fps で動作するマリオを再描画する場合は、帽子、ひげ、「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 の使用に注目してください(後ほど詳しく説明します)。次のグラフは、プリレンダリングを使用した場合のパフォーマンス上のメリットを表したものです(jsperf(英語)):

この手法は、レンダリング処理(上記の例では drawMario)が負荷の大きいものである場合に特に効果的です。この良い例がテキスト レンダリングです。これは負荷の大きい処理ですが、プリレンダリングを使用すると次のようなパフォーマンスの大幅な向上を期待できます(jsperf(英語)):

ただし、上記の例で「pre-rendered loose(余白の多いプリレンダリング)」テスト ケースに低いパフォーマンス結果があることに注目してください。プリレンンダリングを行う際は、一時的な canvas のサイズを描画画像にできるだけ近いサイズにすることが重要です。そうしないと、画面領域外のレンダリングでパフォーマンスが向上しても、大きな canvas を別の canvas にコピーする分パフォーマンスが低下してしまいます(コピー元とコピー先のサイズに応じて程度は異なります)。上記のテストで余白が少ない canvas は次のような小さいサイズです:

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

これに比べて、余白が多くパフォーマンスが低い canvas は次のような大きいサイズです:

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

canvas を一括して呼び出す

描画は負荷の大きい処理なので、長いコマンド セットを含む描画のステート マシンを読み込んでおき、それを使ってすべてを動画バッファにダンプするようにすると効率的です。

たとえば、複数の線を描画する場合は、すべての線を含むパスを 1 つ作成しておき、1 回の描画呼び出しでそのパスを描画すると効率が上がります。つまり、線を個別に描画する次のような例があるとします:

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();
}

これを次のようにして、1 つのポリラインを描画する方が高いパフォーマンスを得られます:

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 canvas の場合にも当てはまります。たとえば、複雑なパスを描画するときは、セグメントを個別にレンダリングするのではなくすべてのポイントをパスにまとめる方が効率的です(jsperf(英語))。

ただし、canvas に関してはこのルールに重要な例外があり、目的のオブジェクトを描画する際のプリミティブに小さな境界ボックス(水平線と垂直線など)がある場合は、個別にレンダリングする方が実際には効率的なことがあります(jsperf(英語)):

canvas の状態を不必要に変更しない

HTML5 の canvas 要素はステート マシンの最上位に実装され、塗りつぶしやストロークのスタイル、現在のパスを構成する過去のポイントなどをトラッキングします。グラフィックのパフォーマンスを最適化しようとするときにはグラフィックのレンダリングのみに注目しがちですが、ステート マシンを操作することもパフォーマンスのオーバーヘッドを招きます。

たとえば、複数の塗りつぶし色でシーンをレンダリングする場合は、canvas 上の配置順ではなく色ごとにレンダリングする方が負荷がかかりません。ピンストライプの柄をレンダリングする方法として、1 本のストライプをレンダリングして色を変更し、次のストライプをレンダリングする、という処理の繰り返しが挙げられるとします:

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

もう 1 つの方法として、奇数のストライプを全部レンダリングしてから偶数のストライプを全部レンダリングする方法が挙げられるとします:

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);
}

次のパフォーマンス テストでは、上記 2 つの方法を使ってピンストライプの柄を描画しています(jsperf(英語)):

予想どおり、交互に描画する方法ではステート マシンを変更することになり、負荷が大きくなるため処理速度が遅くなります。

新しい状態の画面全体ではなく画面の差分のみをレンダリングする

当然のことながら、レンダリングする領域が少ないほど負荷は小さくなります。再描画の際、異なる部分が少しであれば、その差分のみを描画する方法でパフォーマンスは大幅に向上します。つまり、描画の前に画面全体をクリアする次のような例があるとします:

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

そうではなく、描画済みの境界ボックスを記憶しておき、その部分のみをクリアします。

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

このどちらが良いかは、画面を横切る白い点の描画パフォーマンスをテストした次の結果から見てとることができます(jsperf(英語)):

コンピュータ グラフィックスに詳しい方であれば、この手法は、以前レンダリングした境界ボックスを保存してレンダリングのたびにクリアする「再描画領域」としてもご存知かと思います。

この手法はピクセルでレンダリングを行う状況でも使用できます。JavaScript Nintendo エミュレーターに関するプレゼンテーション動画(英語)に説明がありますのでご覧ください。

複雑なシーンには複数の canvas をレイヤーにして使用する

これまでで説明したように、大きな画像の描画は負荷が大きいので極力避けたいものです。プリレンダリングのセクションでは画面領域外でのレンダリング用に別の canvas を使用することについて説明しましたが、他に canvas をレイヤーとして重ねて使用することもできます。前面の canvas で透明度を使用すると、レンダリング時に GPU でアルファを合成することができます。これは次のように、2 つの canvas を絶対位置で重ね合わせてセットアップできます。

<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>

canvas を 1 つしか使わない方法に比べ、この方法では前面の canvas を描画またはクリアするときに背景を変更しなくて済むという利点があります。ゲームやマルチメディア アプリを前景と背景に分割できる場合は、これらを別々の canvas にレンダリングするとパフォーマンスが大幅に向上するのでおすすめです。次のグラフでは、1 つのネイティブ canvas を使用する場合と、前景を再描画またはクリアするだけの場合を比べることができます(jsperf(英語)):

背景を 1 度だけレンダリングするか前景より遅い速度でレンダリングしても、見た目にはわからないことが多くあります(ユーザーの注意は前景にあることがほとんどです)。たとえば、前景は毎回レンダリングし、背景は N 番目のフレームごとにレンダリングするということもできます。

アプリケーションがこのような構造で滑らかに動く場合、canvas を何枚組み合わせても基本的にはうまくいきます。

shadowBlur の使用を控える

他の多くのグラフィック環境と同様に、HTML5 canvas ではプリミティブにぼかしの効果を加えることができますが、この処理にはかなりの負荷がかかります:

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

次のパフォーマンス テストでは、同じシーンをシャドウあり/なしでレンダリングした場合、パフォーマンスに大きな違いがあることがわかります(jsperf(英語)):

canvas をクリアする各種の方法を知っておく

HTML5 canvas は即時モード(リンク先は英語)による描画を特長としているので、シーンの再描画はフレームごとに明示的に行う必要があります。このため、canvas のクリアは HTML5 canvas のアプリやゲームで根本的に重要な処理となります。

canvas の状態を不必要に変更しないで説明したとおり、canvas 全体のクリアは避けることが望ましい場合も多くあります。やむをえず行う場合は、context.clearRect(0, 0, width, height) を呼び出すか canvas 特有の裏技 canvas.width = canvas.width; を使用します。

記事執筆時点では、幅のリセットより clearRect の方が概して高いパフォーマンスが得られましたが、Chrome 14 のように canvas.width でのリセットの方が断然速いケースもありました(jsperf(英語)):

この方法は、前提となる canvas の実装に大きく依存し、また変更が生じやすいため、慎重にご検討ください。詳しくは、canvas のクリアに関する Simon Sarris 氏の記事(英語)をご覧ください。

浮動小数点座標の使用を控える

HTML5 canvas ではサブピクセルのレンダリングがサポートされていますが、この機能を無効にすることはできません。整数以外の座標で描画すると、線が滑らかになるように自動的にアンチエイリアスが使用されます。その結果、視覚効果は次のようになります(サブピクセルを使った canvas のパフォーマンスに関する 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(英語)):

ただし、canvas の実装で GPU アクセラレーションが使用されるようになれば整数以外の座標も高速レンダリングが可能になるので、このような最適化は特に必要ではなくなります。

requestAnimationFrame を使ってアニメーションを最適化する

比較的新しい requestAnimationFrame API は、ブラウザにインタラクティブなアプリケーションを実装する場合におすすめの方法です。ここでは、特定の固定チック レートでレンダリングするようブラウザに命令するのではなく、レンダリング ルーチンを呼び出すようブラウザにリクエストしてブラウザが使用可能になったときに呼び出されるようにします。この場合、ページが前面になければブラウザではそのことが認識されレンダリングが行われないというメリットもあります。

requestAnimationFrame のコールバックは 60 fps の間隔が目安になっていますが、確実にこの間隔で行われる保証はありません。そのため、前回のレンダリングからの経過時間を記録しておく必要があります。このコードは次のようになります:

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

requestAnimationFrame のこの用法は、canvas だけでなく WebGL など他のレンダリング技術にも適用できます。

記事執筆時において、この API は Chrome、Safari、Firefox でのみ使用可能です。こちらの対応策(リンク先は英語)を使用してください。

処理速度が遅いモバイルの canvas 実装

モバイルに関してですが、残念ながら記事執筆時点では、モバイルで GPU アクセラレーションにより canvas が実装されているブラウザは iOS 5.0 ベータ版の Safari 5.1 のみでした。GPU アクセラレーションがない場合、モバイル ブラウザの CPU の処理能力では近年の canvas ベースのアプリケーションに対応できないことがほとんどです。ここまでで挙げたような多くの JSPerf テスト結果でも、モバイルのパフォーマンスはデスクトップに比べて 1 桁低く、異種端末間で正常に動作するアプリの種類は大幅に少ないといえます。

まとめ

この記事では、パフォーマンスの高い HTML5 canvas プロジェクトを開発するために役立つ最適化手法をまとめました。この包括的な資料から新しい知識を得て、成果物に最適化を取り入れていただければ幸いです。最適化するゲームやアプリケーションを現在開発していない場合は、Chrome ExperimentsCreative JS(リンク先はいずれも英語)をヒントとしてご活用ください。

参考資料

  • 即時モードと保持モード(いずれも英語)。
  • HTML5 Rocks の canvas に関するその他の記事
  • Dive into HTML5 の canvas のセクション(英語)。
  • JSPerf(英語): JS パフォーマンス テストを作成できるデベロッパー向けページです。
  • Browserscope(英語): ブラウザのパフォーマンス データを確認できます。
  • JSPerfView(英語): JSPerf テスト結果をグラフで確認できます。
  • canvas のクリアに関する Simon Sarris 氏のブログ投稿(英語)。
  • サブピクセル レンダリングのパフォーマンスに関する Seb Lee-Delisle 氏の ブログ投稿(英語)。
  • requestAnimationFrame の使い方に関する Paul Irish 氏の ブログ投稿(英語)。
  • JS NES エミュレーターの最適化に関する Ben Firshman 氏のプレゼンテーション動画(英語)。

Comments

0