【Unity】ブラーで疾走感のあるポストエフェクトを作る

# 概要
こういうエフェクトを作りました。
画面端のみに残像が生じるようになっているので、レースゲーム等で疾走感を出しつつプレイする上で重要となる画面中心の視認性を保つことが出来ます。
## GIF
![pillar_blur.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526299/c3ee348b-c52b-8051-c848-fccfd626eb63.gif)

![real_blur.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526299/8284be3c-59e1-e343-ad65-57dcc394e82c.gif)

## 静止画
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526299/ee483c5a-365d-2278-db77-de86b4ea2e0a.png)


# ソースコード
## シェーダ
```glsl:MotionBlur.shader
Shader "Hidden/MotionBlur"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _BlurSize ("Blur Size", Float) = 0
        _EdgeCoeff ("Edge Coefficient", Float) = 1
        _SpeedCoeff ("Speed Coefficient", Float) = 0
        _BlurCenterPoint ("Blur Center Point", Vector) = (0.5, 0.5, 0.0)
    }
    SubShader
    {
        // No culling or depth
        Cull Off ZWrite Off ZTest Always

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            sampler2D _MainTex;
            half _BlurSize;
            half _EdgeCoeff; // 端の方だけに効果をかけるために使用する係数。大きいほど端のみに効果が表れる
            half _SpeedCoeff; // スピードに応じて増減させる係数(0~1)。大きくするほど効果が強くなる。ゲーム側の最高速度で1に、停止中は0にする。
            half2 _BlurCenterPoint; // ブラーの中心となる点

            static const int BLUR_SAMPLE_COUNT = 8;
            // 近い点から遠い点に向かってサンプリングする際の重みづけ係数を設定していく
            // 総和が1になるようにする
            static const float BLUR_WEIGHTS[BLUR_SAMPLE_COUNT] = {
                1.0 / BLUR_SAMPLE_COUNT,
                1.0 / BLUR_SAMPLE_COUNT,
                1.0 / BLUR_SAMPLE_COUNT,
                1.0 / BLUR_SAMPLE_COUNT,
                1.0 / BLUR_SAMPLE_COUNT,
                1.0 / BLUR_SAMPLE_COUNT,
                1.0 / BLUR_SAMPLE_COUNT,
                1.0 / BLUR_SAMPLE_COUNT
            };

            float magnitude(float2 vec){
                return max(abs(vec.x), abs(vec.y));
            }

            // 画面上におけるブラーの中心点までの最大距離を計算する
            float calcMaxDistance()
            {
                // どの点が中心点だったとしても、最大距離を取るのは四角のどれか
                float distance1 = magnitude(float2(0, 0) - _BlurCenterPoint);
                float distance2 = magnitude(float2(1, 0) - _BlurCenterPoint);
                float distance3 = magnitude(float2(0, 1) - _BlurCenterPoint);
                float distance4 = magnitude(float2(1, 1) - _BlurCenterPoint);

                float maxDistance = max(distance1, max(distance2, max(distance3, distance4)));

                return maxDistance;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = 0;
                
                // ブラーの中心から該当ピクセルまでの方向ベクトル。このベクトルに沿ってサンプリングを行う
                float2 dir = i.uv - _BlurCenterPoint;

                // ブラーの中心からの距離。距離が遠いほど強くブラーがかかるようにする。
                // ここでアスペクト比を考慮すれば画面上下端に左右端と同じだけの効果を与えることが出来るかもしれない(今回は純粋に距離だけを見たいのでそうしない)
                float distance = magnitude(dir);

                // 方向ベクトルを正規化
                dir /= sqrt(dir.x * dir.x + dir.y * dir.y);

                // 画面の中心から最も遠い点までの距離が1になるように距離を正規化
                distance /= calcMaxDistance(); 

                distance = pow(distance, _EdgeCoeff); // distanceは0~1の範囲を取るので、累乗することで、より端の方だけを効果の対象にする事が出来る。

                for(int j = 0; j < BLUR_SAMPLE_COUNT; j++){
                    float2 samplePoint = i.uv - dir / BLUR_SAMPLE_COUNT * j * distance * _SpeedCoeff *_BlurSize;
                    col += tex2D(_MainTex, samplePoint) * BLUR_WEIGHTS[j] ;
                }

                return col;
            }
            ENDCG
        }
    }
}


```

