《Cocos 游戏开发高级编程》7.3 性能剖析与优化闭环

卷二 13.3 讲清了帧预算四段与 Profiler 面板怎么开。本节把它升成一个可重复的闭环:采样、定位、修复、验证,每一步给出 cc.d.ts 能证明的 API,并用纯 TS 写一个带分位数的帧时间采样器配 vitest 断言。核心纪律是先量再改、一次只改一处、改完同设备复测。

7.3 性能剖析与优化闭环

7.1 讲清了内存住在哪、7.2 给出了显存预算。但预算只是目标,你永远不知道自己有没有达到目标,直到你测过。卷二 13.3 已经把 16.7 ms 切成 logic / physics / render / present 四段,也讲了怎么打开 Profiler 面板。本节往上走一层,把它升成一个可重复、可自动化的闭环。

闭环只有四步:采样 → 定位 → 修复 → 验证。听起来像废话,但工程里翻车的从来不是「不知道这四步」,而是跳步——凭感觉改代码、一次改五处、改完不测就上线。本节把每一步的 API 与数据口径钉死,并给出一个纯 TypeScript 的帧时间采样器与配套 vitest 断言。

7.3.1 闭环四步与三种反模式

步骤产出判据
采样一份帧时间序列(至少 300 帧)同一设备、同一场景、同一帧数
定位「哪一段超」→「哪一行超」从 stats 到分段计时,逐层缩小
修复一处改动一次只改一处,否则无法归因
验证改前 / 改后的 p95 对比同设备复测,差值超过噪声才算数

三种反模式几乎每个团队都踩过:

  1. 凭感觉优化:没采样就改,改完「感觉快了」。感觉会被帧率波动骗,也会被自己的期待骗。
  2. 一次改多处:合批 + 池化 + 降级一起上,结果好了不知道是哪个起的作用,坏了也不知道回滚哪个。
  3. 只测「平均」:均值 14 ms 看起来达标,但 p95 是 28 ms——卡顿感来自长尾帧,不是均值。

第 3 条是本节最想纠正的认知偏差。60 FPS 的体验取决于最慢的那几帧,不取决于平均帧。后面 7.3.3 会用分位数把这件事量化。

7.3.2 采样:读什么、怎么读

Cocos 的性能数据全在 profiler.stats 上。它的类型是 IProfilerState,cc.d.ts 里列了全部字段:

// 来源:@cocos/creator-types@3.8.8, engine/cc.d.ts(IProfilerState)
frame: ICounterOption;
fps: ICounterOption;
draws: ICounterOption;
instances: ICounterOption;
tricount: ICounterOption;
logic: ICounterOption;
physics: ICounterOption;
render: ICounterOption;
present: ICounterOption;
textureMemory: ICounterOption;
bufferMemory: ICounterOption;

每个字段都是 ICounterOption,真正的数值在 .counter.value 上(Counter 类有 get value(): number)。所以取帧耗时写 profiler.stats.frame.counter.value,取逻辑耗时写 stats.logic.counter.value。

围绕它的控制 API 只有三个,签名来自 cc.d.ts:

// 来源:@cocos/creator-types@3.8.8, engine/cc.d.ts(Profiler 类)
isShowingStats(): boolean;
hideStats(): void;
showStats(): void;

再配两个时间源:director.getDeltaTime()(上一帧耗时,秒)与 director.getTotalFrames()(累计帧数)。前者用来做逐帧判定,后者用来给采样打序号——没有帧序号的采样数据是没法对齐 A/B 的。

一个容易忽略的坑:profiler.stats 返回类型是 IProfilerState | null,在引擎还没完成初始化、或 Profiler 模块被裁剪掉时会是 null。所有读取都要先判空,否则真机首帧就可能崩。

7.3.3 L1:帧时间采样器与分位数

采样的核心不是「记下每个数」,而是「在固定窗口里算出可信的统计量」。下面这个采样器是纯 TypeScript,零 cc 依赖,用环形缓冲存最近 N 帧,提供均值、分位数与超预算占比:

// mini2d/perf/frame-sampler.ts —— 纯 TS,无任何 cc 依赖
export class FrameSampler {
  private readonly capacity: number;
  private readonly samples: number[];
  private cursor = 0;
  private count = 0;
  private total = 0;

  constructor(capacity = 300) {
    this.capacity = capacity;
    this.samples = new Array<number>(capacity).fill(0);
  }

  push(frameMs: number): void {
    if (this.count === this.capacity) {
      this.total -= this.samples[this.cursor];
    } else {
      this.count += 1;
    }
    this.samples[this.cursor] = frameMs;
    this.total += frameMs;
    this.cursor = (this.cursor + 1) % this.capacity;
  }

  get length(): number {
    return this.count;
  }

