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

软体:把「必须正好」换成「尽量靠近」,绳子为什么就软了

全课骨架:物理模拟每一帧只做三件事 —— ① 对每个物体积分;② 找出接触(谁压着谁、压多深);③ 解约束(按质量分配地纠回来)。 第 3 课讲的是硬约束:接触必须被满足,穿进去就推出来。可绳子、布料、头发不是"撞到了"——它们从来没被要求正好是某个长度, 只是"尽量靠近"。本课是骨架 ① 与 ③ 的组合:把约束放松一点,软体就出来了。 保真度:真机可证(画面与读数都由本页自己的解算器给出;对照物是闭式静平衡与稳定性判据,读者能拿笔复核)。

(脚本启动中…)

定点观察:切到「4.1 弹簧力」,把「弹簧刚度 k」从 1000 往右推,直到越过状态行里那条稳定线 3600 → 看左边那条链 → 越线不等于立刻爆:k = 4000 时链会停在一个"伸长 1.8 m 的错误振荡"上(正确值是 0.039 m),再往右到 10000 才是真指数发散(画面会打出面板并自动重置)。切到 4.2 以后,同样把 k_s 推到底,链只会更硬、不会失控。

本帧的读数

(脚本启动中…)

4.1 段 · 先用弹簧把它连起来

