DirectXの話 第148回
プロデューサーシステム 17/12/02 up
今回は描画リソースを管理するプロデューサーシステムを実装してみたので、その実装について書いていきます。
まだ完成とは言えないものではありますが、とりあえず使えるレベルにはなりました。
最終的には描画パスのシステムも作成し、これと組み合わせることで柔軟な描画パスシステムを構築できるのではないかと画策しています。
・プロデューサーシステムとは?
最初に、プロデューサーシステムとは何かを簡単に解説します。
とは言っても、以下のリンクに飛んで資料を読んでもらえればわかるかとは思います。
Advanced Graphics Tech: Moving to DirectX 12: Lessons Learned
UBIの方がD3D12に移行した際の実装について語っているものです。
プロデューサーシステムは移行の際に実装されたと思われるものの1つで、描画リソース、というか主にレンダーターゲットなどを管理するシステムです。
基本的な考え方としては、レンダーターゲットの寿命を考慮し、使いまわしやステートの管理を行うシステムです。
・何故プロデューサーシステムが必要なのか?
これにはいくつか理由がありますが、自分が思いつく理由は主に2つです。
1つはターゲットバッファの使用メモリ量を減らすことです。
このサイトのようにサンプルプログラムを作成するだけであれば、必要なバッファを必要な数だけ取得しても問題はほとんどないでしょう。
しかし、コンソールゲーム機の場合はそうもいきません。
昔と比べると増えたとはいえ、メモリ量はそれほど多いわけではありません。
そして、コンソールゲーム機で商品となるゲームを作成する場合、GBufferの枚数やポストプロセスで使用するバッファなど、必要だから生成したとかやってると簡単に1GB以上使用してしまいます。
しかし、バッファには寿命があります。
レンダーターゲットとして描画した後、それをテクスチャとして使用することが多いと思いますが、ある場所でテクスチャとして使用した後は全く使用しないという場面も多くあるでしょう。
GBufferにしても、法線用のGBufferならかなり多くのパスで参照されると思いますが、その他のGBufferはあまり参照されなかったりすることもあるでしょう。
ライティング計算が終わったあとはベースカラーを保存したバッファなど無意味になるかもしれません。
これらのバッファはたいていの場合で解像度がほぼ同じ、フォーマットも同じフォーマットを使用したりするわけです。
であれば使いまわした方が効率はいいですし、使いまわさないとメモリが足りなくなることもあるでしょう。
もう1つはリソースステートです。
D3D12では描画リソースの使用方法が変更される場合にバリアを張る必要がある、ということは以前にも解説しました。
D3D12での描画リソースのバリアは、圧縮状態の展開、描画完了の保証などの理由があります。
このバリアを張るためにはリソースの現在のステートと遷移先のステートを指定する必要があります。
私のサンプル用ライブラリではテクスチャやバッファクラスが現在のステートを保存しており、遷移先のステートだけを指定してバリアを張ることができます。
しかしこの実装では解決できない大きな問題が存在しています。
それがマルチスレッド対応です。
D3D12はコマンドリストを複数スレッドで別々に積み込み処理を行うことができますが、そもそもこの複数スレッドでの処理を行わないとD3D11より遅くなることの方が大半です。
つまり、D3D12で速度を稼ぎたいなら、まず最初にやることは描画コマンドの積み込みをマルチスレッド化することです。
が、マルチスレッド化するとテクスチャやバッファオブジェクトが現在のステートを保持するという実装が問題になります。
2つ以上のスレッドで、あるテクスチャがRTV、SRVとしてそれぞれ使用されるという場面を考えましょう
使い方としては最初のパスでテクスチャをRTVとして使用→次のパスでテクスチャ(SRV)として使用するというのがよくある流れでしょう。
この2つの処理がマルチスレッドで実行された場合、RTVへのバリアとSRVへのバリアはどちらが先にコマンドに積まれるか不定ですので、バリアコマンドが積まれる瞬間のテクスチャオブジェクトが保持する現在のステートが正しいかどうかはわからなくなります。
このような問題を解決するには、ある描画リソースがあるタイミングでどのステートになっているか、次回はどのステートになっていなければならないかを厳しく管理・チェックする必要があります。
プロデューサーシステムが描画リソースの寿命を管理するのであれば、どのタイミングでどのような扱われ方をしているかも検証が可能です。
つまり、遷移前後のステートを管理することが可能というわけです。
・制約について
・描画パスの直列実行
描画パスはいくらか並列処理できるとは言え、基本的には直列な処理です。
これはつまり、グラフィクスパイプに投入する描画パスの命令は順番が決まっている、ということです。
実際の処理はある程度並列化されますが、投入順序は直列でなければなりません。
ただし、ここにコンピュートパイプが入ってくると話は別です。
残念ながら、まだ現段階では非同期コンピュートについては考慮していません。
・コマンド生成前の段階で描画パスは決定されていなければならない
現在想定している描画パスの処理は、描画パスごとに並列に描画コマンドを生成します。
レンダリングスレッドはまず、使用する描画パスから事前に描画リソースの生成、割り当てなどのコマンド生成準備を行い、その後に描画パスごとのコマンド生成をワーカースレッドで並列実行します。
すべてのコマンドが生成し終わったら、最後にすべてを集めて直列の順番に従ってサブミットする、という予定です。
そのため、ワーカースレッドにコマンド生成処理を流す前に描画パスが決定されていないといけません。
その描画パスのコマンド生成処理に来た時に使用するかしないかをフラグでチェックする、というような実装はご法度です。
・描画リソースの使用用途は明確に
描画パスを単体で作った場合、それ以前にどのような描画パスが走っているか、またそのあとにどの描画パスが走るかは想定されないことが多いです。
ポストプロセスの類は特にそうで、前回の描画結果だけを取得して処理を行い、やはり結果だけを流すというやり方が多いでしょう。
しかし、明確な使用用途のある描画リソースも存在します。GBufferや深度バッファはその典型でしょう。
ベースパスで描画されたGBufferの法線情報を別の場所で使いたいという場合に、そもそもどのバッファにその情報が保持されているかを描画パス設計者が知らない、ということはないはずです。
これらのバッファは、どの描画パスの出力何番を使う、という実装より、明確なIDを割り振った方が簡単でわかりやすいだろうと考えました。
そのため、プロデューサーシステムを使う場合には描画リソースを以下のように区別しました。
明確にIDを割り振られている描画リソース(GBufferなど)
前回の描画パスの出力(複数出力もあるので番号指定)
一時リソース(その描画パス内でのみ使用される)
前回の描画パスの出力は、次の描画パスでのみ使用が可能となります。
もしも次に連なっていない、遠くの描画パスで使用されるリソースの場合は明確にIDを割り当ててもらいます。
描画パス自体にIDを割り当て、指定描画パスの出力何番を使う、という手段もあるのですが、今回はリソース自体にIDを割り当てる方法を採用しました。
特にヒストリーバッファへの対応がこの手法じゃないと厳しそうな印象があったので。
ただし、現在はヒストリーバッファには対応していません。のちに対応予定です。
・実装について
サンプルはGitHubに上がっています。
Sample007にある render_resource_manager.h, .cpp がプロデューサーシステムの実装となります。
実装はまあ、結構泥臭くやってます。
パスグラフを作成して、みたいな感じにはしておらず、直列の描画パスなのでリソースがどの描画パスでレンダリングされ、どの描画パスで使用されるかを描画パスを順次チェックして状態を保存するだけです。
これによって使用される描画リソースの数も、寿命も、ステートもチェックできる状態になります。
寿命がはっきりするので寿命が重なっていないリソースの中でフォーマットやサイズなどが同じリソースを検索、使用可能ならそのまま使用するという形で実装しています。
D3D12ではPlaced Resourceというものがあり、予めヒープを取得しておいて描画リソースに必要なサイズを自前で割り当てるという手法も存在しています。
今回こちらを使わなかったのは、この命令を使ったことがないからというのと、D3D11でも使えるように拡張することを考慮した結果です。
使用方法はSample007の main.cpp 内、InitializeRenderResource() 命令で初期化を行っています。
まずは各パスごとにプロデューサーを用意します。各プロデューサーが必要とする入力リソース、出力リソース、一時リソースの数を設定します。
次に各プロデューサーが必要とするリソースのフォーマットやサイズを決定し、プロデューサーに登録します。
最後に RenderResourceManager オブジェクトを初期化、プロデューサーを渡してリソース生成を行えば初期化は完了です。
なお、描画リソースのサイズを基本的に直接指定ではなく、スクリーンサイズに対する割合で処理している理由は画面リサイズへの将来的な対応のためです。
描画リソースの使用方法はまだ泥臭い状態です。
各パスごとに入力・出力リソースを取得し、必要であればバリアを張り、必要であればクリアし、レンダーターゲットとして設定して描画を行います。
バリアは基本的に各パスの先頭で行えば問題ないのですが、これについては自動化も可能なはずなので、描画パスシステムを設計した段階で自動化しようと思っています。
画面クリアやレンダーターゲットとしての設定は基本的に描画パス側に任せようとは思います。
・未実装部分について
まだ予定されているのですが、未実装の部分も存在します。
ヒストリーバッファ対応
スワップチェインへの描画
ヒストリーバッファは対応予定ですが、どんな実装にするかまだ決めてません。
ある程度構想はあるので、たぶん実装には問題ないと思いますが、試してみないと何とも…
スワップチェインへの描画は現在は描画パス側で無理やり対応していますが、きちんとした実装を行いたいところです。
こちらについても早めに対応したいとは思っています。
・実装してみての感想
実装そのものはさほど難しくはないのですが、やはり設計段階でいろいろ問題が出ました。
特にリソースID導入までにはいろいろコネコネしてみたのですが、描画パスそれぞれを完全に独立して実装し、それを何も考えずに接続していい感じに描画リソースを解決してくれる方法は思いつきませんでした。
そもそもGBufferの内容はアプリによって変わることもありますし、あるパスが入力として必要とするリソースを生成するパスが存在しなかったらどうするよ?といった問題もありました。
結局、その辺のことを考えると、アプリ用の描画パスを設計する人が必要なリソースを知らないなんてことはないだろう、という結論に至りました。
それならIDを登録してもらい、その寿命から求められるパズルの解答だけを提供するシステムにした方がいいのでは?と思ってこういう実装になりました。
もっとスマートな方法もあるのかもしれませんが、自分の限界はここまでです。
もし、もっといい方法があるぜよ、という方がいたら教えていただきたいです。はい。