游戏循环

提起电子游戏,大家应该对 FPS 这个单位并不陌生,这是用来描述帧速率/帧频 (frame rate) 的。帧速率是动画流畅度的一个指标。玩家们都知道,游戏如果的没有稳定运行在某个固定的帧速率(比如 60 fps) 的时候会卡。另一方面,帧速率是令游戏开发者头痛的一个指标,因为为了游戏流畅运行需要对程序一遍又一遍地优化。实际上要优化什么呢?就是我们今天要讲的主角:游戏循环 (game loop) ——它是游戏程序的核心部分,有如我们的心跳。

首先,我们先简单分析一下游戏的核心构造和流程是什么样子的。

电子游戏 (electronic game, aka video game) 实质上是一种实时的、动态的交互式模拟 (simulation)。

也就是说,实际上,我们制作游戏所能实现的是:建立了一个“虚拟世界”,让玩家沉浸在其中并与之产生交互。

正如其名,电子游戏是需要依附于特定的硬件上的,因此,它们的运作方式受到硬件条件所制约。比如我们的程序得符合 CPU 运作规律;画面的更新频率也受到到屏幕、显卡 GPU 的限制;玩家与游戏世界的并不能直接交互,只能通过某些特定的设备 (HID) 间接操作及获得反馈等等。

更简单地说,电子游戏是一种人机交互,而且游戏运作不可能超过机器的承载能力。

游戏循环模式

再进一步分析,游戏中的人与机器的交流,是有一个普遍适用的模式的,不管是非实时或是实时的交互。

举个例子,一个“找不同”游戏:

这个游戏展现两张图片给玩家;接受他们的点击(或触摸);解释这些输入信号为成功、失败、暂停、菜单交互等等;最后,根据输入结果计算出将要更新的场景画面。

这个游戏循环是由用户输入所驱动的,一直待机直到有新的输入。这是更偏向基于回合的实现方式,不需要持续地更新每一帧,仅当玩家响应时运转。

古老的文字冒险游戏采用的也是这种方式,像命令行程序一样,系统会等待用户输入才进行下一步。

while (true)
{
  render();
  char* command = waitForInput(); // blocking
  processInput(command);
  update();
}

而另外一些游戏,可能需要精确控制到更细分的时间切片,甚至是玩家没有输入时也会继续循环,即程序不会为了等待用户输入而阻塞 (blocked)。

这是更常见的循环。因为对于大多数游戏,即使你坐下来看着屏幕,不做任何操作,游戏画面也不是静止不动的,动画会继续播放,视觉特效仍闪烁舞动,如果运气不好的话,怪物还会对你操控的英雄角色穷追不舍。

这种实时的方式同样能套用上面的模式。

while (true)
{
  processInput(); // non-blocking
  update();
  render();
}

区别只是每一轮循环没有了阻塞。这也称作游戏的主循环 (main loop)。

将其画成一个流程图,如下:

Game Loop Pattern


阻塞还是非阻塞,这是个问题…

说到这里,让我想起在 Arduino 环境下的编程(与 Arduino 相关的还有 Processing )。

Arduino 的主程序只有 2 个函数, setup()loop()

void setup() {
  // the setup function runs once when you press reset or power the board
}

void loop() {
  // the loop function runs over and over again forever
}

setup() 是程序初始化时要执行的内容,之后进入无限循环的 loop()

先看看 Arduino 中著名的 Hello World 级别的程序之一 Blink

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
}

void loop() {
  digitalWrite(LED_BUILTIN, HIGH);   // turn the LED on (HIGH is the voltage level)
  delay(1000);                       // wait for a second
  digitalWrite(LED_BUILTIN, LOW);    // turn the LED off by making the voltage LOW
  delay(1000);                       // wait for a second
}

Blink 程序的作用是让 Arduino 板载的 LED 闪烁。delay 是一个延时函数,程序执行到那里就会阻塞 (blocking) 直至计时结束。

这种写法很直观,很适合初学者,用来教学是没问题的。但实际生产环境,如果你将大部分逻辑都只写在一个循环体就会显得一团遭。

而且更严重的问题是,这样的写法很难兼顾多个东西同时运作(如要检测按钮按下,LED 闪烁频率要不一样,同时控制多个伺服电机等等)。因为一旦阻塞了流程,其他任务是无法执行的。

再看看另一个稍微复杂一点的例子程序 BlinkWithouDelay

const int ledPin = LED_BUILTIN;  // the number of the LED pin
int ledState = LOW;               // ledState used to set the LED
unsigned long previousMillis = 0; // will store last time LED was updated
const long interval = 1000;       // interval at which to blink (milliseconds)

void setup() {
  pinMode(ledPin, OUTPUT);
}

