《游戏引擎实现原理》2.3 时间缩放、暂停与慢动作

固定步长让物理与渲染解耦,但游戏逻辑时间本身还需要被缩放。本节实现 TimeService:用 timeScale 做慢动作、paused 做暂停,并把逻辑 delta 与 UI delta 拆成两套,真跑出「暂停时逻辑步为 0 而 UI 动画仍推进」的结果。

2.3 时间缩放、暂停与慢动作

固定步长解决了「物理与渲染解耦」,但还有一个维度没处理:游戏逻辑时间本身可以被缩放。子弹时间、慢动作回放、暂停菜单、二倍速建造——这些都是对「逻辑时间」的操作,而不是对渲染帧率的操作。把它们做进主循环,需要一层专门的时间翻译。

本节实现 TimeService,把「真实时间」翻译成「逻辑时间」:timeScale 控制缩放,paused 冻结逻辑。关键约定是逻辑与 UI 用两套 delta——这决定了暂停菜单能不能一边冻住游戏世界、一边继续转圈。

为什么要把两套时间分开

如果暂停时把 delta 直接置 0,那么暂停菜单的动画、按钮的呼吸光效、加载转圈也会跟着冻住——因为它们和游戏逻辑共用同一个 dt。画面会显得「死掉」。正确做法是区分两种时间:

  • unscaledDelta:真实经过的秒数。UI、菜单、过场动画用它,不受 timeScale 与 paused 影响。
  • scaledDelta:paused ? 0 : unscaledDelta × timeScale。游戏世界用它。

这样一来,三种常见需求就变成同一个机制的不同参数:

需求设置scaledDelta
正常timeScale = 1dt
慢动作timeScale = 0.250.25 × dt
暂停paused = true0
加速timeScale = 22 × dt

注意暂停用的是独立的 paused 标志,而不是把 timeScale 设成 0。原因是:暂停时通常还要记住「暂停前的 timeScale」,恢复后原样还原;如果拿 timeScale = 0 当暂停,就没地方存那个原值了。

可运行产物:TimeService

TimeService 只做一件事:把一帧的真实 delta 翻译成逻辑 delta,并分别累积两套总时间。它不碰物理、不碰渲染,因此可以独立测试。

// file: src/core/time-service.ts
// tier: L1
// verify: npx vitest run src/core/time-service.test.ts
//
// helio 内核:时间服务。
// 把「真实时间」翻译成「游戏逻辑时间」——timeScale 缩放、paused 冻结。
// 关键约定:UI/菜单动画吃 unscaledDelta(不受暂停影响),逻辑吃 scaledDelta。

export interface TimeStep {
  /** 未缩放的真实 delta(秒)。UI、菜单、加载动画用它。 */
  unscaledDelta: number;
  /** 受 timeScale 与 paused 影响的逻辑 delta(秒)。游戏世界用它。 */
  scaledDelta: number;
}

export class TimeService {
  #timeScale = 1;
  #paused = false;
  #unscaledElapsed = 0;
  #scaledElapsed = 0;

  get timeScale(): number {
    return this.#timeScale;
  }

  /** 设置缩放。0 表示冻结逻辑,1 为正常,0.25 为慢动作。 */
  set timeScale(value: number) {
    if (!(value >= 0)) {
      throw new RangeError(`timeScale 必须非负,收到 ${value}`);
    }
    this.#timeScale = value;
  }

  get paused(): boolean {
    return this.#paused;
  }

  set paused(value: boolean) {
    this.#paused = value;
  }

  /** 真实经过的总秒数(不受缩放/暂停影响)。 */
  get unscaledElapsed(): number {
    return this.#unscaledElapsed;
  }

  /** 逻辑经过的总秒数(缩放后、扣除暂停)。 */
  get scaledElapsed(): number {
    return this.#scaledElapsed;
  }

  /** 推进一帧。realDeltaSeconds 是真实经过的秒数。 */
  update(realDeltaSeconds: number): TimeStep {
    if (!(realDeltaSeconds >= 0)) {
      throw new RangeError(`realDeltaSeconds 必须非负,收到 ${realDeltaSeconds}`);
    }
    this.#unscaledElapsed += realDeltaSeconds;
    const scaledDelta = this.#paused ? 0 : realDeltaSeconds * this.#timeScale;
    this.#scaledElapsed += scaledDelta;
    return { unscaledDelta: realDeltaSeconds, scaledDelta };
  }
}

验证命令:npx vitest run src/core/time-service.test.ts。

