一个弹簧在每帧收到的时间间隔不同,运动结果会不会跟着显示帧率变化?一个高速质点在一步之后已经落到地面以下,把位置拉回地面、反转速度,是否就完成了正确的反弹?

这两个问题都涉及时间,但需要分别处理。固定步长规定动力学怎样推进,显示插值决定怎样观察离散状态;碰撞还要确定事件发生在步内哪个时刻。本篇沿用上一章的单弹簧,再加入一个能求解析撞击时间的点—平面模型。

显示一帧,不等于积分一步

上一章的质量、刚度与初态保持不变:m=1 kg、k=4 N/m、x=1 m、v=0。速度先更新的半隐式 Euler 需要固定步长 h,且当前弹簧的稳定条件是 0<h<1 秒。如果把每帧收到的时间间隔直接作为 h,渲染负载、暂停和显示频率都会改变离散轨迹。

解决当前复现问题的方法是保留一个时间累积器。收到显示帧的经过时间 Δt,就把它加入积压;只要积压至少有一个完整 h,就执行一次固定积分并减去 h。一帧可以执行零步、一步或多步。

这里不依赖浮点数是否恰好能表示 1/120。FixedSpringClock 使用每秒 72,000 个整数时间单位,一个物理步为 600 单位,即 h=1/120 秒。30、60、144 Hz 的构造显示调度分别每帧增加 2400、1200、500 单位。整数只是此实验的时间表示协议,不意味着真实显示器一定具有这些精确间隔。

累积器始终满足下面的时间账目:

Uelapsed=600N+Ubacklog.U_{\rm elapsed}=600N+U_{\rm backlog}.

N 是已完成的固定步数,积压不能凭空消失。在常规帧处理结束后,积压小于 600;如果设置了每帧最多处理多少步,积压可能继续超过一个步长。

代码没有把“最多处理七步”解释为“剩余时间丢弃”。达到上限后保持积压,再通过后续调用继续清空。这样做可以限制单次处理量,却不能保证持续过载时仍能追上真实时间;稳定复现与实时响应是两个验收问题。

1
2
clock.advance(elapsed_units, max_steps);
const auto display_state = clock.render();

advance 改变模拟状态,render 只读取状态。实际更新仍复用 spring31.hpp,没有增加另一套含义不同的弹簧方程。

四种显示调度到达同一模拟时刻

实验固定总时间 11 秒,物理更新恰好执行 1320 步。第四种调度重复使用整数间隔 120、1800、250、7200、33、6000、1400,既有短帧也有积压多步的长帧;最后一帧只补到相同终点。

构造调度 显示帧数 固定步数 最终积压
30 Hz 330 1320 0
60 Hz 660 1320 0
144 Hz 1584 1320 0
不均匀间隔 333 1320 0

四组最终状态在同一个 C++ 运行中逐位相同:x=−0.999882654659677 m,v=0.01821250561515 m/s。原因是它们从同一初态执行了相同顺序的 1320 次固定更新;并不是不同步长意外得到了相近结果。

独立递推核对全部固定步参考状态,最大差约 3.58×10⁻¹⁵。解析连续解在 11 秒处仍约为 x=−0.9999608264、v=0.0177026186。显示调度一致证明复现性,不能消除积分误差,也不能替代上一章的精度分析。

另一个测试一次输入全部 11 秒,只允许处理七步。剩余积压为 (1320−7)×600=787800 单位。之后用零新增时间、每次最多七步的调用清空,共 188 次;清空后才恢复与参考完全相同的状态。若刚处理七步就比较终点,比较的是不同模拟时刻。

这里共保存 2907 条显示帧记录,包含墙钟时间单位、累计物理步、积压、插值系数和物理/显示状态。这是合成调度实验,没有测量真实显示器刷新频率或操作系统帧间隔。

插值改善显示,但不修改物理状态

累积器保存最近两个已完成状态 previous 与 current。常规情况下取 α=积压/600,按下面的形式显示:

xdisplay=(1−α)xprevious+αxcurrent,0≤α<1.x_{\rm display}=(1-\alpha)x_{\rm previous}+\alpha x_{\rm current}, \qquad 0\leq\alpha<1.

它在两次已知状态之间插值,显示相对模拟时钟有一个固定步的延迟。α=0 时显示 previous,并不是声称 current 尚未算出。速度也用相同规则生成显示值,但显示值不用于下一次受力或积分。