## C#スクリプト
```MotionBlur.cs
using UnityEngine;

public class MotionBlur : MonoBehaviour
{
    [SerializeField] Material motionBlurMaterial;
    [SerializeField] float speed;

    private void Update()
    {
        // 十字キーの上下 or W,Sキーで前後に移動する処理
        float vertical = Input.GetAxis("Vertical");
        transform.position += Vector3.forward * vertical * speed * Time.deltaTime;

        motionBlurMaterial.SetFloat("_SpeedCoeff", Mathf.Abs(vertical));
    }

    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        Graphics.Blit(src, dest, motionBlurMaterial);
    }
}
```

# リポジトリ
https://github.com/Dumble009/EdgeMotionBlur

こちらのリポジトリにソースコードと、サンプルシーンを配置しています。Unityプロジェクトではないので、別途作成したプロジェクトにインポートしてください。

# 解説
シェーダコードの解説をします。

## パラメータ
まず使用するプロパティですが、以下の5つです。
```glsl
sampler2D _MainTex;
half _BlurSize;
half _EdgeCoeff; // 端の方だけに効果をかけるために使用する係数。大きいほど端のみに効果が表れる
half _SpeedCoeff; // スピードに応じて増減させる係数(0~1)。大きくするほど効果が強くなる。ゲーム側の最高速度で1に、停止中は0にする。
half2 _BlurCenterPoint; // ブラーの中心となる点
```
`_MainTex`にはエフェクト適用前のレンダリング結果が自動的に渡されます。
`_BlurSize`は変数名の通り、ブラーによって生じる残像の大きさを表しています。大きくすると残像が大きくなります。
`_EdgeCoeff`は残像が生じる範囲に影響を与えるパラメータで、値が**小さいほど広い範囲**に残像を生じさせます。
`_SpeedCoeff`はスクリプトから渡されるパラメータで、現在の速さが最高速度の何割程度なのかを0~１の範囲で設定します。
`_BlurCenterPoint`は残像が生じる中心点を操作することが出来るようになっており、残像が生じる範囲と残像の方向が影響を受けます。下の画像は、画面下端中央を中心点に設定した際の様子です。残像が上方向に延びている事が分かるかと思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526299/ad9f2608-49b6-b175-7559-b692f0d5b601.png)

定数として以下の2つを使用しています。
```glsl
static const int BLUR_SAMPLE_COUNT = 8;
// 近い点から遠い点に向かってサンプリングする際の重みづけ係数を設定していく
// 総和が1になるようにする
static const float BLUR_WEIGHTS[BLUR_SAMPLE_COUNT] = {
    1.0 / BLUR_SAMPLE_COUNT,
    1.0 / BLUR_SAMPLE_COUNT,
    1.0 / BLUR_SAMPLE_COUNT,
    1.0 / BLUR_SAMPLE_COUNT,
    1.0 / BLUR_SAMPLE_COUNT,
    1.0 / BLUR_SAMPLE_COUNT,
    1.0 / BLUR_SAMPLE_COUNT,
    1.0 / BLUR_SAMPLE_COUNT
};
```
`BLUR_SAMPLE_COUNT`はその名の通り、ブラーをかけるために、いくつの点から色を取得してきて合成するかを表す数です。細かくすればするほど負荷が上がりますが残像のクオリティが上がります。
`BLUR_WEIGHTS`は、各サンプリングポイントから取ってくる色にかける係数の配列です。0番が今エフェクトをかけているピクセルの色にかける係数で、7番がそのピクセルから最も遠いサンプリング点の色にかける係数です。デフォルトでは全て等しくしていますが、遠いサンプリング点ほど値を小さくする等工夫すれば綺麗な残像を作ることが出来るかもしれません。全ての係数の総和が1になるようにしなければ画面が全体的に明るくなったり暗くなったりします。