它如何与上一节的 FixedStepLoop 协作?把 scaledDelta 喂给循环,unscaledDelta 喂给 UI 动画器即可:

每帧:
  const { unscaledDelta, scaledDelta } = time.update(realDt);
  loop.advance(scaledDelta);        // 逻辑:暂停时自动为 0,一步都不跑
  uiAnimator.advance(unscaledDelta); // UI:永远按真实时间走

整个测试文件如下,覆盖了默认倍速、慢动作、暂停、恢复四组场景,也是本节所有数字的来源。

// file: src/core/time-service.test.ts
// tier: L1
// verify: npx vitest run src/core/time-service.test.ts
//
// 断言:慢动作按 1/timeScale 累积、暂停时逻辑冻结而 UI 照常、恢复后无缝续上。

import { describe, expect, it } from 'vitest';
import { FixedStepLoop } from './fixed-step-loop.ts';
import { TimeService } from './time-service.ts';

const STEP = 1 / 60;

describe('TimeService', () => {
  it('默认 1 倍速,缩放后逻辑 delta 正确', () => {
    const t = new TimeService();
    const s = t.update(0.016);
    expect(s.unscaledDelta).toBeCloseTo(0.016, 6);
    expect(s.scaledDelta).toBeCloseTo(0.016, 6);
  });

  it('慢动作 0.25 倍:逻辑时间按 1/4 累积', () => {
    const t = new TimeService();
    t.timeScale = 0.25;
    for (let i = 0; i < 60; i++) t.update(1 / 60);
    expect(t.unscaledElapsed).toBeCloseTo(1, 6);
    expect(t.scaledElapsed).toBeCloseTo(0.25, 6);
  });

  it('暂停时逻辑 delta 为 0,但真实时间照常累积', () => {
    const t = new TimeService();
    t.paused = true;
    const s = t.update(1 / 60);
    expect(s.scaledDelta).toBe(0);
    expect(s.unscaledDelta).toBeCloseTo(1 / 60, 6);
    expect(t.scaledElapsed).toBe(0);
    expect(t.unscaledElapsed).toBeCloseTo(1 / 60, 6);
  });

  it('负 delta 抛错', () => {
    const t = new TimeService();
    expect(() => t.update(-0.1)).toThrow(RangeError);
  });

  it('暂停:逻辑步为 0,UI 动画仍推进', () => {
    const t = new TimeService();
    const loop = new FixedStepLoop({ stepSeconds: STEP, maxStepsPerFrame: 5 });
    let logicSteps = 0;
    let uiProgress = 0; // UI 进度按 unscaledDelta 推进

    for (let i = 0; i < 60; i++) {
      const s = t.update(1 / 60);
      logicSteps += loop.advance(s.scaledDelta).steps;
      uiProgress += s.unscaledDelta;
    }
    expect(logicSteps).toBeGreaterThanOrEqual(59);

    const stepsBeforePause = logicSteps;
    const uiBeforePause = uiProgress;
    t.paused = true;
    for (let i = 0; i < 30; i++) {
      const s = t.update(1 / 60);
      logicSteps += loop.advance(s.scaledDelta).steps;
      uiProgress += s.unscaledDelta;
    }
    console.log('暂停前后 逻辑步:', stepsBeforePause, '→', logicSteps, '| UI 进度:', uiBeforePause.toFixed(3), '→', uiProgress.toFixed(3));
    expect(logicSteps).toBe(stepsBeforePause); // 逻辑完全冻结
    expect(uiProgress - uiBeforePause).toBeCloseTo(0.5, 6); // UI 前进 0.5 秒
  });

  it('恢复后逻辑时间从暂停点继续', () => {
    const t = new TimeService();
    for (let i = 0; i < 30; i++) t.update(1 / 60);
    const before = t.scaledElapsed;
    t.paused = true;
    for (let i = 0; i < 60; i++) t.update(1 / 60);
    expect(t.scaledElapsed).toBeCloseTo(before, 6);
    t.paused = false;
    for (let i = 0; i < 30; i++) t.update(1 / 60);
    expect(t.scaledElapsed - before).toBeCloseTo(0.5, 6);
  });
});

6 个用例全部通过(本机 Test Files 1 passed、Tests 6 passed)。

暂停时逻辑冻结、UI 照常

把 TimeService 接到 FixedStepLoop 上跑一遍:先以正常速度跑 60 帧,再暂停 30 帧。本机真实输出:

暂停前后 逻辑步: 60 → 60 | UI 进度: 1.000 → 1.500

