《游戏引擎实现原理》4.2 固定步长、插值与穿透

物理对步长极其敏感,所以引擎侧必须做三件事:用固定步长累加器推进逻辑,用上一态与当前态之间的插值喂给渲染,用扫掠 AABB 在位移超过自身体积时兜住高速穿透。本节复用内核的 FixedStepLoop,把这三件事写成可跑的 TypeScript,并用 vitest 真跑过。

4.2 固定步长、插值与穿透

4.1 定好了边界,现在往里填第一块内容:把逻辑以固定步长推进,再把结果平滑地交给渲染。物理对步长极其敏感——同一个初速度,步长从 1/60 抖到 1/50,落点就可能差出半米,刚体叠在一起时的接触顺序也会变。所以物理循环从来不用「这一帧真实过了多少秒」,而用「一个恒定的小步长」。这正是卷一 helio-kernel-02-2-fixed-step-accumulator 里那个累加器的用武之地。

本节要做三件事:复用内核的 FixedStepLoop 做固定步长积分;维护上一物理态与当前物理态,用 alpha 给渲染插值;当一帧位移大过自身体积时改走扫掠(CCD),否则高速物体会直接穿过薄墙。

固定步长与插值:两个独立的问题

先看为什么不能直接把 deltaSeconds 喂给积分。假设物体以 10 单位/秒向右飞,墙面厚度 0.2 单位。若某帧卡顿导致 delta = 0.1 秒,这一帧就移动了 1 个单位——远大于墙厚,离散的「当前位置是否重叠」检测根本看不到墙,物体直接穿过去。步长越大,能穿过的东西越薄;把步长钉死,穿透就只和「速度 × 步长」有关,可以用 CCD 精确兜住。

插值是另一件事:物理固定每 1/60 秒推进一次,而渲染帧率可能是 144Hz 或 30Hz。如果渲染直接读物理的当前态,物体就会以 1/60 的节奏「一跳一跳」。做法是保存上一步和这一步的位置,渲染时按 alpha = 累加器剩余 / 步长 在两个态之间线性插值,让位置连续。注意插值只影响显示,物理永远跑在确定的离散态上——这也是确定性回放能成立的前提。

可运行产物:把 FixedStepLoop 接到物理上

下面这个步进器把内核 FixedStepLoop 包了一层。advance 把真实帧时间喂进去,由 FixedStepLoop 决定跑几步、alpha 多少;每一步里我们更新速度(加重力)并移动位置。renderPosition 用 alpha 在 prev / curr 之间补间,物理本身只认 positionOf。

// file: src/subsys/physics/fixed-step-physics.ts
// tier: L1
// verify: npx vitest run src/subsys/physics/fixed-step-physics.test.ts
//
// helio 子系统:把内核的 FixedStepLoop 接到物理上。
// 逻辑以固定步长推进;渲染用 alpha 在 prev / curr 之间插值,避免抖动。
// 位移超过自身体积时改走扫掠(CCD),否则一帧能直接穿过薄墙。

import { FixedStepLoop, interpolate, type StepOutcome } from '../../core/fixed-step-loop.ts';
import type { Vec3 } from '../../core/vec3.ts';
import { aabbFromCenter, sweptAabbVsAabb, type Aabb } from './swept-aabb.ts';

export interface StepperConfig {
  readonly stepSeconds: number;
  readonly maxStepsPerFrame: number;
  readonly gravity: Vec3;
}

interface Body {
  readonly half: Vec3;
  readonly dynamic: boolean;
  velocity: Vec3;
  prev: Vec3;
  curr: Vec3;
}

export class PhysicsStepper {
  readonly #loop: FixedStepLoop;
  readonly #gravity: Vec3;
  readonly #bodies = new Map<number, Body>();
  readonly #statics: Aabb[] = [];
  #lastAlpha = 0;

