その5 ミドルウェアライブラリを構築していこう
ミドルウェアとは下層ライブラリをラップし隠蔽する事で使い勝手とゲームの移植性を高めてくれる中層ライブラリです。例えばDirectXはWindowsでのゲーム製作に大変重宝するライブラリですが、かなり細かくてデバイス直結的で、またバージョンアップによるインターフェイスの大変更が度々あります。IDirect3DDevice8を駆使して一生懸命ゲームを作れば作るほど、次に登場したぜんぜんインターフェイスが違うIDirect3DDevice9への移植が不可能になってしまいます。そういうインターフェイスの違いを吸収して悲劇を軽減し製作を安定化させるのがミドルウェアです。
この章ではそんなミドルウェアを構築するにはどうしたら良いかを試行錯誤してみたいと思います。
@ 下位デバイスのにおい消し
ミドルウェアに必要な事は「下層ライブラリの臭いをなるべく消す」もしくは「下層ライブラリの違いを吸収する」事です。これがうまく出来れば、DirectXとOpenGL、コンシューマ機間でソースを少し変更する事で相互移植する事も可能です。PS2用のソフトを開発するんだけど開発機材が無いので手元にあるXBox360でとりあえず作っちゃおうなんて、嘘のようなホントの話です。これはミドルウェアがPS2とXBox360の違いをしっかり吸収して同じインターフェイスで仕事ができるためです。
下層ライブラリの臭いを消す一つ例です。DirectXにおける描画部門を一手に引き受けているIDirect3DDevice9インターフェイスを下層ライブラリとして用いるときに、例えば、
下層ライブラリ依存のクラス宣言 class Character
{
protected:
IDirect3DDevice9* pDev;
public:
Character( IDirect3DDevice9* pdev );
};
class Texture
{
protected:
IDirect3DDevice9* pDev;
public:
Texture( IDirect3DDevice9* pdev );
};
と、IDirect3DDevice9に依存するクラスを宣言すると、実装側も、
下層ライブラリ依存の実装 void Character::Draw()
{
pDev->SetTexture( ... );
pDev->SetMaterial( ... );
pDev->...;
}
と下層ライブラリへの依存性が非常に高い実装になってしまいます。この実装だと世の中がDirectX10や11に突入した時には大部分を書き直しになります。この移植はかなり厳しいものがあります。ゲームソースが下層ライブラリと直結して強く依存していると土台の変化にソースが対応しきれなくなってしまいます:
上が固いと土台の変化で崩れる…
そこで緩衝材としてミドルウェアを挿入します。上の例のIDirect3DDevice9であれば、このデバイスをラップした独自のデバイスインターフェイスを作ってしまえばいいんです。例えば、
描画デバイスをラップするクラス #define USE_DIRECTX9 1
#define USE_DIRECTX10 0
#define USE_OPENGL 0 // これらは実際は別ヘッダーに定義します
class MyDevice
{
#if USE_DIRECTX9
protected: IDirect3DDevice9* pDev;
public: MyDevice( IDirect3DDevice9* pdev );
#elif USE_DIRECTX10
protected: IDirect3DDevice10* pDev;
public: MyDevice( IDirect3DDevice10* pdev );
#else
public: MyDevice();
#endif
HRESULT SetTexture( ... );
HRESULT SetMaterial( ... );
};
としてみます。ここでは仮にMyDeviceというIDirect3DDeviceをラップするクラスを設けています。クラス宣言部は#ifによりコンパイル時にデバイスを取り替えています。USE_DIRECTX10などのフラグは別ヘッダーにでも置くかプロジェクトのプリプロセッサにでも定義しておけば良いでしょう。
こうすると、CharacterクラスやTextureクラスは、IDirect3DDevice9の代わりにMyDeviceのインターフェイスを通して仕事ができるようになります:
ミドルウェア描画デバイスを用いたクラス class Character
{
protected:
MyDevice Dev;
public:
Character( MyDevice& dev );
};
class Texture
{
protected:
MyDevice Dev;
public:
Texture( MyDevice& dev );
};
こうすると、CharavterクラスやTextureクラスはIDirect3DDevice9インターフェイスの事を知らずにすみます。そして下位デバイスが切り替わってDirectX10になっても気付かず仕事が出来ます。その代わりに移植時やバージョンアップの際には、MyDeviceクラスが自身の持っているインターフェイスを忠実に再現できるように実装し直します。
ミドルウェアは下位層をラップするので、いつかどこかで必ず下位層とぶつかるクラスが出てきます。MyDeviceはまさにその境界にあるクラスで、そういうクラスは下位層インターフェイスを積極的にラップして覆い尽くすことになると思います。
境界ミドルウェアが違いを吸収
B ミドルウェアの境界クラスの実装はバリバリ依存でOK
下位層ライブラリとの境界にあるミドルウェアのクラス宣言部は使いまわされる物なので、#ifディレクティブなどでコンパイル時解決を図りました。一方で、その実装ファイル(.cpp)はもう少し気楽に実装できます。インターフェイスさえ統一して仕事をちゃんとこなしてくれれば、実装部分はある意味どう書いても良いからです。境界ミドルウェアをDirectX10対応にバージョンアップさせたい時は、実装部分だけをごそっと「入れ替えれば」いいんです。
例えばMyDevice.hというヘッダーファイル1つに対して、その実装ファイルが複数あることは決して珍しくありません。「でもそんな事をしたらコンパイラが困るんじゃ?」と思われるかもしれませんが、プロジェクトに両方があると困るのですが、どちらか一つが含まれていれば大丈夫です。この時ヘッダーファイル名と実装ファイル名が同じである必要ももちろんありません。むしろ、境界ミドルウェアについては区別できる名前にしておいた方が後々のファイル管理時に戸惑わずに済みます。MyDevice.hに対して、DirectX9で実装するなら例えばMyDeviceDX9.cppなどとするわけです。同じヘッダーに対してDirectX10対応にするならMyDeviceDX10.cppとして先と区別します。
2つもファイルがあるのは嫌だと考えて、実装ファイルにも#ifディレクティブでプラットフォーム別の実装をするのはあまりお勧めできません。ソースが見づらくなって保守性がかなり下がります(もちろん場合によります)。潔くファイルで分けてしまった方が、きっとすっきりと対処出来ると思います。
A 下層ライブラリが用意している変数、構造体、列挙型の扱い
ラップする時に困るのが引数に渡す変数や構造体、列挙型です。これらは下層ライブラリが用意しているものなので、臭い消しを考えると可能な限りミドルウェア用にカスタマイズしておきたいところです。
変数は1つのヘッダー内でtypedefしてオリジナルの型にします。別のプラットフォームに移植する際には、#ifディレクティブで分離して全部矛盾の無いように書き加えるだけです。サイズ依存がある変数は、それを正しく満たすように工夫する必要は出てきます。例えば、int型=long型=32ビットというのは今だけの話かもしれません。ANSI/ISO C標準ではint型やlong型などの「最低バイト数」は示しているのですが、32ビットでなければならないといは語っていません。ですから将来64ビットマシンが登場した場合に、long型が4バイトになるか8バイトになるかは処理系依存です。4バイトを保障できる整数型として、Visual C++には__int32型と言うのがあります。そういうのはミドルウェアオリジナルの型にtypedefして使うようにすると間違いないです。
構造体の扱いはかなり面倒な部類に入ると思います。ヘッダー内には下層ライブラリが用意している構造体が現れないように工夫する必要があります。つまりミドルウェアライブラリのクラスメンバやメソッドの引数にそれらを渡す事は、臭いを残してしまうので避けた方が無難です。ただし、下層ライブラリとの境界クラスの実装部内で使用する分には構いません。下層ライブラリを初期化するなどでインターフェイスの引数に構造体を必要とする時は、ミドルウェアで自前の構造体を公開して、メソッド内でそれを下層ライブラリのそれと変換する形式にすると、ミドルウェアのクラスを汚しません。
列挙型についても可能であれば自前の列挙型に置き換えた方が良いでしょう。最初は面倒なのですが、一度作ってしまうだけなので、必要になった時にちまちまと整えていきたいところです。
B 規模の大きな特化と汎用性のバランスをとろう
下位層ライブラリの臭いを消すために、DirectXにあるインターフェイスを一生懸命頑張りすべてラップしたとしましょう。「やったぜこれで完全に臭いが消えた!」と喜び勇んでミドルウェアライブラリを使ってみると、すぐに愕然とするはずです。単にラップを繰り返したミドルウェアは、インターフェイスが下位層ライブラリとまったく同じで、DirectXそのものと何も変わら無い物になってしまったからです。
ミドルウェア構築ではラップも大切ですが、むしろ「より使いやすくする」「下位層ライブラリ使用の手間を省く」事を重点に置いた方が良いと思います。例えば板ポリゴンを作る時にDirect3Dだと頂点を宣言してバッファに格納してうんにゃら…としていた所を、「IMyBillboard」のようなインターフェイスでその辺りを隠蔽して、簡単に扱えるインターフェイスに整える。これこそがミドルウェアの役割と魅力だと思います。
ただし、一つのインターフェイスをあまり作りこむと、今度は自由がきかなくなってきます。とにかく便利にしたいからと「ある位置で2度回ってお辞儀をするキャラクタインターフェイス」のような物を作ってしまうと、流用がまったくできなくなります。もちろんそういうクラスはあってしかるべきです。無いとゲームが形作れません。ただそういうのは「上位層クラス」として自分が作りたいゲームにとことん特化させて作る(プロジェクトを分ける)べきで、ミドルウェアライブラリの中にそれを含めるのは利がありません。一般に、規模の大きい特化インターフェイスになるほど再利用性・汎用性は下がってしまいます。両方を満たせるクラスはまずありません。このトレードオフの匙加減が、ミドルウェアの設計で難しいところです。
ゲームを下位層ライブラリで作っていくと、「めんどくせー!」と思う部分が必ずあります。画面に2Dの絵を出したい。それには板ポリゴンを作ってテクスチャを読み込んでほんにゃらして…。これ、よく使うわりに毎回設定するのは面倒ですね。これをもっと楽にできないか?2Dの絵が入ったファイルを読み込んで、次に「描画」とすると描画できてしまう。これだとかなり楽です。こういう所を見つけては補いつつ積み上げるのがミドルウェアの構築方法として妥当かなと思います。
C 1ファイル1クラス・クラス同名ファイルのガイドライン
ミドルウェアを構築する時に気を付けるべきはソース部分だけではありません。ミドルウェアは「実装ファイルをごっそり入れ替える」という性質がある事から、1つのクラスを原則として1つのファイルに収めておきたいところです。あ、先に申しておきますが、必ずそうしなければならないと言う事ではありません。これはガイドラインのようなものだとお考え下さい。
2つ以上のクラスが1つのファイルに入っていると、一方を変更する時に他方のクラスも変更したとみなされてリンクされている他のファイルも全部再コンパイルする必要がでてきます。物凄いマイナーなクラスを変更しているのに、一緒に超メジャーなクラスも含まれていたがために、コンパイル時間が不当に長くなるのは避けたいところです。
時間の問題の他に、大規模なプログラムになると「クラスの検索と管理」がとても重要になります。この時ファイル名がクラス名と一致していると、ファイルを見ただけでクラスを知る事が出来るので、管理しやすくなります。これは1ファイル1クラスの利点です。2ヶ月前に作成したファイルならまだ何となく思い出せるのですが、6ヶ月前だと多分半分忘れてます。そういう時に、クラス別でファイルがわかれていると特定クラスを探す手間は省けます。
このガイドラインの欠点は、管理するヘッダーと実装ファイル数が増えてしまう事です。管理のコツはファイルをカテゴリに分類して整理しようと「思わない」事です。どうせミドルウェアとして固まる物ですし、細かく分類しすぎるとパスの管理も大変になります。逆に1つのフォルダに全部あるんだと思うと、かえって管理が楽になります。ただ、少し整理するならヘッダーファイルと実装ファイルは分けて管理しても良いかと思います。…いや、しなくてもいいかな?さすがに、この辺は好みの問題になりそうです。
D Unicodeへの対応
ミドルウェアを作成する場合に、面倒でもUnicodeに対応しておいた方が良いです。これは差ほど難しい物ではありません。Visual C++の場合は汎用型文字としてTCHAR型がtchar.hに定義されています。これはUNICODEマクロがプリプロセッサに定義されている場合はwchar_t型、そうでない場合はchar型として振舞います。文字を扱う時にchar型ではなくてTCHAR型にしておくと、両モードに対応できます。
STLのstringクラスはchar型専用です。wchar_t型に対応するのはwstringクラスです。型が2つあるのは煩わしいので、自前のミドルウェアでは両方をtstringとtypedefしておけば、ソース上で分けずに済みます。
#if UNICODE
typedef wstring tstring;
#else
typedef string tstring;
#endif
ただし注意があります。ミドルウェアの実装部をlibファイルやdllファイルにする場合は、上のような事をしてはいけません。それはライブラリファイルはコンパイル済みになっているので、UnicodeかANCIかどちらかになってしまうからです。Unicode対応のライブラリをANCIプラットフォームで使う事は多分出来ませんし、できたとしてもメモリ保護違反であっと言う間に止まります(経験者語る)。DirectXはライブラリファイルが両モードに対応するために、例えばDrawTextA、DrawTextWと同じ機能をする2つのメソッドをわざわざ用意してくれています。どうしてもライブラリファイルにしたい場合は、これと同じような対処が必要になるでしょう。
以上長々説明してきました。ミドルウェア作りは最初が肝心です。下層ライブラリをうまく隠蔽して、目に見えるインターフェイスがミドルウェアライブラリのものばかりになっていくと、ゲーム製作が楽になり、開発速度も向上する事請け合いです。ただ、いきなり全部作る事はまず不可能ですから、まずは境界となるデバイス関係からラップしていき、後はそのオリジナルなデバイスを通してパーツ群ミドルクラスを少しずつ構築していけば良いと思います。そうして構築した緩衝材が、さらに膨大なゲーム資産を守ってくれます。