void loop() {
  // here is where you'd put code that needs to be running all the time.

  // check to see if it's time to blink the LED; that is, if the difference
  // between the current time and last time you blinked the LED is bigger than
  // the interval at which you want to blink the LED.
  unsigned long currentMillis = millis();

  if (currentMillis - previousMillis >= interval) {
    // save the last time you blinked the LED
    previousMillis = currentMillis;

    // if the LED is off turn it on and vice-versa:
    if (ledState == LOW) {
      ledState = HIGH;
    } else {
      ledState = LOW;
    }

    // set the LED with the ledState of the variable:
    digitalWrite(ledPin, ledState);
  }
} 

抛弃了 delay() 之后,可以基于时间差值去判断是否更新状态。如果要让多个东西同时运作,以不同的更新频率,只需照葫芦画瓢,增加类似的代码段。不过为了代码看起来优雅一点以及提高可读性,可以再深入一点点,用上 C++ 的类

这种处理方法,不是已经跟一般的游戏循环很像了吗?!

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

FPS++

若循环不再因等待用户输入而阻塞,那么就引出另一个问题了:要运转多快?

循环的每一轮要花费多少时间。用来衡量游戏循环频率有多高,就是常说的 FPS (frame per second) 了。其中的帧 (frame) 是业界常用的术语。这个术语出同样广泛用于动画/电影行业,是指动画序列中的单个画面。而这里的帧是指游戏循环中渲染过程所生成的画面。

根据动画的原理,人眼会看高于 10~12fps 的画面就会认为是连贯的。帧速率越高,画面表现更流畅。

而 60fps 并不是人眼感知的极限,那为什么偏偏选 60fps 呢?

查阅一些资料之后得知,这涉及到我们硬件的问题。目前大部分屏幕都支持 60Hz 的刷新率。而如果 GPU 的渲染速度与屏幕刷新速度不同步的话 ,frame rate ≠ refresh rate,会产生屏幕撕裂 (Screen Tearing) 现象。因此,为兼容大部分屏幕,普遍选择 60fps 的渲染速度。

对于那些新式的设备来说,支持 60fps 可能最低的要求了。

至此,大家应该了解 60fps 是什么意思了。60fps 稍微换算一下,即是 16.666… 毫秒一个周期。

所以,我们优化的方向就是尽可能让机器在 16 毫秒左右内完成循环体内的所有运算工作。

优化,优化…

此外,游戏循环针对不同需求有不同的实现方案,单机游戏和网络游戏采取的游戏同步策略不一样。不同的平台下的实现方式也有差异,比如浏览器环境下就不能直接用 while 死循环来写游戏循环,因为那样会阻塞整个画面的更新,取而代之要用 setTimeout 或 RAF 回调函数。甚至,在更强大的硬件条件下,比如多核、多线程环境下可以将渲染循环分离出来等等。不过,这些已经不是本文讨论范围之内了。

但循环体内要做的工作基本上都离不开那 3 个主要步骤:

1. 处理输入

首先,来看看处理输入 (process input) 这一步。它的主要任务是采集玩家的通过硬件设备输入的数据,提供给下一步更新 (update) 时运用。由于这个过程一般不会损耗太多系统资源,所以对于游戏循环的影响不大。

对于习惯了 GUI 编程的开发者来说,到这里可能会有点不习惯。GUI 应用开发的框架(甚至某些游戏引擎),所提供的 API 是基于 Event 机制的。而 Event 机制的实现并没有什么魔法,其实也是需要通过循环去检测用户输入的,这叫 Event Loop。

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

也就是说,在游戏循环里面处理输入跟 Event Loop 做的工作差不多,区别只是不需要派发事件,而是需要直接利用这些数据,原始的数据,不再封装为事件。

因此,假如你所开发的平台 API 不直能同步读取输入数据的话,你还得自己再绕回来,比如通过事件响应函数将输入数据缓存下来。

2. 更新

更新 (update) 是处理大部分游戏逻辑的一个重要环节。还可能含有物理、 AI 等耗费较多计算资源的处理过程。参与运算的还有用户输入的信号和时间相关的参数。

当对游戏进行性能优化的时候,这一部分是优先考虑的。因为目标很明确,就是想尽办法减少运算量。

同时,这一部分的优化也很吃力,因为关系到游戏逻辑,能去掉的部分很少,多数情况只能优化算法。随着游戏自身的复杂程度增加,每一帧要更新的内容亦会增加。

此外,部分逻辑实际上不需要像画面一样 60fps 的频率更新,根据需要降低频率也能达到很好的优化效果。

条件允许的话,还可以将复杂的并行计算放到 GPU 里处理,以减少 CPU 负担。

3. 渲染

终于到了渲染 (render) 这一步了。最激动人心的时刻到了,前面辛辛苦苦的积累都是为了最终生成一个华丽的画面反馈给玩家。

但这也是非常耗费资源的一个过程,想要游戏流畅运行,必须先解决渲染的性能问题。不过好消息是,对于现代的游戏开发者来说,这个已经不是什么难题了。因为自从有了硬件图形加速,可以将大部分图形运算交给 GPU 处理,渲染所占用的 CPU 时间可以很少很少,从而不会耽误游戏循环的速度,轻轻松松上 60fps。