《游戏程序设计模式》 2.2 - 游戏循环

intent

    把用户输入、处理器速率与游戏时间解耦合。

motivation

    如果有一种这本书不能不讲的模式,那么就是这个模式。游戏循环(Game Loop)是游戏程序设计模式的精粹。几乎每个游戏都使用它,还并不完全一样,而相对的,游戏之外的程序很少使用这个模式。

    为了看它到底多有用,我们快速回忆下。在过去的电脑编程中,程序的工作就行洗碗机。你倾倒一大堆代码进去,按一个按钮,等着,然后得到结果。完毕。这些是批处理程序-一旦工作完成,程序结束。

    今天你仍然能看到它,只是不必写到打孔卡上了。shell脚本,命令行,甚至把一堆markdown变成这本书的Python小脚本都是批处理程序。

interview with a cpu

    最终,程序员意识到把一批代码留在办公室,几个小时后回来取结果是一个找出程序bug的很可怕很慢的方法。他们想要即时反馈。交互式程序出现了。首先出现的一部分交互式程序就是游戏:

YOU ARE STANDING AT THE END OF A ROAD BEFORE A SMALL BRICKBUILDING . AROUND YOU IS A FOREST. A SMALLSTREAM 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);
}

Event loops

    现代图形应用,如果你剥掉它的外壳,与以前文字冒险游戏是一样的。文本处理器在你按下一个键或点击一些东西之前,什么都不做:

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

    主要的不同就是text command换成了user input event-鼠标点击和键盘事件。它仍然像文字冒险游戏,程序会因为等待输入而阻塞,这是个问题。

    不像其它大多数软件,游戏在没有输入的情况下仍然运行。如果你盯着看,游戏画面不会冻结。动画会一直播放。视觉效果飞舞闪烁。如果你不走运,怪物会啃你的英雄。

    这是游戏循环的第一个关键部分:它等待输入,但是不能阻塞。循环总是继续:

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

    后面我们将会改进它,但是基本步骤还是都在的。processInput处理上次调用以来的输入。update更新一次游戏。它处理AI和物理检测(通常按此顺序)。最后render绘制游戏,这样玩家就知道发生了什么。

a world out of time

    如果循环不会因为输入阻塞,那么将会导致一个明显的问题:以多快的速度循环?每一次游戏循环会更新一定量的游戏状态。从游戏中居民角度来看,它们的时钟已经向前走了一下。

    同时玩家的时钟也在走。如果以真实时间测量游戏循环的次数,我们就得到了“每秒帧数”(fps)。如果游戏循环快,fps就高,游戏运行平滑流畅。如果慢,游戏就会抽搐像定格动画。

    通过原始的游戏循环,它能尽可能快地运行,影响帧率的有两个因素。第一个是,每一帧要做多少工作。复杂的物理计算,大量的游戏对象,和许多图像细节会使你的CPU和GPU忙碌,会花费更长时间完成一帧。

    第二个是,底层平台的速度。更快的芯片可以在相同时间处理更多代码。多核CPU,GPU,专用音频硬件和操作系统的调度,都会影响一帧的工作量。

seconds per second

    在早期的视频游戏中,第二个因素是固定的。如果你为NES或APPLE IIe写游戏,你需要确切知道CPU型号,然后专门为其编码。所有你需要担心的是,每一帧能做多少工作。

    旧的游戏被小心编码,每一帧做足够的工作使可以以需要的速度运行。如果你在一个更快或更慢的机器上运行游戏,游戏速度会加快或减慢。

    现在,很少开发者知道游戏运行的硬件。相反,游戏必须智能地适应不同的设备。

    这就是另一个关键的部分:游戏不管什么设备都要以固定速度运行。

the pattern

    游戏循环在游戏运行中会持续不断的执行。每一次循环,它不阻塞的处理用户输入,改变游戏状态,渲染游戏。它追踪时间的流逝控制游戏的速度。

when to use it

    使用错的模式比不使用更糟,所以这章正常提醒不要过度热情。设计模式的目标不是尽可能将模式塞满代码。

    但是这个模式不同。我可以肯定你会使用这个模式。如果你使用一个游戏引擎,即使不自己写,它仍然被使用了。

    你可能以为如果你写一个回合制游戏不会用到它。即使游戏状态不变,视觉的和音频的部分也会更新。动画和音乐都会运行,当游戏等待玩家回合时。

keep in mind

    我们这里讨论的是游戏最重要的一部分代码。有句话说“90%的时间花费在10%的代码上”。游戏循环的代码绝对在那10%中。注意这些代码,注意它的效率。

you may coordinate with the platform's event loop

    如果你为一个有内置消息循环os或平台写游戏,你会有两个循环。你需要使两个协调运行。

    有时,你可以掌控只使用你自己的循环。例如,如果你用windows api写游戏,你的main只能有一个循环。里面,你可以调用PeekMessage处理分发系统消息。不像GetMessage,PeekMessage获取用户输入不会阻塞,你的循环会一直运行。

    其他平台不会让你轻易退出消息循环。如果你的目标是浏览器,消息循环是深深地内置在执行模型里的。你要使用内置循环作为循环。你会调用类似requestAnimationFrame函数,这个函数调用你的代码,保证游戏运行。

