Skip to main content

Chapter 66: Rigid Body Simulation

刚体模拟要解决的主问题,是把一组带质量、形状和材质的物体,在离散帧时间内推进到下一组可渲染的 transform。读完本章后,读者应能追踪一个球从空中落到斜坡、撞到箱体、触发区域事件并回到渲染帧的完整路径:力改变速度,积分器改变位置,碰撞检测生成接触,约束求解器写回冲量,渲染系统读取经过插值的姿态。

本章的贯穿场景是一帧实时演示:一个动态球沿重力方向下落,接触斜坡后反弹并产生切向滑动,随后撞到几个箱体,最后进入一个触发器区域。这个场景足够小,可以在纸面上复盘;它也覆盖刚体模拟最常见的工程问题:接触抖动、穿透、反弹过强、箱体堆叠发散、物理帧和渲染帧不同步。

刚体(rigid body)在图形工程里表示形状内部任意两点之间的相对距离保持固定的物体。这个假设把变形问题移出当前模型,使物体状态可以用位置、旋转、线速度和角速度描述。现代实时引擎里的刚体系统通常采用近似求解,目标是稳定、可控、可调试的视觉结果。Box2D 3.1.0 文档把它定义为游戏用二维刚体模拟库,PhysX 5.3.1 文档也把线速度、角速度、质量属性、力、重力、摩擦、恢复系数、sleeping 和 solver iteration 放在刚体动力学的核心路径中。

因此,本章不把刚体模拟写成纯物理课。每个数学量都要落回一帧中的对象:body state、collision shape、contact manifold、impulse、solver iteration、physics step、render transform 和 debug draw。

66.1 刚体动力学基本定律与数学模型

刚体状态的最小集合由平移和旋转两部分组成。平移部分包含位置 x、线速度 v、质量 m 和合力 F;旋转部分包含姿态 q 或旋转矩阵、角速度 ω、惯性张量 I 和力矩 τ。在贯穿场景中,球下落时主要观察 vx,球碰到斜坡后还要观察 ω,因为接触点的切向冲量会让球产生旋转。

质量决定同样的力产生多少线性加速度。公式 $F = m a$ 在引擎里对应“本 step 内外力如何改写速度”。如果球的质量是 2kg,重力加速度是 -9.8m/s²,重力对应的力是 -19.6N。积分器通常先把加速度折算成速度增量,再把速度折算成位置增量。用半隐式 Euler 表达,就是 v += a * dt,然后 x += v * dt。它的视觉含义很直接:更大的 dt 会让每步位移变长,也会让接触求解承担更大的修正压力。

惯性张量决定刚体抵抗旋转变化的能力。二维示例中可以把它看作一个标量转动惯量,三维中它通常是局部坐标下的对角张量,并随姿态变换到世界空间。球、箱体和细长杆在受到相同力矩时会产生不同角加速度,差异来自形状质量分布。PhysX 文档把 mass、moment of inertia 和 center of mass frame 放在动态 actor 的质量属性中,这个划分很适合工程实现:质量控制平移,惯性控制旋转,质心位置控制力作用点造成的力矩。

力和冲量需要分开理解。力是跨时间积累的输入,适合重力、风、马达、持续推力;冲量是瞬时速度修正,适合碰撞响应。对线速度而言,冲量 J 的结果是 Δv = J / m。对角速度而言,接触点到质心的向量 r 会把冲量转成角冲量,核心关系是 Δω = I^{-1} (r × J)。球撞斜坡时,法向冲量把球从斜坡面推开,切向冲量模拟摩擦,并通过 r × J 改变角速度。

积分器负责把连续时间方程放进离散 step。实时渲染中常见做法是固定物理步长,例如 1/60s,并在渲染帧内用 accumulator 追赶物理世界。固定步长让接触求解、摩擦、反弹和 sleeping 阈值处在稳定尺度中。可变步长会让同一套参数在不同帧率下产生不同效果,表现为箱体堆叠抖动、反弹高度漂移或高速物体漏检。

下面的简化代码展示一个动态 body 的速度和位姿推进。它省略碰撞,只表达外力、速度和变换之间的主干关系。

