60fps 为什么还是一顿一顿?帧时间曲线实测给你看

3 阅读8分钟

同样一段动画,平均 59.9fps,看着还是一顿一顿。

问题不在平均值。10 秒里 P95 帧间隔 16.8ms,最大一帧 30.2ms,那一下就是肉眼看到的顿。

我把每秒 100ms 的卡顿打开,平均帧率还有 54.5fps,最大帧间隔 116.7ms,卡顿指数 6.99。

图 1:开启每秒 100ms 卡顿后,帧时间曲线出现 6 根尖峰,最高 116.7ms,平均帧率仍有 54.5fps

在线案例与源码:gitcode.com/OPCTeam/dem… 成品只有一个 index.html,零依赖、无外链,断网可用。下面的数字都是我在页面上跑出来的。

一、为什么值得看:帧率是平均数,卡顿是最大值

60fps 的意思是每秒 60 帧,换算成帧间隔是 16.7ms。

只要有一帧拖到 30ms,这一秒的平均帧率还是接近 60,眼睛却已经看到停顿。

播放器、游戏、滚动页面都一样,平均值好看不代表顺滑。

真正决定手感的是每一帧的间隔,尤其是最慢的那几帧。

目标帧率理论帧间隔一帧的预算
30fps33.3ms33.3ms
60fps16.7ms16.7ms
120fps8.3ms8.3ms

这张表里最容易被忽略的是 120fps。

屏幕只有 60Hz 时,浏览器每秒最多给你 60 次绘制机会,8.3ms 的预算根本用不上。

后面 120fps 档实测只有 60.0fps,不是代码慢,是屏幕到顶了。

二、准备环境:进入码道 Web

浏览器打开码道 Web 版:devcloud.cn-north-4.huaweicloud.com/chat?source…

登录后直接在对话框里写需求。

码道有三种使用方式:WebUI 浏览器对话、TUI 终端命令行和桌面 IDE 插件。本文用 WebUI 版演示。

这一轮我把需求写成一条,重点只有四个:单文件、无外链、真实测帧、失败路径。

做一个单文件网页应用「帧时间实验室」,纯 HTML + CSS + JavaScript,不依赖任何外部库,不发起任何网络请求,打开 index.html 就能用。

页面要回答一个问题:为什么平均 60fps 的动画还是一顿一顿?全部用真实测量,不做示意图。

功能要求:
1. 顶部并排对照区:左轨道按 30fps 刷新,右轨道按 60fps 刷新,两个方块做同样的水平往返运动,各自带运动拖影,方便肉眼比较顺滑度。
2. 控制条:目标帧率(30 / 60 / 120 / 自定义 5~240)、动画时长(3~30 秒)、动画类型(方块往返 / 滚动文字 / 旋转指针)、开始、停止、重置;再加一个「每秒注入 100ms 卡顿」开关,用来制造平均帧率不低但明显卡顿的场景。
3. 实时帧时间曲线:横轴时间,纵轴帧间隔 ms,画 16.7ms 与 33.3ms 两条参考线,超过目标帧间隔 1.5 倍的帧标红。
4. 统计面板:平均 FPS、平均帧间隔、P95 帧间隔、最大帧间隔、掉帧数、卡顿指数(最大帧间隔 / 中位帧间隔),每次运行结束给出结果。
5. 一张对照表:30 / 60 / 120fps 的理论帧间隔,以及本次实测的平均帧间隔、P95 和掉帧数,方便直接对比。
6. 页面底部放自检面板,加载后自动跑至少 12 条断言并把 PASS / FAIL 显示出来。断言至少覆盖:帧间隔与目标帧率的换算、P95 计算、掉帧判定(大于 1.5 倍目标)、卡顿指数计算、帧率输入边界 5~240、时长边界 3~30、对照表三档数值。
7. 失败路径:帧率输入 0、5 以下、240 以上,或时长小于 3 秒时,输入框变红并显示中文提示「帧率需在 5~240 之间,已保留上一次有效值」或「时长需在 3~30 秒之间,已保留上一次有效值」,同时继续使用上一次有效值,不能崩溃。
8. 页面自检和运行结果都要在界面上看得见,不要只输出到控制台。

完成后打开预览,让我直接看到运行效果。

生成用了 GLM-5.2-ArkTS-SPARK,约 17 分钟,产出 1220 行、34KB 的 index.html。

页面加载后自己跑 20 条断言,全部 PASS 我才开始截图。

图 2:打开页面后的初始界面,左边 30fps、右边 60fps 两条对照轨道,底部是控制条和实时曲线

三、怎么做:两条轨道、一条曲线、六个数字

页面分三块:并排对照、主实验区、统计面板。

并排对照区是两条独立的动画轨道。

左轨道每 33.3ms 更新一次,右轨道每 16.7ms 更新一次,方块做同样的往返运动。

两条轨道都带拖影,慢的那条会留下更长的残影。

主实验区的循环只做一件事:记录两次绘制之间的间隔。

if (elapsed >= this.targetInterval - 2) {
  this.frameIntervals.push(elapsed);
  this._update(elapsed);
  this._render();
}

-2 是 2ms 容差,后面会讲它为什么必须加。

统计面板给六个数字:平均 FPS、平均帧间隔、P95 帧间隔、最大帧间隔、掉帧数、卡顿指数。

掉帧的判定是帧间隔大于目标间隔的 1.5 倍。

