階層構造を進化させる

このブログポストは、Mick West氏のEvolve Your Hierarchy(2007)を、原著者の許可を得て翻訳・公開したものです。全ての権利は、原著者にあります。


ゲームのエンティティをコンポーネントとしてリファクタリング

かなり最近まで、ゲームプログラマーは一貫して深いクラス階層をゲームエンティティを表現するために利用してきた。このような深い階層を利用する方法から、コンポーネントの集約としてゲームオブジェクトを構成するような方法に流行は変化している。このアーティクルでは、それはどういうことなのかの説明と、その利点について検証と、このようなアプローチの実践的な考察を行う。大きなコードベースの上にこのようなシステムを実装した経験と、どのようにして他のプログラマーにこのアイデアを布教し、どのように管理したかについて記す。

ゲームのエンティティ

ゲームエンティティに何が必要であるかは、異なるゲームであれば異なる要求あるが、大半のゲームではゲームエンティティのコンセプトは同じようなものだ。ゲームエンティティはゲーム世界に存在する何らかのオブジェクトであり、たいていそのオブジェクトはプレイヤーから視認することができ、プレイヤーの周りを動く事が出来る。

いくつかのエンティティの例を上げる。

  • ミサイル
  • タンク
  • グレネード
  • ヒーロー
  • 歩行者
  • エイリアン
  • ジェットパック
  • 救急キット

エンティティはいろいろな事が出来る。次に、エンティティにして欲しいと思われることをいくつかあげる。

  • スクリプトを動かせる
  • 移動する
  • 剛体として応答する
  • パーティクルを発生させる
  • 置かれた音を再生する
  • プレイヤーの荷に入れられる
  • プレイヤーに装備される
  • 爆発する
  • 磁石に応答する
  • プレイヤーからターゲットにされる
  • 経路をたどる
  • アニメーションする

伝統的な深い階層

このような一連のゲームエンティティを表現する伝統的な方法は、表現したい一連のエンティティをオブジェクト指向的に分解することだ。これはたいていまともな意図の元始まるが、ゲームエンジンが別のゲームに再利用された時などは、ゲームの開発の進行中に頻繁に修正が行われる。図1のようなクラス階層にとてつもない量のノードをもった状態でほとんどの場合は終了する。

開発中に、ほとんどのエンティティに様々な機能を追加する必要がでてくる。オブジェクトはそれの持つ機能をそのオブジェクトの中にカプセル化しなければならないか、もしくはそのような機能を持つオブジェクトから継承されなくてはならない。たまに、その機能がたとえばCEntityクラスのようなクラス階層のルートに近いところに追加される。これにより全継承先クラスがこの機能の恩恵が受けるが、それによるオーバーヘッドもまたそれらのクラスにもたらされる。

岩やグレネードなど極単純なオブジェクトでさえ、大量の追加機能(と関連するメンバー変数とメンバー関数の不必要な実行)をされる羽目になる。ときどき、伝統的なゲームオブジェクト階層は、“blob"として知られるようなオブジェクトを作る羽目になる。blobは古典的なアンチパターンで、大量の複雑な絡み合った機能を持つ巨大な一つのクラス(もしくはクラス階層の特定の枝)を表現する。

blobアンチパターンはあるときはオブジェクト階層のルート近くに現れ、あるときには葉のノードに現れる。blobとしてもっともあり得るものは、プレイヤーキャラクターを表すクラスだ。ゲームは一つのキャラクターの周りで起こることとしてプログラムされるため、そのキャラクターを表現するオブジェクトはとても沢山の機能を持つことが多い。CPlayerなどのようなクラスの中に、沢山のメンバー関数を持つものとして、よく実装される。

階層のルートの近くで機能を実装する結果、不要な機能を持つ葉のオブジェクトは過負荷になる。しかし逆に、葉のノードにこの機能を実装するという方法でも、残念な結果になってしまう。その機能は分離されたため、その機能を特別にプログラムされたオブジェクトのみ、その機能が使える事になる。他のオブジェクトですでに実装された機能をミラーするためにプログラマーはよくコードの複製を行う。結果、機能の移動や統合をするためのクラス階層の再構造化を行うやっかいなリファクタリングが要求される。

例として、剛体として物理応答を行うオブジェクトの機能を取り上げる。全てのオブジェクトがこのようにふるまう必要はない。図1で見たように、CRigitクラスから継承したCRectとCGrenadeクラスがある。CRigitの機能を乗り物(※CVehicle)に適用したくなった時、何が起こるだろうか?CRigidクラスの階層を上げる必要があり、全ての機能が少数のクラス群に集中し、その他の多くのクラスはそれを継承するという、前に見たルートに集中したblobアンチパターンに近づいてしまう。