  constructor(config: StepperConfig) {
    this.#loop = new FixedStepLoop({
      stepSeconds: config.stepSeconds,
      maxStepsPerFrame: config.maxStepsPerFrame,
    });
    this.#gravity = config.gravity;
  }

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

  addBody(id: number, position: Vec3, velocity: Vec3, half: Vec3, dynamic: boolean): void {
    this.#bodies.set(id, { half, dynamic, velocity, prev: position, curr: position });
  }

  addStatic(box: Aabb): void {
    this.#statics.push(box);
  }

  positionOf(id: number): Vec3 {
    return this.#bodies.get(id)!.curr;
  }

  /** 渲染位置:上一物理态与当前物理态按 alpha 补间。 */
  renderPosition(id: number): Vec3 {
    const b = this.#bodies.get(id)!;
    return [
      interpolate(b.prev[0], b.curr[0], this.#lastAlpha),
      interpolate(b.prev[1], b.curr[1], this.#lastAlpha),
      interpolate(b.prev[2], b.curr[2], this.#lastAlpha),
    ];
  }

  advance(deltaSeconds: number): StepOutcome {
    const outcome = this.#loop.advance(deltaSeconds, (dt) => this.#integrate(dt));
    this.#lastAlpha = outcome.alpha;
    return outcome;
  }

  #integrate(dt: number): void {
    for (const b of this.#bodies.values()) {
      b.prev = b.curr;
      if (!b.dynamic) continue;
      const v: Vec3 = [
        b.velocity[0] + this.#gravity[0] * dt,
        b.velocity[1] + this.#gravity[1] * dt,
        b.velocity[2] + this.#gravity[2] * dt,
      ];
      b.velocity = v;
      b.curr = this.#move(b.curr, [v[0] * dt, v[1] * dt, v[2] * dt], b.half);
    }
  }

  /** 位移大过自身体积时走扫掠,否则直接平移。 */
  #move(from: Vec3, delta: Vec3, half: Vec3): Vec3 {
    const span = Math.hypot(delta[0], delta[1], delta[2]);
    const minHalf = Math.min(half[0], half[1], half[2]);
    if (span <= minHalf) {
      return [from[0] + delta[0], from[1] + delta[1], from[2] + delta[2]];
    }
    let best = 1;
    const box = aabbFromCenter(from, half);
    for (const s of this.#statics) {
      const hit = sweptAabbVsAabb(box, delta, s);
      if (hit !== null && hit.t < best) best = hit.t;
    }
    const t = Math.max(0, best - 1e-4); // 停在接触前一点,别嵌进去
    return [from[0] + delta[0] * t, from[1] + delta[1] * t, from[2] + delta[2] * t];
  }
}

穿透与 CCD:一帧跨过整面墙

CCD 的触发点在引擎侧:位移超过自身体积时,就可能有东西被跨过去。这时不再问「现在重叠了吗」,而问「这一段的路上会先撞到谁」——这就是扫掠。sweptAabbVsAabb 用 slab 法求移动盒与静止盒的最早接触时间 t 与法线:每根轴算出一个进入 / 离开时间区间,三轴区间求交,最先收窄的那根轴就是碰撞面。

// file: src/subsys/physics/swept-aabb.ts
// tier: L1
// verify: npx vitest run src/subsys/physics/fixed-step-physics.test.ts
//
// helio 子系统:扫掠 AABB(连续碰撞检测的核心原语)。
// 给定「当前包围盒 + 本步位移」,求最先撞上静止盒的时间 t 与法线。
// 高速物体一帧跨过整面墙时,离散检测会直接穿过去,靠它兜住。

import type { Vec3 } from '../../core/vec3.ts';

export interface Aabb {
  readonly min: Vec3;
  readonly max: Vec3;
}

export interface SweepHit {
  /** 命中时间,落在 [0, 1]。 */
  readonly t: number;
  readonly normal: Vec3;
}

/** 由中心与半长构造 AABB。 */
export function aabbFromCenter(center: Vec3, half: Vec3): Aabb {
  return {
    min: [center[0] - half[0], center[1] - half[1], center[2] - half[2]],
    max: [center[0] + half[0], center[1] + half[1], center[2] + half[2]],
  };
}

/**
 * mover 沿 delta 平移,与静止盒 target 做扫掠。
 * 用 slab 法:每轴求出进入 / 离开时间区间,取交集;交点即最先接触时刻。
 */
export function sweptAabbVsAabb(mover: Aabb, delta: Vec3, target: Aabb): SweepHit | null {
  let tEnter = 0;
  let tExit = 1;
  let axis = -1;
  let sign = 1;
  for (let i = 0; i < 3; i++) {
    const d = delta[i]!;
    const mn = mover.min[i]!;
    const mx = mover.max[i]!;
    if (d === 0) {
      // 该轴不动:不重叠就永远碰不上
      if (mx < target.min[i]! || mn > target.max[i]!) return null;
      continue;
    }
    const inv = 1 / d;
    let t1 = (target.min[i]! - mx) * inv;
    let t2 = (target.max[i]! - mn) * inv;
    let s = -1;
    if (t1 > t2) {
      const tmp = t1;
      t1 = t2;
      t2 = tmp;
      s = 1;
    }
    if (t1 > tEnter) {
      tEnter = t1;
      axis = i;
      sign = s;
    }
    if (t2 < tExit) tExit = t2;
    if (tEnter > tExit) return null;
  }
  if (axis === -1) return null; // 起点已经重叠,交给离散阶段处理
  const normal: Vec3 = axis === 0 ? [sign, 0, 0] : axis === 1 ? [0, sign, 0] : [0, 0, sign];
  return { t: tEnter, normal };
}

