C++, ComputeShader, HLSL, Unreal Engine 4

[UE4][ComputeShader][HLSL][C++]Unreal Engine 4で(RW)StructuredBufferを用いたComputeShaderを利用する(その2:C++側シェーダクラス)

その1:イントロでは文字通りイントロ(と愚痴…)だけになってしまいましたので、その2では具体的な実装を解説します。

1. C++経由で変数をHLSL側に渡す
2. C++側の変数にはFVectorも入れる
3. StructuredBufferを使う
4. RWStructuredBufferを使う
5. Unreal Engine 4が提供しているConstantBufferに該当するUniform Buffer Structを使う
6. そしてComputeShaderをC++側から呼び出す

という6つを確認できることを目標に以下のことをするComputeShaderを作ることにしました。先に断っておきますが、見た目的には微塵も面白くありません。

サンプルコード一式をGitHubに上げました。
https://github.com/HSeo/ComputeShaderUE419Test

実行してキーボードの1または2を押すと、Output Logに以下のように表示されます。

これだけです。これだけですが、プログラマ目線ではこれで十分かと思います。

では始めましょう。
実行環境はWindows 10, Visual Studio 2017, Unreal Engine 4.19になります。
他のOS環境などでの違いはわかりません。ごめんなさい。
GPU非搭載のマシンで何が起こるかもわかりません。ごめんなさい。

1. 下準備

◆HLSL Tools for Visual Studioのインストール
Unreal Engine 4 Rendering Part 1: Introductionにも書かれている通りです。
無くても構いませんが、インストールしておくと便利だと思います。

◆ConsoleVariables.iniの書き換え
Unreal Engine 4 Rendering Part 1: Introductionにも書かれているように、/Engine/Config/フォルダ内にあるConsoleVariables.iniを書き換えます。
r.ShaderDevelopmentMode=1
r.Shaders.Optimize=0
r.Shaders.KeepDebugInfo=1

に設定します(先頭の”;”を消して非コメント化します)。
コメントのままにしておくとどうなるのかはよくわかりません。ごめんなさい。何もしなくても大丈夫なのかもしれませんが、検証していません。

◆.build.csにモジュールを追加
PublicDependencyModuleNamesに
“ShaderCore”, “RenderCore”, “RHI”
の3つを追加して、一旦Visual Studioを閉じます。

◆Shadersフォルダの生成(UE 4.17以降)
シェーダーをプラグインとしてではなく用いる場合には、プロジェクトフォルダ直下にShadersフォルダを作り、さらにその直下にPrivateフォルダを作ります。
シェーダーファイルはこのPrivateフォルダ内に作ります。

なお、プラグインとして作る場合はPluginフォルダ内に同様にShaders→Privateフォルダを作ります。
詳しくはEpic Games Japan 岡田和也さんのスライドをご覧ください。

4.16以前は、プラグインとして作る場合は同様にPluginフォルダ以下にフォルダを作れば大丈夫ですが、後述するC++での記述内容(パス指定方法)が若干変わるようです。
非プラグインとしてShadersフォルダを作って認識してくれるのかどうかはよくわかりません…。

◆.uprojectの書き換え
最初のハマりどころです。C++プロジェクトを作ると.uprojectファイル(テキストエディタで開けます)内に”Modules”という項目が追加されます。
この中の
“LoadingPhase”: “Default”,

“LoadingPhase”: “PostConfigInit“,
に書き換えます。
こうしないと、シェーダーコンパイルが通りません。
https://github.com/Temaran/UE4ShaderPluginDemo
にも”PostConfigInit”にしようね、とは書かれているのですが、具体的な場所がわからずに最初苦労しました。
LoadingPhaseの詳しい解説は
ELoadingPhase::Type | Unreal Engine
をご覧下さい。モジュールがどのタイミングで読み込まれるかを指定しています(たぶん)。

書き換えたらファイルを保存して、solutionファイルを作り直しましょう。.uprojectを右クリックして”Generate Visual Studio project files”を選択。

これで下準備完了です。

2. シェーダの記述(.usf)

プロジェクト名フォルダ(or Pluginフォルダ)→Shaders→Privateフォルダに.usfという拡張子のファイルを作ります。
グローバルシェーダとして記述することになります。
Unreal Engineのグローバルシェーダは(たぶんUE4.17から).usfと.ushの2種類に分けられています。
シェーダーエントリーポイントが含まれる場合は.usf、それ以外は.ushになるそうです。

ただ、このスライド、MainVS, MainPSを持つ場合に.usfということになっているのですが、ComputeShaderだったらどちらも持たないけど.usfだよなぁ、と思います。
.ushではなく.usfにして下さい。

