アヒルのある日

株式会社AHIRUの社員ブログです。毎週更新!社員が自由に思いついたことを書きます。

DirectX12の描画フロー(基礎編)

こんにちは、みにくい社長です。
昨今は汎用ゲームエンジンが使える為、DirectXで描画エンジンを実装した経験のある人も少なくなってきていると思います。
実際にwebで検索してみても、DX12よりもDX11やDx9の方が記事が多く、DX12の実装では調べてもわからないことが多くて困ります。
DX12は書籍も少なく、(国内では)ほとんど基礎的なことしか書かれていないものしかありません。
基本実装がわかれば応用もできる、と言いたいところですが、ゲームプログラムは複雑で最適化が必要な為、かなりの試行錯誤が必要になります。
今回は描画フローの実装と最適化についてお話したいと思います。
概念を書きますので、基本的な実装については書籍や他のサイトを参考にしてください。

マルチバッファ

ゲームのレンダリングでは、画面に表示するバックバッファをダブルバッファ、もしくはトリプルバッファとし、毎フレーム描画するバッファを切り替えます。
トリプルバッファリングを行う場合、バッファ0~2を0, 1, 2, 0, 1, 2...という順番で切り替えて描画します。まず、メインスレッド(もしくはワーカースレッド)でバッファ0に描画するための描画パケットを作成します。その後、描画スレッドがそのパケットをソートし、GPUに送るためのコマンドバッファに積みます。積み終わったら、GPUにコマンドを送信します。描画パケットを作成したメインスレッドは描画スレッドに仕事を引き継ぎ、次のフレーム(バッファ1)の処理に移ります。描画スレッドもGPUにコマンドを送信したら、次のフレームのコマンドバッファを積み始めます。ただし、GPUが描画を完了するまではGPUに次のコマンドを送信することはできません。
このような流れ作業でバッファを切り替えながら描画していきますが、60フレームのゲームの場合は60/1000=16.66..ミリ秒以内にそれぞれの処理を終えなければ、処理落ちが発生します。処理落ちが発生した場合は、フレームレートを下げたり、フレームスキップを行うことになります。

GPUに必要なリソース

DX12で描画を行うのに必要なリソースは以下になります。
- デバイス(ID3D12Device)
- コマンドリスト(ID3D12GraphicsCommandList)
-※コマンドアロケータ(ID3D12CommandAllocator)
-コマンドキュー(ID3D12CommandQueue)
-ディスクリプタヒープ(ID3D12DescriptorHeap)
-※転送用ディスクリプタヒープ(ID3D12DescriptorHeap)
-※※ルートシグネチャ(ID3D12RootSignature)
-※※パイプラインステート(ID3D12PipelineState)
-※※頂点レイアウト(D3D12_INPUT_ELEMENT_DESC)
-※※シェーダー(void*)
-※定数バッファ(ID3D12Resource)
-※※テクスチャ(ID3D12Resource)
-※※サンプラー(D3D12_SAMPLER_DESC)
-※※頂点バッファ(ID3D12Resource)
-※※インデックスバッファ(ID3D12Resource)
よくあるサンプルではこれらを順に生成する方法が記載されていますが、何をどこでどれぐらい管理すれば良いのかが難しく、悩みどころです。

必要なリソース数

最初にマルチバッファの話をしたのには理由があります。
マルチバッファリングをする際に、これらのリソースをバッファ数分だけ持つ必要があるものとないものがあるからです。
※が付いているものは、マルチバッファの数分だけ用意する必要があります。
※※が付いているものは、使う種類数分だけ用意する必要があります。
使う種類というのが事前にわかれば良いのですが、ゲーム開発では膨大な量のリソースが必要になるため、ほとんどが必要なタイミングで生成するしかありません。ここで管理方法が重要になります。

ルートシグネチャと転送用ディスクリプタヒープ

これはDX11までは自動化されていた、シェーダーリソースのバインド設定です。手動で行うことで最適化できるとのことですが、膨大なシェーダーを扱う際に必要数分を用意するのは現実的ではありません。そして生成コストが高く、毎回生成して破棄するわけにもいきません。
良い案としては、1つのルートシグネチャで全てに対応することです。
ディスクリプタテーブルという単位で登録できるので、テーブルをD3D12_SHADER_VISIBILITY×D3D12_DESCRIPTOR_RANGE_TYPEの数だけ登録しておきます。
ここで問題になるのが、ルートシグネチャに設定するディスクリプタテーブルは、それぞれが連続した領域でなければならないということです。
シェーダーに必要な定数バッファ、テクスチャ、サンプラーを生成する際に連続した領域に生成するとなると、シェーダーが変わるたびに再構築することになってしまいます。なぜかほとんどのサンプルはそのように作られていますが、描画する度にリソースを生成していては、全く速度が出ません。
そこで、転送用のディスクリプタヒープを別途用意し、CopyDescriptorsSimpleという関数を使って必要なものをコピーします。このコピー負荷は軽く、再度生成をしなくても連続したヒープ領域にリソース(インデックス)を並べることができます。
ちなみにリソースをコピーして並べる際にはシェーダーリフレクションの情報を参照することで、シェーダー内でレジスタの指定はしなくても正しくバインドすることができます。
また定数バッファについてですが、グローバル領域で宣言した定数は全て1つの定数バッファ扱いになりますので、その場合はMapしたバッファに対してリフレクションで得られた順序で値を書き込みましょう。
今日はここまで。

基礎編といいながら難しい内容になってしまいましたが、この辺りの文献が非常に少ないと思いますので、参考になれば幸いです。
次回は最適化編です。
では、またねー。