struct RigidBodyState {
Vec3 position;
Quat rotation;
Vec3 linearVelocity;
Vec3 angularVelocity;
float inverseMass;
Mat3 inverseInertiaWorld;
Vec3 accumulatedForce;
Vec3 accumulatedTorque;
};

void IntegrateExternalForces(RigidBodyState& body, float dt) {
Vec3 acceleration = body.accumulatedForce * body.inverseMass;
Vec3 angularAcceleration = body.inverseInertiaWorld * body.accumulatedTorque;

body.linearVelocity += acceleration * dt;
body.angularVelocity += angularAcceleration * dt;
}

void IntegrateTransform(RigidBodyState& body, float dt) {
body.position += body.linearVelocity * dt;
body.rotation = Normalize(IntegrateAngularVelocity(body.rotation, body.angularVelocity, dt));

body.accumulatedForce = Vec3(0.0f);
body.accumulatedTorque = Vec3(0.0f);
}

这段代码对应贯穿场景中的第一段路径:球尚未接触斜坡时,重力持续写入 accumulatedForce,物理 step 把它转为速度和位置。接触发生后,求解器会在这条路径中插入冲量修正,使速度满足接触约束。渲染系统最终读取的是 positionrotation,材质、光照和后处理都只看到已经同步好的可见姿态。

66.2 碰撞检测与响应算法

碰撞系统的输入是形状、上一帧变换、当前预测变换和碰撞过滤信息。输出要从“撞上了”这个布尔判断扩展为一组可供求解器使用的接触约束:接触点、法线、穿透深度、相对速度、摩擦参数和恢复系数。球落到斜坡时,渲染画面只显示球被弹开;物理系统内部实际生成的是一个 contact manifold。

碰撞检测通常分成 broad phase 和 narrow phase。Broad phase 用 AABB、BVH、sweep and prune 或 spatial hashing 快速找出候选碰撞对。它的目标是减少精确形状测试数量。球在空中时,AABB 可能只和少数对象重叠;球接近箱体堆时,候选对会迅速增加。Broad phase 的输出质量会影响 CPU 时间,也会影响后续 narrow phase 的工作量。

Narrow phase 对候选对进行形状级测试。球与斜坡可以用 sphere-plane 或 circle-segment 测试,箱体之间可以用 SAT、GJK/EPA 或特化的 box-box 测试。这个阶段要生成 contact point、normal 和 penetration。Box2D 文档把 contact normal 定义为从一个 shape 指向另一个 shape 的单位向量,把 separation 作为 penetration 的相反量,并用 contact manifold 表达凸形状之间的接触点集合。Box2D 3.1.0 Simulation 文档还说明 contact point id 可用于跨 step 匹配接触点,从而复用上一 step 的接触冲量初值。

碰撞响应由约束求解器完成。实时引擎常把非穿透、摩擦和反弹转成速度约束,再用迭代 solver 逐个接触点修正速度。法向约束要求两个物体沿接触法线方向的相对速度满足非穿透条件;切向约束用库仑摩擦限制滑动速度;恢复系数把一部分撞击速度转成反弹。Box2D 文档中 normal impulse 用于阻止形状穿透,tangent impulse 用于模拟摩擦,这个划分可以直接映射到 debug view:法线箭头解释“被推开”,切线箭头解释“滑动受阻或带动旋转”。

响应过程可以按以下路径复盘。先计算接触点世界坐标和法线,再计算接触点处的相对速度;接着求出满足非穿透的法向冲量;然后在切向方向求摩擦冲量,并把摩擦冲量夹在法向冲量乘摩擦系数的范围内;最后把冲量分别写回两个 body 的线速度和角速度。箱体堆叠稳定性主要来自这个求解循环,而非来自最后的位置修正。

struct ContactPoint {
Vec3 worldPoint;
Vec3 normal;
float penetration;
float normalImpulse;
float tangentImpulse;
float restitution;
float friction;
};

