SPARKCREATIVE Tech Blog

https://www.spark-creative.jp/

【UE5】ポストプロセスマテリアルについて

こんにちは!!!クライアントエンジニアの小林です。

今回はUnrealEngineのポストプロセスマテリアルについてぼちぼち書いていきます。
GTMFの宣伝も兼ねて2週連続投稿です。

会社ブログっぽい宣伝

Game Tools & Middleware Forum 2023 (GTMF 2023)にSPARK社も出展いたします。

Copyright © GTMF All Rights Reserved.

VFXツールのスパークギアを体験できるらしいので興味のある方は是非お越しくださいませ。
こういうかっこいいエフェクトを作れるすごいツールです。

企業さんだけでなく弊社に興味のある方も「事前来場者登録」というフローを踏めば入場できるらしいのでぜひぜひ。
申請方法の手順は公式サイトさんにお任せします。

作業環境

windows 10
visual studio 2022
visual studio code
・UnrealEngine 5.2

概要

ポストプロセスマテリアルの基本的なところをいくつか触れた後にカスタムノードを用いてポストプロセスマテリアルを組んでいきます。最後に小ネタでポストプロセスマテリアル関連のエンジン改造を紹介します。

進めていく上での注意点ですが、ディファードレンダリングを対象としています。フォワードでも大体は同じなのですが使用できるバッファが限られるという差分があるので、もしかしたら動かない箇所などあるかもしれません。

ポストプロセスマテリアルとは

ポストプロセスという描画の後処理部分で画面効果を適用できるマテリアルです。一般的にはポストエフェクトやポスト効果という名称の方が馴染み深いかと思います。

UnrealEngineにはポストプロセスマテリアル以外にも描画の後処理に該当、分類されるものがいくつもあります。画面全体の明るさ調整を担う自動露出やブルーム、カメラのフォーカス機能である被写界深度(DOF)、FXAAやTAAなどの線を滑らかにするアンチエイリアスなどが挙げられます。

左からポストプロセスを未適用、被写界深度を適用、ブルームを適用

これらとポストプロセスマテリアルの違いは拡張性にあります。ブルームや被写界深度などはUnrealEngineに組み込まれた機能故にパラメータからしか見た目の調整ができず、特定の部分の処理を変えたいということが基本的にはできません。基本的というのはシェーダーとかエンジンをいじらなければという前提です。

それに対してポストプロセスマテリアルは、StaticMeshやSkeletalMeshなどで使用されているマテリアルと同様にノードを組み合わせて表現を作ることができるので、ユーザーの任意の画面効果を作成することができます。

ノードで組んだポストプロセスマテリアルなアウトライン

こんな回りくどい言い方をしましたが、冒頭にも書いたとおりポストエフェクト、この一言に尽きます。

BlendableLocationについて

マテリアルの種類をポストプロセスマテリアルに変更するとPost Process Material カテゴリが有効になります。その中のBlendable Location プロパティは感覚で理解するより仕組みを理解した方が扱いやすく且つ割と重要なので触れていきます。

Blendable Location

Blendable Locationはドロップダウンになっているため任意のものを選択できます。

初期値は『After Tonemapping』

まずはそれぞれを選択して見た目を確認してみます。確認する環境としては被写界深度とポストプロセスマテリアルなアウトラインを適用しているプロジェクトとします。今回はReplacing the TonemapperとSSR Inputの見た目と詳細について触れません。理由はシンプルに滅多に使われないので筆者に対して需要がないという面倒くさがり屋さんがひょっこりしてきたためです。

左から『After Tonemapping』『Before Tonemapping』『Before Translucency』

はい、このように見た目が異なります。これまた理由もシンプルでポストエフェクトのパスを挿入している位置が異なるためです。初っ端から文章にするとイメージしにくいと思うのでRenderDocさんでパス順を可視化しながら見ていきます。

After Tonemapping

まずはAfter Tonemappingからです。

見方としては上から順に画面に上書きされていくので、下の方にあるほど最後に描かれる感じです。この辺りは説明しても慣れないとなんとも分かりにくいところではあると思うので、とりあえず進めますね。

