ホーム < ゲームつくろー! < DirectX技術編 < アニメーションの根っこ:アニメーションコントローラと行列スタック

その26 アニメーションの根っこ:ワールド変換行列スタック


 前章で苦労の末D3DXLoadMeshHierarchyFromX関数によってフレーム構造体(D3DXFrame)を取得しました。この構造体の中には、描画オブジェクトに必要な情報がぎっしりと詰まっています。この構造体は今後色々と扱っていくわけですが、とりわけ面倒なのが「フレームのワールド変換行列を求める」という作業です。この作業において「行列スタック」という技術が必須となります。フレームのワールド変換とは何なのか?行列スタックとは何なのか?この章ではその辺りを見ていくことにしましょう。



@ フレームに分散されるメッシュ情報

 D3DXLoadMeshHierarchyFromX関数によって取得されたD3DXFRAME構造体の中には「フレーム」という単位で情報が管理されています。フレームには明確な親子関係及び兄弟関係があり、複雑な樹状リストを形成しています。D3DXFRAME構造体は次のように定義されています。

D3DXFRAME構造体
typedef struct _D3DXFRAME {
   LPTSTR               Name;
   D3DXMATRIX           TransformationMatrix;
   LPD3DXMESHCONTAINER  pMeshContainer;
   struct _D3DXFRAME    *pFrameSibling;
   struct _D3DXFRAME    *pFrameFirstChild;
} D3DXFRAME, *LPD3DXFRAME;