本段只增加一样东西:弹簧刚度 k(只有显式弹簧力,PBD/XPBD 都关掉;其余控件与本课各段一致)。 保真度:真机可证。第 3 课的接触是"硬"的 —— 压进去就推出来,没有商量余地。绳子不是这样: 它只是不想被拉长,拉长了就给一份回拉力,公式是最朴素的那个 F = k·(d − L₀)。本段把这条链交给它。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 弹簧刚度 k 当前长度 d 原长 L₀ = 0.10 m 每颗质量 m = 1 步长 h = 1/60 s 无阻尼时会一直荡 (本页加了一点速度阻尼 才看得到静平衡) function stepSpring(sys, h, k) { for (const p of sys.ps) { p.ax = 0; p.ay = p.pin ? 0 : G; // 重力向下 } for (const [i, j] of sys.cons) { const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy); const f = k * (d - L0) / d; // 胡克定律 a.ax += f*dx; a.ay += f*dy; b.ax -= f*dx; b.ay -= f*dy; } …半隐式积分… ω = √(k/m) 单簧线 4m/h² = 14400 链上最高模态 2ω ⇒ 实际线 m/h² = 3600 ① 线内:正确静平衡 ② 刚越线:错振荡 4000 时伸长差约 50 倍 ③ 再往右:真发散
定点观察:把「弹簧刚度 k」从 1000 往右推,直到越过控制条下方写的那条稳定线 3600 → 看左边那条链、画面左下角那行状态、以及表①「阶段」那一列 → 越线不等于立刻爆:k = 4000 时链会停在一个"伸长 0.78~1.83 m 的错误振荡"上(正确值是 0.039 m,差 45 倍), 而且阻尼把它限在那里不会自己变大;再往右到 10000 才是真指数发散(0.5 s 就 5.5e21),画面会打出面板并 2 s 后自动重置。 三个阶段的分界就是那条线 —— 而这条线比教科书上单根弹簧的 14400 更紧(链上最高的模态是 2ω)。
表① 显式弹簧链:稳定条件与静平衡伸长(N = 6、L₀ = 0.10 m、m = 1、h = 1/60 s、跑满 3 s)
k (N/m)临界步长 √(m/k) (s)阶段末步伸长 (m)末步 \|v\| 峰值 (m/s)算式 mg·N(N−1)/2k

算式(稳定性):单根弹簧的角频率 ω = √(k/m),半隐式欧拉对它的稳定条件是 h·ω < 2,即 k < 4m/h² = 14400。 但一根链上最高的那个模态是略小于 2√(k/m)(相邻两颗粒子反相跳动),于是门槛收到 k ≈ m/h² = 3600 附近, 等价地步长必须小于 √(m/k)。实测(N = 6)的翻转点在这条近似线的右侧一点:3800 仍在稳定侧、4000 已失控 —— 因为 N = 6 的链最高模态还没到 2√(k/m)(模态与链长有关),所以精确门槛比 m/h² 略高。近似线写在这里,判读者对错靠实测。
算式(静平衡):第 0 颗锚定 ⇒ 自由粒子 N−1 颗、弹簧 N−1 根;第 s 根承担它下面 N−s 颗的重力, 于是总伸长 = Σ j·mg/k = mg·N(N−1)/(2k)。拿 N = 6、k = 1000 验算:1×9.81×30/(2×1000) = 0.1472 m —— 表① 那一格应当对上。
"越线"不是一件事,是三件事(这是本页实测纠正过来的一处口径,原来写成"超过就炸"): ① 稳定侧停在正确的静平衡上(3800 的伸长 0.0397 贴着算式 0.0387,差 3%); ② 刚越线(k = 4000)不会爆,而是停在一个错误的极限环上 —— 伸长在 0.78~1.83 m 之间摆,而算式只要 0.0368 m, 差 20~50 倍,而且阻尼把它限住了、不会自己变大; ③ 再往右(k ≥ 4500)才是真发散 —— k = 10000 时 0.5 s 就到 5.5e21。 所以画面上的守卫把 ② 也算作失控(见画面里的面板):它虽然有限,但已经完全不是这根绳子的解了。 这条式子长得和 4.4 段一模一样(到那里把 1/k 写成 α 就是了)。

4.2 段 · 不推力,直接改位置

本段只增加一样东西:改用 PBD 的位置投影(刚度换成 k_s,其余控件一致)。 保真度:真机可证。4.1 的麻烦是双重的:要挑 k,还要一直盯着 h·ω < 2 —— 想更硬就得更小心, 而"更硬"恰恰是布料最想要的东西。PBD 换了个问法:别算力了,直接问"这两个点现在差了多少,把它挪回去"。 挪多少由 k_s 控制(1 = 一步到位,0.5 = 只纠一半),而挪的比例还是按质量倒数分配 —— 和第 3 课同一套。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 PBD 刚度 k_s 约束误差 C = d − L₀ 质量倒数 w = 1/m 迭代/子步数 n_s 这里没有"力" 也没有 h·ω 要躲 —— 只改位置 function solvePBD(sys, ks, n) { for (let it = 0; it < n; it++) for (const [i, j] of sys.cons) { const C = d - L0; // 差了多少 const s = ks * C / (wi + wj) / d; // 每人挪多少 a.x += wi * s * dx; a.y += wi * s * dy; b.x -= wj * s * dx; b.y -= wj * s * dy; } ★ 位移按质量倒数分(第 3 课同一套);k_s = 1 就是"必须正好" k_s = 1 ⇒ 伸长 0(刚性) k_s 越小 ⇒ 越软、垂得越长 k_s 不是刚度 (是"每步纠多少") ⇒ 与步长耦合
定点观察:把「PBD 刚度 k_s」从 1 拉到 0.05 → 看左边那条链垂下来的总长,以及表② → k_s = 1 时整条链恰好是 N×L₀(一点不伸),k_s 越小垂得越长:这就是"软体"的全部机关 —— 同一个约束、同一次求解,只把"纠多少"调小,硬的东西就变软了。
表② PBD 刚度与静平衡伸长(N = 6、L₀ = 0.10 m、n_s = 1 单遍、跑满 3 s)
k_s总伸长 (m)链长 ÷ 原长 (N·L₀ = 0.60 m)备注

算式:PBD 的伸长没有闭式解(它是每步投影之后的残差),但有一个能拿笔验的端点: k_s → 0 时完全不受约束(表② 最后一行,链会一直往下坠、伸长随时间增长)。 而 k_s = 1 那一行实测是 2.7 cm,不是 0 —— 因为一遍扫描是逐条处理的(Gauss-Seidel): 修好第 i 条约束会把第 i−1 条重新推歪,于是每帧重力新造出来的那点长度差,一遍扫不干净。 这和第 3 课表④的迭代问题是同一件事,只是那里的对象是穿透、这里是伸长。 记下这个 2.7 cm —— 4.3 与 4.4 都要用到它(它是一遍扫描的"残差地板")。

4.3 段 · 子步在换什么

本段只增加一样东西:子步数 n_s(k_s 冻回 0.5,其余控件一致)。 保真度:真机可证。4.2 的 k_s 是个 0~1 的数,可它到底是"多软"?它自己说了不算 —— 每步只纠 k_s 份,而"一步"有多长是由步长定的。想把结果调硬,有两条路:调大 k_s,或者把一步拆成 n_s 个更小的步。 本段只动后者,看这两条路是不是同一回事。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 子步数 n_s 子步长 h_s = h / n_s k_s 固定 = 0.50 每子步后都投影一次 两种"更硬"的路 ① 调大 k_s ② 拆子步(本段) function stepSub(sys, h, n, one) { const hs = h / n; for (let s = 0; s < n; s++) one(sys, hs); } one(sys, hs) = { 重力 → 积分(每步下沉 g·h_s²/2) solvePBD(sys, ks, 1); // 每子步一遍 速度 ← (x − x_prev)/h_s ★ 一帧的下沉量 ∝ n_s·(h/n_s)² = h²/n_s —— 与子步数成反比 k_s 固定 0.5 n_s ↑ ⇒ 伸长 ↓ 等效单遍刚度 1 − (1 − k_s)^n 但这两条路 并不等价(表③)
定点观察:把「子步数 n_s」从 1 加到 8,看表③前四列 → 伸长一直在降,但降得比"等效单遍刚度"那条路子慢 → 子步不是"免费的刚度":它让结果更接近刚性,同时也让同一个 k_s 的含义随 n_s 变化 —— 换句话说,k_s 不是一个能写进资产文件的数。这是 4.4 要解决的问题。
表③ 子步数 vs 单遍等效刚度(N = 6、k_s = 0.50 固定、跑满 3 s)
子步数 n_s总伸长 (m)等效单遍刚度 1−(1−k_s)^n用该刚度单遍跑出的伸长 (m)两者之比

算式:单根约束被投影 n 次、每次留 k_s 的残差,剩下的误差是 (1−k_s)^n,所以"等效一次投影" 应当等价于刚度 1 − (1−k_s)^n(k_s = 0.5、n = 2 ⇒ 0.75;n = 4 ⇒ 0.9375)。 表③ 的第 4 列就是把那个等效刚度拿去单遍跑一遍的结果 —— 如果两条路真的一样,它应当与第 2 列相等。 实测并不相等(比值见最后一列),因为子步同时把"每步的下沉量"按 1/n_s 缩小了,而 k_s 没有。 这条差异正是 4.4 段要消灭的东西。

4.4 段 · 想说多硬,就直接说多硬

本段只增加一样东西:把 k_s 换成柔度 α = 1/k(其余控件一致)。 保真度:真机可证。4.3 的结论很别扭:k_s 是"每步纠多少",含义跟着步长跑 —— 美术说"这根绳子柔度 1e-4", 程序却得先问"你每秒跑几帧"。XPBD 把这件事扳回来了:直接用柔度 α = 1/k(单位 m/N,和 4.1 的 k 互为倒数), 在投影式子里带上 α̃ = α / h_s² —— 于是 h_s 变了,α̃ 自动补偿。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 柔度 α = 1/k (m/N) 子步长 h_s 拉格朗日乘子 λ α 与步长无关 (这是它存在的理由) 伸长 = 力 × α = mg·N(N+1)/2 × α function solveXPBD(sys, alpha, hs, n) { const at = alpha / (hs * hs); // ← 步长在这儿被补掉 for (const c of sys.cons) c.lam = 0; for (let it = 0; it < n; it++) for (const c of sys.cons) { const dl = (-C - at * c.lam) / (wi + wj + at); // 乘子增量 c.lam += dl; a -= wi*dl*n; b += wj*dl*n; } ★ 每子步解 ITXP=4 遍(λ 每子步复位);α = 0 退化成 4.2 的 k_s = 1 n_s=1 / 4 / 8 三列 给出同一个伸长 比值 0.990/1.000/1.004/1.002 伸长 = mg·N(N−1)/2·α 与 4.1 的 mg·N(N−1)/2k 同形(α = 1/k) 对照表③:k_s 那边差 18 倍
定点观察:把「柔度 α 指数」逐档调(α = 10⁻²…10⁻⁵),每档都对比表④第 3、4、5 列 → 同一档里,n_s = 1 / 4 / 8 三个数几乎一模一样(比值 0.990 / 1.000 / 1.004 / 1.002) → 这就是"能写进资产文件的刚度":α 换了子步、换了步长、换了帧率都兑现同一个伸长。 拿笔验算:N = 6、α = 1e-4 ⇒ 1×9.81×30/2×1e-4 = 0.0147 m —— 表④ 那一行应当对上(n_s ≥ 4 时实测 0.0147 / 0.0148)。 对照一下表③:同样"换子步"这个动作,k_s 那边差了 18 倍。
表④ XPBD 柔度与静平衡伸长:三档子步数应当给出同一个伸长(N = 6、n_s 个子步、跑满 3 s)
α = 1/k (m/N)算式 mg·N(N−1)/2·α (m)实测 n_s = 1 (m)实测 n_s = 4 (m)实测 n_s = 8 (m)n_s = 8 ÷ 算式

算式:静平衡下第 s 根弹簧承担它下面 N−s 颗的重力 ⇒ 它的伸长是 (N−s)·mg·α, Σ 起来就是 总伸长 = mg·N(N−1)/2·α(和 4.1 那条同形,因为 α 就是 1/k)。 本段的看点是第 3、4、5 列:三档子步数给出的是同一个数 —— 最后一列的比值实测 0.990 / 1.000 / 1.004 / 1.002, 也就是说 α 换了子步(等价于换了步长)也照样兑现。这正是 k_s 做不到的事: 表③ 里同样的"换子步",结果差了 18 倍;这里只差 0.4%。
唯一还有残差的是 n_s = 1 那一列里 α 最小的两行(0.0176 / 0.0069,对着算式 0.0147 / 0.0015)—— 那是表② 里那个"一遍扫描的地板"(约 2.7 cm 量级)盖过了柔度本身;子步一多就消失(n_s = 4 起就贴着算式)。 所以准确的说法是:α 定的是目标值,子步只决定你离它多近。

课末:第二视角 —— 一条链横竖各来一遍,就是布

本页左边那条链是一维的:只连相邻两颗,为的是每个数都能拿笔算。右边那块布用的是同一个解算器, 只是把同一件事在网格上做了两遍 —— 横着连一遍(每行的邻居)、竖着连一遍(每列的邻居), 约束条数从 N−1 变成约 2N(8×6 的网格是 82 条),解算器一行都不用改。 所以"布料是怎么出来的"这个问题,答案不是另写一个布料求解器,而是把"必须正好"放松成"尽量靠近",然后在网格上多连几遍。 下一课把镜头挪到关节与骨骼:点约束、铰约束、motor —— 以及 ragdoll 为什么会抖成面条。