ポストプロセスマテリアルなアウトラインのパスの挿入位置はTonemapよりも後ろにあります。トーンマップはざっくりいうと色味調整をしているパスです。調整方法はちゃんとした計算式と別パスで作成したLook up Textureが絡むので省略しますが、適用前後では色味がだいぶ落ち着いていることが分かるかと思います。厳密には適用後の画像にはブルームも入っているのでだいぶ明るくなっちゃっているのですが、これはUnrealEngineの仕様なのです。

左がTonemapを適用前、右がTonemap適用後

After Tonemappingではトーンマップを適用せずに色が乗るため色味に違和感が出やすいです。逆にいえば指定したとおりの色が乗るため、使い方次第では有効にもなり得ます。

Tonemapの他にはTAAというアンチエイリアスやDOFという被写界深度なパスよりも後ろにあるため、このポストプロセスマテリアルな画面効果には、アンチエイリアス被写界深度が適用されていないことが分かります。そのためアウトラインがぼけていなかったり、エッジが尖っていたりするのです。

After Tonemappingはこんな感じです。このような特徴があるため、主に使用されるシーンとしては画面全体に適用する色味調整や明るさ調整などをしたい場合です。この全体というのが重要で、キャラクターや背景のみの色味を調整したい場合には、After Tonemappingでは出来ません。理由はアンチエイリアス被写界深度が適用されないためです。ポストプロセスマテリアル内でそれと同等の計算をすれば出来なくはないですが、負荷的にもあまり有効な手段とは言えないでしょう。

特徴の覚え方としては単語に区切るとよきです。
After → あと / 後ろ
Tonemapping → トーンマップ

トーンマップを適用したパスよりも後ろに挿入されるポストプロセスマテリアル、こんな感じです。

Before Tonemapping

次にBefore Tonemappingです。

After Tonemappingとは異なりTonemapよりも前にアウトラインパスが挿入されています。Beforeと命名されているのでまぁそのとおりではあるのですが。Before Tonemappingの特徴としては被写界深度よりも後ろで且つ、アンチエイリアスよりは前に適用される点です。

Before Translucency

最後にBefore Translucencyです。

ここまで来ると予想が付いているかもしれませんが、半透明よりも前に適用するポストプロセスマテリアルということです。まぁ厳密には分離半透明と被写界深度よりも前に適用するパスなのですが。

分離半透明というのが少々ややこしい存在なので説明しますが、UnrealEngineでは2種類の半透明が存在します。1つはSceneColorというメインのバッファに直接書き込む半透明、通称Translucency、もう1つは一旦SceneColorとは別のバッファに書き込み、被写界深度が適用された後のSceneColorと一旦書き込んだバッファで半透明合成をする分離半透明、通称Separate Translucencyの2つです。ちなみに分離半透明は筆者が適当に呼称しているだけで正式な日本語の名称はよく分かっていません。

左がTranslucencyなエフェクト、右がSeparateTranslucencyなエフェクト

分離半透明の存在意義ですが、エフェクトや半透明な物体に被写界深度を適用したくない時に使用されます。前述したとおり被写界深度が適用されたSceneColorに対して半透明合成をするので、当然このSeparate Translucencyには被写界深度が適用されないのです。なのでBefore Translucencyという名称ですが、実際の挙動的にはBefore Separate Translucency、もしくはAfter Translucencyなのですよね。Translucencyより前に適用されるポストプロセスマテリアルなんて存在しないんですから。UnrealEngineさんはよく分からないor事象とは異なる命名をすることがたまにあるので困ります。

AfterDOF

少し寄り道をして被写界深度と半透明について触れていきます。

マテリアルの設定を半透明にするとTranslucency カテゴリが有効になります。その中のTranslucency Pass プロパティについてです。これは半透明をどのパスで描画するかを指定する項目です。

Translucency Pass

Blendable Locationと同様にドロップダウンになっており、BeforeDOF / AfterDOF / After Motion Blurの3つが選択できます。BeforeDOFはTranslucency Pass、AfterDOFはSepate Translucny Passもしくは最適化の適用次第ではDOF PassでCompositeされ、After Motion BlurはAfterDOFのモーションブラー版です。

初期値は『After DOF』

Translucency Passの初期値はAfterDOFが選択されています。
されていますが、なぜでしょう、半透明にもDOFが適用されています。

After DOFなのにDOFが適用されている

原因はカメラ距離によってAfterDOFからBeforeDOFに切り替えるという処理が密かに行われるようになったことにあります。コードを覗いてみるとカメラの距離とDOFのフォーカス距離とコンソール変数が影響している感じです。