若将插值后的位置写回 current,下一步就会从取决于显示帧间隔的状态出发,破坏前面的复现结果。检查器在每帧显示前后比较 previous、current、步数与积压,确认这些字段没有变化。

每帧处理上限导致积压大于一个步时,当前实现使用 α=min(积压/600,1),显示 current。它没有向未来外推,也没有把未完成物理时间伪装成已经完成。卡住的显示仍可能明显落后,此时应检查负载与积压记录,不能仅靠插值掩盖。

子步改变的是积分步长

另取外层间隔 H=1.1 秒,比较每个外层调用做一次积分,或分成 s=2 个内部步。内部步长为 δ=H/s,对当前弹簧仍需满足 δω<2,其中 ω=2 rad/s。

内部步数 s δ,秒 11 秒终点 (x,v) 外层采样能量范围,J
1 1.1 (12113.9145,15548.0257) 2–4.14364×10⁸
2 0.55 (0.370951846,2.309646697) 1.32432735–4.43639340

一次大步仍越过上一章的稳定边界。分成两步后 δ=0.55<1,离散状态有界;其理论能量界为 2/(1+0.55) 到 2/(1−0.55),实际外层采样落在其中。

第二组终点与解析约 (−1,0.0177) 依然相差很大。子步在这个例子中恢复了稳定条件,却没有自动获得足够精度。CSV 和 JSON 每隔 H=1.1 秒记录一次外层端点,表里的能量范围只覆盖这些记录,不冒充所有内部状态的极值。

显示频率、外层调度间隔、内部积分步长分别描述不同东西。增加显示帧数并不自动增加内部步数;增加内部步数则实际增加积分工作量。本实验不做性能比较。

高速点穿过地面后,末端修复错在哪里

碰撞模型另用竖直坐标 y,允许区域为 y≥0,静止无限平面位于 y=0。质点无半径,单位质量,只有法向速度,恒定重力向下,g=2 m/s²。没有摩擦、横向运动、旋转或多个接触。

自由飞行的闭式解是:

y(t)=y0+v0t−12gt2,v(t)=v0−gt.y(t)=y_0+v_0t-\tfrac12gt^2,\qquad v(t)=v_0-gt.

取 y₀=1、v₀=−100、一步 Δt=0.05 秒。忽略碰撞得到 y=−4.0025 m、v=−100.1 m/s,质点已经穿入禁止半空间。负末端位置可被简单符号检查发现;对这个无限单侧平面,不能声称所有末端检查都会漏掉碰撞。

但发现穿透不等于正确处理事件。若只在步末将 y 改成 0、把速度反向,得到 (0,100.1)。它相当于允许质点先穿入地面,再在错误时刻反弹,省略了真实撞击之后的剩余飞行。

当前单位质量的机械能为 E=v²/2+gy。初态为 5002 J;末端修复后为 5010.005 J,即使恢复系数为 1,也多出了 8.005 J。最后位置看起来在地面上,并不能证明算法正确。

先求撞击时刻,再消耗剩余时间

令 y(τ)=0,取进入平面的最早非负根。对于 y₀>0:

τ=v0+v02+2gy0g.\tau=\frac{v_0+\sqrt{v_0^2+2gy_0}}{g}.

当 v₀<0 且很大,分子可能出现近似等量相减。实现改用等价形式 τ=2y₀/(√(v₀²+2gy₀)−v₀),并用 hypot 计算撞击速度幅值。有限输入也可能产生溢出,不能把非有限值继续当合法事件处理。

若 τ 大于剩余时长,直接自由飞行到末端;否则先推进到撞击,置 y=0,再应用恢复系数 e:

v+=−e v−,0≤e≤1.v^+=-e\,v^-,\qquad 0\leq e\leq1.

减去已经消耗的 τ 后,继续计算余下时间,必要时处理下一次撞击。e=1 的当前无摩擦静止平面模型保存机械能;e<1 在撞击中耗散法向动能。

高速例子的 τ≈0.00999900020 秒,剩余时间约 0.0400009998 秒。最终 y≈3.99929984 m、v≈99.9399960 m/s,能量仍为 5002 J。正确结果在地面上方,因为反弹后还有约 0.04 秒上升时间。

另一个手算例子更容易核对:y₀=1、v₀=0、g=2、e=1、Δt=1.5。第一次撞击 τ=1 秒,入射速度 −2,反弹速度 2,剩下 0.5 秒。因此终点 y=2×0.5−0.5×2×0.5²=0.75,v=1,E=2 J。末端修复却得到 (0,3)、E=4.5 J。

