ポストプロセスボリュームのブレンドのお話


はじめに

 この記事は Unreal Engine 4 (UE4) その2 Advent Calendar 2020 16日目の記事です。

 ポストプロセスボリュームの挙動について色々自分でも曖昧な理解だったので詳しく調べてみました。
忙しくてあまり時間が無くて地味なネタになってしまいましたが、地味といいつつ調べ始めるとちょっとディープでした。

ポストプロセスボリューム

 ポストプロセスボリュームについて詳しい説明は公式ドキュメントを御覧ください。
 ポストプロセスボリュームに関するパラメーターはこのようになっています。

 Priorityに優先度、Blend Radiusで効果範囲、Blend Weightで効果の強さ、Enabledでボリュームが有効になり、Infinite Extent(Unbound)をチェックするとWorld全体に効果がかかるというのが基本的なところ。

ポストプロセスボリュームのブレンド

 ポストプロセスボリュームはレベルに配置された複数のボリュームどうしでブレンドが可能です。今回の記事はこのブレンドに重点を置いて説明していきます。ブレンドというのは要するに複数のボリュームのポストプロセス効果を混ぜ合わせる事です。その際の優先順位や距離や強さを上記のパラメーターで設定します。
 と、言ったものの、何がどうブレンドされるのかがもうひとつよくわからないので、調べてみました。

ポストプロセスマテリアル以外のブレンド

 ポストプロセスマテリアルが絡むと複雑になるので、まずはポストプロセスマテリアルを持たないボリュームの挙動について。
 結論からさくさく行きます。UE4のバージョンは4.25です。

【ポイント1】Blend Radiusはボリュームの外から効き始める

 ポストプロセスボリュームはボリュームの中にカメラが入ると有効になるのですが、Blend Radiusが設定されていると、ボリュームの境界からBlend Radiusの距離がBlend Weightの減衰距離として計算されます。中心からの距離では無く、ボリュームの境界からの距離であることに注意してください。ブレンドの必要が無い場合はBlend Radiusを0にしておく必要があります。

【ポイント2】複数のボリュームがある場合はBlend Radiusに応じてブレンドされる。

 Blend Radiusの減衰領域が他のボリュームと重なっている場合はそれぞれのボリュームがブレンドされます。

 実際にソースコードを見てみます。

World.cpp
static void DoPostProcessVolume(IInterface_PostProcessVolume* Volume, FVector ViewLocation, FSceneView* SceneView)
{
    const FPostProcessVolumeProperties VolumeProperties = Volume->GetProperties();
    if (!VolumeProperties.bIsEnabled)
    {
        return;
    }

    float DistanceToPoint = 0.0f;
    float LocalWeight = FMath::Clamp(VolumeProperties.BlendWeight, 0.0f, 1.0f);

    ensureMsgf((LocalWeight >= 0 && LocalWeight <= 1.0f), TEXT("Invalid post process blend weight retrieved from volume (%f)"), LocalWeight);

    if (!VolumeProperties.bIsUnbound)
    {
        float SquaredBlendRadius = VolumeProperties.BlendRadius * VolumeProperties.BlendRadius;
        Volume->EncompassesPoint(ViewLocation, 0.0f, &DistanceToPoint);

        if (DistanceToPoint >= 0)
        {
            if (DistanceToPoint > VolumeProperties.BlendRadius)
            {
                // outside
                LocalWeight = 0.0f;
            }
            else
            {
                // to avoid div by 0
                if (VolumeProperties.BlendRadius >= 1.0f)
                {
                    LocalWeight *= 1.0f - DistanceToPoint / VolumeProperties.BlendRadius;

                    if(!(LocalWeight >= 0 && LocalWeight <= 1.0f))
                    {
                        // Mitigate crash here by disabling this volume and generating info regarding the calculation that went wrong.
                        ensureMsgf(false, TEXT("Invalid LocalWeight after post process volume weight calculation (Local: %f, DtP: %f, Radius: %f, SettingsWeight: %f)"), LocalWeight, DistanceToPoint, VolumeProperties.BlendRadius, VolumeProperties.BlendWeight);
                        LocalWeight = 0.0f;
                    }
                }
            }
        }
        else
        {
            LocalWeight = 0;
        }
    }

    if (LocalWeight > 0)
    {
        SceneView->OverridePostProcessSettings(*VolumeProperties.Settings, LocalWeight);
    }
}

 ポイントは

