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

接触与隧穿:球在两帧之间穿过去了,谁该为此负责

全课骨架:物理模拟每一帧只做三件事 —— ① 对每个物体积分;② 找出接触(谁压着谁、压多深);③ 解约束(按质量分配地纠回来)。 上一课讲完了 ① 积分 —— 球会掉、会弹、不会爆了。可它永远不会碰到任何东西,因为还缺 ② 找接触。 保真度:真机可证(画面与读数都由本页自己的检测与积分给出,对照物是给定的几何与守恒量,读者能拿笔复核)。

定点观察:把「初速度 v₀」推到 12 以上 → 看画面里那块薄板与面板上的「判定」 → 离散检测版时不时就整个穿板掉下去(同一个速度,重开一次结果可能不同 —— 漏是按相位发生的);TOI 版次次都停在板上表面;子步版要 n ≥ 3 才次次接得住。

本帧的读数

(脚本启动中…)

2.1 段 · 离散检测:只查终点

本段只增加一样东西:球的速度(检测方法冻成"每帧末查一次球心是否落在板内",其余控件一致)。 保真度:真机可证。上一课球会弹,是因为每帧末检查"球心有没有掉到板里",掉进去就贴回。 但这个检查有个盲区:如果一帧之内球移动的距离超过了板的厚度,球心就从板的上面直接落到了板的下面 —— 终点检查看到的是"在板下面",于是判定没碰到。球就这么穿过去了。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 初态 y = 5 m 初速 v_y = −20 m/s 步长 h = 1/60 s 板:y ∈ [0.30, 0.45] 板厚 t = 0.15 m 板上表面 S_top = 0.45 m function stepDiscrete(s, h) { stepSemi(s, h); // 先推一步 if (s.vy < 0 && s.y < S_TOP) { // 只看终点 if (s.y >= B) { 接住 } // 落在板内 else { 漏接 } // 落到板下 } } ★ 「落在板内」这一条就是本段的盲区 —— 整层跳过时它判"没碰到" |v·h| 与 t 比 |v·h| > t ⇒ 接住率 = t ÷ |v·h| |v·h| ≤ t ⇒ 接住率 100% 临界 v* = t / h = 0.15 × 60 = 9 m/s
定点观察:把「初速度」从 3 推到 30 m/s → 看表①「接住率(实测)」那一列 → v ≤ 6 时恒为 100%,到 9 m/s 开始掉(98.0%),之后一路走低且再也回不去: 这不是渲染 bug,是检测方法本身有盲区 —— 两次检查之间走多远,就漏多远。 注意"漏"是按相位发生的(同一个 v,球从哪个高度进入这一帧决定了这次漏不漏),所以表①测的是接住率而不是"漏或不错"。
表① 离散检测的接住率:扫 200 个进入相位,数有多少次"帧末球心仍在板内"(h = 1/60 s、板厚 t = 0.15 m)
v_y (m/s)一帧位移 |v·h| (m)|v·h| ÷ 板厚 t接住率(实测)接住率(算式 min(1, t ÷ |v·h|))

算式:接住率 = min(1, t ÷ |v·h|) = min(1, t / (|v|·h))。 直觉就是一句:球要正好落在厚 t 的板里;它每帧走 |v·h| 这么远,落在板里的机会就是"板厚占这一帧位移的比例"。 临界速度 v* = t / h = 0.15 × 60 = 9 m/s:低于它 |v·h| ≤ t,一帧必然落在板内(接住率 100%); 高于它,接住率就等于 t/(|v|·h) —— 拿笔算一行就能核对表① 最后两列。 实测与算式只差一点点,且差得有理由:步骤本身还多走 g·h² = 0.0027 m(步内重力), 所以以 v = 9 那一档为例,分母其实是 0.15 + 0.0027 而不是 0.15 ⇒ 实测 98.0%、算式 100%; v 越大这个修正越可忽略(v=30 时 29.5% vs 30.0%)。 这条规律最要紧的地方:|v·h| 越大接住率越低,不可能靠"调参数"补回到 100%。

2.2 段 · TOI:不再只查终点,去解那一刻

本段只增加一样东西:检测方法(速度冻回 2.1 里会漏的那一档,其余控件一致)。 保真度:真机可证。2.1 的问题是"只在帧末看一眼" —— 那如果沿运动轨迹解出"第一次碰到板上表面的时刻"呢? 这就是 TOI(Time of Impact):把这一帧的运动看成一段匀速直线,求它与板上表面的交点时刻 t。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 初态 y = 5 m 初速 v_y = −20 m/s 步长 h = 1/60 s 板上表面 S_top = 0.45 m (此式用步首速度 做匀速假设) function stepTOI(s, h) { const y0 = s.y, vy = s.vy; if (vy < 0) { const t = (S_TOP - y0) / vy; // 解交 if (t >= 0 && t <= h) { 锁在 S_TOP 反弹; return; } } stepSemi(s, h); … 兜底贴回 ★ 「解交」这一行把碰撞提前到了接触那一刻;兜底才是两者共用的 TOI 值 t t ∈ [0, h] ⇒ 锁在板上表面 接住率 100% 接住时下探 0 理论上限 g·h² = 0.0027 m(残差窗口)
定点观察:两个按钮来回切(离散 ↔ TOI) → 看表②「接住率」那一列 → 从 44% 变成 100%: 整段只差"什么时候看一眼" —— 从"帧末看一眼"变成"解出碰撞那一刻"。 注意 TOI 不是零误差的魔法:它拿步首速度做匀速假设,忽略了步内重力; 只是这个残差窗口窄得只有 g·h² = 0.0027 m(占一帧位移的 0.8%),本次 200 相位扫描一次都没落在里面 —— 所以表② 里 TOI 那一格印的是 0.00000,而它的理论上限写在图上,两处都如实标出。
表② 三条路的接住率与"贴回前最大下探"(v = 20 m/s、h = 1/60 s、板厚 t = 0.15 m,各扫 200 个进入相位)
检测方法两次检查之间走过的距离 (m)接住率(实测)接住率(算式)接住时最大下探 (m)

算式:同样是接住率 = min(1, t ÷ 每次检查之间走过的距离),只是"距离"换了: 离散 = |v|·h = 0.333 m(一帧)⇒ 0.15 / 0.333 ≈ 45%; 子步 n=8 = |v|·h/8 = 0.042 m ⇒ 0.15 / 0.042 > 1 ⇒ 100%; TOI 的距离 ≈ 0(它解出碰撞那一刻就停下,不"走过去")⇒ 100%。
「接住时下探」那一列要当心一个陷阱:离散印的是 0.149 m,看着比子步的 0.042 只差 3.5 倍、好得很 —— 可这恰恰是它只有 44% 的另一面:能进入这一列统计的,只有那些"恰好落进这 0.15 m 板里"的幸运次, 下探自然 ≤ 板厚;下探更大的那些根本没被接住,压根不在统计里。 TOI 那一格是 0.00000(残差窗口太窄、本次未命中,理论上限 g·h² 已标在件一上)。 三条都由同一段运动量出来:同一相位扫描范围、同一 h、同一块板,只有"怎么看"不同。

2.3 段 · 子步内检测:当 TOI 解不出来时

本段只增加一样东西:检测策略(速度冻回 2.2 那一档,其余控件一致)。 保真度:真机可证。2.2 的 TOI 只对"球 + 平面"有效 —— 换个复杂形状(多边形、网格、布料)就没有闭式交点公式了。 工程上的通用退路:把一帧拆成 n 个子步,每子步后都查一次。于是"一帧能漏多深"这件事,被换成了"一个子步能漏多深"。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 帧步长 h = 1/60 子步数 n = 1…8 子步长 hh = h / n 板厚 t = 0.15 m 每子步后查一次 (成本 ×n) function stepSubContact(s, h, n) { const hh = h / n; for (let i = 0; i < n; i++) { stepSemi(s, hh); // 只走一小段 if (s.vy < 0 && s.y < S_TOP) { if (s.y >= B) { 接住 } else { 漏接 } } } ★ 只把 h 换成 hh —— 判据一个字没改,接住率就抬了 n 倍 接住率 n=1 → 45% n=4 → 100% n=8 → 100% = min(1, t·n ÷ |v|·h) 满分门槛 n > |v|·h / t = 2.22
定点观察:把「子步数」从 1 拉到 8 → 看表③「接住率」那一列 → n=1 时 45%、n=2 时 90%、n ≥ 3 才回到 100%,之后"接住时最大下探"每翻一倍减半: 门槛是算出来的(n > |v|·h/t = 2.22),不是试出来的 —— 而代价那一列(每帧多查 n 次)要一起看。 这和上一课积分器的子步是同一套权衡:拿计算换精度,连"翻倍减半"这个比例都一样。
表③ 子步内检测:子步数 n 与接住率(v = 20 m/s、h = 1/60 s、板厚 t = 0.15 m,各扫 200 个进入相位)
子步数 n子步长 hh (s)|v|·h ÷ n (m)接住率(实测)接住率(算式 min(1, t·n ÷ |v|·h))接住时最大下探 (m)

算式:接住率 = min(1, t·n ÷ (|v|·h)) —— 把"每次检查之间走过的距离"从 |v|·h 换成 |v|·h/n,接住率就整体抬了 n 倍。 恢复到 100% 的门槛是 t·n ≥ |v|·h,即 n ≥ |v|·h / t = 20/(60×0.15) = 2.22 ⇒ 取整 n ≥ 3。 实测正好卡在这条线上:n=1 → 44.0%、n=2 → 89.5%(都还差一点)、n=4 与 n=8 → 100%。 顺带两条自洽检查:n = 1 那一行与表② 的「离散检测」行逐位相同(子步数取 1 就是离散检测,两条路径跑同一份判据); n ≥ 4 之后「接住时下探」每翻一倍减半(0.0835 → 0.0417,比值 0.499)。

课末:三条路的地图

2.1 看见离散检测的盲区 —— 接住率 = 板厚 ÷ 两次检查之间走过的距离,一帧走多远就漏多远,且永远补不回 100%; 2.2 用 TOI 把"距离"压到 0(接住率 100%,残留下探只剩 g·h²); 2.3 用子步内检测做通用退路:把距离换成 |v|·h/n,于是要先过 n > |v|·h/t 这道门槛,代价 ×n。 至此骨架②「找接触」收齐。下一课讲骨架③:知道了球陷进去多少之后,"把它推出来"有多少种分法、为什么堆塔会抖 —— 约束求解。 (本页已经露出了它的影子:2.2 里那句"兜底贴回"就是一次最朴素的位置级修正。)