DirectXの話 第194

Reserved Resourceを利用したテクスチャストリーミング

24/06/01 up

第188回でテクスチャストリーミングを実装してみましたが、今回はそのテクスチャリソースをReserved Resourceで実装し直し、メモリ管理もしてみようというお話です。
なお、ベースとなっているのはGDC24にて講演された DX12 Memory Management in Snowdrop on PC です。
UBIのSnowdropエンジンで実装されたPC用のメモリ管理についての講演で、GDC Vaultでスライドは無料公開されています。

メモリマッピングから見る3種のリソース

D3D12にはメモリマッピングの観点からすると3種類のリソースが存在します。

1つ目は Committed Resource。ユーザー側で特にメモリ管理を必要としないリソースです。
このリソースはリソースとして作成すると自動的に必要なヒープを作成し、リソースに割り当てます。
簡単に取り扱うことができますが、メモリ管理という観点では扱いづらく、ヒープの使い回しもできません。
基本的には常駐テクスチャのような、寿命の長いリソースに利用するのが一般的です。

2つ目は Placed Resource。こちらはユーザーがヒープを作成し、任意の部分を割り当てる必要のあるリソースです。
このリソースの利点はユーザーがメモリ管理を可能とする点で、欠点はユーザーがメモリ管理をしなければならないという点です。
また、生成・破棄が Committed Resource と比べると高速であるという利点もあります。
ヒープの任意の場所を割り当てることができるので、メモリのオーバーラップも可能です。
ただし、ヒープは連続的な場所を割り当てる必要があるので、テクスチャストリーミングで利用するとCommitted Resourceとあまり変わらない実装になってしまいます。
1フレーム内で寿命が終了する描画ターゲットに利用すると扱いやすいですね。

3つ目が Reserved Resource。今回の本題で、Tiled Resourceとも呼ばれます。
このリソースはPlaced Resourceと同様にユーザーがヒープを生成して割り当てますが、一定の単位でヒープの場所やヒープそのものを切り替えて扱うことができ、また、一定の単位でメモリを割り当てないようにすることも可能です。
割当単位はタイル状で、ミップレベルごと、かつ一定サイズのタイルごととなります。
この仕組みはまさにテクスチャストリーミングで有利になりますが、他にもVirtual Textureのような技術でも利用しやすいです。

この3つのリソースを図示すると以下のようになります。

Placed Resourceでは、リソース生成時にヒープとオフセット値を渡す必要があります。
それに対してReserved Resourceはリソース生成時にヒープを必要とせず、後で割り当てることになります。
割当命令は ID3D12CommandQueue::UpdateTileMappings() 関数です。
コマンドバッファを用いてGPU側で割当を行うわけではなく、CPUで割当を行う点に注意してください。
順序をミスると正常に描画されないことがあります。

Reserved Resourceの特徴

Reserved Resourceはタイルのサイズを64KiBとしていて、この単位で割当を行えます。
1ピクセル4バイトのテクスチャであれば、128x128 でちょうど64KiBとなりますので、このミップレベルは1タイルということになります。
これが256x256のミップレベルであれば4タイルとなり、それぞれでヒープを割り当てることができます。
もちろん、4タイル分のヒープを生成し、それを4タイルに連続メモリとして割り当てることも可能です。

では、64x64以下のミップレベルはどうなるでしょうか?
ミップレベルごとに64KiB取っていくことになってしまうと、1x1や2x2のミップレベルでもそれぞれ64KiB確保されてしまい、無駄が出てきてしまいます。
Reserved Resourceでは小さなサイズのミップレベルについては Packed Mip という設定を行っており、Packed Mipに適用されるミップレベルに対しては1つのタイルを割り当てることになります。
つまり、64x64より小さなサイズのミップレベルはヒープの分離ができません。

D3D12でどのサイズからPacked Mipになるのか、各ミップレベルでタイル数がいくつか、といった情報は以下のような方法で取得できます。