  get average(): number {
    return this.count === 0 ? 0 : this.total / this.count;
  }

  /** 最近秩分位数:p=95 表示 95% 的帧不超过该值 */
  percentile(p: number): number {
    if (this.count === 0) {
      return 0;
    }
    const sorted = this.samples.slice(0, this.count).sort((a, b) => a - b);
    const rank = Math.ceil((p / 100) * this.count) - 1;
    const idx = Math.min(this.count - 1, Math.max(0, rank));
    return sorted[idx];
  }

  /** 超过预算的帧占比,0~1 */
  overBudgetRatio(budgetMs: number): number {
    if (this.count === 0) {
      return 0;
    }
    let over = 0;
    for (let i = 0; i < this.count; i += 1) {
      if (this.samples[i] > budgetMs) {
        over += 1;
      }
    }
    return over / this.count;
  }
}

设计上有两处刻意取舍。第一,用环形缓冲而不是数组加 shift():shift() 是 O(n) 的搬移,在每帧都要记一次的场景下不划算;环形缓冲把写入压到 O(1)。第二,percentile 用「最近秩」口径(ceil(p% × n) - 1 取排序后下标),不做线性插值——性能判定要的是「最差的那几帧有多差」,插值出来的小数反而掩盖长尾。

配 vitest 断言,重点验证环形覆盖与分位数对长尾的敏感:

import { describe, expect, it } from 'vitest';
import { FrameSampler } from './frame-sampler';

describe('FrameSampler', () => {
  it('环形窗口只保留最近 N 帧', () => {
    const s = new FrameSampler(3);
    s.push(10);
    s.push(20);
    s.push(30);
    s.push(40); // 挤掉最早的 10
    expect(s.length).toBe(3);
    expect(s.average).toBeCloseTo((20 + 30 + 40) / 3);
  });

  it('p95 反映长尾,均值看不出来', () => {
    const s = new FrameSampler(100);
    for (let i = 0; i < 90; i += 1) {
      s.push(16);
    }
    for (let i = 0; i < 10; i += 1) {
      s.push(50);
    }
    expect(s.percentile(50)).toBe(16);
    expect(s.percentile(95)).toBe(50);
    expect(s.average).toBeLessThan(20);
    expect(s.overBudgetRatio(33.3)).toBeCloseTo(0.1);
  });
});

第二个用例就是 7.3.1 里那个反模式的量化:均值不到 20 ms,看起来达标,但 p95 是 50 ms。每 10 帧就有 1 帧超 33.3 ms,用户能明确感到卡顿。所以工程上的验收判据应当写成「p95 ≤ 预算」,而不是「均值 ≤ 预算」。

7.3.4 定位:从「哪一段超」到「哪一行超」

采样告诉你「超了」,定位告诉你「哪超了」。Cocos 已经把帧切成四段,所以第一步只需读 stats 里哪个字段最大。第二步要更细,用 director 的帧事件包住一整轮脚本逻辑:

import { director, Director } from 'cc';

let lastBeforeUpdate = 0;
let updateCostMs = 0;

director.on(Director.EVENT_BEFORE_UPDATE, () => {
  lastBeforeUpdate = performance.now();
});

director.on(Director.EVENT_AFTER_UPDATE, () => {
  updateCostMs = performance.now() - lastBeforeUpdate;
});

export function getUpdateCostMs(): number {
  return updateCostMs;
}

EVENT_BEFORE_UPDATE 与 EVENT_AFTER_UPDATE 是 Director 的静态成员,事件名必须写成 Director.EVENT_BEFORE_UPDATE;director 继承自 EventTarget,可以直接 on。这一段包住了所有脚本的 update,把它和 profiler.stats.logic 对一下,就能确认「超标确实来自脚本逻辑」而不是渲染。

再往下就是二分法:在可疑函数里插两个 performance.now(),把耗时对半切,哪一半超就继续切哪一半。performance.now() 在浏览器、微信小游戏、原生端都可用(原生端由引擎适配),所以这套打点跨端通用。唯一要记的是:打点本身有开销,几十个点无所谓,上千个点会自己把帧率拖下来,测完立刻删。

7.3.5 修复:把手段对到症状

定位完,手段要对着症状选,而不是哪个顺手用哪个:

症状首选手段参考
draws 高、render 超同纹理同材质合批、自动图集卷二 13.1
textureMemory 高换压缩格式、降分辨率、关 mipmap本节 7.2
logic 超、GC 尖峰对象池、去掉每帧分配本节 7.1
physics 超降物理步频、减碰撞体卷二 07.2
present 高通常是 vsync 等待,不是坏事卷二 13.3

这张表的价值在于防止误修。render 超标去优化 update 里的循环,纯属白费力气;present 高看着吓人,其实是引擎在等垂直同步,改它没意义。每一行都对应一个已经讲透的机制——需要细节时回看对应章节。

