第二章ゲームループ
前回に引き続き3Dモデルを表示させるために、LWJGLを使用して実現したいと思っています。LWJGLの資料には3Dモデルの詳細が説明されているので、
Gitbookで作成されているドキュメントを参考にして、学習および、実行します。
の章では、ゲーム ループを作成してゲーム エンジンの開発を開始します。ゲーム ループは、すべてのゲームのコア コンポーネントです。これは基本的に無限ループであり、定期的にユーザー入力を処理し、ゲームの状態を更新し、画面にレンダリングします。
注意
このドキュメントは英語なのでそれをブラウザで翻訳して読んでいます。
そして、このドキュメントの内容を補足する内容を記載しています。
ゲームループの基本
次のスニペットは、ゲーム ループの構造を示しています。
while (keepOnRunning) {
input();
update();
render();
}
このinputメソッドは、ユーザー入力 (キーストローク、マウスの動きなど) を処理します。このupdateメソッドは、ゲームの状態 (敵の位置、AI など) を更新する役割を担っています。ゲームループは終わりましたか?まあ、まだです。上記のスニペットには多くの落とし穴があります。まず、ゲーム ループの実行速度は、実行するマシンによって異なります。マシンが十分に高速な場合、ユーザーはゲームで何が起こっているかを確認することさえできません。さらに、そのゲーム ループはすべてのマシン リソースを消費します。
まず、ゲームの状態が更新される期間と、ゲームが画面にレンダリングされる期間を別々に制御したい場合があります。なぜこれを行うのですか?ゲームの状態を一定の速度で更新することは、特に物理エンジンを使用している場合には重要です。逆に、レンダリングが間に合わない場合、ゲーム ループの処理中に古いフレームをレンダリングしても意味がありません。一部のフレームをスキップする柔軟性があります。
- ゲームループの処理「loop()」
-
- input(): キーボード・マウスなどの入力のの処理をする。
- update(): ゲームの状態(敵の位置、AI など)を更新する。
- render(): ゲーム画面の更新をする。
クラス図
クラス名 | 役目 | 概要 |
---|---|---|
Main | ゲームプログラム起動 | ゲームを起動して、IAppLogicをimplements(実装)する |
Window | GLFWのすべての呼び出しを行う | ウィンドウ ハンドル、その幅と高さ、およびウィンドウのサイズが変更など |
Scene | 3D シーンの将来の要素 (モデルなど) を保持 | 現状では空のプレースホルダー |
Render | 画面の描画、レンダリングを行う | 現状では画面をクリアする別のプレースホルダー |
Engine | ゲームロジックを動かす | ゲームループの実行を行う |
ここで、「カプセル化」という言葉について補足します。
- カプセル化とは
- 一つの機能を実現するための処理をまとめて管理するためのクラスの作り方です。
つまり、Windowクラスは、ウィンドウの表示、設定などの処理をひとまとめにして、持っているのでウィンドウの操作をしたければ
このクラスを使用すればよい。という形でプログラムを組むことができます。
同様に、Engineクラス、Scene、Renderとあります。もちろんMainクラスも同様です。
では、各クラスの詳細を見ていきます。
IAppLogicインターフェース
ゲーム ループを調べる前に、エンジンのコアを形成するサポート クラスを作成しましょう。まず、ゲーム ロジックをカプセル化するインターフェイスを作成します。これにより、ゲーム エンジンをさまざまな章で再利用できるようになります。このインターフェイスには、ゲーム アセットの初期化 ( init)、ユーザー入力の処理 ( input)、ゲーム ステートの更新 ( update)、およびリソースのクリーンアップ( ) を行うメソッドが含まれますcleanup。
このインターフェースは、このインターフェースを実装(implements)するクラスに定義したメソッドをオーバーライドする事を強制するので、
このインターフェースを実装(implements)するクラスは、必ず定義したメソッドを実装します。
つまり、MainクラスはIAppLogicを実装(implements)するので、
ゲーム アセットの初期化 ( init)、ユーザー入力の処理 ( input)、ゲーム ステートの更新 ( update)、およびリソースのクリーンアップ( ) を行う
ということです。
package org.lwjglb.engine;
import org.lwjglb.engine.graph.Render;
import org.lwjglb.engine.scene.Scene;
public interface IAppLogic {
void cleanup();
void init(Window window, Scene scene, Render render);
void input(Window window, Scene scene, long diffTimeMillis);
void update(Window window, Scene scene, long diffTimeMillis);
}
Windowクラス
ご覧のとおり、まだ定義していないいくつかのクラス インスタンス ( Window、Sceneおよび) と、これらのメソッドの呼び出しの間に渡されるミリ秒を保持するRenderという名前のパラメーターがあります。diffTimeMillis
Windowクラスから始めましょう。ウィンドウを作成および管理するための GLFW ライブラリへのすべての呼び出しをこのクラスにカプセル化します。その構造は次のようになります。
主に、ウィンドウを起動するために必要な処理を行います。インナークラス(内部クラス)にて、ウィンドウサイズを保持しています。
Window#WindowOptionの重要な部分(プロパティ(フィールド変数))を抜粋しておきます。
- compatibleProfile
- これは、以前のバージョンの古い関数 (非推奨の関数) を使用するかどうかを制御します。
- fps
- 1 秒あたりの目標フレーム数 (FPS) を定義します。値がゼロより小さい場合は、ターゲットを設定したくないが、モニターのリフレッシュをターゲット FPS として使用することを意味します。そのために、v-sync を使用します (これはglfwSwapBuffers、バッファーをスワップして戻る前に、呼び出された時点から待機する画面更新の数です)。
- height
- 目的のウィンドウの高さ。
- width
- 目的のウィンドウの幅。
- ups
- 1 秒あたりの更新の目標数を定義します (既定値に初期化されます)。
package org.lwjglb.engine;
import org.lwjgl.glfw.GLFWVidMode;
import org.lwjgl.system.MemoryUtil;
import org.tinylog.Logger;
import java.util.concurrent.Callable;
import static org.lwjgl.glfw.Callbacks.glfwFreeCallbacks;
import static org.lwjgl.glfw.GLFW.*;
import static org.lwjgl.opengl.GL11.*;
import static org.lwjgl.system.MemoryUtil.NULL;
public class Window {
private final long windowHandle;
private int height;
private Callable<Void> resizeFunc;
private int width;
...
...
public static class WindowOptions {
public boolean compatibleProfile;
public int fps;
public int height;
public int ups = Engine.TARGET_UPS;
public int width;
}
}
ご覧のとおり、ウィンドウ ハンドル、その幅と高さ、およびウィンドウのサイズが変更されたときに呼び出されるコールバック関数を格納するためのいくつかの属性が定義されています。また、ウィンドウの作成を制御するいくつかのオプションを設定するための内部クラスも定義します。
compatibleProfile: これは、以前のバージョンの古い関数 (非推奨の関数) を使用するかどうかを制御します。
fps: 1 秒あたりの目標フレーム数 (FPS) を定義します。値がゼロより小さい場合は、ターゲットを設定したくないが、モニターのリフレッシュをターゲット FPS として使用することを意味します。そのために、v-sync を使用します (これはglfwSwapBuffers、バッファーをスワップして戻る前に、呼び出された時点から待機する画面更新の数です)。
・height: 目的のウィンドウの高さ。
・width: 希望のウィンドウ幅:
・ups: 1 秒あたりの更新の目標数を定義します (既定値に初期化されます)。
`Window+ クラスのコンストラクターを調べてみましょう。
public class Window {
...
public Window(String title, WindowOptions opts, Callable<Void> resizeFunc) {
this.resizeFunc = resizeFunc;
if (!glfwInit()) {
throw new IllegalStateException("Unable to initialize GLFW");
}
glfwDefaultWindowHints();
glfwWindowHint(GLFW_VISIBLE, GL_FALSE);
glfwWindowHint(GLFW_RESIZABLE, GL_TRUE);
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 2);
if (opts.compatibleProfile) {
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_COMPAT_PROFILE);
} else {
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
}
if (opts.width > 0 && opts.height > 0) {
this.width = opts.width;
this.height = opts.height;
} else {
glfwWindowHint(GLFW_MAXIMIZED, GLFW_TRUE);
GLFWVidMode vidMode = glfwGetVideoMode(glfwGetPrimaryMonitor());
width = vidMode.width();
height = vidMode.height();
}
windowHandle = glfwCreateWindow(width, height, title, NULL, NULL);
if (windowHandle == NULL) {
throw new RuntimeException("Failed to create the GLFW window");
}
glfwSetFramebufferSizeCallback(windowHandle, (window, w, h) -> resized(w, h));
glfwSetErrorCallback((int errorCode, long msgPtr) ->
Logger.error("Error code [{}], msg [{]]", errorCode, MemoryUtil.memUTF8(msgPtr))
);
glfwSetKeyCallback(windowHandle, (window, key, scancode, action, mods) -> {
if (key == GLFW_KEY_ESCAPE && action == GLFW_RELEASE) {
glfwSetWindowShouldClose(window, true); // We will detect this in the rendering loop
}
});
glfwMakeContextCurrent(windowHandle);
if (opts.fps > 0) {
glfwSwapInterval(0);
} else {
glfwSwapInterval(1);
}
glfwShowWindow(windowHandle);
int[] arrWidth = new int[1];
int[] arrHeight = new int[1];
glfwGetFramebufferSize(windowHandle, arrWidth, arrHeight);
width = arrWidth[0];
height = arrHeight[0];
}
...
ウィンドウヒントを設定してウィンドウを非表示にし、サイズ変更可能に設定することから始めます。その後、OpenGL のバージョンを設定し、ウィンドウ オプションに応じてコアまたは互換プロファイルを設定します。次に、適切な幅と高さを設定していない場合は、プライマリ モニターのサイズを取得してウィンドウ サイズを設定します。次に、 を呼び出してウィンドウを作成し、ウィンドウのglfwCreateWindowサイズが変更されたとき、またはウィンドウの終了 (ESCキーが押されたとき) を検出するためにいくつかのコールバックを設定します。ターゲット FPS を手動で設定する場合は、呼び出しglfwSwapInterval(0)て v-sync を無効にし、最後にウィンドウを表示してフレーム バッファー サイズ (render() に使用されるウィンドウの部分) を取得します。
クラスの残りのメソッドは、Windowリソースのクリーンアップ、サイズ変更コールバック、ウィンドウ サイズのいくつかのゲッター、およびイベントをポーリングし、ウィンドウを閉じる必要があるかどうかを確認するメソッドです。
public class Window {
...
public void cleanup() {
glfwFreeCallbacks(windowHandle);
glfwDestroyWindow(windowHandle);
glfwTerminate();
GLFWErrorCallback callback = glfwSetErrorCallback(null);
if (callback != null) {
callback.free();
}
}
public int getHeight() {
return height;
}
public int getWidth() {
return width;
}
public boolean isKeyPressed(int keyCode) {
return glfwGetKey(windowHandle, keyCode) == GLFW_PRESS;
}
public void pollEvents() {
glfwPollEvents();
}
protected void resized(int width, int height) {
this.width = width;
this.height = height;
try {
resizeFunc.call();
} catch (Exception excp) {
Logger.error("Error calling resize callback", excp);
}
}
public void update() {
glfwSwapBuffers(windowHandle);
}
public boolean windowShouldClose() {
return glfwWindowShouldClose(windowHandle);
}
...
}
このSceneクラスは、3D シーンの将来の要素 (モデルなど) を保持します。今では空のプレースホルダーです。
package org.lwjglb.engine.scene;
public class Scene {
public Scene() {
}
public void cleanup() {
// Nothing to be done here yet
}
}
Renderクラスは、画面をクリアする別のプレースホルダーになりました。
package org.lwjglb.engine.graph;
import org.lwjgl.opengl.GL;
import org.lwjglb.engine.Window;
import org.lwjglb.engine.scene.Scene;
import static org.lwjgl.opengl.GL11.*;
public class Render {
public Render() {
GL.createCapabilities();
}
public void cleanup() {
// Nothing to be done here yet
}
public void render(Window window, Scene scene) {
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
}
}
Engineこれで、次のように始まる名前の新しいクラスにゲーム ループを実装できます。
package org.lwjglb.engine;
import org.lwjglb.engine.graph.Render;
import org.lwjglb.engine.scene.Scene;
public class Engine {
public static final int TARGET_UPS = 30;
private final IAppLogic appLogic;
private final Window window;
private Render render;
private boolean running;
private Scene scene;
private int targetFps;
private int targetUps;
public Engine(String windowTitle, Window.WindowOptions opts, IAppLogic appLogic) {
window = new Window(windowTitle, opts, () -> {
resize();
return null;
});
targetFps = opts.fps;
targetUps = opts.ups;
this.appLogic = appLogic;
render = new Render();
scene = new Scene();
appLogic.init(window, scene, render);
running = true;
}
private void cleanup() {
appLogic.cleanup();
render.cleanup();
scene.cleanup();
window.cleanup();
}
private void resize() {
// Nothing to be done yet
}
...
}
このEngineクラスは、コンストラクターでウィンドウのタイトル、ウィンドウ オプション、およびIAppLogicインターフェイスの実装への参照を受け取ります。Windowコンストラクターでは、、RenderおよびSceneクラスのインスタンスを作成します。このcleanupメソッドは、他のクラスcleanupリソースを呼び出すだけです。ゲーム ループは、次のrunように定義されるメソッドで定義されます。
public class Engine {
...
private void run() {
long initialTime = System.currentTimeMillis();
float timeU = 1000.0f / targetUps;
float timeR = targetFps > 0 ? 1000.0f / targetFps : 0;
float deltaUpdate = 0;
float deltaFps = 0;
long updateTime = initialTime;
while (running && !window.windowShouldClose()) {
window.pollEvents();
long now = System.currentTimeMillis();
deltaUpdate += (now - initialTime) / timeU;
deltaFps += (now - initialTime) / timeR;
if (targetFps <= 0 || deltaFps >= 1) {
appLogic.input(window, scene, now - initialTime);
}
if (deltaUpdate >= 1) {
long diffTimeMillis = now - updateTime;
appLogic.update(window, scene, diffTimeMillis);
updateTime = now;
deltaUpdate--;
}
if (targetFps <= 0 || deltaFps >= 1) {
render.render(window, scene);
deltaFps--;
window.update();
}
initialTime = now;
}
cleanup();
}
...
}
ループは、更新 ( ) とレンダリング呼び出し ( )の間の最大経過時間をミリ秒単位で制御するtimeUとの2 つのパラメーターを計算することから始まります。これらの期間が消費された場合、ゲームの状態を更新するかレンダリングする必要があります。後者の場合、ターゲット FPS が 0 に設定されている場合、v-sync リフレッシュ レートに依存するため、値を に設定するだけです。ループは、ウィンドウを介してイベントをポーリングすることから始まります。その後、現在の時間をミリ秒単位で取得します。その後、更新呼び出しとレンダリング呼び出しの間の経過時間を取得します。レンダリング (または v-sync のリレー) の最大経過時間を過ぎた場合、 を呼び出してユーザー入力を処理します。最大更新経過時間を超えた場合は、呼び出してゲームの状態を更新しますtimeRtimeUtimeR0appLogic.inputappLogic.update. レンダリング (または v-sync のリレー) の最大経過時間を過ぎた場合、 を呼び出してレンダリング呼び出しをトリガーしますrender.render。
ループの最後で、cleanupメソッドを呼び出してリソースを解放します。
最後に、次のEngineように完了します。
public class Engine {
...
public void start() {
running = true;
run();
}
public void stop() {
running = false;
}
}
スレッドについて少し注意してください。GLFW はメインスレッドから初期化する必要があります。イベントのポーリングもそのスレッドで行う必要があります。したがって、ゲームでよく見られるゲーム ループ用の別のスレッドを作成する代わりに、メイン スレッドからすべてを実行します。Threadこれが、startメソッドでnew を作成しない理由です。
Main最後に、クラスを単純化して次のようにします。
package org.lwjglb.game;
import org.lwjglb.engine.*;
import org.lwjglb.engine.graph.Render;
import org.lwjglb.engine.scene.Scene;
public class Main implements IAppLogic {
public static void main(String[] args) {
Main main = new Main();
Engine gameEng = new Engine("chapter-02", new Window.WindowOptions(), main);
gameEng.start();
}
@Override
public void cleanup() {
// Nothing to be done yet
}
@Override
public void init(Window window, Scene scene, Render render) {
// Nothing to be done yet
}
@Override
public void input(Window window, Scene scene, long diffTimeMillis) {
// Nothing to be done yet
}
@Override
public void update(Window window, Scene scene, long diffTimeMillis) {
// Nothing to be done yet
}
}
インスタンスを作成し、メソッドEngineで起動するだけです。mainこのMainクラスはIAppLogic、今では空になっているインターフェースも実装しています。
ゲームループについて
以下のような説明がありました。
これは基本的に無限ループであり、定期的にユーザー入力を処理し、ゲームの状態を更新し、画面にレンダリングします。
この通りなのですが、 少しかみ砕いて記載したいと思います。やることは以下の通り
- 無限ループ内で実行する
- 定期的にユーザー入力を処理
- ゲームの状態(データの状態)を更新
- 画面を再描画(レンダリング)
これをコードに落とすと次のようになります。
// 無限ループ
while () {
// ユーザー入力
input();
// データの更新
update();
// 画面の更新
rendaer();
}
input(), update(), render()の各メソッドは別途実装します。ただ、実装する内容に関しては作成するものによって変わるのでインターフェースにしておきます。
それが説明にある「IAppLogic」です。
このようにインターフェースを作成し、implementsしてやれば必ず上記の3メソッドを実装することになるので、クラス構成がわかりやすくなります。
処理の流れ
Githubにアップされているコードをどのような順序で処理しているのか一通り調べました。
次のような順序で処理していました。
- メインクラス:メインメソッドのあるクラス
- インターフェースIAppLogicを実装し、自身(Mainクラス)をEngineクラスのコンストラクタに渡してEngine#start()メソッドを実行しています。
- Engineクラス
-
WindowOptionの値、IAppLogicをフィールド変数にセットしていつでも呼び出せるようにしています。
またstart()メソッドからrun()メソッドを実行し、run()メソッドでは、ゲームループを実行しています。
プログラムの実行結果としては、何も表示されない(画面が黒い)状態になります。
Windowクラス
オブジェクト指向プログラミングの基本「役割分担」を行った結果「画面関連の処理を担当するクラス」をWindowクラスとして作成します。
ドキュメントに以下の記載があるように、クラスを作成します。※コードはGithubにアップされていますが。。。
ウィンドウを作成および管理するための GLFW ライブラリへのすべての呼び出しをこのクラスにカプセル化
WindowOption
ウィンドウを作成するのに必要な情報をインナークラス(内部クラス)で管理します。
public class Window {
private final long windowHandle;
private int height;
private Callable<Void> resizeFunc;
private int width;
/** インナークラス */
public static class WindowOptions {
public boolean compatibleProfile;
public int fps;
public int height;
public int ups = Engine.TARGET_UPS;
public int width;
}
}
各フィールド(オプションの値)は次のようになっています。
compatibleProfile: これは、以前のバージョンの古い関数 (非推奨の関数) を使用するかどうかを制御します。
fps: 1 秒あたりの目標フレーム数 (FPS) を定義します。値がゼロより小さい場合は、ターゲットを設定したくないが、
モニターのリフレッシュをターゲット FPS として使用することを意味します。そのために、v-sync を使用します
(これはglfwSwapBuffers、バッファーをスワップして戻る前に、呼び出された時点から待機する画面更新の数です)。
height: 目的のウィンドウの高さ。
width: 希望のウィンドウ幅:
ups: 1 秒あたりの更新の目標数を定義します (既定値に初期化されます)。
Windowクラスのコンストラクタ
まずは、コンストラクタの定義を見てみます。
public Window(String title, WindowOptions opts, Callable<Void> resizeFunc) {
// 省略
}
第一引数にタイトル、第二引数にWindowOption(Windowクラスのインナークラス)、第三引数にCallBackがあります。
第三引数のクラスがよくわからないので、呼び出し元を調べます。
window = new Window(windowTitle, opts, () -> {
resize();
return null;
});
Engineクラスで呼び出されています。変数「window」はWindowクラス型のフィールド変数です。
つまりは、Windowクラスをインスタンス化してwindow変数にセットしているというわけです。
注目するのは、第三引数です。渡しているのは「() -> { ... };」の部分です。これは、いわゆる関数型という書き方で
Functionalインターフェースの理解が必要になります。
なので、今回は、「引数に関数(メソッド)を渡しているのだな。」と理解してください。
まとめると、第三引数ひは、resize()メソッドを呼び出してから、nullを返す処理を行っているメソッドを渡しています。
スレッドについて少し注意してください。GLFW はメインスレッドから初期化する必要があります。イベントのポーリングもそのスレッドで行う必要があります。したがって、ゲームでよく見られるゲーム ループ用の別のスレッドを作成する代わりに、メイン スレッドからすべてを実行します。Threadこれが、startメソッドでnew を作成しない理由です。
いろいろ書いていますが、メインメソッドが動いているスレッドでGLFWの初期化を行いましょう。ということです。
まとめ
IAppLogicクラスにゲームループの中身、cleanup(), init(), input(), update()を作成しています。
Window, EngineクラスはIAppLogicを動かすためにゲームループを作成したり、ウィンドウの処理を行ったりとそれぞれに役割が分担されています。
ここから、ゲームの実装方法、OpenGLの操作方法法を学習していくと思われます。
WindowOptionについて
画面のサイズを保持するクラスですが、このクラスの高さ(height)と幅(width)は、Windowクラスのコンストラクタ、以下の処理部分で値がセットされているようです。ちなみに、int型のデフォルト値(宣言しただけの時にセットされる値は0です。
if (opts.width > 0 && opts.height > 0) {
System.out.println("*** In True ***");
this.width = opts.width;
this.height = opts.height;
} else {
System.out.println("*** In False ***");
glfwWindowHint(GLFW_MAXIMIZED, GLFW_TRUE);
GLFWVidMode vidMode = glfwGetVideoMode(glfwGetPrimaryMonitor());
width = vidMode.width();
height = vidMode.height();
}