ホーム < ゲームつくろー! < DirectX技術編 < Z値をテクスチャに書き込む鉄板な方法

その43 Z値をテクスチャに書き込む鉄板な方法


 Zバッファはスクリーンの目線において一番前面にあるオブジェクトの位置(Z値)をピクセル単位で保持するメモリブロック(サーフェイス)です。これがあるお陰でゲームが正しく描画されていると言っても過言ではありません。

 実は、このZバッファをうまく使うと「深度バッファシャドウ(Dwpth Buffer Shadow)」という手法で影を生成する事ができます。この手法はセルフシャドウも落とす事が出来るため、今日のゲームの至る所で使用されています。そしてこの影生成方法は現在も目まぐるしく改良が施されていまして、その技術革新たるやえらい事になってきています。○×でも早くこれを取り上げたく考えています。

 このワクワクする深度バッファシャドウを実現するには、ある視点から見たZ値を格納する「深度バッファ」をテクスチャとして扱う事が必須です。しかし、DirectX9ではデフォルトの深度バッファにパフォーマンスを犠牲にせずにアクセスする事が非常に難しいのです。ただ、Zバッファをテクスチャ化する鉄板とも言える方法も世の中にちゃんと確立されております。書籍等でも紹介されている方法ではありますが、ここは○×流でまたくどくくどく整理しておきたいと思います(笑)。



@ Z値は自分で計算します

 意外かもしれませんが、Z値は自分で計算してテクスチャに描き込みます。「Direct3Dが計算してくれるのに、なんでよ!」と憤慨されるかもしれません。ここには色々なジレンマがあるんです。

 まず、テクスチャにレンダリングをするためには、テクスチャがD3DUSAGE_RENDERTARGETという属性を持っていなければなりません。この属性を持つ生成可能なテクスチャフォーマットは限られています。D3DFMT_A8R8G8B8は大丈夫です。しかし、深度バッファフォーマットであるD3DFMT_D16などはまず間違いなく生成できません。まずこれを確認しておきます。

 次に、描画デバイスの持つ深度バッファはIDirect3DDevice9::SetDepthStencilSurfaceメソッドで取り替える事ができます。ここにD3DFMT_D16フォーマット(正しくは描画デバイスの持つ深度バッファと互換性のあるサーフェイス)である深度バッファサーフェイスはもちろん設定できます。ところが、D3DFMT_A8R8G8B8フォーマットのサーフェイスは設定できないんです。これは深度バッファフォーマットとの互換性が無いためです。

 レンダリング用テクスチャはD3DFMT_A8R8G8B8などしか作成できない。一方で深度バッファはD3DFMT_D16フォーマットしか受け付けない。このジレンマによりレンダリング用テクスチャをデバイスの深度バッファに直接差し込む事は残念ながらたぶん出来ないんです。

 そこで、オブジェクトの深度であるZ値は自前で計算してテクスチャに描き込まざるを得ません。そのためには「Z値って何?」と言う根本部分から始めないといけません。



A Z値はどうやって計算するのか?

 Z値というのは、カメラの目線方向をZ軸として、スクリーン上の1点をまっすぐ伸ばして行った時にぶつかるオブジェクトまでの距離です:

 左側の図はカメラの空間でワールドを見た時の様子です。Znはカメラが捉えられる最近距離、Zfは最遠距離となっています。さらにカメラは左図のような視野範囲で空間を切り取ると考えます。これが視錐台と呼ばれる形です。この台形型の空間を右図のようにZnを0.0に、Zfを1.0になるように変換します。この時のオブジェクトまでの垂直距離がZ値です。ちなみに、カメラ空間を射影変換行列で変換するだけでは右図のZ軸の幅にはなりません。

 とは言っても、普通は納得はできないもんです。そこで、実際にローカル座標にある1頂点を世界において、実際にZ値を計算してみたいと思います。

 まず、ローカル座標の(x,y,z,1)に頂点があるとします。「w=1?」と思うかもしれません。これは同次系座標といわれる物で、1.0にすると3次元ベクトルと同じと解釈されます。しばらくは気にせずに、流れをご覧下さい。

 この頂点を世界に置くにはワールド変換行列を掛け算します。これには、回転・オフセット・スケールの3要素がありますが、まとめると下のような行列の掛け算になります:

ワールド変換行列は通常左3列だけで表現されます。その結果、真ん中のような計算を経て、最終的には下段のベクトルに示すようなワールド空間に位置するようになります。w=1.0である点でローカル座標と表現は同じなんです。

 さて、このワールド空間にある頂点は、次にカメラの視点に変換されます。これは「ビュー行列」を掛け算します。ビュー行列がどういう格好をしているかはDirectXのD3DXMatrixLookAtLH関数のマニュアルを見ると載っていますが、ここでは細かい要素を見る必要が無いため、模式的に示します:

ビュー行列も左3列だけで表現されます。これ、形としてはワールド変換行列とまったく同じなんですよね。これに左から掛け算するワールド頂点も確か同じ形であったわけですから、結局、

とやはりw=1.0の形になります。つまり、ローカル座標からカメラ空間までは単に点を適当に動かしているだけに過ぎないんだと言う事がわかると思います。

 さて、この次にカメラの空間を「視錐台」として捉え、それを直交座標系に変換する射影変換行列(Perspective Matirx, Projection Matrix)を掛け算します。ここは重要なポイントなので詳しい行列を示します:

射影変換行列の掛け算の結果を示しているのですが、うわ〜と思う方は緑色の部分だけ注目してください。今の注目点はここしかありません。ここは射影変換後の頂点のZ座標(Pz)です。zn及びzfは射影変換行列を作成する時に設定する最近距離と最遠距離です。冒頭でも説明しましたが、射影空間は直交座標ではありますが、この射影変換後のZ座標は「Z値ではありません」。なぜかを感覚的に見るために、緑色のZ座標をグラフにして見ましょう:

 このグラフは最近距離znを1500、最遠距離zfを3000とした時の、頂点のZ座標(Vz)に対する射影変換Z座標(Pz)です。横軸はカメラ空間にある頂点のZ座標、縦軸は対応する射影変換空間内のZ座標です。これを見ますと、Vzがカメラ最近距離よりも手前にある時はPzはマイナス値になっています。カメラに切り取られない範囲です。Vz=1500の位置でPz=0になっているのが確認できますね。そして、Vz=3000の最も遠い距離で、Pzはちょうどzfと同じ3000になっています。つまり、上の緑色の式により、カメラが切り取るZ軸の範囲は0〜zfに(線形に)変換されているんです。

 おおそれならば、Pzをzfで割ると0〜1の範囲になるじゃん!・・・とやりたいのですが、実はそうはしません。その代わり「Vz」で割るんです。それを式にすると、

となります。これをグラフにするとこうなります:

 縦軸に注目してください。見事に0〜1の範囲にしっかり収まっております。グラフは曲がっていますが、これは手前のオブジェクトに対するZ値の解像度を高くする役目をしています。

 でもどうしてVzで割るか、ちょっと謎ですよね。これは、上に示した緑色部分の式の「下」をご覧頂くと一撃でわかります。そう、射影変換後のw成分にVzがそのままあるんです。他から情報を追加することなく射影変換した座標だけからZ値を算出できるので、大変に扱いやすいわけです。しかも、カメラに近いオブジェクトの解像度が良くなるため、近くのオブジェクトの前後関係がおかしくなりにくいという副産効果もあります。実にうまいことできているんです。

 結論として、ある頂点のZ値を算出するには、射影変換した点のZ座標をW座標で割り算します。式の最終形はこちら:

Pは射影変換後の点の座標です。この式を使って自前でZ値を計算するわけです。



