跳到正文
zenn · 游戏开发社区· 来源· maruhana·· 5 小时前AI 评分51

截取画面来测量操作延迟时,发现测量工具本身反而更慢。改用无头浏览器按帧为单位进行测量

画面を撮って操作の遅れを測ったら、測る道具のほうが遅かった。ヘッドレスのブラウザでフレーム単位に測る

正文 · 原文

LAN パーティの記事シリーズ。テーブルを囲むノート PC とスマホ、中央のルーター、上に南京錠付きの証明書の線画

家の PC でサーバを立てて、スマホのブラウザから 4 人で遊ぶ協力クッキングゲームを作っている。全体の話は入口記事に書いた。

クライアント予測の記事では、キーを押してから自キャラが動くまでの遅れを計算で見積もった。計算は書いたが、画面の上で何ミリ秒だったかは測っていない。そこで、記事の縦動画を作るついでに、入力から画面に出るまでを、撮った画面のほうから測ることにした。

ページの中で performance.now() を読めば、入力から状態の更新までは測れる。ただしそれは「ページがそう判断した時刻」で、画面に出た時刻ではない。今回知りたかったのは後者で、画面に出たところを見るために、画面を撮ることにした。

撮り方:Chrome DevTools Protocol を直接たたく

ヘッドレスの Chrome を --remote-debugging-port 付きで起こし、Node から WebSocket で Chrome DevTools Protocol(CDP)を直接たたいた。Node 24 には WebSocket が組み込みで入っているので、依存は 1 つも足していない。

測るのに使った主な命令は、次の 4 つである。

命令 何に使ったか
Emulation.setDeviceMetricsOverride 1080 × 1920 のビューポートを作る
Page.startScreencast 描かれたコマを 1 枚ずつ JPEG で受け取る
Page.screencastFrameAck 受け取ったコマに返事をする
Input.dispatchKeyEvent 決めた時間割りどおりにキーを押して離す
const frames = [];
on('Page.screencastFrame', async (p) => {
  // timestamp は秒。ミリ秒にして控える
  frames.push({ t: p.metadata.timestamp * 1000, data: p.data });
  await send('Page.screencastFrameAck', { sessionId: p.sessionId });
});
await send('Page.startScreencast', {
  format: 'jpeg', quality: 85, maxWidth: 1080, maxHeight: 1920, everyNthFrame: 1,
});

コマごとに付いてくる metadata.timestamp は、プロトコルの定義では "Frame swap timestamp." と説明されている。描き始めた時刻でも、こちらが受け取った時刻でもなく、コマを入れ替えた時刻である。型は Network.TimeSinceEpoch で、単位は秒になる。キーを押した時刻は、Input.dispatchKeyEvent を送る直前に Node の Date.now() で控えた。どちらも同じ PC の時計なので、差を取ればそのまま遅れになる。ただしこの基準だと、Node から Chrome へ命令を渡す WebSocket の受け渡しの時間も、遅れの中に入る。

現行のプロトコルの定義では、ack を返さないまま送られるコマの数に上限があり(maxFramesInFlight、既定は 3)、ack が来たら最後に描いたコマをすぐ送る sendLastFrame という指定もある。測った Chrome 154 にこの 2 つが入っていたかは確かめていない。今回は、受け取るたびにすぐ ack を返している。

起動の引数はいまも --headless=new と書いているが、Chrome の公式ドキュメントによると、Chrome 132.0.6793.0 からは古いヘッドレスが chrome-headless-shell という別のバイナリに分かれた。公式の説明は素の --headless を渡す使い方だけを書いていて、=new を付ける必要はもう無いようだ。

ゲームの画面は Phaser の WebGL で描いている。押し方は、右を短く 5 回、右を長く 1 回、上を 1 回の計 7 回にした。

罠 1:画面全体の変化では、動き出しの時刻が決まらない

最初は、前のコマとの差を画面全体で取り、押したあとで差が跳ね上がった最初のコマを「動き出し」にした。しきい値は、押す前 400 ミリ秒の差の平均と標準偏差から決めた。

これとは別に、自キャラの頭の上にある名札を正規化相互相関で追い、2 ピクセル以上ずれた最初のコマを「動き出し」とする方法も用意した。同じコマの列に 2 つの方法を当てると、こうなる。

押した回 名札を追う 画面全体の差
1 48 ms 48 ms
2 66 ms 0 ms
3 70 ms 70 ms
4 62 ms 62 ms
5 66 ms 66 ms
6 145 ms 29 ms
7 見つからず 39 ms

2 回目の 0 ミリ秒は、押した直後のコマでもうしきい値を超えていたということである。その時刻にキャラはまだ動けない。29 ミリ秒と 39 ミリ秒も、押してから 40 ミリ秒もたっていない。キッチンには明滅するランタンと歩く客がいて、キーと関係なく動いているものを拾っていた。

