当前位置: 首页 > 工具软件 > lwjgl3 > 使用案例 >

3D Game Development with LWJGL 3 第二章:游戏循环

孔彭祖
2023-12-01

游戏循环

在本章节中,将用游戏循环开发一个游戏引擎。游戏循环是每个游戏的核心组件。游戏循环是一个负责周期性地处理用户输入、更新游戏状态以及渲染至屏幕的死循环。

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

while (keepOnRunning) {
    handleInput();
    updateGameState();
    render();
}

所以,上述代码是全部吗?我们完成了游戏循环吗?不,还没有。上述代码片段有很多陷阱。首先游戏循环的运行速度会有所不同,具体取决于它所在运行的机器。如果该机器足够快,玩家甚至不会看到游戏中发生了什么。此外,该游戏循环将会消耗所有硬件资源。

因此,我们需要游戏循环独立于机器,尝试以一个恒定的比率运行。假定我们让它以每秒50帧的速率来运行,那么游戏循环可能是这样的:

double secsPerFrame = 1.0d / 50.0d;
while (keepOnRunning) {
    double now = getTime();
    handleInput();
    updateGameState();
    render();
    sleep(now + secsPerFrame – getTime());
}

这个游戏循环很简单,可以用于一些游戏,但是依然存在一些问题。首先,它假定更新和渲染方法适合可用时间,以便以每秒50帧的恒定速率渲染(secsPerFrame等于20ms)。

除此之外,计算机可能会优先考虑其他任务,会阻止在一个确定的时间段中执行的游戏循环。所以,可能会在非常可变时间步长中结束更新游戏状态,这是不适用于游戏物理的。

最后,休眠精度可能范围到为十分之一秒,因此,它甚至不会以恒定的帧速率更新,即使更新和渲染方法没有消耗时间。所以,正如你所见的,问题没那么简单。

在网上你可以找到大量各种各样的游戏循环。在本书中,我们将使用一种不太复杂的方法,这种方法在很多情况下可以很好地工作。所以,继续往下看,将会解释我们游戏循环的基础。在这里我们使用的模式一般被称为固定频率游戏循环(Fixed Step Game Loop)。

首先,我们可能想要单独控制游戏状态更新的时间段和游戏渲染到屏幕的时间段。为什么要这样做?因为,尤其是当我们使用一些物理引擎的时候,以恒定的速率更新游戏状态最为重要。相反,如果渲染没有及时完成,在处理游戏循环时渲染旧帧是没有意义的。所以我们可以灵活地跳过一些帧。

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

double secsPerUpdate = 1.0d / 30.0d;
double previous = getTime();
double steps = 0.0;
while (true) {
  double loopStartTime = getTime();
  double elapsed = loopStartTime - previous;
  previous = loopStartTime;
  steps += elapsed;

  handleInput();

  while (steps >= secsPerUpdate) {
    updateGameState();
    steps -= secsPerUpdate;
  }

  render();
  sync(loopStartTime);
}

有了这个游戏循环,我们能在固定的步骤中更新游戏状态。但是,我们应该如何控制,让其在连续渲染中不耗尽计算机资源?这可以在同步方法中完成:

private void sync(double loopStartTime) {
   float loopSlot = 1f / 50;
   double endTime = loopStartTime + loopSlot; 
   while(getTime() < endTime) {
       try {
           Thread.sleep(1);
       } catch (InterruptedException ie) {}
   }
}

上述代码做了啥?总之,我们计算游戏循环迭代应该持续多少秒(存储在loopSlot变量中)并且等待的时间量需要考虑到循环花费的时间。于是,我们应该进行小型等待(Thread.sleep(1)),而不是对整个可用的时间段进行一次等待。这样能够允许计算机运行其他的任务,并能避免在上文提到的休眠精度问题。所以,应该这样做:

1.计算应该退出这个等待方法的时间(结束时间),并开始另一个游戏循环的迭代(变量endTime)。
2.将当前时间与结束时间比较,若尚未达到该时间,则等待一毫秒。

