游戏编程模式--游戏循环

 游戏循环

  由浅入深,常见的四种循环模式:

  1. 非同步的固定时间步长、
  2. 同步的固定时长、
  3. 变时步长、
  4. 定时更新、变时渲染;

  游戏循环可以说是游戏编程模式中的精髓,几乎所有的游戏都包含它,相比而言,那些非游戏的程序却很难见它的踪影。这是为什么了?原因在于交互。

  解释之前我们先回想一下早期的程序员的工作方式:他们先写好程序,然后把代码丢给计算机,然后计算机执行几个小时再回来查看执行结果。想想如果执行出错,排查错误的工作会怎么样?程序员意识到这个问题,于是交互式编程就诞生了。它有点像这个样子:

  a small stream flows out of the building and down a gully.

  > go in

  you are inside a building.a well house for a large spring.

  你可以和这个程序实时交互,它等待你的输入,并对你的输入进行反馈。当没有输入的时候,计算机就什么也不做。如果用一段代码来描述这个过程,它将是这个样子的:

while(true)
{
    char* command = readCommand();
    handleCommand(command);
}

  当然,现代的计算机程序都是按事件的方式来处理的,所以用事件的概念来写就是:

while(true)
{
    Event* event = waitForEvent();
    dispatchEvent(event);
}

  这种改写本质上与之前的并没有什么区别。但我们发现一个问题,就是用户没有输入的时候,程序就会阻塞在waitForEvent上,但现实中我们的游戏并不会这样,它会一直运行,画面也会不断的更新。所以真实的游戏循环的第一个特点就是:它处理用户输入,但并不等待用户输入,游戏循环始终在执行:

while(true)
{
    processInput();
    update();
    render();
}

  上述代码就是游戏循环最基本的结构了。processInput处理两帧之间的所有用户输入,upate让游戏(数据)模拟迭代一步,它执行游戏AI和物理计算(这是常见顺序);最后render对游戏进行渲染以将游戏内容展现给玩家。

  很明显,上述程序会以尽可能快的速度运行。两个因素决定了帧率:

  1.循环每一帧要处理的信息量,信息量越大需要的时间就越多;

  2.底层平台的速度。速度越快的芯片在相同的时间内能够处理更多的代码。多核、多CPU、专用声卡以及操作系统的调度器都影响着你在一帧中所能处理的代码量。

  早期的游戏每帧被精心设计的刚好能在一帧的时间内完成代码的运行,以便它能够在开发者期望的速度下运行。但假如你在一个稍快或稍慢的机器上运行,则游戏会发生加速或减速的想象。而今,很少有开发者对他们的游戏所运行的硬件平台有精确的了解,取而代之的是他们必须让游戏智能的适配多种硬件机型。而这就是循环模式的另一个特点:这一模式让游戏在一个与硬件无关的速度常量下运行。

  综上,我们可以对游戏循环模式做一个定义:一个游戏循环会在游戏过程中持续的运转。每循环一次,它非阻塞的处理用户的输入,更新游戏状态,并渲染游戏。它跟踪流逝的时间并控制游戏的速率。

 示例代码

     1.最简单的游戏循环

  接下来让我们一步一步的来完善游戏循环的实现方式,首先,先写一个最简单的游戏循环。

while(true)
{
    processInput();
    update();
    render();
}

  这就是之前我们的那个写法,很明显,这个是最简单的实现方法。但它的问题也很明显——你无法控制游戏运转的快慢,它受底层硬件的影响很大。我们可以对它做一些小改动,假设你希望让游戏以60帧每秒的速度运行,即一帧时间大概是16毫秒。假如你能在这16毫秒内进行所有的游戏更新和渲染,那么你的游戏就可以这个稳定的帧率来执行,而你所需要的就是处理这一帧,等待下一帧的到来。

    2.版本2 稍微改进一点 sleep

    代码如下:

