《Cocos 游戏开发实战》13.3 Profiler 与帧预算

前两节分别压了 Draw Call 与纹理内存,但优化到底有没有用、下一刀该砍哪里,得靠数据说话。本节把 16.7 ms 的帧预算切成逻辑、物理、渲染、呈现四段,逐字段讲透 Cocos Profiler 的统计面板,给出帧时间采样与分段计时的可编译脚本,并落到低端机限帧降级的工程实现。

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 是内置的,不需要装插件。三种打开方式对应三个阶段:

  1. 编辑器预览:运行预览后,菜单「开发者 → 显示统计信息」,预览窗口左下角会出现一排数字。适合开发期随手看一眼。
  2. 运行时脚本:在代码里调用 profiler.showStats(),用 profiler.hideStats() 关闭,用 profiler.isShowingStats() 查询当前状态。适合在真机包里临时打开。
  3. 生产排查:把构建面板的 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 对比
drawsDraw 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 构建与部署 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画