游戏循环(The Game Loop)

在本章中,我们将通过创建游戏循环来开始开发游戏引擎。游戏循环是每个游戏的核心部分,它基本上是一个无休止的循环,负责周期地处理用户的输入、更新游戏状态和渲染图像到屏幕上。

下述代码片段展示了游戏循环的结构:

  1. while (keepOnRunning) {
  2. handleInput();
  3. updateGameState();
  4. render();
  5. }

那么,这就完了吗?我们已经完成游戏循环了吗?显然还没有,上述代码中有很多缺陷。首先,游戏循环运行的速度将取决于运行它的计算机。如果计算机足够快,用户甚至看不到游戏中发生了什么。此外,这个游戏循环将消耗所有的计算机资源。

因此,我们需要游戏循环独立于运行的计算机,尝试以恒定速率运行。假设我们希望游戏以每秒50帧(50 Frames Per Second,50 FPS)的恒定速率运行,那么游戏循环代码可能是这样的:

  1. double secsPerFrame = 1.0d / 50.0d;
  2. while (keepOnRunning) {
  3. double now = getTime();
  4. handleInput();
  5. updateGameState();
  6. render();
  7. sleep(now + secsPerFrame getTime());
  8. }

这个游戏循环很简单,可以用于一些游戏,但是它也存在一些缺陷。首先,它假定我们的更新和渲染方法适合以50FPS(即secsPerFrame等于20毫秒)的速率更新。

此外,我们的计算机可能会优先考虑暂停游戏循环运行一段时间,以运行其他的任务。因此,我们可能会在非常不稳定的时间周期更新游戏状态,这是不符合游戏物理的要求的。

最后,线程休眠的时间精度仅仅只有0.1秒,所以即使我们的更新和渲染方法没有消耗时间,也不会以恒定的速率更新。所以,如你所见,问题没那么简单。

在网上你可以找到大量的游戏循环的变种。在本书中,我们将用一个不太复杂的,在大多数情况下都能正常工作的方法。我们将用的方法通常被称为定长游戏循环(Fixed Step Game Loop)。

首先,我们可能想要单独控制游戏状态被更新的周期和游戏被渲染到屏幕的周期。为什么要这么做?因为以恒定的速率更新游戏状态更为重要,特别是如果使用物理引擎。相反,如果渲染没有及时完成,在运行游戏循环时渲染旧帧也是没有意义的,我们可以灵活地跳过某些帧。

让我们看看现在的游戏循环是什么样的:

  1. double secsPerUpdate = 1.0d / 30.0d;
  2. double previous = getTime();
  3. double steps = 0.0;
  4. while (true) {
  5. double loopStartTime = getTime();
  6. double elapsed = loopStartTime - previous;
  7. previous = loopStartTime;
  8. steps += elapsed;
  9. handleInput();
  10. while (steps >= secsPerUpdate) {
  11. updateGameState();
  12. steps -= secsPerUpdate;
  13. }
  14. render();
  15. sync(loopStartTime);
  16. }

使用这个游戏循环,我们可以在固定的周期更新游戏状态。但是如何避免耗尽计算机资源,使它不连续渲染呢?这在sync方法中实现:

  1. private void sync(double loopStartTime) {
  2. float loopSlot = 1f / 50;
  3. double endTime = loopStartTime + loopSlot;
  4. while(getTime() < endTime) {
  5. try {
  6. Thread.sleep(1);
  7. } catch (InterruptedException ie) {}
  8. }
  9. }

那么上述方法做了什么呢?简而言之,我们计算游戏循环迭代应该持续多长时间(它被储存在loopSlot变量中),休眠的时间取决于在循环中花费的时间。但我们不做一整段时间的休眠,而是进行一些小的休眠。这允许其他任务运行,并避免此前提到的休眠准确性问题。接下来我们要做的是:

  1. 计算应该退出这个方法的时间(这个变量名为endTime),并开始游戏循环的另一次迭代。
  2. 比较当前时间和结束时间,如果没有到达结束时间,就休眠1毫秒。