コンポーネントの集約

現在のゲーム開発に受け入れられてきている、コンポーネントというアプローチは、他のコンポーネントから独立した個々のコンポーネントに機能を分割する一つの方法だ。伝統的なオブジェクト階層は不要となり、オブジェクトは独立したコンポーネントの集約(集合)となった。

これで各オブジェクトは単にそれが必要している機能を持つだけだ。何らかの別の新しい機能は、新しいコンポーネントを追加することで実装される。

コンポーネントの集約でオブジェクトを構成するシステムは、三つの方法で実装されうる。それらは、blobオブジェクトの階層構造からコンポジットオブジェクトに段階的に変更しているように見えるだろう。

管理されたblobとしてのオブジェクト

blobオブジェクトをリファクタリングするよくある方法は、そのオブジェクトの機能をサブオブジェクトに分解することだ。結果、親のblobオブジェクトの大半は他のオブジェクトへの一連のポインター群に置き換えられ、blobオブジェクトのメンバー関数はサブオブジェクトの関数へのインターフェイス関数となる。

ゲームオブジェクトの機能量が十分に少ないか時間がなければ、これは現実的に適当な解決方法となるかもしれない。任意のオブジェクト集約を単にサブオブジェクトがない状態(NULLポインターを持つことで)を許可して実装することもできる。それほど多くのサブオブジェクトがないとすると、オブジェクトのコンポーネントを管理するフレームワークを実装することなく、軽量な疑似コンポジットを持つことができるという点でアドバンテージを持つことができる。

欠点は根本的にこれはまだblobであるという点だ。全ての関数は依然として大きなオブジェクトにカプセル化されている。blobを完全に純粋なサブオブジェクトに分解することができないので、明確なオーバーヘッドが依然として残り、軽量なオブジェクトに負荷をかけることになる。更新が必要な時は全てのポインターでNULLチェックが必要になるため、定数のオーバーヘッドが残るのだ。

コンポーネントコンテナとしてのオブジェクト

次の段階は、オブジェクトの中にコンポーネント(前の例ではサブオブジェクトであったもの)のリストを保持できるように、各コンポーネントを共通基底クラスを共有するオブジェクトに分解することだ。

ゲームエンティティとなるルートオブジェクトを依然として持つので、これは中間の解決方法だ。適当な解決方法となるかもしれないし、コードベースの大半から具象オブジェクトとしてゲームオブジェクトの概念を要求されるならば、全くのただの現実的な解決方法でしかないかもしれない。

ゲームオブジェクトはゲームのレガシーな部分と新しいスタイルの集約オブジェクト群の掛け橋として振る舞うインターフェイスオブジェクトとなる。時間が許せば、最終的にゲームエンティティとしての一枚岩の概念を取り除き、代わりにオブジェクトをより直接的にコンポーネント経由で操作するようにする。最終的に、純粋な集約に変換が可能となるかもしれない。

純粋な集約としてのオブジェクト

この最後の方法では、オブジェクトは単純なオブジェクトの一部の集合となる。図2はゲームエンティティがコンポーネントの集合から成り立っている事を示している。“ゲームエンティティオブジェクト"のようなものはない。図の列は独立したコンポーネントを表し、行はオブジェクトを表す。コンポーネントはオブジェクトを作り上げるが、コンポーネントそれ自体はオブジェクトから独立したものとして扱われる

実践的な例

しかし、この最新式の"コンポーネントベースオブジェクト"システムをsweng-gamedevのメーリングリストで聞いた時、とても素晴らしい考えであると感じた。コードベースを再編成にかかり、二年後それを達成した。

抵抗を待ちうける

最初に遭遇した課題は、このシステムを他のプログラマーたちに説明しようとする事だった。オブジェクトのコンポジションと集約の考え方に特に詳しくない場合、無意味で、不必要に複雑で、不要な追加の仕事にぶつかることになる。オブジェクトの階層からなる伝統的なシステムに長年付き合ってきたプログラマーたちは、そのような方法でやることにとても慣れている。そのような方法で仕事をしたり起こった問題に対処する事にとても上達しさえしていた。

マネージメントにこのアイデアを売り込むこともまた難しい。どれだけゲームをより早くするための助けになるのかについて、平易な言葉で正確に説明できる必要がある。次のように。

今はゲームに新しいものを追加するときはいつでも、それをやるのに時間がかかり、沢山のバグを生み出す。この新しいコンポーネントオブジェクトの方法でやれば、より早く、少ないバグで新しい物を追加できるようになる。

私のアプローチは見えないような形でこれを導入したことだ。最初に、沢山のプログラマーたちと個別に、この考え方について議論し、最終的にそれは素晴らしいアイデアだと確信させた。それから汎用なコンポーネントのために基本的なフレームワークを実装し、コンポーネントとしてのゲームオブジェクトの機能の小さな部分を実装した。

