Fix Your Timestep!
How to step your physics simulation forward
Posted by Glenn Fiedler on Thursday,June 10,2004
Introduction
Hi, I’m Glenn Fiedler and welcome to Game Physics.
在前文中,我们讨论了如何用数值化积分器来积分运动方程。积分听起来很复杂,但它只是一个将你的物理模拟向前推进一小段时间(delta time or dt for short)的方法。
但是,如何选择一个delta time值?这可能看起来像一个琐碎的主题,但是实际上,有很多方法来达到目的,他们每一个都有优缺点-因此接着读吧!
Fixed delta time
最简单的推进方式是用一个固定的delta time,比如60hz:
double t = 0.0;
double dt = 1.0 / 60.0
while( !quit ){
integrate( state, t, dt );
render( state );
t += dt;
}
在很多情况下,这段代码是理想的。如果你有幸让delta time与显示刷新率匹配,并且你可以确保你的更新循环花费的实时时间少于一帧,那么你已经有了更新物理模拟的完美解决方案,你可以停止阅读本文了。
但在现实世界中,您可能不知道显示器的刷新率。VSYNC 可以被关闭,或者你可能要运行在一个很慢的电脑上,不能达到60fps的更新和渲染频速度。
在这些情况下,你的模拟会比你预期的更快或更慢。
Variable delta time
修复这个看起来很简单。只要计算每帧的时间,然后作为delta time反馈给下一帧的计算。这当然是有道理的,因为如果电脑很慢,不能达到60HZ的频率,那么就必须降低到30fps,你将自动传递1/30作为delta time。显示器刷新频率也是这样,75HZ改为60HZ或者只打开VSYNC在快的电脑上:
double t = 0.0;
double currentTIme = hires_time_in_seconds();
while( !quit ){
double newTime = hires_time_in_seconds();
double frameTime = newTime - currentTime;
currentTime = newTime;
integrate( state, t, frameTime );
t += frameTime;
render( state );
}
但是这会遇到一个很大的问题,我们接下来将阐述它。问题是,物理模拟系统的行为,依赖于你传入的delta time。这种效果可能是微妙的,因为你的游戏根据帧速率会有轻微的“感觉”,或者它可能是极端的,就像你的弹簧模拟爆炸到无限远,快速移动的物体穿透墙壁,玩家从地板上掉下来一样。
但有一件事是肯定的,那就是期望你的模拟能够正确处理传递给它的任何增量时间是完全不现实的。为了理解为什么,考虑一下当你传入 1/10 作为delta time到系统里会发生什么?10呢?100呢?最终你会发现一个节点。
Semi-fixed timestep
更现实的是,只有当delta时间小于或等于某个最大值时,您的模拟才会表现良好。在实践中,这通常比试图在大量增量时间值下使仿真防弹更容易。
掌握了这些知识后,以下是一个简单的技巧,可以确保在不同机器上以正确的速度运行时,不会传入大于最大增量的delta time。
double t = 0.0;
double dt = 1 / 60.0;
double currentTime = hires_time_in_seconds();
whilte( !quit ){
double newTime = hires_time_in_seconds();
double frameTime = newTime - currentTime;
currentTime = newTime;
while ( frameTime > 0.0 ){
float deltaTime = min( frameTime, dt );
integrate( state, t, deltaTime);
frameTime -= deltaTime;
t += deltaTime;
}
render( state );
}
这样做的好处是,我们的delta time有了一个上限。它永远不会比上限大,因为我们分解了timestep。坏处是我们在每帧进行了多次的物理计算,更新包括一个额外的步骤,以消耗不可被dt除尽的帧时间的剩余部分。如果你的渲染是性能边界,那么这将不是问题,但是,如果你的模拟是一帧中更昂贵的部分,那么你讲进入“死亡螺旋”。
什么是死亡螺旋呢?这是你的物理模拟到不到所需要的频率时发生的。例如,如果你的模拟说“好的,请模拟X秒的物理状态” 然后它消耗了Y秒,而且Y>X,那么不需要爱因斯坦也知道,你的模拟会落后。它被称为死亡螺旋,因为落后会导致你的模拟越来越难以赶上,会让你越来越落后,导致你模拟更多的步骤。
那么,我们如何避免它?为了保证稳定的更新,我建议留一点空间。您真的需要确保更新x秒物理模拟的实时时间明显少于x秒。如果你可以做到这些了,那么你的物理引擎就可以赶上任何临时峰值通过模拟更多帧。或者,您可以在每帧的最大步数下进行钳制,模拟在大载荷下似乎会减慢。可以说,这比螺旋式的死亡要好,特别是当重负荷只是暂时的尖峰时。
Free the physics
现在,让我们更进一步。如果你想要在相同的输入下从一次运行到下一次运行的精确再现性怎么办?当尝试使用确定性帧同步将物理模拟联网时,这非常有用,但是,通常情况下,知道模拟从一次运行到下一次运行的行为完全相同也是一件好事,并且根据渲染帧速率,不存在任何不同行为的可能性。
但是你问,为什么需要完全固定的delta time来实现?确实有小空余的半固定delta time“足够好”了?是的,你是对的。它在某些情况下足够好了,但是由于浮点运算的精度优先,因此它不是完全的一致。
我们想要的是两个世界中最好的:一个固定的delta time的模拟,和在不同帧率下渲染的能力。这两件事似乎完全不一致-除非我们可以找到一个方法,来将渲染和模拟解耦。
这是如何去做。以固定的dt时间步向前推进物理模拟,同时确保它与来自渲染器的计时器值保持同步,以便模拟以正确的速度前进。例如,如果显示器频率是50fps,模拟运行在100fps,那么我们需要在每个update中,运行两次物理模拟。简单。
那么显示器渲染频率是200fps怎么办?在这个例子中,我们需要每帧运行半步的物理运算,但是我们不能这么做,我们必须用固定的dt推进。因此我们每两帧运行1次物理运算。
更难一点,当显示帧率为60fps,但是我们像用100fps的帧率运行模拟呢?没有简单的倍数。当关闭VSYNC呢?显示帧率随帧数变化而变化?
如果你脑袋爆炸了别担心,所有这一切,只需要改变你的视角。不要去思考在渲染前必须有一段确定的时间,翻转你的视角去考虑:渲染器产生时间然后模拟器消费它,在离散的dt大小的步长中。
例如:
double t = 0.0;
const double dt = 0.01;
double currentTime = hires_time_in_seconds();
double accumulator = 0.0;
while( !quit ){
double newTime = hires_time_in_seconds();
double frameTime = newTime - currentTime;
currentTime = newTime;
accumulator += frameTime;
while( accumulator >= dt ){
integrate( state, t, dt );
accumulator -= dt;
t += dt;
}
render( state );
}
注意,与半固定时间步长不同,我们只集成了步长为dt的步长,因此在一般情况下,在每帧结束时,我们还有一些未模拟的时间。这个剩余时间通过累加器变量传递到下一帧,不会被丢弃。
The final touch
但是剩下的时间怎么办呢?好像不正确,不是吗?
要了解正在发生的事情,请考虑显示帧速率为60fps,物理运行速度为50fps的情况。没有好的倍数,因此当余数在dt上“累积”时,累加器使模拟在每帧主要采取一个物理步骤和偶尔两个物理步骤之间交替进行。
现在,考虑到大多数渲染帧将在累加器中保留一些剩余的帧时间,这些时间不能被模拟,因为它小于dt。这意味着我们在与渲染时间稍有不同的时间显示物理模拟的状态,导致屏幕上物理模拟的细微但视觉上令人不快的结巴。
这个问题的一个解决办法是根据累加器中剩余的时间在先前的物理状态和当前的物理状态之间进行插值。
double t = 0.0;
double dt = 0.01;
double currentTime = hires_time_in_seconds();
double accumulator = 0.0;
State previous;
State current;
while ( !quit ){
double newTime = time();
double frameTime = newTime - currentTime;
if ( frameTime > 0.25 )
frameTime = 0.25;
currentTime = newTime;
accumulator += frameTime;
while ( accumulator >= dt ){
previousState = currentState;
integrate( currentState, t, dt );
t += dt;
accumulator -= dt;
}
const double alpha = accumulator / dt;
State state = currentState * alpha +
previousState * ( 1.0 - alpha );
render( state );
}
这看起来很复杂,这里有一个简单的方法来理解。累加器中的任何余数实际上是衡量在采取另一个物理步骤之前需要多少时间的一个度量。例如,dt/2的余数意味着我们现在处于当前物理步骤和下一个物理步骤之间的中间。余数dt*0.1表示更新是当前状态和下一状态之间的1/10的长度。
我们可以使用这个余数值,简单地除以dt,得到先前物理状态和当前物理状态之间的混合参数。这将给出范围[0,1]内的alpha值,该值用于在两个物理状态之间执行线性插值,以获得要渲染的当前状态。对于单个值和向量状态值,这种插值很容易实现。如果将方向存储为四元数,并使用球面线性插值(slerp)在先前方向和当前方向之间混合,则甚至可以将其与完整的三维刚体动力学一起使用。