插值实际上如何使物体的运动平滑?


10

在过去的8个月左右的时间里,我已经问过几个类似的问题,但并没有真正的高兴,所以我将使这个问题更笼统。

我有一个Android游戏,它是OpenGL ES 2.0。在其中,我有以下游戏循环:

我的循环以固定的时间步长原理工作(dt = 1 / ticksPerSecond

loops=0;

    while(System.currentTimeMillis() > nextGameTick && loops < maxFrameskip){

        updateLogic(dt);
        nextGameTick+=skipTicks;
        timeCorrection += (1000d/ticksPerSecond) % 1;
        nextGameTick+=timeCorrection;
        timeCorrection %=1;
        loops++;

    }

    render();   

我的整合是这样的:

sprite.posX+=sprite.xVel*dt;
sprite.posXDrawAt=sprite.posX*width;

现在,一切都按我的意愿运行。我可以指定我想要一个对象在2.5秒内移动一定距离(例如屏幕宽度),并且它会做到这一点。同样,由于我在游戏循环中允许跳帧,因此我几乎可以在任何设备上执行此操作,并且总是需要2.5秒。

问题

但是,问题在于,当渲染帧跳过时,图形停顿。太烦人了。如果我取消了跳帧的功能,那么一切都会很顺畅,但是会在不同设备上以不同的速度运行。所以这不是一个选择。

我仍然不确定为什么会跳帧,但是我想指出的是,这与性能差无关,我将代码恢复为1个小的sprite,没有任何逻辑(除了移动精灵),但我仍然跳过帧。这是在Google Nexus 10平板电脑上使用的(如上所述,我需要跳帧以保持设备间的速度保持一致)。

因此,我唯一的选择是使用插值法(或外推法),我读过那里的每篇文章,但是没有一篇文章真正地帮助我了解它的工作原理,并且所有尝试的实现都失败了。

使用一种方法,我可以使事情顺利进行,但是这是行不通的,因为它弄乱了我的碰撞。我可以预见任何类似方法都会出现相同的问题,因为插值在渲染时传递给了渲染方法(并在其中起作用)。因此,如果Collision纠正了位置(角色现在正站在墙旁边),则渲染器可以更改其位置并将其绘制墙中。

所以我真的很困惑。人们曾经说过,永远不要在渲染方法中更改对象的位置,但是所有在线示例都显示了这一点。

因此,我要求朝着正确的方向发展,请不要链接到流行的游戏循环文章(deWitters,Fix your timestep等),因为我已多次阅读这些文章。我不是要任何人为我编写代码。请简单地解释一下插值实际上如何与一些示例一起使用。然后,我将尝试将任何想法整合到我的代码中,并在需要时提出更具体的问题。(我确信这是很多人都在努力解决的问题)。

编辑

一些其他信息-游戏循环中使用的变量。

private long nextGameTick = System.currentTimeMillis();
//loop counter
private int loops;
//Amount of frames that we will allow app to skip before logic is affected
private final int maxFrameskip = 5;                         
//Game updates per second
final int ticksPerSecond = 60;
//Amount of time each update should take        
private final int skipTicks = (1000 / ticksPerSecond);
float dt = 1f/ticksPerSecond;
private double timeCorrection;

投票否决的原因是..........?
BungleBonce

1
有时不可能说。在尝试解决问题时,这似乎具有应该解决的所有问题。简洁的代码段,您尝试过的内容的解释,研究尝试,并清楚地说明了问题所在和需要知道的内容。
杰西·多尔西

我不是你的不赞成者,但请澄清一下。您说跳过一帧时图形口吃。这似乎是一个显而易见的声明(错过了一个框架,看起来好像错过了一个框架)。那么您能更好地解释跳过吗?有什么奇怪的事情发生吗?否则,这可能是一个无法解决的问题,因为如果帧频下降,您将无法获得平稳的运动。
塞斯·巴丁

谢谢,Noctrine,当人们不加解释地投票时,这真的让我很生气。@SethBattin,对不起,是的,当然,您是对的,跳帧会引起混乱,但是,某种插值方式应该可以解决问题,就像我上面说的那样,我取得了一些(但有限的)成功。如果我错了,那么我想的问题是,如何使它在各种设备上以相同的速度平稳运行?
BungleBonce

4
仔细重新阅读这些文档。他们实际上并没有在渲染方法中修改对象的位置。他们仅根据方法的最后位置和当前时间(经过了多少时间)修改方法的外观位置。
AttackingHobo 2014年

Answers:


5

要使运动看起来平滑,有两点至关重要,第一点显然是您渲染的内容需要与向用户展示框架时的预期状态相匹配,其二是您需要向用户展示框架以相对固​​定的时间间隔。在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
  1. 首先我们在时间0初始化(所以currentTime = 0)
  2. 我们以1.0(100%currentTime)的比例进行渲染,它将在时间0绘制世界
  3. 完成后,实际时间是3,并且我们不希望帧结束到16,所以我们需要运行一些更新
  4. T + 3:我们从0更新到5(因此之后currentTime = 5,previousTime = 0)
  5. T + 4:仍在帧结束之前,所以我们从5更新为10
  6. T + 5:仍在帧结束之前,所以我们从10更新到15
  7. T + 6:仍在帧结束之前,所以我们从15更新为20
  8. T + 7:仍在帧结束之前,但是currentTime刚好在帧结束之后。我们不想再进行任何模拟,因为这样做会使我们超出下一次要渲染的时间。相反,我们静静地等待下一个渲染间隔(16)
  9. T + 16:是时候再次渲染了。previousTime是15,currentTime是20。因此,如果我们要在T + 16进行渲染,则距离5ms长的时间步长为1ms。因此,我们是整个框架的20%(比例= 0.2)。渲染时,我们在对象的先前位置和当前位置之间以20%的方式绘制对象。
  10. 循环回到3.并无限期继续。

这里还有另一个细微之处,就是要提前进行太长时间的模拟,这意味着即使用户的输入发生在实际渲染帧之前,也可能会忽略用户的输入,但是请不必担心,直到您确信循环顺利进行模拟为止。


注意:伪代码在两个方面都很弱。首先,它没有抓住死亡螺旋的情况(更新需要比fixedTimeStep更长的时间,这意味着模拟远远落在后面,实际上是无限循环),其次,renderInterval再也不会缩短。在实践中,您想立即增加renderInterval,但是随着时间的流逝,应尽可能地将其逐渐缩短,以使其在实际帧时间的一定公差范围内。否则,一次不好/长时间的更新将永远使您陷于低帧率。
MrCranky 2014年

感谢@MrCranky的支持,的确,我在如何“限制”循环渲染中苦苦挣扎多年了!只是无法解决该问题,并想知道这是否可能是问题之一。我将对此进行适当的阅读,并尝试给您提出建议,并回复!再次感谢:-)
BungleBonce