.usfファイル内のシェーダの記述は、基本的にはHLSLと同じだと思います(…私はHLSLスーパー初心者ですので間違っていたらごめんなさい)。

今回書いたものはこれです。

コメントなどで行数が多くなっていますが、やっていることは実質33行目の1行だけです。これが最初に示した数式です。
いくつかポイントを列挙します。

◆Uniform Buffer Structを使いたい場合
Unreal Engineが提供しているCommon.ushをincludeする必要があります。
このCommon.ushは、使用しているUnreal EngineのEngine→Shaders→Privateフォルダに入っています。このフォルダには他にもEpic Gamesによる貴重なシェーダーファイルがぎっしり詰まっており、ここを見るだけでも相当勉強になりそうです。

◆Engine→Shaders→Privateフォルダ内の.usf / .ushをincludeする際のファイルパス
落とし穴が1つあります
/Engine/Shaders/Private/Common.ush
がEngine以下でファイルまでの正しいパスなのですが、コード上では

と書きます。/Shaders/がありません
実はコードのビルド時のログをよーく眺めていると

LogShaders: Shader directory mapping /Engine -> ../../../Engine/Shaders

なるログを見つけることが出来ると思います。
HLSLコード内でパスに/Engineと書くと、それは自動的に/Engine/Shadersに置き換えられる仕様になっています。
何気にハマりやすいポイントの1つかと思いますので注意してください。

なお、Visual Studio側はこのパスを正しく認識できませんので、いつまで経ってもエラーを示す下線が付いたままになってしまうと思います。

◆Uniform Buffer Structを実際に使う
HLSLコードを見ていて、コード内に変数定義が無い変数を見つけたら、Uniform Buffer Structかもしれません。
このコードでは、CalculateOutput関数内でいきなりoffset_yz.y, offset_yz.zなる変数が登場しています。
これはコメントで記載したように、Uniform Buffer Structの定義は.h / .cpp内に記述されているためです。
Visual Studio内での自動補完も出来ず、ここもエラー表示のままになってしまいます。
可読性の点からも、むやみやたらにUnfirom Buffer Structを使うことはお勧めしません。

https://github.com/IntelSoftware/ue4-parallelShaderFishPlugin.hのように、C++側で大量の変数をまとめて構造体として扱いたいとき、そしてその構造体の中身を丸ごとシェーダーでも使いたいときにはとても有用かと思います。

3. C++側からシェーダにアクセスするためのクラスの記述(.h)

シェーダとC++との接続のために、Unreal Engine側が用意しているFGlobalShaderクラスを継承したクラスを作ります。
ヘッダファイルは以下のようになります。

大きく5つのブロックに分かれます。紐解いていけば、単純なことしかやっていません。

◆Uniform Buffer Structの定義

マクロが用意されているので、これに倣って書けば問題ないと思います。
(たぶん)シェーダー側で読める型である必要があるため、FVectorを使いたい場合は(たぶん)float変数3つに分解しておく必要があります。
また、この構造体はBluePrints上で用いるUSTRUCTS()とは別物であるため、構造体名の頭に”F”を付ける必要はありません。

◆DECLARE_EXPORTED_SHADER_TYPE

イマイチよくわかりませんでした。ごめんなさい…。UE4 にグローバル シェーダーを追加してみようによると、

DECLARE_EXPORTED_SHADER_TYPE() マクロを使うと、シェーダーのタイプのシリアル化に必要なエクスポートが生成されます。3 番目のパラメータは、シェーダーのモジュールが存在することになるコード モジュールの外部リンケージのタイプです。

とのことです。
教えて、詳しい人!

◆必須のメンバ関数の定義

計6個のメンバ関数の定義が必須です。関数名、引数ともに上記の通りにする必要があります

・コンストラクタは2種類用意します。引数無しコンストラクタは実装は空のもにします。const ShaderMetaType::CompiledShaderInitializerType&を引数に取るコンストラクタの実装は今回は.cppに書きましたので後述します。

・ShouldCache関数はイマイチよく分かりませんでした…。IsFeatureLevelSupported関数を用いて、実行環境によってシェーダーコンパイルを行わないように設定できるようです。ERHIFeatureLevel::Typeの定義を見てみると、DirectX11サポートの有無を調べたければSM5、MetalであればES3_1などを指定できるようです。この関数がfalseを返してコンパイルしない場合に何をどうすれば良いのかはよく分かりません…。
教えて、詳しい人!