程序实际输出的两个单粗步轨迹:自由飞行、末端一次修复与连续碰撞

图由 C++ 程序的实际采样生成,两面板各有 151 个时刻。红线是自由飞行,橙色虚线与它重合直到步末,最后才画一次竖直修复;蓝线求实际撞击并继续飞行。橙线没有在每个绘图时刻重新修复,否则会把一次大步的错误轨迹画成另一种算法。

零时间事件和反复反弹需要明确策略

y=0、v<0 表示已在边界向禁止区域运动,应立即反弹。y=0、v>0 则正在离开地面,不能选择离开时的零根;应选择返地的正根 2v/g。质点正好在地面且速度为零,由接触支持保持静止,不重复计算无限多个零时长撞击。

e<1 时,相邻弹跳时间按比例缩短,可能在有限时间内出现无穷次事件。只写一个“不断求下一根”的循环,没有终止策略,程序可能无法完成一个模拟步。

当前明确选择两项近似:在恰好 y=0 且速度幅值不超过 rest_speed 时进入支撑状态;达到 max_events 且仍有剩余时间时,将 y、v 设为零并记录 event_limit_hit=true。正的近地高度不会提前吸附,仍先求撞击。

默认低速阈值为 10⁻⁶ m/s,最多 64 次事件。当前非弹性测试在第 18 次事件进入支撑;把事件上限改成 4,则记录上限命中并停止运动。后者是有界耗散回退,不能称为精确求出了所有后续反弹,也不能忽略标志后宣称碰撞正常完成。

求根之前的自由飞行可能因舍入在计算边界附近得到极小负高度,实现只在尚未到算出的 TOI 分支保护这一舍入情况。这个保护与大幅穿透后的通用修复不同;数值检查仍保留原始自由飞行的负末端结果。

验证、练习与复跑

simulation32.json 保存四种调度、积压测试、子步端点和碰撞边界结果;显示帧 CSV、子步 CSV、碰撞 CSV 分别保留逐行数据。302 个碰撞采样另与独立闭式解及弹性能量比较。

检查还覆盖正好在步末撞击、向外/向内零根、静止支撑、时间拆分、近地状态、恢复系数 0/0.5/1,以及事件限额。17 项非法输入和 3 项溢出检查均被拒绝。没有随机数、GPU 替代实验或硬件性能结论。

练习一:60 Hz 的一帧经过 1200 个时间单位,固定步为 600;144 Hz 的一帧经过 500。初始积压为零,分别会执行多少步?144 Hz 连续两帧后积压是多少?

解题要点:60 Hz 第一帧执行两步,积压为零。144 Hz 第一帧零步、积压 500;第二帧累计 1000,执行一步并剩 400。显示帧数不同,完整固定步仍按累计时间决定。

练习二:m=1、k=4,外层间隔 H=3 秒。子步数至少是多少才能进入当前半隐式方法的严格稳定区?这个数保证准确吗?

解题要点:需要 H/s<1,即 s>3,最少四步。三步落在临界值,不能包含。四步只得到 δ=0.75 的稳定条件,仍要检查相同时间的位移、速度误差,不能用有界代替准确。

练习三:y₀=1、v₀=0、g=2,Δt=1.5,e=0.5。求第一次撞击后的剩余飞行终点与能量,并说明是否再次撞击。

解题要点:τ=1、入射速度 −2、出射速度 1。余下 0.5 秒得到 y=0.25、v=0,E=0.5 J;从出射到返地需 2×1/2=1 秒,所以当前余时内没有第二次撞击。初始 2 J 中有 1.5 J 在碰撞时耗散。

1
2
make -C examples/computer-graphics build/simulation32_check
examples/computer-graphics/build/simulation32_check examples/computer-graphics/build

这些函数提供有界的一维调度与接触实验。真实复杂刚体还需要宽相位、多个约束、接触求解器和误差控制;当前 TOI 闭式只适用于静止平面与恒定法向加速度。

参考资料与导航

实际核验与运行回执位于 writing-plans/computer-graphics/evidence/research-32.md、32-cpu.txt。显示调度复现、积分稳定、撞击时间和终止策略分别检查,不能由一张图替代所有数值验收。

系列入口 · 上一篇:力怎样变成运动 · 下一篇:同一场景的三种观察方式。