void ApplyContactImpulse(RigidBodyState& a, RigidBodyState& b, ContactPoint& c) {
Vec3 ra = c.worldPoint - a.position;
Vec3 rb = c.worldPoint - b.position;

Vec3 va = a.linearVelocity + Cross(a.angularVelocity, ra);
Vec3 vb = b.linearVelocity + Cross(b.angularVelocity, rb);
Vec3 relativeVelocity = vb - va;

float normalSpeed = Dot(relativeVelocity, c.normal);
float desiredNormalSpeed = -c.restitution * Min(normalSpeed, 0.0f);
float normalDelta = desiredNormalSpeed - normalSpeed;

float effectiveMass = ComputeEffectiveMass(a, b, ra, rb, c.normal);
float impulseAmount = Max(normalDelta * effectiveMass, 0.0f);
Vec3 impulse = c.normal * impulseAmount;

a.linearVelocity -= impulse * a.inverseMass;
a.angularVelocity -= a.inverseInertiaWorld * Cross(ra, impulse);
b.linearVelocity += impulse * b.inverseMass;
b.angularVelocity += b.inverseInertiaWorld * Cross(rb, impulse);

c.normalImpulse += impulseAmount;
}

这段代码只展示法向冲量,真实 solver 还会处理摩擦、位置误差、多个接触点、多个约束之间的互相影响和迭代顺序。它已经足够解释视觉结果:球撞到斜坡后沿法线方向产生速度反转,沿切线方向保留一部分速度,因此会弹起并继续滚动。

触发器和碰撞体的输出目标不同。触发器只产生 overlap begin/end 事件,适合区域检测、拾取、关卡逻辑;碰撞体要参与接触求解,适合地面、墙、箱体、角色代理。贯穿场景最后的触发器区域应读取 overlap 事件和对象 id,再把事件传给 gameplay 或 debug overlay。它无需改变球的速度,也不应向 solver 添加法向约束。

66.3 集成刚体模拟到渲染管线

刚体模拟接入渲染管线时,关键接口是 physics state → render transform。物理世界维护权威状态,渲染世界维护可见对象。两者之间需要同步位置、旋转、可见性、debug geometry 和事件。若把同步点放错,画面会出现一帧延迟、抖动、碰撞事件先于画面或箱体已经物理静止但渲染还在插值移动。

实时程序通常把物理 step 固定在一个频率上,把渲染帧放在显示刷新节奏上。渲染帧可能是 144Hz、120Hz、60Hz 或随负载波动;物理 step 通常采用 1/60s 或更小固定值。Accumulator 模式能把两者连接起来:每个渲染帧累加真实经过时间,循环执行若干个固定物理 step,再用剩余比例对上一物理状态和当前物理状态做插值。这样渲染画面可以平滑移动,物理求解器仍保持固定参数尺度。

const float fixedDt = 1.0f / 60.0f;
float accumulator = 0.0f;

void Frame(float renderDt) {
accumulator += Clamp(renderDt, 0.0f, 0.25f);

while (accumulator >= fixedDt) {
physicsWorld.SavePreviousTransforms();
physicsWorld.Step(fixedDt);
physicsWorld.DispatchContactEvents();
accumulator -= fixedDt;
}

float alpha = accumulator / fixedDt;
renderScene.UpdateTransformsFromPhysics(physicsWorld, alpha);
renderScene.DrawMainPass();
renderScene.DrawPhysicsDebugOverlay();
}

这段循环表达了工程中的责任边界。Step 只推进物理世界;DispatchContactEvents 把接触和触发器事件交给上层系统;UpdateTransformsFromPhysics 负责把 body 的前后两个 transform 插值成可见 transform;主渲染 pass 只读取同步后的数据。Box2D 文档建议在 world step 后读取 body movement events,PhysX 文档也明确说明渲染可视化至少要读取移动 actor 的更新状态并写回对应 render representation。两者都支持一个判断:渲染同步应跟随物理 step 输出,而非在任意 gameplay 代码中零散读取。

