DirectXの話 第188

テクスチャストリーミング

23/10/22 up

Skull and Bones におけるカメラ依存のテクスチャストリーミング

CEDEC2023でUbisoftの方が同社タイトル「Skull and Bones」で実装したテクスチャストリーミングの実例をお話されていました。
プラットフォームやAPIに制限されない実装を非常にわかりやすく解説されていました。
Cedilにて資料が公開されていますので、詳細についてはこちらをご覧ください。

https://cedil.cesa.or.jp/cedil_sessions/view/2803

今回はこちらで解説された内容の簡易バージョンを実装してみた次第です。

テクスチャストリーミングで考えなければならないこと

テクスチャストリーミングの実装はなかなか大変で、考えなければならないことがいくつかあると考えます。

1つはメモリ管理。だいたいのストリーミングシステムは利用できるメモリを制限します。
予めストリーマーが使用できるメモリを固定することで少ない資源であるVRAMを効率よく扱うことができます。
VRAMの枯渇はWindows PC上ではパフォーマンスが大幅に悪くなりますし、コンソールではメモリ不足によるクラッシュが発生します。
このような問題を避けるためにも適切なメモリ管理が必要ですが、今回のサンプルでは対応していません。今後対応したいとは思いますが…

ストリーミングの優先度も問題になります。
プレイヤーキャラより背景の小さなプロップのテクスチャの解像度が高い、というのは問題です。
アーティストが作成したテクスチャアセットはどちらも4K解像度だとしても、優先度の低い小さなプロップの方は低くしても問題ありません。
この問題については、アセットに何らかの優先度を示す情報を付与するのが一般的だと思います。
UEの場合はテクスチャのグループが存在し、グループによってストリーミングの有無や優先度を切り替えていたはずです。
今回のサンプルではこの問題にも対応していません。すべてのテクスチャが普通の背景テクスチャであるためです。

APIの差をどのように吸収するかも問題になります。
メモリ管理やテクスチャオブジェクトの入れ替えなんかはAPIによって結構差が出てきます。
1つのプラットフォームで動かすだけなら比較的簡単ですが、マルチプラットフォームが重要なゲームエンジンでは必ず対応しなければならない問題です。

そしてストリーミングすべきテクスチャのミップレベルをどのように選択するかという問題です。
優先度やメモリ管理にも関わってくる問題ではありますが、少なくともそのシーンにおいて必要なミップレベルを計測する方法は必要です。
簡単な実装であれば、オブジェクトとカメラの距離、カメラに映るオブジェクトの大まかな面積によってミップレベルを計算する方法があります。
この方法はCPUで計算可能ですので、そのまま直接ストリーミングまで進められるという利点もありますが、マテリアルを自由に作成できるUEのような仕組みではUVのスケールがサンプルされるテクスチャによって違ったりすることもあります。
そのような多くのバリエーションで正常に動作させるのはなかなか難しく、やはり実際に描画されているミップレベルを計算したいわけです。
今回の実装はまさにその部分を中心に行っています。しかもSampler FeedbackのようなGPUによって制限される可能性が非常に低いです。
Compute Shaderが使用可能で、Pixel ShaderでUAVが利用できれば問題ないので、D3D11世代のGPUでも問題なく実装可能です。

サンプルプログラムについて

今回のサンプルプログラムはVisibility Bufferのプロジェクトを利用しています。
通常のDeferred RenderingとVisibility Buffer Renderingのどちらでも動作するように実装しています。

https://github.com/Monsho/VisibilityBuffer

テクスチャストリーミングを利用するには専用のリソースとしてIntel Sponzaを用意していますが、ファイルサイズが大きくてGitHubに直接コミットできないため、別途Dropboxにファイルを置いてあります。
詳細はReadmeを参照してください。

メニューのDebug項目にTexture StreamingのON/OFFフラグがあります。
デフォルトはONで、OFFにするとストリーミングが停止します。
停止するだけですので、現在のミップレベルで固定されます。

また、Miplevel Printのボタンを押すとデバッグ出力に各テクスチャセットのミップレベルを表示します。
Tail Mipsの最大サイズは256ですので、ミップレベルは0~4で表示されるようになっています。
テクスチャ名は代表となるベースカラーテクスチャの名前が表示されます。

テクスチャリソースの新しいフォーマット

