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

约束求解:压进去的那几厘米,该由谁退出来

全课骨架:物理模拟每一帧只做三件事 —— ① 对每个物体积分;② 找出接触(谁压着谁、压多深);③ 解约束(按质量分配地纠回来)。 前两课把 ① 和 ② 讲透了:球会掉会弹,也知道自己压进了多少。可知道压进去多少,不等于知道怎么把它弄出来 —— 本课讲 ③。 保真度:真机可证(画面与读数都由本页自己的求解器给出;对照物是闭式解与守恒量,读者能拿笔复核)。

定点观察:切到「3.1 只位置级」,把「位置修正 β」从小拉到 1 → 看球陷在板里的深度与面板上的能量 → β 越大陷得越浅,可能量照样一路涨(速度没人管)。

本帧的读数

(脚本启动中…)

3.1 段 · 先把它推出来

本段只增加一样东西:位置修正比例 β(只有位置级修正,速度级关掉;其余控件与本段之后各段一致)。 保真度:真机可证。上一课最后一句话留了个口子:2.2 里的"兜底贴回"就是一次最朴素的位置级修正 —— 把球心直接设回表面。 本段把它写清楚:每帧只纠 β 份穿透(β = 1 就是全纠)。这是所有求解器的第一块砖。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 位置修正比例 β 穿透量 pen 步长 h = 1/60 s 速度级修正:关 (本段只管位置) 球半径 R = 0.18 板半径 0.34 function solvePosition(bs, beta, iters, weighted) { for (let k = 0; k < iters; k++) for (let i = 1; i < bs.length; i++) { const lo = bs[i-1], hi = bs[i]; const pen = (hi.r + lo.r) - (hi.y - lo.y); if (pen <= 0) continue; hi.y += beta * pen; // 全推给上面那颗 } ★ 这一行就是本段的正题;它只改 y,一个字都没碰 v 每帧抬高 β·pen 穿透越小 (β 越大越浅) 可速度一路涨 (没人管它) 稳态:pen ≈ |v|·h / β
定点观察:把「位置修正 β」从 0.2 拉到 1,等球落到板上再停住 → 看画面里球心离板上表面的距离,以及面板上的能量 E → β 越大球陷得越浅,可能量照样一路涨:位置修正只把球搬回去,没有把它的速度收住 —— 每帧重力还在往速度里加,能量就这么凭空长出来了。
表① 只有位置级修正时,跑满 1.0 s 的读数(N = 1、h = 1/60 s、v₀ = 0、从板上方 0.9 m 落下)
位置修正 β末态 |v| (m/s)原始穿透 (m)
修正之前
残留穿透 (m)
修正之后
算式 |v|·h/βE ÷ E₀

两列穿透要分清,它们量的是同一帧的两个时刻:原始穿透 = |v|·h/β(球在这一帧里下沉了多少才被看见), 残留穿透 = (1−β)/β · |v|·h(纠完 β 份之后还剩多少)。拿 β = 1 验算:|v| = 9.81 ⇒ 原始 = 9.81/60 = 0.1635 m、残留 = 0 —— 表① 那一行是逐位对上的。β < 1 的两行会略低于算式(β = 0.2 实测 0.763、算式 0.818): 因为"残留"其实是最近约 1/β 帧速度的加权平均,而球这时还在加速,它滞后于当前的 |v| —— 这个偏差是可解释的,不是算错了。 但本段真正要看的不是这几列穿透,是最后一列:β 从 0.2 换到 1,能量都涨到初值的 3.4~3.9 倍 —— 因为这一整段里,没有任何一行代码碰过速度。

3.2 段 · 别让它再钻进去

本段只增加一样东西:速度级修正(β 冻回 1,其余控件一致)。 保真度:真机可证。3.1 的窟窿很清楚:位置修正把球搬回表面,速度还是朝下的,下一帧它又钻进同样的深度 —— 而且每帧重力都往速度里加一笔,永远还不完。修法不是"推得更用力",而是在速度层面直接禁掉"继续互相接近"这件事: 接触的两个物体,法向相对速度必须 ≥ 0。用一次冲量把相对速度打到 −e 倍(e 就是恢复系数)。 这一步本身只会耗散(|rel′| = e·|rel| ≤ |rel|)。但要如实记一笔:3.1 的 1330% 到这里降到 +2.3% —— 不是 0,因为位置级修正还在旁边跑:它把球搬上去,却不收速度。那 +2.3% 就是它留下的账。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 恢复系数 e 法向相对速度 rel = v_上 − v_下 板的质量 = ∞ ⇒ w(板) = 0 接触判据 pen > −SLOP function solveVelocity(bs, e, iters) { for (let k = 0; k < iters; k++) for (let i = 1; i < bs.length; i++) { const lo = bs[i-1], hi = bs[i]; if ((hi.r+lo.r) - (hi.y-lo.y) < -SLOP) continue; const rel = hi.v - lo.v; if (rel >= 0) continue; // 已经在分开 const j = -(1+e) * rel / (wOf(lo) + wOf(hi)); hi.v += j * wOf(hi); lo.v -= j * wOf(lo); ★ 冲量等大反向 ⇒ 总动量不动;这一步只耗散(能量仍涨的那点见右列) 碰后相对速度 rel′ = −e · rel e = 1 完全弹回 e = 0 贴住不动 这一步只耗散 能量净涨:3.1 的 1330% → 本段的 +2.3%
定点观察:把「恢复系数 e」从 0 拉到 1,看球弹起的高度与表②最后一列 → 球碰后速度恰好等于 −e 倍碰前速度 → e = 1 时它弹回原高度;e < 1 时每次弹跳都掉一截:速度级修正把"继续钻进去"这件事从源头掐掉了 —— 3.1 那个"β 再大也还不完"的账到这里基本平了(能量净涨从 1330% 收到 +2.3%)。 但没归零:剩下那 2.3% 来自还在旁边跑的位置级修正,它把球搬上去却不收速度 —— 这一点如实标在右列,不在正文里含糊过去。
表② 球撞"无限重的板":碰后速度与闭式解对照(N = 1、β = 1、碰前 v = −6 m/s)
恢复系数 e碰前 v (m/s)碰后 v′(本页求解器)闭式解 −e·v (m/s)差

算式:板质量取 ∞ ⇒ 质量倒数 w(板) = 0 ⇒ 冲量全部落在球上,解得 v′ = −e · v。 拿 e = 0.5、v = −6 验算:v′ = −0.5 × (−6) = +3.0 m/s —— 表② 那一行应当逐位对上(差为 0,不是"接近")。 这一列是整课的锚:3.3 换成一个有质量的对手时,同一套冲量公式给出的是另一个闭式解 —— 那时才有"谁退多少"的问题。

3.3 段 · 两个都动,谁退多少

本段只增加一样东西:按质量倒数分配修正(球数设为 2、β 与 e 保持上一段,其余控件一致)。 保真度:真机可证。3.1、3.2 都悄悄假定了一件事:对面那个物体动不了(板的质量是 ∞,`wOf(板) = 0`)。 两个都有质量的球撞在一起时这个假定就塌了 —— 上球压下来,下球凭什么一动不动? 工程上的分法是按质量倒数加权:轻的那个退得多。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 上球质量 m₁ = 1 下球质量 m₂ = μ 穿透量 pen 质量倒数 w = 1 / m 球数 N = 2 (本段的正题 需要两颗) const wOf = (b) => (b.m === Infinity ? 0 : 1 / b.m); function solvePosition(bs, beta, iters, weighted) { … 同一段循环,只换下面两行 … const wl = weighted ? wOf(lo) : 0; const wh = weighted ? wOf(hi) : 1; hi.y += beta * pen * wh / (wl + wh); lo.y -= beta * pen * wl / (wl + wh); } ★ weighted = false 就是 3.1 的写法(全推给上面那颗) 上球让出 wh /(wl + wh) 下球让出 wl /(wl + wh) μ = 4 ⇒ 上 0.8 / 下 0.2 (轻的多退)
定点观察:把「质量比 μ」从 1 拉到 5 → 看表③「上球让出」那一列 → μ = 1 时各让一半,μ = 4 时轻的上球让出 0.8: 这就是"按质量分配"的全部内容 —— 它的权重不是质量本身,而是质量倒数(`1/m`), 因为同样一份冲量,轻的物体得到的速度改变更大。
表③ 一次接触里两侧各让出多少、碰后上球走哪边(N = 2、上球 m₁ = 1、下球 m₂ = μ、β = 1、e = 0.5、碰前 v₁ = −6 m/s)
质量比 μ上球让出算式 μ ÷ (μ+1)下球让出碰后上球 v₁′闭式解 (1−e·μ)/(1+μ)·v₁

算式(位移):上球让出 wh/(wl+wh) = μ/(μ+1),下球让出 1/(μ+1)(w = 1/m ⇒ w₁ = 1、w₂ = 1/μ)。 算式(速度):一维对心碰撞 v₁′ = ((m₁ − e·m₂)v₁ + (1+e)m₂v₂)/(m₁+m₂),下球原本静止(v₂ = 0)⇒ v₁′ = (1 − e·μ)/(1 + μ) · v₁。拿 e = 0.5 逐行验算:μ = 0.25 ⇒ (1−0.125)/1.25 × (−6) = −4.2; μ = 1 ⇒ (1−0.5)/2 × (−6) = −1.5;μ = 4 ⇒ (1−2)/5 × (−6) = +1.2 m/s —— 表③ 最后一列应当逐位对上。 注意符号在 μ = 1/e = 2 处翻向:上球比下球轻一倍以上(μ > 2)才会被弹回去,否则它自己也还朝下走。 而 μ → ∞ 时 v₁′ → −e·v₁,正好退回表② 的闭式解 —— 这就是 3.1/3.2 那个"对面动不了"的假定在数学上的位置。

3.4 段 · 一遍不够:迭代

本段只增加一样东西:迭代次数(球数设为 4、改用完整求解器,其余控件一致)。 保真度:真机可证。3.1~3.3 每一段都只处理一对接触。可真实场景里接触是连成链的: 上面那颗球压着中间那颗,中间那颗又压着下面那颗,最下面那颗才压着板。 一遍扫描只把信息传一层 —— 要传到底,就得扫多遍。这就是迭代次数在做的事。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 迭代次数 iters 球数 N = 4 步长 h = 1/60 s 接触链 4 层 (球3–板) β = 1 e = 0.2 function stepWorld(bs, h, cfg) { for (const b of bs) if (b.m !== Infinity) b.v -= G * h; if (cfg.vel) solveVelocity(bs, cfg.e, cfg.iters); for (const b of bs) if (b.m !== Infinity) b.y += b.v * h; const raw = penetrationOf(bs); // 修正之前 if (cfg.pos) solvePosition(bs, beta, iters, weighted); return raw; } ★ 板的质量是 ∞ ⇒ 不参与重力与积分;iters 传给两个 solve 每翻一倍迭代 穿透 ÷ 上一档 0.175 → 0.120 → 0.047 一路在缩 不是"过门槛就好" 顶层下沉 ≈ 1.8 × 穿透
定点观察:把「迭代次数」从 1 拉到 8,等塔稳定下来 → 看表④三列与画面里球叠得紧不紧 → iters = 1 时顶层低了 2 cm、而且一直在抖(残余速度 1.03 m/s);到 iters = 8 只剩 0.02 mm、残余速度 0.023 m/s: 迭代不是"过了某个次数就完事",它是每翻一倍把残余重叠缩到上一档的 1/5~1/20 的持续收敛 —— 所以要按预算选,而不是"调大了就放心了"。
表④ 迭代次数与叠塔的稳态(N = 4、β = 1、e = 0.2、每对初始重叠 0.01 m,跑满 1.5 s 取末态)
迭代次数最大穿透 (m)顶层下沉 (m)残余速度 Σ|v| (m/s)穿透 ÷ 上一档

算式:一帧里的一遍扫描是自下而上处理相邻对的,而位置级修正会同时动两颗(按质量倒数分配)—— 所以修好第 i 对,会把下面第 i−1 对重新压出一点重叠。一遍扫过去,已经修过的对又被碰脏了;每多扫一遍,剩下的重叠就再缩一次。 实测每翻一倍迭代,「穿透 ÷ 上一档」是 0.175 / 0.120 / 0.047 —— 一路在缩,是持续收敛,不是"过了某个门槛就没事了" (四层链、八次迭代也还没到底)。 顶层下沉量的是"顶球比零重叠的理想静止位(0.34 + 0.18 + 3×0.36 = 1.60 m)低了多少",实测恒为最大穿透的约 1.8 倍; 残余速度 Σ|v| 量的是"塔静下来没有" —— 它和穿透同步往下掉(1.03 → 0.023 m/s)。 三列一起看才知道迭代在买什么:买的是"塔站得稳"这件事,代价是每帧多扫几遍。

课末:三条骨架走完了

3.1 用最小的一步把穿透推出去,代价是速度没人管;3.2 在速度层面禁掉"继续接近",能量这才不再涨; 3.3 把"对面动不了"换成按质量倒数分配,才有"谁退多少";3.4 把一次扫描扩成迭代,接触链才传得下去。 至此骨架 ①②③ 各讲透一次。下一课把 ① 和 ③ 组合起来:把"硬"约束换成"软"约束,为什么布料就出来了 —— 软体与 PBD。