しきい値の決め方を疑って、「平均 + 2σ」「平均 + 3σ」「平均の 2 倍」の 3 通りで出し直したが、7 回とも同じ値になった。問題はしきい値ではなく、見ている範囲に別の動きが入っていることだった。

厄介なのは、7 回のうち 4 回は名札と同じ値が出ることである。全部が外れていれば気づけるが、半分以上当たっているので、別の方法と並べないとどれが外れかが分からない。

罠 2:自分で付けた「押した印」は、キャラと同じコマで点く

縦動画には、いまどのキーを押しているかを見せる印を付けたかった。画面の左下に position: fixed の箱を置き、ページの keydown を capture フェーズで拾って、その場で赤くする作りにした。ゲームの処理を通らないので、押した瞬間に点くつもりだった。

印が赤くなった最初のコマも数えた。

押した回 印が点くまで 名札が動き出すまで
1 48 ms 48 ms
2 66 ms 66 ms
3 70 ms 70 ms
4 62 ms 62 ms
5 66 ms 66 ms
6 145 ms 145 ms
7 39 ms 見つからず

印は押してから 2〜5 コマ目に点いていて、中央値は 66 ミリ秒だった。コマの間隔の中央値(14.5 ミリ秒)の 4.5 倍にあたる。コマの間隔は 14.5 ミリ秒が多いが、平均すると約 21 ミリ秒で、ときどき大きく空く。6 回目の 145 ミリ秒も、押してから 5 コマ目以内だった。そして1〜6 回目は、名札が動き出したのと同じコマだった(時刻が完全に一致した)。

DOM の箱の色を変えても、画面に出るのは次に描かれるコマである。予測を入れたゲームも、キーを受けたら次のコマで自キャラを動かす。どちらも「次のコマ」で出るので、同じコマに乗る。この印を 0 点にすると、予測ありの遅れは 0 に見える。

ここから分かることがもう 1 つある。ゲームの処理を一切通らない箱でさえ、押してから画面に出るまで 39〜145 ミリ秒かかっている。入力の配送、次のコマの描画、入れ替え、撮影の受け渡しが全部この中に入っているはずで、このやり方で測れる下限がそこにある。内訳は測っていない。

動画の印は、描画に頼るのをやめた。押した時刻からコマの番号を計算して、撮ったあとで動画に焼き込んでいる。これで印の点灯は押したコマと一致した。

罠 3:ソフトウェアで描かせると、撮れるコマ数が 5〜13 分の 1 になる

GPU のない環境で同じことをする場合に備えて、WebGL をソフトウェア(SwiftShader)で描かせたときも撮った。引数は --use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader である。

回 GPU(既定) ソフトウェア
1 47.7 fps 3.8 fps
2 43.6 fps 4.0 fps
3 46.8 fps 9.6 fps
中央値 46.8 fps 4.0 fps

1 秒に 4 コマでは、1 コマが 250 ミリ秒になる。このあと出す予測あり・なしの差(約 110 ミリ秒)でさえ、まるごと 1 コマの中に消える。同じ入力の時間割りなのに、撮り終わるまでの時間も 13〜19 秒に延びた(GPU では約 11.5 秒)。

ただし、この数字を芯にはできない。同じ PC で前に測ったときは、GPU が 64 fps、ソフトウェアが 9.1 fps だった。中央値どうしでは約 12 分の 1 だが、同じ回の GPU とソフトウェアを比べると 5〜13 倍の幅で揺れ、前回の比は 7 倍だった。言えるのは「何倍も違う」までで、絶対値は日によって違う。fps を記録に残すなら、毎回その場で測って一緒に書くことにした。

それでも測れた数字

名札を追う方法で、予測の有無を同じ条件で比べたのが次の値である。同じ PC の中で、ヘッドレスの Chrome から localhost に繋いでいる。遅延を足した行は、サーバの送信と受信にそれぞれ 100 ミリ秒の待ちを入れる一時的な改造をして、往復 200 ミリ秒の遅延を作った。どれも各 6 回の中央値である。

通信の遅延 予測なし 予測あり
足していない 173 ms(142〜211) 63 ms(50〜81)
往復 200 ms を足した 401 ms(374〜431) 60 ms(40〜91)

予測ありの 63 ミリ秒は、罠 2 の印の中央値(66 ミリ秒)とほぼ重なっている(撮った日は違うが、罠 2 の同じコマ列でも、名札と印は同じコマだった)。つまり予測ありのキャラは、この道具で見える最も早いコマで、もう動いている。予測ありの本当の遅れがこれより小さいかどうかは、この道具では分からない。予測なしとの差(約 110 ミリ秒)のほうは、道具の下限よりはっきり大きいので言える。

遅延を足すと、予測なしは 173 ミリ秒から 401 ミリ秒になり、足した往復 200 ミリ秒がほぼそのまま乗った。予測ありは 60 ミリ秒で、足す前と変わらない。予測ありのキャラは、通信を待たずに道具の下限のあたりで動き続けている。

