requestAnimationFrame と時間管理

アニメーションは「時間と値の関係」。時間を正しく扱えるかが品質の 8 割を決める。 rAF の正しい使い方、デルタタイム、フレームレート対応、バックグラウンドタブ問題まで。

setInterval / setTimeout を使わない

昔のアニメーションは setInterval(fn, 16) で 60Hz っぽく動かしていた。これには大きな問題がある:

requestAnimationFrame の正体

ブラウザの再描画の直前にコールバックを呼んでくれる API(W3C 標準)。 各フレームで 1 回だけ呼ばれる。

function loop(time) {
  // time は performance.now() と同じ高解像度時刻 (ms, 小数あり)
  console.log("frame!", time)
  requestAnimationFrame(loop)
}
requestAnimationFrame(loop)

特徴

停止できるループ

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() の基準

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 との違い

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 とアニメーション

よくあるミス

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 秒」のように。これが頭に入っていれば、フレームレートが変わっても破綻しない。