ホーム < ゲームつくろー! < DirectX10技術編

その6 ジオメトリシェーダの使い方


 なんと10年ぶりのDirectX10技術編の記事になります(2018.9)。

 DirectX10の目玉の一つがジオメトリシェーダです。DirectX9時代の頂点シェーダ、ピクセルシェーダに追加された第3のシェーダで、工夫次第でこれまでのシェーダではできなかった表現が可能になります。ここではジオメトリシェーダの使い方を見ていく事にしましょう。



@ ジオメトリシェーダで出来る事

 ジオメトリシェーダは頂点シェーダの後に続くシェーダで、頂点シェーダで変換された頂点座標がポリゴン1枚分のセットでやってきます。例えばトポロジータイプとして三角形ポリゴンが指定されているなら、ジオメトリシェーダには3頂点の情報が一度に落ちてきます。ジオメトリシェーダ内ではそのポリゴン単位の頂点情報を操作し、元の値と異なる頂点情報にして出力したり、新たに頂点を追加してポリゴンを増やす事も出来ます。そうして変更したり増やしたりしたポリゴンをピクセルシェーダの前段階であるラスタライズステージに受け渡します。

 と言ってもちょっとイメージしにくいかもしれませんので、具体的な例でその機能を体験していきましょう。ここでは入力されたモデルの頂点の位置にジオメトリシェーダ内で作成した小さな立方体を配置してみます。



A 立方体配置ジオメトリシェーダ

 シェーダの計画を立てます。まず頂点シェーダではあえて何も変換せずにローカル座標をそのまま出力します。そうするとポリゴンの3頂点のローカル座標がジオメトリシェーダに直接入力されてきます。ジオメトリシェーダではそのローカル座標に作成した立方体を配置します。その立方体に対してワールドビュー射影変換行列を適用し、ピクセルシェーダへ渡します。

 まずはシェーダをご覧ください:

立方体配置シェーダ
matrix world;
matrix view;
matrix proj;
float scale;

// 立方体の面法線
float3 sqFaceNorm[6] = {
   float3( 0.0, 0.0, -1.0 ),
   float3( 1.0, 0.0, 0.0 ),
   float3( 0.0, 0.0, 1.0 ),
   float3( -1.0, 0.0, 0.0 ),
   float3( 0.0, 1.0, 0.0 ),
   float3( 0.0, -1.0, 0.0 )
};

// 立方体の頂点座標
float3 sqVtx[8] = {
   float3( -1.0, -1.0, -1.0 ),
   float3( 1.0, -1.0, -1.0 ),
   float3( -1.0, 1.0, -1.0 ),
   float3( 1.0, 1.0, -1.0 ),
   float3( -1.0, -1.0, 1.0 ),
   float3( 1.0, -1.0, 1.0 ),
   float3( -1.0, 1.0, 1.0 ),
   float3( 1.0, 1.0, 1.0 )
};

// 立方体のインデックス
int sqIdx[36] = {
   0, 2, 1,
   1, 2, 3,
   1, 3, 5,
   5, 3, 7,
   5, 7, 4,
   4, 7, 6,
   4, 6, 0,
   0, 6, 2,
   2, 6, 3,
   3, 6, 7,
   0, 1, 5,
   0, 5, 4
};

struct VS_INPUT {
   float3 pos : POSITION;
   float3 norm : TEXCOORD0;
};

struct GS_INPUT {
   float4 pos : SV_POSITION;
   float3 norm : TEXCOORD0;
};

struct PS_INPUT {
   float4 pos : SV_POSITION;
   float3 norm : TEXCOORD0;
};

// 頂点シェーダ
GS_INPUT vsMain( VS_INPUT In ) {
   // ローカル位置をそのままジオメトリシェーダへ
   GS_INPUT Out;
   Out.pos = float4( In.pos, 1.0 );
   Out.norm = In.norm;
   return Out;
}