物理 transform 和渲染 transform 需要处理比例尺、坐标系和插值方式。物理系统通常使用米、千克、秒;渲染系统可能使用厘米、单位格或世界单位。两者之间应有明确缩放常量。旋转插值用 normalized lerp 或 slerp,平移插值用线性插值。对高速物体,插值可以改善视觉连续性,但它不会修复物理漏检;漏检属于 collision detection 和 timestep 问题。

Debug draw 是刚体系统接入渲染的诊断界面。贯穿场景中应至少支持 body AABB、collision shape、contact point、contact normal、sleeping 状态、trigger overlap 和 velocity vector。它回答的问题不同于最终画面:最终画面说明玩家看到什么,debug draw 说明 solver 处理了什么。球穿过箱体时,若 debug draw 中 AABB 从未重叠,问题在 broad phase 或 transform 同步;若 AABB 重叠但没有 manifold,问题在 narrow phase 或过滤;若 manifold 存在但速度未改变,问题在 solver 或 body 类型。

下面的 Mermaid 图表示一帧中物理和渲染的资源流。图的边界是单机场景中的 CPU 物理模拟加 GPU 渲染;如果物理在 GPU compute 上运行,还需要补充 buffer barrier 和 queue synchronization。

这个图的关键路径是 Fixed physics step → Body transforms → Interpolation → Render scene。接触事件和 debug geometry 是旁路输出,它们服务逻辑和诊断。若把 gameplay 逻辑直接写进 solver 内部,模块边界会混乱;更稳定的做法是在 step 结束后消费事件,并在下一帧输入阶段写入力、冲量或状态修改。

66.4 稳定性优化与能量保持策略

刚体稳定性来自时间步长、接触几何、solver 迭代、冲量缓存、误差修正和对象休眠的共同约束。实时引擎追求的是视觉可信和参数可控,不追求无限精确的能量守恒。贯穿场景中的球反弹高度、箱体堆叠稳定性和斜坡滑动速度,都是这些参数共同作用的结果。

时间步长是第一层约束。较大的 dt 会让物体每步移动更远,导致 narrow phase 更难捕捉高速接触,也会让约束误差在单步内积累。固定 1/60s 加若干 substep 是常见起点。Box2D 文档给出的示例使用 1/60s time step 和 sub-step count,并说明 sub-stepping 会把 solver 时间切成更小增量,使 contacts 和 joints 以更细粒度响应。对箱体堆叠或长链条,增加 substep 往往比单纯增加视觉帧率更有效。

Solver iteration 控制约束误差收敛。迭代次数太少时,箱体堆会逐渐下沉、左右抖动或摩擦失效;迭代次数提高后,接触约束有更多机会传播到堆叠上层。成本也随之增加,因此调参应先固定场景和 step,再比较位置误差、速度误差、CPU 时间和接触数量。PhysX 文档专门列出 solver iterations,说明它是刚体动力学调参的一部分,而非渲染层可以忽略的内部细节。

Warm starting 利用上一 step 的冲量作为当前 step 的初始猜测。接触点在连续帧之间通常变化很小,复用冲量可以让迭代 solver 更快接近稳定解。Box2D 文档说明 contact point id 用于跨 time step 匹配接触点,并尝试复用上一 step 的 contact impulse result。贯穿场景中,箱体堆静止在地面上时,warm starting 能减少每帧从零开始求解造成的抖动。

Energy drift 指模拟能量随时间产生非预期增加或减少。反弹系数、阻尼、摩擦、位置修正和积分误差都会改变能量。对游戏和实时图形来说,能量变化通常是设计参数的一部分:阻尼让物体停下来,摩擦让球滚动距离缩短,小速度反弹阈值减少微小抖动。Box2D 文档提到低碰撞速度下会采用非弹性处理以减少 jitter,这个经验可以迁移到实时场景:低速接触优先稳定,高速撞击再显示反弹。

Sleeping 把低能量、长时间静止的物体移出主动求解集合。它降低 CPU 开销,也减少静止堆叠中的数值噪声。PhysX 文档说明 sleeping 发生在 actor 一段时间内低于动能阈值之后,睡眠 actor 的线速度和角速度为零,并且外力、非零速度或相关事件会唤醒它。贯穿场景里,箱体堆静止后应进入 sleep;球撞击堆叠时,相关箱体被唤醒并重新进入 solver。

