DirectXの話 第186

共有リソースとOpen Image Denoise

23/06/03 up

複数のAPIでリソースを共有する

Direct3Dでは複数のAPIや別々のデバイス上でリソースを共有する仕組みとしてInteropが存在します。
例えばD3D12とD3D11でテクスチャを共有したり、別々のD3D12デバイスでバッファを共有したりとかです。
通常、このような運用はゲーム開発においてはほとんど行わないと思いますが、状況によっては有用な機能です。
また、一部のWindows機能を利用する場合に必要になるケースもあります。

わかりやすいところではビデオデコードがありました。
2021年末くらいにD3D12にもビデオデコードAPIが追加されましたが、それまではD3D11にしか存在しませんでした。
なのでムービー再生をゲーム中でやろうと思うと、D3D11でデコード→D3D12テクスチャにして描画という流れが必要でした。

また、Windowsのスクリーンキャプチャを実現しようとすると、現在はWinRTのWindows Graphics Captureという機能を使うことになります。
この機能にはD3D11 Interopが提供されているため、スクリーンショット撮影→D3D11で描画という流れが簡単に実現できます。
しかし、D3D12 Interopは提供されていないため、D3D12でスクリーンショットをテクスチャとして利用する場合、D3D11からD3D12にテクスチャを渡す必要があります。

これらを実現するには一旦CPUメモリに転送して、それをさらにD3D12テクスチャに転送するという手段もあるわけですが、当然パフォーマンスは落ちます。
リアルタイム性をそれなりに確保したい場合、どうせ同じVRAM上にあるのであれば、VRAM間のコピー、もしくは直接そのまま使えたほうがいいわけです。
これを実現するのがInteropです。

共有リソースの実装は難しくありません。
まず、D3D12でリソースを作成する際に、以下のヒープフラグを設定します。

D3D12_HEAP_FLAG_SHARED

このとき、リソースはCPUアクセスが不可能なヒープである必要があります。
ですので、基本的には D3D12_HEAP_TYPE_DEFAULT を使用します。UPLOADREADBACK は利用できません。

次にWin32ハンドルを生成します。
ID3D12Device::CreateSharedHandle() メソッドを使用します。
リソース生成時にフラグを立てていれば、基本的には問題なく動作するでしょう。

最後にこのハンドルから各種APIのリソースを生成します。
例えばD3D11の場合は、ID3D11Device::OpenSharedResource() メソッドを利用すると、ハンドルからリソースを生成することができます。

API的には簡単ですが、リアルタイム性のあるアプリを実装する場合は、それぞれのAPIでの処理順序や並列化にも気をつける必要があります。
もちろんデータ競合についても注意しなければなりません。

Intel Open Image Denoise

Intel社が提供している Open Image Denoise(以下OIDN)はディープラーニングベースのレイトレ画像デノイザーです。
当然学習が必要ですが、デフォルトでも学習済みフィルターが適用されていますが、自前で学習させることも可能です。
ライセンスもApache 2.0なので扱いやすいです。

https://www.openimagedenoise.org

OIDN1.xはCPUでの実装のみでしたが、つい最近リリースされた2.0では各社のGPUに対応しました。
使用可能なAPIとして、CUDA、SYCL、HIPに対応しています。
CUDAはGPUコンピューティングに利用されるAPIとして最も有名ではないかと思いますが、SYCLはOpenGLやVulkanでおなじみのKhronosグループが策定しているAPIで、HIPはAMD版CUDAのようなものです。

今回利用するのはCUDA版となります。
SYCLやHIPでの動作確認は行っていません。
というか、物理デバイスとしてCPUかCUDAしか利用できないので致し方なし。

OIDNの不具合?

GitHubのOIDNページには2.0のビルド済みパッケージがアップされています。
通常、単純に利用するだけであればこちらを利用するのがいいのですが、今回のようにD3D12から共有リソースを利用してOIDNに接続する、という方法ではちょっとした不具合?が存在します。
ちょっとした、と言いつつ、この不具合を直さないと今回のサンプルは実行できません。
ですので、今回のサンプルを試してみたい方は、OIDNのビルド環境を作成してコードを修正する必要があります。

CUDAで共有リソースを利用するには、cudaImportExternalMemory() 関数を利用する必要があります。
D3D12の CreateSharedHandle() で作成したWin32ハンドルをこの関数に食わせることで動作するのですが、D3D12_HEAP_TYPE_DEFAULT で作成したリソースは通常VRAM上に存在します。
このVRAM上のリソースをCUDAに食わせる場合、フラグに cudaExternalMemoryDedicated を設定する必要があります。
このフラグは専用メモリ上にあることを示すフラグで、GPUの場合はVRAM上ということになります。

しかし、OIDNではこのフラグを立てていません。
そのため、普通にOIDNにハンドルを食わせると、Invalid Argumentのエラーが出てメモリのインポートに失敗します。
これを回避するには、OIDNの devices/cuda/cuda_external_buffer.cpp を修正してフラグを立てるようにしなければなりません。
私は単純に、以下のような修正を加えました。

なお、OIDNのビルドは色々面倒なので、今回はCUDA版のみを有効にしてビルドを行いました。
仕事で実装するのであれば、CUDAを利用できない場合はCPU版に変更するなどのフォールバックが必要なのですが、サンプルなのでCUDAのみ有効としました。

この不具合っぽいものですが、現段階ではUMAでの対応しかしていないだけという可能性もあります。
UMAの場合は専用メモリではないので、この変更を加えると逆に動作しなくなるかもしれません。
今後は色々と修正が入るかもしれないですので、あくまでも2.0における対応と考えてください。