// ジオメトリシェーダ
[MaxVertexCount(108)]
void gsMain( triangle GS_INPUT In[3], inout TriangleStream<PS_INPUT> triStream ) {

   // 各頂点に配置する立方体の大きさを何となく算出
   float L01 = length( In[1].pos.xyz - In[0].pos.xyz );
   float L12 = length( In[2].pos.xyz - In[1].pos.xyz );
   float L02 = length( In[0].pos.xyz - In[2].pos.xyz );
   float sqScale[3] = {
      min( L01, L02 ),
      min( L01, L12 ),
      min( L12, L02 )
   };

   // 頂点の位置に立方体を配置しWVP変換を
   // 法線はWVで
   float4x4 wv = mul( world, view ); // HLSLでは*演算子は使えない!
   float4x4 wvp = mul( wv, proj );
   int faceId = 0;
   for ( int v = 0; v < 3; ++v ) {
      for ( int p = 0; p < 12; ++p ) {
         faceId = p / 2;
         for ( int i = 0; i < 3; ++i ) {
            PS_INPUT Out;
            int idx = sqIdx[ p * 3 + i ];
            float3 localP = sqVtx[ idx ] * sqScale[ v ] * scale + In[ v ].pos.xyz;
            Out.pos = mul( float4( localP, 1.0 ), wvp );
            Out.norm = mul( float4( sqFaceNorm[ faceId ], 0.0 ), wv ).xyz;
            triStream.Append( Out );
         }
         triStream.RestartStrip();
      }
   }
}

// ピクセルシェーダ
float4 psMain( PS_INPUT In ) : SV_TARGET {
   return float4( float3( 0.8, 0.8, 0.8 ) * ( -normalize( In.norm ).z ), 1.0 );
}

// ラスタライザ
RasterizerState rs {
FillMode = SOLID;
CullMode = BACK;
FrontCounterClockWise = FALSE;
DepthBias = 0;
DepthBiasClamp = 0;
SlopeScaledDepthBias = 0;
DepthClipEnable = FALSE;
ScissorEnable = FALSE;
MultiSampleEnable = FALSE;
AntiAliasedLineEnable = FALSE;
};

// ブレンドステート
BlendState bs {
AlphaToCoverageEnable = FALSE;
BlendEnable[0] = FALSE;
SrcBlend = ONE;
DestBlend = ZERO;
BlendOp = REV_SUBTRACT;
SrcBlendAlpha = ONE;
DestBlendAlpha = ZERO;
BlendOpAlpha = ADD;
RenderTargetWriteMask[0] = 0x0F;
};

// 深度ステンシル
DepthStencilState dss {
DepthEnable = TRUE;
DepthWriteMask = ALL;
DepthFunc = LESS;
StencilEnable = FALSE;
StencilReadMask = 0xff;
StencilWriteMask = 0xff;
FrontFaceStencilFail = KEEP;
FrontFaceStencilDepthFail = KEEP;
FrontFaceStencilPass = KEEP;
FrontFaceStencilFunc = ALWAYS;
BackFaceStencilFail = KEEP;
BackFaceStencilDepthFail = KEEP;
BackFaceStencilPass = KEEP;
BackFaceStencilFunc = ALWAYS;
};

// テクニック
technique10 tech {
   pass p0 {
      SetVertexShader( CompileShader( vs_4_0, vsMain() ) );
      SetGeometryShader( CompileShader( gs_4_0, gsMain() ) );
      SetPixelShader( CompileShader( ps_4_0, psMain() ) );

      SetRasterizerState( rs );
      SetBlendState( bs, float4( 0.0f, 0.0f, 0.0f, 0.0f ), 0xFFFFFFFF );
      SetDepthStencilState( dss, 0 );
   }
}

 ちょっと長いのでポイントを絞って見ていきましょう。グローバル定数には描画する立方体の頂点座標とインデックス情報をダイレクトに記述しています。ジオメトリシェーダを記述可能なHLSLバージョン4.0以上ではグローバル定数(定数レジスタ)のサイズが大幅に引き上げられたため、こういうサイズの大きい記述が容易にできるようになりました。

 頂点シェーダは非常に単純で、入力されてきた頂点をそのまま出力しているだけです。この出力頂点データは3個にまとめられ、次のジオメトリシェーダに渡されます。

 ジオメトリシェーダ部分だけを再掲します:

