游戏编程设计模式——Game Loop

意图

将游戏时间的进度从玩家输入和处理器速度中分离出来。

动机

如果让我选一个本书最不能少的模式,那就是这个。游戏循环是游戏编程模式中最精髓的一个例子。几乎所有的游戏都会有它,再也没有第二个应用如此广泛的。但是在游戏之外有很少用到。

要看它是如何起作用的,让我们把记忆拉回到过去。在哪个编程跟洗碗工一样的青葱岁月。你需要把一堆代码塞进机器,按下按钮,等待,然后出结果。这就是批次模式的编程——一旦工作完成,程序就停止运行了。

你今天仍然能够看到,虽然谢天谢地我们不要在卡片上写代码了。Shell脚本,命令行程序,甚至是那些本书中的简化小Python脚本,都是批次模式的编程。

采访CPU

最后,当程序员们发现必须先把一堆代码放到计算办公司,然后回来等几个小时,才能拿到结果。这改起bug来就会非常慢。他们希望能够得到及时的反馈。交互式编程诞生了,一些最早的交互式程序,就是游戏:

  1. YOU ARE STANDING AT THE END OF A ROAD BEFORE A SMALL BRICK
  2. BUILDING . AROUND YOU IS A FOREST. A SMALL
  3. STREAM FLOWS OUT OF THE BUILDING AND DOWN A GULLY.
  4. > GO IN
  5. YOU ARE INSIDE A BUILDING, A WELL HOUSE FOR A LARGE SPRING.
复制代码


你就可以跟程序实时进行交流。它会等待你的输入,然后响应你。你可以再回复,轮流进行就像你在幼儿园学到的那样。轮到你的时候,它就静静地坐在那里,一动不动。就像这样:

  1. while (true)
  2. {
  3.   char* command = readCommand();
  4.   handleCommand(command);
  5. }
复制代码


事件循环

当今的图形化UI应用很像以前的冒险游戏,当你取下他们的皮肤就会发现这一点。你的word处理器通常也是直到你按键或者点击时才开始做事。

  1. while (true)
  2. {
  3.   Event* event = waitForEvent();
  4.   dispatchEvent(event);
  5. }
复制代码


不同的地方只有它不用文本命令行,而是用用户的输入事件——鼠标点击或者按键。它仍然像文字冒险游戏那样等待用户输入。

不像这些通用软件,游戏在用户不提供输入的时候也要进行下去。如果你就坐在屏幕前发呆,游戏也不会停止。动画依然会播放,特效依然会舞动。如果你不幸运,怪物有可能就开始咬你的英雄了。

这就是游戏循环的第一个关键点:它处理用户输入,但是并不等待输入。循环照样运行。

  1. while (true)
  2. {
  3.   processInput();
  4.   update();
  5.   render();
  6. }
复制代码


我们后面会继续完善,但是基本的元素都有了。processInput()处理了从上一次调用以来的所有用户输入。然后,update()进行一步游戏模拟。运行AI和物理(通常按照这个顺序)。最后,render()绘制一遍游戏,让玩家能看到发生了什么。

延迟的世界

如果输入操作不打断游戏循环,那一个明显的问题就会冒出来:循环运行的有多快?每一次循环都要处理游戏中大量的状态。我们可以看到游戏世界中的居民,也可以看到他们钟表的指针滴答向前。

与此同时,玩家的时钟也在在跑。如果要衡量游戏循环的快慢,我们可以用“每秒帧数”这个概念。如果游戏循环快,FPS就会很高,游戏也会更流畅,更快速。如果游戏循环慢,游戏就会卡得像幻灯片。

在我们现有的简陋循环中,我们让它有多快,跑多快,两个方面决定帧率。第一个是每帧它需要干多少工作。复杂的物理运算,大量的游戏对象,大量的图形细节会让你的CPU和GPU异常忙碌,因此,它会让完成一帧的时间更长。

第二个因素是底层平台的速度。更快的芯片可以在相同时间内运行更多的代码。多核心,多GPU,专业音频硬件,和操作系统的调度都决定了以在一个时间片中能做多少工作。

时间缩涨