コンソール変数
被写界深度のフォーカス距離が影響
ドローコールを積む処理に影響

試しにコンソール変数のr.Translucency.AutoBeforeDOFを調整してみると切り替わるタイミングが分かりやすく変わります。値を小さくするほどBeforeDOFに切り替わりやすくなります。負の値を指定すると機能が無効になり、選択したTranslucency Passで描画されるようになります。

r.Translucency.AutoBeforeDOF -1.0

いやまぁそういう機能が欲しいなと思わないこともないですが、せめてデフォルトでは無効にしておくか、ある程度距離が離れてから適用されるような初期値にして欲しかったですね。最初は割とマジでバグかと思いました。

ちなみに調べてみたら5.2のリリースノートに記載があったのでバージョンアップされる方々はご留意くださいな。

カスタムノードで組んでみる

それではポストプロセスマテリアルで画面効果を作っていきます。何を作るかですが、実は全く決まっていなかったりします。考えるのも手間なのでShadertoyさんでいい感じのポストエフェクトがないか探してみます。

見つけました。NieRの画面効果っぽいやつとのコメントが記されていました。プレイ動画を過去に見たことがある程度であんまり興味なかったので、どのシーンで使われていたか微塵も覚えていないのですが、なんか、かっこいい感じですね。コード量も少なくて優しいので、これを移植しようと思います。

まずは普通にノードで組んでみました。ご覧のとおり可読性を重視すると横長になりやすいのがノードの欠点ですね。

ノードで組んでみた

次にカスタムノードで組んだ場合です。このようにだいぶすっきりしました。

カスタムノードで組んでみた

ちなみに見た目はこんな感じです。写っているモデルがかっこかわいい系なので結構似合いますね。

ノード vs カスタムノード

ノードで組む派とカスタムノードを主体に組む派の二大派閥が存在すると勝手に思っている筆者なのですが、皆さんはどちらの派閥に属しているでしょうか。筆者はどちらかというとシェーダーコードを書く機会が多いのでカスタムノード派だったりします。基本的には好きな方で作ればいい話なのですが、ポストプロセスマテリアルはその利用用途の性質上、SceneColorやGBufferにアクセスすることが多いです。そうなるといちいちSceneTexturesノードを配置してあれこれ繋げるのは面倒だったりするので、カスタムノードでシェーダーコード直書きした方が作業効率や保守性に軍配が上がるんじゃないかなといった感じです。とはいえ、カスタムノードで書いた中身のHLSLコードはエンジンアップデート後に補修が必要な場合があるという弱点もありますので、結局は本当に好みの問題ですね。

シェーダーコード

移植したコード全文です。GLSLからHLSLに置換していますが、行列オーダーが含まれていないためコピペで済みました。SceneTextureなんとかという見慣れない関数はUnrealEngine固有のものです。今回使用したものは基本的な関数なので触れていきます。はてなブログってシェーダー言語系のシンタックスハイライト、用意されていないんですかね。見づらくておこです。

float TickedTime = Time - fmod(Time, 1.0 / Frame);
float Alpha = frac(sin(dot(TickedTime.xx, float2(12.9898, 78.233))) * 43758.5453123);
float GlitchStep = lerp(GlitchStepMin, GlitchStepMax, Alpha);
UV.x = round(UV.x * GlitchStep) / GlitchStep;
float3 NewSceneColor = SceneTextureLookup(ClampSceneTextureUV(ViewportUVToSceneTextureUV(UV, PPI_PostProcessInput0), PPI_PostProcessInput0), PPI_PostProcessInput0, false).rgb;
return lerp(SceneColor, NewSceneColor, 0.3);

ViewportUVToSceneTextureUV

MaterialFloat2 ViewportUVToSceneTextureUV(MaterialFloat2 ViewportUV, const uint SceneTextureId)

これはビューポート座標からテクスチャ座標に変換している関数です。なんでこの変換が必要なのかというとUnrealEngineは動的解像度やエディタ操作で画面サイズを自由に変えられることに対応している為、皆さんが見ている画面サイズとレンダーターゲットサイズに乖離が発生することがあります。この問題に対応するために変換関数を通さないといけないのです。

ViewportSizeとRenderTargetSizeについて