今回の実装を行う上で、テクスチャリソース用の新しいフォーマットを作成しました。

テクスチャリソースは前述のセッションでも語られていますが、Top MipsTail Mipsに分けられます。
Top Mipsは解像度の高い、レベルの低いミップレベルで、これらはミップレベルごとに1つのファイルで保存されます。
Tail Mipsは解像度の低い、レベルの高いミップレベルで、これらは常に読み込まれるテクスチャです。
Tail Mipsはヘッダと一緒に1つのファイルとして保存されています。
リソースの中で、.stexという拡張子がヘッダ+Tail Mipsのファイル、.stex00などの拡張子の後に数字が入っているのがTop Mipsのミップレベルごとの生のテクスチャリソースです。

テクスチャリソースの変換コードはglTFtoMeshツールに含まれています。
コンバータを利用する際に -stex オプションを利用することでテクスチャをストリーミング用に変換することができます。
"-stex 256" のように指定すると、256以下の解像度をTail Mipsとし、それより低いミップレベルはTop Mipsとします。

また、ランタイムのResourceItemクラスも新しく作成しています。
これまでのテクスチャはResourceItemTextureクラスのみでしたが、ストリーミング/ノンストリーミングテクスチャを使用する側が区別しなくても済むようにResourceItemTextureBaseクラスを用意し、オブジェクト取得系の命令をインターフェースとして用意しています。
ノンストリーミングは以前と同様にResourceItemTexture、ストリーミングはResourceItemStreamingTextureとなります。
ResourceItemStreamingTextureにはミップレベルを変更する命令があり、この命令内でファイルを読み込んでいます。
この命令はストリーマーが呼び出すことになります。

テクスチャオブジェクトはCommitted Resourceとして作成されるため、ミップレベルが変更される際には新しいオブジェクトを作成します。
すでに読み込まれているミップレベルは新しいオブジェクトにコピーされ、追加されるミップレベルのみファイルを読み込んでコピーします。
ミップレベルが下る(解像度が上がる)場合は一気にレベルを下げることができますが、上がる場合は1レベルまでしか上がらないようにしています。
コピー命令が発行されるため、コピー用のコマンドがシステムにロードされ、コマンド完了後に新しいテクスチャオブジェクトが利用できるようになります。
詳しくは ResourceItemStreamingTexture::ChangeMiplevel() 関数を御覧ください。

TextureStreamerクラス

TextureStreamerクラスはテクスチャストリーミングを実行可能なクラスです。
ResourceLoaderクラスとは別にスレッドを立てて非同期にストリーミング処理を行います。

現在はResourceLoaderと組み合わさっているわけではなく、自前でストリーミングテクスチャリソースを登録しなければなりません。
本来ならマテリアルシステムなんかと合わせて実装すべきかと思っていますが、現在のサンプル用ライブラリにはマテリアルシステムが存在しないので登録制にしています。

登録する際は複数のテクスチャリソースを一度に登録できます。
登録された複数のテクスチャリソースはStreamTextureSetと呼ばれ、ストリーミングリクエストはセットごとに行います。
同じマテリアルで使用されているテクスチャはベースカラー、ノーマルなどがだいたい同じサイズですし、同じUVでサンプルされるので、必要なミップレベルは同じになるはずです。
なので同じマテリアルで使用されるテクスチャリソースをセットで登録します。

今回は1マテリアルにつき1セットとなっていますが、マテリアルの作り方によっては複数のUV/UVスケールが使われることもあります。
Ubisoftの実装では最大4UVまで対応しているそうですし、実用する場合は同様の複数UV対応が必要になるでしょう。

ミップレベルのフィードバック

ミップレベルのフィードバックはマテリアルの描画時に行います。
Deferred Renderingの場合はGBufferに描画する際に、Visibility Buffer Renderingの場合はMaterialTile描画時にフィードバック用のUAVへと書き込みます。

このUAVはGBufferの縦横1/4サイズのバッファです。
UAVへ書き込まれるのは描画されるマテリアルのインデックスとミップレベルです。
ミップレベルは使用されるUV値から計算されますが、最大解像度は4096としてミップレベルが計算されます。

