requestAnimationFrame と時間管理
アニメーションは「時間と値の関係」。時間を正しく扱えるかが品質の 8 割を決める。 rAF の正しい使い方、デルタタイム、フレームレート対応、バックグラウンドタブ問題まで。
setInterval / setTimeout を使わない
昔のアニメーションは setInterval(fn, 16) で 60Hz っぽく動かしていた。これには大きな問題がある:
- ブラウザの再描画タイミングと無関係 — フレーム間で複数回呼ばれたり、描画されないフレームに呼ばれたりする
- 120Hz / 144Hz モニタに対応できない
- バックグラウンドタブでも動き続ける → CPU / バッテリー消費
- 遅延が累積 — 16ms ぴったりではない
requestAnimationFrame の正体
ブラウザの再描画の直前にコールバックを呼んでくれる API(W3C 標準)。 各フレームで 1 回だけ呼ばれる。
function loop(time) {
// time は performance.now() と同じ高解像度時刻 (ms, 小数あり)
console.log("frame!", time)
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
特徴
- モニタのリフレッシュレートに自動で同期(60Hz / 90Hz / 120Hz / 144Hz)
- バックグラウンドタブでは停止される
- 引数の
timeはperformance.now()と同じ origin - 戻り値の ID で
cancelAnimationFrame(id) - 1 回だけ呼ばれる(連続したいなら自分で再 schedule)
停止できるループ
let running = true
let rafId
function loop(time) {
if (!running) return
// 何かする
rafId = requestAnimationFrame(loop)
}
function start() {
running = true
rafId = requestAnimationFrame(loop)
}
function stop() {
running = false
cancelAnimationFrame(rafId)
}
デルタタイム(最重要)
フレームレートは端末ごとに違う。1 フレームに何 ms 経ったかを毎フレーム計算して、 その値で進める。これが「フレームレート独立」の基本。
let prev = performance.now()
function loop(now) {
const dt = (now - prev) / 1000 // 秒に変換
prev = now
// 例: 100px/秒 で動く
x += 100 * dt
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
これで 60Hz / 120Hz / 30Hz どこでも同じ速度で動く。
第一引数を信用する
コールバックは now を受け取る。これを使う。Date.now() や別の performance.now() 呼びは不要。
// ✗ 重複計測になる
requestAnimationFrame(() => {
const now = performance.now()
...
})
// ✓ 引数を使う
requestAnimationFrame((now) => {
...
})
初回フレームの罠
初回フレームの dt は巨大になることがある(タブを開いてから初フレームまで時間が経っているため)。
対策は「初回はスキップ」または「上限を設ける」。
let prev = null
function loop(now) {
if (prev === null) {
prev = now
requestAnimationFrame(loop)
return // 初回はスキップ
}
const dt = Math.min((now - prev) / 1000, 0.1) // 上限 100ms
prev = now
update(dt)
requestAnimationFrame(loop)
}
固定タイムステップ(ゲームループ向け)
物理シミュレーション等で計算ステップを一定にしたい場合は、累積した時間を 固定 step で消化する手法(Glenn Fiedler「Fix Your Timestep!」が原典)。
const STEP = 1 / 60 // 物理は 60Hz 固定
let acc = 0
let prev = performance.now()
function loop(now) {
const frameTime = Math.min((now - prev) / 1000, 0.25)
prev = now
acc += frameTime
while (acc >= STEP) {
physicsUpdate(STEP) // 必ず 1/60 秒進める
acc -= STEP
}
// 描画は補間値で滑らかに
const alpha = acc / STEP
render(alpha)
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
performance.now() の基準
- ページロード時を 0 とした高解像度時刻(小数 µs まで)
- 現代ブラウザでは精度が抑制されている(Spectre 対策で 0.1ms 単位)
Date.now()は単位が ms 整数 + システム時計に依存(NTP 補正で巻き戻る可能性)— アニメーションには絶対使わない
Page Visibility API と組み合わせ
rAF はバックグラウンドで止まるが、戻ってきたとき巨大な dtを食らう。Visibility 変化を捉えて初期化:
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
prev = performance.now() // dt をリセット
}
})
FPS 計測
let frames = 0
let lastFpsTime = performance.now()
function loop(now) {
frames++
if (now - lastFpsTime >= 1000) {
console.log("FPS:", frames)
frames = 0
lastFpsTime = now
}
requestAnimationFrame(loop)
}
シングルトンループ vs 複数ループ
多くのアニメーションがある場合、1 つの rAF ループで全部回す方が効率的。 各アニメーションが独自に rAF を持つと、フレーム内で順序が予測できず、計測オーバーヘッドも増える。
// グローバルなアニメーションマネージャ
const tasks = new Set()
function tick(now) {
for (const task of tasks) {
if (task(now) === "done") tasks.delete(task)
}
if (tasks.size > 0) requestAnimationFrame(tick)
}
function add(task) {
tasks.add(task)
if (tasks.size === 1) requestAnimationFrame(tick)
}
// 使用
add((now) => {
// ... アニメーション処理
return done ? "done" : "continue"
})
setTimeout / setInterval を使うとき
ゼロではない。非アニメーションのスケジュール(5 秒後にフェードアウト等)はこちらで OK。
setImmediate / queueMicrotask との違い
- queueMicrotask: 現在のタスクの末尾にマイクロタスクを追加。Promise の解決と同じレベル
- setTimeout(fn, 0): 4ms 以上の最小遅延、マクロタスク
- requestAnimationFrame: 次の描画前
- requestIdleCallback: ブラウザが暇なとき(非クリティカル処理向け)
requestIdleCallback と組み合わせ
重い計算を分割して、暇な時間に回す:
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0 && tasks.length > 0) {
processOneTask()
}
})
実例: ボックスを滑らかに動かす
const box = document.querySelector("#box")
let x = 0
let prev = performance.now()
function loop(now) {
const dt = (now - prev) / 1000
prev = now
x = (x + 100 * dt) % window.innerWidth
box.style.transform = `translateX(${x}px)`
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
DPI / DPR とアニメーション
- Canvas は
devicePixelRatioを考慮しないとボケる - アニメーション値は CSS px で考えてよい
- サブピクセル精度(小数 px)でなめらかに
よくあるミス
| NG | 原因 / 対処 |
|---|---|
| カクつく | dt 無しで定数値を加算 / 60Hz 想定で書いている |
| たまに飛ぶ | 初回 dt が巨大、上限を設定する |
| タブ復帰で飛ぶ | visibilitychange で prev をリセット |
| 120Hz で 2 倍速 | 定数加算 + 120Hz。dt ベースに直す |
| 停止できない | cancelAnimationFrame と running フラグの併用 |
| メモリリーク | 切替時に古いループを止める |
Web Workers 上での rAF
Worker には requestAnimationFrame がない(DOM がないため)。
OffscreenCanvas を Worker で使うときは、メインスレッド側で rAF してメッセージを送るか、
固定インターバルで動かす。
アニメーションを書くときは「dt 秒で何が起きる」を最初に決める。 「100px / 秒」「1 回転 / 2 秒」のように。これが頭に入っていれば、フレームレートが変わっても破綻しない。