C++ と OpenGLでゲームを作ってみた
最近こちらの本(以降参考書)を読みました。
内容は、ゲームエンジンを使わずに C++ と OpenGL を使ってゲームを作る上での基本(ゲームオブジェクトの概念や基本的なレンダリング技術、衝突判定やUI、オーディオ等)を習得しようというものでした。一通り読み終わったので、復習のためにシンプルな2Dゲームを作ってみました。今回はそのプロジェクトで学んだことなどを書こうと思います。
目次
作ったもの
プレイヤーは右からやってくる敵のUFOをレーザーで倒します。WASDで移動、スペースキーでレーザーの発射が可能です。プレイヤーが敵のUFOと衝突するか、敵が1体でも画面の左端に到達してしまったらゲームオーバーとなります。どれだけ長く敵の侵攻を食い止められるかがこのゲームの楽しみ方です。
敵の生成インターバルは時間が経つにつれて早くなっていき、最短の状態になっていると1発の外しが致命的になってきます。
※ 上の github リポジトリではソースファイルの公開のみ行っています。実行ファイルは配布していません。
(本当は実行ファイルも配りたかったのですが、Apple のセキュリティ周りとかライセンス関連が大変そうだったので断念してしまいました。)
使ったライブラリ、フレームワークなど
OpenGL
言わずと知れたグラフィックスAPIですね。
SDL
こちらは知らない人もいるかもしれません。SDL (Simple DirectMedia Layer) はキーボードやマウスといった入力デバイスや OpenGL/Direct3D 等を経由したグラフィックスハードウェアへのアクセスを提供する開発ライブラリです。よく OpenGL と一緒に使われる GLFW に近い存在ですね。C言語で書かれていて、C++から利用することが可能です。
SDL_ttf
SDL には機能を拡張するような周辺ライブラリがいくつかあり、SDL_ttf はそのうちの1つです。このライブラリには TrueType フォントのレンダリングのための機能が含まれていて、ゲーム内の UI に使うことができます。参考書でもゲーム内のUIテキストの描画のために使われています。
GLEW
OpenGL の拡張機能を有効化するためのライブラリですね。似たようなライブラリに GLAD があります。
SOIL
OpenGL で画像を描画する際は OpenGL に画像のピクセルデータを渡す必要があります。しかし、画像ファイルからピクセルデータを読み出す処理は OpenGL から提供されていないので、その部分は自分でなんとかするしかありません。選択肢としては、画像ファイルのデータフォーマットを紐解いてデータを読み込む処理を自分で実装するか、画像ファイルを読み込むライブラリを導入するかのどちらかになります。
参考書ではどうしていたかというと、SOIL という OpenGL 向けの画像読み込みライブラリを利用していました。しかし、このライブラリが公開されていたWebページはどうやら無くなってしまったみたいです。一方で、github にリポジトリは残っていました。
画像ファイルを読み込んで、OpenGLが直接読める形式でピクセルデータを扱ってくれるのでかなり便利です。
FMOD
FMOD はゲーム開発に使われるサウンドミドルウェアの1つです。こちらも参考書で使われていたので採用しました。
GLM
OpenGL と親和性の高い数学系ライブラリです。参考書ではベクトルが列ベクトルではなく行ベクトルで表されていました。つまり変換行列を作る時の掛け算の順番が変換を適用する順番と同じになります。大学の講義で散々列ベクトルを扱ってきた自分にとって、行ベクトルには大きな違和感を感じていました。かといって自前で列ベクトル形式で演算できるコードを書くのも時間がかかりそうで面倒だったので、ベクトルが列ベクトルで表されている数学系ライブラリのGLMを採用することにしました。
このライブラリはヘッダオンリーなライブラリなので導入が簡単でいいですね。
学んだこと
レンダリングに関する基本的な知識
私は今までCG分野では Unity にしか触れたことがありませんでした。しかも、レンダリング系には特別興味を抱いていなかったため、レンダリング系の知識はほぼ0と言ってもよいくらいでした。そんな私でしたが、今回のゲーム開発を通して基本的なレンダリングの知識を習得することができたと思います。特に、今回はテクスチャについて深く知ることができました。
参考書ではテクスチャのアニメーションのために、フレームごとの画像ファイルを1枚1枚ロードして、フレームごとにテクスチャを切り替えてアニメーションを実現するような実装が紹介されていました。自分も最初はそのような方式でアニメーションを実装していたのですが、爆発アニメーションを実装する時に、テクスチャアトラスの形をとった素材しか見つけることができませんでした。テクスチャアトラス形式の画像からフレームごとの領域を切り出してそれぞれ1つの画像データとしてロードして描画することも考えましたが、画像の特定の領域を切り出すツールを探しても良さげなものが見つかりませんでした。結局、テクスチャアトラスを1枚の画像データとしてロードして、描画時は利用するUV座標を切り替えることでアニメーションさせるようにしました。これについては参考書には書かれていなかったテクニックだったので、ネットで調べながら実装していきました。
実装の手間は少しかかりますが、テクスチャアトラスを利用することでロードするテクスチャの数を減らすことができてメモリ効率が良くなったりするので、テクスチャを複数個利用する際はなるべく画像データをテクスチャアトラス化してまとめておくのがいいですね。
継承
参考書では継承が至るところで使われていました。中でも、派生クラス独自の振る舞いを記述できるメソッドと基底クラスで絶対に実行する処理を記述するメソッドを分割するテクニックが興味深かったですね。
class Actor { public: virtual void Update(float deltaTime); private: std::vector<Component*> mComponents; };
- Actor クラスはゲームオブジェクトを意味し、Actor クラスのインスタンスは1つのゲームオブジェクトとしてシーンに存在します。
- Actor は Component 型のオブジェクト(ポインタ)を保持します。
- シーンに存在する全 Actor はゲームループを管理する Game クラスに保持されています。
- Game クラスは保持している Actor の
Update
を毎フレーム呼び出します。
Update
メソッドはオーバーライド可能で、派生クラスで独自の振る舞いを定義することが可能です。しかし、Actor クラスの Update
メソッドには保持しているコンポーネントの Update
メソッドを呼び出す処理が記述されています。
void Actor::Update(float deltaTime) { for (auto comp : mComponents) { comp->Update(deltaTime); } }
この処理は派生クラスであっても絶対に実行されなければいけない処理です。そのため、派生クラスでUpdate
メソッドをオーバーライドする場合は以下のように書かなくてはなりません。
void DerivedActor::Update(float deltaTime) { Actor::Update(deltaTime); // 以降 DerivedActor の独自の振る舞いを記述 }
こうすることで派生クラスであっても基底クラスの処理を実行することができますが、この1文を書き忘れてしまうことで致命的なバグが生じてしまいます。これを解決するために、参考書では以下のようにしていました。
class Actor { public: void Update(float deltaTime); virtual void UpdateActor(float deltaTime); private: std::vector<Component*> mComponents; };
void Actor::Update(float deltaTime) { for (auto comp : mComponents) { comp->Update(deltaTime); } // 派生クラスでオーバーライド可能な UpdateActor を呼び出す UpdateActor(float deltaTime); } // 基底クラスでは何も実装しない void Actor::UpdateActor(float deltaTime) { }
このように、どんな派生型であっても実行されることが求められる処理を仮想メソッドから非仮想メソッドに切り出してあげて、そのメソッド内で派生クラスで独自に再定義できる仮想メソッドを呼ぶようにしてあげることで先ほどの問題点を解決することができます。
CMake
参考書ではIDE(Visual Studio か Xcode)を使った開発を推奨していましたが、いつも使っている VScode を使って開発したかったので VScode で開発を進めていました。ソースコードのビルドには、はじめは独自に記述した Makefile を使っていました。
以下はその頃に使っていた Makefile を模したものです。
# ソースファイルが置かれるディレクトリ SRC_DIR = src # ビルドにより生成されたファイルを配置するディレクトリ BUILD_DIR = build/debug # ソースファイルのリスト SRC_FILES = $(wildcard $(SRC_DIR)/*.cpp) # 実行ファイル名 PROGRAM = my_app # コンパイラ CXX = g++ # インクルードファイルのパス INCLUDE_PATHS += -I/usr/local/Cellar/glfw/3.3.5/include INCLUDE_PATHS += -I/usr/local/Cellar/glew/2.2.0_1/include # 使用するC++バージョン FLAGS = -std=c++11 # コンパイルオプション CXXFLAGS = $(FLAGS) -Wall -O0 -g $(INCLUDE_PATHS) -DGL_SILENCE_DEPRECATION # ライブラリファイルのパス LDFLAGS += -L/usr/local/Cellar/glfw/3.3.5/lib LDFLAGS += -L/usr/local/Cellar/glew/2.2.0_1/lib # リンクするライブラリ LDLIBS = -framework OpenGL -lGLEW -lglfw # ビルドコマンド all: $(CXX) $(CXXFLAGS) $(LDFLAGS) $(LDLIBS) $(SRC_FILES) -o $(BUILD_DIR)/$(PROGRAM) # 実行ファイルのクリーン .PHONY: clean clean: -rm -rf build/debug/*
このやり方の問題は、ビルドのたびに全ソースファイルが再コンパイルされてしまうことです。オブジェクトファイルごとに生成方法を定義してあげればこの問題は回避できるのですが、新しくソースファイルを作成したり削除したりするごとに Makefile を編集するのは少し面倒です。
一方で、CMake を利用することでビルドシステムを自動生成することができ、ビルドシステムの選択肢の一つとして Makefile を生成することができるのですが、その Makefile はそのときのビルドに必要なソースファイルだけがコンパイルされるように書かれているのでとっても優秀です。上記の Makefile を使ったビルドのビルド時間の長さにはうんざりしていたので、どうにかこれを解決しようと CMake について色々と調べ上げて、なんとか最低限の CMake の書き方を習得することができました。これのお陰で開発の時間の短縮につながったと思います。
ライセンス関連
今までライセンスについてはそこまで詳しくなかったのですが、エンジニアになるのであればライセンス周りのことも知っとかなきゃならんだろうということで、ライセンス関連の知識を仕入れました。
分かったことは、コピーレフト型のライセンスの元で公開されているライブラリを使うときは気をつけなければならないなーということですね。あと、GPL系のライセンスには本当に気をつけないとダメですね。過去には日本でもGPLライセンスに関わる事件が発生しています。
過去に日本で起こった事件としては、2005年にAQUAPLUSのソフトウェアに GPLでライセンスされた動画デコーダーのライブラリが使われていることがわかり、 郵送によりゲーム本体も含めた当該ソースコード(画像やテキストデータなどのアセットを除く)が開示されたことが有名である。
https://dic.nicovideo.jp/a/gpl
余談ですが、GLM からは2つのライセンスが提供されているので利用するときはどちらかを選んで使うことになります。ひとつは MIT ライセンス。もう1つは Happy Bunny License という可愛らしい名前のライセンスです。このライセンス、ほとんど MIT ライセンスと同じなのですが、それに加えて1つ制限が加えてあります。
Restrictions: By making use of the Software for military purposes, you choose to make a Bunny unhappy.
https://github.com/g-truc/glm/blob/master/copying.txt
つまり、軍事目的での利用を禁止しているのですね。名前は可愛いのに中身は可愛くない...。時間と余裕があればいろんなライセンスの中身を見てみたいものです。
まとめ
支離滅裂で読みづらい文章となってしまいました。とりあえず、今回のプロジェクトを通してC++の基礎の基礎はある程度固めることができたと実感しているのでそこそこ満足しています。冒頭で紹介した本にも大変満足しています。
また、今回のプロジェクトでは動的オブジェクトの扱いのために生のポインタをそのまま扱っていました。しかし、生ポインタで動的オブジェクトを扱うのはメモリリークの危険がつきまといます。だから、早いところスマートポインタを理解して正しく使えるようになりたいですね。