在早期的视频游戏中,游戏循环的时间间隔是固定的。如果你要写一个NES或者Apple IIe的游戏,你会很清楚地知道你的游戏会运行在什么样的CPU上。所有你所担心的就只剩下每一个时间片要处理多少工作。

老游戏都很小心地编写代码,好让计算机在每一帧处理正好足够的运算,好让游戏能够以开发者希望的速度运行。但是如果这个游戏在更快或者更慢的机器上跑,那游戏就会加速或者减速。

现在,很少有开发者能够清楚地知道自己的游戏将会运行在什么样的硬件上。相反,我们的游戏必须适应千变万化的设备。

这就是游戏循环的另外一个关键点:它让游戏在不同的底层硬件上也能以相同的速度运行。

模式

游戏循环在整个游戏过程中在不停地运行着,每一次循环,它都不停顿地处理用户输入,更新游戏状态,渲染游戏。它通过切分一小段时间,来控制游戏速度。

何时用

设计模式要是用错了,还不如不用,所以这个部分有必要提一个醒。设计模式的目的不是让你在代码中强行插入。

但是这个模式有一点不同。我们在使用的时候可以非常自信。如果你在使用游戏引擎,即使不自己写,它也会被用到。

你可能认为在回合制游戏中可以不用游戏循环。但是即使玩家不输入的时候游戏不进行,但视觉和听觉是不断变化的。动画和音乐在你等待回合的时候也是在运行的。

记住

我们在这里讨论的是游戏中最重要的代码。有人说程序90%的时间在跑10%的代码。游戏循环一定在这10%里面。小心处理这些代码,并时刻注意它们的效率。

你可能需要协调与平台事件循环的关系

如果你要在一个具有内置图形UI和事件循环的操作系统上开发游戏,就会有两天循环运行在游戏中,你需要把它们用好。

有时,你可以只用你自己的循环。例如,如果你在用Windows API编写游戏,你的main()函数可以创建一个游戏循环。在里面你,你可以调用PeekMessage()去处理和分发操作系统的事件。不像GetMessage(),PeekMessage()不能中会中断等待用户输入,所以你的游戏循环可以持续运行下去。

有的平台,你不能这么容易的操作事件循环。如果你的目标平台是浏览器,浏览器事件循环的实现被深埋在浏览器的内核中。因此,事件循环就会显式的运行,你就应该把它当做你自己的游戏循环使用了。你可以调用像requestAnimationFrame()这样的函数,去驱动你的游戏运行。

简单的代码

经过这么长的介绍,游戏循环的代码就已经很清楚了。我们来看几种变化,并分析他们的优缺点。

游戏循环驱动了AI,渲染,和其他游戏系统,但是这些并不属于这个模式,所以我们这里只写一个简单的函数调用。像render(),update()以及其他函数的实现,就当做读者的自己的练习吧。

能跑多快跑多快

我们已经看到一个最简单的游戏循环:

  1. while (true)
  2. {
  3.   processInput();
  4.   update();
  5.   render();
  6. }
复制代码


这样做的问题在于,你不能控制游戏能跑多快。在快机器上,循环运行的飞快,以至于玩家都反应不过来发生了什么。在满机器上,游戏就会卡顿。如果你有一些很重的内容,或者有AI和物理,游戏就会运行的很慢。

打一个小盹

第一个变化我们来看一个简单的解决办法。如果你希望游戏运行在60FPS。也就是给每一帧大约16毫秒的时间。只要你的游戏处理和渲染在小于这个时间的时间内完成,你可以得到一个稳定的帧率。你只需要处理这一帧,然后等待,直到开始下一帧,就像这样:
 


代码如下:

  1. while (true)
  2. {
  3.   double start = getCurrentTime();
  4.   processInput();
  5.   update();
  6.   render();
  7.   sleep(start + MS_PER_FRAME - getCurrentTime());
  8. }
复制代码


这里的sleep()保证了即使计算机处理的很快,也不会导致游戏运行的更快。但是如果游戏运行的太慢,就没啥用了。如果你更新和渲染一帧用时超过16毫秒,你的sleep()函数就会帮倒忙。如果我们用的是穿越回来的电脑,一切问题都解决了,可惜事与愿违。

