ゲームの処理落ち対策:描画回数を減らす5つの方法
遊びをつくる執筆にClaudeを使用しています。

ゲームが重くなる原因はさまざまですが、「描画(レンダリング)の回数が多すぎる」ことは見落とされがちな要因の一つです。JavaScriptで動くブラウザゲームの場合、毎フレーム canvas に何十回も drawImage や fillRect を呼び出していると、処理が追いつかずフレームレートが下がります。特に古いデバイスやスペックの低い端末では、この差が体感として現れやすくなります。 この記事では「描画回数そのものを減らす」という切り口に絞り、実装に直接使える5つの方法を順に説明します。ロジックの最適化やメモリ管理の話は別の機会に譲り、ここではキャンバスへの描画コールを減らすことだけを目的とします。
この記事の目次
なぜ描画回数が重くなるのか
ブラウザの <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)」という仕組みを使うと、変化が起きたオブジェクトだけを再描画の対象にできます。
実装の考え方は次の通りです。
- 各オブジェクトに dirty というフラグ(真偽値)を持たせます。
- 位置・状態が変わったときだけ dirty = true にします。
- 描画ループでは 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