现在是构建代码库以便开始编写游戏引擎的第一个版本的时候了。但在此之前,我们来讨论一下控制渲染速率的另一种方法。在上述代码中,我们做微休眠是为了控制需要等待的时间。但我们可以选择另一种方法来限制帧率。我们可以使用垂直同步(Vertical Synchronization),垂直同步的主要目的是避免画面撕裂。什么是画面撕裂?这是一种显示现象,当正在渲染时,我们更新图像储存区,导致屏幕的一部分显示先前的图像,而屏幕的另一部分显示正在渲染的图像。如果启用垂直同步,当GPU中的数据正被渲染到屏幕上时,我们不会向GPU发送数据。

当开启垂直同步时,我们将与显卡的刷新率同步,显卡将以恒定的帧率渲染。用下述一行代码启用它:

  1. glfwSwapInterval(1);

有了上述代码,就意味着至少在一个屏幕更新被绘制到屏幕之前,我们必须等待。事实上我们不是直接绘制到屏幕上。相反,我们将数据储存在缓冲区中,然后用下面的方法交换它:

  1. glfwSwapBuffers(windowHandle);

因此,如果启用垂直同步,我们就可以实现稳定的帧率,而不需要进行微休眠来检查更新时间。此外,帧率将与设备的显卡刷新率相匹配,也就是说,如果它设定为60Hz(60FPS),那么我们就有60FPS。我们可以通过在glfwSwapInterval方法中设置高于1的数字来降低这个速率(如果设置为2,将得到30FPS)。

让我们整理一下源代码。首先,我们将把所有的GLFW窗口初始化代码封装在一个名为Window的类中,传递一些基本的参数(如标题和大小)。Window类还提供一个方法以便在游戏循环中检测按下的按键:

  1. public boolean isKeyPressed(int keyCode) {
  2. return glfwGetKey(windowHandle, keyCode) == GLFW_PRESS;
  3. }

除了有初始化代码以外,Window类还需要知道窗口大小被调整。因此需要设置一个回调方法,在窗口大小被调整时调用它。回调方法将接收帧缓冲区(渲染区域,简单来说就是显示区域)的以像素为单位的宽度和高度。如果希望得到帧缓冲区的宽度和高度,你可以使用glfwSetWindowSizeCallback方法。屏幕坐标不一定对应像素(例如,具有视网膜显示屏(Retina Display)的Mac设备)。因为我们将在进行OpenGL调用时使用这些信息,所以要注意像素不在屏幕坐标中,你可以通过GLFW的文档了解更多信息。

  1. // 设置调整大小回调
  2. glfwSetFramebufferSizeCallback(windowHandle, (window, width, height) -> {
  3. Window.this.width = width;
  4. Window.this.height = height;
  5. Window.this.setResized(true);
  6. });

我们还将创建一个Renderer类,它将处理我们游戏的渲染。现在,它仅会有一个空的init方法,和另一个用预设颜色清空屏幕的方法:

  1. public void init() throws Exception {
  2. }
  3. public void clear() {
  4. glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
  5. }

然后我们将创建一个名为IGameLogic的接口,它封装了我们的游戏逻辑。这样,我们就可以让游戏引擎在不同的游戏上重复使用。该接口将具有获取输入、更新游戏状态和渲染游戏内容的方法。

  1. public interface IGameLogic {
  2. void init() throws Exception;
  3. void input(Window window);
  4. void update(float interval);
  5. void render(Window window);
  6. }

