《Cocos 游戏开发入门》15.2 帧率、内存与性能剖析

玩家说「有点卡」,你打开游戏却觉得还行——因为卡顿是瞬时的,靠肉眼抓不住。本节给 mini2d 写一套性能剖析:用 performance.now() 采样帧耗时,用滚动平均与 P95/P99 百分位判断手感,用 performance.memory 与分配计数区分 GC、绘制、逻辑三类掉帧真凶。

15.2 帧率、内存与性能剖析

15.1 的调试绘制让我们看见了「逻辑」,这一节要看见「时间」。

性能问题最反直觉的一点是:玩家说的「卡」,开发者往往感受不到。因为卡顿是瞬时的——平均 60 帧里只要每隔几秒来一次 100ms 的长帧,玩家就能明显感到一顿,而你盯着屏幕看十秒可能一次都没撞上。所以性能排查的第一原则是:不靠肉眼,靠采样。

本节给 mini2d 写一套轻量剖析器,回答三个问题:现在多少帧?这一秒里最慢的那几帧有多慢?慢在哪(GC、绘制还是逻辑)?

与站内 graphics/ 性能文的分工:那批文章偏渲染管线理论,本节是能直接跑在浏览器里的采样代码与排查方法论。Cocos Creator 3.8 的 profiler 对照放在本节末。

15.2.1 帧耗时从哪来:performance.now() 采样

浏览器的 requestAnimationFrame 回调会带一个 DOMHighResTimeStamp,但更通用、也能用在 Node 测试里的是 performance.now()——返回自页面加载起的毫秒数,精度远高于 Date.now()。

帧耗时的定义是相邻两次循环开始的时间差:

// mini2d/perf/frame-sampler.ts
export class FrameSampler {
  private samples: number[] = [];
  private readonly capacity: number;
  private last = 0;
  private sum = 0;

  constructor(capacity = 300) {
    this.capacity = capacity;
  }

  /** 在每帧循环开头调用,返回本帧耗时(ms) */
  tick(now: number): number {
    if (this.last === 0) {
      this.last = now;
      return 0;
    }
    const dt = now - this.last;
    this.last = now;
    this.samples.push(dt);
    this.sum += dt;
    if (this.samples.length > this.capacity) {
      this.sum -= this.samples.shift()!;
    }
    return dt;
  }

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

capacity 决定「窗口」多大。60 帧/秒下取 300,就是最近 5 秒——足够平滑,又不会慢到掩盖刚发生的问题。

15.2.2 滚动平均:别被瞬时值骗

直接把「上一帧耗时」显示出来毫无意义:它每帧都在跳,你根本读不出趋势。要看的是滚动平均——固定窗口内所有样本的均值:

  get average(): number {
    return this.samples.length === 0 ? 0 : this.sum / this.samples.length;
  }

  /** 平均帧耗时换算成的 FPS */
  get fps(): number {
    const avg = this.average;
    return avg === 0 ? 0 : 1000 / avg;
  }

但平均有个致命缺陷:它会把尖刺抹平。假设 5 秒里 299 帧都是 5ms,只有 1 帧是 300ms,平均下来是 (299×5 + 300) / 300 ≈ 5.98ms——看起来「稳定 167 帧」,可那一帧 300ms 的卡顿玩家能结结实实地感受到。所以平均必须配百分位一起看。

15.2.3 百分位:P95 / P99 才是手感

**百分位(percentile)**回答的是「最慢的那批帧有多慢」。P95 表示「95% 的帧都不超过这个值」,P99 则看更极端的尾部:

  percentile(p: number): number {
    if (this.samples.length === 0) return 0;
    const sorted = [...this.samples].sort((a, b) => a - b);
    const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
    return sorted[idx];
  }

判断标准要对齐帧预算——一帧必须在多少毫秒内画完:

目标帧率每帧预算说明
60 FPS16.67 ms桌面 / 高端手机的基准
30 FPS33.3 ms中低端手机的保底
120 FPS8.33 ms高刷屏,挑战大

一个实用的手感判据:

