本文へスキップ

ゲーム開発者のための Long Animation Frames ​

ゲームが数秒おきにカクつく。Performance パネルを開いて記録ボタンを押し、待って遊んで止める。それからフレームチャートをスクロールして、「なんか変だった」あの一フレームを探す。記録中に起きなかったら、また最初からやり直し。

実は Chromium 系のブラウザは、記録なしで、常時、この問いのほとんどにすでに答えてくれます。それが Long Animation Frames API(LoAF)です。ほとんどのゲーム開発者はその存在を知りません。この記事では、LoAF が何を教えてくれるのか、ゲームにとって足りない2つの点、そして myaku がその穴をどう埋めるのかを紹介します。計測には実際の Phaser ゲームを使いました。

Long Animation Frames とは ​

Long Animation Frames は 2024 年 3 月の Chrome 123 で導入されました。すべての Chromium 系ブラウザ(Chrome、Edge、Opera、Brave)に搭載されていますが、W3C の仕様はまだドラフトです。Firefox と Safari にはまだありません。

LoAF は古い Long Tasks API に代わるものです。Long Tasks が教えてくれたのは「メインスレッドがしばらくブロックされていた」ということだけでした。LoAF は、どのフレームが遅かったのかと、その時間がスクリプト・スタイル・レイアウトにどう配分されたのかを教えてくれます。さらにどのスクリプトが実行されたのかも、ソース URL・関数名・位置付きでわかります。

一度購読すれば、ページが生きている間ずっと報告し続けてくれます。

js
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) console.log(entry.toJSON());
}).observe({ type: "long-animation-frame", buffered: true });

エントリーの中身 ​

本物のエントリーを得るため、Phaser by Example にある小さな Phaser 3 のエンドレスランナー Runner を使い、シーンの update にわざと遅い関数を2つ追加しました。

  • rebuildSkyline():120 フレームごとに 70 ms の処理
  • sortClouds():200 フレームごとに 30 ms の処理

そして npx myaku record で、Chrome で 8 秒間のプレイを記録しました。ブラウザが出力したエントリーの1つを抜粋すると次の通りです。

json
{
  "entryType": "long-animation-frame",
  "startTime": 6280.6,
  "duration": 87.4,
  "renderStart": 6296.9,
  "styleAndLayoutStart": 6367.9,
  "blockingDuration": 21.1,
  "scripts": [
    {
      "invoker": "FrameRequestCallback",
      "invokerType": "user-callback",
      "duration": 71,
      "forcedStyleAndLayoutDuration": 0,
      "sourceURL": "http://localhost:60303/__myaku__/profiler.js",
      "sourceFunctionName": "",
      "sourceCharPosition": 10709
    }
  ]
}

