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 计算 │
└───────────────────────────────┴───────────────────────────────┘
关键约定有三条:
- 引擎侧只认识描述,不认识刚体。 引擎里传递的是
BodyDesc、BodyId,绝不是b2Body*或RAPIER.RigidBody这类具体类型。这条线一旦破了,换后端就要改遍全工程。 - 同步是单向的、增量的。 静态物体由引擎推给物理,动态物体的解算结果由物理拉回引擎;每一帧只处理「变过的」实体,用版本号判断。
- 求解器是黑盒。 引擎不关心它用迭代还是直接法、跑几步,只关心「喂进固定步长,吐出新的变换与接触」。
可运行产物:边界类型与碰撞体同步
边界要能编译、能跑。第一半是类型定义:用判别联合描述碰撞体形状,用接口钉死后端契约。
// 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 固定步长、插值与穿透 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。