在前一篇文章中,我们讨论了如何使用数值积分器对运动方程进行积分。集成听起来很复杂,但它只是一种将物理模拟向前推进一小段时间的方法,称为“增量时间”(或简称DT)。
但是如何选择这个增量时间值呢?这看起来可能是一个微不足道的话题,但实际上有很多不同的方法,每种方法都有自己的长处和短处-所以请继续读下去!
前进的最简单方法是使用固定的增量时间,如1/60秒:
DOUBLE t=0.0;DOUBLE DT=1.0/60.0;WHILE(!QUIT){INTEGRATE(STATE,t,DT);RENDER(STATE);t+=DT;}。
在许多方面,该代码都是理想的。如果您足够幸运地让增量时间与显示刷新率匹配,并且可以确保更新循环占用的实时时间少于一帧,那么您已经有了更新物理模拟的完美解决方案,您可以停止阅读本文。
但在现实世界中,您可能无法提前知道显示刷新率。Vsync可能被关闭,或者您可能运行在一台速度较慢的计算机上,该计算机无法以60fps的速度更新和渲染帧。
解决这个问题似乎很简单。只需测量前一帧花费的时间,然后将该值作为下一帧的增量时间反馈回来。当然,这是有意义的,因为如果计算机以60 Hz的速度更新太慢,并且必须降至30fps,您将自动传入1⁄30作为增量时间。对于75 Hz而不是60 Hz的显示刷新率,甚至在速度较快的计算机上关闭Vsync的情况下,情况也是如此:
Double t=0.0;Double currentTime=hires_time_in_sec();While(!Quit){Double newTime=hires_time_in_sec();Double Frame Time=newTime-currentTime;currentTime=newTime;Integrate(state,t,frame Time);t+=frame Time;Render(State);}
但是这种方法有一个巨大的问题,我现在要解释一下。问题是物理模拟的行为取决于您传入的增量时间。效果可能是微妙的,因为你的游戏根据帧速率有稍微不同的“感觉”,或者它可能是极端的,就像你的弹簧模拟爆炸到无限大,快速移动的物体穿过墙壁,玩家跌倒在地板上!
不过,有一件事是可以肯定的,那就是期望您的模拟正确处理传入的任何增量时间是完全不切实际的。要了解原因,请考虑如果将十分之一秒作为增量时间传递会发生什么情况?一秒怎么样?10秒?100秒?最终你会找到一个突破点。
更现实的说法是,只有当增量时间小于或等于某个最大值时,您的模拟才表现良好。在实践中,这通常比尝试在大范围的增量时间值下使模拟防弹容易得多。
有了这些知识,这里有一个简单的技巧,可以确保您在不同的计算机上仍以正确的速度运行时,永远不会经过大于最大值的增量时间:
Double t=0.0;Double dt=1/60.0;Double currentTime=hires_time_in_sec();While(!Quit){Double newTime=hires_time_in_sec();Double Frame Time=newTime-currentTime;currentTime=newTime;While(frame Time>;0.0){Float deltaTime=min(frame Time,dt);Integrate(state,t,deltaTime);frame Time-。
这种方法的好处是我们现在有了增量时间的上限。它永远不会大于这个值,因为如果大于,我们就细分时间步长。缺点是我们现在对每个显示的更新采取多个步骤,包括一个额外的步骤来消耗任何不能被DT整除的帧时间的剩余部分。如果你被渲染限制,这是没有问题的,但是如果你的模拟是你的帧中最昂贵的部分,你可能会陷入所谓的“螺旋式死亡”。
死亡的螺旋是什么?这是当你的物理模拟跟不上要求它采取的步骤时会发生的事情。例如,如果你的模拟被告知:“好的,请模拟X秒的物理”,如果Y>;X需要Y秒的实时时间才能做到这一点,那么不需要爱因斯坦就会意识到,随着时间的推移,你的模拟落后了。之所以称为死亡螺旋,是因为落后会导致您的更新模拟更多的步骤以迎头赶上,这会导致您进一步落后,这会导致您模拟更多的步骤…。
那么我们如何避免这种情况呢?为了确保稳定的更新,我建议留出一些余地。您确实需要确保更新相当于X秒的物理模拟所需的实时时间明显少于X秒。如果你能做到这一点,那么你的物理引擎就可以通过模拟更多的帧来“赶上”任何暂时的峰值。或者,您可以钳制每帧的最大步数,模拟将在重载下显示为减慢。可以说,这比螺旋式死亡要好,特别是如果沉重的负荷只是一个暂时的峰值。
现在让我们再往前走一步。如果您希望在给定相同输入的情况下从一次运行到下一次运行具有精确的重现性,该怎么办?这在尝试使用确定性锁步将物理模拟联网时非常有用,但通常也是一件很好的事情,因为您的模拟在每次运行到下一次运行时的行为完全相同,没有任何潜在的不同行为,具体取决于渲染帧率。
但您会问,为什么需要完全固定的Delta时间才能做到这一点?剩余步长较小的半固定增量时间肯定“足够好”吗?是的,你是对的。它在大多数情况下是足够好的,但由于浮点运算的精度有限,它并不完全相同。
那么我们想要的是两全其美:模拟的固定增量时间值加上以不同帧速率渲染的能力。这两件事看起来完全不一致,它们确实是不一致的-除非我们能找到一种方法来分离模拟和渲染帧速率。
下面是如何做到这一点的。以固定的DT时间步长提前物理模拟,同时确保它与来自渲染器的计时器值保持一致,以便模拟以正确的速率前进。例如,如果显示帧率为50fps,模拟运行速度为100fps,则每次显示更新都需要执行两个物理步骤。很简单。
如果显示帧速率是200fps,该怎么办?在这种情况下,我们需要在每次显示器更新时采取半个物理步骤,但我们不能这样做,我们必须以恒定的DT前进,所以我们每两次显示器更新就采取一个物理步骤。
更棘手的是,如果显示帧速率是60fps,但我们希望我们的模拟以100fps运行,该怎么办?没有容易的倍数。如果禁用了Vsync,并且显示帧速率随帧而波动,该怎么办?
如果你的头刚刚爆炸了,别担心,解决这个问题所需要的就是改变你的观点。不要认为在渲染之前必须模拟一定数量的帧时间,而是将视点颠倒过来,这样想:渲染器生成时间,模拟以离散DT大小的步长消耗时间。
双t=0.0;常量双Dt=0.01;双currentTime=hires_time_in_sec();双累加器=0.0;While(!Quit){Double newTime=hires_time_in_sec();双帧时间=newTime-currentTime;currentTime=newTime;累加器+=frame Time;While(累加器&>;=dt){Integrate(state,t,dt);累加器-=dt;
请注意,与半固定的时间步长不同,我们只与步长大小为dT的步长进行积分,因此在通常情况下,我们在每帧的末尾都会留下一些未模拟的时间。剩余的时间通过累加器变量传递到下一帧,并且不会被丢弃。
但是剩下的这段时间怎么办呢?好像不太对劲,不是吗?
要了解正在发生的情况,请考虑这样一种情况:显示帧速率为60fps,物理运行速率为50fps。没有合适的倍数,所以累加器使模拟在每帧主要采用一个物理步长和偶尔采用两个物理步长之间交替,当剩余物“累积”在DT以上时。
现在考虑大多数渲染帧将在累加器中留下一些不能模拟的帧时间,因为它小于DT。这意味着我们在与渲染时间略有不同的时间显示物理模拟的状态,导致屏幕上的物理模拟出现细微的但视觉上令人不快的卡顿。
此问题的一种解决方案是基于累加器中剩余的时间在先前和当前物理状态之间进行插值:
双t=0.0;双Dt=0.01;双currentTime=hires_time_in_sec();双累加器=0.0;状态上一;当前状态;While(!Quit){Double newTime=Time();Double Frame Time=newTime-currentTime;if(frame Time>;0.25)frame Time=0.25;currentTime=newTime;累加器+=Frame Time;While(累加器&>;=dt){previousState。state state=currentstate*alpha+previousState*(1.0-alpha);Render(State);}。
这看起来很复杂,但这里有一个简单的思考方法。累加器中的任何剩余部分实际上都是在采取另一个完整的物理步骤之前还需要多少时间的度量。例如,dt/2的余数意味着我们目前处于当前物理步骤和下一个物理步骤之间。DT*0.1的余数表示更新是当前状态和下一状态之间的十分之一。
只需除以dt,我们就可以使用这个余数值来获得前一个物理状态和当前物理状态之间的混合因子。这给出了范围[0,1]中的alpha值,它用于在两个物理状态之间执行线性插值,以获得要渲染的当前状态。对于单个值和矢量状态值,此插值很容易完成。如果将方向存储为四元数并使用球面线性插值(SLERP)在先前和当前方向之间混合,则甚至可以将其与完整的3D刚体动力学一起使用。
格伦·菲德勒是Network Next的创始人兼首席执行官。Network Next正在通过创建一个高端网络传输市场来修复游戏的互联网。