GameDev #2 ゲームオブジェクトと2Dグラフィクス [Devlog #015]

Table of Contents

ゲーム開発者の教科書:Game Programming in C++ を読んで理解したことについてを要約します(内容の転載を避け、詳しく説明しすぎないように配慮します)

ゲームプログラミング in C++


ゲームオブジェクトと2Dグラフィクス

ゲームオブジェクト

オブジェクト階層構造を利用するゲームもあれば、コンポジションを使うゲームもあり、もっと複雑な手法を用いるゲームもある

ゲームオブジェクトの種類

ゲームオブジェクトには、毎フレーム更新されて出力生成で描画されるもの、描画するが更新しない静的オブジェクト、他のオブジェクトが触れた際に何かを発生させるトリガーなどがある

ゲームオブジェクトのモデル

クラス階層構造としてのゲームオブジェクト

オブジェクト志向の標準的なクラス階層構造でゲームオブジェクトを宣言するゲームオブジェクトモデル(is-a関係)

すべてのゲームオブジェクトが1つの基底クラスから派生されるので、モノリシックなクラス階層構造とも呼ばれる

どのゲームオブジェクトも基底オブジェクトのプロパティと関数をすべて持たなければならないという欠点がある

階層構造を拡張して、派生クラス群と基底クラスとの中間に新しいクラスを置くこともできるが、クラス階層構造が複雑化してしまう

クラス階層構造を大きくすると、後に2つの子クラスに共通機能をもたせようとした際に、2つの別の経路で継承することになり(菱形継承)、派生クラスが仮想関数の複数のバージョンを継承する問題を起こしやすい

コンポーネントによるゲームオブジェクト

コンポーネント(構成要素)をベースとするゲームオブジェクトモデル

Unityが採用しているモデルである

1つのゲームオブジェクトクラスがあるが派生クラスを持たず、代わりに必要な機能を実装するコンポーネントオブジェクトを持つ(has-a関係)

このモデルを実装する方法の1つは、コンポーネントにクラス階層構造を使う手法

GameObjectに含まれるのは、コンポーネントを追加(Add)・削除(Remove)する関数だけである

必要とする機能だけを追加しやすいという長所がある

同じゲームオブジェクトにあるコンポーネント間の依存関係が明らかではないという欠点がある

階層構造とコンポーネントによるゲームオブジェクト

モノリシックな階層構造とコンポーネントオブジェクトモデルのハイブリッド

UE4のゲームオブジェクトモデルから部分的にヒントを得ている

Actor.h
Actor.cpp

いくつかの仮想関数を持つ基底クラスActorがあり、各基底クラスActorはコンポーネントを格納する動的配列(std::vector)を持つ

列挙型のStateでクラスActorの状態管理を行う(Update()EActive状態のクラスだけを更新し、EDead状態のクラスは削除する)

UpdateComponents()関数は、すべてのコンポーネントをループして順番に更新する

UpdateActor()関数は、派生クラスで独自の振る舞いを実装する

基底クラスActorは、シングルトンで実装するアプローチ(そのクラスの複数のインスタンスに対応できない)ではなく、依存性の注入(dependency injection)で実装する(アクターがコンストラクタでGameクラスへのポインタを受け取り、別のアクターを追加作成する)

基底クラスActorでは、アクターを追加で作成する際にGameクラスへのアクセスが必要になる

Component.h
Component.cpp

メンバ変数mUpdateOrderはコンポーネントの更新順を前後させる(例えば、カメラコンポーネントは移動コンポーネントがプレイヤーを動かした後に更新する)

処理順を管理するため、ActorAddComponent()関数では新しいコンポーネントが追加される際にコンポーネントの配列をソートする

Componentクラスは、その所有者のアクターへのポイントを持つことで、コンポーネントからアクターの情報にアクセスできる

このハイブリッドアプローチは、モノリシックなオブジェクトモデルの"深いクラス階層構造"を避けるという点で有効である

座標データなどの重要なプロパティを個々のアクターにもたせているため、コンポーネント間のオーバーヘッドに関する問題を回避することができる

その他のアプローチ

  • 考えられるさまざまな機能をインターフェースクラスとして宣言し、個々のゲームオブジェクトで必要なインターフェースを実装する

  • コンテナ的なゲームオブジェクトを完全に排除し、代わりにIDでオブジェクトを管理するコンポーネントのデータベースを利用する

  • オブジェクトをプロパティによって定義する

ゲームオブジェクトをゲームループに統合する

Game.h

Actorクラスへのポインタの、アクティブなアクター群を含むmActorsと待ち状態のアクター群を含むmPendingActorsstd::vector)をGameクラスに追加

新しいアクターは巡回処理が終わるまではmPendingActorsに追加しておく

Game.cpp

受け取ったActorクラスへのポインタを、mUpdatingActorsフラグによってmPendingActorsmActorsに追加するGame::AddActor()関数を追加

Actorクラスへのポインタを受け取って、そのアクターを削除するGame::RemoveActor()関数を追加する

Game::UpdateGame()

デルタタイムの計算後にmActorsにあるアクターをループ処理して、それぞれのActor::Update()を呼び出す

mPendingActorsに追加したアクターをmActorsに移動する

死んだアクターをdeadActorsに移動して削除する

Game::UnloadData()

Actorオブジェクトはコンストラクタとデストラクタで自動的に追加・削除されるため、mActorsをループ処理してアクターを削除するには別のスタイルのループを行う

スプライト

2Dゲームの視覚的オブジェクトのスプライトは、キャラクター、背景、その他の動的なオブジェクトを表現するのに使われる