A Zバッファ用テクスチャを準備

 Z値を算出する仕組みが理解できたところで、さっそく具体的に実装してみましょう。

 まずIDirect3DDevice9を初期化する時に深度ステンシルバッファを設定します。。深度ステンシルバッファと言うのは、描画されるバックバッファと同じ大きさを持つ「前後関係専用」バッファです。これは初期化時にD3DPRESENT_PARAMETERS構造体のメンバ変数に以下の設定をすると自動的に生成されます:

D3DPRESENT_PARAMETERS dpp;
dpp.EnableAutoDepthStencil = TRUE;          // 深度ステンシルバッファ作成指示
dpp.AutoDepthStencilFormat = D3DFMT_D16;    // 深度バッファフォーマット

 深度バッファフォーマットは色々と用意されていますが、そこそこ解像度の良い物を使います。D3DFMT_D16で無ければならないというわけではありません。

 「あれ?でも、深度は自分で計算するんじゃなかったっけ?」と思われた方は本当に鋭い!そうなんです。深度を自前計算するつもりなのに、どうして深度バッファをわざわざ作成しなければいけないか?これは「Zテストを有効にするため」なんです。この疑問は私も抱きまして、試しに深度バッファを切ってレンダリングしてみたのですが、描画オブジェクトの前後関係がめちゃくちゃになりました。なるほど納得です。

 初期化後、Z値を格納する空っぽのレンダリング用テクスチャ(Z値テクスチャ)を用意します。そのためにはまず作成するテクスチャのサイズを与えないといけません。Z値テクスチャの大きさはとりあえずデバイスの持つ深度バッファと同じ大きさにしておきます。そのため、以下のように深度バッファサーフェイスの大きさを取得しておきます:

バックバッファサーフェイスの大きさを取得
// 深度バッファの幅と高さを取得
UINT uiWidth;
UINT uiHeight;
IDirect3DSurface9 *pSuf;
D3DSURFACE_DESC SufDesc;

cpDev->GetDepthStencilSurface(&pSuf); // 深度バッファサーフェイスを取得
pSuf->GetDesc( &SufDesc );    // サーフェイス情報取得
uiWidth = SufDesc.Width;
uiHeight = SufDesc.Height;
pSuf->Release(); // Releaseを忘れない事

このテクスチャサイズの取得方法も結構鉄板ですから覚えておくと何かと便利です。

 Z値テクスチャの作成にはD3DXCreateTexture関数を使います:

D3DXCreateTexture関数
HRESULT D3DXCreateTexture(      
    LPDIRECT3DDEVICE9 pDevice,
    UINT Width,
    UINT Height,
    UINT MipLevels,
    DWORD Usage,
    D3DFORMAT Format,
    D3DPOOL Pool,
    LPDIRECT3DTEXTURE9 *ppTexture
);

pDeviceはIDirect3DDevice9インターフェイスへのポインタを渡します。
Width及びHeightは作成するテクスチャの幅と高さを指定します。
MipLevelsはミップマップレベルを指定します。ここは必ず1を指定して下さい(0とかにするとレンダリング動作がおかしくなります)。
UsageにはD3DUSAGE_RENDERTARGETを指定します。これがポイントです!
Formatにはレンダリング可能なテクスチャフォーマットを指定します。今回はD3DFMT_A8R8G8B8にしておきます。
Poolにはメモリ管理方法を指定するのですが、今回は問答無用でD3DPOOL_DEFAULTを指定します。D3DUSAGE_RENDERTARGETを指定した場合はこれ以外を指定できないためです。
ppTextureには作成した空のテクスチャが返されます。

 注意があります。D3DXCreateTexture関数は引数に与えたサイズではないテクスチャを返す事が良くあります。これは環境にもよりますが、例えば2のべき乗に揃っていないサイズを与えた時などに強制的に適正サイズに拡張される事があります。レンダリングするテクスチャの大きさは次のZ値用深度バッファの作成で極めて重要になってきますので、Z値テクスチャ作成後にそのサイズをもチェックして格納しておきましょう。



