游戏设计模式读书笔记:游戏循环

Posted by 蔡华的博客 on May 6, 2018

游戏循环

  • 游戏循环是一个游戏系统中最为关键的环节,可以说只要开发游戏就会用到这个模式,只不过它很多时候已经被集成到了引擎当中。在使用Unity引擎的过程中,可以体会到一个游戏中所有的表现都依赖于循环。
  • 不同于传统软件循环中只等待输入的情况,一个游戏循环在游玩中不断运行。每一次循环,它无阻塞地处理玩家输入,更新游戏状态,渲染游戏。它追踪时间的消耗并控制游戏的速度。
  • 有时候在某个平台开发游戏循环时,需要使用平台特有的事件循环。

帧率(FPS)

  • 如果我们用实际时间来测算游戏循环运行的速度,就得到了游戏的“帧率”(FPS)。 如果游戏循环的更快,FPS就更高,游戏运行得更流畅、更快。 如果循环得过慢,游戏看上去就像是慢动作电影。
  • 两个因素决定了帧率:
    • 一个是每帧要做多少工作。复杂的物理,众多游戏对象,图形细节都让CPU和GPU繁忙,这决定了需要多久能完成一帧。
    • 另一个是底层平台的速度。 更快的芯片可以在同样的时间里执行更多的代码。 多核,GPU组,独立声卡,以及系统的调度都影响了在一次滴答中能够做多少东西。
  • 当前的游戏开发中,游戏循环的一个重要任务就是不管潜在的硬件条件,以固定速度运行游戏。

循环的演化

跑,能跑多快跑多快
  • 下面这个可以说是最简单的循环,它完全不控制帧率,可能在性能好的设备上运行的非常快,在不好的设备上运行缓慢。
while (true)
{
  processInput();
  update();
  render();
}
休息一下

假设你想要你的游戏以60FPS运行。这样每帧大约16毫秒。 只要你用少于这个的时长进行游戏所有的处理和渲染,就可以以稳定的帧率运行。 你需要做的就是处理这一帧然后等待,直到处理下一帧的时候,就像这样:

image

while (true)
{
  double start = getCurrentTime();
  processInput();
  update();
  render();

  sleep(start + MS_PER_FRAME - getCurrentTime());
}
  • 但是如果每帧超过了16ms,那么仍旧无法保障帧率。
一小步,一大步
  • 游戏循环中需要实现变化的时间间隔。如果说我们在每次更新中将游戏时间推动一个固定量,那么当每帧的真实执行时间超过这个固定量时,会导致游戏时间慢与实际时间。
  • 因此一般都会将上一帧的真实执行时间作为参考来决定这一帧游戏时间的推动量。
double lastTime = getCurrentTime();
while (true)
{
  double current = getCurrentTime();
  double elapsed = current - lastTime;
  processInput();
  update(elapsed);
  render();
  lastTime = current;
}
  • 每一帧,我们计算上次游戏更新到现在有多少真实时间过去了(即变量elapsed)。 当我们更新游戏状态时将其传入。 然后游戏引擎让游戏世界推进一定的时间量。

  • 这样处理的问题:不同性能的设备会导致帧率不同,从而计算浮点型数据的结果在一秒内会有偏差。对于联机游戏来说就是问题了。现在很多的联机游戏已经是在服务器计算位置等数据了,避免了不同设备性能差异造成的错误。

追逐时间
  • 其本质是在固定时间间隔更新游戏。但是这个更新的内容却并非是游戏的全部内容,而是与物理、AI等相关的部分。用过unity的同学应该已经感觉有点熟悉了,这个在unity中就是FixedUpdate。
  • 对于render,因为它只是将某一个时间点的内容显示出来,因此并不受动态时间间隔的影响,只要保证每一帧都做一次就好。

