DirectXの話 第173回

Deferred Texture

20/12/20 up

今回は Deferred Texture をやってみたのでその解説を行います。

サンプルはいつものGitHubにコミット済みです。

サンプル

Deferred Textureとは?

Deferred Texture はあまり聞き慣れない言葉かもしれませんが、Deferred Rendering の亜種となる特殊なレンダリング手法です。

基本的にはスタンダードな Deferred Rendering と同じくライティングを遅延させますが、それだけではなくテクスチャサンプリング自体も遅延させるというのがこの手法のキモです。

スタンダードな Deferred Rendering ではさまざまな情報を G-Buffer に保存します。

G-Buffer は元々 Geometry Buffer という言葉の省略形ではあるのですが、実際に書き込まれる情報はジオメトリ情報として深度とノーマルはあるとしても、ベースカラーやラフネスなどはジオメトリ情報というよりマテリアル情報では?と思うこともあります。

それはさておき、G-Buffer にジオメトリやマテリアルの情報を書き込むことになるわけですが、その結果として G-Buffer への書き込みパスは処理負荷が上がりやすい傾向にあります。

特に昨今では高解像度のテクスチャを利用するため、G-Buffer パスはどんどん重くなる傾向にあります。

この回避策として主に用いられるのが深度プレパスと呼ばれる、深度バッファのみを最初に描画し、G-Buffer パスでのピクセルシェーダ起動量を減らすという手法です。

この手法は多くの描画エンジンで採用されており、Deferred Rendering では当然のこと、Forward Rendering では使用しないのは頭おかしいというレベルに当たり前になってきています。

その結果としてメッシュの描画は最低でも2パス必要になってしまい、頂点数の多いメッシュや大量のメッシュを描画する際に頂点ネックになることもあります。

描画エンジンによっては動的なメッシュや遠距離のメッシュを深度プレパスで描画しないという手法を採るものがありますが、どの手法でも深度プレパスでの頂点ネックと G-Buffer パスでのピクセルシェーダネックを天秤に掛ける必要があります。

また、画面が高解像度化することによる G-Buffer のサイズ問題も出てきています。

Deferred Rendering の性質上、どうしてもライティング時に必要な情報を G-Buffer に書き込んでおく必要があります。

ライティングモデルが増えたり仕様が変更されることによって G-Buffer に書き込む情報が増えると G-Buffer 自体がメモリや帯域を圧迫することになります。

この問題に対応する方法として考えられているのが遅延マテリアルとでも言うべきいくつかの手法です。

その手法の1つが Deferred Texture で、通常であれば G-Buffer で書き込むべきテクスチャサンプリングによって得られる情報をライティングパスまで遅延しようという手法となります。

Deferred Texture では G-Buffer に書き込む情報はテクスチャサンプリングに使用するための情報だけです。

以下は今回のサンプルで G-Buffer に書き込んだ情報の内訳です。

0番目にタンジェントスペースをクオータニオン形式で格納しています。フォーマットは R10G10B10A2_UNorm です。

頂点のタンジェントスペースなのはノーマルマップを遅延でサンプリングした際にワールド空間上に変更するためです。

1番目は頂点UVで、フォーマットは R16G16_Float です。

UVをそのまま格納したのでは精度に問題が出てきてしまうため、小数点以下の値のみ格納しています。

2番めが1番目で格納しているUVの ddx, ddy 命令の結果です。フォーマットは R16G16B16A16_Float です。

このパラメータはテクスチャサンプリング時のミップレベル計算に使用されます。

3番目はマテリアルIDです。フォーマットは R16_UInt です。

マテリアルIDとして使用しているのは15ビットで、1ビットはタンジェントスペースの従法線の符号に使用しています。

この符号情報は MikkTSpace を使用する場合には必要な情報となります。

サンプルでは4番目の G-Buffer が存在しています。ここにはメッシュレット表示用の乗算カラーが含まれています。

もしも頂点カラーがマテリアル情報として必要な場合は別途 G-Buffer に格納する必要がある点に注意してください。

