URPのモーションブラー実装を読んでみる

2023/01/02に公開

はじめに

URPのモーションブラーの実装を読んでみました。

環境

  • Unity 2021.3.16f1
  • Universal RP 12.1.8

URPのモーションブラー

URPのモーションブラーは、カメラの動きに対してのみ、ブラーをかけます。
動いているオブジェクトには、ブラーをかけません。

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

  1. カメラの1つ前のフレームのViewProject行列と、現在フレームのViewProject行列を利用して、画面上のピクセルの位置変化(速度)を求める
  2. 描画点から速度方向へ、テクスチャサンプリングを複数行い、その平均を取る

モーションブラーの大まかな仕組み

カメラが移動すると、オブジェクトが移動して見えます。
直前フレームと現在フレームの間には、残像が発生します。 (モーションブラー)

ここで、直前フレームのオブジェクトのある点Bに注目すると、点Bは点Aまで移動します。

ある点uvを描画する際、速度方向に複数回テクスチャサンプリングすることで
残像を求めることができます。

以上が、モーションブラーの大まかな仕組みになります。

モーションブラーのシェーダー

モーションブラーは、CameraMotionBlur.shader 内の DoMotionBlur 関数にて実装されています。
補足として、日本語のコメントを入れてます。

com.unity.render-pipelines.universal@12.1.8/Shaders/PostProcessing/CameraMotionBlur.shader
half4 DoMotionBlur(VaryingsCMB input, int iterations)
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);

    // シングルパスステレオレンダリング用のUV計算(VR向け)
    float2 uv = UnityStereoTransformScreenSpaceTex(input.uv.xy);

    // 速度を計算
    half2 velocity = GetCameraVelocity(float4(uv, input.uv.zw)) * _Intensity;

    // ランダム値の計算
    half randomVal = InterleavedGradientNoise(uv * _SourceSize.xy, 0);
    
    // iteration * 2 の逆数
    half invSampleCount = rcp(iterations * 2.0);

    half3 color = 0.0;

    UNITY_UNROLL
    for (int i = 0; i < iterations; i++)
    {
        // -velocityの方向へサンプリング
        color += GatherSample(i, velocity, invSampleCount, uv, randomVal, -1.0);

        // +velocityの方向へサンプリング
        color += GatherSample(i, velocity, invSampleCount, uv, randomVal,  1.0);
    }

    // 平均値を計算
    return half4(color * invSampleCount, 1.0);
}

GatherSampleメソッド

GatherSample メソッドは、ある点を指定して、その周囲のピクセルの色を取得するような関数です。

half3 GatherSample(half sampleNumber, half2 velocity, half invSampleCount, float2 centerUV, half randomVal, half velocitySign)
{
    half offsetLength = (sampleNumber + 0.5h) + (velocitySign * (randomVal - 0.5h));
    float2 sampleUV = centerUV + (offsetLength * invSampleCount) * velocity * velocitySign;
    return SAMPLE_TEXTURE2D_X(_SourceTex, sampler_PointClamp, sampleUV).xyz;
}

引数は以下のような意味を持ちます。

  • sampleNumber : サンプル点の番号 (0, 1, 2, ..., N-1)
  • velocity : スクリーン上での速度
  • invSampleCount : 2Nの逆数
  • centerUV : テクスチャUV座標
  • randomVal : 0.0 ~ 1.0のランダム値
  • velocitySign : -1, +1

ノイズを加算している理由

GatherSampleメソッド内でoffsetLength を計算する際、ノイズ値を乗じています。

ノイズを乗算
half  offsetLength = (sampleNumber + 0.5h) + (velocitySign * (randomVal - 0.5h));

以下のようにノイズを外した場合の見た目の変化を観察してみます。

half  offsetLength = (sampleNumber + 0.5h) + (velocitySign);

ノイズを無しのものと、ノイズありのものを横に並べてみます。

ノイズ無しのモノは、モデルの輪郭が見えています。(サンプル数が少なく見えます)
ノイズありのものは、モデルの輪郭が見えません(サンプル数が多く見えます)

ノイズを使ってサンプル数を増やして見せる手法は、ライトシャフトなどでも利用されるテクニックのようです。
参考 : https://sites.google.com/site/monshonosuana/directxの話/directxの話-第142回

offsetLength の 図形的なイメージ

offsetLength は サンプリング点 (sampleNumber)と、次のサンプリング点 (sampleNumber + 1) に挟まれる区間にある、ランダムな1点です。

half offsetLength = (sampleNumber + 0.5h) + (velocitySign * (randomVal - 0.5h));

下図の青い線分上のランダムな1点が offsetLengthになります。

sampleUV の図形的なイメージ

sampleUV は 以下のような計算になっています。

float2 sampleUV = centerUV + (offsetLength * invSampleCount) * velocity * velocitySign;
  • centerUV : テクスチャUV座標
  • offsetLength : sampleNumber ~ sampleNumber + 1 の間のランダム値
  • invSampleCount : iterations * 2 の逆数

sampleUV は、サンプリング点 centerUV から速度方向に伸ばした線分をN等分した区間(下図の青い線分)に関してサンプリングを行います。

青い線分上のランダムな点に関して、サンプリングを行います。

DoMotionBlurは平均を計算している

DoMotionBlur では ある点 uv に着目し、その周囲で複数回サンプリングを行った結果を足し合わせています。

  1. -velocity方向に伸ばした領域でN回サンプリング
  2. +velocity方向に伸ばした領域でN回サンプリング
  3. 1と2の結果を加算し、2Nで割る
half invSampleCount = rcp(iterations * 2.0);

UNITY_UNROLL
for (int i = 0; i < iterations; i++)
{
    // -velocityの方向へサンプリング
    color += GatherSample(i, velocity, invSampleCount, uv, randomVal, -1.0);

    // :velocityの方向へサンプリング
    color += GatherSample(i, velocity, invSampleCount, uv, randomVal,  1.0); 
}

return half4(color * invSampleCount, 1.0);

両方向へサンプリングしている理由

片方の方向だけにサンプリングした場合、
モーションブラーの結果が画面全体が片方にズレる、という不具合が発生します。

実験のため、モーションブラーを以下のように書き換えてみます。

片方だけサンプリング
half invSampleCount = rcp(iterations);

UNITY_UNROLL
for (int i = 0; i < iterations; i++)
{
    // velocityの方向へサンプリング
    color += GatherSample(i, velocity, invSampleCount, uv, randomVal, 1.0);
}

return half4(color * invSampleCount, 1.0);

結果は以下のようになります。
片方向にだけサンプリングしたものは、画面全体がズレます。

Discussion