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

我在家里的电脑上搭了个服务器,正在做一个4人合作的烹饪游戏,用手机浏览器就能玩。整体的介绍写在入口文章里。
客户端预测的文章中,我通过计算估算了从按下按键到角色动起来之间的延迟。虽然写了计算过程,但没有测量在画面上实际是多少毫秒。于是,趁着做文章竖版视频的机会,我打算从输入到画面上显示出来,从录制的画面那一侧来测量。
如果在页面中读取 performance.now(),可以测量从输入到状态更新为止的时长。但那是"页面判断出的时刻",而非实际显示在屏幕上的时刻。这次我想了解的是后者,因此为了查看屏幕上的实际显示效果,我决定对屏幕进行截图。
截取方式:直接调用 Chrome DevTools Protocol
以 --remote-debugging-port 参数启动无头 Chrome,从 Node 端通过 WebSocket 直接对接 Chrome DevTools Protocol(CDP)。Node 24 本身已内置 WebSocket,因此没有添加任何依赖。
用于测量的主要命令为以下 4 个。
| 命令 | 用途是什么? |
|---|---|
Emulation.setDeviceMetricsOverride |
创建一个 1080 × 1920 的视口 |
Page.startScreencast |
逐帧以 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 是否包含这两项。本次每次收到指令后立即返回 ack。
启动参数目前仍写着 --headless=new,但根据 Chrome 官方文档,从 Chrome 132.0.6793.0 起,旧的无头模式已拆分为单独的 chrome-headless-shell 二进制文件。官方说明只写了传递纯 --headless 的用法,似乎已无需添加 =new。
游戏画面使用 Phaser 的 WebGL 渲染。按压方式为:右短按 5 次、右长按 1 次、上按 1 次,共 7 次。
陷阱 1:仅凭画面整体变化无法确定起始时刻
最初,我在整个画面上计算与上一帧的差异,将按压后差异首次跳升的帧定为"起始时刻"。阈值根据按压前 400 毫秒内差异的均值和标准差确定。
另外,我还设计了一种方法:用归一化互相关追踪角色头顶的名牌,将名牌首次偏移 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 倍」三种方法重新计算,但 7 次得到的值都相同。问题不在阈值,而在于观察范围内混入了其他的运动。
棘手的是,7 次中有 4 次给出与名牌相同的值。如果全部都不一致就能发现了,但超过半数都是吻合的,所以不与其他方法并列对比就无法判断哪个是错的。
陷阱 2:自己放置的「按压印记」会在与角色相同的格子上亮起
对于竖屏视频,我想加一个标识来显示当前正按着哪个键。我在屏幕左下角放了一个 position: fixed 方框,在捕获阶段捕获页面上的 keydown,然后就地将其变为红色。由于不经过游戏逻辑处理,本意是按键瞬间就会点亮。
我也数了那个标记变红的第一张卡。
| 按下的次数 | 直到指示灯亮起为止 | 直到铭牌开始移动 |
|---|---|---|
| 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 毫秒 | 未找到 |
印章在按下后的第 2 至 5 帧出现,中位数为 66 毫秒。这相当于帧间隔中位数(14.5 毫秒)的 4.5 倍。帧间隔以 14.5 毫秒居多,但平均约为 21 毫秒,偶尔会有较大的空隙。第 6 次的 145 毫秒也落在按下后第 5 帧以内。并且第 1 至 6 次与名牌开始移动的帧完全相同(时刻完全一致)。
即使改变 DOM 盒子的颜色,屏幕上呈现的也是下一帧要绘制的画面。加入预测机制的游戏,在接收到按键后同样会在下一帧移动己方角色。由于两者都出现在「下一帧」中,因此落在同一帧上。将此标记设为 0 时,有预测机制的延迟看起来就是 0。
由此还可以看出另一件事:即便完全不经过游戏处理的盒子,从按下到画面呈现也需要 39~145 毫秒。输入的传递、下一帧的绘制、切换、拍摄交接,应该全部包含在这其中,用这种方式能测到的下限就在那里。具体内Breakdown尚未测量。
视频的标记不再依赖渲染。根据按下时刻计算帧号,拍摄后再烧录进视频中。这样,标记的点亮便与按下的帧一致了。
陷阱 3:使用软件渲染时,可拍摄帧数降为 1/5 至 1/13
为应对没有 GPU 的环境下的相同情况,也拍摄了使用软件(SwiftShader)渲染 WebGL 时的结果。参数为 --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 |
每秒4帧,每帧即为250毫秒。后文提到的有预测与无预测之间的差异(约110毫秒),甚至完全被一整帧所吞没。输入的时间分配相同,拍摄完成所需的时间却延长到了13〜19秒(GPU上约为11.5秒)。
但,不能把这个数字当作基准。在同样的 PC 上之前测得 GPU 为 64 fps、软件渲染为 9.1 fps。中央值之比约为 1/12,但同一轮次中 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),或将timestamp传递给Input.dispatchKeyEvent。 - 整体差异的阈值稍加调整应该就可以了。换过 3 种方式,得到的值都一样。这次,把观察范围限定在想要追踪的对象(名牌)上。
- 用
requestAnimationFrame输出印不就好了吗。只要依赖绘制,在本次结果中就无法从"下一帧"中逃脱。这次决定从按下时刻开始计算。
尚未核实的事项
- 所有测量均在无头 Chrome 中完成。未在实际手机设备上进行测量。实际设备从启动到画面显示所需的时间与此是另一回事。
- 在有预测的情况下,耗时约 60 毫秒,无法降至 60 fps 单帧(约 17 毫秒)的原因是:未分项测量。输入传递、渲染、替换、捕获交接中哪一项占比最大并不清楚。
- 帧间隔并不恒定(中位数 14.5 毫秒,均值约 21 毫秒)。比这更细微的差异,用这种方法无法判断。
- fps 的对比各做了 3 次,延迟对比各做了 6〜7 次,次数较少。
总结
用拍摄画面来测量时,测量工具本身也存在延迟和分辨率限制。本次测试中,即便是理应最快显示的标记,中位数也要 66 毫秒;若用软件渲染,一帧则变为 250 毫秒。这两个数值都与想要测出的差异相当,甚至更大。
所有陷阱的共同点在于,并非工具不返回结果,而是返回了一个看似合理的数字。整体差异有超过一半是准确的,标记也能干净利落地输出 0。因此要先测量工具本身。在相同条件下测好与按键无关的标记何时出现、每秒能拍多少帧,就能划出一条分界线:比这个更小的差异无法下结论。
LAN 派对的文章
- 用 Phaser + WebSocket 做了一款四台手机合作烹饪游戏 —— 一度把「在线」收回到局域网,12 天后才恢复
- iPhone 上显示「连接已断开」 —— 自签名 HTTPS 与
ERR_SSL_HTTP_REQUEST - 装在家人手机上的自制 CA 也能伪造银行证书 —— 用 Name Constraints 堵住这个口子
- 把 WebSocket 的接收速率限制放在 JSON 解析之前 —— 接收速率限制与大厅闲置房间的自动销毁
- 只让 Codex 跑了 75 轮外观改进 —— 停止手动中转后,导入间隔从 50 分钟缩短到 4 分钟
- 在 WebSocket 协作游戏中,不区分「延迟」和「偏移」,预测就会崩 —— 保持服务器权威的前提下,用客户端侧预测来减轻操作
- 生成的精灵表图片无法等分裁切 —— 根据空白区域的大小定位帧的位置并裁切出来
- 手机上无法触达姓名输入框 —— 居中对齐和滚动放在了同一个元素上
- 这篇文章 —— 截图测量操作延迟时,测量工具本身反而更慢
注記
- 所有数值均是在同一台 Windows PC 上,通过 CDP 操作无头 Chrome 截取的帧中测得的。陷阱 1〜3 使用 Chrome 154,fps 每个条件测 3 次,延迟测 7 次按键(名牌为 6 次)。有预测与无预测的对比(含加了延迟的条件)是另一天各测 6 次的,未记录当时的 Chrome 版本
- 之前测得的 fps(64 和 9.1)是同一台 PC 在另一天截取的记录
- 未测量延迟的细分(输入分发、渲染、帧切换、截图交接),也未在智能手机实机上测量
来源:zenn · 游戏开发社区 · zenn.dev