立方体配置ジオメトリシェーダ
// ジオメトリシェーダ
[MaxVertexCount(108)]
void gsMain( triangle GS_INPUT In[3], inout TriangleStream<PS_INPUT> triStream ) {

   // 各頂点に配置する立方体の大きさを何となく算出
   float L01 = length( In[1].pos.xyz - In[0].pos.xyz );
   float L12 = length( In[2].pos.xyz - In[1].pos.xyz );
   float L02 = length( In[0].pos.xyz - In[2].pos.xyz );
   float sqScale[3] = {
      min( L01, L02 ),
      min( L01, L12 ),
      min( L12, L02 )
   };

   // 頂点の位置に立方体を配置しWVP変換を
   // 法線はWVで
   float4x4 wv = mul( world, view ); // HLSLでは*演算子は使えない!
   float4x4 wvp = mul( wv, proj );
   int faceId = 0;
   for ( int v = 0; v < 3; ++v ) {
      for ( int p = 0; p < 12; ++p ) {
         faceId = p / 2;
         for ( int i = 0; i < 3; ++i ) {
            PS_INPUT Out;
            int idx = sqIdx[ p * 3 + i ];
            float3 localP = sqVtx[ idx ] * sqScale[ v ] * scale + In[ v ].pos.xyz;
            Out.pos = mul( float4( localP, 1.0 ), wvp );
            Out.norm = mul( float4( sqFaceNorm[ faceId ], 0.0 ), wv ).xyz;
            triStream.Append( Out );
         }
         triStream.RestartStrip();
      }
   }
}

 ジオメトリシェーダは頂点シェーダ等とはちょっと趣きが異なります。

 まず最初に[MaxVertexCount(108)]というのは、ジオメトリシェーダから出力する最大の頂点数を表します。今回のジオメトリシェーダでは三角ポリゴンの3頂点に対して立方体を配置します。一つの立方体は12枚の三角ポリゴンで構成されているので36頂点。それが3つあるので36×3=108頂点を出力するためここにその個数を記載しています。

 ジオメトリシェーダ関数の引数はこうなっています:

ジオメトリシェーダ関数の引数
[MaxVertexCount(108)]
void gsMain( triangle GS_INPUT In[3], inout TriangleStream<PS_INPUT> triStream ) {
 ..
}

 まず第1引数には頂点シェーダから降りてくる頂点データの配列を指定します。この時「triangle」というプリミティブタイプのキーワードを付けます。これは字面の通りなのですが「三角形として降りてきますよ」という意味です。このプリミティブタイプには他に以下の物があります:

プリミティブタイプ 意味
point 点のリスト
line ラインのリストもしくはストリップ
triangle 三角形リストもしくはストリップ
lineadj 隣接性ラインリストもしくはストリップ
triangleadj 隣接性三角形リストもしくはストリップ

このプリミティブタイプはDirect3D10で描画時にコールするID3D10Device::IASetPrimitiveTopologyメソッドの引数(D3D10_PRIMITIVE_TOPOLOGY列挙型)と対応させます。

 続くTriangleStream<PS_INPUT>はジオメトリシェーダの出力となる頂点受け取る変数で「ストリーム出力オブジェクト」と呼ばれています。頂点シェーダ等はreturnで値を返しますが、ジオメトリシェーダはこの型の変数に渡すスタイルという訳です。TriangleStreamは三角形の頂点を受け取る事が出来る型ですが、その他にも以下のストリームオブジェクトを指定できます:

ストリーム出力オブジェクト 意味
PointStream 点ストリーム
LineStream ラインストリーム
TriangleStream 三角形ストリーム

多くの場合ポリゴンを塗る事が多いためTriangleStreamになるかなと思いますが、面積を持たない他のストリーム出力オブジェクトも使い様はあります。

 ジオメトリシェーダの実装について次に見ていきます。

立方体配置ジオメトリシェーダ
// ジオメトリシェーダ
[MaxVertexCount(108)]
void gsMain( triangle GS_INPUT In[3], inout TriangleStream<PS_INPUT> triStream ) {

   // 各頂点に配置する立方体の大きさを何となく算出
   float L01 = length( In[1].pos.xyz - In[0].pos.xyz );
   float L12 = length( In[2].pos.xyz - In[1].pos.xyz );
   float L02 = length( In[0].pos.xyz - In[2].pos.xyz );
   float sqScale[3] = {
      min( L01, L02 ),
      min( L01, L12 ),
      min( L12, L02 )
   };

   ...
}

 変数Inは頂点の配列なのでインデックスでその情報にアクセスできます。上でやっているのは3頂点に配置する立方体のサイズをざっくりと決めているだけです。1つの頂点から伸びる2辺の長さを調べて、短い方の距離を採用しています。

 次に頂点の位置に立方体を配置します:

