计算机图形学 32:模拟怎样稳定地显示
一个弹簧在每帧收到的时间间隔不同,运动结果会不会跟着显示帧率变化?一个高速质点在一步之后已经落到地面以下,把位置拉回地面、反转速度,是否就完成了正确的反弹?
这两个问题都涉及时间,但需要分别处理。固定步长规定动力学怎样推进,显示插值决定怎样观察离散状态;碰撞还要确定事件发生在步内哪个时刻。本篇沿用上一章的单弹簧,再加入一个能求解析撞击时间的点—平面模型。
显示一帧,不等于积分一步
上一章的质量、刚度与初态保持不变: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 单位。整数只是此实验的时间表示协议,不意味着真实显示器一定具有这些精确间隔。
累积器始终满足下面的时间账目:
N 是已完成的固定步数,积压不能凭空消失。在常规帧处理结束后,积压小于 600;如果设置了每帧最多处理多少步,积压可能继续超过一个步长。
代码没有把“最多处理七步”解释为“剩余时间丢弃”。达到上限后保持积压,再通过后续调用继续清空。这样做可以限制单次处理量,却不能保证持续过载时仍能追上真实时间;稳定复现与实时响应是两个验收问题。
1 | |
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,按下面的形式显示:
它在两次已知状态之间插值,显示相对模拟时钟有一个固定步的延迟。α=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₀=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:
当 v₀<0 且很大,分子可能出现近似等量相减。实现改用等价形式 τ=2y₀/(√(v₀²+2gy₀)−v₀),并用 hypot 计算撞击速度幅值。有限输入也可能产生溢出,不能把非有限值继续当合法事件处理。
若 τ 大于剩余时长,直接自由飞行到末端;否则先推进到撞击,置 y=0,再应用恢复系数 e:
减去已经消耗的 τ 后,继续计算余下时间,必要时处理下一次撞击。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 | |
这些函数提供有界的一维调度与接触实验。真实复杂刚体还需要宽相位、多个约束、接触求解器和误差控制;当前 TOI 闭式只适用于静止平面与恒定法向加速度。
参考资料与导航
- Box2D 官方 Simulation 文档,Time Step 与 Sub-Steps,固定步长和内部子步的不同作用。当前实验独立实现,没有调用 Box2D。
- Catto:Continuous Collision,GDC 2013,第 9 页,撞击后的剩余时间;第 21–22 页讨论匀速点—平面求交。本篇恒加速度根由自己的自由飞行方程推导。
- MIT Underactuated:Contact,弹跳球、自由飞行、撞击和恢复系数。
实际核验与运行回执位于 writing-plans/computer-graphics/evidence/research-32.md、32-cpu.txt。显示调度复现、积分稳定、撞击时间和终止策略分别检查,不能由一张图替代所有数值验收。






