截取画面来测量操作延迟时,发现测量工具本身反而更慢。改用无头浏览器按帧为单位进行测量
画面を撮って操作の遅れを測ったら、測る道具のほうが遅かった。ヘッドレスのブラウザでフレーム単位に測る

家の 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パーティの記事
- Phaser + WebSocket でスマホ4台の協力クッキングゲームを作った —— 「オンラインで」を一度 LAN に閉じ、12日後に戻すまで
-
iPhone で「接続が切れました」 —— 自己署名 HTTPS と
ERR_SSL_HTTP_REQUEST - 家族のスマホに入れる自作 CA は、銀行の偽証明書も作れる —— Name Constraints で口を塞ぐ
- WebSocket の受信レート制限を JSON パースの前に置いた —— 受信レート制限とロビー放置部屋の自動破棄
- 見た目の改善だけを Codex に75段階まわした —— 手での中継をやめたら、取り込み間隔が50分から4分になった
- WebSocket の協力ゲームで「遅延」と「ズレ」を区別しないと予測はガタつく —— サーバ権威のまま、クライアント側予測で操作を軽くする
- 生成画像のスプライトシートは等分割で切れない —— 空白の量からコマの位置を探して切り出す
- スマホで名前入力欄に届かなかった —— 中央寄せとスクロールを同じ要素に置いていた
- この記事 —— 画面を撮って操作の遅れを測ったら、測る道具のほうが遅かった
注記
- 数値はすべて、同じ Windows の PC の上で、ヘッドレスの Chrome を CDP で操作して撮ったコマから測った。罠 1〜3 は Chrome 154 で、fps は各条件 3 回、遅れは 7 回の押下(名札は 6 回分)。予測あり・なしの比較(遅延を足した条件を含む)は別の日に各 6 回撮ったもので、そのときの Chrome の版は控えていない
- 前に測った fps(64 と 9.1)は、同じ PC で別の日に撮ったときの記録である
- 遅れの内訳(入力の配送・描画・入れ替え・撮影の受け渡し)は測っていない。スマホの実機でも測っていない
来源:zenn · 游戏开发社区 · zenn.dev