そして残りのプログラマーたちにプレゼンテーションを行った。いくつかの混乱と抵抗があったが、実装され動作したので、それ以上の議論は起こらなかった。

遅い進行

フレームワークはできたが、静的な階層構造をオブジェクトのコンポジションに変換するのには時間がかかった。置き替えられたコードは機能的には何も変わらない何かへリファクタリングすることに何時間も何日も費やすのは報われない仕事だ。さらには、ゲームの次のイテレーションのための新しい機能を実装をしているさなかにこれを行っていたのだ。

早期の段階で、大きなクラス、つまりスケータークラスのリファクタリングで問題にぶち当たった。大量の機能を含んでおり、その時はリファクタリングすることはほとんど不可能だった。さらには、ゲーム中の他のオブジェクトシステムがコンポーネントのもののやり方に適合するまで、本当にリファクタリングはできなかった。今度は、スケーターもコンポーネントにならなければ、きれいにコンポーネントとしてリファクタリングできなくなった。

ここでの解決方法は"blobコンポーネント"を作ることだった。これは一つの巨大なコンポーネントで、スケータークラスの沢山の機能をカプセル化している。他の少数のblobコンポーネントは他の場所で必要とされ、最終的に全てのオブジェクトシステムをコンポーネントの集合に適合させていった。一度置き換えができれば、blobコンポーネントは徐々に単一機能のコンポーネントにリファクタリングされていく。

結果

このリファクタリングの最初の結果は、かろうじて形になったといったところだ。しかし時間が経つにつれて、機能は控えめなコンポーネントにカプセル化されたので、コードはよりクリーンによりメンテナンスが簡単になった。少ないコンポーネントの組み合わせと新しいものを追加することで簡単に、より少ない時間でプログラマーたちは新しいタイプのオブジェクトを作れるようになった。

デザイナーによって完全に新しいタイプのオブジェクトを作れるように、データドリブンのオブジェクトシステムを作成した。これは新しいタイプのオブジェクトの制作と調整する速度を計りしれないほど上げた。

結果、プログラマーたちは(差異はあるにせよ)コンポーネントシステムを受け入れるようになり、コンポーネント経由で新しい機能を追加することに慣れるようになった。共通インターフェイスと厳密なカプセル化はバグを低減させ、コードは読みやすくなりメンテナンスがしやすくなり再利用がしやすくなった。

実装の詳細

各コンポーネントに共通のインターフェイスを提供することは、仮想関数を持つ基底クラスから継承することを意味する。これはいくらかのオーバーヘッドを生み出す。オブジェクトの単純さを維持するのと比べて追加のオーバーヘッドは比較的小さいので、このアイデアに反対してはいけない。

各コンポーネントは共通インターフェイスをもつため、追加のデバッグメンバー関数をそれぞれのコンポーネントに追加することはとても簡単だ。コンポジットオブジェクトの内容を人間が読めるようにダンプするオブジェクトインスペクターを追加することを、比較的に単純な問題にした。後でこれは常に全てのゲームオブジェクトの最新状態を取れる洗練されたリモートでバッギングツールに進化した。伝統的な階層構造であれば、これは実装するのに退屈な何かになりそうだ。

理想的には、コンポーネントはお互いについての知識を持つべきではない。しかしながら、実践的には、特定のコンポーネント間で依存関係が常にあるだろう。コンポーネントは他のコンポーネントに素早くアクセスするべきであることは、パフォーマンス上の要求でもある。最初は、全てのコンポーネントの参照をコンポーネントマネージャーに持たせたが、5%もCPU時間を余計に使うようになったので、コンポーネントに他のコンポーネントへのポインタを持つことを許可し、他のコンポーネントのメンバー関数を直接呼べるようにした。

オブジェクト制作はデータドリブンであったため、コンポーネントのリストが予期しない順序であった場合、問題を起こしうる。 あるオブジェクトが物理をアニメーションの前に更新したり、他のオブジェクトがアニメーションを物理の前に更新したら、それらはそれぞれの順序の同期から漏れてしまう。このような依存関係は、発見しコード中で強制しなくてはならない。

結論

blobスタイルのオブジェクト階層構造から、コンポーネントの集合のコンポジットオブジェクトへの移行は、私が行った決定の内ベストなものの一つだ。最初の結果は既存のコードをリファクタリングするのに沢山の時間がかかったため、がっかりしたものだった。しかしながら、最終的な結果は軽量で柔軟で堅牢で再利用しやすいコードとなったため、とても価値があるものとなった。

リソース


個人的メモ書き欄。