Tunneling 是高速物体在离散 step 中跨过薄物体的现象。处理顺序通常是先缩小 step 或增加 substep,再给少数高速对象启用 continuous collision detection(CCD),并用 ray cast 或 shape cast 辅助触发器区域。Box2D 文档说明默认使用 CCD 防止动态体穿过静态体,并可把高速动态体配置成 bullet,使它对更多 body 类型执行连续碰撞。工程判断应很克制:CCD 提升鲁棒性,也会增加 narrow phase 和 TOI 计算成本。

刚体稳定性排查可以按固定顺序执行。先确认物理单位和尺度,例如 body 尺寸、重力、质量范围是否处于引擎推荐区间;再固定 timestep,记录 step 数和最大 accumulated time;接着查看 contact manifold 是否稳定存在;然后调整 solver iteration、substep、摩擦和恢复系数;最后处理特殊高速物体的 CCD、sleeping 和触发器检测。这个顺序把问题从全局时间尺度逐步收缩到局部接触约束。

66.5 Rigid Body Collision and Elastic Feedback Scene

贯穿演示场景由球、箱体、斜坡和触发器组成。它的目标是验证“物理状态能稳定产生可见反馈”。球负责测试重力、反弹、滚动和高速接触;箱体负责测试堆叠、摩擦、sleeping 和唤醒;斜坡负责测试法线方向、切向摩擦和角速度;触发器负责测试 overlap 事件和渲染同步。

场景数据应保持小而明确。球是动态体,形状为 sphere 或 circle,材质设置为中等摩擦和较高恢复系数;斜坡是静态体,材质设置为中等摩擦和低恢复系数;箱体是动态体,质量相近,摩擦较高,恢复系数较低;触发器是 sensor shape 或 trigger volume,只输出 begin/end 事件。这个组合能清楚地区分“接触求解改变速度”和“触发器只发事件”。

弹性反馈由视觉、声音和调试数据共同表达。视觉上,球碰到斜坡时可以短暂显示 contact flash 或 squash-free impact ring;声音上,可以根据法向冲量大小触发不同音量;调试层可以显示接触法线和 impulse 数值。输入数据来自 solver 输出,不来自物体位移猜测。这样能让反馈和物理事件同步,球轻轻贴住斜坡时不会误触发强撞击效果,球高速撞箱体时也能得到明显反馈。

一个最小实现可以把 contact normal impulse 映射到反馈强度。低于阈值时只显示 debug contact,高于阈值时触发粒子、声音或屏幕震动。阈值应使用物理单位调试,并记录 mass、relative speed 和 impulse。若直接用渲染帧中两帧位置差估算撞击强度,插值、帧率和物理 step 数会污染结果。

void OnContactSolved(const ContactEvent& event) {
float strength = 0.0f;

for (const ContactPoint& point : event.points) {
strength = Max(strength, point.normalImpulse);
}

if (strength > impactFeedbackThreshold) {
SpawnImpactRing(event.worldPoint, event.normal, strength);
PlayImpactSound(event.worldPoint, strength);
}
}

这段代码的关键是使用 solver 结果作为反馈输入。normalImpulse 已经包含质量、相对速度、接触法线和约束求解结果的影响,比单纯比较速度更接近“这次撞击对物体造成了多少变化”。在箱体堆叠中,多个接触点会同时产生冲量,反馈系统应合并强度并限制同一帧的事件数量。

测试这个场景时,先观察球从固定高度落到斜坡的反弹高度。反弹高度稳定说明 timestep、restitution 和低速阈值处在可控范围。再观察球沿斜坡滚动时是否持续加速到异常值,异常通常来自摩擦过低、角阻尼过低或重力尺度过大。接着观察箱体堆静止后是否进入 sleep,若长期抖动,先检查 contact manifold 稳定性,再检查 solver iteration 和物体尺度。最后让球高速进入触发器,确认 trigger begin/end 事件和渲染画面中的进入时刻一致。