ModifyCompilationEnvironmentは全然分かりませんでした。Unreal Engine 4 Rendering Part 2: Shaders and Vertex Dataによると(一部省略)、

The second important concept is the ability to change preprocessor defines in the HLSL code before compilation. This functions is called before the shader is compiled and lets you modify the HLSL preprocessor defines.

とのことで、CFLAG_StandardOptimization以外にも様々なフラグ(ECompilerFlags)があり、指定することで色々変わる…のだとは思うのですがそれ以上は全然わからず…。基本的には上記のコピペで良いような気がします。
また、Yet another blog…: Adding Global shaders to UE4 v2.0には

The new ModifyCompilationEnvironment() function is used when the same C++ class defines different behaviors and be able to set up #define values in the shader.

と書かれており、異なるサンプル実装が書かれています。
教えて、詳しい人!

なお、(たぶん)UE4.18以上と未満とでは引数の型が異なっており、const FGlobalShaderPermutationParameters&が以前はEShaderPlatform Platformでした。Unreal Engineのバージョンを上げたときにシェーダーコンパイルが通らなくなってしまう原因の1つです。

ShouldCompilePermutationはさらに全く分かりませんでした。
Yet another blog…: Adding Global shaders to UE4 v2.0によると

The ShouldCompilePermutation() function, needed when a permutation of a global shader is required. This is a slightly more advanced topic outside the scope of this post.

とのことで、単純にコピペして完了とします。
教えて、詳しい人!

・最後のSerializeはとても重要で、各種Shader Parameter(後述)を設定します。
やはりYet another blog…: Adding Global shaders to UE4 v2.0の説明を借りれば

The Serialize() method is required. This is where the compile/cook time information from the shader’s binding (matched during the serialization constructor) gets loaded and stored at runtime.

とのことです。今回は.cppで実装しました。

◆C++側で設定した値をシェーダ側と結びつけるための関数

具体的な実装は.cppで記述してありますが、C++で設定した値や配列をシェーダ側に渡す、または終了処理のための各種関数です。
FRHICommandListなるものが毎回出てきますが、RHIはRendering Hardware Interfaceの略で、Unreal Engineが提供している、プラットフォーム非依存のための一連のレンダリングインターフェースです。
ComputeShaderはUnreal Engineのレンダリングスレッド内で処理されるのですが、その中の各種処理の際にFRHICommandListの参照が必要になります(…と理解しているのですが違ったらごめんなさい)。
教えて、詳しい人!

また、配列をシェーダ側に渡す場合は、StructuredBufferやTexture2Dなど(GPU側から見て)Read Onlyのものの場合にはFShaderResourceViewRHIRefを介して、RWStructuredBufferやRWTexture2Dなど(GPU側から見て)Writeも行うものの場合にはFUnorderedAccessViewRHIParamRefを介して渡すことになります。

◆C++側で設定した値をシェーダ側と結びつけるための変数

intやfloatなどはFShaderParameter経由、(RW)StructuredBufferなどはFShaderResourceParameter経由でC++側とシェーダ側との橋渡しを行います。
…という理解だったのですが、FShaderParameterの説明を見ると

A shader parameter’s register binding. e.g. float1/2/3/4, can be an array, UAV

と書かれていますね…。うーむ、よくわからん…。
教えて、詳しい人!

3. C++側からシェーダにアクセスするためのクラスの記述(.cpp)

全部ヘッダに書いてもたぶん大丈夫なのですが、.cppは以下のように書きました。

大きく4つのブロックに分かれます。

◆Uniform Buffer Struct変数の生成

Uniform Buffer Structを使う場合の、HLSLコード内での変数名をここで定義します。
OffsetYZ offset_yz;
と書くかわりに上記のように書きます。

◆必須のメンバ関数の実装

const ShaderMetaType::CompiledShaderInitializerType&を引数に取るコンストラクタでは、まず継承元のFGlobalShaderをInitializerで初期化した後、シェーダ側の変数とFShaderParameterFShaderResourceParameterとの対応付けを行います。
FShader(Resource)Parameter.Bind(Initializer.ParameterMap, TEXT(“HLSLでの変数名”), SPF_Mandatory);
という形です。
最後のSPF_MandatoryはEShaderParameterFlagsというフラグの1つで、SPF_MandatoryまたはSPF_Optionalのどちらかになります。
パラメータが使われていないときにコンパイルエラーにするかしないかを決めるそうですが、基本的にはSPF_Mandatoryにしておけば問題ないと思います。
私が見た限りでは、ComputeShaderを用いるコード全て、SPF_Mandatoryしか使っていませんでした。

