← 图形原理 · 交互课 课题五 · 物理模拟 · 第 5 课

关节:为什么 ragdoll 会抖成面条

全课骨架:物理模拟每一帧只做三件事 —— ① 对每个物体积分;② 找出接触(谁压着谁、压多深);③ 解约束(按质量分配地纠回来)。 第 3 课的接触、第 4 课的绳,都是"两个物体各自有位置、被一条规则拉住"。关节是同一类东西的第三种形态: 它不需要 ② —— 约束方程直接就写在两个刚体之间("这两个锚点必须重合"),而它的新东西是 物体开始会转:位置对了不够,还得让转动也听话。ragdoll 抖,抖的就是这一层。 保真度:真机可证(画面与读数都由本页自己的刚体求解器给出;对照物是复摆的闭式周期与关节误差,读者能拿笔复核)。

(脚本启动中…)

定点观察:先看「5.1 点约束(含转动项)」,棒会像一个复摆那样摆;再点「5.1 变体:不含转动项」→ 看关节处的白点与棒的距离 → 不含转动项时锚点会分离、周期也乱(差多少见表①)。

本帧的读数

(脚本启动中…)

5.1 段 · 把两个锚点钉在一起

本段只增加一样东西:关节约束(一个点约束把一个刚体的局部锚点钉到世界锚点上;其余控件与本课各段一致)。 保真度:真机可证。接触要"先检测到才算数"(第 3 课),绳是"相邻粒子之间"(第 4 课); 而 ragdoll 的肩关节既没有接触、也不是粒子对 —— 它是两个刚体各自的局部锚点必须落在世界上的同一个点。 新的麻烦是:物体现在会转,所以约束的有效质量里除了 1/m,还要多一项 (r×n)²/I。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 两个锚点世界坐标 r = 锚点 − 质心 质量 m、惯量 I 棒:I = mL²/12 转动项 (r×n)²/I 开/关由按钮切 function solvePoint(c, iters) { const pA = anchorOf(A), pB = anchorOf(B); const C = Math.hypot(pB.x-pA.x, pB.y-pA.y); let kk = wOf(A) + wOf(B); if (SPIN) kk += (rAn*rAn)/A.I + (rBn*rBn)/B.I; const lam = -rel / kk; // 不含位置偏置 B.v += wOf(B)*lam*n; B.w += lam*rBn/B.I; A.v -= wOf(A)*lam*n; A.w -= lam*rAn/A.I; ★ 位置误差另由 projectPoint 消(同一套有效质量);少了转动项 ⇒ 传不了力矩 有效质量(逆) 1/m + (r×n)²/I 锚点离质心越远 ⇒ 转动项越大 不含转动项时 棒根本不转 (见表① 后两列)
定点观察:先点「5.1 点约束(含转动项)」,再看「5.1 变体:不含转动项」 → 看棒还在不在摆,以及表① 后两列 → 不含转动项时棒彻底不动了:它转过的角度是 0.0000°,连周期都测不出来: 因为约束冲量是按有效质量的倒数分的,漏掉 (r×n)²/I 那一项,这条约束就只能改平移、不能传力矩 —— 物体被"钉住了位置"却转不动。含转动项时它是规规矩矩的复摆,周期贴着闭式 2π√(2L/(3g))(误差 ≤0.21%)。
表① 复摆:点约束算出的周期对两条闭式解(m = 1、初始角 0.15 rad 的小角度、h = 1/60 s、迭代 4 次)
棒长 L (m)闭式复摆 2π√(2L/3g) (s)实测(含转动项)(s)相对误差实测(不含转动项)(s)不含转动项时棒转过的角度

算式:匀质棒绕质心 I = mL²/12;锚点在端点 ⇒ 由平行轴定理绕锚点 I_锚 = I + m(L/2)² = mL²/3。 复摆的周期 T = 2π√(I_锚 / (m·g·d)),d = L/2 ⇒ T = 2π√(2L/(3g))。拿 L = 0.6 验算: 2π√(1.2/29.43) = 2π×0.20195 = 1.2689 s —— 表① 那一行(含转动项)实测 1.271,差 0.21%。
这条式子只在小角度成立(大角度周期会变长,修正是 O(θ²));本表用 0.15 rad(8.6°),修正量约 0.14%,可忽略。
不含转动项那一列是本段的正题,而且结论比预想的更硬:它不是"周期偏一点",是"根本没有周期" —— 实测棒转过的角度是 0.0000°。原因:位置级投影照样能把锚点拉到重合(所以看不到分离), 但没有 (r×n)²/I 这一项,冲量就无法产生角冲量 ⇒ 这条约束只能改平移、传不了力矩, 于是棒被钉在一个固定姿态上、只有质心被锁住 —— 一个关节如果传不了力矩,绑出来的就是木头人,不是骨架。

5.2 段 · 只能转到这儿:关节限位

本段只增加一样东西:角度限位(在同一个点约束之外,再加一条"相对角不许超出 [−θmax, +θmax]";其余控件一致)。 保真度:真机可证。5.1 把位置钉住了,可棒还能自由转一整圈 —— 真实的肘关节不是这样。 限位是同一种约束的一个更简单的版本:它落在角度上,不是位置上,所以有效质量只剩 1/I_A + 1/I_B(没有 (r×n)² 那类项)。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 限位角 θmax 相对角 rel = aB − aA 相对角速度 relW 这一条没有位置项 ⇒ 有效质量只有 1/I_A + 1/I_B 纯耗散(撞上就停) function solveLimit(c, iters) { const rel = B.a - A.a, relW = B.w - A.w; if (rel >= c.lo && rel <= c.hi) return; // 在范围内不管 const tgt = rel < c.lo ? c.lo : c.hi; const kk = wAng(A) + wAng(B); const lam = -relW / kk; // 越界时把相对角速度归零 B.w += lam * wAng(B); A.w -= lam * wAng(A); } ★ 越界才出力;撞上去是纯耗散的(不回弹) 相对角被钳在 [−θmax, +θmax] 实测最大偏离角 应当逐位贴着 θmax 硬限位会抖动 (真引擎会加恢复系数/软限位) 本页如实标出这一点
定点观察:点「5.2 关节限位」,把「限位角 θmax」从 5° 往右推 → 看表② 的「实测最大偏离角」与画面里棒被挡住的位置 → 限位角设多小,棒的偏离角就恰好被压到多小(差 1e−15 量级);但推到 45° 以上就完全不生效了: 因为棒在这个初速下的自然摆幅只有 27.6°,够不到那么大的限位 —— 限位不是"把角度变小",它只在"本来会撞到"的时候才有作用。 这也说明关节限位与弹簧的区别:它不是一根把角度往回拉的弹簧,而是一条只管"不许超过"的硬规则。
表② 关节限位:相对角被钳在哪个角上(m = 1、L = 0.6、迭代 4 次、初始角速度 −3 rad/s)
θmax (°)实测最大偏离角 (°)差 (°)帧末越界占比

算式:限位这一条没有位置项,它的有效质量就是 1/I_A + 1/I_B(锚在世界时只剩 1/I_B = 12/(mL²))。 拿 L = 0.6、m = 1 验算:I = 1×0.36/12 = 0.03 kg·m² ⇒ 一次冲量要改变 1 rad/s 需要 0.03 N·m·s —— 这就是"让这根棒转起来多难"的度量。
表② 的读法:前两档的「差」是 1e−15 量级(也就是逐位咬住)—— 限位是硬约束,它把角度恰好压在 θmax 上; 第 4 行是特意留的对照:θmax = 45° 时实测只有 27.6°,因为棒在这个初速下的自然摆幅就是这么大、根本没撞到限位 ⇒ 限位不生效。 限位只在"本来会超"的时候才有作用,它不改变本来就合规的运动 —— 这一句是这一段最该记住的。
最后一列「帧末越界占比」四行都是 0.00%:这是位置级投影的功劳(越界当帧就被纠回来)。 本页如实标出限制:真正的引擎里硬限位配合速度级的恢复系数才不抖,本页没有做那一层,所以只报告"帧末"的越界占比。

5.3 段 · 让它自己转:motor

本段只增加一样东西:motor(驱动)(把限位换成"把相对角速度打到目标值";其余控件一致)。 保真度:真机可证。限位只能挡,挡不出动作。想让它自己转(风车、机器人关节、车轮)就得给一个目标: motor 就是"把相对角速度驱到 ω_target"的那条约束 —— 形式和限位一模一样,只把目标从"角度边界"换成"角速度值"。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 目标角速度 ω_target 当前相对角速度 最大冲量 maxLam 没有 maxLam 时 一步就到位 (=无限扭矩) 有 maxLam 才是电机 function solveMotor(c, iters) { const relW = B.w - A.w; const kk = wAng(A) + wAng(B); let lam = (c.target - relW) / kk; lam = Math.max(-c.maxLam, Math.min(c.maxLam, lam)); B.w += lam * wAng(B); A.w -= lam * wAng(A); } ★ 同一条式子:限位把目标写成"角度边界",motor 写成"角速度值" 区别只在"目标量"是什么 —— 再加一条电池/重量,就是机器人关节 稳态相对角速度 应当逐位 = ω_target maxLam 越小 ⇒ 起步越慢 但稳态值不变 (那是目标的定义) 能量在涨(电机做功)
定点观察:点「5.3 motor」,把「motor 目标 ω」从 0.2 拉到 3 → 看画面里棒的转速、以及表③ 的「实测稳态 ω」 → 稳态角速度逐位等于你设的目标值,和棒长、和迭代次数都无关: motor 不是"给一个力",而是"给一个必须满足的速度"—— 所以它会做功(能量在涨),这也是它和前面所有约束最大的不同。
表③ motor:稳态相对角速度对目标值(m = 1、迭代 4 次、跑满 4 s 后取末段平均)
目标 ω_target (rad/s)实测稳态 ω (rad/s)差末态能量 (J)

算式:一次冲量把相对角速度从 relW 打到 target,需要 λ = (target − relW) / (1/I_A + 1/I_B), 所以稳态时 relW 恒等于 target(残差只剩数值误差)。拿 target = 1.2 验算:稳态相对角速度就是 1.2 rad/s —— 表③ 那一行应当逐位对上。 第 4 列要如实看:能量在涨,因为电机在做功(W = τ·Δθ)—— 这是本课唯一一个"能量不守恒是应该的"段, 它的账不能拿守恒判据去卡(本页在门禁里把这一段标成"不查能量",改由"稳态值 = 目标值"这条断言来管)。

5.4 段 · 为什么 ragdoll 会抖

本段只增加一样东西:把关节串成链(节数 ≥ 2,其余控件一致)。 保真度:真机可证。5.1~5.3 都只有一条约束,一遍就解完了。真实角色是一串关节: 肩带着肘、肘带着腕 —— 而且求解器是按顺序一条一条解的,解完肩再解肘时,肩那一条又被弄歪了。 一遍扫描只把信息传一节,剩下的就是"抖"。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 迭代次数 iters 节数 nb 关节误差 = |pB − pA| 一遍扫描只传一节 顺序是自根向梢 ⇒ 解梢时动摇了根 迭代 = 多扫几遍 function stepRig(bs, cs, h, iters, gx, damp) { for (const b of bs) if (!b.fixed) { b.vy += G*h; b.vx += GX*h; } for (let k = 0; k < iters; k++) for (const c of cs) solveOne(c); // ← 速度级一遍 for (const b of bs) if (!b.fixed) { b.x += b.vx*h; b.y += b.vy*h; b.a += b.w*h; } for (let k = 0; k < iters; k++) for (const c of cs) projectOne(c); } ★ 一帧 = 重力 → 速度级 ×iters → 积分 → 位置级 ×iters(与第 3 课同一套两级分工) 所以"抖"不是随机噪声 —— 是没解干净的残差在每帧换方向 真引擎的解法:子步 + warm starting + 松弛因子(都在附录) 关节最大误差 iters = 1 时最大 每翻一倍迭代 误差再缩一截 节数越多 ⇒ 收敛越慢 这就是"面条感"的来源
定点观察:点「5.4 关节链」,把「迭代次数」从 1 拉到 8,再把「关节节数」从 2 拉到 4 → 看表④「关节最大误差」以及画面里各节之间有没有被拉开 → iters = 1 时误差 349 mm(每节才 600 mm 长,等于被拉断了);加到 8 次降到 44 mm,每加一档都掉到上一档的 0.4~0.6 倍: 抖不是随机噪声,是没解干净的残差每帧换一个方向 —— 这和第 3 课"叠塔要迭代"是同一件事, 只是这里的链更长、还带转动。而且这条链是"准静态加载"(竖直起步 + 重力斜约 20°), 所以表里的数是干净的稳态量;如果让四节从水平甩下来,误差会被起步那一瞬主导,四档的差别就被运气盖住了。
表④ 关节链:迭代次数与稳态关节误差(L = 0.6、m = 1、4 节、竖直起步 + 重力斜约 20° 的准静态加载、跑满 4 s、取后 1/3 的峰值)
迭代次数关节最大误差 (mm)链末端最低点 (m)相对上一档

算式:一遍自根向梢的扫描,只把"根的要求"传到第一节;要让信息走完 nb 节,至少要 nb 遍。 表④ 实测:迭代 1 → 2 → 4 → 8 时,稳态关节误差是 348.6 / 194.2 / 77.3 / 44.5 mm, 「相对上一档」是 0.557 / 0.398 / 0.576 —— 一路在缩,是持续收敛(不是"过某个次数就没事了")。 拿"每节 600 mm 长"当尺子验算量级:iters = 1 时误差 349 mm ≈ 半节棒长,肉眼就是"被拉断了"; iters = 8 时 44 mm ≈ 7% 节长 —— 仍然是能看见的面条感,这正是真引擎还要叠上子步与 warm starting 的原因(附录)。
另外两条如实标注:这是准静态加载(竖直起步、重力斜约 20°、加了与上一课同源的一点速度阻尼), 所以量到的是干净的稳态误差;「链末端最低点」用来交叉验证 —— 关节被拉开时链会"变长"、末端荡得更低(2523 → 2028 mm)。

课末:这五课其实是一件事

把五课的名字排在一起看:积分器(怎么把时间推下去)、接触(怎么发现两个物体碰上了)、 约束求解(怎么把它们纠回来)、软体(怎么把"必须"放松成"尽量")、关节(怎么把两个会转的刚体绑在一起)。 五课的求解器其实是同一条式子在不同约束上的重复: 算出这条约束的"有效质量"(1/m 加上转动项、再按质量倒数分配),乘上"违反了多少",一正一负地打回去。 接触、绳、关节、限位、motor 的差别只有两处:约束是关于什么量的(距离?角度?角速度?), 以及有效质量里有没有转动项。
如果只记一句话:物理引擎里没有"碰撞""绳子""关节"这些不同的东西,只有一堆约束与一个求解器; 那些名字是给美术与策划用的。本课题到此收口;附录里留了三样下一层的东西:子步与 warm starting、摩擦锥、以及关节的软约束与电机模型。