Standard Mipsがパックされていないミップレベル、つまり、ミップレベルごとに1つ以上のタイルが割り当てられているミップレベルを示します。
なお、Packed Mipが使われるかどうか、どのレベルからPacked Mipが使われるかはGPUによって異なる可能性があります。
今回、私のコードはNVIDIAのRTX上での動作を想定しており、AMDやIntelでも同じPacked Mipが使われることを保証していない点に注意が必要です。

また、Reserved Resourceはリソースとしてはフルミップレベルを生成しているのと同じで、メモリを割り当てられているかどうかにかかわらずフルミップレベルのSRVを生成することができます。
このSRVを利用してテクスチャをサンプリングするとどうなるでしょう?
NVIDIA RTX上ではクラッシュはしませんでしたが、未割り当てのミップからサンプリングした結果は真っ黒になりました。
他メーカーの場合はどうかはわかりませんが、コンソールハードの場合はクラッシュする可能性もあるのではないかと思います。

この問題を回避するには、割当されているミップレベルをベースとしたSRVに変更するか、シェーダ内で最小ミップレベルを考慮してサンプリングしなければなりません。
特に同じミップレベルでもタイルごとに割り当ての有無が違っている場合はシェーダで判断するしかありません。
今回の実装ではミップレベル単位でしか割り当てないので、SRVを生成し直すという手法を用いました。

メモリマネージャの実装

GDC24の資料によると、Snowdropエンジンは描画グラフ的な機能が存在せず、そのため1フレームあたりに消費される描画ターゲットなどの総容量を確認する方法がなかったそうです。
最近のPCゲームでは、グラフィッククオリティを調整する画面で使用される想定VRAM量を表示するゲームもあります。
これらのゲームは描画グラフによって1フレームで必要となるメモリがわかるので、それと常駐リソース、そしてGPUのVRAMサイズに応じてテクスチャストリーミングのメモリプールサイズを求めることができます。
Snowdropはそれを確認する手段がなかったため、適切なプールサイズを求めることができなかったようです。

そこで、常にVRAMの使用状況を管理し、VRAMの残りサイズが一定値を下回った際にストリーミングのプールを適宜解放するという手法を用いたようです。
しかしここでCommitted ResourceやPlaced Resourceを用いた場合の問題が出てきます。

Committed Resourceではミップレベルをリソース側で変更しない限りヒープはリソースが取得したままです。
ミップレベルを上げてサイズを小さくするには別のCommitted Resourceを作成するしかありません。
しかも描画に利用されているテクスチャであれば、ミップレベルを上げる前のリソースも一時的に保持し、新しいリソースに対して必要なミップをコピー、その後に削除となります。
残りメモリが少ない状態でリソースが多重に存在するのは明らかなリスクとなります。

Placed Resourceでは連続したヒープが必要になるため、新しいリソースを生成してヒープの割当位置を上げたミップレベルの分だけ移動したとしても、削除したいミップレベルのヒープは残ったままです。
これを避けるには別のヒープを用意してそちらにコピーして…というCommitted Resourceと同じ問題を抱えることになります。

では、Reserved Resourceを利用すれば無条件に対応できるのか?というと、そうとも言えません。
Placed Resourceのように空いたヒープ領域を単純に割り当てる方法では、以下の図のように複数のテクスチャの異なるミップレベルにヒープが割り当てられてしまうという問題が生じます。

このように割当されてしまっているとすると、すべてのテクスチャリソースの低レベルミップを削除したとしてもヒープは解放できません。

そこで、Snowdropでは1つのヒープサイズを64MiBに限定し、それぞれのヒープには同一サイズのミップしか割り当てないようにしました。
つまり、128x128はこのヒープを、256x256はこのヒープを、というように分けて管理するようにしたのです。
これによってバラバラに確保されていたヒープ領域が以下の図のようにスッキリとしました。

