ホーム < ゲームつくろー! < プログラマブルシェーダ編 < HLSLによる極短レンダリング

その7 HLSLによる極短レンダリング


 HLSLで頂点シェーダとピクセルシェーダを作成するとオリジナルなレンダリング方法が実現できます。これは無限の可能性をプログラマに与えてくれるのですが、いかんせん慣れないと何の事やらだったりします。前章では取っ掛かりとしてHLSLを何となく見てきましたが、この章ではその仕組みをもう少し詳しく眺めて、実際に極単コードでレンダリングまでやってみます。



@ DirectX10からはHLSLが当たり前になります!

 HLSLは結局何をするものなのか?これは簡単に言えばIDirect3DDevice9インターフェイスがデフォルトで持つ「固定機能パイプライン」の流れを横取りします。ですから、固定機能パイプラインが理解できていないと、HLSLはちょっと扱うのに難しい物になってしまいます。IDirect3DDevice9がDrawPrimitiveメソッドなどで「描画せよ!」と命令してから終わるまでに何が行われているかは、プログラマブルシェーダ編その1『プログラマブルシェーダーって何?』でじっくり説明しております。

 「でも、やっぱり何だか難しいんだよね・・・」と言っているあなた!そんな悠長な事をもう言ってられなくなってしまいました。次のDirectX10では、なんと固定機能パイプラインが廃止されます(Direct3D 9 から Direct3D 10 に移行する際に考慮すべき点:http://www.microsoft.com/japan/msdn/directx/general/bb205073.aspx)。つまり「描画せよ!」の後についてもプログラマが意識して実装する必要が出てきたわけです。ということで、HLSLに今から慣れておくのは今後の為にも必須です!



A HLSLの通り道を覚えてしまいましょう

 DirectX9でのHLSLは実は凄いシンプルです。本当です。上から下までプログラムが通っておしまいという懐かしきC言語ライクなプログラムスタイルなんですから。しかも、その筋道も全部決まっています(パイプラインですから)。プログラマはその筋道に沿ってHLSLプログラムを作るだけなんです。早速上から下までざざっと眺めてしまいましょう。

 HLSLは「technique(テクニック)」という所からスタートします。ここがエントリポイントなんです。C言語でいうmain関数のようなものです。techniqueの中には「pass(パス)」というくくりが1つ以上あります。1つのパスが1つの筋道になっていて、途中で他のパスに行く事はありません。1つのパスの中には頂点シェーダ関数とピクセルシェーダ関数の呼び出し部分があり、プログラムからパスを選択すれば、この筋道で最後まで描画が行われます:


 上の図をHLSLプログラムでは次のように表現します:

technique部分
technique SampleTec01
{
   pass P0
   {
      // シェーダ
      VertexShader = compile vs_1_1 VertexShaderFunc( );
      PixelShader = compile ps_1_1 PixelShaderFunc( );
   }

     pass P1
     {
        // ...
     }
}

technique SampleTec01
{
      // ...
}

 これは、「SampleTex01のパスP0は、頂点シェーダとしてVertexShaderFunc関数、ピクセルシェーダとしてPixcelShaderFunc関数を通りますよ」と宣言しています。両シェーダの関数名はプログラマが自由に決められます。1つのパスの中に複数の頂点シェーダもしくは複数のピクセルシェーダを置いた場合、最後に宣言された関数が採用されます。

 描画を開始する前、デバイスにはポリゴンの頂点をセットします。その後プログラマブルシェーダを走らせると、最初に頂点シェーダ関数が呼ばれます。頂点シェーダにはデバイスにセットした頂点が1つずつ渡されます。頂点が渡されるというのはちょっと曖昧な表現かもしれませんね。具体的には「頂点シェーダ入力セマンティクス」という種類の情報がごそっと渡されます:

頂点シェーダ入力セマンティクス
セマンティクス 意味
POSITION ローカル空間の頂点の位置座標
BLENDWEIGHT ブレンディングの重み
BLENDINDICES ブレンドのインデックス
NORMAL 法線ベクトル
PSIZE ポイントサイズ
DIFFUSE ディフューズ色
SPECULAR スペキュラ色
TEXCOORD# テクスチャ座標
TANGENT 接線
BINORMAL 従法線
TESSFACTOR テセレーション係数

 すべて1つの頂点に関係する情報で、すべてDirectXから頂点シェーダ関数の引数に渡されます。でも、これ全部を扱うのは宣言も大変ですし何より大混乱です。そこで、頂点シェーダ関数は「引数をユーザが選択できる」仕組みを整えてくれました:

頂点シェーダ関数部分
float4 VertexShaderFunc( float4 Pos : POSITION ) : POSITION
{
  // ...
}

引数に注目してください。Posというのはfloat4型(4次ベクトルです)なのです。その右にコロンを挟んでPOSITIONというセマンティクスを明記しています。これで「Posはローカル空間にある頂点の位置座標ですよ」と宣言しているんです。使わない他のセマンティクスは明記しなくても構いません。これは中々にすばらしい仕組みなんです。

 さて、頂点シェーダは引数の情報を元に内でごにょごにょと仕事をし、最終的に値を返す事が目的です。この返す情報は「頂点シェーダ出力セマンティクス」と呼ばれています:

頂点シェーダ出力セマンティクス
セマンティクス 意味
POSITION スクリーン空間に変換した頂点の座標
PSIZE ポイントサイズ
FOG 頂点フォグ
COLOR 頂点の色
TEXCOORD# テクスチャ座標

 これらはすべてスクリーン上での頂点の情報です。頂点シェーダはこれ以外の値を返せません。上のプログラム例の場合、関数の後ろに「: POSITION」というセマンティクスが付いています。これは、この頂点シェーダ関数が最終的に「スクリーン空間での頂点座標を返しますよ」と宣言しているわけです。

 「んだば、他の情報はどうなってるんよ?」と当然思われるかもしれません。他の情報は変更される事無くスルーされて出力されます。自分が操作したい情報だけを扱える仕組みになっているわけです(^-^)。もちろん、複数の情報を一度に返す方法もちゃんとありますが、それは次の章で紹介する事にします。

 引数と出力は基本的には独立していますので、上の図のようにCOLORを入力値として扱っていなくても出力でいきなり返してもまったく問題ありません。ただし、頂点シェーダは少なくともスクリーン座標を返さなければエラーとなってしまいます




B ピクセルシェーダの通り道


 さて、続いてピクセルシェーダです。ピクセルシェーダ関数にも入力値である「ピクセルシェーダ入力セマンティクス」があります:

ピクセルシェーダ入力セマンティクス
セマンティクス 意味
COLOR# ディフューズ色・スペキュラ色
TEXCOORD# テクスチャ座標

 頂点シェーダに比べると随分少ないですよね。それだけ触る部分が少ないとも言えます。COLORはスクリーン上のあるピクセルの色で、COLOR0とするとディフューズ色が、COLOR1とするとスペキュラ色が取得できます。ただ、残念ながら色が穿たれるスクリーン座標をピクセルシェーダで取得する事はできません。もっとも、これは取得できない方がきっと正しいと思います。

 テクスチャ座標は入力値のスクリーンピクセルに貼り付ける予定のテクスチャ上の点です。今度は色ではなくて座標です。「なんで?」と思いますよね。ここが奥深い所です。スクリーン座標は画面の大きさによって変化してしまいますが、テクスチャはすべて(0,0)-(1,1)の範囲に収まります。つまり、座標を指定すれば色がわかるわけです。これは明らかに「ピクセルシェーダではテクスチャをうまく扱って下さいね」っと暗示しています。奥深いもんです。

 ピクセルシェーダに入る段階のイメージ図を下に示します:


 ピクセルシェーダ関数の宣言はこんな感じです:

ピクセルシェーダ関数部分
float4 PixelShaderFunc( float4 Color : COLOR0, float2 Tex : TEXCOORD0 ) : COLOR
{
  // ...
}


 さて、ピクセルシェーダ関数は最終的に2つの情報(ピクセルシェーダ出力セマンティクス)を返します:

ピクセルシェーダ出力セマンティクス
セマンティクス 意味
COLOR# ディフューズ色・スペキュラ色
TEXCOORD# テクスチャ座標

最終的なピクセルの色とテクスチャの座標を返します。ピクセルシェーダは最低限COLORを返す必要があります。この後に各種テストが行われ、テストに合格するとピクセルシェーダが返す色がスクリーンに反映されます。



B 最小のレンダリング

 では、さっそくレンダリングができる極小シェーダを作成してみましょう。ここで作成するのは色もへったくれもなく、単に頂点をスクリーン座標に変換して投影するという物です。些細な物ですが、画面に絵が出ると格別にうれしいものですよ。

 頂点シェーダに入ってくる頂点の座標はローカル空間の座標値です。頂点シェーダではローカル座標にある頂点をスクリーン座標に変換します。これが頂点シェーダのメインワークです。

 「あれ?でもこれってワールド変換行列とか、座標を変換する行列を登録しないとだめだよね。IDirect3DDevice9::SetTransformメソッド使ってさ?」と思われた方は鋭いです。実はその通りなんですがSetTransformメソッドは使いません。これは固定機能パイプライン専用なんです。シェーダプログラムに行列を設定するには、それを格納する変数をシェーダ内に新設します。例えば次のような変数を宣言してみましょう:

ワールドビュー射影変換行列宣言
float4x4 matWorldViewProj;

これはC言語のグローバル変数のようにシェーダ関数の外に宣言します。float4x4というのは、見た目でなんとなくわかるかと思いますが、float4型のベクトルが4つ連なった4×4の行列変数です。頂点シェーダでは、この変数にワールド・ビュー・射影変換行列をこの順番で掛け算した行列が入っているという前提でプログラムを組みます。ここにどうやって行列を設定するかはすぐ後で説明します。

 頂点シェーダに入ってくる入力情報は1頂点です。先ほども申しましたように、これは「ローカル座標値」です。今これをLocalPosとしておきましょう。これに上の行列を掛け算すれば、スクリーン座標(正確には射影空間)まで一気に変換されます。この掛け算作業は頂点シェーダの内部で次のようなシェーダプログラムを組む事で行えます:

ワールドビュー射影変換頂点シェーダ
float4 BasicTransform( float4 LocalPos : POSITION ) : POSITION
{
   // ローカル座標にある頂点をワールドビュー射影変換で
   // 一気にスクリーン座標にしてしまう
   return mul( LocalPos, matWorldViewProj );
}

 mulというのは掛け算を行う組み込み関数です。組み込み関数というのはシェーダ内に最初から定義されている関数の事で、非常に沢山用意されています。上のプログラムでは、mul関数によってローカル座標に変換行列を掛け算しています。ローカル座標は1×4のベクトル、対して行列は4×4なので、結果として1×4のベクトル(float4型)が出力されてきます。それをreturn文で返しているわけです。これで、入力された頂点はスクリーン座標に無事に変換されました。次はピクセルシェーダです。

 ピクセルシェーダに入ってくるのはスクリーンに投影されたポリゴン内のある1点の情報です。ですから、その値をそのまま返せば、ピクセルシェーダの仕事は実は終了してしまいます:

ピクセルシェーダ
float4 NoWorkingPixelShader( float4 ScreenColor : COLOR0 ) : COLOR0
{
   // 入力されたスクリーンピクセルの色をそのままスルー
   return ScreenColor;
}

これで、ラスタ処理によって決められた色がそのまま使われます。以後深度テストなどが行われまして、最終的にスクリーンに点を打つか打たないかが決められます。そこはハードウェアが勝手にやってくれる仕事でして、シェーダプログラムではノータッチです。

 この極小のシェーダプログラムをまとめるとこのようになります:

極小プログラマブルシェーダ
// ワールドビュー射影変換行列宣言
float4x4 matWorldViewProj;


// 頂点シェーダ
float4 BasicTransform( float4 LocalPos : POSITION ) : POSITION
{
   // ローカル座標にある頂点をワールドビュー射影変換で
   // 一気にスクリーン座標にしてしまう
   return mul( LocalPos, matWorldViewProj );
}


// ピクセルシェーダ
float4 NoWorkingPixelShader( float4 ScreenColor : COLOR0 ) : COLOR0
{
   // 入力されたスクリーンピクセルの色をそのままスルー
   return ScreenColor;
}


technique BasicTec
{
   pass P0
   {
      VertexShader = compile vs_1_1 BasicTransform();
      PixelShader = compile ps_1_1 NoWorkingPixelShader();
   }
}

このシェーダプログラムを通せば、3Dオブジェクトはちゃんと画面に表示されます。



C ID3DXEffectインターフェイス登場!

 さて、上で作成したシェーダプログラムを実際に使用するには、プログラム側でこれを読み込ませる必要があります。これを強烈にサポートしてくれるのがID3DXEffectインターフェイスです。初登場です(^-^)

 プログラムでは、描画デバイスの初期化が終了した後にID3DXEffectインターフェイスを生成します。この生成方法には幾つかあるのですが、D3DXCreateEffectFromFile関数を用いると外部ファイルに定義されたHLSLを読み込んでくれます:

D3DXCreateEffectFromFile関数
HRESULT D3DXCreateEffectFromFile(
    LPDIRECT3DDEVICE9   pDevice,
   LPCSTR              pSrcFile,
   CONST D3DXMACRO*    pDefines,
   LPD3DXINCLUDE       pInclude,
    DWORD               Flags,
    LPD3DXEFFECTPOOL    pPool,
    LPD3DXEFFECT*       ppEffect,
    LPD3DXBUFFER        *ppCompilationErrors
);

pDeviceは描画デバイスです。
pSrcFileにはシェーダプログラムが書き込まれたファイル名を相対パスで指定します。
pDefinesはプリプロセッサ定義というものを行うD3DXMACRO構造体を定義するのですが、今は知らなくても構いません(NULL)。
pIncludeには#includeの操作を行うID3DXIncludeインターフェイスを定義しますが、これも今は無視して構いません(NULL)。
Flagには読み込み時のオプションをフラグで設定します。これはデバッグを吐き出すとか、検証しないなど付加的なフラグしかありません。通常0を定義するだけでで十分です。
pPoolはエフェクト間で共通するグローバル変数を取りまとめてくれるID3DXEffectPoolインターフェイスを渡します。ここが設定されていると、読み込まれたシェーダにエフェクト間スコープがあるグローバルなデータが付加されます。必要な時に設定しますが、今はいりません(NULL)。
ppEffectにID3DXEffectインターフェイスへのポインタが返ります。
ppCompilationErrorsにはコンパイルエラーが起こった時のエラー情報が格納されてきます。必要なければここもNULLで構いません。

 ということで、実質必要なのは描画デバイスpDevice、シェーダプログラムファイル名pSrcFileそして受け取るID3DXEffectインターフェイスポインタppEffectです。典型的な設定例を以下に示します:

D3DXCreateEffectFromFile関数によるエフェクト生成
ID3DXEffect *pEffect;

D3DXCreateEffectFromFile(
   pDev,
   _T("Effect00.fx"),
   NULL,
   NULL,
   0,
   NULL,
   &pEffect,
   NULL
);

シェーダプログラムが正しければ、pEffectに有効なポインタが返ります。ちなみに、シェーダプログラムファイルの事を良く「エフェクトファイル(.fx)」と呼びますので、以後この呼び方で統一します。

 シェーダを使うにはID3DXEffectインターフェイスのメソッドを利用します。使い方はとても簡単です。
 まず、シェーダプログラムで定義されているグローバル変数を設定するところから始めます。先ほど作成したシェーダプログラムではmatWorldViewProj行列変数を定義していました。ID3DXEffectインターフェイスには数々の変数設定メソッドがありますが、行列はID3DXEffect::SetMatrixメソッドで設定するのが楽です:

ID3DXEffect::SetMatrixメソッド
HRESULT SetMatrix(      
    D3DXHANDLE hParameter,
    CONST D3DXMATRIX* pMatrix
);

hParameterにはD3DXHANDLEというハンドルを渡すのですが、ここは「エフェクトファイル内の変数名」でも構いません。今回の場合だとここには「"matWorldViewProj"」という文字列を渡します。
pMatrixには設定したい行列へのポインタを渡します。

 グローバル変数を全部設定したら、もう描画作業に移れます。描画の最初には通常と同じようにIDirect3DDevice9::BeginSceneメソッドの呼び出しから始まります。次にエフェクトファイル内に定義されているテクニックID3DXEffect::SetTechniqueメソッドで指定します:

ID3DXEffect::SetTechniqueメソッドによるテクニックの選択
pEffect->SetTechnique( "BasicTec" );

1つのエフェクトファイル内には複数のテクニックを設定できるため、このメソッドを正しく呼び出さなければなりません。テクニックは上のように名前で指定します。続いて描画を独自のシェーダに切り替えるためにID3DXEffect::Beginメソッドを呼び出します:

ID3DXEffect::Beginメソッドでシェーダ開始
UINT numPass;
pEffect->Begin( &numPass, 0 );

2つの引数があります。第1引数にはテクニック内に定義されているパスの数が返されます。第2引数はデバイスステートの情報を保存するか否かのフラグを立てます。0にすると保存・復元が有効になります。大抵は0で問題ないはずです。デバイスステートの状態を保つ事は通常の固定機能パイプラインですとめちゃくちゃに大変だったのですが、ここではなんとそれを自動でやってくれます。Beginメソッドを呼び出した瞬間から、パイプラインは独自のものになります。

 独自のシェーダに切り替えた後、今度はどのパスを使うかも選択します。これにはID3DXEffect::BeginPassメソッドを使用します。尚、古い日本語ヘルプにはこのメソッドの説明はありませんので、確認したい方は英語版をご覧下さい:

ID3DXEffect::BeginPassメソッドでパスを選択
pEffect->BeginPass( 0 );

使い方は引数にパスの番号を指定するだけです。これで、使用する頂点シェーダ及びピクセルシェーダの選択が終わりました。

 後は、通常の描画と同様にローカル座標に定義された頂点群をデバイスに設定します。これは本当に通常と同じ方法です。メッシュの場合もまったく同様で、いつもと同じようにマテリアルやテクスチャを設定してID3DXMesh::DrawSubsetメソッドを呼ぶと描画が実施されます。

 描画が終了したら、パス及び描画終了の宣言をします。これはID3DXEffect::EndPassメソッド及びEndメソッドを呼び出します:

終了宣言
pEffect->EndPass();
pEffect->End();

これでデバイスの状態が復活して固定機能パイプラインに戻ります。1つのパイプラインを使っている間はこれを呼び出す必要はありません。すべての描画が終了したら、描画デバイスの描画終了宣言(IDirect3DDevice9::EndSceneメソッド)とPresentメソッドを呼び出せば画面に描画されます!

 細切れな説明なので、ちょっと流れが不透明ではありますが、この章のサンプルプログラムを見れば「あ〜簡単ね」と思って頂けるはずです。




 HLSLによるシェーダプログラムを生成して描画まで一気に説明してきました。これは実につたないパイプラインですが、すべてはここから始まるんです!Game Programming Gemsシリーズなどで紹介されているシェーダープログラムも、この基本さえおさえれば十分実現可能です。さらに、DirectX10からはHLSLによる本格的なプログラマブルシェーダ時代が到来します。今のうちにシェーダに慣れておいた方が何かとお得なんです。

 今回説明した内容を踏まえたサンプルプログラムをこちらに公開します。まずは実行してみて、その感触をつかむと良いかと思います。