本节摘要:最小物理引擎不是框架,而是四条状态更新:加速度来自重力,速度累加速度变化,位置累加速度,碰到地面就把法向速度翻面并乘恢复系数。先把这颗球跑稳,再对照完整引擎补上积分器选择、宽相、接触流形和约束求解。动手优先的意思是:先有可观察的运动,再给运动找理论名字。
阅读完本节,你应当能够:
打开空白循环,不要先建“PhysicsWorld”。只要一个球:位置、速度、半径、质量。每步做三件事——加重力、挪位置、若圆心低于地面加半径则把它抬回去并把竖直速度反向。三件事做完,屏幕上就有一个会跳的点。这一跳比任何架构图都值钱:它暴露了时间步、穿透、能量三个后续全部问题的原型。
把球想成台球桌上的一颗球,重力是桌面微微倾斜。你不会先发明一整套关节库再去推这颗球。你先用手把它推出去,看它撞边时怎么弹。引擎开发同一逻辑:最小可观察现象,再把现象里的错误逐条变成模块。
每一步: v ← v + g * dt x ← x + v * dt 若 x.y - r < 地面: x.y ← 地面 + r v.y ← -v.y * 恢复系数
这不是玩具伪代码,这是显式欧拉加最简单的位置投影。游戏里 80% 的“角色落地”早期原型就是这个形状:一个点、一个半空间、一次速度反射。
地面检测用“圆心到平面的有符号距离”。距离大于半径就分开;小于半径就穿透。穿透时两件事都要做:把位置抬到刚好相切,把速度的法向分量按恢复系数缩放。只抬位置不改速度,球会在下一帧继续往下钻,看起来像粘在地上发抖。只改速度不抬位置,球会在地下弹几下才浮上来,模型穿模。
| 字段 | 为什么现在就要有 | 以后谁用 |
|---|---|---|
| 位置 | 画出来、测地面 | 约束投影、休眠判定 |
| 速度 | 积分、反弹 | 冲量、摩擦、CCD 扫掠 |
| 半径 | 球对平面最便宜 | 换成凸包后变成形状句柄 |
| 质量 | 以后两球对撞要动量 | 质量倒数进有效质量 |
| 恢复系数 | 弹跳观感 | 接触约束的目标相对速度 |
质量现在好像没用,单球撞无限质量地面时,球的质量不出现在公式里。立刻加上是为了下一节两球相撞时你不必回头改结构。习惯上存质量倒数:静止地面倒数是 0,动态体是 1/m,求解器里乘法比除法多,零倒数自然表示无穷质量。
弹跳球的 if 很快会腐烂。两颗球、一个斜坡、一个活动箱子,分支会爆炸。工业引擎把“碰到了”变成接触记录:法向、穿透深度、接触点、两个刚体索引、摩擦与恢复。求解器只吃接触数组,不关心形状是球还是胶囊。
最小版本可以先只生成一种接触:球对静平面。
Contact: bodyA, bodyB # B 可以是静态地面哨兵 normal # 从 B 指向 A,单位向量 penetration # 正数表示重叠 restitution friction
单步流程变成:积分预测位置 → 生成接触 → 对每条接触把相对速度的法向分量弹开 → 沿法向把穿透分给能移动的那一方。这已经是冲量求解器的漫画版。第 4 章会把“弹开”换成计算冲量标量,再应用到线速度和角速度。
注意顺序:先积分再碰撞。有人喜欢“先检测再移动”,那会把这一帧的运动丢掉。标准实时引擎是预测-校正:积分给出可能穿模的预测,碰撞和约束再把非法部分拧回来。
💡 关键直觉:碰撞检测不负责“看起来对”,它只负责产出接触。看起来对应该由求解器改速度和位置。把两件事混在一个
if里,后面加摩擦和休眠会改不动。
两球相撞时,反射公式要换成沿法向的相对速度分解。设法向从 B 指向 A,相对速度 vrel = vA - vB,法向分量 vn = dot(vrel, n)。若 vn 已经分离(大于 0)且没有穿透要修,就不要再施加分离冲量,否则会把已经离开的物体再推一把,出现“粘连后弹飞”。恢复系数 e 把碰撞后的目标法向相对速度设为 -e * vn。对心、无摩擦时,冲量大小是 -(1+e)*vn / (invMassA + invMassB)。把这个标量沿 n 加到 A、减到 B,动量守恒自动满足。地面是 invMassB=0 的特例,公式退回“自己弹开”。
不要在这一步引入角速度。旋转是刚体的第二张皮,会把接触点从“球心沿法向”变成“表面上的杠杆臂”,冲量开始产生转矩。球可以先当质点。等质点堆箱子稳定了,再给盒子加方向和惯性张量,否则你分不清是积分炸了还是角冲量写错了。
休眠可以很早加:速度低于阈值连续若干帧,就把物体标睡,跳过积分。地面上的球停住后不睡,会因浮点残差一直微微颤,接触生成每帧都跑。阈值要带单位意识:用米/秒,不要用“像素/帧”。睡下去的物体被新接触碰到时要立刻醒,否则箱子砸到睡着的地板会穿过去。
把最小球和产品引擎并排,缺口一目了然。缺口不要一起补。积分不稳时加宽相没有意义;接触数据还不存在时写 PGS 求解器是空转。
| 完整引擎模块 | 弹跳球里的对应 | 现在缺什么 | 建议补丁顺序 |
|---|---|---|---|
| 时间步进 | 直接用渲染 dt | 固定步、累加器 | 本节之后立刻做 1.2 |
| 积分器 | 显式欧拉 | 半隐式、能量行为 | 第 2 章 |
| 形状 | 一个球 | 凸包、胶囊、网格 | 第 3 章窄相 |
| 宽相 | 无,因为只有一对 | SAP、网格、BVH | 第 3 章宽相 |
| 接触缓存 | 每帧新建 | 热启动、流形持久化 | 第 3.3 节 |
| 求解器 | 速度取反 | 顺序冲量、摩擦锥 | 第 4 章 |
| 关节 | 无 | 铰链、滑移、电机 | 第 4.3 节 |
| 休眠与岛 | 无 | 唤醒图、岛睡眠 | 第 6.2 节 |
| 调试画线 | 看球 | 法向、冲量、包围盒 | 第 7.1 节 |
补丁顺序之所以把时间步放在积分器前面,是因为欧拉在变化的 dt 下会表现出“有时稳有时炸”,你会被误导去调重力或恢复系数。先把 dt 钉死,爆炸如果还在,才是积分器的锅。
穿透修正有两种常见写法。硬投影:直接改位置,简单,破坏动量,堆箱子时底层会慢慢下沉或抖动。鲍姆加特稳定化:不直接改位置,而在速度层加一项 bias = penetration * beta / dt,让求解器在若干帧内“挤”出穿透。弹跳球阶段用硬投影没问题,箱子一多就要换成偏置速度,否则会看到底层箱子被压进地面再弹出来。偏置系数 beta 通常 0.1 到 0.3,太大变弹簧,太小穿透消除太慢。
恢复系数不要设成 1。数值误差会让能量往上爬,球越跳越高。0.2 到 0.6 适合游戏箱子,0.8 以上要配合更稳的积分和位置修正上限。还有一个“碰撞阈值”:法向相对速度若只是轻微接触(比如已经几乎静止地搁在地面),不要按弹跳处理,否则物体永远睡不着。很多引擎把相对速度低于 1 米每秒的接触当成非弹性,e 当 0 用。