クライアント予測の記事では、予測なしの遅れを平均 170 ミリ秒、最悪 220 ミリ秒と計算していた。撮った画面では 142〜211 ミリ秒で、計算に近い。ただしこの値には道具の遅れも入っているはずなので、合っているとまでは言わない。

撮影のほかの罠

  • 起動直後に押したボタンが無反応になる。ゲームの入口のボタンは静的な HTML なので、スクリプトが読み込まれる前から DOM にある。押せるが、まだ何も起きない。body に入力方法のクラスが付くのを待ってから押すようにした
  • 縦にスクロールする箱の上へはみ出した部分は、Page.captureScreenshot の captureBeyondViewport を使っても撮れない。描かれていないものは撮れない。撮りたいときは、箱を普通の流れに置き直して下へずらした
  • ビューポートを作るのに display: contents で当時の CSS を重ねる方法は、スマホの CSS の記事の「測り方」に書いた。過去の見た目を、マークアップを触らずに再現できる

ほかのやり方について

  • Puppeteer や Playwright を使えばよいのでは。どちらも Chromium では CDP の上で動いている。Playwright の録画(recordVideo)は動画ファイルを残すもので、コマごとの時刻は付かない。v1.61 からは page.screencast.start() の onFrame がコマごとの時刻(ブラウザが表示した時刻)を受け取るようになっていて、同じことは Playwright だけでもできそうだ。その時刻が CDP の metadata.timestamp と同じものかは確かめていない。Chromium なら CDP のセッションを開く手もある。今回は依存を足さずに済んだので、CDP を直接たたいた
  • ページの中で測ればよいのでは。入力から状態の更新までなら、そのほうが正確である。ただし画面に出た時刻ではない。予測ありとなしの差は、画面に出るところで見たかった
  • Event Timing API や Performance パネルを使えばよいのでは。Event Timing API の duration は、キーの処理のあと次に描くまでをページの中で返す(8 ミリ秒単位)。ただし、それは印の箱が赤くなるコマと同じもので、キャラが動いたコマでも、画面に出た時刻でもない。罠 2 の 66 ミリ秒をページの中と外に分けるには使えそうだ。Chrome の Performance パネルやトレースには入力とフレームが時系列で並ぶので、内訳の手がかりになりそうだが、今回は使っていない。押した時刻の基準も、今回は送る直前の Date.now() にしたが、ページが受けた時刻(event.timeStamp)や、Input.dispatchKeyEvent に渡せる timestamp を使う手もある
  • 全体の差のしきい値を工夫すればよいのでは。3 通り変えても同じ値だった。今回は、見る範囲を追いたいもの(名札)に絞った
  • 印を requestAnimationFrame で出せばよいのでは。描画に頼る限り、今回の結果では「次のコマ」から逃げられなかった。今回は、押した時刻から計算することにした

確かめていないこと

  • すべてヘッドレスの Chrome の中で測った。スマホの実機では測っていない。実機の画面に出るまでの時間は、これとは別物である
  • 予測ありの 60 ミリ秒前後が、60 fps の 1 コマ(約 17 ミリ秒)まで下がらない理由は、分けて測っていない。入力の配送、描画、入れ替え、撮影の受け渡しのどれが大きいかは分からない
  • コマの間隔は一定ではない(中央値 14.5 ミリ秒、平均は約 21 ミリ秒)。それより細かい差は、この方法では言えない
  • fps の比較は各 3 回、遅れの比較は各 6〜7 回で、回数は少ない

まとめ

画面を撮って測るとき、測る道具にも遅れと分解能がある。今回は、いちばん速く映るはずの印でも中央値で 66 ミリ秒かかり、ソフトウェアで描かせれば 1 コマが 250 ミリ秒になった。そのどちらも、測りたいものの差と同じくらいか、それより大きい。

罠はどれも、道具が答えを返さないのではなく、もっともらしい数字を返す形だった。全体の差は半分以上当たるし、印はきれいに 0 を出す。だから先に道具そのものを測る。キーと無関係の印がいつ出るか、1 秒に何コマ撮れるかを同じ条件で測っておくと、それより小さい差は言えないという線が引ける。

LANパーティの記事


注記

  • 数値はすべて、同じ Windows の PC の上で、ヘッドレスの Chrome を CDP で操作して撮ったコマから測った。罠 1〜3 は Chrome 154 で、fps は各条件 3 回、遅れは 7 回の押下(名札は 6 回分)。予測あり・なしの比較(遅延を足した条件を含む)は別の日に各 6 回撮ったもので、そのときの Chrome の版は控えていない
  • 前に測った fps(64 と 9.1)は、同じ PC で別の日に撮ったときの記録である
  • 遅れの内訳(入力の配送・描画・入れ替え・撮影の受け渡し)は測っていない。スマホの実機でも測っていない

来源:zenn · 游戏开发社区 · zenn.dev