要使运动看起来平滑,有两点至关重要,第一点显然是您渲染的内容需要与向用户展示框架时的预期状态相匹配,其二是您需要向用户展示框架以相对固定的时间间隔。在T + 10ms处呈现一个帧,然后在T + 30ms处呈现一个帧,然后在T + 40ms处呈现一个帧,即使模拟显示的是正确的,对用户来说也似乎正在颤抖。
您的主循环似乎缺少任何选通机制,无法确保仅定期进行渲染。因此,有时您可能会在渲染之间进行3次更新,有时可能会进行4次。基本上,您的循环将尽可能多地渲染,只要您模拟了足够的时间以将模拟状态推到当前时间之前,您就会然后渲染该状态。但是,更新或渲染所花费的时间以及帧之间的间隔的任何可变性也将有所不同。模拟的时间步长固定,渲染的时间步长可变。
您可能需要的是在渲染之前等待,以确保仅在渲染间隔开始时才开始渲染。理想情况下,它应该是自适应的:如果您花费太长时间进行更新/渲染并且间隔的开始已经过去,则应该立即渲染,但也要增加间隔长度,直到可以一致地渲染和更新并且仍然可以间隔结束之前的下一个渲染。如果您有足够的空闲时间,则可以缓慢减小间隔(即增加帧速率)以再次渲染。
但是,这就是问题所在,如果您在检测到模拟状态已更新为“现在”后没有立即渲染帧,则会引入时间混叠。呈现给用户的帧是在错误的时间呈现的,其本身感觉就像是断断续续。
这就是您阅读的文章中提到的“部分时间步长”的原因。它在那里是有充分的理由的,这是因为除非您将物理时间步固定为固定渲染时间步的某个固定整数倍,否则根本无法在正确的时间呈现帧。您最终提出它们的时间太早或太晚。获得固定的渲染速率并仍然呈现物理上正确的东西的唯一方法是接受渲染间隔到来时,您很可能会在两个固定的物理时间步之间处于中间位置。但这并不意味着在渲染过程中会修改对象,只是渲染必须临时确定对象的位置,以便可以将它们渲染到更新之前和之后的某个位置。这很重要-永远不要更改渲染的世界状态,只有更新才能更改世界状态。
因此,将其放入伪代码循环中,我认为您需要更多类似的东西:
InitialiseWorldState();
previousTime = currentTime = 0.0;
renderInterval = 1.0 / 60.0; //A nice high starting interval
subFrameProportion = 1.0; //100% currentFrame, 0% previousFrame
while (true)
{
frameStart = ActualTime();
//Render the world state as if it was some proportion
// between previousTime and currentTime
// E.g. if subFrameProportion is 0.5, previousTime is 0.1 and
// currentTime is 0.2, then we actually want to render the state
// as it would be at time 0.15. We'd do that by interpolating
// between movingObject.previousPosition and movingObject.currentPosition
// with a lerp parameter of 0.5
Render(subFrameProportion);
//Check we've not taken too long and missed our render interval
frameTime = ActualTime() - frameStart;
if (frameTime > renderInterval)
{
renderInterval = frameTime * 1.2f; //Give us a more reasonable render interval that we actually have a chance of hitting
}
expectedFrameEnd = frameStart + renderInterval;
//Loop until it's time to render the next frame
while (ActualTime() < expectedFrameEnd)
{
//step the simulation forward until it has moved just beyond the frame end
if (previousTime < expectedFrameEnd) &&
currentTime >= expectedFrameEnd)
{
previousTime = currentTime;
Update();
currentTime += fixedTimeStep;
//After the update, all objects will be in the position they should be for
// currentTime, **but** they also need to remember where they were before,
// so that the rendering can draw them somewhere between previousTime and
// currentTime
//Check again we've not taken too long and missed our render interval
frameTime = ActualTime() - frameStart;
if (frameTime > renderInterval)
{
renderInterval = frameTime * 1.2f; //Give us a more reasonable render interval that we actually have a chance of hitting
expectedFrameEnd = frameStart + renderInterval
}
}
else
{
//We've brought the simulation to just after the next time
// we expect to render, so we just want to wait.
// Ideally sleep or spin in a tight loop while waiting.
timeTillFrameEnd = expectedFrameEnd - ActualTime();
sleep(timeTillFrameEnd);
}
}
//How far between update timesteps (i.e. previousTime and currentTime)
// will we be at the end of the frame when we start the next render?
subFrameProportion = (expectedFrameEnd - previousTime) / (currentTime - previousTime);
}
为此,所有要更新的对象都需要保留有关它们以前所在位置和现在位置的知识,以便渲染可以使用其有关对象所在位置的知识。
class MovingObject
{
Vector velocity;
Vector previousPosition;
Vector currentPosition;
Initialise(startPosition, startVelocity)
{
currentPosition = startPosition; // position at time 0
velocity = startVelocity;
//ignore previousPosition because we should never render before time 0
}
Update()
{
previousPosition = currentPosition;
currentPosition += velocity * fixedTimeStep;
}
Render(subFrameProportion)
{
Vector actualPosition =
Lerp(previousPosition, currentPosition, subFrameProportion);
RenderAt(actualPosition);
}
}
让我们以毫秒为单位布置时间线,即渲染需要3毫秒才能完成,更新需要1毫秒,更新时间步固定为5毫秒,渲染时间步开始(并保持)为16毫秒[60Hz]。
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
R0 U5 U10 U15 U20 W16 R16 U25 U30 U35 W32 R32
- 首先我们在时间0初始化(所以currentTime = 0)
- 我们以1.0(100%currentTime)的比例进行渲染,它将在时间0绘制世界
- 完成后,实际时间是3,并且我们不希望帧结束到16,所以我们需要运行一些更新
- T + 3:我们从0更新到5(因此之后currentTime = 5,previousTime = 0)
- T + 4:仍在帧结束之前,所以我们从5更新为10
- T + 5:仍在帧结束之前,所以我们从10更新到15
- T + 6:仍在帧结束之前,所以我们从15更新为20
- T + 7:仍在帧结束之前,但是currentTime刚好在帧结束之后。我们不想再进行任何模拟,因为这样做会使我们超出下一次要渲染的时间。相反,我们静静地等待下一个渲染间隔(16)
- T + 16:是时候再次渲染了。previousTime是15,currentTime是20。因此,如果我们要在T + 16进行渲染,则距离5ms长的时间步长为1ms。因此,我们是整个框架的20%(比例= 0.2)。渲染时,我们在对象的先前位置和当前位置之间以20%的方式绘制对象。
- 循环回到3.并无限期继续。
这里还有另一个细微之处,就是要提前进行太长时间的模拟,这意味着即使用户的输入发生在实际渲染帧之前,也可能会忽略用户的输入,但是请不必担心,直到您确信循环顺利进行模拟为止。