  • P50 超过预算 → 整体就慢,是持续性问题,优化点明确。
  • P50 正常但 P99 爆表 → 偶尔卡一下,多半是 GC 或加载引起的尖刺(见 15.2.4)。
  • 平均正常但 P95 明显偏高 → 有间歇性的重逻辑,比如每隔几帧触发一次的大范围碰撞检测。

只看平均会漏掉「P99 很高」这一类最影响手感的问题,这就是为什么剖析面板一定要同时显示 P50 / P95 / P99。

15.2.4 内存:performance.memory 与对象分配计数

帧耗时的尖刺常常源于 垃圾回收(GC)。Chromium 系浏览器暴露了 performance.memory,能读到 JS 堆的使用量:

// mini2d/perf/memory.ts
export interface MemoryInfo {
  usedJSHeapSize: number;
  totalJSHeapSize: number;
  jsHeapSizeLimit: number;
}

export function readHeap(): MemoryInfo | null {
  const perf = performance as Performance & { memory?: MemoryInfo };
  return perf.memory ?? null;
}

performance.memory 是非标准 API(只有 Chromium 系支持,Safari / Firefox 没有),所以必须做能力检测、拿不到就优雅降级。真正要看的是趋势:usedJSHeapSize 如果画成曲线是锯齿状(涨一段、骤降、再涨),那个「骤降」就是一次 GC;锯齿越密、落差越大,说明每帧分配的临时对象越多。

比堆大小更直接的是对象分配计数。给需要频繁创建的对象加一个全局计数器:

// mini2d/perf/alloc-counter.ts
export const allocCounter = { frame: 0, total: 0 };

export function countAlloc(): void {
  allocCounter.frame += 1;
  allocCounter.total += 1;
}

export function resetFrameAlloc(): void {
  allocCounter.frame = 0;
}

在对象工厂、事件对象、向量临时变量等地方调用 countAlloc(),每帧结束读一次 allocCounter.frame。如果这个数字持续在几百以上,GC 尖刺几乎是必然的——目标应该是把它压到个位数,做法就是复用对象(对象池)而不是每帧 new。

15.2.5 把采样结果显示到屏幕上

数据采了,总得看得见。最省事的方式是复用 15.1 的 DebugDraw,在屏幕左上角画几行文字——固定屏幕坐标,不随相机移动:

// mini2d/perf/hud.ts
import type { FrameSampler } from './frame-sampler';
import type { DebugDraw } from '../debug/debug-draw';

export function drawPerfHud(
  dd: DebugDraw,
  s: FrameSampler,
  heap: { usedJSHeapSize: number } | null,
): void {
  const lines = [
    `FPS ${s.fps.toFixed(0)}  avg ${s.average.toFixed(1)}ms`,
    `P50 ${s.percentile(50).toFixed(1)}  P95 ${s.percentile(95).toFixed(1)}  P99 ${s.percentile(99).toFixed(1)}`,
    heap ? `heap ${(heap.usedJSHeapSize / 1048576).toFixed(1)} MB` : 'heap n/a',
  ];
  for (let i = 0; i < lines.length; i++) {
    dd.text(8, 16 + i * 16, lines[i], '#00ff99');
  }
}

两个细节值得留意:一是 heap 可能为 null(非 Chromium 内核),所以模板里要留 n/a 分支;二是 HUD 本身不能进性能统计,它和 15.1 的调试层一样,只在开发模式、且排在正式渲染之后 flush。

percentile 每次调用都会 [...samples].sort(),即 O(n log n)。HUD 每秒刷新几次就够,不要每帧调用三次,否则剖析器本身成了性能负担——这是很讽刺但很常见的自伤。

15.2.6 定位掉帧真凶:GC、绘制、逻辑三分法

拿到数据后,怎么判断慢在哪?先把一帧切成阶段分别计时:

// mini2d/perf/phases.ts
export interface FramePhases {
  physics: number;
  update: number;
  render: number;
}

export function measurePhases(
  ctx: CanvasRenderingContext2D,
  dt: number,
): FramePhases {
  const t0 = performance.now();
  physicsStep(dt);
  const t1 = performance.now();
  update(dt);
  const t2 = performance.now();
  render(ctx);
  const t3 = performance.now();
  return { physics: t1 - t0, update: t2 - t1, render: t3 - t2 };
}

有了阶段数据,掉帧就能归到三类真凶之一:

现象真凶验证方法对策
尖刺周期出现,堆曲线锯齿GCallocCounter.frame 持续偏高对象池复用,减少临时对象
render 阶段占比长期 > 60%绘制看 Draw Call 数与每帧绘制图元数合批、图集、裁剪不可见对象
update 阶段突增,尖刺不规则逻辑看是否每 N 帧触发一次重计算分摊到多帧、缓存结果、降频
physics 随实体数线性增长物理统计宽相候选对数量空间划分、休眠静止刚体

排查顺序建议:先看 P99 是不是被 GC 主导(堆曲线 + 分配计数最快能确认),再看 render 占比,最后才怀疑业务逻辑。因为前两者是「系统性」的,一旦确认往往一改就见效。

15.2.7 一个真实的排查案例

《方块冲刺》第 12 关上线后收到「捡金币时偶尔卡一下」的反馈。按上面的流程走一遍:

