マガジン

ゲームの処理落ち対策:描画回数を減らす5つの方法

遊びをつくる

執筆にClaudeを使用しています。

既存の木製ビー玉コースを保ちながら、一つの青い橋を慎重に足す手元
記事のテーマをもとに制作したAI生成イラスト。

ゲームが重くなる原因はさまざまですが、「描画(レンダリング)の回数が多すぎる」ことは見落とされがちな要因の一つです。JavaScriptで動くブラウザゲームの場合、毎フレーム canvas に何十回も drawImage や fillRect を呼び出していると、処理が追いつかずフレームレートが下がります。特に古いデバイスやスペックの低い端末では、この差が体感として現れやすくなります。 この記事では「描画回数そのものを減らす」という切り口に絞り、実装に直接使える5つの方法を順に説明します。ロジックの最適化やメモリ管理の話は別の機会に譲り、ここではキャンバスへの描画コールを減らすことだけを目的とします。

この記事の目次
  1. なぜ描画回数が重くなるのか
  2. 方法1:オフスクリーンキャンバスで背景を事前描画する
  3. 方法2:変化したオブジェクトだけを再描画する(ダーティフラグ)
  4. 方法3:スプライトシートで画像読み込みとクリッピングを集約する
  5. 方法4:描画対象をビューポートでカリングする
  6. 方法5:描画レイヤーを分割して更新頻度を変える
  7. 5つの方法をどう組み合わせるか
  8. 次の行動
  9. 参考資料

なぜ描画回数が重くなるのか

ブラウザの <canvas> はCPUが処理する2D描画APIを持っています。drawImage() や fillRect() のような描画命令は呼び出すたびにコストが発生します。命令の数が増えれば増えるほど、1フレーム内に積み上がる処理量が増え、16.6ミリ秒(60fps時の1フレーム予算)を超えると処理落ちが起きます。

GPUを使うWebGLやWebGPUと異なり、Canvas 2D APIはバッチ処理(複数の命令をまとめて一度に実行すること)が自動化されていません。そのため、呼び出し回数の削減は開発者が意識して行う必要があります。

方法1:オフスクリーンキャンバスで背景を事前描画する

背景や地形など、毎フレーム変化しない部分を毎回描き直すのは無駄です。オフスクリーンキャンバス(画面に表示しない仮のキャンバス)に一度だけ描いておき、毎フレームはその結果を drawImage() で貼り付けるだけにします。

// 初期化時に一度だけ
const bg = document.createElement('canvas');
bg.width = 800; bg.height = 600;
const bgCtx = bg.getContext('2d');
// ここで背景を描く(タイルマップなど)

// ゲームループ内
ctx.drawImage(bg, 0, 0); // 1回の呼び出しで済む

タイルマップを例にとると、100枚のタイルを毎フレーム描くのを1回の drawImage に変えるだけで、描画コールを大幅に削減できます。「変化しないものは事前に焼き付ける」という原則で適用対象を探してください。

方法2:変化したオブジェクトだけを再描画する(ダーティフラグ)

画面全体を毎フレーム clearRect で消してから再描画する方法は実装が簡単ですが、変化していない部分まで毎回描き直すことになります。「ダーティフラグ(dirty flag)」という仕組みを使うと、変化が起きたオブジェクトだけを再描画の対象にできます。

実装の考え方は次の通りです。

  1. 各オブジェクトに dirty というフラグ(真偽値)を持たせます。
  2. 位置・状態が変わったときだけ dirty = true にします。
  3. 描画ループでは dirty === true のオブジェクトだけ描画し、その後 dirty = false に戻します。

この方法はパズルゲームやボードゲームのように「動くオブジェクトが一部だけ」という場面で特に効果的です。アクションゲームで画面全体が常に動いている状況では恩恵が少ないため、ゲームの性質で判断してください。

方法3:スプライトシートで画像読み込みとクリッピングを集約する

