本节摘要:刚体状态由质心位置、线速度、朝向、角速度组成。点积给投影,叉积给力矩和切向速度,四元数给朝向乘法。把这些运算接到加速度和角加速度上,自由飞行才能写对;碰撞冲量不过是在同一套状态上再加一次瞬时增量。
阅读完本节,你应当能够:
质点只要位置和速度。刚体多了朝向和角速度,因为同一位置上,转不转会决定接触点的线速度。接触点速度等于质心速度加上角速度叉乘半径向量。这公式是后面所有摩擦、滚动、铰链的根。写不对叉积左右手,摩擦会把箱子吸进地面。
朝向有三种常见存储。欧拉角人能读,积分会万向节锁,角加速度也难写。旋转矩阵九个数,正交性会被误差破坏,每步正交化昂贵。四元数四个数,乘法表示合成旋转,积分就是 q += 0.5 * omega_quat * q * dt 再归一化。游戏刚体几乎都用四元数,矩阵只在需要变换向量时现算。
BodyState: x : vec3 # 质心世界坐标 v : vec3 # 线速度 q : quat # 单位四元数 w : vec3 # 角速度,世界或局部需约定 invM : float invILocal : mat3 # 局部惯性倒数
角速度存在世界还是局部,必须全引擎统一。存在世界时,惯性张量要每步用旋转矩阵相似变换到世界:Iworld = R * Ilocal * R^T。存在局部时,欧拉方程多科里奥利项。新手用世界角速度更不容易漏项,代价是每步变惯性。盒子、球这些主轴惯性是对角的,局部存对角三个数就够,别一上来存满 3x3。
| 量 | 运算 | 物理含义 |
|---|---|---|
| 点积 a·n | 投影 | 法向速度、穿透沿法向 |
| 叉积 r×F | 转矩 | 力在接触点产生旋转 |
| 叉积 w×r | 切向速度 | 接触点因旋转扫过 |
| q * v | 旋转向量 | 局部半径变世界半径 |
| 归一化 q | 投影回单位球 | 防止朝向缩放物体 |
把半径从局部变世界:rWorld = rotate(q, rLocal)。接触点相对质心的向量必须是世界系,因为速度是世界系。混用一个局部半径去叉世界角速度,箱子会沿奇怪的轴飞。
牛顿第二定律在质心上仍成立:a = F * invM。重力是 m*g 再乘 invM,结果就是 g,所以静态体 invM=0 时你根本不该把重力加进速度——加了也要乘 0。实现上对静态和睡着的物体直接跳过积分。
角加速度 alpha = I^{-1} * tau。I 在世界系下随朝向变。球的 I 各向同性,世界局部一样,这是球好写的原因。细杆绕长轴惯性很小,同样力矩会猛转,数值上角速度爆炸,需要钳制最大角速度,例如 50 弧度每秒。钳制是工程保险丝,不是物理定律。
function integrateUnconstrained(b, dt, F, tau): if b.invM == 0: return b.v += F * b.invM * dt IinvW = worldInvInertia(b.q, b.invILocal) b.w += IinvW * tau * dt b.x += b.v * dt b.q = normalize(b.q + 0.5 * quatFromOmega(b.w) * b.q * dt)
上面是半隐式欧拉:先更新速度,再用新速度更新位置。第 2.3 节会对比。这里先保证四元数乘法顺序与 quatFromOmega 的定义一致:通常纯四元数 (0, wx, wy, wz)。写反乘序,物体会绕错轴,看起来像磁力。
重力只产生力,不产生力矩——除非你错误地把重力作用点放在非质心。浮力、弹簧若挂在表面上,要同时加力和力矩。角色“站立稳定”有时靠额外的直立力矩,那是控制器,不是刚体积分。
💡 关键直觉:接触冲量与普通力走同一扇门:改 v 和 w。区别只是力要乘 dt,冲量已经是积分过的增量,不要再乘一次 dt。这是第 4 章最常见的数量级错误。
惯性张量必须相对质心。美术给的网格原点在脚底,直接按顶点算惯性会得到一个“抬脚底转”的怪物体。要么预计算时平移到质心,要么运行时用平行轴定理。质量、质心、惯性应作为形状资源烘焙出来,不要每帧对网格积分。
点积小于 0 表示两个向量夹角钝角,常用来判断“相对速度是靠近还是分离”。叉积的模是平行四边形面积,二维里用标量叉积 x1*y2 - y1*x2 判断左右。凸包 SAT 依赖轴上的投影区间,投影就是点积。这些不是数学课插播,是窄相的砖。
平面有符号距离:dot(p, n) - d,n 单位化。球对平面穿透 = 半径 - 有符号距离。AABB 对 AABB 是分轴投影重叠。OBB 需要把轴变到对方局部。你在这一节把 project(axis, points) 写成稳定函数,第 3 章会直接调用。
数值习惯:
== 0| 运算 | 失败样子 | 防护 |
|---|---|---|
| 未归一化 q | 物体慢慢变大或剪切 | 每步 normalize |
| 欧拉角积分 | 某姿态附近乱抖 | 不用欧拉存状态 |
| 叉积左右手混 | 摩擦把物体吸入 | 全引擎右手,测试用已知转矩 |
| 局部世界混 | 接触点速度错 | 半径先转到世界再叉 |
| 对零向量单位化 | NaN 污染全场 | 先判长度 |
调试画三个轴:把局部 x/y/z 轴用 q 转到世界画出来。积分若正确,轴应保持正交且长度 1。若轴在变短,归一化没调用;若正交性丢了,四元数乘法写错。这比看数字表快。
⚠️ 常见坑:用 32 位 float 存地球尺度坐标,原点在世界 0,物体在 10 公里外,加减位置时小数被吃掉,碰撞抖动。大世界要相对摄像机原点平移仿真,或分块局部坐标。从一开始别用“像素坐标当 float 位置”。
刚体休眠时仍要保持 q 归一化。唤醒时不要对睡着期间的动画朝向做半步积分。运动学体的 q 由动画写,物理只读它来变换形状。动态体不要每帧从欧拉角重建 q,那会丢掉角速度的连续性,表现为“传送式旋转”,接触点乱跳。
把这一节接到弹跳球:给球加一个无意义的 q(球视觉上旋转可有可无),但盒子必须有 q。下一个实验应是无碰撞的盒子在重力下自由坠落并带一点初始角速度,看它是否平稳旋转。旋转稳了,再谈弹簧和碰撞。几何没写对就上 GJK,你会调试一个不存在的碰撞 bug。
质量倒数和惯性倒数在零质量时要定义成 0,不要用 1/0。锁定某轴平移(2D 里锁 z)可以把该轴 invM 当 0,或在积分后清零该分量。锁定旋转同理把对应惯性倒数置 0。这是简单铰链的穷办法,真关节还是留给第 4.3 节。
最后留一个对照实验:纯平移的球,和同样质量、从高处落下的盒子,落地前两者质心轨迹应重合——重力不产生力矩、质心运动与形状无关。若不重合,你把重力加成了力矩,或把位置积分写成了某个顶点而不是质心。质心轨迹是刚体正确性的第一根金标准。
全引擎约定右手系:叉积 x×y=z。导入的网格若是左手,碰撞法向会反,箱子往地下吸。在资源管线里把顶点绕轴转成右手,运行时不要每帧判断。调试轴颜色:x 红 y 绿 z 蓝,和常见图形约定一致,错了肉眼能看出来。
惯量烘焙:对三角网格相对质心算惯性张量,对角化后存局部主轴。运行时 q 把主轴转到世界。细长杆最小惯量接近 0,数值上角速度爆炸,给一个最小惯量下限,相当于空气阻尼之外的保险丝。美术把支点放在脚底,质心必须重新算,不能假设原点是质心。
单元测试不必上图形:已知力臂的力应产生已知转矩;90 度四元数应把局部 x 转到世界 y(按你的旋转定义验收一次写进注释);两个垂直单位向量点积为 0,误差小于 1e-5。把这些放进每次提交都跑的小测试,比重启游戏看轴有效。
大世界原点平移:每帧把仿真原点放到关注区域,位置存相对坐标。跨块传送时当 teleport,取消插值。不要用 double 存所有位置当第一方案,带宽和 SIMD 都会痛,先分区。
新手用世界,少漏科里奥利项,每步变惯性。局部存要写欧拉方程完整项。全引擎统一,写进状态注释。混用一次就会绕错轴。
万向节锁,角加速度难写,插值要小心万向。给人看的调试可以出欧拉角,状态不要存它。从欧拉每帧重建 q 会丢掉角速度连续性,旋转像传送。
法向投影错会导致该弹开却平行滑;叉积左右手错会导致摩擦吸入或转矩反向。用已知力臂:水平力打在质心上方应产生可见旋转方向,和右手螺旋一致。
把物体放到一万米外做碰撞,看接触是否抖。能复现就上相对原点。不要等玩家走到地图边缘再修。测试关放在远处。
质心轨迹不再与球重合。重力作用点必须是质心。
接触点速度错,摩擦吸入。半径先转到世界。
旋转像传送,接触点乱跳。状态只存 q。
角速度爆炸。保险丝不是物理定律但要有。
float 抖。相对原点或分块。测试关放远处。
飞天。冲量直接改速度。
物体缓慢剪切。每步 normalize,隔几步不够。
不如把对应倒数置 0,少一次写错机会。
法向反,往地里吸。管线转成右手。
看不出绕错轴。红绿蓝固定。
下一节用一根弹簧证明:同样的状态,换一种加法,能量会自己长出来。