立方体配置ジオメトリシェーダ
// ジオメトリシェーダ
[MaxVertexCount(108)]
void gsMain( triangle GS_INPUT In[3], inout TriangleStream<PS_INPUT> triStream ) {

   ...

   // 頂点の位置に立方体を配置しWVP変換を
   // 法線はWVで
   float4x4 wv = mul( world, view ); // HLSLでは*演算子は使えない!
   float4x4 wvp = mul( wv, proj );
   int faceId = 0;
   for ( int v = 0; v < 3; ++v ) {
      for ( int p = 0; p < 12; ++p ) {
         faceId = p / 2;
         for ( int i = 0; i < 3; ++i ) {
            PS_INPUT Out;
            int idx = sqIdx[ p * 3 + i ];
            float3 localP = sqVtx[ idx ] * sqScale[ v ] * scale + In[ v ].pos.xyz;
            Out.pos = mul( float4( localP, 1.0 ), wvp );
            Out.norm = mul( float4( sqFaceNorm[ faceId ], 0.0 ), wv ).xyz;
            triStream.Append( Out );
         }
         triStream.RestartStrip();
      }
   }
}

 グローバル変数にワールド、ビュー、射影変換行列が渡されいるので、そこからWVP及びWV行列を作成しています。気を付けたいのがHLSLでは行列同士を「*」で掛け算すると、いわゆる行列の掛け算ではなく、各要素がそのまま掛け算されます。なので面倒ですがmul関数を使いましょう。

 一番外側のループは頂点番号(v=0,1,2)です。続く内側のループは立方体の各ポリゴン番号(p=0-11)になります。さらに内側のループが立方体のポリゴン1枚の頂点番号(i=0,1,2)になります。localPには入力で渡された頂点座標を中心とした立方体の頂点idxのローカル座標が格納されます。計算時にスケールをかけて大きさを調整しています。求めた立方体のローカル座標にwvp行列をかけて一気に射影空間にまで変換(これは頂点シェーダでやっている事と一緒)します。立方体の法線は面法線から取り出して、それをビュー空間まで変換しています。

 大切なのはここから。そうして算出した頂点(PS_INPUT)をストリーム出力オブジェクトに渡します。Appendメソッドを用いると頂点が一つストリームに追加されます。上の例では三角形ストリームなので、3頂点をひと塊として連続で渡します。そして、3つ分Appendしたら必ずRestartStripメソッドを呼び出します。このメソッドはストリーム出力に区切りをつける働きをします。これを呼び出さないとピクセルシェーダにポリゴンが正しく伝わりません。

 ジオメトリシェーダはこのように元の頂点の情報から新しいポリゴンを出力する事が出来ます。やろうと思えば頂点を一つも出力しないという事も出来ます。その場合ピクセルシェーダから先は動きません。

 ピクセルシェーダでやっている事は法線の情報から適当に影を付けて出力しているだけです。

 テクニックの所でジオメトリシェーダ関数を指定する必要があります:

テクニック
// テクニック
technique10 tech {
   pass p0 {
      SetVertexShader( CompileShader( vs_4_0, vsMain() ) );
      SetGeometryShader( CompileShader( gs_4_0, gsMain() ) );
      SetPixelShader( CompileShader( ps_4_0, psMain() ) );

      SetRasterizerState( rs );
      SetBlendState( bs, float4( 0.0f, 0.0f, 0.0f, 0.0f ), 0xFFFFFFFF );
      SetDepthStencilState( dss, 0 );
   }
}

後はこのシェーダをC++側で呼び出して、頂点情報を渡すだけでその頂点位置に立方体が配置されます。



 DirectX10での大きな追加要素の一つがジオメトリシェーダです。工夫次第で本当に面白い事が色々と出来ますし、使い方も至極簡単なので是非活用してみて下さい。今回の例を踏まえたサンプルコードもアップしましたので参考にしてみて下さい(^-^)