#move 里的逻辑:位移不超过自身体积时直接平移;超过时对所有静态盒做扫掠,取最小 t,停在接触前一点点(减一个 epsilon,避免下一帧起点就嵌在墙里)。注意这里只做「动态 vs 静态」的拦截——动态 vs 动态的 CCD 需要预测对方轨迹,属于完整求解器的范畴,本章不做。测试覆盖了固定步长计数、渲染插值,以及「高速穿墙被拦下」与「无遮挡时不误判」两种情况。

// file: src/subsys/physics/fixed-step-physics.test.ts
// tier: L1
// verify: npx vitest run src/subsys/physics/fixed-step-physics.test.ts
//
// 断言:固定步长计数与 alpha、渲染插值、CCD 拦截高速穿透。

import { describe, expect, it } from 'vitest';
import { PhysicsStepper } from './fixed-step-physics.ts';
import { aabbFromCenter } from './swept-aabb.ts';

const STEP = 1 / 60;
const NO_GRAVITY: readonly [number, number, number] = [0, 0, 0];

function stepper(): PhysicsStepper {
  return new PhysicsStepper({ stepSeconds: STEP, maxStepsPerFrame: 4, gravity: NO_GRAVITY });
}

describe('PhysicsStepper', () => {
  it('固定步长:喂 1 步跑 1 步,喂 2.5 步跑 2 步 + alpha 0.5', () => {
    const a = stepper();
    a.addBody(1, [0, 0, 0], [0, 0, 0], [0.5, 0.5, 0.5], true);
    const one = a.advance(STEP);
    expect(one.steps).toBe(1);
    expect(one.alpha).toBeCloseTo(0, 9);

    const b = stepper();
    b.addBody(1, [0, 0, 0], [0, 0, 0], [0.5, 0.5, 0.5], true);
    const two = b.advance(2.5 * STEP);
    expect(two.steps).toBe(2);
    expect(two.alpha).toBeCloseTo(0.5, 9);
  });

  it('渲染插值落在 prev 与 curr 之间', () => {
    const s = stepper();
    s.addBody(1, [0, 0, 0], [30, 0, 0], [0.5, 0.5, 0.5], true); // 每步走 0.5
    s.advance(1.5 * STEP);
    expect(s.positionOf(1)[0]).toBeCloseTo(0.5, 9); // 物理态已在 0.5
    expect(s.renderPosition(1)[0]).toBeCloseTo(0.25, 9); // 渲染补间到 0.25
  });

  it('CCD:一帧位移 10 单位被薄墙拦在墙前', () => {
    const s = stepper();
    s.addBody(1, [0, 0, 0], [600, 0, 0], [0.5, 0.5, 0.5], true); // delta = 10
    s.addStatic(aabbFromCenter([5, 0, 0], [0.5, 0.5, 0.5])); // 墙面在 x=4.5
    s.advance(STEP);
    const x = s.positionOf(1)[0];
    expect(x).toBeLessThan(4.5); // 没穿到墙里
    expect(x).toBeGreaterThan(3.9); // 中心停在 4.0(墙面 4.5 减自身半长 0.5)
  });

  it('无遮挡时高速位移照常推进(不误判穿透)', () => {
    const s = stepper();
    s.addBody(1, [0, 0, 0], [600, 0, 0], [0.5, 0.5, 0.5], true);
    s.advance(STEP);
    expect(s.positionOf(1)[0]).toBeGreaterThan(9.9);
  });
});

本机实跑(npx vitest run src/subsys/physics/fixed-step-physics.test.ts):

 ✓ src/subsys/physics/fixed-step-physics.test.ts > PhysicsStepper > 固定步长:喂 1 步跑 1 步,喂 2.5 步跑 2 步 + alpha 0.5 1ms
 ✓ src/subsys/physics/fixed-step-physics.test.ts > PhysicsStepper > 渲染插值落在 prev 与 curr 之间 0ms
 ✓ src/subsys/physics/fixed-step-physics.test.ts > PhysicsStepper > CCD:一帧位移 10 单位被薄墙拦在墙前 0ms
 ✓ src/subsys/physics/fixed-step-physics.test.ts > PhysicsStepper > 无遮挡时高速位移照常推进(不误判穿透) 0ms
 Test Files  1 passed (1)
      Tests  4 passed (4)

固定步长、插值、CCD 三件事都在引擎侧闭环了,物理库只负责在固定步长里解算。下一节处理最后一组边界职责:空间查询、碰撞分层与事件分发。

阅读导航:上一节:4.1 引擎与物理库的边界 · 下一节:4.3 空间查询、分层与事件 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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