ホーム < ゲームつくろー! < DirectX技術編
その61 完全ホワイトボックスなスキンメッシュアニメーションの解説
スキンメッシュアニメーションは3Dのゲームの一つの大きな壁であり、一つの憧れ(?)でもあるかなと思います。かつてマルペケでもDirectX技術編その23辺りからDirectXが用意しているスキンメッシュアニメーションの解説を行いました。当時は良く分からない状態からスタートしましたので、解説もまぁ…分かったようなわからないような内容になっております(^-^;。
一通り実装して思ったのは「DirectX9が用意するスキンメッシュの仕組みを使うと本質がわからなくなる!」という事でした。D3DXLoadMeshHierarchyFromX関数を使ってXファイルを読み込むと、D3DXFRAME構造体とID3DXAnimationControllerが何故か出来ていて、アニメーションコントローラで時間を進めるとなぜかD3DXFRAMEの中身が更新されている。わかっているとありがたい仕組みですが、わからない時には恐ろしいまでのブラックボックスです。これでは何時まで経ってもスキンメッシュの理解に届きません。
そこで本章では、XファイルどころかD3DXLoadMeshHierarchyFromX関数等も一切用いずにスキンメッシュアニメーションをするポリゴンを作ってみる事にしました。つまりすべての処理が見える「完全ホワイトボックス」なコードです。これで、「スキンメッシュアニメーションとはなにか?」という根本が垣間見られれば良いなぁと思う次第です。
@ 本章はサンプルをまずは実行してみて下さい!
本章は予め作ってあるサンプルを通して解説していきます。まずはサンプルを動かしてみて下さい:
こんな感じで三叉に分かれたポリゴンがぐねぐねと動きます。気持ち悪(^-^;いやはや。
円錐はボーンのつもりです。このサンプルにはmain.cpp以外のファイルがありません。つまり、このサンプルの中にポリゴンデータ、ボーンの連結、各種姿勢の計算などスキンメッシュアニメーションの基本的な部分が全部入っています!コード透過度の高いサンプルとなっております(^-^)
さ、それではじっくり行きますよ〜。
A ポリゴン設計図
メッシュから始めましょう。今回は下のようなメッシュを作ることにしました:
左図の各座標点は一所懸命に手計算。本来こういう頂点データはモデラーが出力してくれるためそれを読み込めば良いのですが、今回は完全ホワイトボックスなので全部用意です。
上図の頂点数は15個。そして各頂点はに次のような頂点データが表記されています:
(-1.3660, -0.2113)
(0.5, 0.5)
(3, 4)
一番上が座標(X,Y)です(今回はZ=0.0で固定です)。真ん中は頂点に影響するボーンの影響力。そして一番下が影響するボーンの番号です。
緑色の丸はボーンを表します。「あれ?ボーンって長いよね?」と思われるかもしれません。しかし、実際ボーンというのは関節と関節の間をつないだものに過ぎません。回ったり移動したりするのは関節なんです。Mayaではこの丸いのを「ジョイント」と呼んでいます。ジョイント(ボーン)は親子関係があり、上の場合0番がルートとなり三方に広がります。
そのジョイントの向きですが、これは右図のようにしました。各ジョイントについている軸は赤いのがX軸、緑色のがY軸です。今回は初期姿勢でみんなX軸の方向に伸びるようにしました。もちろん、こういうジョイントごとのローカル姿勢(もしくは親ジョイントからの相対姿勢)も普通はモデラーが出力してくれます。
このポリゴンを今回は「三角形リスト」でつなぎます。そのための頂点インデックスも必要になります。これもモデラーが出力してくれますが、今回は(0, 1, 14)のようにちまちまと打ち込みます。この経験がとっても大切なんです(^-^)
プログラムの最初では実際にこのポリゴンの情報を打ち込んでいます:
////////////////////////////////////
// ポリゴンメッシュのデータ定義
///////
// 頂点数は15個
Vertex vtx[15] = {
{ D3DXVECTOR3(-0.5000f, -2.2887f, 0.0f), D3DXVECTOR3(1.00f, 0.00f, 0.00f), {2, 0, 0, 0} },
{ D3DXVECTOR3(-0.5000f, -1.2887f, 0.0f), D3DXVECTOR3(0.50f, 0.50f, 0.00f), {1, 2, 0, 0} },
{ D3DXVECTOR3(-0.5000f, -0.2887f, 0.0f), D3DXVECTOR3(1.00f, 0.00f, 0.00f), {0, 0, 0, 0} },
{ D3DXVECTOR3(-1.3660f, 0.2113f, 0.0f), D3DXVECTOR3(0.50f, 0.50f, 0.00f), {3, 4, 0, 0} },
{ D3DXVECTOR3(-2.2321f, 0.7113f, 0.0f), D3DXVECTOR3(1.00f, 0.00f, 0.00f), {4, 0, 0, 0} },
{ D3DXVECTOR3(-1.7321f, 1.5774f, 0.0f), D3DXVECTOR3(1.00f, 0.00f, 0.00f), {4, 0, 0, 0} },
{ D3DXVECTOR3(-0.8660f, 1.0774f, 0.0f), D3DXVECTOR3(0.50f, 0.50f, 0.00f), {3, 4, 0, 0} },
{ D3DXVECTOR3( 0.0000f, 0.5774f, 0.0f), D3DXVECTOR3(1.00f, 0.00f, 0.00f), {0, 0, 0, 0} },
{ D3DXVECTOR3( 0.8660f, 1.0774f, 0.0f), D3DXVECTOR3(0.50f, 0.50f, 0.00f), {5, 6, 0, 0} },
{ D3DXVECTOR3( 1.7321f, 1.5774f, 0.0f), D3DXVECTOR3(1.00f, 0.00f, 0.00f), {6, 0, 0, 0} },
{ D3DXVECTOR3( 2.2321f, 0.7113f, 0.0f), D3DXVECTOR3(1.00f, 0.00f, 0.00f), {6, 0, 0, 0} },
{ D3DXVECTOR3( 1.3660f, 0.2113f, 0.0f), D3DXVECTOR3(0.50f, 0.50f, 0.00f), {5, 6, 0, 0} },
{ D3DXVECTOR3( 0.5000f, -0.2887f, 0.0f), D3DXVECTOR3(1.00f, 0.00f, 0.00f), {0, 0, 0, 0} },
{ D3DXVECTOR3( 0.5000f, -1.2887f, 0.0f), D3DXVECTOR3(0.50f, 0.50f, 0.00f), {1, 2, 0, 0} },
{ D3DXVECTOR3( 0.5000f, -2.2887f, 0.0f), D3DXVECTOR3(1.00f, 0.00f, 0.00f), {2, 0, 0, 0} },
};
// 頂点インデックス
// ポリゴン数は13枚で三角形リストなので13×3 = 39個あります
WORD idx[39] = {
0, 1, 14,
1, 13, 14,
1, 2, 13,
2, 12, 13,
2, 7, 12,
2, 6, 7,
2, 3, 6,
3, 5, 6,
3, 4, 5,
7, 8, 12,
8, 11, 12,
8, 9, 11,
9, 10, 11,
};
// インデックスを辿って三角形リストを作成
Vertex v[39];
for ( int i = 0; i < 39; i++ )
v[i] = vtx[idx[i]];
頂点を表すVertexは次のような構造体になっています:
// 最低限の頂点情報
// 座標と各ボーンの重みそしてボーン行列番号があればスキンメッシュはできます!
struct Vertex {
D3DXVECTOR3 coord;
D3DXVECTOR3 weight;
unsigned char matrixIndex[4];
};
頂点座標coordと頂点に影響を与えるボーンの重みweight、そして実際に影響を与えるボーンの番号であるmatrixIndexを宣言しています。
頂点配列vtxからポリゴンを作るために頂点インデックスを作っています。今回はポリゴンが13枚あって三角形リストとしてつなげるのでインデックスは39個になります。最終的に配列vがモデルを構成するポリゴン情報となります。
こういう情報は全部モデラーが出力する頂点情報に含まれています。また独自のフォーマットを作る場合少なくともこれらの情報をファイルに含める必要があるわけです。
B 頂点宣言
さて、上の頂点を描画デバイスに理解させるには「頂点宣言」が必要になります。これは「1頂点のどこに何の情報があるか」を表すオブジェクトで、IDirect3DVertexDeclaration9というインターフェイスで表現されます。このインターフェイスを作るにはD3DVERTEXELEMENT9構造体の配列を先に作る必要があります:
// 頂点宣言
D3DVERTEXELEMENT9 declAry[] = {
{0, 0, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_POSITION, 0},
{0, 12, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_BLENDWEIGHT, 0},
{0, 24, D3DDECLTYPE_UBYTE4, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_BLENDINDICES, 0},
D3DDECL_END()
};
この構造体1つで1頂点内の1要素の情報を表します。全部で6つのメンバがありますが、特に大切なのは2番目にある「Offset」、3番目の「Type」、5番目の「Usage」です。Offsetは先に作った1頂点を表すVertex構造体に含まれる項目が何バイト目にあるかを示します。Vertex構造体は以下の表のようになっていました:
変数名 型 オフセット位置 coord D3DXVECTOR3 0 weight D3DXVECTOR3 12 matrixIndex unsigned char[4] 24
よってOffsetは右列のようになります。
Typeには要素の型とサイズをD3DDECLTYPE列挙型で示します。例えばVertex::coordはD3DXVECTOR3で、これは実質float3つ分(12バイト)の情報です。その場合はD3DDECLTYPE_FLOAT3を設定します。他にも多数のフラグがありますのでリファレンスを参照して適切なフラグを設定して下さい。Usageはこの要素の使われ方、もう少し別の表現で言うなら「要素の意味・種類」を設定します。例えば、coordは「位置座標」です。その場合はD3DDECLUSAGE_POSITIONを設定します。このUsageはシェーダ内の「頂点シェーダの入力セマンティクス」と対応しています。
こういう感じでモデルファイルなどから読み込んだ頂点がどう並んでいて各要素が何であるかをD3DVERTEXELEMENT9配列でしっかり設定したら、IDirect3DDevice9::CreateVertexDeclarationメソッドで頂点宣言オブジェクトであるID3DXVertexDeclarationを作ることができます:
IDirect3DVertexDeclaration9 *decl = 0;
g_pD3DDev->CreateVertexDeclaration( declAry, &decl );
頂点宣言オブジェクトは、描画の直前に頂点情報と共に描画デバイスに渡すことになります。ここまででポリゴン情報が整いました(^-^)。
C ボーンの連結
ここからはボーンのお話です。言うまでもなく、ボーンには明確な親子関係があります。冒頭の図では0番のボーンが親となり、1,3,5番がその子、さらにそれらの子に孫ボーンが連結しています。親のボーンが動いたら子もそれに合わせて動くことで「骨」が表現されるわけです。そして普通親には複数の子がいますが、子が複数の親を持つことはありません。
この親子関係を表現する方法は色々とありますが、一般的にはいわゆる「ルートボーン」から樹状に骨を表現します:
この連結(樹状構造)をプログラムで表現するには、2つの方法が考えられます。以下の構造体例を御覧下さい:
struct Bone {
int id;
int childNum;
Bone *children;
D3DXMATRIX offsetMat;
D3DXMATRIX initMat;
D3DXMATRIX boneMat;
D3DXMATRIX *combMatAry;
};
struct Bone {
int id;
Bone *firstChild;
Bone *sibling;
D3DXMATRIX offsetMat;
D3DXMATRIX initMat;
D3DXMATRIX boneMat;
D3DXMATRIX *combMatAry;
};
上3つに注目です。左側は自分の子ボーンを配列として持つタイプの構造体です。一方右側は自分の第1子ボーンそして次の兄弟ボーンへのリンクを持つタイプです。D3DXFRAME構造体は右側の方法を取っていますね。感覚的には、左の方が子ボーンの配列を持つだけなので上の図とも相性が良く簡単そうに感じますが、実は右側の方がメモリ管理の面で優位です。左の場合、children配列に連続した子ボーン配列を確保する、つまり配列newする必要が出てしまいます。右の方は第1子ボーンと次の兄弟ボーンへの単一ポインタを持てば良いだけで、Bone自体がnewする必要は無く、またポインタの先に幾つ子ボーンが並んでいるのかも心配する必要がありません。私も両方実装してみましたが、右側の方が何だかんだで扱いが良かったと感じました。
さて、何も考えずにボーンをバラバラにnewすると、どこか適当な場所に実体が確保されますが、それだと末端のボーンから情報を得るのにツリーを辿っていく必要が出てきます。ボーンには何かとサクサクアクセスしたいのですが、樹状構造だとそれがなかなかに厳しいものがあります。そこで、1モデルのボーンを線形配列としてガバっと確保してしまいます。そして、その後連結作業を行います。こうすると、線形アクセス(ハッシュアクセス)もできる樹状構造が確立されます:
※FC: FirstChild SIB: Sibling です
モデル単位でこれを保持しておくと、ボーンをいっぺんに消すのも非常に簡単です。樹状構造は親子関係が必要になる計算の時だけ使う事にして、生死はモデルが管理する事にすれば、仕事がうまく分担されます。
プログラムでは次のようにボーンの配列を一気に確保し、連結作業を行っています:
Bone *bones = new Bone[7]; // ボーン確保
// 親子関係を構築します
// 今回は手でべたべたとやります
bones[0].firstChild = &bones[1];
bones[1].firstChild = &bones[2];
bones[3].firstChild = &bones[4];
bones[5].firstChild = &bones[6];
bones[1].sibling = &bones[3];
bones[3].sibling = &bones[5];
第1子供が4つに兄弟が2つ。上の図の通りに連結しているのがわかるかと思います。
D ボーンオフセット行列はボーンの初期姿勢から作れるのですよ!
次です。冒頭の右図を再度御覧下さい:
緑色の丸で示されているジョイント(ボーン)は「ローカル空間での姿勢」で定義されています。ここからまず「ボーンオフセット行列」を作ります。スキンメッシュアニメーションをする上で一番わかりにくいのはこのボーンオフセット行列かなと思います。これが何であって、どうして必要なのか、スキンメッシュの計算構造が理解できていないとさっぱりわからないんですよね。大丈夫です、ここでじっくり説明します。
ボーンオフセット行列とは、ボーンの初期姿勢を「ローカル軸にぴったり戻してしまう」行列です。例えば、下の図のように戻してくれる行列です:
ボーンオフセット行列はすべてのボーンに1つずつ対応した物があります。では、なぜこういう行列が必要なのか?下の図を御覧下さい:
ゆっくり説明しますね。今、初期位置(2,1)で上図のような傾き(回転)を持ったジョイントがあったとします。また、このジョイントに影響される頂点(黄色丸)が付随しています。このローカルな頂点を先程ちまちまと打ち込んだわけです。この初期位置を基準として、あるフレームでジョイントを(0,4)に移動させるとしましょう。この時もちろん黄色丸で表した頂点も一緒に移動します。
移動のさせ方として、直感的には点線矢印で示すように初期位置から移動したい場所までズバっ!と動かすのが良さそうに思います。ところが、こういう移動をさせるには「移動先と初期姿勢との差分」を毎回計算する必要が出て来てしまいます。上の場合ですと(0,4)-(2,1)=(-2,3)という移動量を計算する必要が生じます。実際は回転も入るでしょうから、差分を得るには何らかの逆行列を毎フレーム全ジョイントに関して再計算するという高負荷な手間が生じます。
一方黒矢印で示すように、初期姿勢のジョイントをボーンオフセット行列を使って一端原点に戻し、それを(0,4)の位置に移動させるという、一見すると回りくどいと思われる方法を取るとどうでしょうか。初期姿勢のジョイントを原点に戻すのは毎回同じボーンオフセット行列をかければ良く、さらに移動先である(0,4)に移すための行列は普通モデラーが出力してくれます(もしくはキーフレーム補間で計算)。つまり、ジョイントの姿勢をすべて計算済みの「既知」の行列だけから更新する事ができるんです。こちらの方が遥かに低負荷なスキンメッシュアニメーションなんです。
こういうジョイント(ボーン)の姿勢更新を行うために、初期姿勢を原点に戻すボーンオフセット行列が必須というわけなんです。
ボーンオフセット行列を作るのはとっても簡単です。初期姿勢行列はローカル座標の原点にあるジョイントを初期姿勢の位置に移動・回転させるものです(モデラーが出力してくれます)。その逆の動きをさせるのですから「初期姿勢行列の逆行列」を計算すれば良いのです。「ボーンオフセット行列は初期姿勢行列の逆行列」、これはもう暗記項目です(^-^)
さて、プログラムではジョイントの初期姿勢を先に作ってからボーンオフセット行列を計算しています:
// 初期姿勢の計算
// まずはローカル姿勢を設定して
// 最終的に自分の親からの相対姿勢に直します。
D3DXMatrixRotationZ(&bones[0].initMat, D3DXToRadian(-90.0f));
D3DXMatrixRotationZ(&bones[1].initMat, D3DXToRadian(-90.0f));
D3DXMatrixRotationZ(&bones[2].initMat, D3DXToRadian(-90.0f));
D3DXMatrixRotationZ(&bones[3].initMat, D3DXToRadian(150.0f));
D3DXMatrixRotationZ(&bones[4].initMat, D3DXToRadian(150.0f));
D3DXMatrixRotationZ(&bones[5].initMat, D3DXToRadian( 30.0f));
D3DXMatrixRotationZ(&bones[6].initMat, D3DXToRadian( 30.0f));
bones[0].initMat._41 = 0.0000f; bones[0].initMat._42 = 0.0000f;
bones[1].initMat._41 = 0.0000f; bones[1].initMat._42 = -1.0000f;
bones[2].initMat._41 = 0.0000f; bones[2].initMat._42 = -2.0000f;
bones[3].initMat._41 = -0.6830f; bones[3].initMat._42 = 0.3943f;
bones[4].initMat._41 = -1.5490f; bones[4].initMat._42 = 0.8943f;
bones[5].initMat._41 = 0.6830f; bones[5].initMat._42 = 0.3943f;
bones[6].initMat._41 = 1.5490f; bones[6].initMat._42 = 0.8943f;
// ボーンオフセット行列を計算しておきます。
// オフセット行列は各ボーンの「ローカル姿勢」の逆行列です。
D3DXMATRIX *combMat = new D3DXMATRIX[7]; // 合成変換行列。これがシェーダに渡ります
for ( int i = 0; i < 7; i++ ) {
bones[i].id = i;
bones[i].combMatAry = combMat;
D3DXMatrixInverse(&bones[i].offsetMat, 0, &bones[i].initMat);
}
各ジョイントの初期姿勢は冒頭のポリゴン設計図にある通りで、Z軸で指定の分だけ回転させています。さらに、各ジョイントの座標を行列の_41と_42(それぞれX成分、Y成分に対応)に直接書き込んでいます。こうして出来上がった初期姿勢行列の逆行列を計算することで、ボーンオフセット行列を計算しています。
初期姿勢及びオフセット行列の計算は実際は普通必要ありません。大抵モデラーが出力したファイルからそれらの行列を直接得る事ができます。少なくともボーンの姿勢は絶対にあるはずなので、もしボーンオフセット行列を直接得られなくとも計算してしまえます。独自のモデルファイルを作る時には双方ともファイルに埋め込んでしまいましょう。
もう一つ、上のプログラムにあるcombMatは実は極めて重要です。これはボーンを動かす合成行列の配列で、後述するシェーダに渡すため配列にしています。Bone構造体はみんなこの配列の先頭ポインタを知っていて、姿勢を更新するときに勝手に書き込んでくれます。これについてはず〜っと後で出てくるので忘れないで下さい。
E 初期姿勢を親空間ベースに変換
ボーンオフセット行列があれば、あるフレームで各ジョイントのローカル姿勢があればもうスキンメッシュができてしまいます。普通FK(フォワードキネマティクス)をする場合、各フレームでのジョイントローカル姿勢はすぐに得られます。普段はこちらを使用して下さい。今回は完全ホワイトボックスということで、ジョイントローカル姿勢を毎フレーム計算で求めます。IK(インバースキネマティクス)の触りのようなもんです。
自前でジョイントを動かそうと思う時、動かす軸を考える必要があります。一つは「自分の親の軸」なのですが、これが意外と扱いにくいんです。親の初期軸は結構色々な方向を向いていて、例えば同じ「Y軸回転」でもあらぬ方向に回転してしまう事があります。それよりも扱い易いのは「自分の初期姿勢の軸をベースに動かす」方法です:
上のようなジョイントの更新を実現するには、初期姿勢を親空間ベースに変換する必要があります。なぜかと言うと、特定のフレームでの子ジョイントの姿勢は、
[子供のローカル姿勢] = [自分の空間での差分姿勢] × [親空間での子の初期姿勢] × [親のローカル姿勢]
で求まるためです。
プログラムでは現在初期姿勢は「ローカル空間ベース」です。これを親空間ベースに変換するには、子の初期姿勢に親のボーンオフセット行列を掛け算します。「なぜ!?」と思った方は次のように考えてみて下さい。親のローカル位置が(10, 0)、子のローカル位置が(11, 0)だとします。親から見るとこの位置は(1, 0)ですよね。と言うことは、このローカル位置に対して親の位置を引き算すれば親ベースになります。親のローカル姿勢の逆行列はこの引き算の作用をします。ローカル姿勢の逆行列は…ボーンオフセット行列です。ですから、
[子供の親空間ベースでの姿勢] = [親空間での子の初期姿勢] × [親のオフセット行列]
なんです。
プログラムではこれを再帰関数を用いて変換しています:
// 初期姿勢を親の姿勢からの相対姿勢に直します。
// まず子の末端まで下りて、自分のローカル空間での初期姿勢 × 親のボーンオフセット行列で相対姿勢が出ます
// 親子関係を辿るので再起関数が必要です。
struct CalcRelativeMat {
static void run(Bone* me, D3DXMATRIX *parentoffsetMat) {
if ( me->firstChild )
run( me->firstChild, &me->offsetMat );
if ( me->sibling )
run( me->sibling, parentoffsetMat );
if ( parentoffsetMat )
me->initMat *= *parentoffsetMat;
}
};
CalcRelativeMat::run( bones, 0 );
上の太文字部分が変換箇所です。引数で入ってくるボーンの初期姿勢はローカルベースなので、親のボーンオフセット行列を右から掛けて親空間ベースに変換しています。この再帰関数はここでしか使わないものなので、ローカルな構造体のメンバ関数にしてしまいましたが、もちろんグローバルな関数でも何らかのクラスのメンバ関数でも構いません。
ここまでで、各ボーンについて、
・ ボーンオフセット行列
・ 親空間ベースの初期姿勢
・ ボーン連結
の作業が終わりました。お疲れ様です(^-^;
F ボーンを動かす頂点シェーダ
ここからは「描画の仕方」に話を移します。DirectX9の固定機能パイプラインにはスキンメッシュを行う仕組みが整っています。ただ…かなりにブラックボックスですし、裏でやっている事が見えない分個人的には使いにくさが否めません。今回完全ホワイトボックスなスキンメッシュアニメーションをするために、裏でしていることも全部オープンにします。そのため、描画はすべてシェーダに置き換えます。
DirectX9のシェーダには頂点シェーダとピクセルシェーダがあります。この中で頂点を直接動かすスキンメッシュに関与するのは頂点シェーダです。サンプルの頂点シェーダは次のようになっています:
const char *vertexShaderStr =
"float4x4 view : register(c0); "
"float4x4 proj : register(c4); "
"float4x4 world[12] : register(c8); "
" "
"struct VS_IN { "
" float3 pos : POSITION; "
" float3 blend : BLENDWEIGHT; "
" int4 idx : BLENDINDICES; "
"}; "
" "
"struct VS_OUT { "
" float4 pos : POSITION; "
"}; "
" "
"VS_OUT main( VS_IN In ) { "
" VS_OUT Out = (VS_OUT)0; "
" float w[3] = (float[3])In.blend; "
" float4x4 comb = (float4x4)0; "
" for ( int i = 0; i < 3; i++ ) "
" comb += world[In.idx[i]] * w[i];"
" comb += world[In.idx[3]] * (1.0f - w[0] - w[1] - w[2]);"
" "
" Out.pos = mul( float4(In.pos, 1.0f), comb );"
" Out.pos = mul( Out.pos, view ); "
" Out.pos = mul( Out.pos, proj ); "
" return Out; "
"} ";
シェーダは文字列で記述しています。もちろん外部のファイルから読み込んでも良いのですが、コンパイル前に文字列化はしますので、最初からプログラム内に埋め込むことも当然できるわけです。
頭から見ていきましょう。float4x4型として外部から行列を渡します。view、projはそれぞれビュー行列と射影変換行列で、これはおなじみだろうと思います。特徴的なのはworld[12]とワールド変換行列が12個配列として渡されている部分かなと思います。ここには頂点を動かすための行列の配列をごそっと渡します。で、なぜ12個なのか?これについてはもう少し先まで読んだ段階で説明します。
ところで変数の後に「register(c0)」みたいのがありますね。見慣れない方もいらっしゃるかもしれませんが、これは「レジスタ指定」をしています。シェーダはレジスタというGPU内のメモリに格納された値を使って計算を行います。レジスタへはDirectX側から書き込みできるのですが、何も指標がないとDirectX側でどこに何を書けば良いかわからなくなります。そのため、シェーダ側でregister(c0)などのように格納する位置を指定してあげるわけです。
続いて頂点シェーダの入力セマンティクス(入力引数)です。上のプログラムではVS_OUTという構造体にまとめています。posには頂点のローカル座標、blendにはその頂点に影響を与えるボーン行列の影響力、そしてidxには影響を与えるボーンの番号がそれぞれ描画デバイスから渡されてきます。これは「Bで頂点宣言」で指定した要素と型も含め全く一緒にする必要があります。一方出力は変換後の頂点座標だけです。
頂点シェーダ本文(main関数)を見てみましょう。実質重要なのは次の部分だけです:
for ( int i = 0; i < 3; i++ )
comb += world[In.idx[i]] * w[i];
comb += world[In.idx[3]] * (1.0f - w[0] - w[1] - w[2]);
Out.pos = mul( float4(In.pos, 1.0f), comb );
ここでは1つの頂点を動かす合成行列を作っています。作り方はとっても簡単で、影響するボーン行列を重み付けして足し算しているだけです。式で表すと次の通りです:
1つの頂点が複数の変換行列によってちょこっとずつ動かされるからスキンメッシュになるわけでして、上の計算は極めてコアで重要な部分と言えます。ただ、すべての頂点が必ず4つのボーンに影響されているわけではありません。しかし、頂点シェーダ内で影響数を分岐構文で書くとパフォーマンスを落とします。よって、影響していない部分は影響力wを0にしてしまう事で対処します。
後はいつものようにローカル頂点に対して[ワールド]×[ビュー]×[射影変換]と行列をかけ算して頂点を射影空間に移動させて出力します。
ピクセルシェーダは次のように極めてシンプル…というより白色しか返してません:
const char *pixelShaderStr =
"struct VS_OUT {"
" float4 pos : POSITION;"
"};"
"float4 main( VS_OUT In ) : COLOR {"
" return float4(1.0f, 1.0f, 1.0f, 1.0f);"
"}";
結局スキンメッシュアニメーションの本質は頂点シェーダでして、ピクセルシェーダは必要に応じで好きなように描画してくれればいい部分です。
G シェーダオブジェクト作成
Fのシェーダプログラムは言ってみれば人が読める文字列(HLSL)でして、このままではDirectXは理解してくれません。よって、このプログラムをコンパイルしてシェーダオブジェクト(IDirect3DVertexShader9、IDirect3DPixelShader9)にする必要があります。この作り方は至ってお決まりです:
ID3DXBuffer *shader, *error;
IDirect3DVertexShader9 *vertexShader;
IDirect3DPixelShader9 *pixelShader;
// 頂点シェーダ作成
HRESULT res = D3DXCompileShader( vertexShaderStr, (UINT)strlen(vertexShaderStr), 0, 0, "main", "vs_3_0", D3DXSHADER_PACKMATRIX_ROWMAJOR, &shader, &error, 0 );
if ( FAILED(res) ) {
OutputDebugStringA( (const char*)error->GetBufferPointer() );
return 0;
};
g_pD3DDev->CreateVertexShader( (const DWORD*)shader->GetBufferPointer(), &vertexShader );
shader->Release();
// ピクセルシェーダ作成
res = D3DXCompileShader( pixelShaderStr, (UINT)strlen(pixelShaderStr), 0, 0, "main", "ps_3_0", D3DXSHADER_PACKMATRIX_ROWMAJOR, &shader, &error, 0 );
if ( FAILED(res) ) {
OutputDebugStringA( (const char*)error->GetBufferPointer() );
return 0;
};
g_pD3DDev->CreatePixelShader( (const DWORD*)shader->GetBufferPointer(), &pixelShader );
shader->Release();
シェーダプログラムをD3DXCompileShader関数を用いてコンパイルしDirectXが理解できるデータの塊にします(ここは流石にブラックボックスです)。そこからIDirect3DDevice9::CreateVertexShader及びCreatePixelShaderメソッドに渡すとIDirect3DVertexShader9オブジェクトとIDirect3DPixelShader9オブジェクトを作ってくれます。
HLSLに頂点シェーダとピクセルシェーダの両方を書く事が多いかと思いますが、IDirect3DDevice9が扱えるのは個別のシェーダプログラムなのでご注意ください。また上のようにコンパイルに失敗したかを判定するようにするとデバッグウィンドウにコンパイルエラーになった部分を出力してくれます。
H ボーンを動かそう!
さて、ここまで揃うとスキンメッシュをするための準備が整います。ちょっと何を揃えたか列挙しますね:
・ 頂点データ(座標、ウェイト、ボーンID)
・ ボーンオフセット行列
・ ボーンの親空間ベースの初期姿勢行列
・ 合成行列を計算する頂点シェーダ、ピクセルシェーダ
案外こんなもんです(^-^)。
これだけ揃えてボーンを動かすと、メッシュが勝手に付いてきてくれます。ではどのようにボーンを動かすのか?以下の部分を御覧下さい:
///////////////////////////////////////
// ボーンの姿勢を更新
// 最終的には、
// [ボーンオフセット行列] × [ワールド空間でのボーンの姿勢]
// を計算します。
// 各ボーンの初期姿勢からの差分姿勢(親空間ベース)を更新
// これは適当にぐにぐに動かして構わない部分です
D3DXMATRIX defBone[7];
D3DXMatrixIdentity(&defBone[0]);
for ( int i = 1; i < 7; i++ ) {
D3DXMATRIX tmp;
D3DXMatrixRotationY(&defBone[i], D3DXToRadian(sinf(val) * 70.0f));
}
// 各ボーン行列の親空間ベースでの姿勢を更新
// 差分姿勢×初期姿勢(共に親空間ベース)です。
for ( int i = 0; i < 7; i++ )
bones[i].boneMat = defBone[i] * bones[i].initMat;
// 親空間ベースにある各ボーン行列をローカル空間ベースの姿勢に変換
// ここは親子関係に従って行列を掛ける必要があります
// 掛ける順番は 子 × 親 です。
D3DXMATRIX global;
D3DXMatrixRotationZ( &global, val * 0.1f );
struct UpdateBone {
static void run( Bone* me, D3DXMATRIX *parentWorldMat ) {
me->boneMat *= *parentWorldMat;
me->combMatAry[me->id] = me->offsetMat * me->boneMat;
if ( me->firstChild )
run( me->firstChild, &me->boneMat );
if ( me->sibling )
run( me->sibling, parentWorldMat );
};
};
UpdateBone::run( bones, &global );
実はたったのこれだけです。
まず、defBoneという配列は各ジョイントの自分自身の軸ベースの動きです。Eの中で出てきた「自分の空間での差分姿勢」です。これはどう動かしても良いわけですが、今回は自分のY軸を回転軸として±70度回ってもらうことにしました(ルートボーンである0番は動かないとしました)。至って適当です。
次にその差分行列と各ジョイントの初期姿勢から親空間での姿勢を計算しています。これもEですでに説明した通りです。
次からが一番の肝!「ジョイントを動かす」と言うのはワールド空間での各ジョイントの姿勢を計算することです。FK(フォワードキネマティクス)だろうとIK(インバースキネマティクス)だろうと、最終的にはこれなんです!各ジョイントのワールド空間での姿勢は、
[ジョイントのワールド空間での姿勢] = [ジョイントのローカル空間での姿勢] × [モデルのワールド空間での姿勢]
と分解できます。
各ジョイントのローカル空間での姿勢は常に、
[ローカル空間での姿勢] = [自分の親空間ベースでの姿勢] × [親のローカル空間での姿勢]
で表現されます。右辺の[自分の親空間ベースでの姿勢]はついさっき計算して全部揃えました。よって後は親のローカル空間での姿勢があれば良いわけです。これは、
[ローカル空間での姿勢] = [自分の親空間ベースでの姿勢] × ([親の親空間ベースでの姿勢] × [親の親のローカル空間での姿勢])
と右辺第2項をさらに分解すると答えが出てきます。同様の分解を一番上の親まで続けると、各ボーンの親空間ベースでの姿勢行列をズラッと掛け算したものが出てくるわけです。つまり、ついさっき計算した行列ですべて計算できてしまうわけです。必要なのは「ジョイントの一番祖先までつながる姿勢行列の列」です。
上のプログラムのUpdateBoneは、再帰関数を用いて各ジョイントのローカル空間での姿勢、さらに合成行列を計算しています。引数に渡されるBoneは自分のワールド空間での姿勢を計算します。計算後その行列を自分の子に渡します。子は上から降ってくる親の姿勢を用いて自分の姿勢を更新します。これを末端まで続けるわけです。自分の兄弟に対しては親の行列を引渡しています。スキンメッシュアニメーション(FK)をする場合、たぶん必ずこの再帰関数が出てくると思います。
再帰関数内で合成行列も一緒に計算しています。これは、
[合成行列] = [ボーンオフセット行列] × [自身のワールド姿勢]
となっています。頂点をボーンオフセット行列で原点中心に戻し、それをワールド空間に一気に飛ばしているわけです。ぴんとこない方は上の式を、
[合成行列] = [ボーンオフセット行列] × [自身のローカル姿勢] × [モデルのワールド変換行列]
と分解して考えると見通しが良くなるのではないでしょうか。シェーダに渡す合成行列は正にこれです!
I シェーダに合成行列を渡して描画!
すべてのジョイントについて合成行列を作成できたら、後はそれをシェーダに渡して描画するだけです:
// シェーダ設定
// 変数を書き込むレジスタ位置はシェーダに書いてありますよ。
g_pD3DDev->SetVertexShader( vertexShader );
g_pD3DDev->SetPixelShader( pixelShader );
g_pD3DDev->SetVertexShaderConstantF(0, (const float*)&view, 4);
g_pD3DDev->SetVertexShaderConstantF(4, (const float*)&proj, 4);
g_pD3DDev->SetVertexShaderConstantF(8, (const float*)combMat, 4 * 7);
// ポリゴン描画
// ワイヤーフレーム描画でカリング無しで
g_pD3DDev->SetRenderState(D3DRS_FILLMODE, D3DFILL_WIREFRAME);
g_pD3DDev->SetRenderState(D3DRS_CULLMODE, D3DCULL_NONE);
g_pD3DDev->SetVertexDeclaration( decl );
g_pD3DDev->DrawPrimitiveUP(D3DPT_TRIANGLELIST, 13, &v, sizeof(Vertex));
描画デバイスには頂点シェーダとピクセルシェーダの両方を渡します。頂点シェーダに記述してあるレジスタに合わせてビュー行列や射影変換行列も渡します(SetVertexShaderConstantFメソッド)。レジスタ8番以降は合成行列の配列をごそっと渡します。Dの最後に出てきたcombMatがここでようやく再出しました。すべてはこの行列の配列を作るために仕込んできたんです。ふ〜(^-^;
今回はポリゴンをワイヤーフレームで描画しますのでSetRenderStateメソッドのD3DRS_FILLMODEをD3DFILL_WIREFRAMEに設定します。また裏から見ても描画して欲しいのでカリングは切りました。
最後に頂点宣言を描画デバイスに教えて、実際のデータをDrawPrimitiveUPメソッドで渡すことで描画が完了します。あ、もちろんちゃんと頂点バッファ(IDirect3DVertexBuffer9)を作って描画してもいいんですよ(むしろそっちの方が超高速なのでお勧め)。
J どうして合成行列の配列の要素が12個なのか?
スキンメッシュの基本的な描画については以上ですべてです。ところでそう言えば、頂点シェーダに定義した合成行列の配列の要素数が12個だったのを覚えておりますでしょうか?これどうして12個なのか?
1枚の三角ポリゴンに影響する最大のジョイント(ボーン)数はいくつでしょうか。頂点数が3つで1つの頂点に影響するジョイント数は最大4つ。各頂点が全く別のジョイントから影響されるとすると3×4=12というわけです。「なんだそれだけ…」と思ったら大間違い。これは重要な問題なんです。合成行列の要素数が12と言うことは、1つのメッシュに使えるボーンの数は最大12本という事になってしまうんです。これは人体など四肢を持つ動物などでは全く仕様に耐えません。
一つ考えられる解決方法は頂点シェーダの合成行列の要素数を増やすことです。例えば100本とか。これならばかなりのモデルに対応できます。でもですね、これには「定数レジスタ数の壁」が立ち塞がります。
DirectXのリファレンス「Registers - vs_2_0」及び「Registers - vs_3_0」によると、定数レジスタの数はビデオカード依存で、少なくとも256個と書かれています。1レジスタはfloat4つ分です。行列はfloat16個なので、1行列につき4つレジスタを消費する事になります。12個渡すと実は48個も消費していたわけです。これが100行列だと400レジスタ。大オーバーです。理論上は最大64個しか頂点シェーダに渡せないんです。
少し最適化するならば、行列を4×3の形で渡します。4列目はどうせ(0, 0, 0, 1)なのでシェーダ内でくっつけてあげるわけです。こうすると3レジスタしか消費しないので85個行列を渡せます。でも、ジョイントが100以上あるモデルには対応できません。「そんなモデル早々無いでしょ?」。いやいや、最近のハイエンドなゲームだと100どころか200を超えるメッシュもざらです。
つまり、1メッシュではいずれ限界が来るわけです。
ではどうするか?メッシュを分割します。どう分割するかと言うと頂点シェーダ内に引き渡す合成行列数を超えないポリゴングループに分割するんです。これは中々面倒な作業ですが、DirectXのスキンメッシュはその辺をやってくれています(ここはありがたいもんです)。これについてはそのうちマルペケでも取り扱うことになるかなぁと予想しています。
K オリジナルフォーマットを作ってみようじゃないか
DirectX10以降ではXファイルのサポートがバッサリ切られています。それに変わるフォーマットも一応あるのですが(.sdkmesh: Overview of the SDK Mesh File Format参照)、どうも本流になっていない感じですし、そもそも出力できるモデラーが皆無と思われます。
DirectX10が今一盛り上がらなかった理由の一つはメッシュが簡単に出せない事にあったかなと思います。そしてこれはDirectX11でも同様で、メッシュが出せないとせっかくの高機能も台無しです。選択肢は2つです。Xファイルのような広く知れ渡るフォーマットとライブラリの出現を待つか、自分でさっさと作ってしまうかです。本格大規模ゲームを作ろうと思うなら、ぜひ後者を選択して欲しいと思います。マルペケでもこの辺のオリジナルフォーマットについては検討しております(2010.2現在)。
この章ではスキンメッシュアニメーションについて長々と説明してきました。スキンメッシュアニメーションをするのに必要とする物はそれほど多くはありません。この章は上からどどっと実行しているだけですが、より発展させるならばこの処理をちゃんとクラス化する必要があります。それについては…いずれまたでしょうか(^-^;