## フラグメントシェーダ
フラグメントシェーダの中では、まず初めにブラーの中心から該当ピクセルまでの距離と方向を計算します。
```glsl
float2 scale = _BlurSize;
fixed4 col = 0;

// ブラーの中心から該当ピクセルまでの方向ベクトル。このベクトルに沿ってサンプリングを行う
float2 dir = i.uv - _BlurCenterPoint;

// ブラーの中心からの距離。距離が遠いほど強くブラーがかかるようにする。
// ここでアスペクト比を考慮すれば画面上下端に左右端と同じだけの効果を与えることが出来るかもしれない(今回は純粋に距離だけを見たいのでそうしない)
float distance = magnitude(dir);

// 方向ベクトルを正規化
dir /= sqrt(dir.x * dir.x + dir.y * dir.y);

// 画面の中心から最も遠い点までの距離が1になるように距離を正規化
distance /= calcMaxDistance(); 

distance = pow(distance, _EdgeCoeff); // distanceは0~1の範囲を取るので、累乗することで、より端の方だけを効果の対象にする事が出来る。
```
方向ベクトル`dir`は後ほどサンプリングする点を決定するために使用します。
距離`distance`は後ほどサンプリングする距離に掛け合わせることで`distance`が大きいほど遠い点からサンプリングする＝ブラーが強くなるようにします。ここで、`distance`はブラーの中心とそこから最も遠い点の間の距離が1となるように正規化をします。
`calcMaxDistance`関数の中身は以下のようになっています。
```glsl
float calcMaxDistance()
{
    // どの点が中心点だったとしても、最大距離を取るのは四角のどれか
    float distance1 = magnitude(float2(0, 0) - _BlurCenterPoint);
    float distance2 = magnitude(float2(1, 0) - _BlurCenterPoint);
    float distance3 = magnitude(float2(0, 1) - _BlurCenterPoint);
    float distance4 = magnitude(float2(1, 1) - _BlurCenterPoint);

    float maxDistance = max(distance1, max(distance2, max(distance3, distance4)));

    return maxDistance;
}
```
中心点がどこにあったとしても、中心点から最も遠い点は四角のいずれかのはずなので、中心点から各角までの距離を計算し、その中で最大のものを返しています。ここで、`magnitude`はベクトルの大きさを計算する関数です。
```glsl
float magnitude(float2 vec){
    return max(abs(vec.x), abs(vec.y));
}
```
ここでは、L2ノルムではなくL1ノルムを使用しています。L2ノルムを使用すると、残像が歪んでしまうためです。
* L2ノルム
    ![norm2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526299/093134d2-097c-2524-ef73-725cde9c4d9b.png)
* L1ノルム
    ![norm1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526299/e961c75e-b6e6-01e3-63e1-06a012d9c59b.png)

好みの問題だと思いますが、L2ノルムだと角に近いほど残像が強くなるため、カーブを描くような残像が生じます。L1ノルムの残像の方が自然な気がします。ただし、超高速で移動するような演出をしたい場合はL2ノルムの方がかっこよかったりするかもしれません。

中心点からの距離と方向が計算出来たら、サンプリングを行いブラーをかけます。
```glsl
for(int j = 0; j < BLUR_SAMPLE_COUNT; j++){
    float2 samplePoint = i.uv - dir / BLUR_SAMPLE_COUNT * j * distance * _SpeedCoeff *_BlurSize;
    col += tex2D(_MainTex, samplePoint) * BLUR_WEIGHTS[j] ;
}
```
まずは、中心点に向かう方向ベクトル`-dir`を`BLUR_SAMPLE_COUNT`の数だけ分割し、`j`をかけます。つまり、`-dir`の方向にある`j`番目の点を表しています。ここに、中心からの距離、現在のスピード、ブラーの大きさを掛け合わせて、サンプリングする点を決めます。本来残像は中心に向かって発生するため、サンプリングするべきは中心から離れる`dir`方向にある点のはずです(外側にある点の色が中心に向かって滲んでくるため)。ここで中心に向かう向きの`-dir`を使用しているのは、中心から離れる方向にサンプリングを行うと、画面端にアーティファクトが生じてしまうためです。下のGIF画像を見ると、左端の座席が吸い込まれるようになっているのが分かると思います。これは、画面外の点をサンプリングしてしまうために発生する現象です。なので、画面中央に向かってサンプリングする事でその発生を防ぐことが出来ます。残像の向きについても、そもそもサンプリングの方向に依らず両側に色が滲むことから違和感は正直あまり無いと思います。
![artifact2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526299/ecc89575-2d37-f29d-b998-76a306b7d11f.gif)


# まとめ
普段シェーダコードはあまり書かないので何か無駄なことをやっていたりもっといいやり方があれば是非教えてください。