ライティングのタイミングではこれらの情報を G-Buffer からサンプリングし、マテリアルIDから必要なテクスチャを割り出し、ライティング前にテクスチャサンプリングを行います。

しかしここで問題が生じます。

通常テクスチャサンプリングはレジスタに割り当てられたテクスチャを利用します。そのため、シェーダ側では指定レジスタのテクスチャを使用するという方法しか存在しません。

もちろん分岐を使うことで使用するテクスチャを動的に変更することは可能ですが、マテリアルの数やそれにまつわるテクスチャ枚数が不明な状態で分岐を作るなど不可能です。

実際のゲームではシーンによって、プレイヤーのプレイ方法によっても表示されるメッシュは変化し、マテリアル数もテクスチャ数も変更されるので、それに合わせたシェーダを書くのは不可能でしょう。

では Texture2DArray を用いるのはどうかというと、このオブジェクトはミップレベルもサイズもフォーマットもすべてのテクスチャで同一でなければなりません。

フォーマットはともかく、テクスチャサイズまで一緒にするのはゲームアプリケーションでは難しいでしょう。

そこで登場するのがバインドレステクスチャです。

バインドレステクスチャとレジスタスペース

実はHLSLでは以下のようなテクスチャ宣言が可能だったります。

このような記述をすることでテクスチャをC/C++の配列と同様にアクセスすることが可能となっています。

なお、この場合はレジスタ番号 r0, r1, r2 に texImage の 0~2 が割り当てられることになります。

また、2重配列、3重配列も可能だったりますが、今回は使用しません。

しかしこれでもテクスチャ枚数は限られてしまいます。

もちろん非常に大きな値をデフォルト値として割り当てることも可能なのですが、もしもその数値を超えた枚数が必要になってしまうとシェーダのコンパイルし直しになってしまいます。

であれば、以下のような記述が許されてほしいということになるでしょう。

実はこの記述は許されます。これがバインドレステクスチャと呼ばれる手法です。

ただしこの記述を用いた場合、texImage は最終的に何枚使われるのかわからないため、r0 以降のレジスタ番号は基本的に使用できなくなります。

例えばシェーダ内で使用されるすべてのテクスチャを配列の中に含めてしまって、特定のテクスチャ (例えば G-Buffer0 など) に固定のインデックスを割り当てておく方法もなくはないです。

ただしこの場合はシェーダコードの視認性に問題が出てくるでしょう。インデックス番号を直値でやらずに正しく定義しておけばまだマシですが、それでも用途の違うテクスチャを1つの配列にまとめてしまうのはよろしくありません。

そこでレジスタ指定にスペースを指定します。現在のHLSLではこれが許可されます。

記述方法は以下のようになります。

Texture2D texUnique : register(r0); Texture2D texArray[] : register(r0, space1);

この記述ではレジスタ r0 が2つのテクスチャで使用されているわけですが、texArray はスペース番号として space1 を指定されています。

省略されている texUnique は暗黙的に space0 を指定されているのと同じです。

スペースが違う同じレジスタ番号は、別の棚の引き出しと同じようなものと解釈して良いでしょう。

つまり、space0 と space1 の r0 の引き出しは別物であり、別のものを格納できるというわけです。

このレジスタスペースはレジスタ番号と同じような運用も可能なのですが、Root Signature を作成する場合にレジスタ番号では可能な手法がスペースでは使用できません。

それはデスクリプタテーブルを利用する際にベースレジスタ番号からいくつのレジスタ番号にデスクリプタを割り当てるかという情報です。

デスクリプタテーブルで指定するデスクリプタレンジは以下のような構造体です。

ここで BaseShaderRegister から NumDescriptors 個のレジスタ番号までは1つのデスクリプタ配列で表現することが出来ます。

これはDirectXの話 第163回で解説したデスクリプタヒープのコピー戦略でも使用していますが、レジスタスペースではこのような運用は不可能です。

ではレジスタスペースはどのような場面で役に立つのでしょうか?

