ゲームループ
この章では、ゲーム ループを使用してゲーム エンジンを開発します。ゲーム ループはすべてのゲームの中核コンポーネントです。ゲーム ループは、ユーザー入力の定期的な処理、ゲームの状態の更新、画面へのレンダリングを担当する無限ループです。
次のコード スニペットは、ゲーム ループの構造を示しています。
while (keepOnRunning) {
handleInput();
updateGameState();
render();
}
では、上記のコードはすべてでしょうか? ゲームループは完了しましたか? いいえ、まだです。上記のコード スニペットには多くの落とし穴があります。まず、ゲーム ループは、実行されているマシンに応じて異なる速度で実行されます。マシンが十分に高速であれば、プレイヤーはゲーム内で何が起こっているのかさえわかりません。さらに、このゲーム ループはすべてのハードウェア リソースを消費します。
したがって、ゲーム ループをマシンに依存せず、一定の比率で実行する必要があります。1 秒あたり 50 フレームで実行していると仮定すると、ゲーム ループは次のようになります。
double secsPerFrame = 1.0d / 50.0d;
while (keepOnRunning) {
double now = getTime();
handleInput();
updateGameState();
render();
sleep(now + secsPerFrame – getTime());
}
このゲーム ループはシンプルで一部のゲームで使用できますが、まだいくつか問題があります。まず、更新メソッドとレンダリング メソッドが、1 秒あたり 50 フレームの一定速度 (secsPerFrame は 20 ミリ秒) でレンダリングするために利用可能な時間内に収まると想定しています。
さらに、コンピュータが他のタスクを優先して、一定期間ゲーム ループの実行が妨げられる場合があります。そのため、非常に可変的な時間ステップでゲームの状態を更新することになる可能性があり、これはゲームの物理学には適していません。
最後に、スリープ精度は 10 分の 1 秒に及ぶ可能性があるため、更新メソッドとレンダリング メソッドに時間がかからない場合でも、一定のフレーム レートで更新することさえできません。ご覧のとおり、問題はそれほど単純ではありません。
オンラインでは多種多様なゲーム ループを見つけることができます。本書では、多くの状況でうまく機能する、それほど複雑ではないアプローチを使用します。それでは、ゲーム ループの基本について説明します。ここで使用するパターンは、一般に固定ステップ ゲーム ループと呼ばれます。
まず、ゲームの状態が更新される期間と、ゲームが画面にレンダリングされる期間を個別に制御したい場合があります。なぜこれを行うのでしょうか? 特に物理エンジンを使用する場合、ゲームの状態を一定の速度で更新することが最も重要であるためです。逆に、レンダリングが時間内に完了しない場合、ゲーム ループの処理中に古いフレームをレンダリングしても意味がありません。したがって、いくつかのフレームをスキップする柔軟性があります。
ゲームループがどのようになるかを見てみましょう:
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. 現在時刻と終了時刻を比較し、終了時刻に達していない場合は 1 ミリ秒待ちます。
次に、ゲーム エンジンの最初のバージョンの作成を開始できるように、プロジェクトを作成しましょう。ただし、その前に、レンダリング レートを制御する別の方法について説明しましょう。垂直同期 (v-sync、フルネーム垂直同期) を使用できます。垂直同期の主な目的は、画面のティアリングを回避することです。画面ティアリングとは何ですか? これは、レンダリング中にビデオ メモリを更新することによって生成される視覚効果です。その結果、イメージの一部は以前のイメージとして表示され、別の部分は更新されたイメージとして表示されます。垂直同期がオンになっている場合、レンダリングの進行中に画像は GPU に送信されません。
垂直同期がオンになっている場合、グラフィックス カードのリフレッシュ レートが同期され、最終的には一定のフレーム レートになります。実現するには次のコードを使用します。
glfwSwapInterval(1);
このコード行を使用して、画面に描画する前に少なくとも 1 回の画面更新を待つ必要があることを指定します。実際、画像は画面に直接描画されません。代わりに、バッファに保存され、次のメソッドを使用して交換されます。
glfwSwapBuffers(windowHandle);
したがって、V-Sync をオンにすると、利用可能な時間を確認するためにマイクロスリープを実行する必要がなく、一定のフレーム レートが有効になります。さらに、フレーム レートはグラフィック カードのリフレッシュ レートと一致します。つまり、グラフィックス カードが 60Hz (1 秒あたり 60 回のリフレッシュ) に設定されている場合、フレーム レートは 1 秒あたり 60 フレームになります。glfwSwapInterval メソッドに 1 より大きい数値を渡してレートを下げることもできます (たとえば、パラメーターが 2 の場合、レートは 30FPS になります)。
ここで、先ほど作成したゲーム ループに戻り、コードを整理する必要があります。まず、すべての GLFW ウィンドウ初期化コードは Window というクラスにカプセル化されており、一部の機能 (タイトルやサイズなど) の基本的なパラメーター化が可能です。Window クラスは、ゲーム ループでのキー検出のメソッドも提供します。
public boolean isKeyPressed(int keyCode) {
return glfwGetKey(windowHandle, keyCode) == GLFW_PRESS;
}
Window クラスは、初期コードを提供することに加えて、ウィンドウ サイズの変更にも注意を払う必要があります。したがって、ウィンドウ サイズが変更されたときに呼び出されるコールバック メソッドを設定する必要があります。このコールバック メソッドは、フレーム バッファ (つまり、レンダリング領域、この場合は表示領域) の幅と高さをピクセル単位で受け取ります。フレームバッファの幅と高さを画面座標で受け取りたい場合は、glfwSetWindowSizeCallback メソッドを使用できます。
画面座標は必ずしもピクセルに対応するとは限りません (たとえば、Retina ディスプレイを搭載した 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]..
}
}
ゲーム ループは、Runnable を実装する GameEngine クラスの run メソッドに含まれます。
@Override
public void run() {
try {
init();
gameLoop();
} catch (Exception excp) {
excp.printStackTrace();
}
}
このメソッドは初期化タスクを実行し、ウィンドウが閉じるまでゲーム ループを実行します。スレッドに関して注意すべき点の 1 つは、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 インスタンスを 1 つだけ作成して実行します。
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 で実行できますが、GLFW ドキュメントで説明されているように、macOS で実行するには若干の変更が必要になります。
OS X で現在サポートされている OpenGL 3.x および 4.x コンテキストは、前方互換性のあるコア プロファイル コンテキストのみです。サポートされているバージョンは、10.7 Lion では 3.2、10.9 Mavericks では 3.3 および 4.1 です。いずれの場合も、コンテキストの作成が成功するには、GPU が指定された OpenGL バージョンをサポートしている必要があります。
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 コードが gitee
Chapter02に複製されました。