《移动游戏产品的架构·客户端与移动运行时》9.1 帧率分级与动态分辨率

帧率档位不是把 30 改成 60 就结束:60fps 的帧预算只有 16.7ms,切档必须带回滞,动态分辨率也只能救 GPU 瓶颈。本节以《潮汐消消》为主案例,给出 30/60fps 双档策略、先降分辨率再降帧率的降级顺序、升级判定、抖动抑制与最低档报警口径,并说明方舟为什么不能照搬。

事故:60fps 开关把低端机推进了抖动

《潮汐消消》的家园装饰活动上线后,案例设定中的部分低端机在开启 60fps 高档时,帧时间在 20ms 到 40ms 之间来回跳。运营看板上的平均帧率仍有 55fps,看起来「基本达标」;玩家的体感却是每隔一两秒卡一下,画面还时不时变糊。

第一版规则很直接:帧时间超过 16.7ms 就降分辨率,下一帧恢复了就升回去。结果是分辨率每几帧跳一次,文字和装饰边缘反复变软,卡顿却没有消失。复盘发现三件事同时错了:没有把 60fps 当成「预算减半」,切档没有时间窗口与回滞,降分辨率也没有区分 CPU 瓶颈还是 GPU 瓶颈。

本节的主张是:帧率是预算档位,不是开关;切档要窗口、回滞和冷却;动态分辨率只解决 GPU 侧的像素成本,不能拿来掩盖主线程的长任务。

先算帧预算,再谈档位

帧预算是一帧内所有阶段可用的总时间。案例设定中,30fps 对应 33.3ms,60fps 对应 16.7ms;这不是两个独立的性能目标,而是同一份工作要在更短窗口内完成。

目标帧率每帧预算典型可用时间真正的含义
30fps33.3ms输入、逻辑、渲染、提交合计不超过 33.3ms休闲玩法的基础档
60fps16.7ms同样的阶段合计不超过 16.7ms预算是 30fps 的一半,不是「跑两倍快」

代码里用微秒整数表达预算:30fps 为 33_333,60fps 为 16_667。16.7ms 写成浮点数后反复比较,会在边界上产生难以解释的抖动;整数微秒让「超没超」只有一个确定答案。

预算必须先于档位存在。没有预算表,团队只能凭体感判断「这台机器能不能开 60fps」;有了预算表,才能把「降分辨率」「降帧率」「切片」放进同一套决策。

双案例档位表

两个产品的默认档不同,降级空间也不同。下表是案例设定,不是机型名单。

产品默认档低档高帧档案例设定帧预算
消消30fps30fps + 低画质60fps33.3ms / 16.7ms
方舟60fps30fps60fps 满帧16.7ms / 8.3ms(更高帧档口径)

消消的低端机占 35%,策略第一目标是守住 30fps 下限;60fps 只对通过分档的设备开放,且开放后仍要能被帧预算控制器降回来。方舟默认 60fps、低档 30fps,帧预算更紧;它的 8.3ms 属于更高帧档口径,不参与本节 30/60 两档模型,不能把 8.3ms 当作 60fps 的常规预算。

档位是产品决策,设备评分只决定初始档位。初始档位来自 8.2 的分档结果,运行期是否守得住,要看本节控制器与 9.2 的阶段归因。

切档要回滞,不是每帧改一次

如果每一帧都比较一次阈值,帧时间在边界附近抖动时,档位就会反复切换。控制器用三层约束把「一次抖动」和「持续趋势」分开。

参数案例设定作用调错的后果
windowFrames90 帧用窗口内 p95 判断趋势窗口太短,偶发长帧触发降档
minDwellFrames60 帧切档后至少停留一段时间切完立刻切回,画面闪烁
downgradeRatio1.05p95 超过预算 5% 才降没有比例带,边界抖动就降档
upgradeRatio0.75p95 低于预算 75% 才升升档后立刻超预算,来回振荡
cooldownFrames30 帧降档后冷却,期间不升冷却太短,降完就弹回
relapseFrames180 帧帧率降档后更长的观察期帧率反复在 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 帧14200us16667usGPU 0.62hold窗口未满,继续观察
第 90 帧20000us16667usGPU 0.80lower-scalep95 超降档线,1.00→0.85
第 120 帧11800us16667usGPU 0.79hold冷却中,不升档
第 210 帧8000us16667usGPU 0.58raise-scale余量足够,0.85→1.00
第 300 帧9000us16667usGPU 0.57hold已在最高分辨率

这张表能回答三个常见争议:切档是不是因为一次偶发长帧、降档后有没有立刻弹回、升档是不是因为余量真的够。没有原因字段,线上只能看到「画质变了」,无法判断是策略正确还是抖动。

记录粒度也要控制。逐帧上传会淹没日志,通常只上传动作帧、窗口边界与最低档报警;本地可以保留最近若干个窗口,出问题时再导出。

参数怎么定

参数不能从别的项目复制,要从本项目的帧时间分布与玩法节奏推导。下表是推导顺序,不是推荐值。

参数推导方法消消的案例设定方舟的差异
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-bound89 次 hold,1 次 lower-scale60fps / 0.8520000us / 16667usGPU 占比 0.8,先降分辨率
recovery239 次 hold,1 次 raise-scale60fps / 1.08000us / 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,不能把模型输出写成真机测量结果。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查