仮にエディタ起動時の画面サイズが1440x628だとします。起動直後はビューポートサイズとレンダーターゲットサイズは同一です。もしかしたら多少違うかもしれませんがあくまで説明なのでそういうことにしてください。そして起動後にエディタの画面サイズを1440x628から704x340に小さくします。画面サイズを小さくしているだけなのでビューポートサイズはレンダーターゲットサイズに収まります。この場合にはレンダーターゲットサイズは変わりません。そのためサイズに乖離が発生するのです。

RenderDocでViewportSizeとRenderTargetSizeを可視化

なんでレンダーターゲットサイズを変えずに使い続けるのかというとシンプルにメモリの再確保にかかるコストダウンのためです。これに関してはなんとなく伝わればいいのですが704x340x任意のビット数のメモリを確保するわけです。画面サイズはマウスで引っ張って調整できるので、サイズを毎フレーム変えることもできます。先ほどのメモリサイズを毎回解放しては確保してなんてことをしていたら、なんとなく負荷高そうだなというのは分かりますよね。まぁそういうことが理由で新しいビューポートサイズがレンダーターゲットサイズに収まるのであれば再確保をしていないのです。

Viewport座標とRenderTarget / Texture座標

乖離が発生する理由は以上のとおりで次は変換が必要な理由についてです。ビューポート座標もテクスチャ座標も0.0 ~ 1.0で表現されます。UnrealEngineは左上が原点の(0.0, 0.0)で、右下が(1.0, 1.0)となっています。ちなみに先日投稿されたUnityさんは左下が原点の(0, 0)で、右上が(1.0, 1.0)となっていたり、こういう差分面倒です。

そしてサイズに乖離があるのにビューポート上の座標で、レンダーターゲットの情報を取得しようとするとこんなことが起きます。ビューポート上では画面中央の(0.5, 0.5)の位置を指しています。その辺りにはちょうどモデルの腕がありますね。さて、レンダーターゲットではどうでしょうか。真っ黒です。

Viewport座標(0.5, 0.5)とRenderTarget座標(0.5, 0.5)

そりゃそうですよね。サイズが異なるのですからテクスチャ座標から変換したら異なる位置指し示します。分かりやすく実数値にするとビューポートでは704x0.5→352, 304x0.5→152、レンダーターゲットでは1440x0.5→720, 628x0.5→314となり明らかに位置が異なることが分かるかと思います。

長々と話しましたがこれがビューポート座標からテクスチャ(レンダーターゲット)座標に変換しないといけない理由です。変換方法はピクセルの幅で上手いこと計算してあげることでビューポート座標からテクスチャ座標を求めることができます。計算例ではシェーダー言語を使っていますが実際にはCPU側で計算してコンスタントバッファにぶち込んでいます。動的解像度とテクスチャ座標という2つの前提知識が必要なので説明が長くなっちゃいましたね。

// ビューポートサイズは始点と終点から算出される
float2 ViewportMin = float2(0.0, 0.0);
float2 ViewportMax = float2(704.0, 304.0);
float2 ViewportSize = ViewportMax - ViewportMin;

// レンダーターゲットサイズ
float2 RenderTargetSize = float2(1440.0, 628.0);

// レンダーターゲットの1ピクセルあたりの幅
float2 InverseRenderTargetSize = 1.0 / RenderTargetSize;

// ビューポート基準 > レンダーターゲット基準
float2 UVViewportMin = ViewportMin * InverseRenderTargetSize;
float2 UVViewportMax = ViewportMax * InverseRenderTargetSize;
float2 UVViewportSize = UVViewportMax - UVViewportMin;

// ビューポート座標
float2 ViewportUV = float2(0.5, 0.5);

// ビューポート座標からレンダーターゲット座標に変換
float2 RenderTargetUV = ViewportUV * UVViewportSize + UVViewportMin;
SceneTextureId

第1引数にはビューポート座標、第2引数にはなんのテクスチャ座標に変換するのかを基本的にはマクロで指定します。マクロの定義はMaterialTemplate.ushにあります。数値直書きでもいいですがぱっと見、なにを指定しているのか分からなくなるので素直にマクロ名を指定するのがいいと思います。

MaterialTemplate.ush

ちなみにSceneTexturesノードでもさりげなくドロップダウンで選択していることだったりします。