sample code

    对于这么长的介绍,游戏循环的代码其实非常直白。我们将会看看几个变种,分析优点和缺点。

    游戏循环驱动AI,绘制和其它游戏系统,但是这不是这个模式的重点,所以我们直接调用虚构的函数。实现render,update还有其它的留给读者当做练习。

run,run as fast as you can

    我们已经看过最简单的游戏循环:

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

    这个的问题是你无法控制游戏循环的速度。在快机器上,它运行的很快。在慢机器上,它运行的像龟速。如果,你在一帧还有大量工作,像ai或者物理等,要做,那么还会更慢。

take a little nap

    第一个变种,我们添加一个简单的修改。假设你想让游戏有60fps。一帧有16毫秒。只要你可以在这时间内完成游戏处理和绘制的工作,你就可以保证一个稳定的帧率。所有你需要做的就是处理一帧,等待下一帧的绘制,就像:

    19104044_iYS9.png

    代码像这样:

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

    sleep保证了,如果一帧处理的很快,循环不会执行太快。但是,如果游戏运行太慢,它就毫无用处。如果update和render花费时间超过16ms,sleep时间将会是负值。如果,我们能使电脑时间回退,一切都会很简单,很可惜,我们不能。

    相反,游戏慢下来了。你可以通过减少一帧的工作量解决此问题-减少图形和特效或者简化AI。但是,这会影响游戏质量,甚至在快机器上。

one small step,one giant step

    让我们尝试一些更复杂的方法。我们的问题基本上归结为:

    1.每一次update都会更新一定量的游戏时间

    2.会花费一定量的现实时间来处理update

    如果,第二步比第一步用时长,游戏就会慢下来。如果我们想通过16ms来更新超过16ms的游戏内容,那么我们将无法保持。但是,我们可以通过超过16ms的时间,更新超过16ms的游戏内容,降低update的频率,这样仍能保持。

    主意就是根据自上一帧依赖经过的现实时间来更新游戏时间。一帧需要的时间越长,游戏更新的时间也就越长。游戏总是能跟上现实时间,因为它一次更新的游戏时间就是根据现实时间来的。它们被称为可变或流动时间步长。像这样:

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

    每一帧,我们计算从上一帧以来,流逝了多少现实时间。当我们更新游戏状态,我们将这个时间传进去。引擎根据这个时间更新游戏。

    假设有一颗子弹从屏幕射过。通过固定时间步长,每一帧,子弹根据速度移动。通过可变时间步长,你可以根据流逝的时间缩放子弹速度。随着时间步长变大,子弹一帧移动的距离也会变大。子弹将会在相同现实时间内通过屏幕,不管是20小步还是4大步。这看起来像个胜利者:

  •     游戏以一致的速率运行在不同的硬件上。

  •     玩家使用快机器会得到更流畅的效果。

    但是,有一个潜伏的严重问题:游戏不确定也不稳定。这里有一个陷阱:

    假设有一个二人网络游戏,fred有一个高性能游戏机,george有一个老古董pc。上述子弹从两人的屏幕上飞过。在fred的机器上,游戏运行很快,所以每个时间步长很小。我们假设,子弹用50帧穿过屏幕。在George的机器上可能只有5帧。

    这说明在fred的机器上,物理引擎更新子弹位置50次,但是George只有5次。大多数游戏使用浮点数,容易产生舍入误差。每一次你相加两个浮点数,你得到的答案会有一点误差。fred计算的次数是George的10倍,所以fred的误差会比George大。同一个子弹在不同的机器上会到达不同的位置。

    这只是可变时间步长导致的一个棘手问题,还有很多问题。为了以现实时间运行,游戏物理引擎逼近真实力学定律。为了使模拟不飞起,会使用阻力。阻力小心地调到一个确定时间步长。步长不同,物理就变得不稳定。

    不稳定是很恶心的,这里的例子只是一个反面例子,这引导我们走向更好……

play catch up

    不受可变时间步长影响的部分通常是渲染。因为渲染引擎捕获的是一瞬,并不关心经过了多长时间。它绘制碰巧出现的东西。

    我们可以利用这个事实。我们将会以固定时间步长更新游戏,因为这样更简单也更稳定。但是,何时渲染可以有灵活性,为了释放处理器时间。

    就像这样:一定量的现实时间从上一帧流逝。这就是我们需要模拟的游戏时间,以赶上现实时间。我们以固定时间步长做这些事。就像这样:

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根据流逝的现实时间。这个用来计算游戏时间落后现实时间多少。我们再写一个内部循环更新游戏,一步是固定时间,直到赶上现实时间。一旦我们要赶上,我们渲染,然后从头再来。你可以想象成这样:

    19171143_CRHH.png

    注意,这里的时间步长不再是可见的帧。MS_PER_UPDATE是我们更新游戏的粒度。步长越短,想赶上现实时间需要处理的时间越长。所需时间越长,游戏波动越大。理想情况下,你想它很短,快过60fps,这样游戏在快机器上可以模拟得高保真。

    但是不能太短。你必须确保时间步长大于update所需的时间,甚至在最慢的机器上。否则,你的游戏不可能赶得上现实时间。

    幸运的是,我们有一些喘息的空间。诀窍是,把渲染从update中拿出来。这将节省大量cpu时间。最终结果就是游戏在不同的硬件上以恒定速度运行。只是在慢机器上,游戏会波动。

(未完)

转载于:https://my.oschina.net/xunxun/blog/491254

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值