相反,如果游戏太慢了,我们需要减少每一帧要做的工作——砍掉一些图形效果,减弱AI。但是这回影响所有玩家的游戏体验,包括那些拥有高端机器的人。

一小步,一大步

让我们试一下更复杂的办法。这个问题我们可以简化成:

1、每一次更新把游戏时间前进了一个固定的时间段。

2、处理游戏运算的时候,实际上使用了一段固定的真实时间。

当后者用时超过前者时,游戏就被放慢了。如果我们用多于16ms的时间去处理应该在16ms内完成的事情,那就不可能保持应有的游戏速度。但是如果我们让游戏前进多于16ms的进度,那就可以保持游戏速度,只不过刷新频率变慢了而已。

这个想法就要去确定一个时间段,它代表了从上一帧到现在经过了多少真实时间。这段时间越长,游戏进度的步子就越大。它总会与真实时间相匹配,因为它会一点点趋近于真实时间。我们把这种方法叫做可变的或者动态时间段。就像这样:

  1. double lastTime = getCurrentTime();
  2. while (true)
  3. {
  4.   double current = getCurrentTime();
  5.   double elapsed = current - lastTime;
  6.   processInput();
  7.   update(elapsed);
  8.   render();
  9.   lastTime = current;
  10. }
复制代码


每一帧,我们都会计算从上一帧开始我们耗费了多少真实时间。当我们更新游戏状态的时候,我们把这个时间穿进去。游戏引擎会根据这个时间去调整游戏世界的进度。

比如,你有一颗穿过屏幕的子弹。在固定的时间段内,每一帧,你可以根据他的速度移动它。在一个变化的时间段内,你可以根据这个时间段去缩放他的速度。随着时间段的变大,每一帧它走过的路程也就越长。这颗子弹将会在相同的真实时间内穿越屏幕。无论它在一个两倍快的机器上,还是在四倍慢的机器上。这样看起来已经胜利了:

在不同的机器上,游戏运行的进度相同。

机器更快的玩家,可以得到更流畅的游戏体验。

但是,这里有一个潜在的问题:我们给游戏增加了不确定性和不稳定性,我这里有一个例子:

比如,我又一个双人联网对战的游戏,弗雷德有一台牛逼游戏计算机,但乔治用了一台古老的PC。前面说的那颗子弹,在他们的屏幕上穿越。在弗雷德的机器上,游戏运行的很快,所以每一帧时间都很短。比如,子弹穿越屏幕用了50帧。但在可怜的乔治的机器上,可能只有5帧。

这就意味着,在弗雷德的机器上,物理引擎更新了子弹的位置50次,但在乔治的机器上只进行了5次。大多数游戏使用了浮点数,他们会出现化整误差。每次你把两个浮点数相加,得到的结果可能不同。弗雷德的机器多进行了十倍的运算,游戏账号买卖所以他可能会有比乔治更大的误差。所以同样一颗子弹可能停在不同的地方。

这只是其中一个比较严重的问题,还有更多。为了能够实时运行,物理引擎往往用逼近法去模拟力学原理。为了不让逼近法失控,会加入一些阻尼。这些阻尼是按步起作用的。所以,这也会让物理引擎变得不稳定。

这些例子很严重,鞭策我们继续改进…

加把劲

引擎中的一个部分不会收帧率变化的影响,那就是渲染。因为渲染引擎只是绘制一瞬间的内容,它不关心与上一次绘制之间的时间有多长。它渲染的是当前的一切。

我们可以利用这一点进行改进。我们可以用固定的时间间隔去更新游戏,这样会让一切都更简单更稳定,像物理和AI。而我们可以去调整渲染的速度,以节省一些处理时间。

就像这样:从上一次更新开始,到现在所用的一个固定的时间段,我们把这段时间当成真实世界比我们“领先”的时间。我们用一连串的固定时间来追赶它,代码如下:

  1. double previous = getCurrentTime();
  2. double lag = 0.0;
  3. while (true)
  4. {
  5.   double current = getCurrentTime();
  6.   double elapsed = current - previous;
  7.   previous = current;
  8.   lag += elapsed;
  9.   processInput();
  10.   while (lag >= MS_PER_UPDATE)
  11.   {
  12.     update();
  13.     lag -= MS_PER_UPDATE;
  14.   }
  15.   render();
  16. }