image

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();
}
  • 上面的代码某种程度是也是unity内部的实现。而里面的update函数可以换做FixedUpdate,这也是为何在输出log的情况下,可能会发现一个Update中会穿插多个FixedUpdate。因为unity中默认是FixedUpdate是一秒执行50次,即0.02秒一次,但是如果说一帧的真实时间超过了0.02s,那么为了追赶时间一帧中会执行多次FixedUpdate。
卡在中间
  • 其实上面一步已经算是比较完善的循环过程,在固定的时间更新游戏,而在任意一个时刻渲染。但是这样也意味着某次渲染可能是在两次更新之间。如下图所示,在第三次渲染时刚好渲染的时间点处于更新的中间。

image

  • 这个会造成什么问题呢?如果说是一颗子弹在运行,玩家在渲染的一瞬间期望看到的是子弹处于两个更新点之间的位置,但是因为下一个Update没有执行,所以真正看到的是子弹在左边的位置。也就是说期望与现实不符。其实人眼很可能根本看不到这么细微的差别,但是这个问题确实是存在的。

image

  • 为了解决这个问题我们可以采用这样的一个方式render(lag / MS_PER_UPDATE);, 在上面的代码中可以看出,lag经过计算后在render调用前的值为到下一帧的值。因此在渲染时考虑这个时间可以平滑的预计算物体的位置。但是这个位置并不一定是正确的,因为也许物体会碰撞发生位置的变化,但是这个必须要等下一帧中的update里面确认。
  • PS:我并不确定这个操作是否有实际的意义。

设计决策

拥有游戏循环的是你,还是平台?
  • 在使用循环的时候我们可能会遇到这样三种情况:
    • 处于某个特定的平台,而这个平台有自己的循环,比如web browser上开发游戏就会受到浏览器本身的事件驱动机制阻碍。
    • 已经在使用某个游戏引擎了,比如说unity引擎,它就已经具备了内置的循环并且通过Update函数开放。
    • 编写自己的循环
  • 以上三种情况各有利弊:
    • 平台循环可以让你不必编写和优化自己的核心循环,但是你失去了对于时间的控制。
    • 游戏引擎循环与平台的情况基本一致。而且目前我们大多数开发都在使用商业引擎。
    • 自己编写当然最大的好处是完全可以自己控制,但是如果你在某个平台或者引擎上开发就需要考虑如何与其它循环协作的问题。
如何管理能量消耗?
  • 随着移动游戏时代的到来,越快越好的策略被舍弃,因为必须考虑手机设备的电量使用和CPU过度使用而造成的发热问题。
  • 移动游戏更加注意游戏的体验质量,而不是最大化图像画质。 很多这种游戏都会设置最大帧率(通常是30或60FPS)。 如果游戏循环在分配的时间片消耗完之前完成,剩余的时间它会休眠。这给了玩家“足够好的”游戏体验,也让电池轻松了一点。
你如何控制游戏速度?
  • 固定时间步长,没有同步:也就是最开始的那套循环,完全依赖于硬件设备的性能。这个最大的弊端是影响游戏速度。
  • 固定时间步长,有同步:它的前提是游戏运行帧率很高,所以加了限制。这样可以节约电量,但是如果一旦帧率不够高其实也会造成游戏时间比现实时间慢的问题。
  • 动态时间步长:能适应并调整,避免运行得太快或者太慢。 如果游戏不能追上真实时间,它用越来越长的时间步长更新,直到追上。但是它也让游戏不确定而且不稳定。这是真正的问题,当然。在物理和网络部分使用动态时间步长会遇见更多的困难。
  • 固定更新时间步长,动态渲染:能适应并调整,避免运行得太快或者太慢。 只要能实时更新,游戏状态就不会落后于真实时间。如果玩家用高端的机器,它会回以更平滑的游戏体验。但是它更复杂。主要负面问题是需要在实现中写更多东西。 你需要将更新的时间步长调整得尽可能小来适应高端机,同时不至于在低端机上太慢。

参考