DirectXの話 第108回
Compute Shader事始め
今回はDirectX11の新機能であるCompute Shaderを使ってパーティクルを計算・描画してみます。
Compute Shaderは近年話題のGPGPUを実現するための機能で、他のシェーダと異なりパイプラインの中に組み込まれていません。
そもそもシェーダと名付けること自体がどうかと思うのですが、他のシェーダもシェーディング以外に使われているのですから問題ないのかもしれませんね。
GPGPUはGeneral Purpose GPUの略語で、その名の通り汎用目的にGPUを使用することを言います。
GPUはグラフィクスアクセラレーションを目的として作成されたものですが、その計算力を生かしてグラフィクス以外の計算を高速化することが可能です。
それを実現するためにNVIDIAのCUDAやオープン系のOpenCLといったライブラリも存在しています。
DirectXでは11から採用されたCompute Shaderを利用します。この機能はDirectX10.1対応ボードでも使うことが可能だったりします。
Compute Shaderを見ていく前にその特徴でもあるRWバッファとスレッドグループについて簡単に解説しておきます。
RWバッファは読み込み/書き込みが可能なバッファです。
というとRenderTargetとして作成したテクスチャを思い浮かべる方もいるかと思いますが、こちらはテクスチャとして使用する場合は読み込みオンリー、RenderTargetとして使う場合は書き込みオンリーです。
RWバッファはシェーダ内で読み込み/書き込みを同時に行うことが可能です。このバッファから読み込んだデータを加工して元に戻す、と言うことが可能なわけです。
シェーダバージョン4.0で使用できるRWバッファは RWStructuredBuffer と RWByteAddressBuffer の2つです。
前者は構造体配列のバッファで、後者はバイトオーダのバッファです。
これらのバッファはシェーダコード内で以下のように指定します。
struct SampleStruct
{
int value0;
float value1;
};
RWStructuredBuffer<SampleStruct> rwBuffer0 : register( u0 );
RWByteAddressBuffer rwBuffer1 : register( u1 )
アクセス方法は、RWStructuredBuffer は普通に配列として使用できます。
RWByteAddressBuffer はLoad命令とStore命令を利用してバイト単位でアクセスできます。
例えば、4バイト目にfloatの3次元ベクトルが存在するとすると、以下のようにアクセスすることが出来ます。
float3 v = asfloat( rwBuffer1.Load3(4) );
v *= 2.0;
rwBuffer1.Store3( 4, asuint(v) );
Load や Store の第1引数はバイト数です。通常は4バイト単位でアクセスするので、4の倍数にしておきます。
また、uintでしかLoad、Storeは出来ないので、floatに直す場合やfloatからuintにする場合は as~ 命令を使用します。
今回のサンプルはパーティクルと言うこともあって RWStructuredBuffer を使用したかったのですが、VertexBufferとしても使用する場合は RWStructuredBuffer は使用できません。
ランタイムでは以下のように作成します。
D3D11_BUFFER_DESC desc;
ZeroMemory( &desc, sizeof(desc) );
desc.ByteWidth = sizeof(Particle) * maxParticles;
desc.Usage = D3D11_USAGE_DEFAULT;
desc.BindFlags = D3D11_BIND_VERTEX_BUFFER | D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_UNORDERED_ACCESS;
desc.CPUAccessFlags = 0;
desc.MiscFlags = D3D11_RESOURCE_MISC_BUFFER_ALLOW_RAW_VIEWS;
// desc.MiscFlags = D3D11_RESOURCE_MISC_BUFFER_STRUCTURED; // RWStructuredBufferを作成する場合はこっち
// desc.StructureByteStride = sizeof(Particle); // RWStructuredBufferなら構造体のサイズを指定する
pDev->CreateBuffer( &desc, NULL, &m_pVertexBuffer );
Usage に D3D11_USAGE_DEFAULT を、BindFlags に D3D11_BIND_UNORDERED_ACCESS を、MiscFlags に D3D11_RESOURCE_MISC_BUFFER_ALLOW_RAW_VIEWS を指定します。
RWStructuredBuffer の場合は MiscFlags に D3D11_RESOURCE_MISC_BUFFER_STRUCTURED を指定します。
D3D11_USEGE_DEFAULT を指定したバッファはCPUからアクセスすることが出来ませんが、RWバッファとして使うには必須です。
CPU側でバッファの内容を使用したい、もしくはCPU側でバッファの内容を書き換えたいという場合は Usage に D3D11_USAGE_STAGING を指定したバッファを通して行う必要があります。
StagingバッファはCPUからアクセスすることが可能で、ID3D11DeviceContext::CopyResource() もしくは ID3D11DeviceContext::CopySubresourceRegion() で全体/部分コピーが可能です。
RWバッファのビューは以下のように作成します。
D3D11_UNORDERED_ACCESS_VIEW_DESC uavDesc;
ZeroMemory( &uavDesc, sizeof(uavDesc) );
uavDesc.Format = DXGI_FORMAT_R32_TYPELESS;
uavDesc.ViewDimension = D3D11_UAV_DIMENSION_BUFFER;
uavDesc.Buffer.FirstElement = 0;
uavDesc.Buffer.NumElements = 0;
uavDesc.Buffer.Flags = D3D11_BUFFER_UAV_FLAG_RAW;
pDev->CreateUnorderedAccessView( m_pVertexBuffer, &uavDesc, &m_pVertexView );
作成するバッファのビューは ID3D11UnorderedAccessView です。
RWByteAddressBuffer の場合、 Flags には D3D11_BUFFER_UAV_FLAG_RAW を指定します。
RWバッファの解説はこれで完璧というわけではありませんが、今回必要な部分はこの程度ですので次に行きます。
次はスレッドグループです。これは、複数のスレッドをまとめたもの、という言い方が正しいかどうかは微妙ですが、まあそんな感じのものです。
シェーダコードではCompute Shaderを実行するスレッドの1グループ分の数を指定しなければなりません。
Compute Shaderの関数の直前に以下のように記述します。
[numthreads(4, 4, 1)]
void DispatchCS( uint3 did : SV_DispatchThreadID )
{
...
}
numthreads が Compute Shader 1回分のスレッドを意味しています。
上記の例では 4*4*1 = 16 スレッドが1グループで処理される数ということになります。
これは 16*1*1 = 16 と全く同等ですが、2次元、3次元でバッファにアクセスしたい場合は 4*4*1 の方がわかりやすく、そしてアクセスしやすくなります。
最大のスレッド数はシェーダバージョン4.0で786個、シェーダバージョン5.0で1024個です。
また、4.0の場合はZが必ず 1 になり、5.0では64が最大値です。つまり、4*4*4 は4.0では使用できず、5.0では使用できるということになります。
この値はシェーダごとに固定値になってしまいますが、あくまでもスレッドグループ1つのスレッド数です。
つまり、スレッドグループを複数立ち上げてやればいいわけです。こちらはランタイムで Compute Shader を実行する際に指定します。
pD3DContext->Dispatch( 100, 100, 1 );
こちらもXYZの3次元で指定しますので、最終的には6次元のグリッド状のものになるというわけです。
ここでのXYZは64k未満でなければならないようですが、シェーダバージョン4.0の場合はやっぱりZが1である必要があります。
Compute Shader で処理されるスレッドが何番のスレッドなのか取得することが出来ますが、これにはいくつかの異なる引数を指定できます。
例として、numthreads で (X, Y, Z) と指定されており、Dispatch で (X', Y', Z') として実行されたとしましょう。
このとき、(x, y, z) 番目のスレッド、(x', y', z') 番目のグループの場合は各引数で以下のような計算が行われます。
SV_GroupIndex のみ uint で、他はすべて uint3 です。
これらは正直なところ、慣れるまでは非常にわかりにくいです。2次元、3次元でアクセスする必要性がない場合、Xのみを利用した1次元で指定しておくのが簡単です。
今回のサンプルでも1次元でのアクセスしかしていません。
numthreads で (1,1,1) と指定し、Dispatch() メソッドで制御するのもありですが、グループごとに共有メモリが使えるという利点もあるので、共有メモリを使いたい場合はおすすめできません。
共有メモリについては今回は使用していないので解説しませんが、たぶんそのうち解説すると思います。
なお、スレッドグループについてはDirectXのヘルプがわかりやすいと思います。numthreads か Dispatch で調べてみてください。
ではサンプルの Compute Shader を見ていきましょう。と言っても、難しいものではありません。
RWByteAddressBuffer rwbParticle : register( u0 );
[numthreads(100, 1, 1)]
void DispatchCS( uint3 did : SV_DispatchThreadID )
{
// パーティクルの通し番号を求める
uint index = did.z * 100 * 1 + did.y * 100 + did.x;
// パーティクル計算
const uint vbIndex = index * 40; // 頂点データの先頭アドレスインデックス
float3 pos = asfloat( rwbParticle.Load3(vbIndex + 0) );
float3 vel = asfloat( rwbParticle.Load3(vbIndex + 16) );
float angle = asfloat( rwbParticle.Load(vbIndex + 28) );
float dAngle = asfloat( rwbParticle.Load(vbIndex + 32) );
pos += vel;
vel.y -= gravity;
angle += dAngle;
rwbParticle.Store3( vbIndex + 0, asuint(pos) );
rwbParticle.Store3( vbIndex + 16, asuint(vel) );
rwbParticle.Store( vbIndex + 28, asuint(dAngle) );
}
サンプルでは1万発のパーティクルを処理しています。
スレッドグループは100スレッドから成っており、これを100グループ作成しています。
パーティクルは速度の分だけ移動し、重力加速度に従い落下していきます。回転角度はZ軸の回転角度です。
パーティクルの生成は1フレームに1発ずつで、これはCPUで行っています。
寿命の管理はしておらず、1フレームずつパーティクルのインデックスを進めていくという、Compute Shader に有利と思われる処理を行っています。
そのため、このシェーダでは座標の移動、重力加速度による速度の変化、角度の変更だけが行われています。難しくありませんよね?
サンプルではこれとは別に、パーティクル生成を Geometry Shader を使って生成しています。
DirectX9までであれば、パーティクル計算の後にパーティクルの位置から4頂点を生成し、これを利用して描画するというようなことをしていたはずです。
Geometry Shader を使用することで1頂点から4頂点を生成することが出来ます。
サンプルは添付ファイルから取得してください。
今回は3つのシーンを用意してあります。テンキーの1~3でそれぞれシーン0~2に切り替えることが出来ます。
シーン0 は従来通りの方法で、CPUでパーティクル計算を行い、CPUで4頂点を生成して描画する方法です。
シーン1はパーティクル計算はCPUで行うものの、頂点バッファはパーティクルの1頂点のみにし、Geometry Shader で4頂点を生成する方法です。
シーン2はGPUでパーティクルを計算し、その結果をそのまま頂点バッファとして、Geometry Shader で4頂点を生成する方法です。
スペースキーを押すと500フレーム分の処理を計測し、その平均をデバッグウィンドウに出力します。
私のPC(Core i5, Radeon5870)では以下のような結果になりました。
数値はすべてマイクロ秒です。
残念ながら、シーン1が最も高速でした。
描画だけなら頂点生成を行っていないシーン2が高速ですが、そもそものパーティクル計算が遅い。
原因はたぶんCPU~GPU間のやりとりでしょう。10000発のパーティクルを簡単に計算するだけならCPUでやった方が高速だということです。
単純にRadeon5870はCompute Shaderが遅いだけかもしれませんが。
しかし、シーン0の頂点バッファ生成が非常に重いですね。これをGeometry Shaderに逃がすのが最も高速化に貢献しています。
シーン1の頂点バッファ生成はパーティクルの位置と角度をコピーするだけなのに対して、シーン0ではsin, cos計算なんかもやっていますし、ベクトル・行列計算も行っていますので。
より複雑な計算(例えば物理演算や衝突判定など)をやらせたら速いんですかね?
とりあえず、Compute Shader を使った方が速くなるようなものを探してみましょうかねぇ。