2022年8月2日火曜日

Compute Shaderで頂点バッファを事前計算

このサイトの記事を見つけて震えた。
こんなことができるのかと。

要約すると、シャドウとかで同じメッシュを1フレーム内でも複数回レンダリングするなら、それを計算シェーダで行列計算結果をキャッシュして、あとはそのまま使えばいいじゃないという話。カスケードシャドウなんか効果ありそう。

必要な要素は、頂点バッファとUAVリソースの準備と、計算シェーダの仕組み。

まず、頂点バッファのリソースについて。
現状のライブラリ内ではVertexというクラスになっている。頂点バッファとインデックスバッファをまとめて持っていて外からは操作できない。
以前もリソース関連のリファクタリングをしたんだけど、その時Vertexは除外した。

Resourceクラスは、定数バッファ、テクスチャ、レンダーターゲット、中間レンダーターゲット、深度バッファを扱える。
BeginRender、EndRender時に中間レンダーターゲット、深度バッファについてはResourceBarrierで、リソースステータスを変えてそれぞれテクスチャとして利用できるようにしていた。

頂点バッファの利用箇所はDraw時のIASetVertexBuffersに渡すだけかと思っていたけど、今回計算シェーダでシェーダリソースとして使う。
計算シェーダでStructuredBufferとして利用するために、リソースステータスをD3D12_RESOURCE_STATE_VERTEX_AND_CONSTANT_BUFFERからD3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCEに変える必要がある。
PSではD3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCEでテクスチャとして参照できるけど、CSではD3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCEにするらしい。

というわけでVertexクラスを廃止して、Resourceクラスに頂点バッファとインデックスバッファを追加した。

UAVは定数バッファと違って、決まった利用方法はなく用途別に書き込みと参照をする必要があるためResourceクラスには頂点バッファ(UAV版)として追加する。Create時にD3D12_VERTEX_BUFFER_VIEWも用意して、IASetVertexBuffersに渡せるように準備する。

計算シェーダの仕組みは通常のグラフィックスパイプラインのソースをコピーして、CreateGraphicsPipelineStateをCreateComputePipelineStateに変える。

	oDesc.Flags = D3D12_ROOT_SIGNATURE_FLAG_NONE
	|	D3D12_ROOT_SIGNATURE_FLAG_DENY_VERTEX_SHADER_ROOT_ACCESS
	|	D3D12_ROOT_SIGNATURE_FLAG_DENY_HULL_SHADER_ROOT_ACCESS
	|	D3D12_ROOT_SIGNATURE_FLAG_DENY_DOMAIN_SHADER_ROOT_ACCESS
	|	D3D12_ROOT_SIGNATURE_FLAG_DENY_GEOMETRY_SHADER_ROOT_ACCESS
	|	D3D12_ROOT_SIGNATURE_FLAG_DENY_PIXEL_SHADER_ROOT_ACCESS
	;
フラグは全部不許可にしてD3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUTもなくすと、GPUによっては最適化される模様。

いざ実行してみるとランタイムエラー地獄。
1つ1つ潰していく。

まずコマンドラインアロケータとコマンドリスト。
CreateCommandAllocator、CreateCommandListに渡す引数がD3D12_COMMAND_LIST_TYPE_DIRECTからD3D12_COMMAND_LIST_TYPE_COMPUTEにかわる。

次にルートパラメータ。
D3D12_ROOT_PARAMETER1のShaderVisibilityフラグを、SRVと、SamplerであればD3D12_SHADER_VISIBILITY_PIXELとしていたけど、CSの場合はD3D12_SHADER_VISIBILITY_ALLにする。

ヒープの設定方法。
SetGraphicsRootDescriptorTableからSetComputeRootDescriptorTableに変わる。

コマンドキュー。
CreateCommandQueueを別途用意する必要があり、D3D12_COMMAND_QUEUE_DESCのTypeをD3D12_COMMAND_LIST_TYPE_DIRECTからD3D12_COMMAND_LIST_TYPE_COMPUTEに変える。