それぞれのスプライトには、1つ以上の画像ファイルが割り当てられる

iOSではPVR、PC及びXBoxではDXTなどの画像フォーマットを利用する

このコードではPNGを利用する(PNGは圧縮された画像フォーマットであり、ハードウェアはPNGファイルをネイティブで描画できないため時間が掛かるが…)

画像ファイルのロード

Game.cpp
Game::Initialize()

SDL_Init()関数でSDL Imageを初期化する

Game::GetTexture()

Game::GetTexture()関数はGame::LoadData()関数の中での実行であり、SDL_Textureと、それに対応するファイル名との連想配列を作る

IMG_Load()関数で画像ファイルをSDL_Surface構造体にロードする

SDL_CreateTextureFromSurface()関数でSDL_Surface構造体をSDL_Textureに変換する

ゲーム開発では基本的に、あらゆる種類のアセットを一括して処理するアセット管理システムを利用するが、ここでは利用しない

Game::LoadData()関数の役割は、ゲームワールドのすべてのアクターを作成することである

スプライトの描画

画像のアルゴリズムによって、奥から手前の順序でスプライトを描画する

コンポーネントを利用するゲームオブジェクトモデルを使うので、Componentクラスを継承したSpriteComponentクラスを作成する

SpriteComponent.h
SpriteComponent.cpp

mDrawOrderメンバ変数で指定された順序でスプライトコンポーネントを描画する

Game::AddSprite()関数を通して、Gameクラスのスプライトコンポーネントの配列に追加される

Game.cpp
Game::AddSprite()

mDrawOrderの順でスプライトコンポーネントがソートされる

Game::GenerateOutput()関数では、単純にスプライトコンポーネントの配列をループ処理してそれぞれのSpriteComponent::Draw()関数を呼ぶ

バックバッファをクリアして、バックバッファとフロントバッファを交換する間に処理を行う

画像のアルゴリズムは3Dを考慮するといくつかの難点があるが、2Dだと非常にうまく機能する

SpriteComponent.h
SpriteComponent.cpp
SpriteComponent::SetTexture()

mTextureメンバ変数を設定し、SDL_QueryTexture()関数を呼び出してテクスチャの幅と高さを得る

SpriteComponent::Draw()

テクスチャを描画する関数として、シンプルなSDL_RenderCopy()関数、高度な挙動に対応したSDL_RenderCopyEx()関数がある

SDL_Rect構造体のx,y座標は、描画先の左上隅の座標に対応する

SDLは角度を度数で受け取るが、Actorクラスはラジアンを使うため、Math::ToDegreesを用いる

SDLでは正の角度が時計回りだが、数学で通常使われる単位円では反時計回りなので、正負逆転による調整を行う

スプライトアニメーション

AnimSpriteComponent.h

スプライトアニメーションの実現には、各フレームに対応する画像を配列に格納する

mAnimFPS変数は、異なるフレームレートでスプライトアニメーションを再生させるために利用できる

mCurrFrame変数は、現在のアニメーションを表示している期間を追跡管理できる

AnimSpriteComponent.cpp
AnimSpriteComponent::SetAnimTextures()

渡された配列をメンバ変数mAnimTexturesに設定し、mCurrFrameを0にリセットする

SpriteComponentクラスから継承したSetTexture()関数を呼び出して、アニメーションの最初のフレームを渡す

AnimSpriteComponent::Update()

アニメーションのFPSとデルタタイムの関数としてmCurrFrameを更新する

mCurrFrameintにキャストして、mAnimTexturesから適切なテクスチャを選択してSetTextureを呼び出す

背景のスクロール

BGSpriteComponent.h

背景用にSpriteComponentクラスを派生する

BGTexture構造体にそれぞれの背景テクスチャに対応するオフセットを割り当てる(このオフセットを毎フレーム更新することで、スクロール効果を生み出す)

BGSpriteComponent.cpp
BGSpriteComponent::SetBGTextures()

オフセット値はSetBGTextures()で初期化される

追加される背景テクスチャの内部では、それぞれのテクスチャは直前の背景テクスチャの右側に配置される

BGSpriteComponent::Update()

それぞれの画像が完全に画面外に出た場合を考慮しながら、背景テクスチャのオフセットを更新する

BGSpriteComponent::Draw()

所有アクターの位置と背景のオフセット情報によって位置を調節しながら、SDL_RenderCopy()関数を用いて背景テクスチャを描画する


背景に複数のレイヤーを使い、各レイヤーを別々の速度で動かすことで奥行きを感じさせる視差スクロールという実装がある

これを実装するには、1つのアクターに複数のBGSpriteComponentsをもたせ、それぞれに異なる描画順序を指定し、個々の背景に異なるスクロール速度を割り当てる

ゲームプロジェクト

Ship.h

Actorクラスの派生クラスとして宇宙船を表す

左右と上下の速度を制御する2つの速度の変数を持つ

Ship.cpp

コンストラクタでは、mRightSpeedmDownSpeedを0で初期化して、宇宙船に使うためのAnimSpriteComponentを作成してテクスチャを設定する

Ship::ProcessKeyboard()

キーボード入力で船の速度を変更する

Ship::UpdateActor()

ゲームの更新時に宇宙船を動かす

動きに関しては別のコンポーネントとして実装するべきであるが、今回はShip::UpdateActor()関数の中で実装する

参考文献

ゲームエンジンアーキテクチャ 第3版
↑ Naughty Dogは"クラッシュ・バンディクー"や"アンチャーテッド"で有名

Game Programming Gems 6 日本語版