现在来创建项目,以便开始编写我们的游戏引擎的第一个版本。但是,在这之前,将谈谈另一种控制渲染速率的方法。我们可以使用垂直同步(v-sync ,全称vertical synchronization)。垂直同步的主要目的是避免屏幕画面撕裂。什么叫画面撕裂?这是一种在渲染时更新显存(video memory)而产生的视觉效果。结果就是一部分图像表现为稍前的图像,另一部分表现为更新后的图像。若开启垂直同步,则不会在渲染进行时将图像发至GPU。

在开启垂直同步时,会同步显卡的刷新率,其最终的结果就是表现为恒定的帧速率。用下述代码实现:

glfwSwapInterval(1);

我们使用这行代码,来指定在绘制到屏幕之前,必须等待至少一次屏幕更新。事实上,图像并没有被直接绘制到屏幕上。相反,它被存入缓冲区,并用此方法进行交换:

glfwSwapBuffers(windowHandle);

因此,开启垂直同步能实现恒定的帧速率,而无需执行微睡眠来检查可用时间。此外,帧速率将匹配显卡的刷新速率。也就是说,当显卡设置为60Hz(每秒刷新60次),帧速率则为每秒60帧。我们也可以在glfwSwapInterval方法中传入大于1的数字来缩小该速率(例如参数为2,速率则为30FPS)。

现在让我们回到刚才所写的游戏循环,我们需要整理下代码。首先,将所有GLFW窗口初始代码封装在名为Window的类中,允许一些特征进行基础参数化(例如标题与尺寸)。该Window类还将提供一个用于游戏循环中按键检测的方法:

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

该Window类除了提供初始代码,还需注意窗口尺寸变化。因此需要设置一个在窗口大小变化时调用的回调方法。这个回调方法将接收到以像素为单位的帧缓冲区的宽度和高度(即为渲染区域,在此示例中为显示区域)。如果想要接收屏幕坐标中帧缓冲区的宽度和高度,可以使用glfwSetWindowSizeCallback方法。
屏幕坐标不一定与像素对应(例如,在带视网膜显示器(Retina Display)的Mac上)。在执行一些OpenGL回调时,相比于屏幕坐标,对我们更有用处的是像素。在 GLFW 文档中你可以获取更多相关信息。

// Setup resize callback
glfwSetFramebufferSizeCallback(windowHandle, (window, width, height) -> {
    Window.this.width = width;
    Window.this.height = height;
    Window.this.setResized(true);
});

创建一个名为Renderer的类,该类将处理游戏渲染的逻辑。目前,该类将只有一个空的inti方法与另一个使用设置的颜色清除屏幕的方法:

public void init() throws Exception {
}

public void clear() {
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
}

创建一个名为IGameLogic的接口,该接口将封装游戏逻辑。这样做可以将游戏引擎可重复使用在不同的游戏产品中。该接口将会有获取输入、更新游戏状态与呈现特定于游戏数据的方法。

public interface IGameLogic {

    void init() throws Exception;

    void input(Window window);

    void update(float interval);

    void render(Window window);
}

创建一个名为GameEngine的类,该类包含游戏循环的代码,用于实现游戏循环,其中vSync参数允许我们选择是否开启垂直同步:

public class GameEngine implements Runnable {

    //..[Removed code]..

    public GameEngine(String windowTitle, int width, int height, boolean vSync, IGameLogic gameLogic) throws Exception {
        window = new Window(windowTitle, width, height, vSync);
        this.gameLogic = gameLogic;
        //..[Removed code]..
    }
}

在GameEngine类实现Runnable的run方法中将包含游戏循环:

@Override
public void run() {
    try {
        init();
        gameLoop();
    } catch (Exception excp) {
        excp.printStackTrace();
    }
}

