《游戏引擎实现原理》4.1 引擎与物理库的边界

本节不写求解器,只把引擎与物理库之间那条线画出来:用判别联合描述碰撞体形状,用一个 PhysicsBackend 接口把 Jolt、Rapier 或自研后端挡在下面,再用 ColliderSync 只把版本号变过的实体推给物理侧。全部纯 TypeScript,可在 Node 直跑并跑通 vitest。

4.1 引擎与物理库的边界

上一章我们把动画系统接进了引擎,这一章轮到物理。很多人一提「做物理」就想到解方程、写求解器、调约束——那不是引擎该干的事,也不是这一章要干的事。工业引擎几乎从不自己写刚体求解器:Unity 用 PhysX、Unreal 用 Chaos、Godot 用 Jolt、Bevy 用 Rapier。引擎真正要做、也最难做对的一件事,是在引擎与物理库之间画一条线:线的一侧是引擎的世界(实体、变换、碰撞体描述、事件),另一侧是物理库(解算、接触、约束)。这条线画好了,你随时能把 PhysX 换成 Jolt 而不动游戏逻辑一行;画歪了,物理库就会像藤蔓一样爬满整个引擎。

这一章只做这条线。我们要实现四件事:抽象接口(本节)、固定步长积分与渲染插值(4.2)、穿透处理(CCD 的引擎侧接入,也在 4.2)、空间查询与分层事件(4.3)。不写求解器——本书的目标不是造一个物理引擎,而是学会把别人的物理引擎安全地嵌进自己的引擎里。这条「接口隔离具体实现」的思路,和卷一 helio-kernel-01-1-layers-and-boundaries 里讲的「内核不认识渲染层」是同一条铁律。

本章与站内既有内容的分工

「物理」是站内既有内容里出现频率很高的词,先把边界讲清楚,避免重复:

既有文章它负责什么本节只做什么
/posts/defold/从子系统视角讲物理:刚体、碰撞形状、触发器的用法从引擎实现者视角讲「引擎与物理库之间那条线怎么画」
/game-engine-architecture-ecs/讲 ECS 引擎骨架「长什么样」讲物理组件如何与引擎变换同步,不重推 ECS 原理
/defold-ecs-architecture/从现成引擎的子系统视角讲 ECS把物理当作一条边界,而不是一个模块

一句话:别人讲「物理怎么用」,本节讲「物理库怎么接进来、接口怎么划、同步怎么做」。

边界的两侧:谁负责什么

先把这个边界钉死。下面这张表是本章的总纲,后面每一节都在这两列里填东西:

┌───────────────────────────────┬───────────────────────────────┐
│  引擎侧(helio 负责)          │  物理后端(Jolt / Rapier 负责)│
├───────────────────────────────┼───────────────────────────────┤
│  实体 → 碰撞体描述(BodyDesc) │  从描述造出内部刚体            │
│  变换的版本号与脏标记          │  积分、约束求解、接触生成      │
│  固定步长与渲染插值(4.2)     │  提供 raycast / overlap(4.3) │
│  分层掩码与事件分发(4.3)     │  报告「当前哪些对在接触」      │
│  CCD 的触发时机(4.2)         │  实际扫掠 / TOI 计算           │
└───────────────────────────────┴───────────────────────────────┘

关键约定有三条:

  1. 引擎侧只认识描述,不认识刚体。 引擎里传递的是 BodyDesc、BodyId,绝不是 b2Body* 或 RAPIER.RigidBody 这类具体类型。这条线一旦破了,换后端就要改遍全工程。
  2. 同步是单向的、增量的。 静态物体由引擎推给物理,动态物体的解算结果由物理拉回引擎;每一帧只处理「变过的」实体,用版本号判断。
  3. 求解器是黑盒。 引擎不关心它用迭代还是直接法、跑几步,只关心「喂进固定步长,吐出新的变换与接触」。

可运行产物:边界类型与碰撞体同步

边界要能编译、能跑。第一半是类型定义:用判别联合描述碰撞体形状,用接口钉死后端契约。

// file: src/subsys/physics/physics-types.ts
// tier: L1
// verify: npx vitest run src/subsys/physics/transform-sync.test.ts
//
// helio 子系统:物理层与引擎之间的边界类型。
// 引擎只认识这里的 ColliderShape / BodyDesc / PhysicsBackend;
// 换 Jolt、Rapier 还是自研后端,上层调用代码一行都不用改。

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

