13.3 Profiler 与帧预算
13.1 把 Draw Call 压到 42,13.2 把纹理内存压到 24 MB 以内。但这两个数字只是手段,不是目标——真正的目标是每帧不超过 16.7 ms。跑酷游戏一帧里要做的事很多:读输入、算角色位置、判定碰撞、更新粒子、渲染上百个精灵、呈现到屏幕。任何一段超标,整帧就掉。
这一节把「一帧 16.7 ms」拆成可分配、可测量、可定位的预算,再讲怎么用 Cocos 自带的 Profiler 把超标的段落找出来,最后给出低端机限帧降级的实现。做完这一节,《方块冲刺》的性能优化才算闭环:有基线(13.1)、有手段(13.2)、有度量(本节)。
13.3.1 帧预算:16.7 ms 怎么切
60 fps 意味着每帧只有 16.67 ms 的预算。这 16.67 ms 不是一整块,而是被引擎切成四段依次执行:
| 阶段 | 做什么 | 预算(参考) |
|---|---|---|
logic | 所有脚本的 update / lateUpdate | ≤ 4 ms |
physics | 物理步进与碰撞 | ≤ 2 ms |
render | 渲染器提交、合批、裁剪 | ≤ 6 ms |
present | 呈现到屏幕(含 vsync 等待) | ≤ 2 ms |
| 余量 | 应对单帧波动 | ≥ 2 ms |
四段加起来是 14 ms,剩下的 2 ms 是余量,不能算进可用预算。原因很现实:帧与帧之间的耗时是有抖动的,某个金币飘字突然多了二十个、某次 GC 恰好落在这一帧,都可能让本来 14 ms 的一帧变成 18 ms。余量就是给这种抖动留的缓冲。一份排得满满当当的预算表,等于没有预算。
这组预算是参考值,需在你的设备上复测。 低端机(比如 2019 款 iPad)上,render 一段往往就吃掉 8~10 ms,此时要优先按 13.1 的办法合批,而不是去抠 logic 里那零点几毫秒。
13.3.2 打开 Profiler 的三种方式
Cocos Creator 的 Profiler 是内置的,不需要装插件。三种打开方式对应三个阶段:
- 编辑器预览:运行预览后,菜单「开发者 → 显示统计信息」,预览窗口左下角会出现一排数字。适合开发期随手看一眼。
- 运行时脚本:在代码里调用
profiler.showStats(),用profiler.hideStats()关闭,用profiler.isShowingStats()查询当前状态。适合在真机包里临时打开。 - 生产排查:把构建面板的
Source Maps打开(见 13.3.7),在真机上用 Chrome 的 Performance 面板录一段,拿到带源码行号的火焰图。适合定位线上卡顿。
第三种方式信息最全,但需要「真机 + 调试浏览器」的组合,微信小游戏上还要额外开调试模式,成本较高。日常优化用前两种就够。
生产包里不要默认开着统计面板。它是纯 Canvas 绘制,本身就要占几个 Draw Call 和一点 CPU,会污染你的测量结果。正确做法是用一个 URL 参数或调试开关控制:
import { profiler, sys } from 'cc';
const debug = sys.isBrowser && location.search.includes('perf=1');
if (debug) {
profiler.showStats();
}
13.3.3 读 stats:每个字段是什么意思
profiler.stats 返回一个 IProfilerState,里面每个字段都是「计数项」,真正的数值在 .counter.value 上。字段与含义:
| 字段 | 含义 | 看什么 |
|---|---|---|
fps | 当前帧率 | 掉到 55 以下就说明有帧超标 |
frame | 每帧总耗时(ms) | 与 16.67 对比 |
draws | Draw Call 数 | 13.1 的成果在这里体现 |
instances | 渲染实例数 | 与 draws 一起看批次效率 |
tricount | 三角形数 | 2D 游戏通常很小,异常大说明有图元泄漏 |
logic | 脚本逻辑耗时(ms) | 超标先查 update 里的循环 |
physics | 物理耗时(ms) | 未用物理系统时应接近 0 |
render | 渲染提交耗时(ms) | 13.1 优化的主战场 |
present | 呈现耗时(ms) | 高通常意味着 vsync 等待,不是坏事 |
textureMemory | 纹理内存(MB) | 13.2 的成果在这里体现 |
bufferMemory | 缓冲区内存(MB) | 2D 游戏通常很小 |
读这张表有个诀窍:先看 frame 有没有超,再看是哪一段超。如果 frame 没超,其他字段再难看也不用管;如果超了,logic 和 render 里哪个大就先查哪个。新手常犯的错是盯着 draws 这个数字焦虑,却忽略了真正超标的可能是 logic。
13.3.4 L2:帧时间采样与超预算告警
统计面板是人眼看的,自动化需要代码采样。下面这段组件把最近 60 帧的耗时存进一个滑动窗口,任一帧的逻辑或渲染超预算就打日志,还提供一个 average() 给外部读平均帧时间。它只依赖 cc 的公开 API,能被类型检查。
import { _decorator, Component, profiler } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('FrameBudgetMonitor')
export class FrameBudgetMonitor extends Component {
@property
logicBudgetMs = 4;
@property
renderBudgetMs = 6;
private samples: number[] = [];
private readonly windowSize = 60;
lateUpdate(): void {
const stats = profiler.stats;
if (!stats) return;
const logic = stats.logic.counter.value;
const render = stats.render.counter.value;
this.samples.push(logic + render);
if (this.samples.length > this.windowSize) {
this.samples.shift();
}
if (logic > this.logicBudgetMs || render > this.renderBudgetMs) {
console.warn(
`[budget] logic=${logic.toFixed(1)}ms render=${render.toFixed(1)}ms`
);
}
}
average(): number {
if (this.samples.length === 0) return 0;
let sum = 0;
for (const v of this.samples) sum += v;
return sum / this.samples.length;
}
}
注意 lateUpdate 里读的是上一帧的统计——profiler.stats 每帧在渲染结束后更新,所以读到的是最近一帧的完整数据,不会出现「读到半帧」的问题。另外,samples 用数组 shift() 维护窗口,60 个数字的开销可以忽略,不必上更复杂的数据结构。
13.3.5 定位到具体代码:分段计时
知道是 logic 超标还不够,得知道是哪段脚本。两个办法:一是给关键函数手动打点,二是监听 director 的帧事件,在整段逻辑前后各取一次时间。
import { director, Director } from 'cc';
let lastBeforeUpdate = 0;
let updateCost = 0;
director.on(Director.EVENT_BEFORE_UPDATE, () => {
lastBeforeUpdate = performance.now();
});
director.on(Director.EVENT_AFTER_UPDATE, () => {
updateCost = performance.now() - lastBeforeUpdate;
});
export function getUpdateCost(): number {
return updateCost;
}
director 继承自 EventTarget,所以可以直接 director.on(...);EVENT_BEFORE_UPDATE 与 EVENT_AFTER_UPDATE 是 Director 的静态成员,所以事件名要写成 Director.EVENT_BEFORE_UPDATE(用实例 director.EVENT_BEFORE_UPDATE 会编译不过)。这两个事件包住了一整轮所有脚本的 update。把这段装进一个模块,再在需要的地方调 getUpdateCost(),就能把「所有 update 的总耗时」和 profiler 报的 logic 对上。
要更细的粒度,就在可疑函数里自己打点:
import { _decorator, Component } from 'cc';
const { ccclass } = _decorator;
@ccclass('LevelTicker')
export class LevelTicker extends Component {
update(): void {
const t0 = performance.now();
this.spawnCoins();
this.moveObstacles();
const cost = performance.now() - t0;
if (cost > 1.5) {
console.warn(`[tick] level cost ${cost.toFixed(2)}ms`);
}
}
private spawnCoins(): void {}
private moveObstacles(): void {}
}
performance.now() 在浏览器、微信小游戏与原生端都可用(原生端由引擎适配),所以这套打点跨端通用。唯一要注意的是:打点本身也有开销,几十个点无所谓,成千上万个点会拖慢帧率,测完记得关掉。
13.3.6 降级:低端机限帧与画质降级
不是所有设备都能跑满 60 fps,与其让低端机在 45 fps 上下抖动(体验比稳定 30 fps 更差),不如主动降到 30 fps。Cocos 里改帧率就是给 game.frameRate 赋值:
import { _decorator, Component, game, sys } from 'cc';
const { ccclass } = _decorator;
@ccclass('AdaptiveQuality')
export class AdaptiveQuality extends Component {
private downgraded = false;
update(): void {
if (this.downgraded) return;
// 低端机策略:移动端且当前目标 60 fps,则降到 30 fps 稳定帧率
if (sys.isMobile && game.frameRate !== 30) {
game.frameRate = 30;
this.downgraded = true;
console.log('[quality] downgraded to 30fps');
}
}
}
game.frameRate 的类型是 string | number,赋 30 即可;微信小游戏里也可以传 '30'。这段代码是「一刀切降级」,生产里更稳的做法是按实测帧时间自适应:连续 N 帧平均帧时间超过 20 ms 就降一档,降到 30 fps 后如果还超标,再关粒子、减金币数量。降级要分级,不要一步到底。
还有一个和 13.2 联动的点:Game.EVENT_LOW_MEMORY 事件会在系统内存吃紧时触发,应该监听它并主动释放非必要资源:
import { game, assetManager, Game } from 'cc';
game.on(Game.EVENT_LOW_MEMORY, () => {
// 释放缓存里的临时资源,给系统腾内存
assetManager.cacheManager?.clearLRU();
});
13.3.7 L3:把 Source Maps 打开
要在真机上定位到具体代码行,构建时必须带 Source Maps。下面是 settings/v2/packages/builder.json 里相关选项的节选。本段是 Cocos Creator 工程文件,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。
{
"__version__": "1.0.0",
"options": {
"sourceMaps": true,
"md5Cache": true,
"debug": false
}
}
在编辑器里打开「构建发布」面板,你会看到什么:右上角有一排复选框,Source Maps 已勾选,MD5 Cache 也已勾选,Debug 未勾选;下方「主包压缩类型」显示 Merge Deps(14.1 会展开讲这三个选项的组合)。改动的值会写回上面那份 builder.json。
(以上工程文件需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。)
需要权衡的是:Source Maps 会让构建产物体积明显变大,正式发布包应当关掉,只在需要排查线上问题的「调试包」里打开。所以工程里通常维护两套构建配置:release 用 sourceMaps: false,debug 用 sourceMaps: true。
13.3.8 收口
本章三节合起来,给《方块冲刺》建立了一套完整的性能闭环:
- 基线:
docs/perf-baseline.md,记录 Draw Call、纹理内存、帧时间三个数; - 手段:自动图集与合批(13.1)、压缩与引用计数(13.2);
- 度量:
FrameBudgetMonitor与分段计时(本节),把 16.7 ms 拆到四段; - 兜底:
AdaptiveQuality的低端机降级,以及监听EVENT_LOW_MEMORY。
这一章做完,工程里多了 4 个性能相关组件、2 份构建/压缩配置、1 份基线文档。下一章把这些「在编辑器里调好的东西」真正构建成三端产物——H5、微信小游戏、原生 Android / iOS,讲清每个平台构建面板上那些参数到底填什么。
阅读导航:上一节:13.2 纹理、内存与 GC · 下一节:14.1 H5 构建与部署 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。