近代的タスクシステムの構築(2)


近代的タスクシステムの構築
http://d.hatena.ne.jp/yaneurao/20090203
を書いたのだけど、なんだか周囲の反応が、私が想定していた反応と全然違うような気がする。


現代的なゲームにおいて、タスクシステムがいるのか/いらないのか、もしいるとしたらどうしているのか、もしいらないとしたらどうしていらないのかという点は、みんなわかってるものだと思っていたので大部分を省略したのだが、案外この部分がみんなわかってないのかも知れない。


■ タスクシステムから学ぶべきこと


例えば、この人のソースを見てみよう。


タスクシステムの話。(2) (9-Laboratory)
http://qo.sakuratan.com/2009/02/03/090203/

/*自機*/
class Mine
{
   /*現在位置を返す*/
   const Vec3& GetPosition();
};
/*敵機*/
class Enemy
{
public:
  /*自機にめがけて打つ*/
  void OnShootMine()
  {
     const Vec3& minePos = mine_->GetPosition();
     /*以下、何か打つ*/
  }
  void SetMine(const shared_ptr<Mine>& mine ){ mine_ = mine;}
private:
  shared_ptr<Mine> mine_;
};
class GameMain
{
public:
   void InitGame()
   {
      mine_ = shared_ptr<Mine>( new Mine() );
      enemy_ = shared_ptr<Enemy>( new Enemy() );
      enemy_->SetMime( mine_ );/*これがダサいか、taskSystem.GetTask(5000)がダサいか。*/
   }
private:
   shared_ptr<Mine> mine_;
   shared_ptr<Enemy> enemy_;
}


このソースではshared_ptrを用いて実装されている。(ソースは引用するに当たり、いくつか修正した。)


問題はEnemyクラスがMineをshared_ptrで保持しているということだ。これは(このあといろいろ追加していくつもりなら)良くない書き方だ。これではオブジェクトを循環参照するとき、きちんとオブジェクトが解放されない。


つまり、(上のソースで言えば)GameMainはshared_ptrで保持していいが、そこ以外のクラス(ここではEnemyクラスやMineクラス)は、すべてshared_ptrではなくweak_ptrで保持すべきだ。このことは、昨日の記事で書いた。この点を今一度確認していただきたい。


また、上のソースでは、Enemyクラス生成時にMineクラスのinstanceが生成されている必要がある。一般的にはそんなことは保証されない。だから、そういうケースにおいては、もっと抽象的なタスク間の通信手段が必要になる。それをどうやるのか。


このように、タスクシステムの実装を通じて、タスク間通信を抽象化して捉えたときに、いろいろ見えてくることもある。だから、タスクシステムについて一通り理解して、(なるべく汎用的かつ高速に動作するものを)実装して、問題点を洗い出しておくことは有効だと私は主張しているのであって、タスクシステム自体は、最終的にゲーム作りに使おうと使うまいとそんなことはどうでもいいのである。


少なくともこの人には、私は、昨日の記事から、「ああ、このケースではshared_ptrとweak_ptrを組み合わせて使うのか!」と学んで欲しかった。


すべてのゲームにタスクシステムが必要なのではない。上のプログラムのように事前に生成されるinstanceがすべて確定していればそのようなタスク間通信の問題に頭を悩まされることもないだろう。そういうゲームを作るのに抽象的なタスク間通信なんて実装したところで無用の長物である。


だから、

「なんで、わざわざグローバルから取り出す必要あるの?ややこしくしてない?」
という点。わざわざ、そうしないといけないようには思えません。

は正しい。タスク間通信が上のプログラムのように単純化できるケースでは、わざわざタスク間通信を抽象化する意味はあまりない。


逆に、ある規模のゲームを作るとき、汎用的なタスク間通信の仕組みが常に必要になることがある、そういうゲームにとっては、タスク間通信がしっかりサポートされたタスクシステムがあれば、そのタスクシステムは再利用できる。また、タスクシステム自体が再利用可能であるということは、それに付随する遺産(タスクデバッガ、タスクビュアー、タスクエディタ、タスクスクリプトなどタスクシステムに関連していろいろ作ってきたツール類)も同時に再利用可能である。そういうときは、それなりにフレームワークからの恩恵が受けられる。そういうものを作ってきていない/作る気がないなら、まったくどうでもいい話になってしまうのだが。


あるいは、もっと大きな規模のゲームならば、タスクシステムを放棄して、それ専用のミドルウェアを開発していく方向になるだろう。これは、タスクの呼び出しのオーバーヘッド自体が無視できなかったり、タスクシステムレベルでの並列化に技術的な限界があったり、何十万というオブジェクトを扱うのにstd::listが向いてなかったりするからだ。