SceneTexturesノード

ClampSceneTextureUV

MaterialFloat2 ClampSceneTextureUV(MaterialFloat2 BufferUV, const uint SceneTextureId)

UV座標をビューポートの範囲内に収めている関数です。一般的なUVクランプは0.0以上、1.0未満ですが、ビューポートサイズとレンダーターゲットサイズが異なることがある環境なため、このような関数を使用しないといけないのです。

注意点としてクランプ値はテクスチャフィルタリングにバイリニアを使用している前提で、ハーフピクセル分オフセットされた値になっています。基本的にはポイントが使用されるのであまり有用性を感じない仕様なのですが、公式仕様なので仕方なしですね。

ちなみにクランプではなく範囲外の座標が渡されたら処理をしないという記述をしたい場合には、ClampSceneTextureUV関数ではなく、GetSceneTextureUVMinMax関数を使用するといいです。戻り値はfloat4で、xyにUVの最小値、zwにUVの最大値が格納されています。

// アウトライン書いたりするときにオフセットする場合
UV += Offset;

// 0.5オフセットされている点にだけ注意
float4 UVMinMax = GetSceneTextureUVMinMax(PPI_PostProcessInput0);
if (UV.x >= UVMinMax.x && UV.y >= UVMinMax.y && UV.x < UVMinMax.z && UV.y < UVMinMax.w)
{
    // 画面内の深度だから考慮するよ
}
else
{
    // 画面外の深度は見ないとか
}

SceneTextureLookup

float4 SceneTextureLookup(float2 UV, int SceneTextureIndex, bool bFiltered)

SceneColorやGBufferを参照している関数です。SceneTextureLookup関数を使用する際の注意点として、カスタムノードの入力に最低でも1つはPostProcessInput0を選択したSceneTextureノードを繋げる必要があるという点です。厳密にはPostProcessInput0以外でも大丈夫で、SceneColorやGBuffer系ですね。Separate TranslucencyのようなGBufferとは別パスのものではダメです。試しにSceneTextureノードを外してコンスタントパラメータに変えてみるとこのようにシェーダーコンパイルエラーが出ます。

そんな関数定義されてないよというエラー

理由としてはSceneTextureノードを繋げないとNEEDS_SCENE_TEXTURESというフラグが立たないためです。そのフラグが存在しないとSceneTextureLookup関数、それ自体が存在しないことになります。

NEEDS_SCENE_TEXTURES

なんでこんなことをしているのかというのはシェーダーコンパイルコストの削減のためです。そのマテリアルがSceneColorやGBufferに一度もアクセスしていないのであれば、それに関連するコードを生成する理由がないですからね。この最適化の問題点は、初心者には分かりにくいという点でしょうか。ドキュメントにもろくに記載がないため、初見でこの現象を引いたらまぁまぁデバッグが大変だと思われます。

ちなみにbFilteredはテクスチャサンプラーにバイリニアを使用するか否かを指定するところです。ClampSceneTextureUV関数で説明した部分ですね。基本的にはfalseが指定されるのであんまり存在意義がないです。結局はアンチエイリアス処理で上手いことボケるので、下手にバイリニアを使うより素直にポイントを使うことが多いんです。

おわり!!!

お疲れさまでした!!!

割となんとなく使いがちな機能や関数について復習できたいい機会でした。After DOFの切り替え距離に関してはリリースノートでは完全に見逃していたので、これを書かなければいつかのエンジンアップデートであわあわするところでした。ブログって基本的にマジで面倒なんですけど、フィーリングで理解している部分を否が応にも言語化しないといけないので、意外と理解度をあっぷあっぷするのにちょうどいい媒体なのだなと思う筆者です。でも面倒だよね。

ポストプロセスマテリアル関連のエンジン改造について1つ紹介するつもりでしたが、思ったよりカスタムノードの説明が長ったらしくなってしまい筆者の言語化MPが尽きてしまいました。また今度にします。

余談ですが本来はマテリアルインプットの追加よりこちらを先に投稿する予定でした。その予定だったのですが、うちの簡易水冷さんが力尽きてしまい、エンジンビルドをすると5900Xさんの爆熱を抑えられずに確定でサーマルスロットをするという由々しき事態が発生したため、大人しく休日を寝て過ごした結果、投稿が次の週にズレてしまいました。