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

积分器与稳定性:同一个球,为什么换个写法就越弹越高

全课骨架:物理模拟每一帧只做三件事 —— ① 对每个物体积分;② 找出接触(谁压着谁、压多深);③ 解约束(按质量分配地纠回来)。 本课只讲 ① 积分。保真度:真机可证(画面与读数都由本页自己的积分给出,对照物是闭式解与守恒量,读者能拿笔复核)。

定点观察:把「步长 h」拉到最大 → 看两条能量曲线 → 显式欧拉每一跳都比上一跳更高(这不是 bug,是它的固有行为);半隐式只在一条带里来回。

本帧的读数

(脚本启动中…)

1.2 段 · 步长一大就爆

本段只增加一样东西:步长 h(其余控件与 1.1 完全一致)。保真度:真机可证。自检面板在页底。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 步长 h = 1/120 s 初态 y = 5 m 初速 v = 0 g = 9.81 m/s² 地面 y = 0(贴回) function stepExplicit(s, h) { const x0 = s.x, y0 = s.y, vx0 = s.vx, vy0 = s.vy; s.x = x0 + vx0 * h; s.y = y0 + vy0 * h; s.vx = vx0; s.vy = vy0 - G * h; } ★ 位置与速度都取旧值 —— 这两行就是本段的正题 E(t) 序列 每次触地的 起跳高度 每一跳都比 上一跳更高
定点观察:把「步长 h」拉到最大 → 看表②「触地前 E」那一列 → 显式那一列逐跳单调上升(越弹越高): 能量不是"少算了一点",是被系统地加上去了 —— 这就是显式欧拉的固有行为,跟显卡、浏览器都无关。

把中间量摊开(件三:每张表都能拿笔验算)

表① 自由落体:本页积分 vs 闭式解(h = 1/120 s、起跳速度 6 m/s)
t (s)本页积分 y (m)闭式解 y (m)差 (m)

算式:y = y₀ + v₀·sin(0.9)·t − ½g t²(y₀ = 5 m、v₀ = 6 m/s、g = 9.81)—— 拿笔算一行就能核对本页。 差额那一列随 h 线性缩小(一阶),这正是 1.1 段的结论。

表② 每一跳的能量:显式欧拉 vs 半隐式(同 h、同初值:v₀ = 2 m/s)
第 n 跳触地前 E (J)触地后 E (J)Eₙ÷Eₙ₋₁(显式)Eₙ÷Eₙ₋₁(半隐式)

算式:E = ½m|v|² + mgy(m = 1 kg)—— 每行都能用那一刻的 v 与 y 手算。 实测:显式那一列每一行都 > 1(每次触地净长约 2%);半隐式每一行都 < 1(每次净掉约 1%)——两者都不是精确守恒, 但方向相反、且半隐式的偏差小一半。真正的差别要看「触地前 E」那一列:显式逐跳单调上升(越弹越高),半隐式只在一条窄带里起伏。

1.3 段 · 只差两行的顺序

本段只增加一样东西:积分器的写法(步长 h 冻回 1.2 里最糟的那一档,其余控件一致)。保真度:真机可证。

显式欧拉 · 位置与速度都取旧值 半隐式(辛)欧拉 · 先推速度,再用新速度推位置 function stepExplicit(s, h) { const x0 = s.x, y0 = s.y, vx0 = s.vx, vy0 = s.vy; s.x = x0 + vx0 * h; s.y = y0 + vy0 * h; s.vx = vx0; s.vy = vy0 - G * h; } ★ s.y 用的是旧 vy ⇒ 每步凭空长 (1/2)g²h² function stepSemi(s, h) { s.vy -= G * h; s.y += s.vy * h; s.x += s.vx * h; } ★ s.y 用的是刚更新过的 vy ⇒ 能量有界振荡
定点观察:两个按钮来回切 → 看表② 的两列 → 显式每次触地净长 +2%、半隐式每次净掉 −1%: 整段只差「推位置」用的是旧速度还是新速度这一处 —— 同一台机器、同一个 h、同一个初值,结果的定性完全相反。

1.4 段 · 步子迈小一点:一帧当 8 帧用