1つは今回のようなバインドレステクスチャで使用する方法ですが、もう1つはリソースのカテゴリーを明確にすることです。

レジスタ番号だけでシェーダリソースを管理するのは一般的ではありますが、そのような場合にはどうしてもデスクリプタヒープをコピーするような手法を取らなければならなくなります。

しかし常にコピーをする必要はあるのかというと、実はそうでもないという場合が多いのです。

例えば複数のマテリアルを描画する際、シーン情報となるカメラやライトの情報はすべてのマテリアルで同じものを利用します。

また、同じメッシュ内のマテリアルであればメッシュの変換行列やスケルトン行列情報も同じものを使用します。

マテリアルのDraw Callごとに変更するのはマテリアルの情報に関するものだけです。

コピー戦略ではこれらの情報は全て同一に扱ってコピーしてしまいます。シーンやメッシュに関する情報が変更されているかいないかは関係ありません。

例えばスペース0はシーン用、スペース1はメッシュ用、スペース2はマテリアル用というようにカテゴリーごとにスペース番号を変更しておけば、Draw Callごとにコピーするデスクリプタを最小限に抑えることができます。

特にこの手法はVulkanで有効で、Vulkanはデスクリプタをデスクリプタセットという単位でシェーダに送る必要があります。

デスクリプタセットは描画が完了するまで変更することが出来ないので、コピー戦略のような手法を用いた場合にはDraw Call数分のデスクリプタセットを生成する必要が出てきます。

これは速度的に問題が出てくる可能性が高いので、カテゴリーごとにデスクリプタセットを分けて変更の周波数に合わせた取り回しをすべきです。

まあ、私はエンジン開発に携わった時に結局コピー戦略を用いましたが。

だってVulkan使わないし。

NonUniformResourceIndex について

実際のコードを見ていきましょう。C++コードは特に重要ではないので、シェーダコードのみの解説を行います。

まず、今回のサンプルでは Deferred Texture とスタンダードな Deferred Rendering を切り替えることができるようになっています。

また、ライトカリングは Cluster Culling を行っています。GPUでカリングしており、cluster_cull.c.hlsl がそのコードとなります。

Deferred Texture 用の G-Buffer 描画は dt_pre.p.hlsl で行っています。頂点側はスタンダードな Deferred Rendering と同様にメッシュシェーダを用いています。

特に難しい部分は存在しないと思いますが、一応αテストは行っています。

また、タンジェントスペースを変換したクオータニオンは PackQuat() 関数を用いて R10G10B10A2_Unorm で格納できる形にパックしています。

このフォーマットでは数値として利用できるのは3チャンネルのみなので、A2に最大値の要素番号を格納し、アンパックする際に再構築するようにしています。

詳しくは common.hlsli を御覧ください。

Deferred Texture バージョンのライティングは dt_lighting.c.hlsl で記述されたコンピュートシェーダで行っています。

ライティング自体はスタンダード Deferred Rendering バージョンと同様なので割愛しますが、G-Buffer を用いてマテリアル情報を取得する部分が異なります。

そのため、マテリアル情報取得部分だけ見ていきましょう。

ここで MaterialInfo 構造体はサンプリングすべきテクスチャインデックスを格納しています。これが Structured Buffer でシェーダに渡されています。

このバッファは当然ですがマテリアルIDの番号順に整理されています。

今回は静的メッシュのみなので事前生成しただけのものを利用していますが、毎フレームレンダリングするべきメッシュが変わったりする場合は毎フレーム更新する必要があります。

テクスチャサンプリングでは SampleGrad() 関数を用います。

この命令はUVと一緒に ddx, ddy 命令の結果を与えることでミップレベルを計算してくれるものです。

コンピュートシェーダでは通常 Sample() 命令は使用できないため、SampleGrad() 命令を用いるか、SampleLevel() 命令でミップレベルを明示する必要があります。

なお、SM6.6からCS上でも Sample() 命令が使用できるようになりますが、今回の用途では不向きだと思います。

さて、ここで問題になるのがバインドレステクスチャである texBindless 配列からテクスチャを参照する際の NonUniformResourceIndex() という関数です。