卡顿指数是最大帧间隔除以中位帧间隔,1 表示很稳,超过 3 就该查了。

实时曲线把每一帧画成一条竖线,16.7ms 和 33.3ms 两条参考线固定在背景里。

超过 1.5 倍目标的帧标红,一眼就能看到尖峰。

图 3:60fps 档运行到第 5 秒,主实验区的方块正在移动,曲线已记录 303 帧

四、实测:30、60、120 三档,外加一次卡顿注入

我先跑 30fps 和 60fps,再跑 120fps,最后打开卡顿注入再跑一次 60fps。

每次 10 秒,窗口 1600×1000,同一台机器、同一个浏览器。

目标理论帧间隔实测平均实测 P95最大帧间隔掉帧数
30fps33.3ms33.4ms33.4ms47.3ms0
60fps16.7ms16.7ms16.8ms30.2ms1
120fps8.3ms16.7ms16.7ms16.8ms600
60fps 加卡顿16.7ms18.4ms16.8ms116.7ms11

30fps 档跑出 30.0fps,平均间隔 33.4ms,10 秒里没有一帧超过 50ms。

60fps 档跑出 59.9fps,平均间隔 16.7ms,但最大一帧 30.2ms,掉了 1 帧。

这一帧就是开头说的那下顿。

120fps 档跑出 60.0fps,掉帧数 600。

600 不是 600 次事故,是 120fps 的 1.5 倍阈值只有 12.45ms,60Hz 屏幕每帧 16.7ms 都超过它。

卡顿注入那一组最有意思。

平均帧率 54.5fps,看着只掉了 5 帧。

最大帧间隔 116.7ms,卡顿指数 6.99,10 秒里出现 11 次明显停顿。

只看平均 FPS,这组和正常 60fps 差不多;只看曲线,问题一目了然。

图 4:统计面板与对照表,平均 59.9fps、P95 16.8ms、最大 30.2ms、掉帧 1、卡顿指数 1.81

五、验证与踩坑:三个真实问题

第一个问题出在测量本身。

第一版 60fps 档跑出 39.8fps,平均间隔 25.1ms,10 秒掉了 202 帧。

我盯着代码看了一遍,问题在循环的入口判断。

if (elapsed >= this.targetInterval) {

60fps 的目标间隔是 16.667ms,浏览器实际给的是 16.6ms 左右。

16.6 小于 16.667,这一帧被跳过,下一帧记成 33.3ms。

一帧跳过一帧,平均值正好落在 25ms 附近。

把判断改成 elapsed >= this.targetInterval - 2,留 2ms 容差,60fps 档回到 59.9fps。

这不是界面问题,是测量口径错了。

第二个问题在自检里。

有一条断言写的是 16.7ms 目标下,25ms 应该算掉帧。

可 16.7 × 1.5 = 25.05,严格大于才算掉帧,25 差 0.05ms。

页面显示 FAIL,我把用例改成 26ms,20 条断言全过。

这条 FAIL 说明自检确实在跑,不是摆设。

第三个问题是失败路径。

第一版输入 500 后,代码立刻把输入框改回上一次有效值,再触发 blur,红框和提示马上被清掉。

用户几乎看不到错误。

我改成保留非法输入,只把运行逻辑回退到上一次有效值。

现在输入 500,输入框变红,提示停在下面,点开始仍然按上一次有效的 60fps 跑。

图 5:自定义帧率输入 500,输入框变红,提示「帧率需在 5~240 之间,已保留上一次有效值」

六、这个工具验不了什么

  • 它测的是 requestAnimationFrame 的回调间隔,不是 GPU 合成和屏幕扫描。
  • 它分不清浏览器是没画,还是画了但没显示。
  • 120fps 档需要 120Hz 屏幕,60Hz 屏只能测到 60fps。
  • 窗口被遮挡或标签页切到后台时,浏览器会降频,数字会变。
  • 卡顿注入是把上一帧时间往回拨 100ms,制造一段 116.7ms 的帧间隔,不是真的忙等 100ms。
  • 它验证的是统计口径,不是浏览器卡顿的物理成因。
  • 单机、单浏览器、单次 10 秒,不能拿来当性能基准。
  • 它不测输入延迟,也不测显示器响应时间。

七、直接下载试玩

Demo 的源码、本文档和 5 张截图都在公开仓库里。

本地跑三步。

git clone https://gitcode.com/OPCTeam/demo_park.git
cd demo_park\codearts-frame-time-lab
python -m http.server 8080

浏览器打开 http://localhost:8080/index.html。

先点「开始」看 60fps 曲线,再打开「每秒注入 100ms 卡顿」看尖峰,最后把自定义帧率改成 500 看红框。

八、使用码道体会

  • 把验收条件写进需求,比写完再补测试省事。
  • 自检要覆盖主逻辑的边界,不然测试全绿也可能测错。
  • 真实数据一跑出来就能发现口径问题,39.8fps 这种数藏不住。
  • 单文件、无外链的约束,让复现成本降到一次下载。
  • 边界要写进页面,比如 60Hz 屏跑不了 120fps。
  • 真实 bug 比设计取舍更值得写,读者能照着复现。

九、总结

60fps 是一个平均数,顺滑是每一帧的事。

把帧间隔画出来,平均帧率骗不了人。

这个页面只有 34KB,打开就能跑,你也可以拿它测自己的动画和滚动。