本段只增加一样东西:每帧的子步数 n(积分器冻回 1.3 里的半隐式、步长档位保持当前值,其余控件一致)。 保真度:真机可证。上一段解决了「写法错会越弹越高」,但偏差本身还在 —— 一阶方法里它是 ∝ h 的。 想把它压下去只有两条路:换一个更高阶的积分器(1.5 之后),或者把 h 拆小。本段先做后者,因为它最便宜、也最常用。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 帧步长 h 子步数 n = 8 子步长 hh = h / n 积分次数 ×n (每帧成本随之 ×n) function stepSub(s, h, n, step) { const hh = h / n; for (let i = 0; i < n; i++) stepOne(s, hh, step); } ★ step 还是同一个 step(1.2 / 1.3 的函数原样传进来) —— 只是把「一步」换成了「n 个更小的步」 max |y − y_闭式| 同一段物理时间 T = 0.6 s n=1 → n=8 偏差约 ÷ 8 (一阶,∝ h)
定点观察:把「每帧子步」从 1 拉到 8 → 看表③「max |y − y_闭式|」那一列 → n 每翻一倍,偏差约砍半: 一阶方法的偏差 ∝ h —— 这不是个估算,是个能在表里逐行验证的等比数列。
表③ 子步数 n 与「同一段时间内的最大偏差」(自由落体、显式欧拉、T = 0.6 s、v₀ = 6 m/s)
子步数 n子步长 hh (s)max |y − y_闭式| (m)偏差 ÷ 上一档

算式:y_闭式 = y₀ + v₀·sin(0.9)·t − ½g t²,max 在 t ∈ (0, 0.6] 上取。 一阶方法的偏差 ∝ h:n 翻倍 ⇒ hh 减半 ⇒ 偏差约减半。表③最后一列应当接近 0.5,越接近越说明这一课没白讲。

1.5 段 · 帧率抖,物理不抖

本段只增加一样东西:把「推一步用的 h」与「这一帧的墙钟时长」解耦(积分器、子步、初值与上一段完全一致)。 保真度:真机可证。1.1 ~ 1.4 都假设 h 是个常数 —— 但浏览器每一帧回来的间隔并不恒定(performance.now() 之差在抖)。 如果直接用「上一帧到这一帧的墙钟」当 h,物理参数就成了帧率的函数:同一台机器、同一个球,60 fps 与 47 fps 跑出来的轨迹不一样。 本段把这事钉死:物理只认固定 h,墙钟的零头丢进一个累加器。

输入(墙钟在抖) 这个阶段在执行的规则(照抄件二真源码) 输出 frame(now) 序列 Δt₁ = 16.7 ms Δt₂ = 21.3 ms Δt₃ = 14.2 ms Δt₄ = 18.9 ms … (60 fps 抖动 ±30% 是浏览器的常态) function frame(now) { const wall = Math.min(0.25, (now - lastT)/1000); lastT = now; acc += wall; while (acc >= stepH) { acc -= stepH; stepSub(...); } } ★ 物理只按 stepH 走;零头留在 acc 里等下一帧 物理时刻 t 恒为 h 的整数倍 同初值跑两次 逐位相同 (帧率抖,轨迹不抖)
定点观察:把页面放后台再切回来(墙钟会跳一下) → 看自检面板「确定性」那一行 → 同初值两次运行逐位相同 = true: 物理时刻被锁在 h 的整数倍上,墙钟怎么抖都进不来。这一行就是「确定性」三个字在工程上的含义。
表④ 墙钟抖 vs 物理不抖:同一段时间(T = 0.6 s)用两套 Δt 序列喂同一个累加器
墙钟 Δt 序列物理步数末态 y (m)末态 v_y (m/s)两条序列末态差 |Δy|

算式:acc += Δt;while (acc ≥ h) { acc −= h; 推一步 }。 两条 Δt 序列的构造是「首 +δ、尾 −δ、其余不动」(δ = 0.3h),浮点相加的总和严格等于 T = 0.6 —— 因此物理步数一样、末态逐位相同(不是「几乎相同」)。表④ 的最后一列应当精确等于 0。 反例(拿笔就能验证):如果把抖动序列写成「先生成再除以总和归一」,浮点求和顺序会让总墙钟差 1 ulp, 在 0.6 s / h = 72 的边界上足以差出整整一步,末态偏差 0.12 m 量级 —— 这不是物理错,是「总墙钟没对齐」。

课末:这条链走到哪了

1.1 给出闭式解当对照物;1.2 看见显式欧拉的固有行为;1.3 把它拆到只差两行;1.4 把偏差压到 ∝ h;1.5 把帧率抖隔离在物理之外。 至此骨架①「积分」收齐。下一课讲骨架②:当两个物体不再只是「各跑各的」,怎么知道谁压着谁、压多深 —— 接触检测。