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 FPS | 16.67 ms | 桌面 / 高端手机的基准 |
| 30 FPS | 33.3 ms | 中低端手机的保底 |
| 120 FPS | 8.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 };
}
有了阶段数据,掉帧就能归到三类真凶之一:
| 现象 | 真凶 | 验证方法 | 对策 |
|---|---|---|---|
| 尖刺周期出现,堆曲线锯齿 | GC | allocCounter.frame 持续偏高 | 对象池复用,减少临时对象 |
render 阶段占比长期 > 60% | 绘制 | 看 Draw Call 数与每帧绘制图元数 | 合批、图集、裁剪不可见对象 |
update 阶段突增,尖刺不规则 | 逻辑 | 看是否每 N 帧触发一次重计算 | 分摊到多帧、缓存结果、降频 |
physics 随实体数线性增长 | 物理 | 统计宽相候选对数量 | 空间划分、休眠静止刚体 |
排查顺序建议:先看 P99 是不是被 GC 主导(堆曲线 + 分配计数最快能确认),再看 render 占比,最后才怀疑业务逻辑。因为前两者是「系统性」的,一旦确认往往一改就见效。
15.2.7 一个真实的排查案例
《方块冲刺》第 12 关上线后收到「捡金币时偶尔卡一下」的反馈。按上面的流程走一遍:
- 采样:
FrameSampler的 P50 是 6ms(正常),但 P99 达到 48ms——典型的尖刺型卡顿。 - 看堆曲线:
usedJSHeapSize呈密集锯齿,每隔约 2 秒一次大落差。 - 看分配计数:捡金币时
allocCounter.frame从 3 跳到 约 800。 - 定位:金币特效每次触发都
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 开发者控制台与作弊命令 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。