该方法将执行初始化任务并运行游戏循环,直至窗口关闭。在线程上需要注意的一点是,GLFW需要从主线程初始化,事件的轮循应该也在该线程中完成。因此,需在主线程中执行所有内容,而不是为游戏循环创建单独的线程,这在游戏中是很常见的。

在代码中,你会发现我们创建了其他辅助类,例如定时器(它将提供计算所花费时间的实用方法),并会被游戏循环使用。

该GameEngine类只是将输入和更新方法委托给IGameLogic实例。渲染方法同样委托给IGameLogic实例并更新窗口。

protected void input() {
    gameLogic.input(window);
}

protected void update(float interval) {
    gameLogic.update(interval);
}

protected void render() {
    gameLogic.render(window);
    window.update();
}

程序的起点的main方法中,只创建一个GameEngine实例并运行。

public class Main {

    public static void main(String[] args) {
        try {
            boolean vSync = true;
            IGameLogic gameLogic = new DummyGame();
            GameEngine gameEng = new GameEngine("GAME",
                600, 480, vSync, gameLogic);
            gameEng.run();
        } catch (Exception excp) {
            excp.printStackTrace();
            System.exit(-1);
        }
    }

}

最后只需要创建游戏逻辑类,对于本章来说,这将是一个更简单的类。每当用户按下向上/向下键时,它会增加/减少窗口的颜色数值(RGB)。render方法将只用于根据指定颜色更新窗口。

public class DummyGame implements IGameLogic {

    private int direction = 0;

    private float color = 0.0f;

    private final Renderer renderer;

    public DummyGame() {
        renderer = new Renderer();
    }

    @Override
    public void init() throws Exception {
        renderer.init();
    }

    @Override
    public void input(Window window) {
        if (window.isKeyPressed(GLFW_KEY_UP)) {
            direction = 1;
        } else if (window.isKeyPressed(GLFW_KEY_DOWN)) {
            direction = -1;
        } else {
            direction = 0;
        }
    }

    @Override
    public void update(float interval) {
        color += direction * 0.01f;
        if (color > 1) {
            color = 1.0f;
        } else if ( color < 0 ) {
            color = 0.0f;
        }
    }

    @Override
    public void render(Window window) {
        if (window.isResized()) {
            glViewport(0, 0, window.getWidth(), window.getHeight());
            window.setResized(false);
        }
        window.setClearColor(color, color, color, 0.0f);
        renderer.clear();
    }    
}

在render方法中,当窗口尺寸变化时,会收到相应的通知,以便更新视图,将坐标中心定位至窗口中央。

上述创建的类的层次结构有助于将游戏引擎的代码与特定游戏的代码分开。虽然现在似乎没有必要,但是我们需要将每个游戏使用的通用任务从特定游戏的状态逻辑、绘图与资源中隔离出来,以便实现游戏引擎的复用。在后续章节中,随着游戏引擎变得更加复杂,我们将需要重构这些类的结构层次。

平台差异(macOS)

上述代码能够在Windows与Linux上运行,但是在macOS上运行则需要稍作修改,正如GLFW文档中所述:

The only OpenGL 3.x and 4.x contexts currently supported by OS X are forward-compatible, core profile contexts. The supported versions are 3.2 on 10.7 Lion and 3.3 and 4.1 on 10.9 Mavericks. In all cases, your GPU needs to support the specified OpenGL version for context creation to succeed.

OS X目前仅支持的OpenGL 3.x和4.x的上下文为向上兼容的核心模式上下文。支持的版本为OS X 10.7 Lion上的OpenGL 3.2与OS X 10.9 Mavericks上的OpenGL 4.1。在所有情况下,你的GPU都需要支持指定的OpenGL版本,才能成功创建上下文。

因此,为了支持在后续章节中介绍的功能,需要在创建窗口类之前将下述代码添加至Window类中:

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

这将让程序在3.2和4.1之间使用最高的OpenGL版本。若未引入这些代码,则会使用旧版OpenGL。

本章代码

官方github代码已clone至gitee
chapter02

 类似资料: