事故:60fps 开关把低端机推进了抖动
《潮汐消消》的家园装饰活动上线后,案例设定中的部分低端机在开启 60fps 高档时,帧时间在 20ms 到 40ms 之间来回跳。运营看板上的平均帧率仍有 55fps,看起来「基本达标」;玩家的体感却是每隔一两秒卡一下,画面还时不时变糊。
第一版规则很直接:帧时间超过 16.7ms 就降分辨率,下一帧恢复了就升回去。结果是分辨率每几帧跳一次,文字和装饰边缘反复变软,卡顿却没有消失。复盘发现三件事同时错了:没有把 60fps 当成「预算减半」,切档没有时间窗口与回滞,降分辨率也没有区分 CPU 瓶颈还是 GPU 瓶颈。
本节的主张是:帧率是预算档位,不是开关;切档要窗口、回滞和冷却;动态分辨率只解决 GPU 侧的像素成本,不能拿来掩盖主线程的长任务。
先算帧预算,再谈档位
帧预算是一帧内所有阶段可用的总时间。案例设定中,30fps 对应 33.3ms,60fps 对应 16.7ms;这不是两个独立的性能目标,而是同一份工作要在更短窗口内完成。
| 目标帧率 | 每帧预算 | 典型可用时间 | 真正的含义 |
|---|---|---|---|
| 30fps | 33.3ms | 输入、逻辑、渲染、提交合计不超过 33.3ms | 休闲玩法的基础档 |
| 60fps | 16.7ms | 同样的阶段合计不超过 16.7ms | 预算是 30fps 的一半,不是「跑两倍快」 |
代码里用微秒整数表达预算:30fps 为 33_333,60fps 为 16_667。16.7ms 写成浮点数后反复比较,会在边界上产生难以解释的抖动;整数微秒让「超没超」只有一个确定答案。
预算必须先于档位存在。没有预算表,团队只能凭体感判断「这台机器能不能开 60fps」;有了预算表,才能把「降分辨率」「降帧率」「切片」放进同一套决策。
双案例档位表
两个产品的默认档不同,降级空间也不同。下表是案例设定,不是机型名单。
| 产品 | 默认档 | 低档 | 高帧档 | 案例设定帧预算 |
|---|---|---|---|---|
| 消消 | 30fps | 30fps + 低画质 | 60fps | 33.3ms / 16.7ms |
| 方舟 | 60fps | 30fps | 60fps 满帧 | 16.7ms / 8.3ms(更高帧档口径) |
消消的低端机占 35%,策略第一目标是守住 30fps 下限;60fps 只对通过分档的设备开放,且开放后仍要能被帧预算控制器降回来。方舟默认 60fps、低档 30fps,帧预算更紧;它的 8.3ms 属于更高帧档口径,不参与本节 30/60 两档模型,不能把 8.3ms 当作 60fps 的常规预算。
档位是产品决策,设备评分只决定初始档位。初始档位来自 8.2 的分档结果,运行期是否守得住,要看本节控制器与 9.2 的阶段归因。
切档要回滞,不是每帧改一次
如果每一帧都比较一次阈值,帧时间在边界附近抖动时,档位就会反复切换。控制器用三层约束把「一次抖动」和「持续趋势」分开。
| 参数 | 案例设定 | 作用 | 调错的后果 |
|---|---|---|---|
windowFrames | 90 帧 | 用窗口内 p95 判断趋势 | 窗口太短,偶发长帧触发降档 |
minDwellFrames | 60 帧 | 切档后至少停留一段时间 | 切完立刻切回,画面闪烁 |
downgradeRatio | 1.05 | p95 超过预算 5% 才降 | 没有比例带,边界抖动就降档 |
upgradeRatio | 0.75 | p95 低于预算 75% 才升 | 升档后立刻超预算,来回振荡 |
cooldownFrames | 30 帧 | 降档后冷却,期间不升 | 冷却太短,降完就弹回 |
relapseFrames | 180 帧 | 帧率降档后更长的观察期 | 帧率反复在 30/60 之间跳 |
判断用 p95,不用平均值。平均 55fps 可能由大量 16ms 帧和少量 40ms 帧拼出来,玩家记住的是后者。p95 把最差的 5% 拉进决策,才接近体感。
回滞有两层:幅度上是 1.05 与 0.75 之间的宽带,时间上是窗口、停留、冷却与复发观察期。以 60fps 为例,预算 16.7ms,降档线约为 17.5ms,升档线约为 12.5ms;帧时间在 15ms 与 17ms 之间抖动时,p95 约 17ms,落在带内,控制器保持不动。
升级还要检查「下一档装不装得下」。从 30fps 升到 60fps 时,不能只看当前 33.3ms 预算还有余量,而要确认 p95 不超过 60fps 预算的 75%,也就是约 12.5ms。否则升上去第一秒就会超预算,再被降回来。
一次切档的完整记录
只看最终档位无法解释「为什么切」。控制器每次动作都带原因,日志至少保留窗口 p95、预算、瓶颈占比、动作与前后档位。
| 时间点 | 窗口 p95 | 当前预算 | 瓶颈占比 | 动作 | 记录原因 |
|---|---|---|---|---|---|
| 第 60 帧 | 14200us | 16667us | GPU 0.62 | hold | 窗口未满,继续观察 |
| 第 90 帧 | 20000us | 16667us | GPU 0.80 | lower-scale | p95 超降档线,1.00→0.85 |
| 第 120 帧 | 11800us | 16667us | GPU 0.79 | hold | 冷却中,不升档 |
| 第 210 帧 | 8000us | 16667us | GPU 0.58 | raise-scale | 余量足够,0.85→1.00 |
| 第 300 帧 | 9000us | 16667us | GPU 0.57 | hold | 已在最高分辨率 |
这张表能回答三个常见争议:切档是不是因为一次偶发长帧、降档后有没有立刻弹回、升档是不是因为余量真的够。没有原因字段,线上只能看到「画质变了」,无法判断是策略正确还是抖动。
记录粒度也要控制。逐帧上传会淹没日志,通常只上传动作帧、窗口边界与最低档报警;本地可以保留最近若干个窗口,出问题时再导出。
参数怎么定
参数不能从别的项目复制,要从本项目的帧时间分布与玩法节奏推导。下表是推导顺序,不是推荐值。
| 参数 | 推导方法 | 消消的案例设定 | 方舟的差异 |
|---|---|---|---|
windowFrames | 覆盖 1–3 秒的采样,且包含至少两个完整交互周期 | 90 帧 | 60fps 下窗口应更长,避免半秒内的战斗波动触发切档 |
minDwellFrames | 不短于一次界面过渡或一局开场 | 60 帧 | 战斗开场更长,停留也应更长 |
downgradeRatio | 大于正常抖动幅度,小于持续过载幅度 | 1.05 | 帧预算更紧,比例带不能更窄 |
upgradeRatio | 留出切档后新瓶颈出现的余量 | 0.75 | 默认 60fps,升级应更保守 |
cooldownFrames | 覆盖一次分辨率切换的资源重建 | 30 帧 | 资源更重,冷却更长 |
relapseFrames | 覆盖一次典型玩法循环 | 180 帧 | 战斗循环更长,复发观察期更长 |
参数定完后要做两类回归:一类是合成帧序列,验证边界抖动、持续过载、恢复期与最低档;另一类是真实设备上的长时运行,验证参数没有把设备推入「降档—升档」循环。合成序列能证明逻辑正确,不能证明体感正确。
帧策略的验收口径
帧策略的验收不能只看「平均帧率有没有到 30/60」。至少要同时看下面几项,否则很容易把抖动当成达标。
| 指标 | 口径 | 不达标时的第一动作 |
|---|---|---|
| 帧时间 p95 | 按窗口统计,不用全剧平均 | 检查瓶颈归因,再决定降分辨率还是降帧率 |
| 长帧比例 | 超过预算 2 倍的帧占比 | 进入 9.2 的阶段切片,找主线程长任务 |
| 每分钟切档次数 | 分辨率与帧率的动作总数 | 放宽比例带或延长窗口,先止血再调参 |
| 最低档报警率 | flag 会话占比 | 排查是否 CPU 长期超预算,不能继续降画质 |
| 分辨率下限命中率 | 渲染比例停在 0.65 的时长占比 | 评估是否应直接降帧率或减少特效 |
这些指标要按设备档位拆分。低端机的 p95 与高档机混在一起,平均值会把问题稀释;分档之后,才能判断是策略参数不合适,还是某一档设备的预算根本不够。
与固定画质档的关系
固定画质档解决「这台设备一开始用什么」,帧策略解决「运行中守不守得住」。两者不能互相替代:只有固定档,遇到发热或场景峰值就会掉帧;只有动态策略,没有初始档,低端机会先被推上高档再被降回来,开局体验最差。
正确的关系是:分档给出初始档位与画质上限,帧策略只能在上限内下调,不能把低端机升到未授权的画质。升级也必须受上限约束,否则一次误判就会把设备推到它从未通过验证的档位。
降级顺序:先分辨率还是先帧率
降级顺序由瓶颈决定,不由画面偏好决定。控制器用 cpuUs 与 gpuUs 估算 GPU 占比:占比达到 60% 判为 GPU 瓶颈,低于 40% 判为 CPU 瓶颈,中间判为未知。
| 瓶颈 | 第一动作 | 第二动作 | 不要做 |
|---|---|---|---|
| GPU | 降低渲染分辨率 | 分辨率到下限后降帧率 | 先降帧率,白白牺牲手感 |
| CPU | 直接降帧率 | 仍超预算则报警 | 降分辨率,像素少了逻辑不会变快 |
| 未知 | 先降一档分辨率 | 下一个窗口仍超预算则降帧率 | 连续降分辨率直到画面糊 |
这条顺序背后的判断是:分辨率主要影响像素填充与带宽,帧率影响输入到画面的反馈频率。GPU 吃不消时,先减像素通常比先掉帧更划算;CPU 吃不消时,像素再少也没用。
升级顺序与降级相反:先把帧率升回目标档,再逐级恢复分辨率。玩家先感受到操作跟手,再看到画面变清晰;反过来先升分辨率,可能在帧率还没恢复时就再次超预算。
动态分辨率不是万能药。它通常只作用于场景渲染目标,不作用于 UI 与文字;三消这类 2D 游戏还可能被 overdraw 与图集带宽限制,降低分辨率带来的收益远小于 3D 场景。更常见的问题是:团队把「降分辨率」当成通用止血贴,遇到主线程长任务也降,结果画面糊了,帧时间一点没变。
模型给分辨率设了下限 0.65,到下限仍超预算就降帧率;帧率也到下限时返回 flag,由上层记录「最低档仍超预算」并上报。没有这个报警,控制器只会在最低档安静地循环,问题被掩盖。
分辨率阶梯也不能随便加档。每多一档,就多一组资源重建路径与回归用例;阶梯过密时,控制器会在相邻档位之间反复微调,玩家看到的不是「自适应」,而是画面持续发虚。
| 渲染比例 | 主要收益 | 风险 | 使用条件 |
|---|---|---|---|
| 1.00 | 原生清晰度 | 无 | 预算充足或设备通过高档验证 |
| 0.85 | 少量像素与带宽 | 文字边缘略软 | GPU 占比高,首轮降级 |
| 0.75 | 明显降低填充成本 | 装饰细节变糊 | 仍超预算,且瓶颈在 GPU |
| 0.65 | 最低可用画质 | 观感明显下降 | 仅作最后一级,必须带报警 |
阶梯之外还要处理「假问题」。下面三类现象经常被误判为帧率策略失效,实际上不是同一层的问题:
- UI 线程被业务阻塞:输入后 200ms 才响应,但帧时间看起来正常;这是主线程长任务,应进入 9.2 的切片,不是降分辨率。
- 资源加载尖峰:切场景或打开家园时一次性解码大量贴图,持续几秒的高帧时间;应做预加载与分帧解码,靠降档只会把加载期拉长。
- 温控降频:前几分钟正常,之后整体变慢且设备发热;这是 9.3 的温控问题,帧策略只能被动跟随,不能当作根因修复。
把这三类问题排除后,剩下的才是「预算内守不住」的帧策略问题。否则控制器会替别的模块背锅,参数越调越乱。
可运行模型:frame-policy-demo.ts
完整控制器在 src/runtime/frame-budget.ts,包含帧预算、p95 窗口、瓶颈分类、降级/升级顺序与最低档报警。下面的 demo 用同一份实现跑两组输入,第一组是 GPU 瓶颈,第二组是恢复期。
// file: fixtures/frame-policy-demo.ts
// tier: L1
// verify: node fixtures/frame-policy-demo.ts
import { FrameBudgetController } from "../src/runtime/frame-budget.ts";
const controller = new FrameBudgetController(undefined, { fps: 60, scale: 1 });
function feed(label: string, frames: number, frameUs: number, cpuUs: number, gpuUs: number): void {
const actions: Record<string, number> = {};
let last = controller.observe({ frameUs, cpuUs, gpuUs });
actions[last.action] = (actions[last.action] ?? 0) + 1;
for (let index = 1; index < frames; index += 1) {
last = controller.observe({ frameUs, cpuUs, gpuUs });
actions[last.action] = (actions[last.action] ?? 0) + 1;
}
console.log(JSON.stringify({
label,
actions,
fps: last.targetFps,
scale: last.renderScale,
bottleneck: last.bottleneck,
p95Us: last.p95Us,
budgetUs: last.budgetUs,
}));
}
feed("gpu-bound", 90, 20_000, 4_000, 16_000);
feed("recovery", 240, 8_000, 3_000, 5_000);
运行 node fixtures/frame-policy-demo.ts,输出可以读成两张决策记录:
| 场景 | 动作计数 | 结束档位 | p95 / 预算 | 解释 |
|---|---|---|---|---|
gpu-bound | 89 次 hold,1 次 lower-scale | 60fps / 0.85 | 20000us / 16667us | GPU 占比 0.8,先降分辨率 |
recovery | 239 次 hold,1 次 raise-scale | 60fps / 1.0 | 8000us / 16667us | 冷却结束后按余量升回分辨率 |
第一组前 90 帧在填窗口,不会立刻动作;第 90 帧 p95 超过降档线,分辨率从 1.0 降到 0.85。第二组先经历 30 帧冷却,窗口再次填满后 p95 只有 8ms,分辨率升回 1.0。整个过程没有出现「降一档、下一帧升回来」的抖动。
配套测试 test/runtime/frame-budget.test.ts 有 15 条路径,覆盖窗口未满、GPU/CPU 分流、边界抖动、复发观察期、最低档报警、四阶段归因、长帧阶段归属与多帧平均。运行命令是 node --test test/runtime/frame-budget.test.ts。
模型不读取真实 GPU 计数器,也不推断厂商调度策略;cpuUs 与 gpuUs 是调用方注入的估算值。这里是模型,不是真机测量结果。
反例:全量锁 30fps 不是省事
面对抖动,最省事的方案是全量锁 30fps。它确实能让低端机稳定,但代价是高端机的 60fps 卖点消失,输入反馈与动画顺滑度一起下降;而且锁 30fps 并不能解决 CPU 长任务,只是把预算从 16.7ms 放宽到 33.3ms,长任务该超还是超。
另一个极端是全量动态 60fps,低端机一上来就进高档,再靠控制器降回来。开局最差的几秒恰好是玩家第一印象,这种「先超预算再降档」的路径不可接受。
合理路径是:分档给出初始档位,帧策略在上限内微调,低端机从 30fps 开始,高档机才从 60fps 开始;任何降级都要有记录,任何升级都要重新验证预算。
双案例:方舟为什么不能照搬
方舟默认 60fps、低档 30fps,帧预算只有 16.7ms,战斗场景逻辑更重。照搬消消「先降分辨率」会在 CPU 瓶颈上白降,必须先看阶段归因再决定降哪一项。
消消低端机占 35%,策略优先守 30fps 下限;方舟低端机占 15%,但默认 60fps 更不能抖,切档窗口应更长、升级更保守。两边的参数不能互相复制。
与相邻章节的关系
8.2 把设备差异归一化为档位与预设,本节决定档位在运行期如何升降;9.2 把一帧拆成输入、逻辑、渲染、提交,为「降分辨率还是降帧率」提供归因证据;9.3 讨论长时间运行后的温控降频与验收。低端机整体降级取舍可以参考低端机降级 ,卡顿与崩溃的观测口径可以参考崩溃与卡顿 ,UI 适配与渲染比例的关系可以参考UI 适配 。
小结
- 帧率是预算档位:30fps 为 33.3ms,60fps 为 16.7ms;60fps 的预算是前者的一半。
- 切档用窗口 p95 与双层回滞,不用每帧阈值;升级前要确认下一档预算也装得下。
- 降级顺序由瓶颈决定:GPU 先降分辨率,CPU 直接降帧率,未知瓶颈先降一档再观察。
- 动态分辨率只作用于像素成本,不能救主线程长任务;必须有下限和最低档报警。
- 模型只验证决策逻辑,真实 GPU 行为与持续性能仍要回到真机矩阵。
阅读导航:上一节:8.3 平板、折叠屏与异形屏 · 下一节:9.2 每帧预算与主线程任务切片 。
常见错误与边界
- 用平均帧率判断流畅度。平均 55fps 可以掩盖周期性 40ms 长帧;决策至少看 p95,必要时同时看长帧比例。
- 每帧比较阈值直接切档。没有窗口、比例带与冷却时,边界抖动会让分辨率或帧率反复跳,玩家看到的是闪烁而不是稳定。
- CPU 瓶颈也降分辨率。像素减少不会让逻辑变快;先确认瓶颈,再决定降哪一项,否则只会牺牲画质。
- 升级只看当前预算。从 30fps 升 60fps 必须用下一档预算重新校验,否则升上去立刻超预算,形成振荡。
- 没有最低档报警。分辨率与帧率都到下限后仍超预算,控制器必须返回
flag;否则问题会静默留在最低档。 - 把动态分辨率当成万能降级。逻辑瓶颈下降分辨率只会让画面变糊,帧时间不会下降;必须先看瓶颈占比。
- 切档后不重置观察窗口。切完立刻用旧样本判断,会把切档前的长帧带进新档;每次动作后都要重新填窗口并进入冷却。
- 把窗口 p95 当成单帧上限。p95 描述的是趋势,不是每帧都不能超过;偶发单帧尖峰应由长帧比例单独观测。
- 不同场景共用一套参数。家园与关卡的负载形态不同,窗口与冷却应按场景标定,不能一份参数跑全站。
- 边界:本节模型不测量真实 GPU 性能与发热降频。
cpuUs、gpuUs是输入估算,真机持续性能与温控验收见 9.3,不能把模型输出写成真机测量结果。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。