さて、メモリマネージャはメモリが足りないとなった場合にどうするかというと、もっともサイズの大きなミップレベルのヒープから削除します。
上の図であれば 256x256 用のヒープを最初に解放します。
各ヒープが自身を割り当てているリソースを知っているようにすれば、そのリソースにミップの解放を指示することができます。
こうすることでそれぞれのリソースはサイズのもっとも大きなミップレベルを解放することができ、メモリマネージャはその後にヒープを削除することができるようになります。
サイズが上位のものから解放されるため、ミップレベルが歯抜けで解放されるということもありません。

テクスチャストリーミングではミップレベルの高い、つまり容量の小さいミップレベルの方がオンメモリになりやすいです。
この手法ではすべてのヒープが64MiBとなるので、多くオンメモリになりやすい小さなミップレベルは有利です。
その点でもこの手法はテクスチャストリーミングでミップレベルが頻繁に変化する状況でも扱いやすいわけです。

サンプル実装について

では、サンプルについてです。
テクスチャストリーミングを実装しているVisibilityBufferサンプルのストリーミング周りを修正してコミットしています。

VisibilityBufferサンプル

対象コードはSampleLib12のtexture_streamer.h/.cppです。
TextureStreamAllocatorがメモリ管理を行うアロケータで、各ヒープはTextureStreamHeapクラスとなります。

以前のコードではテクスチャストリーマーはある程度のテクスチャをまとめたテクスチャセット単位で行っていましたが、テクスチャセット自体はマテリアル側の都合ですので、テクスチャストリーマーはリソース単位でストリーミング処理を行うようにしました。
また、それに伴いアプリ側で管理しているマテリアルにテクスチャセット的なものを用意し、ストリーマー側がミップレベルをいじる可能性も考慮して、リクエストを投げる際に参考にする現在のミップレベルをテクスチャリソースから取得するようにしました。

TextureStreamAllocatorについては前述のアルゴリズムを実装しているだけですので解説は行いません。
ただ、ストリーミングテクスチャリソースは第188回を見ていただくとわかるのですが、必ずロードされるTail Mipsと、ストリーミングされるTop Mipsが存在します。
今回の変更で、Tail MipsはPacked Mipとなる64KiBを考慮して決定されるようにしています。
これより低いミップレベルを指定することは可能ですが、逆に高いミップレベルを指定しても自動的にPacked Mipとなるように調整されます。
そして、Tail MipsについてはTextureStreamAllocatorは利用しません。テクスチャリソース側で別途ヒープを確保するようにしています。
Tail Mipsは必ず読み込まれる前提ですので、このような実装でも問題ないと判断しました。

サンプルではプールサイズを限定することができるようになっています。
デフォルトでは無限となっていますが、256MBに設定すると即座にミップレベルが下がるのがわかります。
その状態でもテクスチャが壊れたりはしませんので、正常動作しているでしょう。

この手法の弱点

GDCの講演ではReserved Resourceの弱点である64KiBアラインメントについても言及されています。
Reserved Resourceを利用するとヒープはタイル単位で扱わなければならず、必ず64KiBアラインメントとなってしまいます。
しかし、テクスチャがPacked Mipにも満たない小さなテクスチャや、ストリーミングによってそのような小さなテクスチャしか必要じゃないと判断された場合、64KiBのアラインメントは無駄が多くなってしまいます。

そこで、SnowdropではTail Mips的なもの小さな値も可能とし、Packed Mipに満たないレベルしか読み込まない場合は従来型のPlaced Resourceを使用しているようです。
この場合は4KiBのアラインメントを用いることができるので、64KiBより明らかに余裕が出てきます。
そしてPacked Mipを超えるような状況になるとReserved Resourceに切り替えて前述のメモリ管理を行います。

現代のゲームはテクスチャの枚数が非常に多いため、64KiBのアラインメントでも無駄が発生してしまう場面があります。
サンプル程度の場合はそこまで気にする必要がないかもしれませんが、AAAタイトルではその程度のムダも考慮したほうがいいかもしれません。