《游戏引擎实现原理》2.1 帧、时间与帧预算

帧预算决定了引擎能否稳住 60Hz。本节把一帧拆成逻辑、提交、GPU 与呈现四个阶段,用 text 时间线标出 16.67ms 的预算边界,再从零实现一个可注入假时钟的 FrameTimer,在 Node 里真跑出 300 帧的耗时分布、百分位与 FPS。

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 时间就越少:

刷新率每帧预算备注
60Hz16.67ms上世代主机的目标
90Hz11.11msVR 的最低门槛
120Hz8.33ms高刷屏的常见档
144Hz6.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 固定步长、累加器与渲染插值 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径