然后我们将创建一个名为GameEngine的类,它将包含我们游戏循环的代码,该类将实现储存游戏循环:

  1. public class GameEngine implements Runnable {
  2. //...
  3. public GameEngine(String windowTitle, int width, int height, boolean vSync, IGameLogic gameLogic) throws Exception {
  4. window = new Window(windowTitle, width, height, vSync);
  5. this.gameLogic = gameLogic;
  6. //...
  7. }

vSync参数允许我们选择是否启用垂直同步。你可以看到我们实现了GameEngine类的run方法,其中包括游戏循环:

  1. @Override
  2. public void run() {
  3. try {
  4. init();
  5. gameLoop();
  6. } catch (Exception excp) {
  7. excp.printStackTrace();
  8. }
  9. }

GameEngine类提供了一个run方法,该方法将执行初始化任务,并运行游戏循环,直到我们关闭窗口。关于线程需要注意的一点是,GLFW需要从主线程初始化,事件的轮询也应该在该线程中完成。因此,我们将在主线程中执行所有内容,而不是为游戏循环创建单独的线程。

在源代码中,你将看到我们创建了其他辅助类,例如Timer(它将提供用于计算已经过的时间的实用方法),并在游戏循环逻辑中使用它们。

GameEngine类只是将inputupdate方法委托给IGameLogic实例。在render方法中,它也委托给IGameLogic实例并更新窗口。

  1. protected void input() {
  2. gameLogic.input(window);
  3. }
  4. protected void update(float interval) {
  5. gameLogic.update(interval);
  6. }
  7. protected void render() {
  8. gameLogic.render(window);
  9. window.update();
  10. }

在程序的入口,含有main方法的类只会创建一个GameEngine实例并运行它。

  1. public class Main {
  2. public static void main(String[] args) {
  3. try {
  4. boolean vSync = true;
  5. IGameLogic gameLogic = new DummyGame();
  6. GameEngine gameEng = new GameEngine("GAME",
  7. 600, 480, vSync, gameLogic);
  8. gameEng.run();
  9. } catch (Exception excp) {
  10. excp.printStackTrace();
  11. System.exit(-1);
  12. }
  13. }
  14. }

最后,在本章中我们只需要创建一个简单的游戏逻辑类。它只会在按下上或下键时,变亮或变暗窗口的颜色缓冲区的清空颜色,render方法将会用这个颜色清空窗口的颜色缓冲区。

  1. public class DummyGame implements IGameLogic {
  2. private int direction = 0;
  3. private float color = 0.0f;
  4. private final Renderer renderer;
  5. public DummyGame() {
  6. renderer = new Renderer();
  7. }
  8. @Override
  9. public void init() throws Exception {
  10. renderer.init();
  11. }
  12. @Override
  13. public void input(Window window) {
  14. if (window.isKeyPressed(GLFW_KEY_UP)) {
  15. direction = 1;
  16. } else if (window.isKeyPressed(GLFW_KEY_DOWN)) {
  17. direction = -1;
  18. } else {
  19. direction = 0;
  20. }
  21. }
  22. @Override
  23. public void update(float interval) {
  24. color += direction * 0.01f;
  25. if (color > 1) {
  26. color = 1.0f;
  27. } else if ( color < 0 ) {
  28. color = 0.0f;
  29. }
  30. }
  31. @Override
  32. public void render(Window window) {
  33. if (window.isResized()) {
  34. glViewport(0, 0, window.getWidth(), window.getHeight());
  35. window.setResized(false);
  36. }
  37. window.setClearColor(color, color, color, 0.0f);
  38. renderer.clear();
  39. }
  40. }

render方法中,当窗口大小被调整时,我们接收通知,以便更新视口将坐标中心定位到窗口的中心。

创建的类层次结构将帮助我们将游戏引擎代码与具体的游戏代码分开。虽然现在可能看起来没有必要,但我们已将每个游戏的通用代码,从具体的游戏的逻辑、美术作品和资源中分离出来,以便重用游戏引擎。在此后的章节中,我们需要重构这个类层次结构,因为我们的游戏引擎变得更加复杂。

平台差异(OSX)

你可以运行上面的代码在Windows或Linux上,但我们仍需要为OS X平台做一些修改。正如GLFW文档中所描述的:

目前OS X仅支持的OpenGL 3.x和4.x版本的环境是向上兼容的。OS X 10.7 Lion支持OpenGL 3.2版本和OS X 10.9 Mavericks支持OpenGL 3.3和4.1版本。在任何情况下,你的GPU需要支持指定版本的OpenGL,以成功创建环境。

因此,为了支持在此后章节中介绍的特性,我们需要将下述代码添加到Window类创建窗口代码之前:

  1. glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
  2. glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 2);
  3. glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
  4. glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);

这将使程序使用OpenGL 3.2到4.1之间的最高版本。如果没有上述代码,就会使用旧版本的OpenGL。