B Z値レンダリング用深度バッファ作成が必要なんです

 さて、新しいZ値テクスチャを作成すれば、後はレンダリングすればよさそうなものなのですが、実はそれだとレンダリングに失敗します。実は、DirectX9ではレンダリングするサーフェイス(テクスチャ)よりも深度バッファが小さいと、Zテストをキャンセルしてしまうんです。その結果前後関係があべこべな描画になってしまいます。これを回避するためには、Z値テクスチャと同じ大きさを持つ深度バッファを新規に作成する必要があります。

 深度バッファはテクスチャとして作成できません。これを作成するのはIDirect3DDevice9::CreateDepthStencilSurfaceメソッドです:

IDirect3DDevice9::CreateDepthStencilSurfaceメソッドの設定例
pDev->CreateDepthStencilSurface(
   ZTexWidth,     // Z値テクスチャの実質幅
   ZTexHeight,    // Z値テクスチャの実質高
   D3DFMT_D16,
   D3DMULTISAMPLE_NONE,
   0,
   FALSE,
   &pZBufSurface,   // Z値用深度バッファサーフェイス
   NULL
);


 Aの最後で述べたZ値テクスチャの幅高がここで活用されます。メソッドが成功すれば、pZBufSurfaceに深度バッファに使える適切なサーフェイスが返されます。プログラム側で最初に用意しておくのはZ値テクスチャとZ値用深度バッファだけです。



C プログラマブルシェーダーの作成

 @の行列計算は頑張ればプログラム上からでも一応可能です。しかし恐ろしく面倒な上、多分1枚描画するのに涙の出る程時間がかかります。こういうピクセル単位の計算はGPUの大得意な分野です。よって、ここはプログラマブルシェーダーを使う事にします。シェーダプログラムが良くわからないと言う方は、プログラマブルシェーダ編のHLSL関係をちらちらとお読み下さればきっと十分です。難しい事は何もしません。

 まずは頂点シェーダのプログラム部分をご覧下さい:

頂点シェーダ
float4x4 matWorldViewProj;   // ワールドビュー射影変換行列

struct VS_OUTPUT
{
   float4 Pos : POSITION;   // 射影変換座標
   float4 ShadowMapTex : TEXCOORD0;   // Zバッファテクスチャ
};


