4、可变时间步长:简单但危险,物理模拟中的陷阱
固定时间步长虽然稳定,但有个硬伤——它不够灵活。你想想看,游戏里场景复杂的时候,一帧要算几百个物理碰撞;场景简单的时候,可能就几个刚体在飘。固定步长为了照顾最坏情况,往往把步长设得很小,结果简单场景里白白浪费性能。
于是有人想:为什么不根据实际负载动态调整步长呢?帧率高的时候步长大一点,帧率低的时候步长小一点。听起来很合理,对吧?
嗯,这个思路确实诱人。但我在项目中吃过它的亏,今天就跟大家聊聊这个「简单但危险」的方案。
4.1 可变步长的基本思路
可变时间步长的核心逻辑就一句话:每帧传入真实的帧间隔时间。代码写起来也特别简单:
// 伪代码:可变时间步长
float dt = clock.getDeltaTime(); // 真实帧间隔
physicsWorld.step(dt); // 直接传入
你看,比固定步长那套累加逻辑简洁多了。没有循环,没有余数处理,一行搞定。性能也好看——场景简单时步长大,计算量小;场景复杂时步长自动变小,精度自然提升。
但问题就出在这个「自动」上。
4.2 三大致命陷阱
我当年第一次用可变步长做物理模拟时,觉得找到了银弹。结果测试刚跑起来,物理世界就炸了——物体穿模、弹簧乱跳、刚体飞向太空。为什么会这样?
说白了,可变步长有三个躲不开的坑:
| 陷阱 | 表现 | 根本原因 |
|---|---|---|
| 数值发散 | 物体抖动、爆炸、穿模 | 步长超过积分器稳定域 |
| 非确定性 | 回放不一致、联机不同步 | 步长依赖真实时间 |
| 帧率耦合 | 高帧率物理变慢、低帧率物理变快 | 物理行为与帧率绑定 |
4.3 数值发散:最直接的灾难
物理模拟本质上是在解微分方程。每个积分器都有稳定的步长范围。拿最简单的显式欧拉来说,它的稳定域是 λ·h < 2(λ 是系统特征值,h 是步长)。
一旦步长超过这个阈值,误差就会指数级放大。我见过一个弹簧系统,步长从 1ms 跳到 8ms,弹簧直接「炸」了——振幅在 3 帧内从 0.1 米飙到 100 米。
更隐蔽的问题是:步长变化本身就会引入误差。积分器假设步长是常数,突然改变步长相当于给系统注入了一个「冲击」,高阶积分器尤其敏感。
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 知识体系总览
下面这张图总结了可变时间步长的核心逻辑和陷阱:
4.8 如果非要用,怎么降低风险?
有些项目确实绕不开可变步长(比如某些离线渲染器)。如果你非用不可,我建议至少做三件事:
- 加硬上限:步长最大不超过 1/20 秒(50ms),超过就截断。虽然会引入「时间暂停」感,但总比物理爆炸强。
- 用高阶积分器:RK4 或隐式欧拉对步长变化更鲁棒。显式欧拉在可变步长下基本是自杀。
- 做步长平滑:不要直接用原始 dt,而是用指数平滑
dt_smooth = α·dt_raw + (1-α)·dt_smooth,避免步长突变。