约束求解:压进去的那几厘米,该由谁退出来
全课骨架:物理模拟每一帧只做三件事 —— ① 对每个物体积分;② 找出接触(谁压着谁、压多深);③ 解约束(按质量分配地纠回来) 。
前两课把 ① 和 ② 讲透了:球会掉会弹,也知道自己压进了多少。可知道压进去多少,不等于知道怎么把它弄出来 —— 本课讲 ③。
保真度:真机可证 (画面与读数都由本页自己的求解器给出;对照物是闭式解与守恒量,读者能拿笔复核)。
这台设备拿不到 canvas 2D 上下文,活例跑不了。
本页的原理与数值表照常可读 (画面只是把同一个数画出来)。
球数 N
位置修正 β
恢复系数 e
质量比 μ
迭代次数
3.1 只位置级
3.2 +速度级
3.3 +按质量分配
3.4 +迭代
跑 / 停
定点观察:切到「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。