修复还有一条纪律:一次只改一处。改完立刻用 7.3.6 的方式复测,确认这一处有效、且没有把别的指标拖坏(比如合批可能增加纹理内存,池化可能增加常驻内存)。多处同改,等于放弃归因能力。

7.3.6 验证:A/B 对比与回归门禁

验证的判据只有一条:同一设备、同一场景、同一帧数下,改后的 p95 明显低于改前。三条约束缺一不可——换个设备测,差值全被硬件差异淹没。

工程里通常维护一张这样的对比表:

指标改前改后判据
p50 帧时间14.2 ms13.8 ms参考
p95 帧时间26.5 ms17.1 ms达标(≤ 16.7 的 1.1 倍)
draws18042达标
textureMemory41 MB16 MB达标

上表数值是示意,需在你的设备上复测。 重点在口径而非数字:p50 几乎不变、p95 大幅下降,是「削掉长尾」的典型形状;如果 p50 和 p95 一起降,那是整体加速,通常是换了压缩格式或减了工作量。两种形状对应不同的优化手段,看懂形状能反过来验证你改对了地方。

「回归门禁」是把这张表自动化:把基线数值写进配置,每次构建后自动采样一遍,p95 超过基线的 1.1 倍就报警。这一步是 7.3.7 的组件要干的事。

7.3.7 L2:自动回归采样组件

把 L1 的采样逻辑搬进 Cocos 组件,就得到一个能跑在真机上的回归门禁。它只依赖 cc 公开 API,能被类型检查:

import { _decorator, Component, profiler } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('PerfGate')
export class PerfGate extends Component {
  @property
  budgetMs = 16.7;
  @property
  sampleCount = 300;

  private samples: number[] = [];

  update(): void {
    const stats = profiler.stats;
    if (!stats) return;

    this.samples.push(stats.frame.counter.value);
    if (this.samples.length < this.sampleCount) return;

    const p95 = this.percentile(this.samples, 95);
    if (p95 > this.budgetMs) {
      console.warn(`[perf] p95=${p95.toFixed(2)}ms over budget ${this.budgetMs}ms`);
    }
    this.samples.length = 0;
  }

  private percentile(values: number[], p: number): number {
    const sorted = values.slice().sort((a, b) => a - b);
    const rank = Math.ceil((p / 100) * sorted.length) - 1;
    const idx = Math.min(sorted.length - 1, Math.max(0, rank));
    return sorted[idx];
  }
}

几个工程细节:budgetMs 默认给 16.7,但低端机应传 33.3(30 FPS 的帧预算);sampleCount 取 300 帧,约合 60 FPS 下 5 秒,太短会被瞬时抖动骗,太长则反馈太慢;采满一批就清空,避免长期运行下数组无限增长。生产环境里,console.warn 应换成上报到你的崩溃/性能平台(卷二 16.3 讲过上报通道),这样线上卡顿才有数据可查。

注意这个组件本身也有开销:每帧一次 push 可以忽略,但 300 帧一次的 sort 会有约 0.1 ms 的瞬时开销。如果它恰好落在某帧,会轻微污染那一帧的读数——所以更稳的做法是把统计放到帧末、隔帧做,或者只在「性能测试模式」下启用它。

7.3.8 工程决策:什么时候优化,什么时候不

最后落到决策。性能优化最大的成本不是改代码,而是它挤占了做功能的时间,所以要用「值不值得」筛一遍:

  • 先做完,再优化:功能没定型时优化,等于给一个还要改的结构加固。等玩法稳定、有了可复现的测试场景再动手。
  • 只优化热点:Amdahl 定律说得很直白——优化一个只占 5% 耗时的模块,哪怕把它压到 0,整体最多快 5%。用采样确认热点在哪,别优化「看起来慢」的地方。
  • 有基线才优化:没有基线,你无法证明优化有效,也无法在回归时发现退步。基线就是 7.3.6 那张表。
  • 够用就停:p95 达标之后继续抠,边际收益会迅速趋近于零。把时间还给玩法。

反过来,有两种情况必须马上优化,不能拖:低端机上首帧就崩(显存超限)、核心玩法帧稳定低于 30 FPS。前者是 7.2 的预算问题,后者是本节闭环要解决的。

到这里,第 7 章把「内存模型 → 显存预算 → 性能闭环」串成了一条线:7.1 说清内存住哪、谁回收;7.2 把显存算成可测的预算;本节用采样与分位数确认预算真的被守住。下一章从 JS 侧转到原生侧——引擎怎么通过 JSB 把 JS 调用桥到 C++,以及这条桥的代价在哪。

阅读导航:上一节:7.2 纹理、显存与资源预算 · 下一节:8.1 原生平台与 JSB 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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