静态几何用哨兵刚体还是单独的“世界碰撞器”列表?两种都行。哨兵刚体让求解器走同一套双刚体公式,代码少分支;缺点是静态物体也会占刚体槽。大型场景通常刚体表只放动态和运动学物体,静态网格走单独宽相。弹跳球用一个 staticGround 哨兵就够,invMass=0,速度恒 0。
运动学物体是第三种:位置由动画或玩家输入写,物理不积分它,但别的物体可以撞它。电梯、移动平台属于这一类。最小引擎可以暂不实现,但字段上留一个 type:动态、运动学、静态。后面角色控制器会用到运动学胶囊。
⚠️ 常见坑:用像素当单位,重力写成每帧加 0.5。换分辨率或帧率,全世界的“质量感”都变。从第一行就用米和秒,屏幕缩放留给摄像机。
调试输出现在就可以加:画速度向量、画接触法向、打印动能。动能在无阻尼弹跳里应缓慢下降(数值耗散)而不应上升。上升就是时间步或反射写错。第 7 章会把这些画线升级成接触热图,但习惯要在第一颗球时养成。
单位再强调一次。重力取 9.81 米每二次方秒,球半径 0.2 米,dt 1/60 秒,恢复系数 0.4,这样跳两下就会明显衰减,适合观察。若你把半径写成 20“像素”、重力写成 0.5,你其实在另一套匿名单位里调参,以后和渲染、动画对不上。物理世界用米,屏幕用像素,中间只有一个投影系数。
单球稳定后,复制出第二个球,让它们用上面的对心冲量互撞。不要急着加盒子。两球互撞能验证质量倒数、法向方向、分离速度符号。常见符号错误:法向从 A 指向 B 却按“加到 A”来施加冲量,两球会吸在一起。画出法向箭头,箭头必须从接触点指向被推开的那个球。
这一节结束时,你手里应该有:一个固定结构的刚体记录、一个接触记录、一个“积分-生成接触-反射-投影”的函数。它还不是引擎,但它已经能失败——失败的方式(穿地、越跳越高、互撞粘连)会告诉你下一章该看哪。这比先读完线性代数更接近真实工作。
下一节把渲染帧时间从这条循环里拆出去,用累加器喂固定步长,让卡顿不再变成物理爆炸。