float SquaredBlendRadius = VolumeProperties.BlendRadius * VolumeProperties.BlendRadius;

 なるほど、距離の2乗で減衰するのね・・・っていやこの変数使ってないやんw

          if (VolumeProperties.BlendRadius >= 1.0f)
          {
              LocalWeight *= 1.0f - DistanceToPoint / VolumeProperties.BlendRadius;

 ということで、Blend Radius内では距離に比例して減衰します。距離2乗で減衰した方が良い場合もありそうですけどね。ソースコードにEpicの迷いが感じ取られます(ほんとに?)。
 BlendRadiusが1以上ならBlendRadiusで減衰するのですが、ボリューム内はDistanceToPointが0になるので、LocalWeightにはBlendWeightが適用されます。Blend Radiusが1以上の場合、DistanceToPointはボリュームの外周からの距離なので、ボリュームから離れるとWeightが0に減衰していくことになります。

ポストプロセスマテリアルのブレンド

 ポストプロセスボリュームが持つ各種パラメーターは前述のとおり、ブレンドされたり上書きされたりします。ただし、ポストプロセスマテリアルに関してはボリュームごとに持つ数も機能も違うので、そう簡単には行きません。こちらも検証して、まずは結論から。

【ポイント1】ポストプロセスボリュームが1つだとブレンドされない。

 はい。1つだけポストプロセスボリュームを置いてもポストプロセスマテリアルのブレンドは機能しません。Blend Weightが0なら何もしない。0より大きければ100%有効になります。中間値はありません。
 ポストプロセスマテリアルには個別にWeightが指定できます。マテリアルの横のアレです。

 これもボリュームのBlend Weightと同じように0なら無効0より大きければ100%有効になります。
 ところでこれ、名称は何でしょう?ソースを追っていくと、FWeightedBlendableという構造体で定期されてます。

USTRUCT(BlueprintType)
struct FWeightedBlendable
{
    GENERATED_USTRUCT_BODY()

    /** 0:no effect .. 1:full effect */
    UPROPERTY(interp, BlueprintReadWrite, Category=FWeightedBlendable, meta=(ClampMin = "0.0", ClampMax = "1.0", Delta = "0.01"))
    float Weight;

    /** should be of the IBlendableInterface* type but UProperties cannot express that */
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category=FWeightedBlendable, meta=( AllowedClasses="BlendableInterface", Keywords="PostProcess" ))
    UObject* Object;

Weightですね。

 そしてポストプロセスボリュームが複数存在してボリューム間のブレンドが有効になっている状態ではWeightによりブレンドされます。これに関しては後ほど詳しく。

【ポイント2】ポストプロセスボリュームが複数ある場合は厄介。

 ここからが本番。ポストプロセスマテリアルのブレンドはいろいろ厄介です。ポストプロセスマテリアルが設定されて、Weightが0より大きい数値になっているボリュームが複数ある場合に初めて異なるボリュームのポストプロセスマテリアルどうしがWeightでブレンドされることになります。また、Blend RadiusによるWeightの減衰も有効になります。
 実際に検証してみましょう。
 こんなふうに2つのポストプロセスボリュームを置いてみました。左は画面が緑に、右は画面が赤になるマテリアルを置いてあります。まずは両方ともBlend Radiusは0にしてあります。
 左にカメラが入ると緑に右にカメラが入ると赤になります。

 まあ、シンプルな状態では期待どおりの結果です。しかし、ここからが複雑。
左のBlend Radiusは0のまま、右のBlend Radiusに2000を設定して、左のボリュームに入ると

 なんだか赤みがかった緑になります。赤のBlend Radiusの範囲内だと他のボリュームの中でも問答無用にブレンドされます。なのですが、この状態で緑の方のBlend Radiusは変更すると、完全な緑に戻ります。そして、一度この状態になると再びBlend Radiusを0にしても緑のままです。
 なんかバグっぽいぞ?と思わなくも無いですが、この状況を解消する方法があります。ポストプロセスボリュームのPriorityです。緑の方のPriorityを1にしておけば、赤の方をどういじっても緑のボリューム内には影響がありません。つまり他のボリュームとブレンドされたくない場合はPriorityを大きくしておけば良いみたいです。プライオリティの数値が同じだと後で変更された方が強くなるみたいです。ロード直後の場合はおそらくロードされる順番で決まるのかな。

【ポイント3】Infinite Extend(Unbound)は強い

 さて、もうひとつ厄介な話。Infinite Extend(Unbound)にチェックを入れるとWorld全体に影響する範囲無視のボリュームになるわけですが、コイツが強い、強すぎる。赤の方をこの設定にしてしますと緑の中にいても真っ赤になります。緑のPriorityを1にしてBlend Radiusを最大まで持っていくと緑になりますが、そうすると赤の中も緑になります。緑をIninite Extendにしても同じです。
 Infinite Extendのボリュームを置くときはワールドにボリュームをひとつだけ置く場合に限定したほうが良さそうです。

【まとめ】ブレンドを使う場合は、PriorityとBlend Radiusをきっちり設定しましょう。

  • Unboundなボリュームを置くときは他にポストプロセスボリュームを置かないほうが良い。
  • 複数配置する場合は、PrioirtyとBlend Radiusを意識して設定する。
  • Blend Radiusはボリュームの外にも影響を与える。
  • ポストプロセスマテリアルのBlend Radiusによる減衰、Weightによる強弱はポストプロセスマテリアルを持つボリュームが複数ある状態でのみ有効。

といったところです。けっこう複雑だし、気をつけないとハマりそうです。

ブレンドのプロセス

で、何がどうブレンドされるの?ソースコードを追いながら調べてみました。

 まずは各ポストプロセスボリュームがBlend WeightとBlend RadiusからWeightを計算します。その後SceneView.cpp内のOverridePostProcessSettingsでブレンド処理が行われます。
 各ボリュームのブレンドはFinalPostProcessSettingsという変数に対して行われていきますが、この変数の中身は

Scene.h
    void SetBaseValues()
    {
        *this = FPostProcessSettings();

        AmbientCubemapIntensity = 0.0f;
        ColorGradingIntensity = 0.0f;
    }

 のようにFPostProcessSettings構造体の初期値で初期化されています。そして、各ボリュームがブレンドされた結果が格納されたFinalPostProcessSettingsが描画に使用されます。
 実際のブレンドを行うのはSceneView.cppの
void FSceneView::OverridePostProcessSettings(const FPostProcessSettings& Src, float Weight)
です。長い関数なので引用はしません。興味のある人は自分で見てみると良いです。
 この関数が何をブレンドして何を上書きするかはソースを詳しく見てください。
 LERP_PP(パラメーター名)で処理されているのものはWeight値でLerp(補間)処理されます。ブレンドができないパラメーターや困難なパラメーター、例えばアルゴリズムの選択だったりテクスチャのアサインのようなパラメーターは上書きされます。ポストプロセスが持つ殆どのパラメーターがここで処理されます。

 ブレンド対象のポストプロセスボリュームはPriorityでソートされているので、Priorityが小さいものから順にブレンドされていきます。順番にブレンドされるということは、平均値では無いので、後でブレンドされるボリュームほど強く影響するということに注意しましょう。いくつボリュームがあってもプライオリティがもっとも大きくてカメラがボリューム内にあってBlend Weightが1になっているボリュームがあれば、最終的にそのボリュームだけが影響します。
 A, B, Cとプライオリティが低い順にブレンドされる際は、初期値とAがブレンドされ、その結果とBがブレンドされ、さらにその結果にCがブレンドされるといった感じです。その際のブレンド値はBlend Weightの値がBlend Radiusで減衰した値になります。プライオリティが低いボリュームの影響は低くなって行きます。
 ブレンドではなく上書きされるパラメーターは最後にブレンドされる、つまり最もプライオリティ値が高いボリュームのものが採用されます。つまりプライオリティの高いボリュームのBlend Weight値が低かったり、Blend Radiusぎりぎりだったとしても、プライオリティが有線されます。何がブレンドで何が上書きかを把握しておかないと思ったようにポストプロセスが機能しない可能性があるということになるので注意が必要です。大事なパラメーターを持つボリュームのプライオリティは高くしておきましょう。

 さて、ポストプロセスボリュームの各パラメーターはこのようにブレンドされて最終的なパラメーターが決まるのですが、ポストプロセスマテリアルに関してはちょっと特殊です。ポストプロセスマテリアルのブレンドはMaterial.cppの
void UMaterialInterface::OverrideBlendableSettings(class FSceneView& View, float Weight) const
で処理されます。ここでマテリアル内のScalar ParameterとVector Parameterを列挙してそれぞれのパラメーターに補完処理をします。ここがちょっと複雑で、ブレンド対象のポストプロセスボリュームのポストプロセスマテリアルと同じベースマテリアルだった場合は同名のパラメーターどうしでWeightで補間処理をし、そうでない場合は0と補間します。つまりパラメーターにWeightを乗算した値になります。

ポストプロセスマテリアルのブレンドで注意が必要な点をまとめると

パラメーターが補間の結果不適切な数値にならないか

 例えば補間で変化したパラメーターがゼロ除算を起こしたり、予期せぬ描画不具合を起こしたりする可能性があるかもしれません。

パラメーターではあるが、変化しては困る場合

 Weightで変化させる意図では無いパラメーターも補間されてしまいます。定数値的に設定していたパラメーター、例えば特定の色だったり、フレネルの調整値だったり、そういったパラメーターも補間されていしまいます。

ポストプロセスマテリアルがボリュームに設定した数だけ実行される

 ベースマテリアルが共通の場合は共有されて補間されますが、そうでないマテリアルは別個に描画されます。ポストプロセスマテリアルは基本、全画面の全ピクセルに処理をするので負荷が高めです。複雑なマテリアルになるとけっこう馬鹿にできません。それが大量に実行されると顕著な描画負荷になる可能性があります。

 以上を踏まえると、なるべく安全に複数のポストプロセスボリュームでポストプロセスマテリアルを運用する場合は次のような設定が良いのではと思いました。

  • 各ボリュームのBlend Radiusはなるべく重複しないように配置する。特に3つとか4つとか重複するとポストプロセスマテリアル以外の挙動も予想しにくくなる。
  • 各ボリュームのポストプロセスマテリアルの構成を揃える。そうしておけば変動させたくないパラメーターの値を揃えておけば補間されても変動しないし、ボリュームが重なったときに描画されるマテリアル数を抑えることができる。

 ブレンドの対象になるマテリラルパラメーターを限定する機能が欲しいなと思いました。

まとめ

 「ポストプロセスのブレンドってどうなってるの?」と聞かれて軽い気持ちで調べてみましたが、意外と闇が深かった感じがします。また、ポストプロセスマテリアルの検証中けっこう怪しい挙動がありました。Weightを0にしたはずのマテリアルがボリュームの境目で一瞬描画されたり、バグっぽい現象がけっこう起きます。ぶっちゃけあまり複雑なことをしないほうが良さそうな予感がしましたw
 たくさんのポストプロセスボリュームを配置して細かい制御をしているレベルをたまに見かけますが、本当にアーティストの期待通りの挙動してるんだろうか?と不安になります。
 Blend Radiusの範囲を視覚化する機能があると良いかもしれません。

 ちょっと駆け足で検証したので嘘を書いているかもしれません、気づいた人は指摘していただけると幸いです。
 今年ももう終わりです。コロナに翻弄された一年でしたが来年は落ち着けると良いですね。UE5が楽しみです。それではみなさん、良いお年を!