requestAnimationFrameは何回呼ばれるか:画面の更新と時計
遊びを見つける執筆にClaudeを使用しています。

ブラウザゲームで四角形を動かす場面を考えてみます。更新のたびに「右へ5ピクセル進める」と書くと、同じコードが60Hzの画面と120Hzの画面で別の速さになります。画面の更新が1秒に60回なら1秒で300ピクセル、120回なら600ピクセル進むからです。 題名の問いへの答えは、「画面の更新に合わせて呼ばれるので、端末と状況しだいで変わる」です。固定の回数ではありません。そのため、速さを守る仕組みは「何回呼ばれたか」ではなく「前回から何秒たったか」を基準にします。 この記事では、その考え方と、自分の端末で呼ばれる回数を数える実験の設計を紹介します。あわせて、アソバースの公式作品の確認済みの仕様から、時間をどう扱う設計が読み取れるかを整理します。
この記事の目次
requestAnimationFrameが呼ばれるタイミング
requestAnimationFrame(以下rAF)は、次に画面を描き替える直前に関数を呼ぶようブラウザへ頼む仕組みです。一度頼むと1回だけ呼ばれるので、動き続けるゲームでは、呼ばれた関数の中でもう一度rAFを頼みます。
呼ばれる頻度は、多くの環境で画面の更新頻度に合わせられます。60Hzなら毎秒およそ60回、120Hzなら毎秒およそ120回です。ただし次のような場合は、これより減ることがあります。
- タブが背景に回ると、呼び出しが止まったり大きく減ったりします。
- 省電力の設定で、更新の頻度が抑えられることがあります。
- 処理が重いと、次の更新に間に合わず、呼び出しの間隔が空きます。
つまり、1秒あたりの回数は「画面の性能」「ブラウザや端末の設定」「ゲーム側の処理の重さ」の3つで決まります。ゲームを作る側からは、どれも固定できません。
同じ速さを保つ考え方:1回あたりではなく1秒あたり
対策は、動きの量を「1回の更新あたり」ではなく「1秒あたり」で決めることです。rAFが呼ぶ関数には、その時点の時刻(ミリ秒)が引数として渡されます。前回の時刻との差を秒にしたものを経過時間(dt)と呼ぶと、移動量は次の式で求められます。
位置の増分 = 1秒あたりの速さ × dt
1秒あたり300ピクセルと決めておけば、60Hzではdtが約0.0167秒で1回に約5ピクセル、120Hzではdtが約0.0083秒で1回に約2.5ピクセル進みます。1回の移動量は違っても、1秒たてば同じ距離に着きます。60回と120回の端末が同じ速さを保つ仕組みは、これだけです。
滑らかさは変わります。120Hzの画面では同じ距離を細かく刻んで描くので、動きがなめらかに見えます。速さは同じで、刻みの細かさだけが違うと考えるとわかりやすくなります。
自分の端末で数える実験の設計
次の問いを、自分の端末で確かめてみます。
問い:この端末では、rAFは1秒に何回呼ばれるか。また、回数に頼る動きと時間に頼る動きは、どれだけ差が出るか。
以下は、単独で動く最小構成のHTMLです。ファイルとして保存してブラウザで開くと、上の四角は「1回5ピクセル」、下の四角は「1秒300ピクセル」で動き、5秒ごとに呼び出し回数を表示します。
<canvas id="c" width="600" height="80"></canvas>
<p id="out">計測中…</p>
<script>
const ctx = document.getElementById("c").getContext("2d");
const out = document.getElementById("out");
let last = null, start = null, frames = 0, fx = 0, tx = 0;
function loop(now) {
if (last === null) { last = now; start = now; }
const dt = Math.min((now - last) / 1000, 0.1);
last = now;
frames++;
fx += 5;
tx += 300 * dt;
if (fx > 580) fx = 0;
if (tx > 580) tx = 0;
ctx.clearRect(0, 0, 600, 80);
ctx.fillRect(fx, 10, 20, 20);
ctx.fillRect(tx, 50, 20, 20);
if (now - start >= 5000) {
const sec = (now - start) / 1000;
out.textContent = "1秒あたり約" + (frames / sec).toFixed(1) + "回";
frames = 0; start = now;
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>記録の取り方
表示される値を、条件ごとに3回ずつ書き留めます。条件は、通常の状態、端末の省電力設定を入れた状態、可能なら別の画面(外付けモニターなど)に移した状態です。単位は「1秒あたりの回数」です。表示は5秒ごとなので、最初の値は捨てて、2回目以降の値を使うと安定します。
結果の読み方
- 値が画面の更新頻度(60や120など)に近いなら、rAFは画面に合わせて呼ばれています。
- 省電力設定で値が下がるなら、設定が更新頻度を抑えています。
- 値が一定せず大きく上下するなら、ほかのタブやアプリで処理が重くなっている可能性があります。
もう一つの見どころは、2つの四角の動きです。上の四角は580ピクセル進むのに約116回の更新が必要です。60回/秒なら約1.9秒、120回/秒なら約1秒で端に着く計算になります。下の四角は、更新頻度によらず約1.9秒です。実際に自分の端末で、どちらの四角がどれだけの間隔で往復するかを、ストップウォッチなどで確かめてみてください。60Hzだけの端末では、上の四角が下の四角と一致するはずです。この場合、120Hz側の動きは計算で予想し、可能なら別の端末で見比べます。
時間で動かすときに起きやすい失敗
dtを使えば解決、とは限りません。よくある落とし穴は3つあります。
- 経過時間が大きくなりすぎる:背景のタブから戻った直後は、dtが数秒になることがあります。そのまま掛けると、四角が一気に飛びます。コードの Math.min(…, 0.1) は、この上限を設ける処置です。
- 薄い壁を通り抜ける:dtが大きいと1回の移動量も大きくなり、細い壁やゴールを飛び越えることがあります。衝突判定が多いゲームでは、dtを小さな固定幅(たとえば1/120秒)で何回かに分けて進める「固定刻み」の方法がよく使われます。
- 最初の一回の扱い:初回は「前回の時刻」がありません。コードの last === null の分岐のように、初回だけ別に扱う必要があります。
公式作品の時間設計から読み取れること
アソバースの公式作品の確認済みの仕様から、時間の扱いに関わる部分を拾います。内部の実装そのものは確認済み事項にないので、「仕様が秒で書かれている」という点に絞って読みます。
- 紙ひこうき郵便、確認済みの時間の仕様:海辺36秒・丘40秒・山44秒。最後の6秒で郵便受けへ
- 時計じかけの台所、確認済みの時間の仕様:本番は1皿28秒、配膳6秒
- 雨音のピアノ、確認済みの時間の仕様:録音は30秒・128音まで
- 灯りのチェス、確認済みの時間の仕様:CPUの思考時間は約450msに制限
これらはどれも「何回の更新で」ではなく「何秒」で決められています。60Hzでも120Hzでも制限時間が同じであることがルールとして意味を持つ作品なので、時間を基準に進行を測る設計が自然だと読み取れます。ただし、内部でdtを使っているのか、別の時計を使っているのかは確認済み事項にありません。この部分は、作品を開いて実際に遊び、端末ごとの体感を確かめてください。
もう一つ、共通の仕様として「動きを抑える」設定に対応しています。時間の基準を守ることと、動きの量を減らすことは別の話で、この2つを分けて設計していると読めます。
次の行動
保存したHTMLを開き、まず条件ごとに1秒あたりの呼び出し回数を3回ずつ記録してみてください。次に、自分で作っているゲームの x += 5 のような「1回あたり」の動きを、x += 速さ * dt に書き換えてみてください。アソバースに投稿する作品なら、手元の端末で動きの速さが変わらないことを確かめてから、入力、表示確認、公開の順に進めてください。
参考資料
- MDN Web Docs「window.requestAnimationFrame()」
- MDN Web Docs「Performance.now()」「DOMHighResTimeStamp」

