CHAPTER 02 · 全23項目
ゲームループと時間
経過時間 dt、フレームの予約、速い画面での描画の間引き、ポーズ中の停止処理を読み解きます。
主な参照先:frame / ensureLoop / update / redrawIfPaused
次のフレームを自分で予約する
// 描画の目標間隔。プレイ中は 60fps、動きの少ないタイトルとゲームオーバーは 30fps に抑える。
// 120Hz・144Hz などの画面では requestAnimationFrame がもっと頻繁に呼ばれるので、間引いて更新と描画の回数を減らす
// (動きは経過時間 dt で進めるので、間引いても速さは変わらない)。
const PLAY_FRAME_MS = 1000 / 60, IDLE_FRAME_MS = 1000 / 30;
let vsyncMs = 1000 / 60, lastVsync = last; // 画面の更新間隔の推定値(rAF が呼ばれる間隔の移動平均)と、直前に呼ばれた時刻
function frame(now) {
rafId = 0;
const gap = now - lastVsync;
lastVsync = now;
if (gap > 0 && gap < 50) vsyncMs += (gap - vsyncMs) * 0.1; // ポーズ明けなどの長い間隔は推定に使わない
// 目標間隔に収まる回数(60Hz のプレイ中は 1、120Hz なら 2)ごとに描く。90Hz や 100Hz は毎回描き、60fps を下回らせない。
// 判定には半回分の余裕を持たせ、呼ばれる時刻の揺れで描くはずの回を飛ばさないようにする。
const target = state === "title" || state === "gameover" ? IDLE_FRAME_MS : PLAY_FRAME_MS;
const every = Math.max(1, Math.floor(target / vsyncMs + 0.15));
if (now - last < (every - 0.5) * vsyncMs) {
rafId = requestAnimationFrame(frame);
return;
}
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
if (!paused) update(dt);
draw(now / 1000);
// ポーズ中は静止画のままにしてループを止める。再開は setPaused(false) → ensureLoop()。
if (!paused) rafId = requestAnimationFrame(frame);
}requestAnimationFrame(frame) は、次に画面が書き換わるタイミングで frame を呼ぶ予約です。1回の呼び出しで更新と描画を行い、必要なら次回を予約します。呼ばれる回数は画面のリフレッシュレートしだいで、60 Hzの画面なら毎秒60回、120 Hzの画面なら毎秒120回です。更新回数を常に60回と決め打ちしているわけではありません。
速い画面では描画を間引く
120 Hzや144 Hzの画面で毎回更新と描画をすると、60 Hzの画面の2倍以上の処理をすることになります。このゲームの動きは60 fpsあれば十分滑らかなので、frame() の前半で、今回描くかどうかを決めます。
- 画面の更新間隔を推定呼ばれた間隔
gapを1割ずつ混ぜた移動平均vsyncMs - 何回に1回描くか目標の間隔に更新が何回収まるかを
everyに - まだなら予約だけ前回の描画から
every回ぶんたつまで戻る
目標の間隔は、プレイ中が PLAY_FRAME_MS(1/60秒)、動きの少ないタイトルとゲームオーバーが IDLE_FRAME_MS(1/30秒)です。every は切り捨てで求めるので、90 Hzや100 Hzの画面では1になり、間引かずに毎回描きます。60 fpsを下回らせないためです。
| 画面 | プレイ中 | タイトル・ゲームオーバー |
|---|---|---|
| 60 Hz | 毎回(60 fps) | 2回に1回(30 fps) |
| 90 Hz | 毎回(90 fps) | 3回に1回(30 fps) |
| 120 Hz | 2回に1回(60 fps) | 4回に1回(30 fps) |
| 144 Hz | 2回に1回(72 fps) | 4回に1回(36 fps) |
速度に経過秒数を掛ける
now はミリ秒単位なので、前回との差を1000で割って秒に直します。速度が毎秒120 pxなら移動量は 120 * dt。30 fpsでも60 fpsでも、同じ1秒ならほぼ同じ距離を進みます。ただし本体は dt を最大0.05秒に制限し、長い停止後に大きく飛ばないようにしています。前の節の間引きで描画が毎秒60回になっても72回になっても、自機や敵の速さが変わらないのはこのためです。
計算してみる:フレーム数と移動量
毎秒120 pxで進む場合の、1回あたりの移動量を比較します。
ポーズではループの予約も止める
function ensureLoop() {
if (rafId || paused) return;
last = performance.now();
rafId = requestAnimationFrame(frame);
}ポーズ中は update() を呼ばず、次のフレームも予約しません。再開時の ensureLoop() は時刻を取り直すため、止めていた時間が一度に加算されません。言語やミュートをポーズ中に変えた場合は、redrawIfPaused() が1回だけ描き直します。