UAVは1/16サイズですのですべてのピクセルの情報を書き込めるわけではありません。
1フレームにつき4x4の中から1ピクセルだけを書き込みます。もちろん、毎フレーム書き込むピクセルインデックスを変更します。
この処理はGBufferへの書き込みシェーダで行われるので、mesh.p.hlslmaterial_tile.p.hlslで処理されています。
ミップレベルはUVの勾配から求めています。

GBufferへの描画完了後、UAVをそのまま利用せずに各マテリアルの最小ミップレベルをCompute Shaderで求めます。
マテリアル数分のuint32バッファにInterlockedMinでミップレベルを格納していきます。
シェーダはmiplevel_feedback.c.hlslとなります。ClearCS命令でバッファをクリアし、FeedbackCS命令でミップレベルの格納を行います。

Pixel ShaderでInterlockedMin命令で直接格納することも可能ではあるのですが、パフォーマンス面で問題が出る可能性が高いです。
また、フィードバック用のCompute Shaderはその後にリードバックバッファへのコピーをするだけでGPUで利用することはありません。
ですのでコンピュートキューでの処理が可能です。PSでのUAV書き込みの負荷が十分に低ければパフォーマンスに大きな問題は出ないでしょう。
まあ、コンピュートキューがいっぱいになるくらいまで使用されている場合はその限りではありませんが。

ストリーミングリクエスト

リードバックバッファに書き込まれたミップレベルは2フレーム後にCPUで読み込み、各マテリアルの要求最小ミップレベルを取得します。
しかし、テクスチャストリーミングのリクエストは要求レベルを取得した段階では行いません。

ピクセルインデックスは16フレームで一周するため、1フレームの結果をそのまま利用してしまうとフレームごとに最小ミップレベルが変わってしまう可能性があります。
今回のサンプルでは30フレームの時間を待ち、その際の最小ミップレベルを選択するようにしています。
要求レベルが現在のレベルと同一である場合は30フレームのカウントをやり直します。
また、直近の要求レベルがリクエストを出す最小ミップレベルから大きくズレている場合は再度カウントをし直すようにしています。
これによって頻繁なストリーミングリクエストが発生しないようにします。

これらの処理は SampleApplication::ManageTextureStream() 関数で制御しています。
良い実装とは言えないのですが、必要な結果は十分に得られています。

その他の話

このストリーミングシステムを実装する上でマテリアルリストが必要になります。
Deferred Renderingの場合はマテリアルリスト的なものは不要ですが、Visibility BufferのようなDeferred Material Renderingの場合はそもそもの描画システム的にマテリアルリストが必要になります。
実際、サンプルのVisibility Bufferではマテリアルリストを作成していました。
しかしDeferred Renderingでは実装していなかったので、今回の実装に合わせて共通処理としてフレームのマテリアルリストを作成するようにしています。

また、以前のDeferred Renderingでは深度プレパスが存在しませんでした。
しかしこれによってPixel ShaderでのUAV書き込みが問題になりました。
深度プレパスがないため、Pixel Shaderの起動順番でUAVへの書き込みが行われてしまいます。
当然描画されていないピクセルの結果がUAVに書き込まれてしまうこともあるため、深度プレパスは絶対に必要になります。

本手法の問題

本手法はカメラに映っていないテクスチャのミップレベルはどんどん上がっていくことになります。
カットシーンなどでカメラのカットが切り替わった際に近くのオブジェクトのテクスチャ解像度が低く表示されることがあります。
これが初めてのカットインであればそこまで問題ないのですが、2カット前に映っていたものだったりするとユーザーからはあまり良い印象を持たれないかもしれません。
カメラからの距離だけでミップレベルを計算する手法であればこの問題に対応することもできますが、本手法では良い対処法がありません。

また、半透明に対してはこの手法は使えません。別の手法で計算する必要があります。
半透明はストリーミングしないというのであれば問題ないですが、ストリーミング対応するのであれば別の手法を用意する必要があります。
複数のシステムがあるのは実装側としても面倒ですし、ユーザー側にも考慮しなければならない項目を増やすことになります。
それであればCPU側で、というのもありかもしれません。
ただ、Ubisoftさんのセッションによれば、この手法を実装したことで使用メモリ量を減らすことができ、その上で解像度の改善も行われたそうです。

とはいえ、本手法は実装しやすく、表示範囲という点では良い結果を得られる手法だと思います。
APIやプラットフォームの制限を受けにくいのも良い点です。
個人的には制約を考慮しても良い手法だと感じます。