谢谢@MrCranky,好的,我已经阅读并重新阅读了您的答案,但我听不懂:-(我尝试实现它,但是它给了我一个空白的屏幕。与我的移动对象的先前位置和当前位置有关吗?另外,那行“ currentFrame = Update();”又如何呢?否则我打电话给update还是将currentFrame(position)设置为新值?再次感谢您的帮助!!
BungleBonce

是的,有效。之所以将PreviousFrame和currentFrame用作Update和InitialiseWorldState的返回值,是因为要允许渲染绘制世界,因为它处于两个固定的更新步骤之间,因此您不仅需要具有每个位置的当前位置您要绘制的对象,还有它们以前的位置。您可以让每个对象在内部保存两个值,这很麻烦。
MrCranky 2014年

但是也有可能(但要困难得多)构造事物,以便将表示时间T的当前状态所需的所有状态信息都保存在单个对象下。从概念上讲,这在解释系统中存在的信息时要干净得多,因为您可以将帧状态视为由更新步骤产生的某种东西,而保持先前的帧只是保留一个或多个那些帧状态对象。但是,我可能会将答案重写为有点像您实际上可能会实施的答案。
MrCranky 2014年

3

每个人都告诉你的是正确的。切勿在渲染逻辑中更新子画面的仿真位置。

这样想,您的子画面有2个位置;模拟表明他在上次模拟更新时所在的位置,以及渲染精灵的位置。它们是两个完全不同的坐标。

该精灵将在其外推位置进行渲染。计算每个渲染帧的外推位置,该渲染帧用于渲染精灵,然后丢弃。这里的所有都是它的。

除此之外,您似乎已经有了很好的理解。希望这可以帮助。


优秀@WilliamMorrison-感谢您确认这一点,我从来没有真的100%确信是这种情况,我现在认为我正在某种程度上使这项工作正常-欢呼!
BungleBonce

只是好奇@WilliamMorrison,使用这些扔掉的坐标,如何减轻精灵被“嵌入”或“恰好”位于其他对象上的问题-显而易见的例子是2D游戏中的实体对象。您是否还必须在渲染时运行碰撞代码?
BungleBonce

在我的游戏中,这就是我要做的。请比我更好,不要那样做,这不是最好的解决方案。它将渲染代码与不应使用的逻辑复杂化,并在冗余冲突检测上浪费了CPU。最好在倒数第二个位置和当前位置之间进行插值。这可以解决问题,因为您不会外推到不良位置,但是会在使仿真落后一步的情况下使事情复杂化。我很想听听您的意见,采取的方法以及您的经历。
William Morrison 2014年

是的,这是一个棘手的问题。我在这里已经问过一个单独的问题gamedev.stackexchange.com/questions/83230/…如果您想关注它或做出一些贡献。现在,您在评论中提出的建议是,我还没有这样做吗?(在上一帧和当前帧之间进行插值)?
BungleBonce 2014年

不完全的。您实际上现在正在推断。您可以从仿真中获取最新数据,并在小数步长后推断该数据的外观。我建议您通过分数时间步长在最后一个模拟位置和当前模拟位置之间进行插值,以进行渲染。渲染将落后模拟1个时间步。这样可以确保您永远不会在模拟未验证的状态下渲染对象(即,除非模拟失败,否则弹丸不会出现在墙上。)
William Morrison
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.