複数の小さな画像ファイルを個別に drawImage で呼び出すより、一枚の大きなシート画像(スプライトシート)にまとめてクリッピング(切り取り描画)した方が、ブラウザのテクスチャ切り替えコストを減らせます。

drawImage には切り取り範囲を指定する9引数バージョンがあります。

// drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
ctx.drawImage(sheet, 64, 0, 32, 32, x, y, 32, 32);

スプライトシートを使うとファイルのネットワーク読み込み回数も減り、ゲーム起動時のロード時間短縮にもつながります。Aseprite や TexturePacker などのツールを使うと、複数画像を自動でシート化し、座標データをJSON形式で出力できます。

方法4:描画対象をビューポートでカリングする

画面外にあるオブジェクトは描画しても表示されません。にもかかわらず drawImage を呼び出せば処理コストだけが発生します。「カリング(culling)」はビューポート(画面の表示領域)の外にあるオブジェクトを描画対象から除外する処理です。

実装は比較的シンプルで、描画前にオブジェクトの座標と画面サイズを比較するだけです。

function isVisible(obj, canvasW, canvasH) {
  return obj.x + obj.w > 0 &&
         obj.x < canvasW &&
         obj.y + obj.h > 0 &&
         obj.y < canvasH;
}

for (const obj of objects) {
  if (isVisible(obj, canvas.width, canvas.height)) {
    obj.draw(ctx);
  }
}

スクロールするマップを持つゲームで特に効果が大きい方法です。マップが大きくなればなるほど、画面外オブジェクトの割合が増えるため、カリングによる削減量も比例して大きくなります。

円形オブジェクトや回転するオブジェクトはAABB(軸平行バウンディングボックス)で近似すると計算コストを抑えられます。厳密な形状で判定する必要はありません。

方法5:描画レイヤーを分割して更新頻度を変える

一枚のキャンバスにすべてを描くのではなく、複数のキャンバスを重ねて使う方法です。CSSで position: absolute を使って重ねることで、更新頻度が異なる要素を分離できます。

  • 背景(地形・装飾)、更新頻度:変化時のみ
  • キャラクター・敵、更新頻度:毎フレーム
  • UIとスコア表示、更新頻度:イベント発生時のみ

UIのスコア表示を毎フレーム再描画するのはよくある無駄です。スコアが変わったときだけ専用のキャンバスを更新すれば、メインキャンバスのループに一切影響しません。

レイヤーを増やしすぎると管理が複雑になるため、実用上は2〜3枚程度にとどめる方が保守しやすいです。「ほぼ変化しない層」と「常に動く層」の2分割から始めると導入しやすいです。

5つの方法をどう組み合わせるか

どれか一つだけを適用するより、ゲームの構造に合わせて複数を組み合わせると効果が高まります。判断の目安は以下の通りです。

  • スクロールするマップがある → カリングを最初に入れる
  • 背景が静止している → オフスクリーンキャンバスで事前焼き付け
  • UI要素が多い → レイヤー分割でUIを切り離す
  • キャラクターのアニメーションが多い → スプライトシートで整理する
  • 動かないオブジェクトが多数ある → ダーティフラグで再描画を間引く

まず requestAnimationFrame のループ内に console.time / console.timeEnd を挟み、1フレームの所要時間を計測してから対策を選ぶと、効果を数字で確認しながら進められます。

次の行動

まずブラウザの開発者ツール(Performance タブ)で1フレームの処理時間を計測し、16.6ミリ秒を超えているフレームがどこで起きているか確認してください。計測結果をもとに、この記事の5つの方法から一つ選んで実装し、計測結果が改善するかを確認します。改善が確認できたら、次の方法を重ねて適用してください。改善したゲームはアソバースに投稿して、さまざまなデバイスでの動作を試すことができます(https://aso-verse.com)。

参考資料

  • MDN Web Docs — Canvas API(drawImage・OffscreenCanvas のリファレンス)
  • MDN Web Docs — requestAnimationFrame
  • Chrome Developers — Profiling Canvas with DevTools