// 頂点シェーダ
VS_OUTPUT ZBufferCalc_VS( float4 Pos : POSITION )
{
   VS_OUTPUT Out = (VS_OUTPUT)0;

   // 普通にワールドビュー射影行列をする
   Out.Pos = mul( Pos, matWorldViewProj );

   // テクスチャに位置を登録
   Out.ShadowMapTex = Out.Pos;

   return Out;
}

 頂点シェーダでは入力値としてローカル頂点位置Pos(POSITIONセマンティクス)だけ使用します。Posをワールドビュー射影変換して、その結果をテクスチャ0番に登録します(太文字部分)。気を付けたいのが、この代入はテクスチャの色を変えているのではなくて、頂点に定義されているテクスチャ座標を変更しています。なぜこんな事をするのかと言うと、テクスチャ座標内の任意位置のz成分とw成分の補間値をハードウェアに計算してもらうためです。ん〜説明になっていませんね(^-^;。下の図をご覧下さい:

 シェーダ内ではスクリーン座標は4次ベクトルとして扱えます。頂点シェーダでスクリーン座標として変換した頂点座標(z,w成分付き)をテクスチャ座標にそのままコピーすると、スクリーンにテクスチャをそのまんまの方向でビタっと貼る事と同じ意味になります。この時奥行きの情報であるzやw成分はうまく線形補間されてピクセルシェーダに渡されます。ですからピクセルシェーダでZ値を1ピクセルずつ具体的に算出できる仕組みになるんです。スクリーンに透明のシートを貼って、頂点の位置にマジックで印を付けて補間値を求める様子を想像すると、少しわかりやすいかもしれません。・・・慣れもいるかな(^-^;。

 続いてピクセルシェーダをご覧下さい:

ピクセルシェーダ
// ピクセルシェーダ
float4 ZBufferPlot_PS( float4 ShadowMapTex : TEXCOORD0 ) : COLOR
{
   // Z値算出
   return ShadowMapTex.z / ShadowMapTex.w;
}

引数に渡されてきた補間テクスチャ座標からZ値を算出しています。そしてそれを色として出力しています。このシェーダプログラムを通せば、Z値が画面に描画されます。

 ピクセルシェーダについて大きな注意が1つあります。このピクセルシェーダのようにテクスチャの値を個別に取り出して演算するというのはピクセルシェーダ2.0以降で対応しています。バージョン1.1だと実は割り算作業をピクセルシェーダで行えないためエラーになります。あれこれ色々考えたのですが、ps1_1でZ値を計算する良い方法を結局思いつく事が出来ませんでした。知っている方は是非お知らせ頂ければと思います。



C テクスチャを入れ替えて描画

 最後の詰めは描画部分です。Z値テクスチャをレンダリングターゲットとして設定し、またZ値用深度バッファに切り替えて、シェーダプログラムを通せばOKです。レンダリングターゲット等を切り替える時には、現在設定されているデバイスを取得しておく必要があります。その作業は最初に1度だけやっておけば十分です。一気に行きましょう:

プログラム側
// レンダリングターゲットを切り替え
IDirect3DSurface9 *pDeviceBackSurf;
IDirect3DSurface9 *pDeviceZBuff;
pDev->GetRenderTarget( 0, &pDeviceBackSurf );    // レンダリングターゲットを取り替える
pDev->SetRenderTarget( 0, pZBufferSurf );

// エフェクトを作成
Com_ptr<ID3DXEffect> cpEffect;
D3DXCreateEffectFromFile(
   cpDev.GetPtr(),
   _T("ZValuePlot.fx"),
   NULL,
   NULL,
   D3DXSHADER_DEBUG,
   NULL,
   cpEffect.ToCreator(),
   NULL
);

// エフェクト内のワールドビュー射影変換行列を設定
D3DXMATRIX mat, View, Proj;
D3DXMatrixPerspectiveFovLH( &Proj, D3DXToRadian(45), 640.0f/480.0f, 20.0f, 300.0f);
D3DXMatrixLookAtLH( &View, &D3DXVECTOR3(30,20,30), &D3DXVECTOR3(0,0,0), &D3DXVECTOR3(0,1,0) );
D3DXMatrixIdentity( &mat );
mat = mat * View * Proj;   // ワールドビュー射影行列生成
cpEffect->SetMatrix( "matWorldViewProj", &mat );

// 描画開始
cpEffect->SetTechnique( "ZValuePlotTec" );
UINT numPass;

cpEffect->Begin( &numPass, 0 );   // プログラマブルシェーダにパイプラインを切り替え

   cpEffect->BeginPass(0);
   for(i=0; i<dwMatNum; i++)
      cpMesh->DrawSubset(i);   // メッシュを描画
   cpEffect->EndPass();

cpEffect->End();



 Z値をテクスチャに描画するだけであれば、プログラム自体は極めて簡単です。

 以上のプロセスに沿って実際にZ値を描画するサンプルプログラムをこちらで公開します。テクスチャを作成するという趣旨から、テクスチャを作成した後にスプライトに貼り付けます。変数定義等が少し異なっておりますが、流れは一緒です。



 Z値を目で見るということは多分今回のようなテスト段階しかないと思いますが、このソースだけでレンダリングソースに切り替えやシェーダプログラムの扱いなど、多くの大切な技術がぎゅ〜っと詰まっています。またミップマップを1枚にしないと駄目だとか、シェーダから固定機能パイプラインに戻すにはシェーダをリセットしないといけないなど、サンプルプログラムには細かで必要不可欠な調節が沢山入っています。冒頭でも述べましたが、Z値テクスチャの作成は「深度バッファシャドウ」という影生成方法の必須技術です。今のうちに会得しておいた方が幸せになれます(^-^)