暂停的 30 帧里,逻辑步数一个都没涨(60 → 60),因为 scaledDelta 恒为 0,FixedStepLoop 的累加器压根攒不满一步;而 UI 进度从 1.000 推进到 1.500,正好多了 30 / 60 = 0.5 秒。这就是「暂停菜单里转圈动画照转、游戏世界纹丝不动」在代码层面的实现。

慢动作走的是同一条路径:timeScale = 0.25 时,真实跑满 1 秒,scaledElapsed 只累积到 0.25 秒(测试断言到 1e-6)。而恢复时逻辑时间从暂停点无缝续上——不会因为暂停期间累积了真实时间而「瞬移」,因为暂停期间 scaledElapsed 根本没动。

一个容易忽略的细节:暂停期间仍然要照常调用 loop.advance(scaledDelta),只是 scaledDelta 为 0。不要「暂停了就不调用循环」——那样累加器里的零头会一直留着,恢复瞬间可能出现一次多余的补步。让循环照常跑、喂 0,才是最干净的。

时间缩放的三种粒度

TimeService 是全局的,但真实项目里「时间」往往要按层级分开控制。一个常见的分层是:

层级控制对象典型用途
全局时间整个世界暂停菜单、过场动画
逻辑组时间一类系统(如敌人 AI)只冻结敌人、玩家照常动
单对象时间某个实体受击顿帧、局部子弹时间

三种粒度的实现方式完全一致——都是「一份累加器 + 一个缩放系数」,只是挂载的位置不同。全局那一层就是本节实现的 TimeService;逻辑组那一层通常是给 ECS 的某个查询打一个「时间通道」标签;单对象那一层则把缩放系数直接存在组件里。理解了全局版的实现,剩下两层只是把它复制到更细的粒度上。

需要强调的是:无论哪一层,UI 永远吃未缩放的 unscaledDelta。否则「敌人 AI 被冻结」时,连敌人头顶的血条动画也会一起卡住,观感会很怪。

常见坑与边界

时间系统看起来只有几行代码,但踩坑点不少。下面这张表是实践中最常出错的四类:

场景错误做法正确做法
暂停菜单动画也冻住UI 也用 scaledDeltaUI 用 unscaledDelta,不受暂停影响
用 timeScale = 0 当暂停恢复时没地方存原倍速独立的 paused 标志,timeScale 原样保留
用 timeScale 补偿掉帧逻辑时间忽快忽慢,物理失稳掉帧交给 maxStepsPerFrame 钳制,别动时间
慢动作时音效原速音画不同步音频播放速率随 timeScale 一起缩放

其中第二条最容易忽视。如果拿 timeScale = 0 表示暂停,那么「暂停前是 2 倍速」这个信息就被覆盖了,恢复时只能猜一个默认值。用独立的 paused 标志,timeScale 保持不变,恢复就是一行 paused = false。

第三条则是设计原则:timeScale 是游戏设计参数(子弹时间、慢动作回放),不是性能兜底手段。用放大 timeScale 去「追回」掉帧的时间,只会让逻辑时间剧烈波动,物理反而更容易发散。掉帧该由固定步长循环的 maxStepsPerFrame 处理,这与上一节的结论一致。

第一条和第四条都关乎「时间的两套账」。凡是不参与游戏规则的东西——菜单动画、加载转圈、音效播放——都应该吃未缩放的时间,这样它们才能在暂停/慢动作时保持正常的观感。反过来,凡是影响游戏规则的东西——角色移动、技能冷却、AI 决策——必须吃缩放后的时间,否则慢动作下角色还能以原速跑动,游戏逻辑就穿帮了。判断标准很简单:这件事在暂停时应该继续发生吗? 应该 → 用 unscaledDelta;不应该 → 用 scaledDelta。

小结

至此第 2 章的时间系统收口:FrameTimer 负责测量,FixedStepLoop 负责解耦,TimeService 负责缩放与冻结。三者都只依赖注入的 Clock,全部可在 Node 里确定性复现。

本章贯穿的三条不变量,值得抄进任何引擎的时间模块:

  • alpha 恒落在 [0, 1)——插值绝不允许外推。
  • 单帧物理步数恒 ≤ maxStepsPerFrame——帧率必须有界。
  • 暂停时 scaledDelta == 0 而 unscaledDelta > 0——逻辑冻结、UI 照常。

下一章进入子系统层,看这些零件如何按顺序启动与关闭。

阅读导航:上一节:2.2 固定步长、累加器与渲染插值 · 下一节:3.1 子系统注册、启动顺序与逆序关闭 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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