ホーム < ゲームつくろー! < クラス構築編 < 描画の責任者は?線形描画とツリー描画のお話し
描画の責任者は?線形描画とツリー描画のお話し
ゲーム製作において「描画」というのは必要不可欠な部分です。そして、実に厄介な部分でもあります。それは1つのゲーム画面を構成するオブジェクトの数が膨大であり、絶えず動いているからです。また、描画するオブジェクトの種類も様々で、しかもメモリ上にいつでも存在するものでもありません。RPGでエンディング間際であるにもかかわらずゲームの最初に登場したオブジェクトが残っていては、メモリがいくらあっても足りません。そのような入れ替わり立ち代りのオブジェクトを管理して、正しく描画する。少し考えただけでも大変であることが容易に想像できます。実は描画部分を製作することは、ゲーム全体のシステムの根幹を作るのに匹敵するんです。
この章ではそのような厄介者の「描画回り」をどのようにして作成したら良いか試行錯誤してみたいと思います。
@ 描画の基本は「オブジェクト指向」
ゲームに登場する多くのオブジェクトが「描画する」という機能を有します。点や線、サーフェイス、板ポリゴン、ポリゴンオブジェクト、スキンメッシュアニメーション、etc...とあらゆるオブジェクトが描画対象となります。ある1画面を構成するには、それらの基本プリミティブをさらに複合させて描画します。
「どう描画するかは知らないけれども描画するという機能を持つ」。これはオブジェクト指向のお手本のような状態です。つまり、全ての描画対象クラスに仮想関数として定義されたDrawメソッドを持たせて、描画ルーチンで全ての描画対象オブジェクトのDrawメソッドを呼び出してしまえばいいんです。巷の教本にもあると思われる典型的な例でそれを示します:
描画ルーチンの例 #include <list>
using namespace std;
// オブジェクト基本クラス
class CObject
{
public:
virtual bool Draw() = 0; // 描画関数(純粋仮想関数)
};
// 円クラス
class CCircle : public CObject
{
protected:
float m_fRadius; // 半径
public:
virtual bool Draw(); // 円を描画
};
// 長方形クラス
class CRectangle : public CObject
{
protected:
float m_fH; // 高さ
float m_fW; // 幅
public:
virtual bool Draw(); // 長方形を描画
}
////////////////////////
// メイン関数
///////
int main()
{
CCircle *pCircle = new CCircle;
CRectangle *pRectangle = new CRectAngle;
list< CObject* > ObjList;
// オブジェクトを登録
ObjList.push_back( pCircle );
ObjList.push_back( pRectangle );
// 描画ループ
list<CObject*>::iterator it;
while( !bDrawEnd )
{
for(it=ObjList.begin(); i!=ObjList.end(); it++)
(*it)->Draw(); // 各オブジェクトを描画する
}
delete pCircle;
delete pRectangle;
return 0;
}
余分な部分は端折っていますが、注目は太文字部分です。ObjListリストにはCCircleオブジェクトへのポインタとCRectangleオブジェクトへのポインタが1つずつ格納されています。これらはすべてCObjectクラスの派生系なので、Draw関数を持っています。よって、ループ内ではCCircle::Drawメソッド及びCRectangle::Drawメソッドがそれぞれ呼ばれます。オブジェクト指向の基本中の基本である「多態性」を描画に使っているわけです。
巷の教本はここまでは大抵書いてくれています。非常に大切な部分でありがたいことなのですが、では実際にゲーム製作でこれを使ってみようと思うと教本通りに中々いかない事にすぐに気が付くはずです。教本で例として扱っているオブジェクトの数と、ゲームで扱う数とでは雲泥の差がありますし、教本ではオブジェクトの追加と描画の呼び出し方法は教えてくれますが、「削除」の方法はあまり触れてくれません。冒頭でも述べましたように、ゲームではオブジェクトの生成と削除が物凄いスピードで行われます。それをきちっと管理しながら描画しなければ、すぐに手詰まりになってしまうんです。
A 教本では教えてくれない描画ループの「削除」の話し
@で示した追加型の描画ループには1つ大きな落とし穴があります。それは、追加したオブジェクトポインタの存在を保証できない事です。上のプログラムでループの最中にpCircleをdeleteしたらどうなるでしょうか?その瞬間にメモリ保護違反でゲームがストップします。毎回の描画において、ただの1つもダングリングポインタを作ってはいけないんです。
この厳しい制約を守って問題を一気に解決する方法としては、スマートポインタの使用が考えられます。スマートポインタを用いれば、ダングリングポインタの問題は物理的に起こりえません(スマートポインタについてはクラス構築編『スマートポインタテンプレートクラス』をご覧下さい)。オブジェクトのポインタをリストに登録する代わりにスマートポインタを扱うようにすれば、描画ループが保持している間はポインタの先のオブジェクトが保証されます。安全の保証には、描画ループとスマートポインタ(もしくはガーベージコレクタ)は切っても切れない関係なんです。そしてうれしいことに、スマートポインタを用いると、教本には載っていない「削除」の問題も非常に簡単に解決できるんです。人により好き好みがあるかもしれませんが、私はゲーム製作全般にスマートポインタの使用を強く勧めます。
さて、ではスマートポインタにした場合の@のプログラム改良例を見てみましょう:
スマートポインタを用いた描画ルーチンの例 #include <list>
#include <SmartPtr.h> // スマートポインタ(これは自前のものでもフリーのライブラリでも何でもいいです)
using namespace std;
// クラスの部分は省略します・・・
typedef sp<CObject> SPObject; // CObjectのスマートポインタ
////////////////////////
// メイン関数
///////
int main()
{
sp<CCircle> spCircle = new CCircle;
sp<CRectangle> spRectangle = new CRectAngle;
list< SPObject > ObjList;
// オブジェクトを登録
ObjList.push_back( spCircle );
ObjList.push_back( spRectangle );
// 描画ループ
while( !bDrawEnd )
{
// 各オブジェクトを描画する
// スマートポインタで「->演算子」がオーバーロードされているとします
for(it=ObjList.begin(); i!=ObjList.end(); it++)
(*it)->Draw(); // 各オブジェクトを描画する
}
return 0;
}
スマートポインタはnew演算子で確保したオブジェクトを内包します。内包したポインタ先の削除も自動的にやってくれますので、deleteを呼ぶ必要はありません。ちなみに、return文の直前まで、各オブジェクトの参照カウンタは「2」になっています。
スマートポインタを用いるとdeleteが必要なくなりますが、誰が削除するかがわからなくなるのでオブジェクトを消すという概念があいまいになってきます。気を付けないとメモリにオブジェクトが溜まったままになるのですが、恩恵の方が凄まじいので使わない手はありません。スマートポインタでの「消去」という概念は2つあります。1つはスマートポインタ自体を削除することです。これは、スマートポインタをメモリから削除する事でして、削除直前にデストラクタによって参照カウンタが減らされることになります。カウンタが0になれば、内包したポインタ先のオブジェクトがdeleteされます。もう1つは「別のオブジェクトポインタを保持する」事です。これは、それまで保持していたポインタを入れ替える事になるので、やはり参照カウンタが減らされます。
描画ループでの削除は前者の方でして、リストからスマートポインタを除くことで削除が行われます。手っ取り早い話し、上プログラムで、
ObjList.clear();
とすると、描画オブジェクトはリストから全てなくなりますので、それ以上の描画はされません。この時スマートポインタの参照カウンタは、登録したオブジェクトすべてについて1つずつ減らされるだけです。中にはそれによってメモリから無くなるオブジェクトもあるでしょうが、少なくとも描画ループは自身からスマートポインタを削除した後の事については関与しません。
B 消去通知と意外と難しいリストからの消去
スマートポインタを用いれば、リストから描画したくないオブジェクトをはずすだけで事足ります。ダングリングポインタの問題も決して起こりません。必ずしもこの通りにしなければならないと言う理由はありませんが、安定した描画にはこれに似た機構が絶対に必要になります。ところで、全部消すのは簡単ですが、寿命の尽きたオブジェクトだけをリストから削除するにはどうしたら良いでしょうか?
スマートポインタを使っている場合、内包しているオブジェクト自らがdeleteを呼ぶわけにはいきません(これは最悪のダングリングポインタを誘発します)。オブジェクトの削除権限はあくまでもスマートポインタを持っている親にあるんです。ここで言う「親」とは描画リストに他なりません。ということは、リストは何らかの形で削除をすべきオブジェクトを判別する必要があります。
リストが持っているのはCObjectという基本クラスのオブジェクトポインタです。それが必要か不必要かは、ポインタをいくら眺めていても判断できません(スマートポインタなのでNULLポインタは考えない)。削除するには誰かに「もう必要ないですよ〜」と教えてもらうしかないわけです。この考えを推し進めると、削除を通告する最も簡単な通知者は登録されているオブジェクト本人であることがわかります。つまり、「私を消してください」とオブジェクト自体が要求するんです。この事から、CObjectには描画の有無を知らせる仕組みが必要になります。これはそれほど片意地張らなくても良くて、単にCObject::Drawメソッド自体が通知を出すようにすれば良いだけです:
描画通知付きCObjectクラス class CObject
{
bool Draw(); // 戻り値は描画通知
};
描画対象オブジェクトのDrawメソッドを呼んだ結果、戻り値がfalseだったら、リストはそのオブジェクトをはずします。はずすだけで、少なくとも描画対象にはならなくなるわけです。
さて、STLのlistはコンテナ内のオブジェクトをはずす時間が一定で、且つ他のコンテナよりも群を抜いてその作業が高速です。ところが、リストからはずすと言う何でもなさそうな機構を実際に作ってみると、意外と混乱するんです。これは実例を見た方が早いでしょう。次のプログラムをご覧下さい:
リストから要素をはずす(失敗例) list<CObject*>::iterator it;
for(it=ObjList.begin(); it!=ObjList.end(); it++)
{
if( !(*it)->Draw() )
// オブジェクトをリストからはずす
it = ObjList.erase( it );
}
非常に素直な事をしています。Drawメソッドがfalseを返したら、そのイテレータをeraseメソッドを用いてリストからはずしているだけです。eraseメソッドははずした次のイテレータを返してくれます。ところが、これを動かしてみると全然うまく動きません。リストが消されたり消されなかったりするんです。それはeraseメソッドの微妙な仕様にあります。
list::eraseメソッドは、引数のイテレータに該当する要素をリストから削除して、戻り値に次のイテレータを返します。上の実装の場合、eraseメソッドで消された時点でitが次のイテレータを指しますが、forループの頭に戻った時に「it++」によってさらに次のイテレータに変わってしまいます。つまり、消された次のイテレータが指す描画オブジェクトがスキップされてしまうんです。その様子を下の図に表してみました:
これを解決するには、削除後(真ん中の段階)にイテレータを1つ戻せば良いのですが、そうなると今度は「0番」が削除要求を出した時にエラーとなってしまいます。0の後ろは無いんです!このように、リストの要素の削除は案外侮れないんです。
諸悪の根源は実はforループの頭でイテレータをインクリメントするところにあります。eraseメソッドがおこすインクリメントとかぶってしまうわけです。そこで、forループを次のように書き換えます:
リストから要素をはずす(おしい例) list<CObject*>::iterator it;
for(it=ObjList.begin(); it!=ObjList.end();) // 最後の「it++」を消す
{
if( !(*it)->Draw() ){
// オブジェクトをリストからはずす
it = ObjList.erase( it );
continue;
}
it++; // ここでインクリメント
}
forループの最後の項でイテレータのインクリメントをしないようにします。そして、削除要求があった場合は、イテレータをインクリメントせずにcontinueとしてループを続けます。削除しない場合は、ループの最後でイテレータを増やします。こうすると、不当なスキップが無くなるので、正しくリストから要素を削除できるようになります。これがSTLのリストから要素を除くコツです(^-^)。
C 描画の責任者は一人が沢山か?
細かい話を色々としてきましたが、この章の核心は実はここからです。Drawメソッドが返す値を用いてリストから描画オブジェクトを削除する部分は取りあえず出来ました。削除の自動化が実現できているわけです。では、この描画ループ自体は誰が持つのでしょうか?
ゲームを製作していると、いずれ場面(シーン)を総括する大きなクラスに行き着くと思います。このクラスがゲームの大きなループを回すわけです。となると、このクラスが描画ループを持つと考えても良さそうです。実際、そのような実装をしたゲームは沢山あると思います。図で表すと次のような仕組みです:
シーンクラスがSPObjectのリストを持って、そこに登録されたオブジェクトが登録順に描画される。この単純な描画機構を「線形描画」とでも呼んでおきましょう。巷の教本には、この仕組みが良く紹介されています。確かに、これは非常に分かりやすい仕組みですし、オブジェクト指向を使っている面もあります。しかし、1つ大きな問題を抱えているんです。
線形描画の場合、シーンクラスは描画するオブジェクトを全て把握している必要があります。つまり、どこかで必ず、画面に出てくる点や線やポリゴンオブジェクトへのスマートポインタをシーンクラスが持つ描画リストに登録しなければならないんです。末端のオブジェクトまで全部知っている必要がある。これはオブジェクト指向になっていません。実は、上の説明にとどまっている巷の教本は、深い意味でのオブジェクト指向を実践していないんです。
シーンクラスが描画に関して真にやるべきことは、「自分自身が把握している描画オブジェクトのDrawメソッドのみを呼ぶ」ということではないでしょうか。つまり、自分が意識しない末端のオブジェクトの事については、シーンクラスではなくて、それについて良く知っているオブジェクトが描画をするべきなんです。知らないやつの事は意識しない。それがオブジェクト指向の根本的な考え方です。それに習うなら、「描画の責任者は一人じゃなくて沢山いる」というのが、描画ループの真にあるべき姿だと思います。
では具体的にどうするか?話しはとっても簡単なんです。例えばシーンクラスには、ゲーム画面である部分を担当する専門のクラス(CChildObject)を持たせます(CObjectから派生)。そして、そのクラスのオブジェクトを描画リストに登録します。CChildObjectは自分が担当するオブジェクトを生成し保持しますが、この時に描画すべき子オブジェクトを自身の持つ描画リストに登録します。シーンクラスのDrawメソッド内でCChildObject::Drawを呼び出し、さらにCChildObject::Drawメソッドの内部で子オブジェクトのDrawメソッドを呼び出す。こうすると、描画は次のようなツリー状になります:
Child1::Drawメソッド内では描画リストに登録された3つの描画対象のDrawメソッドが順番に呼ばれます。SubA::Drawメソッド内にもさらに2つの描画オブジェクト(Sub1, Sub2)があり、それはSubA::Drawxメソッド内で責任を持って描画します。シーンクラスは末端のSub1オブジェクトの事など微塵も知りませんが、画面にはちゃんと描画はされることになります。このツリー状の描画機構を「ツリー描画」と呼ぶことにしましょう。ツリー描画を実装すれば、描画回りは相当に自由が利くようになります。
D 描画リストクラス
上のツリー描画の仕組みは、実は個々のオブジェクトが独自に自身の持つオブジェクトのDrawメソッドを呼んでも実現できます。数が少ないのでしたら別にリストにする必要も無いわけです。ただ、仕組みを統一しておくと登録や削除について気にすることなく開発を進められるようになります。
ツリー描画を実現する実装には、大きく2つの選択肢があります。1つは、CObject基本クラスに子描画オブジェクトの登録やそれらの描画を行うメソッドを追加するという選択です。これは、クラスの派生を意識しています。もう1つは子描画オブジェクトの登録と描画の部分を1つのクラスに独立してまとめてしまって、子描画を必要とするクラスがそれをコンポジションとして持つという選択です。どちらも一長一短がありますが、どちらでもツリー描画は実現できます。ただ、機能の義務付けを強くし、統一した方法で描画を実現したいので、ここではクラスの派生で考えてみる事にします。
描画機能を持つCObjectクラスに子描画オブジェクトの登録やそれらの描画を担うメソッドを追加します。メ例えば次のようになるでしょうか:
描画リスト付きCObject class CObject
{
protected:
list<SPObject> m_DrawObjList; // 子描画オブジェクトリスト
public:
virtual bool Draw(); // 戻り値は描画通知
virtual size_t RegistDrawObj( SPObject spDrawObj ); // 子描画オブジェクトを登録
protected:
virtual void DrawChild(); // 子描画オブジェクトの描画
};
RegistDrawObjメソッドで子描画オブジェクトをリストに追加し、DrawChildメソッドで自身の持つ子描画オブジェクトのDrawメソッドをリストを辿って順番に呼び出します。DrawChildメソッドの中身はBで示した実装になるかと思います。このメソッドはprotected宣言されていますが、これは外部がこのメソッドを不用意に呼び出すことを禁止するためです。各メソッドの実装部分は、もう殆ど示されたようなものなので省略致します。この比較的簡単な追加をするだけで、描画ツリーはあっさりと実現できます。ただし、各オブジェクトのDrawメソッド内でちゃんとDrawChildメソッドを呼ぶ必要があります。
E 万歳までにはもう少し・・・「描画オブジェクトの譲渡」
「これで描画はばっちりだ」と思いたい所なのですが、実はもう少し足りない部分があるんです。これは、ツリー構造特有の問題と言っても良いかもしれません。Cの最後に示した図をご覧下さい。例えば、SubAをSTGの敵機だとしましょう。そして、Sub1というのが敵機が発射した弾だとします。構造として分かりやすい関係です。自機が敵機を破壊して、敵機がメモリからいなくなったとします。この時、敵機が発射した弾も運命共同体になって一緒になくなってしまいます!描画担当者である敵機がいなくなったのですから当然です。つまり、描画担当者が複数になると、その担当者と運命を共にするオブジェクトが山ほど生じてくるんです。
これは、「各オブジェクトの寿命」をかなり真剣に考えなければならないことを示しています。先ほどのような状況を起こさないために、真っ先に考え付く事は「担当者を消さない」という事です。敵機を破壊しても、それが発射した弾が全部消えるまで敵機オブジェクトを残しておくと、見た目の違和感は無くなります。しかし、これには末端の末端までの動作をチェックする必要があるわけでして、せっかくのオブジェクト指向が台無しです。よって、残すという考えはあまりお勧めできません。
そこで考え付くのが「描画オブジェクトの譲渡」です。
ある描画担当者がいなくなる時(Drawメソッドがfalseを返す時)、親はその描画担当者が持っている描画リストを引き継ぎます。これにより、描画担当者は描画責任から解放されて、後腐れなくメモリから去ることができます。親は引き継いだオブジェクトを自分自身のリストに追加して、以後の描画を担当することになります。これを実現するには、先ほどのCObjectクラスに子の描画リストをそっくり受け継ぐUnderTake(譲渡)メソッドを追加します:
描画リスト付きCObject class CObject
{
protected:
list<SPObject> m_DrawObjList; // 描画オブジェクトリスト
public:
virtual bool Draw(); // 戻り値は描画通知
virtual size_t RegistDrawObj( SPObject &spDrawObj ); // 描画子オブジェクトを登録
protected:
virtual void DrawChild(); // 子オブジェクトの描画
virtual void UnderTake( SPObject &spDrawObj ); // 描画オブジェクトの譲渡
};
このメソッドの実装は例えば次のようになります:
UnderTakeメソッド実装部 void CObject::UnderTake( SPObject &spDrawObj )
{
// 子オブジェクトの描画リストの中身をコピーする
list<SPObject>::iterator it;
for(it=spDrawObj->m_DrawObjList.begin(); it!=spDrawObj->m_DrawObjList.end(); it++)
{
m_DrawObjList.push_back( (*it) ); // 自身にコピー
}
引数の描画リストを空にする
spDrawObj->m_DrawObjList.clear();
};
中身をそっくり受け継いで、相手を空っぽにしているだけです。ここで「m_DrawObjListはprotected宣言だからアクセスできないんじゃ?」と思われた方は非常に鋭いです。実は、自身と同じクラスであるオブジェクトを引数にした時は、そのオブジェクトのprotected宣言された変数にダイレクトにアクセスできます。よって、上のプログラムはちゃんと動くんです。
UnderTakeメソッドは、DrawChildメソッド内で描画担当者が描画を放棄した時に呼び出すようにします。実装例を示しておきましょう:
DrawChildメソッド実装部 void CObject::DrawChild()
{
// 子描画オブジェクトの描画を実施
list<CObject*>::iterator it;
for(it=m_DrawObjList.begin(); it!=m_DrawObjList.end();) // 最後の「it++」を消す
{
if( !(*it)->Draw() ){
// 描画の権利を受け継ぐ
UnderTake( (*it) );
// オブジェクトをリストからはずす
it = ObjList.erase( it );
continue;
}
it++; // ここでインクリメント
}
}
こうすることによって、STGで敵機が破壊されて弾の描画権を放棄したとしても、残った弾は自分の意思で飛んでいきます。どこで誰がいなくなっても、とりあえず描画は継続されるわけです。いつでもその状況が必要であるわけではありませんが、これが無いと困る場面も結構ありますので、自動化しておけば間違いないでしょう。
「描画ループ」というゲームの基本的な部分は、掘り下げていくと少なくともこのくらいまでは考えないとうまくいかないんです。教本が押さえている基本部分はもちろん大切ですが、そのままでは大きなゲームは作れません。今回紹介した方法は、オブジェクト指向の枠組みを崩していませんし、基本クラスに追加するだけなので、明日からでも使えます。改良の余地も残されています。例えばオブジェクトのコピーの問題。2つのオブジェクトをコピーすると、子オブジェクトの描画が2回行われるかもしれません。この対処はこれまた結構面倒なんです。しかしながら、ツリー描画は有用ですから、皆さんの使いやすいように実装してみると良いかなと思います。
ツリー描画の考え方は、実は「オブジェクトの更新」についてもそっくりそのまま当てはまります。フレーム単位で動くゲームでは、オブジェクトの1回の更新を1フレーム分の動きの更新と考えると、オブジェクトを扱いやすくなります。この更新(Updateメソッド)を呼ぶ責任者も、描画同様にツリー状になって良い部分です。そうなると、更新と描画でツリーを2回通る事になり速度低下を招きそうに感じますが、何万ものオブジェクトを一度に扱わない限り殆ど気にする必要はありません。パーティクルなどは更新と同時に描画ができるかもしれませんので、両方を一度に行う工夫をしたって良いんです。担当者が複数いるわけでして、臨機応変が利きます。
ツリーの恩恵に預かって、描画と更新の呼び出しと削除から解放されたプログラム開発を目指しましょう〜