while(true)
{
    double start = getCurrentTime();
    processInput();
    update();
    render();
    
    sleep(start+MS_PER_FRAME-getCurrentTime());
}

  这里的一个改进就是当程序不需要一帧的时间就处理完了所有工作,就sleep剩余的时间,保证游戏不会过快的运行。但如果游戏过慢时,这个改进将毫无帮助。

    3. 变长时间步长

     我们还可以尝试更复杂的办法,现在我们的问题是:

  1.每次更新游戏花去一个固定的时间值;

  2.需要花些实际时间来进行更新。

  加入第二步的时间长于第一步,那么游戏会变慢。例如当需要16毫秒以上的时间来更新帧速为16毫秒每帧的游戏时,就可能无法维持运行速度。但假如我们能在单独一帧这样红进行超过16毫秒的游戏状态更新,那么我们可以不那么频繁的更新游戏并且能够赶上游戏的行进速度。具体的想法就是计算这一帧距离上一帧的实际时间间隔作为更新步长。帧处理的实际时间越长,这个步长也就越长。这个办法使得游戏总会越来越接近实际时间。他们称此为变值时间步长(或者浮动时间步长),代码如下:

double lastTime = getCurrentTime();
while(true)
{
    double current = getCurrentTime();
    double elapsed = current - lastTime;
    processInput();
    update(elapsed);
    render();
    
    lastTime = getCurrentTime();
}

   这个方法非常的巧妙,比如子弹飞行的速度,若在之前的循环中,不同机器上子弹表现的速率是不同的,但使用这种浮动步长则可以很好的处理这种情况,在慢的机器上,处理一帧的时间较长,所以步长也长,而在较快的机器上则较短,处理的时候使用(速度*步长)计算子弹在这一帧中行进的距离,这样,不同机器上子弹一秒移动的距离就表现的相同了。看样子好像达到了我们的目的,但这种方式也隐藏这不少的问题。首先就是浮点数计算的问题,我们知道计算做浮点数运算是会有误差的,帧率较高的机器更新的次数多,所以执行的浮点数计算也多,相应的累计误差也更大,这样在积累了足够的误差后,不同的机器上,子弹的位置将不一样。另一个就是会影响物理引擎,游戏的物理引擎通常会做实际物理规则的近似。为了防止这近似计算失控,系统通常都会进行减幅运算。这个减幅运算被小心的安排成以某个固定时长进行,而采用浮动步长将会导致物理引擎也变得不稳定。

     4.固定时间步长   

      那么让我们再观察一下现在的实现,会发现render这一部分是不受步长影响的一部分,通常,游戏引擎中渲染也不会受步长影响。因为渲染引擎表现的是游戏时间中的一瞬间,所以它并不关心距离上次渲染过去了多长时间,它只是把当前游戏状态渲染出来而已。我么可以利用这一点,还是使用固定步长更新,因为这会使物理引擎和AI更为稳定。但允许再渲染的时候进行一些灵活的调整以释放出一些处理器时间。它的方式是这样的:渲染过去了一段真实时间,这一段时间就是我们需要模拟游戏的“当前时间“,以便赶上玩家的实际时间。我们使用一系列的固定步长时间来实现它。代码如下:

double previous = getCurrentTime();
double lag = 0.0;

while(true)
{
    double current = getCurrentTime();
    double elapsed = current - previous;
    previous = current;

    lag += elapsed;
    processInput();

    while(lag >= MS_PER_UPDATE)
    {
        update(); 
        lag -= MS_PER_UPDATE;
    }

    render();
}

   这段代码的逻辑就是追赶的部分不再是更新一次就立即追上玩家时间,而是分多次固定步长来更新。MS_PER_UPDATE这个常量只是我们更新游戏的时间间隔,不再是视觉上的帧率。这一间隔越短,追赶实际时间花费的处理次数就越多。间隔越大,跳帧就越明显。理论上,你希望它足够短,通常快于60FPS,以使游戏维持高保真度。但要注意,别让它过短。你必须保证这个时间步长大于每次update()函数的处理时间,即使再最慢的机器上也如此,否则,你的游戏便跟不上现实时间。

   很幸运,我们赢得了一些喘息的时间。我们通过把渲染拉出更新循环之外来实现这一点。这一方法释放了大量的CPU时间。最后的结果是游戏通过国定时间步长更新,实现了在多硬件平台上以恒定的速率进行游戏模拟。只不过在地段机器上玩家会看到游戏窗口出现跳帧的情况。

      5.最终版本 固定步长更新 渲染时间变长

  不过,这里还有一个问题,就是残留的延迟。我们的更新是固定步长更新,但渲染时随机的时间点。这意味着从玩家的角度来看,游戏常会在两次更新之间渲染出完全相同的画面。还是那子弹的飞行来分析,因为渲染时间点并不总是在更新完成时,也就是说完全可能在两次更新之间渲染,所以就有这样的情况出现,前一帧子弹在屏幕左侧,后一帧子弹在屏幕的右侧,如果渲染在两帧之间进行,那么玩家希望看到的是子弹在屏幕的中间,但按照上面的实现方法,子弹依然会在屏幕的左侧。这意味着动作看起来会显得卡顿而不流畅。那如果处理这种情况了?上面的代码有个问题,就是我们不是在lag等于0的时候退出循环,而是在lag小于MS_PER_UPDATE的时候,那这个时候的lag表示什么了?其实这时候的lag表示的是进入下一帧(渲染)的时间间隔,也就是实际渲染的画面应该是当前再往前更新lag时间的画面。所以一个优化就是把这个时间传入render函数,我们假设当前的变化趋势不变,利用这个时间进行一个预测,比如子弹的速度是400像素每秒,那我们传入0.5秒的时候,那它出现的位置我们就预测为当前位置+200像素。当然这个推断不一定准确,比如子弹在lag时间内碰到墙反弹了,那这个时候计算的位置就不对了。这个我们是没有办法的,除非物理引擎更新完成。但这个瑕不掩瑜,比不预测造成的卡顿情况要好太多。