まあそんなわけで、ゲームプログラミングの教科書でタスクシステムは(これを絶対に使いなさいという意味ではなく、タスク間通信を扱う題材の一つとして)取り上げて解説するに値する内容ではあると私は思うのだが。


■ タスクシステムのメリット


あと、せっかくなので、もう少しこの人の疑問に私がお答えしよう。

記事中のOnDraw内では実際の描画は行わず、何を描画するのかだけ蓄えるようにして、
全てのOnDraw()が終わった時点で別口でソートして、そこから初めて描画するように
するべきじゃないかな?と自分は考えます。
(そしてそれには「タスクシステム」は要らない。と思います。)


描画の順序を制御するためだけならば(現代なら)タスクシステムは不要だというのは正しい。タスク間通信が出来たり、タスクフレームワークから恩恵が受けられるのがタスクシステムの大きなメリットだと私は思う。


しかし、プライオリティを持った描画を行なうと一概に言っても、いろいろなケースが想定される。


A) 3Dが使える環境で2D表示をしている場合
A-1) テクスチャ描画をするときにZ軸の設定だけで描画プライオリティを実現できるかも知れない
A-2) それでもfogなどのエフェクトなどは順序に意味があるので描画順序が守られる必要があるかも知れない。


B) ワールドの更新(OnMove)と描画(OnDraw)を同時にやっていい場合
B-1) OnMoveとOnDrawを一体化して一つのメソッドになっていて構わない。あまり美しいとは言い難いが、2回に分けて、メソッド呼び出しをするのが嫌なので、こういう実装で済むときはこれでも良いだろう。


C) ワールドの更新と描画とは分離する場合
C-1) OnDrawが呼び出されたときに描画してしまう方法。これで済むならこれでも良い。
C-2) OnDrawが呼び出されたときに描画せず、描画内容を何らかの形で蓄え、そこで設定したプライオリティに従ってあとで並び替えて描画する方法。


このへんは、そのゲームによって適切なものを選択すれば良い。ゲームの仕様で要求されているのがB-1)なのにC-2)で実装するのは手間がいたずらに増えるだけである。


このように、ゲームによって要求されるものが異なる&ターゲット環境が異なるので、汎用的に書くなら、OnDrawの引数はtemplate classになる。昨日の記事で、引数がtemplate classになっていたのはそういう意味である。タスクシステムの利用方法を上記C-2)に限定したくないのである。


それはさまざまな環境で動く、動かしうるゲームフレームワークを作りたいからである。フレームワークとして、A),B),C)のいずれかにターゲット環境を固定したくないのである。


■ タスク間通信は型を指定して行なうべきか?


前回の記事で省略した部分を少し書いておく。タスク間通信を行なうため、タスクの型を指定してその型のタスクすべてを列挙することが出来ると便利だということを前回書いた。(タスクシステムにとって必須の機能だと言うつもりはないが、特定のゲームを実装するのに有用だとは言えると思う。)


ところが、このとき指定する型は、普通は、最後に派生された型に限定したくない。


BossTaskというclassから、BossTask1,BossTask2,BossTask3,…というclassを派生させているとしよう。これらは各ステージのボスである。ボスが画面上にいるときは画面には、ボスの体力の残りを表示したい。すなわち、BossTask派生型のinstanceを列挙したい。しかし、このとき、列挙するためのメソッドを次のようには書きたくない。

template <class T>
std::list<T*> getAllTask()
{
   std::list<T*> list;
   foreach(Task* task in tasks)
   {
     T* t = dynamic_cast<T>(task);
     if (t!=NULL)
        list.push_back(t);
   }
   return list;
}

これは、tasksがたくさんの要素を扱っているととても遅いからである。(でもたくさんの要素がなければこう書いても良い。)


また、dynamic_castによる型判定も、うまくやれば本当はわずかなテストで済むはずなのだが(例えば http://www.kmonos.net/wlog/57.php#_1848060114 )、そうなっているコンパイラは皆無だろう。


出来れば、std::listもdynamic_castも使わずに実装して、基底型を指定して高速にその型の派生型のタスクを列挙できるような機能をタスクシステムに提供して欲しい。テンプレートメタプログラミングを駆使するとこれはうまく書く方法が(中略)


このようにタスクシステム自体には、ゲームプログラミングに必要なパターンがたくさん出てくる。だからこそ、面白く、題材として取り上げる価値がある。