4、可变时间步长:简单但危险,物理模拟中的陷阱

固定时间步长虽然稳定,但有个硬伤——它不够灵活。你想想看,游戏里场景复杂的时候,一帧要算几百个物理碰撞;场景简单的时候,可能就几个刚体在飘。固定步长为了照顾最坏情况,往往把步长设得很小,结果简单场景里白白浪费性能。

于是有人想:为什么不根据实际负载动态调整步长呢?帧率高的时候步长大一点,帧率低的时候步长小一点。听起来很合理,对吧?

嗯,这个思路确实诱人。但我在项目中吃过它的亏,今天就跟大家聊聊这个「简单但危险」的方案。

4.1 可变步长的基本思路

可变时间步长的核心逻辑就一句话:每帧传入真实的帧间隔时间。代码写起来也特别简单:

// 伪代码:可变时间步长
float dt = clock.getDeltaTime();  // 真实帧间隔
physicsWorld.step(dt);            // 直接传入

你看,比固定步长那套累加逻辑简洁多了。没有循环,没有余数处理,一行搞定。性能也好看——场景简单时步长大,计算量小;场景复杂时步长自动变小,精度自然提升。

但问题就出在这个「自动」上。

4.2 三大致命陷阱

我当年第一次用可变步长做物理模拟时,觉得找到了银弹。结果测试刚跑起来,物理世界就炸了——物体穿模、弹簧乱跳、刚体飞向太空。为什么会这样?

说白了,可变步长有三个躲不开的坑:

陷阱 表现 根本原因
数值发散 物体抖动、爆炸、穿模 步长超过积分器稳定域
非确定性 回放不一致、联机不同步 步长依赖真实时间
帧率耦合 高帧率物理变慢、低帧率物理变快 物理行为与帧率绑定

4.3 数值发散:最直接的灾难

物理模拟本质上是在解微分方程。每个积分器都有稳定的步长范围。拿最简单的显式欧拉来说,它的稳定域是 λ·h < 2(λ 是系统特征值,h 是步长)。

一旦步长超过这个阈值,误差就会指数级放大。我见过一个弹簧系统,步长从 1ms 跳到 8ms,弹簧直接「炸」了——振幅在 3 帧内从 0.1 米飙到 100 米。

⚠️ 我曾经踩过的坑: 有一次做布料模拟,用可变步长跑得好好的。突然用户切了个后台程序,回来时帧率掉到 10fps,步长飙到 100ms。布料直接撕裂成碎片,物理引擎报了一堆 NaN。从那以后,我所有可变步长方案都加了步长上限。

更隐蔽的问题是:步长变化本身就会引入误差。积分器假设步长是常数,突然改变步长相当于给系统注入了一个「冲击」,高阶积分器尤其敏感。

4.4 非确定性:联机和回放的噩梦

这个坑在单机游戏里不明显,但一上联机就暴露了。可变步长依赖真实时间,而真实时间每次运行都不一样——

  • 同一段物理回放,两次运行结果不同
  • 联机时两个客户端帧率不同,物理世界迅速分叉
  • 调试时加个断点,回来物理状态全变了

我参与过一个赛车游戏项目,物理用了可变步长。结果回放功能怎么都做不对——同样的操作录下来,回放时车子每次都在不同位置翻车。最后不得不把物理改成固定步长,回放才稳定下来。

💡 个人习惯: 如果项目需要回放或联机,我建议直接放弃可变步长。非要用的话,至少把随机种子和输入序列固定下来,但物理状态仍然不可控。

4.5 帧率耦合:物理行为随帧率漂移

这个陷阱最隐蔽。你以为可变步长能让物理「自适应」,实际上它让物理行为完全绑定了帧率。

举个例子:一个物体以 10m/s 的速度运动。60fps 时每帧移动约 0.167 米,30fps 时每帧移动约 0.333 米。如果物理逻辑里有个碰撞检测阈值是 0.2 米——

  • 60fps 时:每帧移动 0.167 米 < 0.2 米,碰撞正常
  • 30fps 时:每帧移动 0.333 米 > 0.2 米,直接穿模

同样的物理世界,换个帧率就崩了。这合理吗?

更麻烦的是,有些物理效果(比如阻尼、摩擦)对步长特别敏感。步长一变,物体的「手感」就变了。玩家会明显感觉到:高配电脑上物理「黏黏的」,低配电脑上物理「滑滑的」。

4.6 什么时候可以用可变步长?

说了这么多坏话,可变步长就一无是处吗?也不是。它有两个场景确实合适:

适用场景 原因 注意事项
非交互式渲染 不需要回放和联机 加步长上限,避免极端情况
低精度粒子系统 粒子数量多,对精度不敏感 碰撞检测用连续检测

但如果你在做游戏物理、物理模拟器、或者任何需要确定性的系统——我建议你三思。

4.7 知识体系总览

下面这张图总结了可变时间步长的核心逻辑和陷阱:

可变时间步长:核心逻辑与陷阱 可变时间步长 基本思路:dt = 真实帧间隔 三大致命陷阱 ① 数值发散 步长超稳定域 → 爆炸/NaN ② 非确定性 回放/联机结果不一致 ③ 帧率耦合 物理行为随帧率漂移 结论:简单但危险,非交互场景可谨慎使用

4.8 如果非要用,怎么降低风险?

有些项目确实绕不开可变步长(比如某些离线渲染器)。如果你非用不可,我建议至少做三件事:

  1. 加硬上限:步长最大不超过 1/20 秒(50ms),超过就截断。虽然会引入「时间暂停」感,但总比物理爆炸强。
  2. 用高阶积分器:RK4 或隐式欧拉对步长变化更鲁棒。显式欧拉在可变步长下基本是自杀。
  3. 做步长平滑:不要直接用原始 dt,而是用指数平滑 dt_smooth = α·dt_raw + (1-α)·dt_smooth,避免步长突变。
🔑 一句话总结: 可变时间步长用「简单」换来了「危险」。除非你清楚自己在做什么,否则固定步长永远是更安全的选择。我在项目中见过太多「先试试可变步长,后面再改」的案例——最后没有一个不改回来的。