[UE5] HLSLでガウシアンぼかし(Gaussian Blur)エフェクト作成とパフォーマンス検証
1 はじめに
きっかけ
UEのプロジェクトでGaussian Blurエフェクトを使用したいと考えていました。Gaussian Blur自体はいろんなところで使用されている画像処理アルゴリズム(Unreal自身のMobile DoFも一例)ですが、実際にUnrealでの実装に関するリソースはあまり多くなさそうです。現在ネット上で共有されているいくつかの実装方法がありますが、多くは固定のkernel sizeとウェイトを使用しているため、ぼかし効果のカスタマイズとプロシージャル調整は難しいです。
また、Gaussian Blurアルゴリズムは、手法が異なる複数のバージョンが存在して、どれを使用すべきか一見判断が難しいと思います。そのため、今回私はHLSLとUEのCustomノードを利用して、4つのGaussian Blurアルゴリズムを実装しました。この記事では、それらのコードと実装方法をまとめて、UE内実際のパフォーマンスを比較しようと思います。
この記事のアルゴリズムは主にIvan Kutskirのブログを参考にしています。
使用した環境はUnreal Engine 5.6.1, Windows 11, DemoレベルはEpic GamesのSoul: Cityです.
2 アルゴリズムとUnrealでの実装
4つの方法はそれぞれ違いがありますが、根本的にいずれも「Custom」ノードを使って、HLSLシェーダーコードを実行するマテリアルを作って、Post Process Volumeで出力する形になります。多分HLSLを使わなくて、完全にノードでもアルゴリズムを実装できると思います。でもやはりHLSLを使うと、ビジュアルプログラミングより数学演算が読みやすくなり、コードを管理しやすくなりを感じるので、今回はHLSLで書きます。
2.1 二次元ガウシアンアルゴリズム(2D Gaussian)
まずは一番直接的、わかりやすいの2D Gaussianを実装します。ピクセル一つ一つに対して、周りのピクセルとの相対位置は変数の二次元ガウス関数がウェイトとして、色の加重平均値を計算します。この結果はGaussian Blurと呼ばれます。
ガウス関数の定義域は実数全体ですが、データは平均付近に集中するため、0±3σ間でデータをとる確率は99.73%、0±2.57σ間も99%あります(σはガウス分布の標準偏差)。そのため、周りの一定範囲内(2.57σもしくは3σ)のピクセルだけを計算しても、結果は大きく変わりません。その計算の範囲はkernelと呼ばれる正方形です。kernelの「半径」は2.57σ、「サイズ」は自身を含めて2*2.57σ+1と定義されます。
Unreal Engineで実装するには、まずポストプロセス用のマテリアルを作ります。マテリアルのDetails欄に、Material Domainは「Post Process」に設定し、その下Post Process Materialの部分でBlendable Locationは「Scene Color After Tonemapping」を設定します。(UE5.3以前はここの名前と選択肢は若干違うが、とりあえず最後の位置を選択すれば問題ないと思います。)
マテリアルエディター内で右クリックして、「Custom」ノードを追加します。DetailsでOutput TypeをCMFT Float 3を設定し、Inputsで入力パラメータを4つ(SceneTexture、Size、InvSize、Sigma)追加します。そしてこのCustomノードの出力ピンから、マテリアルのEmissive Colorピンまで接続します。
SizeはScene Textureの解像度、InvSizeはSizeの逆数で、ピクセルのサイズ(Texel Size)です
SceneTextureノードを追加し、Details欄でScene Texture IdをPostProcessInput0に設定します。ノードのColor、Size、InvSizeピンをそれぞれCustomノードのSceneTexture、Size、InvSizeインプットに接続します。これでHLSLコードからScene Texture(すなわちゲームの画面)のUVを取得し、テクスチャをサンプルできるようになります。
いよいよ本番のHLSL。Customノード内に入れるコードは以下のようになります。二重forループでkernel内のピクセルを走査して、二次元ガウス関数でウェイトを計算し、そのピクセルの色を加重して結果に加算します。
struct GaussianBlur
{
// The 2D Gaussian Function
float Gaussian2D(float x, float y, float sigma)
{
float PI = 3.14159265f;
return exp(-(x * x + y * y) / (2.0f * sigma * sigma)) / (2.0f * PI * sigma * sigma);
}
} GB;
// Getting UV of the current pixel, requires SceneTexture input to work
// 14 is the ID for PostProcessInput0
float2 SceneUV = GetDefaultSceneTextureUV(Parameters, 14);
// 2.57sigma provides 99% coverage of data points in Gaussian distribution
// KernelSize = 2 * HalfKernelSize + 1
int HalfKernelSize = ceil(2.57f * Sigma);
float WeightSum = 0.0f;
float4 Result = float4(0.0f, 0.0f, 0.0f, 0.0f);
for (int j = -HalfKernelSize; j <= HalfKernelSize; j++)
{
for (int i = -HalfKernelSize; i <= HalfKernelSize; i++)
{
// UV of the current kernel pixel
float2 OffsetUV = SceneUV + float2(i, j) * InvSize;
OffsetUV = saturate(OffsetUV);
// Sample Scene Texture ID 14 (PostProcessInput0)
float4 SceneOffsetSample = SceneTextureLookup(OffsetUV, 14, false);
// Calculate Gaussian Weight
float GaussianWeight = GB.Gaussian2D(i, j, Sigma);
Result += GaussianWeight * SceneOffsetSample;
WeightSum += GaussianWeight;
}
}
// Normalize
Result /= WeightSum;
return Result;
完成したマテリアルはこのようになります。
次は、Post Process Volumeをレベルに追加します。Infinite Extend (Unbound)を有効にして、Rendering→Post Process MaterialsでArray Elementを追加し、Asset Referenceをクリックし、先に作ったポストプロセスマテリアルを選択します。
これにてぼかし効果がViewportに反映されます。
エフェクトを実現しましたが、このアルゴリズムの時間計算量はO(n²)で、加えてガウス関数の計算はGPUにとっては比較的に非効率です。強いぼかし(σが大きい)が必要の場合は、フレームレートが激しく低下してしまいます。
2.2 二段階一次元ガウシアンアルゴリズム(1D Gaussian)
二次元ガウス関数は変数分離ができます。つまり、2.1で使用された二変数のウェイト計算式は、それぞれ変数一つだけ含む二つの式の積として表示できます。
(UMD)
したがって、kernel走査のために使った遅い二重forループは、独立する二つのループに置き換えることができます(2-pass)。最初は水平方向のピクセルだけで一次元ガウス加重平均を計算して、次のパスでは前の結果をインプットとして、垂直方向を走査してもう一回加重平均を計算します。最終の結果は2.1と同じですが、時間計算量はO(n²)からO(n)に大きく向上します。
HLSLシェーダーコードはGPUで並行計算するので、一つマテリアル内他のピクセルの計算結果読み取りは不可能です。そのため、UEで実装するにはマテリアルを二つ用意しなければならない。
そして、二つマテリアル内のSigmaパラメータを一括変更できるように、Material Parameter Collectionを作って、Scalar Parameterを追加し、そこでSigmaを定義します。
Material Parameter Collectionの設定
MPCを作成したら、マテリアルエディターでCollection Parameterを追加し、コレクションとパラメータ名を選択するとパラメータを使用できます。
HLSLコードはそれぞれ以下のようです。
//------------------Horizontal 1D Gaussian Filter---------------------//
struct GaussianBlur
{
float Gaussian1D(float x, float sigma)
{
float PI = 3.14159265f;
return exp(-(x * x) / (2.0f * sigma * sigma)) / (sqrt(2.0f * PI) * sigma);
}
} GB;
float2 SceneUV = GetDefaultSceneTextureUV(Parameters, 14);
int HalfKernelSize = ceil(2.57f * Sigma);
float WeightSum = 0.0f;
float4 Result = float4(0.0f, 0.0f, 0.0f, 0.0f);
for (int i = -HalfKernelSize; i <= HalfKernelSize; i++)
{
float2 OffsetUV = SceneUV + float2(i, 0.0f) * InvSize;
OffsetUV = saturate(OffsetUV);
float4 SceneOffsetSample = SceneTextureLookup(OffsetUV, 14, false);
float GaussianWeight = GB.Gaussian1D(i, Sigma);
Result += GaussianWeight * SceneOffsetSample;
WeightSum += GaussianWeight;
}
Result /= WeightSum;
return Result;
//------------------Horizontal 1D Gaussian Filter---------------------//
//------------------Vertical 1D Gaussian Filter---------------------//
struct GaussianBlur
{
float Gaussian1D(float x, float sigma)
{
float PI = 3.14159265f;
return exp(-(x * x) / (2.0f * sigma * sigma)) / (sqrt(2.0f * PI) * sigma);
}
} GB;
float2 SceneUV = GetDefaultSceneTextureUV(Parameters, 14);
int HalfKernelSize = ceil(2.57f * Sigma);
float WeightSum = 0.0f;
float4 Result = float4(0.0f, 0.0f, 0.0f, 0.0f);
for (int j = -HalfKernelSize; j <= HalfKernelSize; j++)
{
float2 OffsetUV = SceneUV + float2(0.0f, j) * InvSize;
OffsetUV = saturate(OffsetUV);
float4 SceneOffsetSample = SceneTextureLookup(OffsetUV, 14, false);
float GaussianWeight = GB.Gaussian1D(j, Sigma);
Result += GaussianWeight * SceneOffsetSample;
WeightSum += GaussianWeight;
}
Result /= WeightSum;
return Result;
//------------------Vertical 1D Gaussian Filter---------------------//
Post Process Volumeにポストプロセスマテリアルを一つずつ追加したら、二段階ポストプロセスで同じGaussian Blurエフェクトになります。
2.3 ガウシアンぼかしの三重ボックスぼかし近似(3-Pass Box Blur)
ガウス関数によってピクセルごとのウェイトが異なるのGaussian Blurと違って、Box Blurというぼかしアルゴリズムには同じウェイトを使っています。つまり、簡単な算術平均で結果を得られます。したがって、Box Blurは処理速度が優れる一方、kernel外側のピクセルの影響を受けやすいため、画面に不自然な格子状パターンが出るデメリットもあります。
Box Blurから生じた格子状パターン(Wikipedia)
しかし、実はBox Blurを複数回実行することで、Gaussian Blurの効果を近似することができます(中心極限定理により)。Ivankのブログによると、Box Blurを3回繰り返すと、Gaussian Blurと比べてピクセルあたりの平均誤差は0.04%で十分近くなります。これに通して、速くて簡単なアルゴリズムでもほぼ同じ効果が得られます。
Ivankのブログで、σ値から、Box Blur各パスの適切なkernel radiusの計算方法を詳しく説明しています。これはUEで実装したいなら、ヘルパーActorを用いて、Begin Playで正確なkernel sizeを計算して、Material Parameter Collectionに更新する必要があります。
Blueprintで作った、半径を計算して、結果をMPCに更新する仕組みの例
それはやはりちょっとめんどくさいと思います。もっとシンプルなアプローチにするために、パターンを探してみました。3パスの場合、σ値とkernel半径の大まかな関係をまとめました。
σ(nは0を含む自然数) | Pass 1半径 | Pass 2半径 | Pass 3半径 |
---|---|---|---|
n < σ <= n + 0.33 | n-1 | n | n |
n + 0.33 < σ <= n + 0.67 | n | n | n |
n + 0.67 < σ <= n + 1 | n | n | n+1 |
ご覧の通り、Box Blurの半径実はσ値と大きく変わりません。そのため、σが比較的大きい限り、半径値をすべてfloor(n)に設定しても相当似た結果が得られるかもしれません。
この関係は、同じくO(n²)の時間計算量でありながら、3-Pass Box Blurが2D Gaussianより実行速度が速いの原因も繋がります。Gaussian Blurではkernel半径として2.57σが使用されますが、ここでは約1σだけです。つまり、1パスで処理されるピクセル数は2.57 * 2.57で約6.6倍少なくなり、3回繰り返しでも計算量が少ないことをわかります。
float2 SceneUV = GetDefaultSceneTextureUV(Parameters, 14);
int Radius = round(BoxRadius);
int KernelSize = 2 * Radius + 1;
float BoxWeight = 1.0f / float(KernelSize * KernelSize);
float4 Result = float4(0.0f, 0.0f, 0.0f, 0.0f);
for (int j = -Radius; j <= Radius; j++)
{
for (int i = -Radius; i <= Radius; i++)
{
float2 OffsetUV = SceneUV + float2(i, j) * InvSize;
OffsetUV = saturate(OffsetUV);
float4 SceneOffsetSample = SceneTextureLookup(OffsetUV, 14, false);
Result += SceneOffsetSample * BoxWeight;
}
}
return Result;
2.2と同じような流れで、マテリアルを三つ実装します。
各パスでマテリアルの内容は変わらないので、Material Functionでノードをカプセル化して、メンテナンス性を高めることができます。
Material FunctionのDetails欄でExpose to Libraryを有効にしたら、マテリアル内で使用可能となります。
ここで注意すべきのは、マテリアル内容は一緒にも関わらず、一つマテリアルをPost Process Volumeに3回重複設定しても、思った通り作動しません。UE自身の最適化機能かもしれませんが、重複するマテリアルは一回だけ実行されます。
実装したら、3-Pass Box BlurはGaussian Blurによく似た結果をもたらすことが確認できます。ただし、この近似アプローチの弱点は、σが小さい場合は成り立てません。例えば、σ=1の場合、半径値は0, 0, 1となりますが、これは単にBox Blur一つだけになってしまって、Gaussian Blurを近似することができません。
2.4 三重一次元ボックスぼかし(3-Pass 1D Box Blur)
二次元ガウス関数の分解と同様に、2D Box Blurも水平、垂直両方向の一次元算術平均の積に分解できます。理論的に、これでアルゴリズムがより一層速くなれます。ですが、これの実装にマテリアルは六つも要り、準備するのは大変です。
// -----------------BoxBlur1D Horizontal--------------------
float2 SceneUV = GetDefaultSceneTextureUV(Parameters, 14);
int Radius = round(BoxRadius);
float BoxWeight1D = 1.0f / float(2 * Radius + 1);
float4 Result = float4(0.0f, 0.0f, 0.0f, 0.0f);
for (int i = -Radius; i <= Radius; i++)
{
float2 OffsetUV = SceneUV + float2(i, 0) * InvSize;
OffsetUV = saturate(OffsetUV);
float4 SceneOffsetSample = SceneTextureLookup(OffsetUV, 14, false);
Result += SceneOffsetSample * BoxWeight1D;
}
return Result;
// -----------------BoxBlur1D Horizontal--------------------
// -----------------BoxBlur1D Vertical--------------------
float2 SceneUV = GetDefaultSceneTextureUV(Parameters, 14);
int Radius = round(BoxRadius);
float BoxWeight1D = 1.0f / float(2 * Radius + 1);
float4 Result = float4(0.0f, 0.0f, 0.0f, 0.0f);
for (int j = -Radius; j <= Radius; j++)
{
float2 OffsetUV = SceneUV + float2(0, j) * InvSize;
OffsetUV = saturate(OffsetUV);
float4 SceneOffsetSample = SceneTextureLookup(OffsetUV, 14, false);
Result += SceneOffsetSample * BoxWeight1D;
}
return Result;
// -----------------BoxBlur1D Vertical--------------------
もちろん結果は2.3と同じです。
3 パフォーマンス比較
これまで実装してきた四つのアルゴリズム、実際に作動する時のパフォーマンスを検証するために、同じシーンでそれぞれσが5、20、80を設定した場合のフレームレートを比較しました。
基準(ぼかしなし)
σ = 5(順次で2D Gaussian, 1D Gaussian, 2D Box, 1D Box)
σ = 20
σ = 80
結果は、2D Gaussianは予想通りの最下位。3-Pass Box Blurは数倍ぐらい速くなるが、さすがO(n²)のアルゴリズムはσ=20以上だと厳しいです。一方O(n)のアルゴリズムは、どんなσでも相対的な高速で出力できます。
ちょっと意外なのは、1D Gaussianと1D Boxは、ほぼ同じフレームレートのことです。2D Boxは明らかに2D Gaussianより軽かったのに、1D Box Blurは1D Gaussian Blurと差をつけませんでした。あくまでは私の推測ですが、Scene Textureを6回繰り返して読み書きすることで時間を無駄に費やしたのは原因かもしれません。
話は少し変えますが、ここで注意すべきの点があります。4つの実装すべては、ViewportでプレビューするまたはPIEでプレーする際に、画面の右端と下端が暗くなったり、ランダムの色が入ったりといった現象があります。これは多分、Editor内でScene TextureのUVが[0, 1]の範囲ではないのバグが原因と考えています。これはすでにEpic GamesがKnown Issuesに問題として記載されていたバグだそうです。StandaloneゲームとHigh Resolution Screenshotでは問題は発生しません。
色バグの例。Editor Viewportだけに影響されているようです。
(Epic Developer Community)
4 まとめ
今回は、ポストプロセスマテリアルとHLSLを用いて、Unreal Engineで四種類のGaussian Blurエフェクトを実装しました。検証の結果、1D Gaussianと1D Boxはどちらも速いアルゴリズムですが、必要マテリアル数と小さいσ時への適用性を含めて考えると、1D Gaussianはこの中でUEで使用するのが最適な選択肢と言えるでしょう。
Discussion