/** 带品牌的刚体句柄,防止裸 number 混进 API。 */
export type BodyId = number & { readonly __brand: 'BodyId' };

/** 碰撞体形状:判别联合,kind 是标签。新增形状时编译器会逼你补全所有分支。 */
export type ColliderShape =
  | { readonly kind: 'sphere'; readonly radius: number }
  | { readonly kind: 'aabb'; readonly half: Vec3 };

/** 创建一个刚体所需的全部信息:引擎侧组装,物理后端消费。 */
export interface BodyDesc {
  readonly shape: ColliderShape;
  readonly position: Vec3;
  readonly dynamic: boolean;
  /** 碰撞分层:位序号 0..31,配合 4.3 的掩码使用。 */
  readonly layer: number;
}

/** 引擎与物理库之间的唯一契约。 */
export interface PhysicsBackend {
  createBody(desc: BodyDesc): BodyId;
  destroyBody(id: BodyId): void;
  setPosition(id: BodyId, position: Vec3): void;
  getPosition(id: BodyId): Vec3;
  step(fixedDelta: number): void;
}

/** 引擎侧变换:版本号只在真正变化时自增。 */
export interface EngineTransform {
  readonly position: Vec3;
  readonly version: number;
}

ColliderShape 用 kind 做标签的判别联合,而不是 enum——TS 的 erasable syntax 禁止 enum,而判别联合在 switch 里同样能做穷尽检查,新增形状时编译器会直接报「分支未覆盖」。

第二半是引擎侧的碰撞体同步,也就是本章最容易出 bug 的地方:推多了每帧白跑一遍全部实体,推少了模型与碰撞体错位。

// file: src/subsys/physics/transform-sync.ts
// tier: L1
// verify: npx vitest run src/subsys/physics/transform-sync.test.ts
//
// helio 子系统:引擎变换 → 物理后端的单向同步。
// 每帧只把「版本号变过」的实体推给后端;动态刚体再把解算结果拉回引擎。
// 这是边界上最容易出 bug 的地方:推多了浪费,推少了穿模。

import type { Vec3 } from '../../core/vec3.ts';
import type { BodyDesc, BodyId, EngineTransform, PhysicsBackend } from './physics-types.ts';

interface Binding {
  readonly body: BodyId;
  lastVersion: number;
}

export class ColliderSync {
  readonly #backend: PhysicsBackend;
  readonly #bindings = new Map<number, Binding>();

  constructor(backend: PhysicsBackend) {
    this.#backend = backend;
  }

  get boundCount(): number {
    return this.#bindings.size;
  }

  /** 给实体挂一个碰撞体,并用当前变换初始化后端。 */
  attach(entity: number, transform: EngineTransform, desc: Omit<BodyDesc, 'position'>): BodyId {
    const body = this.#backend.createBody({ ...desc, position: transform.position });
    this.#bindings.set(entity, { body, lastVersion: transform.version });
    return body;
  }

  detach(entity: number): boolean {
    const b = this.#bindings.get(entity);
    if (b === undefined) return false;
    this.#backend.destroyBody(b.body);
    this.#bindings.delete(entity);
    return true;
  }

  /** 把变过的引擎变换推给后端,返回本帧真正推送的实体数。 */
  push(transforms: ReadonlyMap<number, EngineTransform>): number {
    let pushed = 0;
    for (const [entity, b] of this.#bindings) {
      const t = transforms.get(entity);
      if (t === undefined || t.version === b.lastVersion) continue;
      this.#backend.setPosition(b.body, t.position);
      b.lastVersion = t.version;
      pushed++;
    }
    return pushed;
  }

  /** 把动态刚体的解算结果拉回引擎变换,返回写回的实体数。 */
  pull(transforms: Map<number, EngineTransform>): number {
    let pulled = 0;
    for (const [entity, b] of this.#bindings) {
      const t = transforms.get(entity);
      if (t === undefined) continue;
      const p = this.#backend.getPosition(b.body);
      if (sameVec(p, t.position)) continue;
      const version = t.version + 1;
      transforms.set(entity, { position: p, version });
      b.lastVersion = version;
      pulled++;
    }
    return pulled;
  }
}

function sameVec(a: Vec3, b: Vec3): boolean {
  return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
}