読み方を見ていきましょう。

  • duration:フレーム全体で 87 ms。60 Hz のゲームの持ち時間は 16.7 ms です。
  • renderStart から styleAndLayoutStart まで:レンダリングフェーズで、requestAnimationFrame のコールバックが走るのはここです。今回は 71 ms で、ほぼすべてを占めています。
  • scripts:実行されたもの。FrameRequestCallback は requestAnimationFrame のコールバックを意味します。ほかにはイベントハンドラー(IMG#player.onload、DOMWindow.onmessage)、タイマー、Promise の解決などが現れます。
  • sourceURL、sourceFunctionName、sourceCharPosition:そのスクリプトの場所。クリックハンドラーやローダーのコールバックなら、これがあなたの関数そのものなので、すぐに飛んでいけます。
  • forcedStyleAndLayoutDuration:スタイルを変えた後にレイアウトを読み取った(offsetWidth、getBoundingClientRect)ことで、スクリプトが強制したレイアウトの時間。キャンバスのゲームではまれですが、DOM ベースの UI ではよくあります。

これだけでもフレームカウンターよりずっと多くの情報です。ただしゲームには、落とし穴が2つあります。

落とし穴その 1:50 ミリ秒 ​

LoAF が報告するのは 50 ms を超えるフレームだけです。Web ページにとっては妥当なしきい値ですが、ゲームにとっては 3 フレーム分です。

sortClouds() のフレームはそれぞれ 30 ms かかっていました。ほぼ 2 フレーム分で、プレイヤーはこれをカクつきとして感じます。ところがブラウザは、そのどれについてもエントリーを出しませんでした。8 秒間のプレイで LoAF が報告したのは 3 フレームでしたが、実際にはゲームで 7 回ヒッチが起きていました。

つまり LoAF は最悪のフレームの説明はできても、悪いフレームをすべて見つけることはできません。やはりフレームは自分で計測する必要があります。

落とし穴その 2:すべてが一つのコールバック ​

もう一度スクリプトのエントリーを見てください。ゲームでは、ほぼすべての処理が1つの requestAnimationFrame コールバック、つまりエンジンのループの中で行われます。LoAF はその境界で止まるので、遅いフレームはどれも、おおむね「ゲームループに 71 ms かかった」としか言いません。

今回の記録では、エントリーは Myaku 自身を指しています。Myaku が各フレームを計測するために requestAnimationFrame をラップしているからです。Myaku がなければ、エンジンが requestAnimationFrame に渡したもの、Phaser ならそのゲームループを指すはずです。どちらにしても正しいのですが、あまり多くは語ってくれません。

Myaku はどう穴を埋めるのか ​

myaku は Web ゲームのためのフレームプロファイラーです。常に動き続け、直近 10 秒分のフレームを保持し、遅いフレームの一つひとつを説明します。LoAF が役立つところでは LoAF を使い、LoAF に見えないところでは独自の計測を使います。

すべてのフレームを計測します。 各 requestAnimationFrame コールバックの時間を計るので、50 ms ではなく、ディスプレイの間隔の 1.5 倍(60 Hz なら 25 ms)で遅いフレームと判定します。

これをきちんと計測しようとしたら、この記事を書いている最中に Myaku 自身の穴が見つかりました。コールバックが 30 ms かかったフレームの後、Chrome は次のフレームを、通常の 16.7 ms 刻みにぴったり戻ったタイムスタンプで届けることがよくあります。そのため、タイムスタンプ同士の間隔は普通に見えてしまうのです。現在の Myaku は、フレームの長さを少なくとも自身のコードが走った時間以上として数えます。上の 7 回のヒッチをすべて捕まえられたのはこのおかげです。

コールバックの中まで見ます。 LoAF が Myaku のラッパーのせいにしたときは、Myaku がそれをそのフレームでのゲーム自身のコールバックとそのソース行に置き換えます。さらにエンジンスパンがコールバックを細かく分けます。Phaser なら update、update:<scene key>、render、PixiJS・KAPLAY・Excalibur なら update と render、three.js なら render です。これらはすべて自動で検出されます。

70 ms のフレームの1つでは、次のようになります。

Frame 136 took 71 ms (budget 25 ms). 71 ms was script: step in node_modules/.vite/deps/phaser.js:22944. Spans: update:game 70 ms, update 70 ms, render 0.1 ms.

LoAF が一度も報告しなかった 30 ms のフレームでは、こうです。

Frame 316 took 30 ms (budget 25 ms). The game's frame code ran 30 ms. Spans: update:game 30 ms, update 30 ms, render 0.1 ms.

限界についてははっきりさせておきます。Myaku は rebuildSkyline() という名前までは出しません。Phaser のループの中でどの関数が走ったのかは、LoAF と Myaku のどちらからも見えないからです。わかるのは、そのフレームとかかった時間です。さらに、71 ms のうち 70 ms を使ったのは描画ではなく game シーンの update だったこともわかります。最後の一歩として、怪しい処理を自分のスパンで囲んでみてください。

ts
import { myaku } from "myaku";

const profiler = myaku();

update() {
  profiler.span("skyline", () => this.rebuildSkyline());
  profiler.span("clouds", () => this.sortClouds());
}

すると判定に skyline の名前が出てきます。

スクリプト以外も確認します。 フレームが遅くなる理由には、スクリプトのエントリーには現れないものもあります。Myaku は WebGL コンテキスト上のテクスチャアップロードとシェーダーコンパイルも数え、ガベージコレクションを見つけるためにヒープを監視し、隠れたウィンドウや覆われたウィンドウをブラウザがスロットリングしたことにも気づきます。各判定には原因(script、layout、upload、compile、gc、throttled、hidden、unknown)と確信度が付きます。証拠が乏しいときは、推測せずに unknown と答えます。

Long Animation Frames がない環境では ​

Firefox や Safari でも、Myaku はすべてのフレーム、あなたのスパンとエンジンスパン、そして WebGL とヒープの証拠を計測します。欠けるのはスクリプトの帰属情報なので、script の判定はフレームのコード時間とスパンだけに頼ることになります。ゲームを自動で操作する myaku record には Chrome か Edge が必要です。

試してみよう ​

エントリーファイルの先頭に一行追加するだけで、画面の隅にフレームグラフが現れます。赤いスパイクをクリックすると、その判定が読めます。

ts
import "myaku/auto";

あるいは、ビルドに手を加えずに記録できます。

bash
npx myaku record dist/

myaku を始める → · Phaser のゲームがカクつくのはなぜ? · PixiJS のゲームがカクつくのはなぜ?