setInterval() を requestAnimationFrame() に変えてもカクカクさせない方法

前提知識

まず requestAnimationFrame() は setInterval() と同じで定期的にコールバックを実行する API

requestAnimationFrame() を使うことによるメリットは以下の通り。

  • タブがバックグラウンドになった時に fps を落として実行される
  • ブラウザの描画更新単位と同じ単位で呼び出される

よって

  • 低メモリ消費
  • 省電力

しかし setInterval() で書いていた処理を requestAnimationFrame() に書き換えるに当たって一つ問題がある。 それは実行する間隔を指定できないこと。 setInterval() は第2引数でミリ秒でコールバックが呼ばれる間隔を指定できる。 しかし requestAnimationFrame() が受け付けるのはコールバックのみ。 ブラウザの描画更新単位と同じ単位で呼び出されるため効率の良い API だが、呼ばれる間隔はまちまちになってしまう問題がある (大体 60 fps と言われているが、バックグラウンドになった際にはもっと低 fps になるらしい)。

コールバックを実行するタイミング指定できない問題をどうにかする

自分は今回糸通しのゲームで requestAnimationFrame() 対応したかった。 このゲームはPCとスマホ両対応といいつつ自分が暇な時に触りたいので圧倒的にスマホの需要のほうが大きい。 しかし FPS を制御できないのはアクションゲームにとっては致命的。 だけど冒頭に挙げたメリットはスマホにとってうれしいことばかりだったのでそれなりに対応したい問題だった。

そこで検索したら以下の記事がヒットした。

requestAnimationFrame のタイミングにたよって値を変えるするというのではなく、経過時間を管理し再描画のタイミングで「経過時間に合わせたフレーム」を表示してやればよさそうです。

requestAnimationFrame でフレームと再描画更新を制御する

上記の記事では「経過時間に合わせたフレーム」を表示するために描画の対象を「コマ割り」し、フレームごとのコマを描画している。 ただ、自分の場合はゲームのメインループをカクつかせずに回す方法が知りたかった。 ゲームはユーザの入力が絡むシーンが大半なので単純にコマ割できず、 毎フレームごとにじりじり座標を更新していかなければならない場合が大半なのでどうすべきか考えた結果、 その方法が割と上手くいったので共有してみる。

解決策

やりたいことは以下のような感じです。

setInterval() を使うなどして FPS を固定させた場合のそれぞれのオブジェクトの移動量を100%とした時、 前回との経過時間から「実際の FPS」を計算し、「想定している FPS」と比較して割合を出します (70%とか120%とか)。 そしてその割合を移動量に掛けることで描画のタイミングがバラついても移動量は FPS 固定の時と同じ移動量になる(はず)なので、 カクつくことなく描画できます。

より分かりやすく実際のアルゴリズムで書いてみると以下の通りです。

  1. 1回の移動量に対する割合を計算する
  2. 描画した時の時間を覚えておく
  3. その計算量に応じてオブジェクトを移動させる

実際のコードはこんな感じです (色々省略してます)。

ito-to-shi/app.js at 65634edaa427a53649f8ac825e71c97b05c36097 · tyru/ito-to-shi · GitHub

  update() {
    // 1. 1回の移動量に対する割合を計算する
    // 注意:この関数では経過時間しか計算していないので嘘コメントです。
    // 実際はそれぞれのオブジェクトで経過時間から割合を計算しています。
    const now = Date.now();
    const elapsedMs = now - this._prevUpdatedTime;

    // 2. 描画した時の時間を覚えておく
    this._prevUpdatedTime = now;

    // 3. その計算量に応じてオブジェクトを移動させる
    // (計算量である elapsedMs をそれぞれの画面のオブジェクトに渡して座標を移動して描画してもらっている)
    screen.update(elapsedMs);
  }

で呼ばれた update() メソッドの冒頭で割合を出しています。

ito-to-shi/running.js at 65634edaa427a53649f8ac825e71c97b05c36097 · tyru/ito-to-shi · GitHub

  update(elapsedMs) {
    const movePercent = elapsedMs / constant.THE_FPS;

    // (省略)
  }

ちなみに 30 FPS の想定ですが、constant.THE_FPS = 1000.0 / 30.0 となっていて非常に紛らわしい変数名になってました… *1

なので上の処理は movePercent = 経過時間(ミリ秒) / 1フレームにかかるミリ秒 になります。 100 ms かかる想定が 200 ms かかったら 2.0 (=200%) です。 その場合は2フレーム分オブジェクトを移動させればいいですね。

失敗例

逆に失敗した方法は、想定している FPS 分の経過時間が経っていなかったら座表計算や描画をスキップする方法です。 これだとスキップした時には当然描画は行われないため、カクついてしまいます (この件で描画全体をスキップするのではなく毎回描画は行う方法でないとカクつくんじゃないかと思って冒頭の方法に変えた)。

ito-to-shi/app.js at f29fae827b02d238ca753ff20f60ad69cbfb3a6e · tyru/ito-to-shi · GitHub

  update() {
    // Skip if main loop was called too early.
    const now = Date.now();
    const stepFrames = Math.floor((now - this._prevUpdatedTime) / constant.THE_FPS);
    if (stepFrames > 0) {
      this._prevUpdatedTime = now;
    }
    // Update screen.
    const dispatcher = this._screenDispatcher;
    const screen = dispatcher.screens[dispatcher.screenId];
    if (screen && screen.update) {
      screen.update(stepFrames);
    }
  }

stepFrames > 0 の時のみ「描画した時の時間を保存&呼ばれた update() メソッドで描画」しているため、小数点以下が考慮されていません。

呼ばれた update() メソッドは以下の通り。

ito-to-shi/running.js at f29fae827b02d238ca753ff20f60ad69cbfb3a6e · tyru/ito-to-shi · GitHub

  update(stepFrames) {
    for (let i = 0; i < stepFrames; ++i)
      if (!this._doUpdate())
        break;
  }

前回の経過時間から描画するフレーム数ごとに描画関数を呼ぶようにしただけ。 名前は変わってますが movePercent と stepFrames は同じ値ですね。 この方法だとstepFrames=0.7(70%)とかの場合、ループは1回も回りません。

実際のデモ

(この記事のデモのつもりじゃないけど) 実際に以下のリンクから遊べます。

一応前のバージョンと比較してプレイしてみたかったので、前のバージョンもプレイ可能にしてみた。

前のバージョンと比べると普段の操作はむしろ前よりもヌルヌルになった気がします (ブラウザからすると setInterval() の方が非効率な API なんでしょうね…)。 ちなみに前のバージョンは色々バグっていたのでそこはあまりツッコまないでください。

*1:今は修正済み。あとついでに movePercent もダサい名前で変えたい…けど良い名前が思いつかない