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 空间查询、分层与事件 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。