  1. 采样:FrameSampler 的 P50 是 6ms(正常),但 P99 达到 48ms——典型的尖刺型卡顿。
  2. 看堆曲线:usedJSHeapSize 呈密集锯齿,每隔约 2 秒一次大落差。
  3. 看分配计数:捡金币时 allocCounter.frame 从 3 跳到 约 800。
  4. 定位:金币特效每次触发都 new 了一整组粒子对象,且 forEach 里还创建了临时向量。

对策是给粒子加对象池、把临时向量提成模块级复用变量。改完后 allocCounter.frame 回到个位数,P99 降到 11ms,卡顿消失。

这个案例说明:「偶尔卡一下」几乎总是 GC 尖刺,而 GC 尖刺的根因是每帧分配。用分配计数去定位,比用肉眼看画面快得多。

15.2.8 Cocos 对照:profiler、game.frameRate 与 director

Cocos Creator 3.8 内置了性能面板 profiler,并用 game / director 暴露帧信息。以下为 L2 片段(import { ... } from 'cc'):

import { _decorator, Component, profiler, game, director, sys, log } from 'cc';
const { ccclass } = _decorator;

@ccclass('PerfOverlay')
export class PerfOverlay extends Component {
  start(): void {
    profiler.showStats();      // 左下角显示 FPS / Draw Call / 内存等
    game.frameRate = 60;       // 锁定目标帧率(3.8 用 frameRate,setFrameRate 已废弃)
  }

  update(): void {
    const frame = director.getTotalFrames();
    if (frame % 60 === 0) {
      log('frame', frame, 'dt', director.getDeltaTime().toFixed(3), 'now', sys.now().toFixed(0));
    }
  }

  onDestroy(): void {
    profiler.hideStats();
  }
}

对照要点:

  • profiler 是引擎导出的单例(export const profiler: Profiler),showStats() / hideStats() 开关面板,isShowingStats() 查询状态,stats(get stats())可读到内部统计数据。它等价于我们 15.2.1~15.2.4 手写的那套采样 + 显示,只不过引擎已经内置。
  • game.frameRate 是 getter/setter(get frameRate(): string | number),赋值即锁定目标帧率;旧的 setFrameRate / getFrameRate 在 3.8 已标记废弃。另有 game.frameTime 描述「帧间隔阈值」,用于把调试时的超长帧间隔自动夹回合理值。
  • director.getTotalFrames() 返回累计帧数,director.getDeltaTime() 返回上一帧的 dt(单位秒,和我们 performance.now() 的毫秒要换算),director.getScheduler() 可取调度器。sys.now() 提供跨平台的高精度时间,正是 performance.now() 的引擎级封装。
  • 日志统一走引擎的 log / debug / warn / error 函数(export function log(...data: unknown[]): void 等),它们比 console.log 多了平台适配(原生端会转发到系统日志)。调试输出建议用 debug 而非 log,生产环境可整体关闭。

到这里,「看见逻辑」和「看见时间」两件工具都齐了。但排查时还有一个更高频的需求:不想改代码、不想重启,就想现场改个数——比如把重力调大一点试试手感。下一节做游戏内开发者控制台,用命令注册表实现改数值、跳关、无敌、加速时间。

阅读导航:上一节:15.1 调试绘制与可视化 · 下一节:15.3 开发者控制台与作弊命令 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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