该场景也适合连接图形管线。主渲染 pass 使用插值后的 transform 绘制球和箱体;debug overlay 绘制碰撞形状、AABB、contact normal 和 trigger bounds;post effect 可以读取 impact event 触发局部闪光或屏幕反馈。这样,刚体模拟从“后台数值系统”变成了可观察、可复查、可调参的视觉系统。

最小自检任务

给定一个场景:一个动态球从高度 4m 下落到倾角 25° 的静态斜坡,斜坡底部有三个动态箱体,斜坡后方放置一个触发器区域。物理步长固定为 1/60s,渲染帧率在 60Hz120Hz 之间波动。运行后出现三个现象:球有时穿过触发器没有事件,箱体堆静止后持续轻微抖动,撞击反馈有时比画面提前一帧。请按本章的判断顺序定位问题,并说明每个现象应优先检查的管线阶段、资源状态和调参方向。

答案要点

触发器漏事件应优先检查触发器检测路径。若 trigger 只依赖离散 overlap,高速球可能在一个 physics step 内跨过触发器体积;先查看 debug draw 中球的上一帧和当前帧 swept path,再考虑缩小 physics step、增加 substep,或对触发器路径补 shape cast。这个问题属于 collision detection 和事件生成阶段,渲染插值只能改善可见连续性,不能补回未生成的 overlap 事件。

箱体堆抖动应优先检查单位尺度、contact manifold、solver iteration、warm starting 和 sleeping。若 debug overlay 中接触点每帧跳动,问题偏向 narrow phase 几何或物体尺度;若接触点稳定但箱体仍下沉或震荡,问题偏向 solver iteration、substep、摩擦和恢复系数;若箱体已经满足低速条件却持续参与求解,继续检查 sleep threshold、wake event 和外部力输入。

撞击反馈提前一帧应优先检查事件消费时机和渲染同步。contact event 应在 physics step 结束后生成,并和插值后的 render transform 使用同一帧提交策略。若事件直接驱动渲染特效,而可见物体仍显示上一组插值 transform,反馈会先于画面。稳定做法是在 step 后缓存 impact event,并在本帧 UpdateTransformsFromPhysics 后提交特效,或把特效位置绑定到插值后的 contact representation。

本章知识点总结

  • 刚体状态:刚体模拟用位置、旋转、线速度、角速度、质量和惯性描述物体在当前 step 的可求解状态。
  • 力与冲量:力适合持续作用,冲量适合碰撞响应,二者都最终通过速度变化影响可见 transform。
  • 惯性张量:惯性张量描述质量分布对旋转变化的阻抗,决定同一接触冲量产生多少角速度变化。
  • 固定步长:固定 physics step 让接触、摩擦、反弹和 sleeping 参数处在稳定时间尺度中。
  • Broad phase:Broad phase 用空间结构筛选候选碰撞对,主要影响碰撞检测成本和后续 narrow phase 数量。
  • Narrow phase:Narrow phase 生成 contact point、normal、penetration 和 manifold,为约束求解器提供接触几何。
  • 接触冲量:法向冲量阻止穿透,切向冲量模拟摩擦,并可通过接触点力臂改变角速度。
  • 渲染同步:渲染系统应读取物理 step 输出并进行插值,使可见 transform 与 contact event 保持同一帧语义。
  • Debug draw:碰撞形状、AABB、contact normal、velocity vector 和 trigger bounds 能把视觉异常定位到具体物理阶段。
  • Solver iteration:Solver iteration 提供约束误差的收敛机会,箱体堆叠稳定性通常依赖足够的迭代和稳定接触点。
  • Warm starting:Warm starting 复用上一 step 的接触冲量初值,可减少静止接触和堆叠场景的抖动。
  • Sleeping:Sleeping 把长时间低能量 body 移出主动求解集合,减少 CPU 开销和静止堆叠噪声。
  • CCD:CCD 通过 swept motion 和 time of impact 处理高速物体穿透,适合少量高速关键对象。
  • 弹性反馈:撞击特效应读取 solver 产生的 normal impulse,使视觉、声音和物理接触强度一致。