push 只遍历已绑定的实体,用版本号比较决定是否真的调 setPosition;pull 反过来把动态刚体的新位置写回引擎变换,并推进版本号,这样下一帧 push 不会把它又推回去。测试用一个只记录位置、不做任何解算的 FakeBackend 来验证——它恰好证明了「接口就是全部契约」:只要实现这五个方法,引擎侧就能工作。

// file: src/subsys/physics/transform-sync.test.ts
// tier: L1
// verify: npx vitest run src/subsys/physics/transform-sync.test.ts
//
// 断言:只有版本号变过的实体被推送;pull 把解算结果写回并推进版本号。

import { describe, expect, it } from 'vitest';
import { ColliderSync } from './transform-sync.ts';
import type { BodyDesc, BodyId, PhysicsBackend } from './physics-types.ts';
import type { Vec3 } from '../../core/vec3.ts';

/** 测试用后端:只记录位置、不做解算——证明边界接口本身就是全部契约。 */
class FakeBackend implements PhysicsBackend {
  #next = 1;
  readonly positions = new Map<BodyId, Vec3>();
  readonly created: BodyDesc[] = [];
  setPositionCalls = 0;

  createBody(desc: BodyDesc): BodyId {
    const id = this.#next++ as BodyId;
    this.positions.set(id, desc.position);
    this.created.push(desc);
    return id;
  }
  destroyBody(id: BodyId): void {
    this.positions.delete(id);
  }
  setPosition(id: BodyId, p: Vec3): void {
    this.setPositionCalls++;
    this.positions.set(id, p);
  }
  getPosition(id: BodyId): Vec3 {
    return this.positions.get(id)!;
  }
  step(): void {
    /* 无解算 */
  }
}

const sphere = { shape: { kind: 'sphere', radius: 0.5 } as const, dynamic: true, layer: 1 };

describe('ColliderSync', () => {
  it('只推送版本号变化的实体', () => {
    const backend = new FakeBackend();
    const sync = new ColliderSync(backend);
    const world = new Map([
      [1, { position: [0, 0, 0] as Vec3, version: 1 }],
      [2, { position: [1, 0, 0] as Vec3, version: 1 }],
      [3, { position: [2, 0, 0] as Vec3, version: 1 }],
    ]);
    sync.attach(1, world.get(1)!, sphere);
    const b2 = sync.attach(2, world.get(2)!, sphere);
    sync.attach(3, world.get(3)!, { ...sphere, dynamic: false, layer: 2 });

    expect(sync.boundCount).toBe(3);
    expect(sync.push(world)).toBe(0); // 没人动过

    world.set(2, { position: [1, 0, 5], version: 2 }); // 只有 2 变了
    expect(sync.push(world)).toBe(1);
    expect(backend.setPositionCalls).toBe(1);
    expect(backend.getPosition(b2)).toEqual([1, 0, 5]);
  });

  it('pull 把后端位置写回引擎并推进版本号', () => {
    const backend = new FakeBackend();
    const sync = new ColliderSync(backend);
    const world = new Map([[7, { position: [0, 0, 0] as Vec3, version: 1 }]]);
    const body = sync.attach(7, world.get(7)!, sphere);

    backend.setPosition(body, [0, -3, 0]); // 模拟一次解算结果
    expect(sync.pull(world)).toBe(1);
    expect(world.get(7)!.position).toEqual([0, -3, 0]);
    expect(world.get(7)!.version).toBe(2);
    expect(sync.push(world)).toBe(0); // 刚拉回来的不该再推一遍

    expect(sync.detach(7)).toBe(true);
    expect(sync.detach(7)).toBe(false);
    expect(sync.boundCount).toBe(0);
  });
});

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

 ✓ src/subsys/physics/transform-sync.test.ts > ColliderSync > 只推送版本号变化的实体 2ms
 ✓ src/subsys/physics/transform-sync.test.ts > ColliderSync > pull 把后端位置写回引擎并推进版本号 0ms
 Test Files  1 passed (1)
      Tests  2 passed (2)

有了这条边界,npx tsc --noEmit 保证引擎侧不会漏出具体后端类型,npx vitest 保证增量同步真的只推变过的实体。下一节我们把内核的固定步长循环接到物理上,并处理高速穿透。

阅读导航:上一节:回到目录 · 下一节:4.2 固定步长、插值与穿透 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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