DirectXの話 第107回

ジオメトリシェーダを使用した複数画面描画

今回はサイト初、ジオメトリシェーダをやります。

Windows Vistaをスキップしてしまったため、今までジオメトリシェーダをいじってきませんでした。

私以外にもそういう人は多いと思います。

たぶん、そういう人たちはジオメトリシェーダの使い道が微妙にわかっていないのではないかと思います。

その昔、まだまだシェーダに対応したGPUが高価だったとき、シェーダって意味があるのかと思ったこともありました。

ピクセルシェーダの負荷が極めて高かったとき、頂点シェーダがあればピクセルシェーダはいらなくね?と思ったこともありました。

しかし、シェーダはあって当然、頂点シェーダはすでに座標変換以上の使い道が薄くなってしまいました。ライティングはピクセルシェーダが基本ですね。

ジオメトリシェーダも使い道がよくわからない方は実際に使用してみることをお勧めします。そうすれば色々とアイデアが出てくることでしょう。

ジオメトリシェーダは頂点シェーダとピクセルシェーダの間に実行されます。

DirectX9までであれば、頂点ストリームからプリミティブに使用される頂点が頂点シェーダに入力されます。

頂点シェーダではその1つの頂点を変換して、やはり1つの頂点を出力します。

これらの頂点からプリミティブが形成され、ラスタライズが行われ、それぞれのピクセルでピクセルシェーダが実行されます。

ジオメトリシェーダはプリミティブの形成とラスタライズの間に挿入されます。

入力されるものは1つのプリミティブに使用される複数の頂点、出力はやはり複数の頂点ですが、入力された頂点以上の頂点を出力することが出来ます。

例えば、描画キックしたときのプリミティブが三角形なら頂点は3つ入力され、出力するプリミティブが三角形リストなら3の倍数の頂点が出力されます。

また、ジオメトリシェーダは出力するプリミティブを指定のレンダリングターゲット、指定のビューポートに描画させることが可能です。

今回はこれを利用して複数の画面を1パスで描画してみます。

サンプルではDeferred Lighting を用いています。そのため、描画のパスは全部で3つ。法線・深度パス、ライトバッファパス、最終パスの3つです。

通常、この手法で2画面を描画する場合は各パスを2回ずつ処理しなければならず、全部で6つのパスが必要になります。

ジオメトリシェーダを使用することでこれを各1パス、計3パスで処理することが出来るようになります。

下記は法線・深度パスに使用されるジオメトリシェーダの例です。

[maxvertexcount(12)]

void RenderGS( triangle OutputVS inGeom[3], inout TriangleStream<OutputGS> TriStream )

{

for( int target = 0; target < 2; ++target )

{

OutputGS outVert;

outVert.targetIndex = target;

for( int v = 0; v < 3; ++v )

{

// 座標のビュー変換

float4 wvpos = float4(

dot( mtxView[target][0], inGeom[v].pos ),

dot( mtxView[target][1], inGeom[v].pos ),

dot( mtxView[target][2], inGeom[v].pos ), 1.0f );

outVert.pos.x = dot( mtxProj[target][0], wvpos );

outVert.pos.y = dot( mtxProj[target][1], wvpos );

outVert.pos.z = dot( mtxProj[target][2], wvpos );

outVert.pos.w = dot( mtxProj[target][3], wvpos );

outVert.depth = -wvpos.z / screenParam[target].y;

// 法線のビュー変換

outVert.normal.x = dot( mtxView[target][0].xyz, inGeom[v].normal );

outVert.normal.y = dot( mtxView[target][1].xyz, inGeom[v].normal );

outVert.normal.z = dot( mtxView[target][2].xyz, inGeom[v].normal );

TriStream.Append( outVert );

}

TriStream.RestartStrip();

}

}

ジオメトリシェーダの最初に maxvertexcount はジオメトリシェーダで出力される最大の頂点数を設定します。

上記プログラムでは出力される頂点数が必ず6となるので、12も指定する必要はありませんが、何となく余裕を持っておく、というだけの理由で12にしています。

入力される頂点はプリミティブタイプ(line, triangleなど)、頂点シェーダの出力の型、引数名(配列)となります。例では、triangleで3頂点が入力されます。

