ブラウザで動くゲームの画面は、静止画をずっと表示しているわけではありません。1秒間に約60回、画面全体を消して描き直すという作業を延々と繰り返しています。この記事では、その「描いて消して、また描く」仕組みをHTML5のCanvas 2Dで組み立てる方法を、動くコードとともに解説します。物理エンジンの使い方は別記事に譲り、ここでは描画とループそのものに集中します。
最終更新: 2026年8月11日
setInterval ではなく requestAnimationFrame で書くと、ディスプレイの書き換えに同期し、タブが非表示のあいだは自動的に停止します。ctx.save() と ctx.restore() で必ず挟まないと、その後のすべての描画に変形が残ります。devicePixelRatio の分だけ解像度を上げて解決しますが、canvas.width への代入はコンテキストを初期化するため ctx.scale() は必ずその後に呼びます。Canvasは、HTMLに置いた「絵を描くための白紙」です。HTML側では要素を1つ用意するだけで、あとはJavaScriptから描画命令を送り込みます。
<canvas id="stage" width="400" height="600"></canvas>
JavaScriptからは、要素を取得したあとコンテキストと呼ばれる描画窓口を受け取ります。2Dの絵を描く場合は '2d' を指定します。以降の描画命令はすべて、このコンテキストオブジェクトに対して呼び出します。
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
ここで最初につまずきやすいのが座標系です。Canvasの原点(0, 0)は画面の左上にあり、xは右に向かって増え、yは下に向かって増えます。数学の授業で習うグラフとは上下が逆です。「y座標が大きいほど画面の下」と覚えてしまえば混乱しません。落下する物体を表現したいときは、yを増やしていけばよいことになります。
もうひとつ注意点があります。Canvasのサイズは、HTML属性の width / height と、CSSの width / height で意味が違います。前者は「絵を何ピクセルで描くか」という解像度、後者は「画面上で何ピクセル分の場所を占めるか」という表示サイズです。この2つがずれると絵が引き伸ばされてぼやけます。ここは第7章で詳しく扱います。
Canvasの描画命令は数多くありますが、ゲームを作り始めるだけなら覚えることは多くありません。まずは四角形からです。
// 塗りつぶした四角形
ctx.fillStyle = '#90CAF9';
ctx.fillRect(50, 100, 200, 80); // x, y, 幅, 高さ
// 枠線だけの四角形
ctx.strokeStyle = '#1976D2';
ctx.lineWidth = 4;
ctx.strokeRect(50, 100, 200, 80);
fillStyle と strokeStyle は、それぞれ「塗りの色」と「線の色」を指定する設定値です。一度設定すると、次に変更するまでずっと効き続けます。描画命令のたびに色を渡すのではなく、色を設定してから描くという順番になっている点が、慣れるまで少し独特に感じるかもしれません。
円や自由な形を描くときは、パスという概念を使います。「線を引く道筋を宣言してから、最後にまとめて塗るか線を引くか決める」という二段構えです。
ctx.beginPath(); // パスを始める
ctx.arc(200, 300, 50, 0, Math.PI * 2); // 中心x, 中心y, 半径, 開始角, 終了角
ctx.fillStyle = '#F48FB1';
ctx.fill(); // 塗りつぶして確定
角度の単位は度ではなくラジアンです。1周は Math.PI * 2、半周は Math.PI になります。度で考えたいときは 度 * Math.PI / 180 で変換できます。また、beginPath() を忘れると前に描いたパスが残ったままつながってしまい、意図しない線が現れます。パスを描くときは毎回 beginPath() から始めるのが安全です。
文字の描画も基本的な流れは同じです。
ctx.font = 'bold 24px sans-serif';
ctx.fillStyle = '#333';
ctx.textAlign = 'center'; // 基準点を文字の中央に
ctx.textBaseline = 'middle'; // 基準点を文字の上下中央に
ctx.fillText('SCORE: 120', 200, 50);
textAlign と textBaseline を指定しないと、渡した座標は文字の左下あたりが基準になります。スコア表示のように「この位置に中央揃えで置きたい」場合は、この2つを設定しておくと計算が楽になります。文字の幅を知りたいときは ctx.measureText('文字列').width で測れます。
ここがCanvasでゲームを作るうえで最も重要な発想の転換です。Canvasは、描いた内容を覚えていません。四角形を描いたあとで「あの四角形を右に10動かす」という命令は存在しないのです。Canvasにあるのは「ここに絵の具を置く」という命令だけで、置いた絵の具はそのまま残り続けます。
ではどうやって物を動かすのか。答えは全部消して、新しい位置に描き直すです。
ctx.clearRect(0, 0, canvas.width, canvas.height); // 全部消す
ctx.fillRect(x, y, 50, 50); // 新しい位置に描く
x = x + 1; // 次回はもう1px右へ
これを高速で繰り返すと、四角形が右へ滑らかに移動しているように見えます。パラパラ漫画と同じ原理です。
「動いた部分だけ消して描き直したほうが速いのでは」と思うかもしれません。理屈のうえではその通りで、差分描画という手法は実在します。しかし、どこが変化したかを正確に追跡するコードは驚くほど複雑になり、少しでも計算を誤ると前フレームの残像が画面に残ります。現代のブラウザとGPUは全画面の塗り直しを十分に速く処理できるため、まずは毎フレーム全消しで書き、遅くなってから考えるのが現実的な順序です。
なお、clearRect は文字どおり透明に消すため、背景が透けます。背景色や背景画像を敷きたい場合は、消す代わりに背景を塗りつぶすほうが1命令で済みます。
// 透明にする代わりに、背景色で塗りつぶす(これも「全消し」になる)
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, canvas.width, canvas.height);
「消して描く」を繰り返す仕組みがゲームループです。ブラウザでこれを書くときの正解は requestAnimationFrame です。
function loop() {
update(); // 状態を進める(位置の計算など)
render(); // 画面を描く
requestAnimationFrame(loop); // 次のフレームを予約
}
requestAnimationFrame(loop);
requestAnimationFrame は「次に画面が更新されるタイミングで、この関数を1回呼んでください」とブラウザに依頼する命令です。繰り返し実行したい場合は、関数の最後で自分自身をもう一度予約します。止めたいときは戻り値のIDを保存しておき、cancelAnimationFrame(id) に渡します。
setInterval(loop, 16) でも一見同じことができそうですが、実用上の差は小さくありません。
requestAnimationFrame はディスプレイの書き換えに同期して呼ばれます。setInterval は独自のタイマーで動くため、描画の途中で画面が切り替わり、絵が上下でずれて見える現象(ティアリング)やカクつきが起きやすくなります。requestAnimationFrame は呼ばれなくなります。見えていない画面の描画にCPUとバッテリーを使わずに済みます。setInterval は裏で走り続けます。「更新」と「描画」を別の関数に分けておくと、後々の見通しがよくなります。ポーズ機能を付けるときは update() だけ止めて render() は動かす、といった制御が自然に書けるためです。
先ほどの例では、1フレームごとに x = x + 1 としていました。これには落とし穴があります。60Hzの画面では1秒に60px進みますが、120Hzのゲーミングモニターでは1秒に120px進んでしまい、環境によってゲームの速さが変わるのです。処理が重くてフレームが落ちたときは逆に遅くなります。
解決策がデルタタイムです。「前のフレームから何秒経ったか」を測り、その秒数を移動量に掛けます。requestAnimationFrame のコールバックには、経過時刻がミリ秒で渡されるので、これを使います。
let lastTime = null;
const SPEED = 60; // 1秒あたり60px進む
function loop(timestamp) {
if (lastTime === null) lastTime = timestamp;
let dt = (timestamp - lastTime) / 1000; // 秒に変換
lastTime = timestamp;
// タブ復帰直後などに巨大な値が来るので上限を設ける
if (dt > 0.1) dt = 0.1;
x = x + SPEED * dt; // 1フレームあたりではなく、1秒あたりで考える
render();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
こう書くと、60Hzでも120Hzでも「1秒で60px」が保たれます。速度を「1フレームあたり」ではなく「1秒あたり」で定義するのがコツです。
上限を設けている行にも意味があります。ユーザーが別タブに移動して数分後に戻ってくると、timestamp の差が数十秒になることがあります。そのまま計算すると物体が画面外まで一瞬でワープしてしまうため、1フレームで進める時間に上限をかけて破綻を防ぎます。
回転した四角形を描きたくなったとき、四隅の座標を三角関数で計算する必要はありません。Canvasには座標系そのものを動かすという機能があります。紙を回してから、まっすぐ絵を描くイメージです。
ctx.save(); // 現在の状態を保存
ctx.translate(cx, cy); // 原点を物体の中心へ移動
ctx.rotate(angle); // 座標系ごと回す(ラジアン)
ctx.fillRect(-w / 2, -h / 2, w, h); // 原点中心に描く
ctx.restore(); // 状態を元に戻す
ポイントは fillRect に渡している座標です。原点をすでに物体の中心へ移動させてあるので、そこから幅と高さの半分だけ左上にずらした位置から描けば、中心を軸に回転する四角形になります。
ここで絶対に忘れてはいけないのが ctx.save() と ctx.restore() のペアです。translate や rotate の効果はその後のすべての描画に残り続けます。restoreを書き忘れると、次に描く物体も回転した座標系のまま描かれ、2つ目、3つ目とどんどんずれていきます。原因が分かりにくい描画バグの筆頭です。
save() は変形だけでなく、fillStyle、strokeStyle、font、globalAlpha といった設定値もまとめて保存します。「この物体だけ半透明にしたい」といった場合も、save と restore で挟んでおけば他の描画に影響しません。
ctx.save();
ctx.globalAlpha = 0.5; // ここだけ半透明
ctx.fillRect(10, 10, 100, 100);
ctx.restore(); // 不透明に戻る
ctx.scale(sx, sy) を使えば拡大縮小もできます。消滅するブロックを縮ませる演出などは、経過時間から倍率を求めて scale に渡すだけで実現できます。
スマートフォンや高解像度ディスプレイで作ったゲームを見ると、文字や線がぼやけて見えることがあります。原因は、第1章で触れた「解像度」と「表示サイズ」のずれです。
近年の端末は、CSS上の1ピクセルを物理的な2〜3ピクセルで表示します。この倍率は window.devicePixelRatio で取得できます。Canvasの解像度をCSSサイズと同じにしてしまうと、ブラウザが引き伸ばして表示するためぼやけます。対策は、解像度だけを倍率分だけ増やし、描画時の座標はCSSピクセルのまま扱えるようにスケールをかけることです。
function setupCanvas(canvas) {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
// 実際の描画解像度を倍率分だけ上げる
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const ctx = canvas.getContext('2d');
// 以降はCSSピクセル基準の座標で描けるようにする
ctx.scale(dpr, dpr);
return ctx;
}
ここには順序の罠があります。canvas.width や canvas.height に値を代入すると、コンテキストの状態がすべて初期化されます。変形も色の設定もリセットされるため、ctx.scale(dpr, dpr) は必ずサイズ変更のあとに呼ぶ必要があります。ウィンドウのリサイズでキャンバスを作り直す場合も、そのたびに設定し直してください。
逆に、ドット絵風の見た目をあえて残したい場合は、拡大時の補間をオフにします。
ctx.imageSmoothingEnabled = false; // 拡大してもドットがぼやけない
スマートフォンの画面サイズは機種ごとにばらばらで、縦持ちと横持ちでも比率が変わります。「幅400pxの画面」を前提に座標を決め打ちすると、別の端末ではレイアウトが破綻します。
実用的な対処は、基準となる寸法を1つ決め、すべての大きさをその割合で表すという設計です。当サイトで公開しているゲームでも、画面の幅と高さから基準寸法を1つ求め、地面の幅もブロックの大きさもその比率で計算しています。
// 画面から基準寸法を1つ決める
const base = Math.min(canvas.width, canvas.height * 1.5);
// すべての大きさを基準寸法に対する割合で表す
const groundWidth = base * 0.6; // 地面は基準の60%
const blockHeight = base * 0.045; // ブロックの高さは4.5%
この方式なら、小さなスマートフォンでも大きなPCモニターでも、画面に対する各要素の比率が一定に保たれます。「どの端末でも同じ難易度になる」という点で、ゲームバランスの面でも意味があります。
画面の回転やウィンドウのリサイズに追従させる場合は、resize イベントで再計算します。ただしリサイズ中はイベントが連続して大量に発生するため、少し待ってから1回だけ処理する形にすると無駄がありません。
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
setupCanvas(canvas); // 解像度とscaleを設定し直す
layout(); // 座標を計算し直す
}, 250);
});
描画が重くなってきたときに効果が出やすいのは、次のような点です。
fillStyle を毎回代入している箇所は見直す価値があります。ctx.shadowBlur によるぼかし影は、見た目のわりに処理が重い部類です。多数の物体すべてに影を付けると一気に負荷が上がります。背景に敷く createLinearGradient のグラデーションも、毎フレーム作り直すのではなく一度作って変数に保持しておくべきです。// 画面外のキャンバスに背景を一度だけ描く
const bg = document.createElement('canvas');
bg.width = canvas.width;
bg.height = canvas.height;
const bgCtx = bg.getContext('2d');
drawBackground(bgCtx); // 重い描画はここで1回だけ
// 毎フレームは1命令で貼るだけ
function render() {
ctx.drawImage(bg, 0, 0);
drawObjects();
}
ブラウザによっては OffscreenCanvas という専用のAPIも使えますが、上のように通常のCanvas要素を画面に追加せずに使うだけでも、同じ効果が得られます。対応環境を気にせず使えるぶん、こちらのほうが手軽です。
最後に、改善に取り組む前に必ず測ってください。ブラウザの開発者ツールにあるパフォーマンス計測を使うと、どの処理に時間がかかっているかが一覧で分かります。推測で書き換えると、効果のない場所に手を入れてコードだけが複雑になります。
Canvas 2Dでゲーム画面を作るときの要点は、次の5つに集約されます。
requestAnimationFrame で書くsave() と restore() で必ず挟むdevicePixelRatio を考慮し、座標は固定ピクセルではなく比率で決めるここまでの内容で、動く絵を描く土台は整います。次のステップとして、物体どうしの衝突や重力を扱いたくなったら、物理エンジンの出番です。その導入はMatter.jsで作るブラウザ物理ゲーム開発入門で解説しています。物理エンジンに計算を任せつつ、描画はこの記事で扱った自前のCanvas描画で行う、という組み合わせが実用的です。
← トップに戻る