double previous = getCurrentTime();
double lag = 0.0;

while(true)
{
    double current = getCurrentTime();
    double elapsed = current - previous;
    previous = current;

    lag += elapsed;
    processInput();

    while(lag >= MS_PER_UPDATE)
    {
        update(); 
        lag -= MS_PER_UPDATE;
    }

    render(lag / MS_PER_UPDATE);    //归一化后无需担心帧率
}

 设计决策

  虽然我们已经做了很多的优化了,但依然留下了很多的问题。一旦你考虑诸如与显示器刷新速率的同步、多线程、GPU等因素,实际的游戏循环将会变得复杂许多。在这样的高级层面,你可能需要考虑一下这些问题:

  谁来控制游戏循环

  这是你或多或少要面临的问题。假如你的游戏嵌入浏览器里,那么你往往无法自己编写经典的游戏循环。浏览器自带基于事件的机制已经预先包含了这一循环。类似的,假如你使用了现成的游戏引擎,你也将以来于它的游戏循环而不是你自己来控制。每个方法都有其优缺点:

  •   使用平台的事件循环

  优点是相对简单,你无须担心游戏核心循环的代码和优化的问题;它也会于平台协作的很好,但缺点就是你失去了时间的控制,平台会在其认为合适的时候调用你的代码,而且更糟的是许多应用程序的事件循环的概念并不同于游戏——它们通常很慢且断续。

  •   使用游戏引擎的游戏循环

  这也是大多数游戏采用的做法,因为编写一个好的游戏循环需要很多的技巧,而游戏引擎的通常是由业内非常有经验的人来编写的,其性能和安全性都有保证,唯一的缺点就是当出现一些于引擎循环不那么合拍的需求时,你无法获得循环的控制权。

  •   自己编写游戏循环

  优点很明显,一切都在你的掌控中,但随之而来的是你不得不处理游戏之后的一些事,比如操作系统的事件处理。

  能量损耗

  这里的选择有两个:第一就是尽可能快的跑,这会提供最好的游戏体验但会消耗更多的能量,通常建议在pc上这么干;第二就是限制帧率,在移动平台上,电池电量是有限了,所以我们要节省电量的使用,限制帧率后,在本帧处理完后的空余时间,游戏将会休眠。

  如何控制游戏速度

  上面我们给出了四个版本的游戏循环实现方法,分别是

  •   非同步的固定时间步长

  它是最简单的方式,但游戏速度受硬件平台和游戏复杂度的影响,

  •   同步的固定时长

  它是我们的第二种实现方式,这个依然很简单,它省电而且不会运行过快,但它可能过慢,除非外置帧渲染并同步。

  •   变时步长

  优点是能适应过快或过慢的硬件平台。但采用这种方式,游戏将变得很不稳定,尤其是物理模块和网络模块。

  •   定时更新、变时渲染

  这个是我们实现中最复杂的方式,能适应过快或过慢的硬件平台。但缺点就是复杂,要真正使用还有很多的工作要做。

  

 

posted @ 2019-09-19 23:55  北冥有鱼其名为鲲  阅读(1352)  评论(0编辑  收藏  举报