triangleだから必ず3頂点というわけではありません。2枚の三角形を入力として使用するため6頂点、という場合もあります。

TriangleStream は出力される三角形ストリームです。inout で指定します。ここにジオメトリシェーダの計算結果を出力します。

計算部分は単純に1つの三角形を2つの別々のビュー変換行列で変換しているだけです。

頂点は TriangleStream::Append() 命令でストリームに出力します。十分な数の頂点を出力したあとで TriangleStream::RestartStrip() 命令を発行します。

もちろん、前回の RestartStrip() から今回の RestartStrip() までに入力された頂点でストリップ三角形を作成します。

見てもわかるとおり、ほとんどやっていることは頂点シェーダと変わりません。しかし、プリミティブを増やすことが出来るという点が大きな違いとなっています。

さて、下記を見てください。これは、上記のジオメトリシェーダで出力する頂点の型です。

struct OutputGS

{

float4 pos : SV_POSITION;

float3 normal : NORMAL;

float depth : DEPTH;

uint targetIndex : SV_RenderTargetArrayIndex;

};

SV_RenderTargetArrayIndex という見慣れないセマンティックがあると思います。これがレンダリングターゲットを指定するためのセマンティックです。

他にもサンプルでは SV_ViewportArrayIndex というセマンティックを使用しています。こちらはビューポートを指定するものです。

DirectX9にはなかったのですが、DirectX10からは複数のビューポートと複数の深度バッファを使用することが出来るようになっています。

DX9世代でもMRTは使用できましたが、ビューポートと深度バッファは1つしか使用できませんでした。

そのため、DX9世代のMRTの使用方法としては、同じ座標にポリゴンを複数回描画する場合にしか使用できませんでした。

DX10ではどちらも複数設定、処理することが出来ます。

また、レンダリングターゲットのサイズが同一である場合、1つのレンダリングターゲットに複数のサーフェイスを用意することが可能になっています。

D3D11_TEXTURE2D_DESC desc;

ZeroMemory( &desc, sizeof(desc) );

desc.Width = 640;

desc.Height = 480;

desc.MipLevels = 1;

desc.ArraySize = 2;

desc.Format = format;

desc.SampleDesc.Count = 1;

desc.SampleDesc.Quality = 0;

desc.Usage = D3D11_USAGE_DEFAULT;

desc.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE;

desc.CPUAccessFlags = 0;

desc.MiscFlags = 0;

HRESULT hr = pD11Dev->CreateTexture2D( &desc, NULL, &m_pRenderTexture );

if( FAILED(hr) ) { return false; }

上記はテクスチャ配列を作成する場合の設定です。ArraySize が配列の大きさで、上記の指定方法では m_pRenderTexture は2つの同一サイズサーフェイスを持っていることになります。

配列のサイズは D3D11_SHADER_RESOURCE_VIEW_DESC や D3D11_DEPTH_STENCIL_VIEW_DESC でも指定する必要があります。また、カラーバッファと深度バッファの配列サイズは同一でなければなりません。

ビューポートを複数指定する場合、RSSetViewports() の第1引数にビューポートの数を、第2引数にビューポート情報配列の先頭アドレスを渡します。

サンプルではスペースキーで複数画面描画、左上画面の拡大、右下画面の拡大と切り替えられるようになっています。

拡大と言っても、実際には複数画面描画を使用せずに1画面のみの描画をそれぞれのカメラで処理しているだけです。

さて、このような手法で複数画面を描画するのは実用的といえるのでしょうか?

実際には難しいのではないかと思います。

例えば、2つの視点がきわめて近い、そして見ている方向も似ているのであれば描画キックの回数を減らせるチャンスとなるでしょう。

しかし、画面分割COOPをやる場合はそんなに接近しているとは限りません。

この場合、各視点におけるビューフラスタムカリングの意味がなくなる可能性があります。

複数視点のすべてでカリングされるオブジェクト以外は必ず描画キックされなければならなくなります。

それでも描画キックを減らした方が効果がある、というならやる価値はあるでしょう。結局は、実際に計測してみないとわかりません。