复制代码


在每一帧的开始,我们根据消耗的真实时间,更新lag。这代表了游戏时间比真实世界时间慢了多少。然后我们在内部起一个循环去更新游戏,每一步消耗一个固定时间段,直到它追上现实时间。一旦我们干上它,我们再开启渲染。就像这样:
 


注意这里的时间间隔不再是可见的帧率了。MS_PER_UPDATE只是我们用来更新游戏的时间粒度。间隔越短,追赶真实时间的处理次数就越多。间隔越长,游戏变化就月剧烈。你会希望这个时间足够短,最好比60FPS还要快,这样游戏就会在快机器上模拟的更准确。

但是要小心不能搞的太短,你需要确保这个时间段要比update()的时间长,即使是在最慢的机器上。否则,你的游戏就会跟不上真实时间。

幸运的是,我们为自己留了一些余地。我们把渲染强行从更新循环中分拆出来。这也节省了很大一部分CPU时间。渲染的结果就是游戏可以用固定的时间间隔和恒定的速度运行,不管在什么样的机器上。只不过在慢机器上,玩家看到的变化稍微剧烈一些。

卡在中间

我们还遗留了一个问题。我们用固定的时间间隔更新游戏,但是我们在不确定的时间点渲染。这意味着在玩家看来,游戏经常在两次更新之间进行显示。

这是时间线:
 


你看到了,我们的更新操作很紧凑,间隔固定。然而我们的渲染却不一样,比更新频率要低,并且不稳定。这样也还好。令人不爽的是渲染并不一定跟更新在一起。看一下第三次渲染。它正好在两次更新之间:
 


想像一下,一颗子弹要穿越屏幕。在第一次更新的时候,它在屏幕左边。第二次更新的时候它移动到了屏幕右边。游戏在两次更新之间做了渲染,所以玩家期待的应该是子弹出现在屏幕中央。在我们现有的实现中,它仍然会出现在屏幕左侧。这就意味着游戏看起来会磕磕绊绊。

不过,我们知道渲染的时间离两次更新的时间有多久:它储存在lag里面。当它小于更新间隔时,我们跳出更新循环,而不是为0的时候。这就是我们距离下一次更新的时间。

当我们渲染的时候,我们把它传进去。

  1. render(lag / MS_PER_UPDATE);
复制代码


渲染器知道每一个游戏对象和它当前的速度。发现子弹离屏幕的左边缘20像素,并且每帧向又移动400像素。如果我们在两帧的正中间,我们会传入0.5给render()。因此,它就会把子弹华仔中间,在220像素上。哒哒!平滑的运动。

当然,这也许会造成一个错误结果。当我们计算下一帧的时候,可能会发现子弹遇到了障碍,或者减速了,或者有别的变化。我们渲染的位置有可能跟它下一帧的位置冲突。但是如果不完全更新完物理和AI,我们也不知道。

所以,这种推演或多或少的是在猜测并且有时会出错。幸运的是这种错误不容易被发现。至少它不会比卡顿更容易被感受到。

设计决策

限于篇幅,有更多的内容我们并没有讲。一旦搀和进了像垂直同步,多线程,多GPU这些东西,一个真正的游戏循环就会变得很恐怖。在更高的层次,有几个问题你需要考虑:

是你控制游戏循环,还是平台?

更多情况下,这不需要你选择。如果你的游戏运行在浏览器上,你就不能写你自己的传统意义上的游戏循环。浏览器的基于事件的机制天然支持。与之相似,如果你用现成的游戏引擎,你就直接用它提供的游戏循环,而不需要自己造轮子。

使用平台的消息循环:

1> 简单,你不需要担心写和优化游戏的核心循环。

2> 它可以在平台上很好的运行。你不需要关注是不是要给平台时间去处理它自己的消息,缓存消息,或者处理输入冲突。

3> 你会丧失对时间的控制。平台会在他觉得合适的时间去调用你的代码。如果调用频率和流畅度你不满意,那也没招,更可恶的是,大多数应用消息循环在设计的时候压根就没考虑游戏,所以经常很慢,切不稳定。

使用游戏引擎的循环:

1>你真的没必要自己写。写一个游戏循环是很蛋疼的事情。因为核心代码每一帧都会调用,所以一些很小的bug和效率问题都会大大的影响你的游戏。一个可靠的游戏循环是选择已有引擎的一个很重要的原因。

2> 你不去写它,当然,带来的问题就是,即使引擎不能完美的满足你,你也束手无策。


自己写:

1> 完全控制。你可以为所欲为。你可以设计成最适合你游戏的方式。

2> 你必须要面对平台。应用框架或者操作系统通常都需要处理一些自己的消息。如果你接管了循环,它就得不到这些消息了。你需要手动控制这些消息,好让框架不被卡死。

如何管理能量消耗?

这不像5年前那样,游戏是运行在街机或者一些专用的手持设备上。现在出现在了智能手机,笔记本,和其他移动设备上。就出现了你需要关注的问题了。一个游戏运行的很漂亮,但是不到半小时就把玩家的手机搞的没电了,这不会让玩家开心。

现在,你不仅需要考虑你的游戏看起来很漂亮,还需要考虑尽可能的少用CPU。也就是当你在一帧中做完了该做的时间,就让CPU去sleep。

尽可能的快跑:

这可能是PC游戏的做法(也可能运行在笔记本上)。你的游戏循环可能从不让操作系统调用sleep,相反,空余的时钟,会被用来提高FPS,或者图形表现力。

这回给你带来最好的游戏体验,但是也会耗费更多的电。如果玩家用笔记本,他们就会得到一个很好的暖腿宝。

限制帧率:

移动端游戏通常更注重游戏性而不是图形表现。很多游戏都会这只帧率上限(通常是30或者60FPS)。如果游戏循环在时间限制前完成工作,它会调用sleep歇一歇。

这给了玩家“足够的”体验,并且让电池更抗用一些。

怎样控制游戏速度?

一个游戏循环有两个要点:不阻断的用户输入和适应时间分割。输入很直观,关键在于你如何处理时间。有无数的平台可以跑游戏,但一款游戏只能在少数几个平台上跑,如何适应变化是关键。

固定时间间隔并且不同步

这就是我们的第一个示例代码。你的游戏循环有多快,跑多快。

1> 简单,这是主要(呃,也是唯一的)好处。

2> 游戏速度完全依赖硬件和游戏复杂度。主要缺点就是只要有一点变化就会影响游戏速度。这正是游戏循环要解决的。

带同步的固定时间间隔

进一步复杂一点的就是使用固定时间间隔但是加入验车或者同步点,避免游戏运行的太快。

1> 也很简单。它只比前面的多了一行代码。在多数游戏循环中,不管怎样,都要进行同步。至少你需要双缓存你的图形,并且同步翻转缓冲区,去刷新显示。

2> 对能量很友好。这对移动游戏显得更重要。你不希望干掉玩家的电池。你只需要让它sleep几毫秒,而不是把更多的处理塞进去,就会节省能量。

3> 游戏不会跑的太快,这就消除了固定间隔的一半忧虑。

4> 游戏跑太慢。如果它用太长的时间去更新和渲染,游戏也会变慢。因为这种做饭并没有把更新和渲染分离。它不仅会降低帧率,也会降低游戏速度。

可变时间间隔

我在这里把它当成一种可选方案,因为我认识的开发者,大多数都不赞成这种做法。最好记住为什么它是一个坏想法:

1> 它适应过快或者过慢。如果游戏不能追上真实时间,它就会增加时间间隔,直到追上。

2> 它会让游戏变得不可控,不稳定。这才是真正的问题。在可变的时间间隔下,物理和网络就会变得更难控制。

固定的更新时间,可变的渲染

我们最后展示的代码是最复杂的,但也是适应性最强的。它用固定时间间隔更新,但是如果需要,可以丢掉渲染帧去追赶真实时间。

1> 它适应过快或过慢。只要游戏能够及时更新,游戏时间就不会落后。如果玩家的机器够好,那就会得到更流畅的游戏体验。

2> 它更复杂。主要的缺点就是实现起来更复杂,你必须确定一个更新时间间隔,足够小已适应高端机器,足够大以适应低端机器。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值