これがなにかというと、このテクスチャ参照インデックスはシェーダ内で固定ではないです、という宣言です。

実はシェーダはリソース参照をする際には特に指定がなければ固定のインデックスを用いようとします。

この宣言を用いることで固定のインデックスではなくスレッドごとに変動するインデックスですと宣言するわけです。

ただ、実際にこの宣言を行わなくても特に問題は発生しませんでした。

これはCSだからかもしれませんが、通常のグラフィクスパイプラインでは発生するかもしれません。

少なくともバインドレステクスチャを使用する際には基本的に宣言しておいたほうが良いでしょう。

この後はディレクショナルライトと Cluster Culling を行った128個の点光源によるライティングを行っています。

この部分のコードはスタンダードな Deferred Rendering と同様です。

クオリティとパフォーマンスについて

パフォーマンスは私の環境では高速化せず、逆に低速化していました。

サンプルで使用しているSponzaでは頂点ネックにならないでしょうし、そこまで効果は大きくないでしょう。

より頂点数の多いシーンではもしかしたら高速化するかもしれません。

クオリティについては一長一短という印象。

主にエッジ部分で2つの手法には差が出ているのですが、スタンダードな手法ではエッジ部分のスペキュラノイズのようなものが目立つように感じます。

しかし、テクスチャエッジ部分と思われる部分で Deferred Texture では隙間ができてしまっているようです。

これが何に起因しているのかはなんとも言えない部分ですが、小数点のみを使用している点や精度の問題ではないようです。

また、16ビット浮動小数点の精度の問題があるため、微妙にスタンダードよりピクセルがずれる現象も確認しています。

リアルタイムに切り替えができるので確認してみてください。

Deferred Textureの制約

Deferred Texture や似たような手法である Visibility Buffer はバインドレステクスチャが使えることが前提条件です。

まあ、現在のハードではほぼ使うことができるので問題ないのですが、古い機種も対象にする場合はシェーダモデル的に使えない可能性もあります。

また、モバイルデバイスもどの程度対応しているのか不明です。

大きな制約としてはスタンダード Deferred Rendering よりマテリアルの制約が厳しいという部分です。

スタンダード手法では G-Buffer への書き込み時にマテリアル情報をさまざまに変更することが出来ます。

Unreal Engine 4のようなエンジンではマテリアルのレイヤリング、ディゾルブ処理、特殊な表現はすべてマテリアルエディタで作成することが出来ますが、これはほぼ全て G-Buffer パスで行います。

最終的にこれらのエンジンではライティング時の処理の違いはライティングモデルの違いのみ対応すれば問題ありません。

しかし Deferred Texture では使えそうなのはUV値の計算くらいです。例えばテクスチャ参照を行ってその結果をUV値に反映させるとかは普通には出来ません。

もちろん、UV値への反映だけなら G-Buffer パスで行う手もありますが、複雑なマテリアルはUV値だけでどうこうすることが出来ない場合も多いです。

マテリアルレイヤリングを行う場合に複数のUV値が必要になる場合はさらに G-Buffer が増えることになりますし、ライトマップを用いている場合も同じ問題が出てきます。

Substance Painterでペイントしたメッシュをそのまま出力する、というだけなら良いでしょう。

それにちょっとディテールを付け足す程度ならシェーダ内の分岐でも対応できるでしょう。

しかしマテリアル芸的なことは難しいのがこの手法です。

もちろん、作りたい映像によって得手・不得手はありますので、Deferred Texture が有効な映像もあるとは思いますが、少なくともスタンダード手法で問題がない状態であれば特に使う必要がないのではないかとも考えています。

もしあなたが高解像度レンダリングを必要とし、スタンダード Deferred Rendering で G-Buffer が増えに増えてしまっていて、Forward Rendering では扱いきれないライト数を扱う必要があるようなときは Deferred Texture や Visibility Buffer を検討してもいいのでは?という気もします。

割と正直な話、将来性がある技術って印象がないんですよね…