Serialize(FArchive& Ar)関数では、bool値の取得と戻り値はどのコードでも全く同じ記載でしたのでコピペで良いはずです。
Ar << 用いているFShader(Resource)Parameter;
とします。
内部で実際に何が行われているかまでは実装を追っていません。
教えて、詳しい人!

◆C++側で設定した値をシェーダ側と結びつけるための関数

いずれもFRHICommandList&を引数に取ります。

FShaderParameterの場合は、SetShaderValueを用います。2つ目の引数であるFComputeShaderRHIParamRefGetComputeShader()で取得することが出来ます。
本当は、FShaderParameter変数が正しくbindされているかどうかを
FShaderParameter::IsBoundで調べたほうが良いのではないかという気もするのですが、私が確認したコード全てで、FShaderParameter変数に関してはIsBound()は使われていませんでした。
理由はよく分かりません。
教えて、詳しい人!

・Uniform Buffer Structを用いる場合には、当該Unform Buffer Struct変数を用意し(上記ではoffset_yz)、値を代入したうえで、SetUniformBufferParameterを用いて値をシェーダ側に渡します。SetUniformBufferParameterの実装は3種類あるのですが、他の2種類を使っている例を見つけられず、使い方がよく分かりません…。
教えて、詳しい人!

GetUniformBufferParameterは公式ページに記載されている通りで、

Finds an automatically bound uniform buffer matching the given uniform buffer type if one exists, or returns an unbound parameter.

とのことです。

厄介、というか一番謎だったのは、TUniformBufferRef::CreateUniformBufferImmediateの第一引数で値を渡すのですが、第二引数のEUniformBufferUsageです。3種類の値を取り得るのですが、上記コードのコメントでも書いたように、
DirectX11用の実装本体では、EUniformBufferUsageは一切使われていませんでした。
つまり、3種類のうちどの値を取っても結果は同じです
ちなみにDirectX12用の実装では、この値によって処理の分岐が行われていました。

で、私は最初、UniformBuffer_MultiFrameを指定すれば、HLSLではConstant Bufferと呼ばれているくらいですので、一度値を渡した後にずっと値が保持されるものと思っていたのですが、色々実験してみるとどうやらそうではないようでした…。
このあたりの詳しいことは、(たぶん)NVIDIAの中の人による[メモ]UE4のDX11のConstant Bufferの取り扱いについて | shikihuikuをご覧下さい。
ちなみにこの記事内で触れられているr.UniformBufferPoolingは、上記コードのコメントにも書いたように、ConsoleManager.cpp内で1に初期設定されています。せいぜい2, 3フレーム保持されるということなのでしょうか…?
教えて、詳しい人!

・HLSL内でStructuredBufferやTexture2Dなど(GPU側から見て)Read Onlyの型の場合はShaderResourceViewを介して値をやり取りするためFRHICommandList::SetShaderResourceViewParameterを、RWStructuredBufferやRWTexture2Dなど(GPU側から見て)Writeも行うものの場合にはUnorderedAccessViewを介して値をやり取りするためSetUAVParameterを使います。なぜ関数名がSetSRVParameterでないのかは謎です。

なお、FShaderResourceParameterに対しては、IsBound()でbindされているかどうかのチェックを行ったり、処理が終わったら開放する関数を作るそうです。
開放にはFShaderResourceViewRHIParamRef()FUnorderedAccessViewRHIParamRef()を用います。
FShaderResourceParameter::GetBaseIndexが何をやっているのかはイマイチよくわかりませんでした…。
教えて、詳しい人!

◆シェーダタイプの登録

公式解説記事であるUE4 にグローバル シェーダーを追加してみようで詳しく説明されています。
グローバルシェーダを継承したクラス(FTestComputeShader)を、.usfファイル(”/Project/Private/compute_shader_test.usf”)のシェーダエントリポイント(”CalculateOutput”)に、シェーダステージ(SF_Compute)としてマップする、という意味のようです。
最後のSF_ComputeはEShaderFrequencyの値の1つで、今回はComputeShaderですが、VertexShaderであればSF_Vertexを、PixelShaderであればSF_Pixelを指定することになります。

——

…と、これでやっとC++側でのシェーダの窓口の完成です。
ではこのクラスを実際にどう使うか、ということになるのですが、またしても長文になってしまったので、続きは次回(その3:実際に用いる)に回します。
サンプルプロジェクトをGitHubに上げてありますので、解説なんて要らないという方はご自由にソースコードを眺めてくださいませ。

コメントを残す

メールアドレスが公開されることはありません。