2.1 帧、时间与帧预算
主循环是引擎的心跳。它每转一圈,世界就前进一小步:读输入、跑逻辑、算物理、提交绘制命令、把画面推上屏幕。这一圈花掉的时间就是帧时间,它的倒数是帧率。游戏跟不跟手、掉不掉帧,几乎全看这一圈稳不稳。
本节先把一帧拆成四个可测量的阶段,画出一条帧预算时间线;再从零实现一个 FrameTimer。它不依赖浏览器的 requestAnimationFrame,而是从一个注入的 Clock 读时间——所以我们能在 Node 里把几百帧的耗时分布一次跑完,结果可复现。
这是全书第 2 章第 1 节。代码用 TypeScript,运行在 Node 26 的 type-stripping 模式下,全部可复现。
本章与站内既有内容的分工
站内已有不少文章讲「引擎是什么、怎么选、ECS 怎么组织」。本章不复述那些结论,只做一件事:把主循环从零写出来,并跑出真实数字。分工如下。
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/posts/graphics/ | 渲染管线、GPU 执行模型、纹理与内存 | 只把「提交 + GPU」记成预算里的两段耗时 |
/graphics-render-graph/ | 帧图与渲染调度理论 | 只关心它在帧预算里占多少毫秒 |
/game-engine-architecture-ecs/ | 一篇文章讲清 ECS 引擎骨架 | 只写骨架里「主循环」这一圈的时间切片 |
/defold-ecs-architecture/ | 引擎子系统的工程视角 | 只做从零实现的 FrameTimer,不搬结论 |
/posts/game/engine/ | 引擎选型与对比 | 不选型,直接动手实现内核 |
上面的外链用的是站点产物里真实存在的 URL:普通文章被 permalink 规则压到根级
/<slug>/,而专题 section 页留在/posts/<topic>/。把文章写成/graphics-math-foundations/会是死链。
一帧到底花在哪:帧预算的四个阶段
把一帧按时间顺序切开,能看到四段开销:
- 逻辑(logic):输入采样、ECS 系统、动画求值、物理积分。跑在主线程。
- 提交(submit):把这一帧要画的东西整理成命令,压给图形 API。仍是 CPU 侧。
- GPU:上一帧提交的命令真正在 GPU 上执行。它与 CPU 并行,通常落后一帧。
- 呈现(present):交换链 present,以及等待垂直同步(vsync)的时间。
60Hz 下每帧预算约 16.67ms。下面这条时间线是一帧「健康」的样子:
0ms 4ms 6ms 12ms 13ms 16.67ms ← 预算线
├────────┼────────┼──────────────┼──────┼────────────────┤
│ logic │ submit │ gpu │present│ idle │
│ 逻辑 │ 提交 │ GPU 执行 │ 呈现 │ 空闲 │
└────────┴────────┴──────────────┴──────┴────────────────┘
4.0ms 2.0ms 6.0ms 1.0ms 3.67ms
四段合计 13ms,还剩 3.67ms 余量——这就是「有余量」的帧。反之,只要 logic 段被一个慢系统撑到 18ms,这一帧立刻超预算,下一次 vsync 没赶上,帧率掉到 30。
一个关键认知:GPU 段与 CPU 段是并行流水线。CPU 在跑第 N+1 帧的 logic/submit 时,GPU 还在执行第 N 帧的命令。所以「一帧的墙钟时间」并不等于四段之和,而约等于 max(CPU 段之和, GPU 段)。上面那条线是「各段在各自时间轴上的占用」,用来定位谁超支;真实帧率由 CPU 与 GPU 谁先成为瓶颈决定。
帧预算随刷新率变化
「16.67ms」只是 60Hz 的数字。显示器的刷新率越高,每帧预算越薄,留给 logic + submit 的 CPU 时间就越少:
| 刷新率 | 每帧预算 | 备注 |
|---|---|---|
| 60Hz | 16.67ms | 上世代主机的目标 |
| 90Hz | 11.11ms | VR 的最低门槛 |
| 120Hz | 8.33ms | 高刷屏的常见档 |
| 144Hz | 6.94ms | 电竞显示器 |
同一套逻辑,在 60Hz 下有 16.67ms 预算、在 144Hz 下只剩 6.94ms。这意味着为 144Hz 优化的引擎,逻辑必须比 60Hz 版本快一倍以上。这也是为什么「固定步长」的物理步率(常见 60Hz 或 30Hz)与渲染刷新率要分开考虑:物理不必跟着屏幕一起提速,只有渲染需要跑满高刷。
可运行产物:注入假时钟的 FrameTimer
先定义时钟抽象。所有时间都从 Clock 取,绝不直接读 performance.now()——这是让主循环可测试的唯一前提。
// file: src/core/clock.ts
// tier: L1
// verify: npx vitest run src/core/frame-timer.test.ts
//
// helio 内核:时钟抽象。
// 引擎里所有「时间」都必须从 Clock 取,绝不直接读 performance.now()——
// 只有这样测试才能注入假时钟,把几分钟的模拟压缩到几毫秒跑完。
/** 单调递增的毫秒时钟。真实实现包 performance.now(),测试用 FakeClock。 */
export interface Clock {
nowMs(): number;
}
/** 假时钟:测试与确定性回放用。 */
export class FakeClock implements Clock {
#nowMs: number;
constructor(startMs = 0) {
this.#nowMs = startMs;
}
nowMs(): number {
return this.#nowMs;
}
/** 推进时钟。负值直接抛错,避免测试里把方向写反却静默通过。 */
advance(deltaMs: number): void {
if (!(deltaMs >= 0)) {
throw new RangeError(`FakeClock.advance 需要非负毫秒,收到 ${deltaMs}`);
}
this.#nowMs += deltaMs;
}
}
再实现 FrameTimer:用一个环形缓冲记录最近 N 帧的耗时,给出平均、峰值、百分位与 FPS,并能把耗时打成直方图看长尾。
// file: src/core/frame-timer.ts
// tier: L1
// verify: npx vitest run src/core/frame-timer.test.ts
//
// helio 内核:帧计时器。
// 用一个环形缓冲记录最近 N 帧的耗时,给出平均 / 峰值 / 百分位 / FPS。
// 注入 Clock 后完全不依赖真实 rAF,可在 Node 里把帧分布一次跑出来。
import type { Clock } from './clock.ts';
/** 一帧的四个阶段耗时(毫秒)。这是「帧预算」被谁吃掉的账本。 */
export interface FramePhases {
/** 游戏逻辑:输入、ECS 系统、物理、动画求值。 */
logic: number;
/** 提交:构建命令缓冲、把绘制命令压给图形 API(CPU 侧)。 */
submit: number;
/** GPU:上一帧命令在 GPU 上执行的时间(异步,通常落后一帧)。 */
gpu: number;
/** 呈现:交换链 present 与垂直同步等待。 */
present: number;
}
/** 一帧总耗时(毫秒)。 */
export function frameTotalMs(phases: FramePhases): number {
return phases.logic + phases.submit + phases.gpu + phases.present;
}
/** 是否落在预算内(默认 60Hz 的 16.67ms)。 */
export function withinBudget(phases: FramePhases, budgetMs = 1000 / 60): boolean {
return frameTotalMs(phases) <= budgetMs;
}
export interface FrameStats {
lastMs: number;
avgMs: number;
p95Ms: number;
maxMs: number;
fps: number;
samples: number;
}
export class FrameTimer {
readonly #clock: Clock;
readonly #windowMs: number[];
#cursor = 0;
#count = 0;
#frameStartMs = 0;
#running = false;
#lastMs = 0;
constructor(clock: Clock, windowSize = 120) {
if (!Number.isInteger(windowSize) || windowSize <= 0) {
throw new RangeError(`windowSize 必须是正整数,收到 ${windowSize}`);
}
this.#clock = clock;
this.#windowMs = new Array<number>(windowSize).fill(0);
}
/** 标记一帧开始。 */
begin(): void {
this.#frameStartMs = this.#clock.nowMs();
this.#running = true;
}
/** 标记一帧结束,记录耗时(毫秒)并返回它。 */
end(): number {
if (!this.#running) {
throw new Error('FrameTimer.end 之前必须先 begin');
}
const duration = this.#clock.nowMs() - this.#frameStartMs;
this.#running = false;
this.#windowMs[this.#cursor] = duration;
this.#cursor = (this.#cursor + 1) % this.#windowMs.length;
if (this.#count < this.#windowMs.length) this.#count++;
this.#lastMs = duration;
return duration;
}
get lastMs(): number {
return this.#lastMs;
}
get sampleCount(): number {
return this.#count;
}
/** 滑窗平均耗时(毫秒)。 */
avgMs(): number {
if (this.#count === 0) return 0;
let sum = 0;
for (let i = 0; i < this.#count; i++) sum += this.#windowMs[i];
return sum / this.#count;
}
maxMs(): number {
let max = 0;
for (let i = 0; i < this.#count; i++) {
if (this.#windowMs[i] > max) max = this.#windowMs[i];
}
return max;
}
/** 第 p 百分位耗时(p ∈ [0,1]),最近邻法,够用且不分配额外内存。 */
percentile(p: number): number {
if (this.#count === 0) return 0;
const sorted = this.#windowMs.slice(0, this.#count).sort((a, b) => a - b);
const idx = Math.min(this.#count - 1, Math.floor(p * this.#count));
return sorted[idx];
}
/** 滑窗平均 FPS。 */
fps(): number {
const avg = this.avgMs();
return avg > 0 ? 1000 / avg : 0;
}
stats(): FrameStats {
return {
lastMs: this.lastMs,
avgMs: this.avgMs(),
p95Ms: this.percentile(0.95),
maxMs: this.maxMs(),
fps: this.fps(),
samples: this.#count,
};
}
/** 帧耗时直方图:宽度 bucketMs 的桶计数,用于看长尾。 */
histogram(bucketMs: number): number[] {
if (!(bucketMs > 0)) {
throw new RangeError(`bucketMs 必须为正,收到 ${bucketMs}`);
}
const buckets: number[] = [];
for (let i = 0; i < this.#count; i++) {
const b = Math.floor(this.#windowMs[i] / bucketMs);
buckets[b] = (buckets[b] ?? 0) + 1;
}
return buckets;
}
}
验证命令:npx vitest run src/core/frame-timer.test.ts。类型检查:npx tsc --noEmit。
真跑出来的帧耗时分布
喂一段「基准 16.67ms ± 1.5ms 抖动,3% 概率额外 +20ms 卡顿」的 300 帧序列,本机真实输出如下(直方图 2ms 一桶):
samples=300 avg=17.12 p95=18.12 max=36.57 fps=58.4
[14-16ms) 78
[16-18ms) 194
[18-20ms) 22
[34-36ms) 2
[36-38ms) 4
194 帧落在 16–18ms,说明绝大多数帧贴着预算线跑;avg = 17.12ms 对应 58.4 FPS,略低于 60——因为抖动把一部分帧推过了 16.67ms。真正危险的是那 6 帧 ≥34ms:它们对应前面「3% 概率 +20ms」的尖峰。平均值会骗人,长尾才是掉帧的元凶,这就是 FrameTimer 要同时给 p95 和 max 的原因。
这条分布完全由假时钟驱动:没有 requestAnimationFrame、没有真实等待,300 帧在 1ms 内跑完,PRNG 种子固定,结果可复现。下一节我们把「每帧喂一个 dt」这个模型,升级成固定步长累加器——因为真实 dt 抖成这样,直接喂给物理是会出事的。
阅读导航:上一节:目录 · 下一节:2.2 固定步长、累加器与渲染插值 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。