最後に一番嵌ったのが、このエラー。
D3D12 ERROR: ID3D12CommandAllocator::Reset: A command allocator 0x00000230EAF471E0:'Unnamed ID3D12CommandAllocator Object' is being reset before previous executions associated with the allocator have completed. [ EXECUTION ERROR #552: COMMAND_ALLOCATOR_SYNC]

探してもNvidiaのドライバの問題でパッチで直るよ、ありがとうぐらいしか見つからなくて困った。
内容は、前の実行が完了してないのに次の処理するためにリセットしてるという感じで、意味がわからない。
デバッグでステップ実行しつつちょっとずつ回していくと出ないんだけど、F5押して一気に進めるとこのエラーが出続ける。

原因は、スワップチェーンのPresent後にフレームの同期を取るためMoveToNextFrameを呼んでいるけど、この中でコマンドキューにシグナルを送っている。
ComputeShader用のコマンドキューの方もシグナルを送って同期する必要があった。

ここまでは計算シェーダだけ追加して、結果には何も反映させていなかった。
やっとエラーは出なくなったので、計算シェーダで出力したUAバッファを頂点バッファとして使ってみる。

結果は真っ暗だったり、ほんの少しだけ何かが表示されたりしていた。
冒頭の記事、シェーダ関数の先頭部分[numthreads( 32, 1, 1 )]をそのまま写していた。リファレンスを確認するとプログラム側から指定するDispatchの3つの引数と、このnumthreadsの掛け算の数だけ実行されるらしい。
Dispatch(1,1,1)として、numthreads( 32,1,1 )なので、SV_DispatchThreadIDのxには0~31が渡ってくると思われる。記事では三角形1つ分の処理をしているので、頂点数は3つ。3頂点に対して32スレッドは多いけど、範囲外アクセスしても大丈夫ってことを言っていたんだな。なんで大丈夫かはわからないけど。

イマイチ2段階で指定する意味がわからない。今回のは1次元データで1000頂点分処理させたいとする。numthreadsは32スレッドにして、Dispatchに32を指定する。32×32=1024で、24回分オーバするけど範囲外アクセスは大丈夫ということでいいんだろうか?
Dispatchに渡す値の計算式は(頂点数+スレッド数-1)÷スレッド数。
このひとはオーバしない様に考慮してる。あと、Dispatchに指定できる範囲が65535までなのもこのひとの記事で知った。

1542頂点
1スレッド 29.01us
2スレッド 18.02us
4スレッド 13.18us
8スレッド 12.08us
16スレッド 11.67us
32スレッド 11.98us
64スレッド 12.19us
128スレッド 12.76us

PIXで測ってみるとIntelのGPUだとこんな感じだった。
頂点数がオーバする可能性があることと、1スレッドだと結構遅い(遅くないと思ってた)ので、シェーダの指定は16スレッドにして、1メッシュの頂点数は最大16×65535で制限をかけることにする。
そういえば1スレッドのときには問題なかったけど複数スレッドで実行すると、レンダリングしているオブジェクトがちらつくようになった。
計算シェーダが書き込んでいる途中で頂点バッファとして使っているんだろうか?
計算シェーダの最後にWaitForGPUを追加したら安定した。

参考にした記事と違っていたところが数点。
まずUAVを頂点バッファとして使う時にリソースのステータスをD3D12_RESOURCE_STATE_GENERIC_READにすると書かれているが、これに遷移させようとするとエラーになった。もともとD3D12_RESOURCE_STATE_VERTEX_AND_CONSTANT_BUFFERにしてたんだけど、気になって試してみたらだめだった。
あと、頂点バッファでfloat3で入れたデータをUAVに出力する際float4になると言う記述。
これは、他でもアライメントの関係でfloat4になってしまう場面を見てきたのでそういうものかなと思ってたんだけど、float3のまま渡すことが出来た。


今までの処理結果


頂点キャッシュの処理結果

処理結果を比べてみると、今までの方が僅かに速い。
全体で1.4081msから1.4169msに増えた。おかしい・・・。
キャッシュ対象のメッシュは全部で6回レンダリングされる。メッシュ内でパーツが6個に分かれているので、同じメッシュでDrawIndexedInstancedは36回呼ばれる。
計算シェーダでは6つ分のパーツをまとめて計算する。
DrawIndexedInstancedはかなり並列に動いている&計算シェーダで行った処理がそこまで重くないということか?
同じDrawIndexedInstancedで比べると、旧は147.92us、新は147.19usで僅かに縮まっているので、個々の処理についてはキャッシュが効いているみたいだ。もっと頂点数が増えたり、頂点シェーダでやることが増えたら効果が出てくるだろう。

計算シェーダ
	uint i = In.ID.x ;
	float4 Pos = float4( gSrc[ i ].Pos, 1.0f ) ;
	float4 Normal = float4( gSrc[ i ].Normal, 0.0f ) ;
	uint4 BoneID = gSrc[ i ].BoneID ;
	float4 Weight = gSrc[ i ].Weight ;
	float4x4 BoneTransform
		= gMesh.Bones[ BoneID[0]] * Weight[0]
		+ gMesh.Bones[ BoneID[1]] * Weight[1]
		+ gMesh.Bones[ BoneID[2]] * Weight[2]
		+ gMesh.Bones[ BoneID[3]] * Weight[3]
	;
	gDst[ i ].Pos = mul( gMesh.World, mul( BoneTransform, Pos )).xyz ;
	gDst[ i ].Normal = mul( gMesh.World, Normal ).xyz ;
	gDst[ i ].UV = gSrc[ i ].UV ;

メッシュをレンダリングするシェーダが絡むと常にBoneTransformと、gMesh.Worldの行列計算が必要だった。
キャッシュすることにより後続のデータではBoneIDとWeightをなくせる。また、ボーンなしのマップとかでも頂点データにダミーのBoneID、Weightを入れて同じシェーダでレンダリングできるように合わせていたけど、それをやめて最初からキャッシュ後と同じ形にすることが出来た。


最後に
「この記事を書いた時点ではMesh Shaderがあるのですが,いまの時点では頂点シェーダを使うシーンは多くあると思います」

と、気になる文章。メッシュシェーダなるものがある。

先は長い・・・。

0 件のコメント:

コメントを投稿