Nameにはフレームの名前が格納されます。
TransformationMatrixにはフレーム行列が入ります。これを理解することが、この章の1つの肝です。
pMeshContainerにはメッシュの情報が格納されます。フレームがメッシュを持っていない場合はNULLになります。
pFrameSiblingは兄弟フレームへのポインタが格納されます。兄弟が複数いる場合、自分の次の兄弟へのポインタとなります。兄弟同士がリスト構造になっていると言うわけです。
pFramefirstChildは子フレームへのポインタが格納されます。親にしてみれば、子は沢山いるかもしれませんが、その一番年上の子(変な表現ですが)へのポインタとなります。他の子は年上の子の兄弟として検索されることになりますね。

 なにやらややこしそうなのですが、フレーム自体の属性(名前、フレーム行列、メッシュ情報)以外は樹状構造を形成する変数だけです。そう分けると、シンプルな構造体になっています。D3DXMESHCONTAINER構造体についてはその25で嫌と言うほど中身を見ましたので、改めて取り上げる必要は無いでしょう。

 ここで重要なのは、1つのフレームの中にメッシュの情報も格納されていると言う点です。これにより、このフレーム構造体のみで描画まで持っていけるのです。また、フレームの親子関係は、そのままメッシュの親子関係になります。これにより「複数のメッシュの親子関係を保ったまま描画する」という事が可能になるのです。しかし、そこに行くまでには、中々にして大変なのです(^-^;



A ワールド変換行列を計算する

 1つのポリゴンメッシュはワールドに置かれて、初めてカメラに捕らえられます。そのためにはポリゴンが世界のどの位置に置かれるかを定義するワールド変換行列を得なければなりません。もし、ポリゴンメッシュにフレームが1つしかないのであれば、それはどこも可動しないオブジェクトであって、ワールド変換行列はユーザが思ったそれそのものとなります。しかし、稼動する部分が複数あって、それによりフレームが複数に分割されている場合は、フレームごとのワールド変換行列が必要になります。…と説明してもイメージが普通沸きませんので、簡単な例でこの単元を説明していきます。

 今ルートフレーム(親はローカル座標)の原点が(10,0,0)にあるとしましょう。このフレームの子(ChildFrame)の原点は、親からの相対値として(7,0,0)にあるとします。

 上はその様子を図示したものです。緑色の子フレームは、赤い座標軸で表されている親から見ると、(7,0,0)に位置していますよね。フレーム行列に記録されているのは、この10とか7という相対値です。もし、何も考えずに各フレームの相対値をX軸方向に-10だけ平行移動するワールド変換をしたとする、こうなってしまいます。


 ワールド空間におけるルートフレームの位置は(0,0,0)、そして子フレームは(-3,0,0)になってしまいました。親フレームは思ったとおりなのに、子フレームの位置は明らかにおかしいです。これは、単純に(7,0,0)-(10,0,0)=(-3,0,0)という計算をしてしまったからです。相対値をずらしてしまうのはまずいんです!
 
 子フレームの位置はルートフレームからの相対値なのですから、ルートフレームのズレ分を足し算しなければいけません。先ほどの図で言うと、まず親が10ずれて、さらに子が7ずれているわけですから、子フレームは(17,0,0)だけ「ローカル座標」からずれています。これはローカル座標からの絶対値です。この座標値からx=-10というワールド変換をしますと、めでたく正しい位置(7,0,0)が出てきます。フレームの変換方法、実感できましたでしょうか?


 ある子フレームのローカル座標に対する絶対値を求めるには、その親フレームのローカル座標からのズレを全部足し算していく必要がある事がわかりました。ただ、フレームの相対値は行列で表されていますので、足し算ではなくて「掛け算」になります。これは、行列による変換の基本ですよね。

 今、ルートフレームの持つ変換行列(ローカルからの相対値)をM(Root→Local)とし、その直下にある子フレームの行列(ルートフレームからの相対値)をM(Child→Root)とします。この時、子フレームをローカル座標に変換する行列M(Child→Local)は、

M(Child→Local) = M(Child→Root) × M(Root→Local)

と計算されます。この掛ける順番に注意してください。右側には、子フレームにたどり着くまでの親の絶対値が来ます。矢印部分だけを見ると、何となくそういう感じがしますよね。この関係は、どんなに深いところにある子フレームに対しても変わりません。ついでに、孫フレームをローカル座標に変換してみましょうか。孫フレームは子フレームからの相対行列M(GrandChild→Child)を持っています。これをローカル座標にもっていくには、

M(GrandChild→Local) = M(GrandChild→Child) × M(Child→Root) × M(Root→Local)
                             = M(GrandChild→Child) ×
M(Child→Local)

とすれば良い事がわかります。求めたローカル変換座標に対して、さらにワールド変換を適用すれば、どの子フレームも正しくワールド空間に飛ばされます。

 フレームには子だけでなく「兄弟フレーム(Sibling Frame)」もあります。これは、同じ親を持つわけですから、それをローカルに持っていくには、これまでの表記法と照らし合わせて、

M(Sibling1→Local) = M(Sibling→Parent) × M(Parent→Root) ×M(Root→Local)

と計算していきます。一度ルールを覚えてしまえば、簡単ですね。



B 行列スタックでワールド変換行列を最適に求める

 ある子フレームをローカル座標に持っていくには、それまでの親フレームの行列を全部掛け算する必要がある事がわかりました。孫フレームに注目すると、掛けるべき行列は[子フレームの行列]×[それまでの親フレームの行列]です。よく見れば、それまでの親フレームの行列が再び登場していますよね。これは、子フレームを考えた時にもう計算されているはずなんです。この計算の最適化を狙ったのが「行列スタック」です。これは、図を見た方が分かりやすいので、まず以下の図をご覧下さい。

 まずワールド変換行列を「スタック」に入れて置きます。スタック(Stack)とは「積み重ねる」と言う意味で、上の図で言うと透明のボックスに当たります。F1(=ルートフレームの行列)をワールド変換する行列を求めるには、その行列にWorldと書かれた行列を掛け算します。それは「スタックの一番上にある行列を取り出して掛け算する」という作業になります。掛け算の結果は、フレームに登録して置きましょう。その後で、算出されたF1のワールド変換行列をスタックに上から積み重ねます。これを「Push」と言います。ここまでが1つの作業です。

 では次に、F1の子フレームであるF2のワールド変換行列を求めてみます。これも、図をご覧下さい。

 先ほどまでの作業で、スタックには2つの行列が積まれています。一番上にあるのはF1をワールド変換する行列です。F1の子であるF2にこの行列を掛け算すると、F2をワールド変換する行列が出来上がります。動きがさっきと全く一緒であることに注目です。これは、子がどれだけ深くなっても同じ作業になるわけです。「子を掘り進める時はPushして行く」。これが行列スタックの1つの基本となります。

 さて、兄弟フレームについては、少し別の作業が加わります。今、F1には兄弟F1Sibがいるとします。F1Sibの親はローカル座標です。よって、F1Sibの扱いは実質F1と同じになります。ただ、スタック自体は上の図のようにF2→Wまで積まれているとしましょう。F1Sibのワールド変換行列を求めるには、現在のスタックから箱を2つ取り出す(「Pop」する)必要があります。

 このPopのタイミングですが、F2の解析が終わり、もうF2→Wが必要に無くなった時に1つPopされ、さらにF1→Wもいらなくなった時にそれがPopされることになります。Popし終わった後、欲しいF1Sib→Wを算出すれば、またそれを改めてPushし、今度はF1Sibの子を検索していくことになります。これは、樹状に分かれたフレームを1つ1つ検索していく「再帰関数」によって大変すっきりと表現することが出来ます。



C ワールド変換行列スタッククラス

 Bで説明した行列スタックを用いたワールド変換行列の計算方法は、再帰関数によって綺麗に表現することが出来ます。ただ、ローカル関数として再帰関数を定義するのはちょっといただけませんので、ちゃんとクラス化することにしましょう。
 クラス化の前に、まず準備としまして、フレーム行列を格納しているD3DXFRAME構造体をおさらいしておきましょう…と思ったら、この構造体、まだ説明していませんでした(^-^;。改めて、この大切な構造体を見てみます。

 D3DXFRAME構造体の複雑なリスト構造はD3DXLoadMeshHierarchyFromX関数が全てやってくれます。ただ、この構造体にはワールド変換行列を格納するものは設定されていません。それが無いと、えらい不便なのです。ですから、この構造体を派生させて、オリジナルなフレーム構造体であるD3DXFRAME_WORLD構造体を新設します。

D3DXFRAME_WORLD構造体
class D3DXFRAME_WORLD : public D3DXFRAME
{
public:
   D3DXMATRIX WorldTransMatrix;
};

この派生構造体をD3DXLoadMeshHierarchyFromX関数に渡す事になるのですが、内部でもこの派生クラスを生成してもらわなければなりません。その25のサンプルプログラムで示しておりますCAllocHierarcyBaseクラスは、生成部分が派生関数になっていますので、この拡張を簡単に行うことが出来ます。一応派生後のD3DXLoadMeshHierarchyFromX関数の使い方の例を挙げておきます。

// メッシュオブジェクトの読み込み
CAllocHierWorldFrame AH; // CAllocHierarchyBaseの派生クラス
D3DXFRAME_WORLD* pFR;
ID3DXAnimationController* pAC;
D3DXLoadMeshHierarchyFromX(
   Filename,
   D3DXMESH_MANAGED,
   g_pD3DDev,
   &AH,
   NULL,
   (D3DXFRAME**)(&pFR),
   &pAC
);

太文字の所がポイントですね。こうしなければ受け付けてもらえません。これらの拡張を行えば準備が整います。ワールド変換行列スタッククラスに移ることにしましょう。

 ワールド変換行列スタッククラスの目的はただ1つ。現在設定されているワールド変換行列を用いてフレーム構造体にあるワールド変換行列をすべて更新することです。よって、公開メンバ関数は、

・ ワールド変換行列を設定する(SetWorldMatrix関数)
・ フレーム構造体を更新する(UpdateFrame関数)

となりそうです。

 宣言部はこうなります。

ワールド変換行列スタッククラス(宣言部)
#pragma once

#include <d3dx9.h>
#include <stack>
#include "AllocHierarchyBase.h"   // ←D3DXFRAME_WORLDが定義されています

using namespace std;


interface IWorldTransMatStack
{
public:
   virtual void SetWorldMatrix( D3DXMATRIX* worldmat );
   virtual void UpdateFrame( D3DXFRAME_WORLD* frame );
};


class CWorldTransMatStack
{
protected:
   D3DXMATRIX m_WorldTransMatrix;
   stack< D3DXMATRIX* > m_MatrixStack;

public:
   CWorldTransMatStack(void);
   ~CWorldTransMatStack(void);
   virtual void SetWorldMatrix( D3DXMATRIX* worldmat );
   virtual void UpdateFrame( D3DXFRAME_WORLD* frame );

   protected:
   void CalcFrameWorldMatrix( D3DXFRAME_WORLD* frame ); // フレームワールド行列算出再帰関数
};


プロテクト宣言されたCalcFrameWorldMatrix関数が、再帰関数としてBで説明したスタック作業を行いながら各フレームのワールド変換行列を算出していきます。

 実装部ですが、UpdateFrame関数とCalcFrameWorldMatrix関数を見てみましょう。

UpdateFrame関数(実装部)
void CWorldTransMatStack::UpdateFrame( D3DXFRAME_WORLD* frame )
{
   // スタックの初期化
   while(!m_MatrixStack.empty())
      m_MatrixStack.pop();

   // ワールド変換行列をスタックに積む
   m_MatrixStack.push( &m_WorldTransMatrix );

   // ルートフレームからワールド変換行列を連続計算
   CalcFrameWorldMatrix( frame );
}

 この関数内では、スタックを空にして、一番最初にスタックに存在するワールド変換行列を積んで、再帰関数をスタートさせています。STLのstackにはclear関数が設けられていませんので、1つずつポップする必要があります(何でだろう…)。

CalcFrameWorldMatrix関数(実装部)
void CWorldTransMatStack::CalcFrameWorldMatrix( D3DXFRAME_WORLD* frame )
{
   // 現在のスタックの先頭にあるワールド変換行列を参照
   D3DXMATRIX *pStackMat = m_MatrixStack.top();

   // 引数のフレームに対応するワールド変換行列を計算
   D3DXMatrixMultiply( &frame->WorldTransMatrix, &frame->TransformationMatrix, pStackMat );

   // 子フレームがあればスタックを積んで、子フレームのワールド変換座標の計算へ
   if( frame->pFrameFirstChild != NULL ){
      m_MatrixStack.push( &frame->WorldTransMatrix );
      CalcFrameWorldMatrix( (D3DXFRAME_WORLD*)frame->pFrameFirstChild );
      m_MatrixStack.pop(); // 子フレームがもう終わったのでスタックを1つ外す
   }

   // 兄弟フレームがあれば「現在の」スタックを利用
   if( frame->pFrameSibling != NULL){
      CalcFrameWorldMatrix( (D3DXFRAME_WORLD*)frame->pFrameSibling );
   }
}

 実装はこれだけです。関数に入ったら、今のスタックでワールド変換行列を計算することにまずは集中します。それが終わったら、子フレームを持っているかを検索します。これがあると言うことは、最初に計算した自身のワールド変換行列が必要になりますので、それをスタックに新しく積んで、子のフレームへの計算に移行します。子フレームの計算から戻ってくる時には、自分が与えた行列がスタックの頭に残っているはずなので、それを外しておきます。そうすることで、兄弟が利用できるワールド変換行列が顔を出すわけです。兄弟に対しても同様に関数を呼び出しますが、兄弟の計算にはスタックをいじりません。引数のframeはD3DXFRAME_WORLD型ですが、子フレームや兄弟フレームを指すメンバ変数はD3DXFRAME型です。よって、どうしても明示的な変換が必要になってしまうことに注意してください。

 この再帰関数を全て抜け、UpdateFrame関数を抜けた時には、スタックにはローカル空間のワールド変換行列のみが残され、フレームのワールド変換行列は全て更新されています。



D 描画まで持って行きます!

 全てのフレームのワールド変換行列が定義されれば、後の描画は非常に簡単です。フレームを辿って行って、メッシュがあるフレームに対してメッシュ(ID3DXMeshインターフェイス)のDrawSubsetを呼び出せばいいだけなんです。その時のワールド変換行列は、もう計算してあります。
 フレームを辿るという作業は、実はもうCWorldTransMatrixクラスで行っています。このクラスを少し工夫するだけで、もう一度辿って描画するという手間を省くことが出来ます。辿っている最中にメッシュコンテナを持つフレームを見つけたら、そのフレームを一次的な描画リストに格納してしまうのです。先ほどまでの実装部分に少し追加します。

UpdateFrame関数(実装部)
void CWorldTransMatStack::UpdateFrame( D3DXFRAME_WORLD* frame )
{
  // スタックの初期化
   while(!m_MatrixStack.empty())
      m_MatrixStack.pop();

   // 描画フレームリストの初期化
   m_DrawFrameList.clear();

   // ワールド変換行列をスタックに積む
   m_MatrixStack.push( &m_WorldTransMatrix );

   // ルートフレームからワールド変換行列を連続計算
   CalcFrameWorldMatrix( frame );
}

フレームを辿る再帰関数にも少し付け加えます。

CalcFrameWorldMatrix関数(実装部)
void CWorldTransMatStack::CalcFrameWorldMatrix( D3DXFRAME_WORLD* frame )
{
   // 現在のスタックの先頭にあるワールド変換行列を参照
   D3DXMATRIX *pStackMat = m_MatrixStack.top();

   // 引数のフレームに対応するワールド変換行列を計算
   D3DXMatrixMultiply( &frame->WorldTransMatrix, &frame->TransformationMatrix, pStackMat );

   // フレームにメッシュコンテナがあれば、このフレームをリストに追加する
   if( frame->pMeshContainer != NULL )
      m_DrawFrameList.push_back( frame );


   // 子フレームがあればスタックを積んで、子フレームのワールド変換座標の計算へ
   if( frame->pFrameFirstChild != NULL ){
      m_MatrixStack.push( &frame->WorldTransMatrix );
      CalcFrameWorldMatrix( (D3DXFRAME_WORLD*)frame->pFrameFirstChild );
      m_MatrixStack.pop();    // 子フレームがもう終わったのでスタックを1つ外す
   }

   // 兄弟フレームがあれば「現在の」スタックを利用
   if( frame->pFrameSibling != NULL){
      CalcFrameWorldMatrix( (D3DXFRAME_WORLD*)frame->pFrameSibling );
   }
}


こうしておいて、描画リストを渡すGetDrawList関数を新規に設ければ、後は普通の描画と何も変わりません。

描画部分
CWorldTransMatStack WTMStack;
list< D3DXFRAME_WORLD* > *pDrawList;

WTMStack.SetWorldMatrix( WorldMat );   // ワールド変換行列の設定
WTMStack.UpdateFrame( pFrame );        // フレームのワールド変換行列を更新
pDrawList = WTMStack.GetDrawList();              // 描画リストを取得

list< D3DXFRAME_WORLD* >::iterator it = pDrawList->begin();
int materialnum;
int i;
for( ; it!=pDrawList->end(); it++)
{
   pD3DDev->SetTransform( D3DTS_WORLD, &(*it)->WorldTransMatrix );   // ワールド変換行列を設定
   materialnum = (*it)->pMeshContainer->NumMaterials;
   for(i=0; i<materialnum; i++)
   {
      g_pD3DDev->SetMaterial( &(*it)->pMeshContainer->pMaterials[i].MatD3D );
      (*it)->pMeshContainer->MeshData.pMesh->DrawSubset(i);
   }
}


 これで、Xファイルから読み込んだメッシュオブジェクトを描画する所まで、一通りの筋道が通りました。いやはや、大変です。ただ、上の状態では、まだアニメーションしません。というのは、フレーム行列を変更していないからです。逆に言えば、フレーム行列さえ変更してしまえば、アニメーションはもう実現できたようなものです。そういえば、その25で取得したもう1つのインターフェイスがありました。ID3DXAnimationControllerインターフェイスです。アニメーションはこちらのお仕事です。さぁ、もう少しです。



E アニメーションコントローラでフレーム行列を動かそう

 D3DXLoadMeshHierarchyFormX関数で取得できたもう1つのインターフェイスであるID3DXAnimationControllerインターフェイスは、Xファイル内に定義されているアニメーションを管理するクラスです。このインターフェイスは非常に沢山のメンバ関数を持っており、1つの章を設けて説明しなければならないのですが、今は単純にアニメーションをするAdvanceTime関数だけを使うことにしましょう。

 ID3DXAnimationController::AdvanceTime関数は、次のように定義されています。

描画部分
HRESULT ID3DXAnimationController::AdvanceTime(
   DOUBLE TimeDelta,
   LPD3DXANIMATIONCALLBACKHANDLER pCallbackHandler
);

TimeDeltaというのは次のアニメーションまでの差分時間のことです。これは実時間を与えます。もし60FPSで動くゲームを想定しているならば、ここには1/60.0秒が入ります。ただ、別に絶対そうしなければならないと言うわけではなく、早く動かしたいのであれば、その分差分時間を多めに設定するなど、ユーザ側で調節ができます。
pCallbackHandlerは、今はNULLにしておいて下さい。これについても、いずれ章を設けて説明します。

 D3DXLoadMeshHierarchyFormX関数によって取得されたD3DXFRAME構造体とこのインターフェイスは裏でひそかに繋がっていて、AdvanceTime関数を使うことにより、自動的にフレーム行列が更新されます。よって、ユーザはただ時間だけを気にすれば良いだけという事になります。

 これで、アニメーションの基礎部分は終了です。この段階で、機械や戦車、車、時計、歯車などのオブジェクトの形が変化しないあらかじめ動作の決められたアニメーションは実現できます。これだけでも十分にゲームは出来ます。ただ、やはり人、動物などのやわらかいオブジェクトのアニメーションもやりたいもんです。そこまで行くには、さらにもう少しだけ前進する必要があるんです。次の章では、そのための基礎知識である「ボーンの操作」について見ていくことにしましょう。