追記

ExternalMemoryTypeFlagD3D12ResourceからOpaqueWin32に変更することで、ビルド済みOIDNでも動作することを確認しました。

GitHubのIssueによると、現バージョンでもD3D12Heapではエラーが出ないとか、D3D12リソース作成時にD3D12_HEAP_FLAG_SHARED_CROSS_ADAPTERを利用することで問題がないとか。
ただ、このフラグはBufferに対しては使用できず、Texture2Dにする必要があるそうです。
この場合、D3D12_TEXTURE_LAYOUT_ROW_MAJORを利用することでリニアテクスチャとして作成する必要があるようです。
この手法は修正箇所が多くなるので試していません。

なお、うちの環境だけかもしれませんが、OpaqueWin32を利用すると、HDRを有効にしたASUS PA27UCXで妙な画面の点滅が発生していました。
描画しているウィンドウだけでなく、ディスプレイ全体で発生していますが、クラッシュはしていません。
DELL G3223Qにウィンドウを移動すると発生しないため、ASUSのディスプレイの問題なのかな?という印象です。
なお、HDR無効の場合はどちらのディスプレイでも発生していません。

実装

今回はOIDNを動作させるため、簡易のパストレーサーを実装しました。

https://github.com/Monsho/PathTracerD3D12

平行光源とスカイライトのみ、インポータンスサンプリングもしてない適当実装なのは許してもう方向で。
ブルーノイズも使ってないので、その点ではデノイズが不利かもしれないです。

処理の流れとしては以下のようにしています。

DXRでパストレース

結果のバッファをコピー

描画完了待ち

デノイズ

次フレームで描画

バッファのコピーは本来不要なのですが、自分のシステムのレンダーグラフが共有リソースに対応させていないのでコピーするようにしています。

また、OIDNが現状ではテクスチャリソースに対応していません。
通常、グラフィクスAPIのテクスチャリソースはパフォーマンスの関係で内部的にはリニアな形状になっていません。
しかしOIDNはリニアなテクスチャにしか対応していないので、今回はByteAddressBufferとして生成したバッファにパストレの結果を保存しています。

ではコードの解説です。

初期化関数である InitializeOIDN() とデノイズを行う ExecuteDenoise() を提示しました。
一部省略している部分もありますが、基本的に繰り返しで行っている部分です。

最初の20行目まではOIDNのデバイス生成部分です。
CUDAが使用できるかどうかをチェックするため、物理デバイスとしてCUDAデバイスが存在するかチェックしてからCUDAデバイスを作成します。
また、その次の処理で externalMemoryTypes をデバイスから取得します。
この値は共有リソースとして使用可能なタイプが列挙されているフラグで、D3D12のCommittedResourceが使用できるかどうかを確認しています。

30行目付近でバッファを生成した後、50行目付近から CreateSharedHandle() でリソースハンドルを取得しています。
このハンドルはOIDNデバイスの newBuffer() 関数に食わせます。
一応エラーチェックもしていますが、前述の不具合っぽいものを修正すればエラーは出ないはずです。
Invalid Argumentのエラーが出るようであれば前述の修正が入っていないか、VRAM上のリソースではないかになるかと思います。
OIDNのバッファを作成したらハンドルは不要になりますので、CloseHandle() で閉じてください。

ExecuteDenoise() はOIDNでのデノイズ実行関数です。
まず newFilter() 関数で RT フィルターを作成します。レイトレース画像用のフィルターということです。
なお、フィルターには RTLightmap というライトマップ用のフィルターもあります。

次に作成したフィルターにデータを設定します。
イメージとしてはノイジーなレンダリング結果である "color"、補助用の"albedo", "normal"、出力用の"output"となります。
補助用の2種類はオプションなのでなくても問題ないですが、あるとなしでは結果に大きく差が出ます。
トップの画像は1sppの結果をデノイズしているのですが、補助用のバッファが存在しない場合はだいぶ酷い結果になります。

その他の設定としては、レンダリング結果が LinearRGB なので、"hdr" を有効に、"albedo"と"normal"はノイジーな結果ではないので "cleanAux" を有効にしています。

設定を行ったらコミットして実行です。
実行はAsyncでもできますが、今回は同期するようにしています。
エラーチェックで問題が出ていなければデノイズ結果バッファにデノイズ処理後の画像が含まれますので、そちらを画面に表示します。

結果について

補助バッファを使うことでだいぶ良い結果が得られることがわかりました。
補助バッファがあると1sppでもかなり良い結果となっています。
逆にない場合は、1sppだとかなりのブロックノイズが出てしまって、画質の悪いYoutube動画みたいになります。

リアルタイムに動かすとよく分かるのですが、安定的なデノイズは期待できません。
AI生成画像の動画を作成している人をSNS上で稀に見かけますが、ある程度コントロールできるモデルを使っていても動画にすると揺れが発生していたりします。
このような挙動がこちらでも確認できています。
しかしサンプリング数が上がるとだいぶ安定して、8sppくらいだと動画にしても問題なさそうな印象があります。
もちろん映像によるでしょうし、よりディテールの細かいシーンではうまく行かないかもしれません。

パフォーマンスはなんとかリアルタイムで動かせるか、というくらい。RTX4080でそれなので、2080とかだとちょっと厳しいかもです。
並列動作させればあるいは?とは思いますが、パストレもデノイズもGPUを利用するので高速化するかどうかは疑問です。
しかし、準リアルタイムくらいの速度でなら動かすことができるかもしれませんし、レンダリング結果のプレビュー用なら十分使えると思います。
ゲームで使うにはまだまだ先ですね。