Unityのポストプロセスでガウシアンブラーを実装するには

Unityのポストプロセスでは、ブラーエフェクトが提供されていません。そのため、ぼかし表現をするためには、自分でブラーを実装しなければならないのですが、ブラーにもいくつか種類があります。今回は、そんなブラーの中でも、ガウシアンブラーと呼ばれるブラーについて解説していきたいと思います。

ブラーエフェクトの基本

まずは、ブラーの基本について、おさらいしておく必要があります。なぜなら、ガウシアンブラーの大部分が平均ブラーといっても過言ではないからです。

この記事では、平均ブラーで説明してあることについては、ある程度省略しています。まだ平均ブラーの実装を見たことがないという方は、ぜひこちらの記事をご覧ください。

平均ブラーとの違い

平均ブラーとの大きな違いは、色のサンプリングに重みづけをして平均にバイアスをかけているという点です。そのため、平均ブラーでは目立ちがちだった輪郭線を回避することができます。

ガウス関数

ガウシアンブラーの肝となる加重平均において、重要となってくるのはガウス関数という計算公式です。ガウス関数について、詳しい定義は以下の通りです。

$f(x) = A exp (\frac {- x ^ {2}} {2 \sigma ^2} )$
$exp: 自然対数$
$x: 基準となるテクセルからの距離$
$ \sigma : 標準偏差$
$A : 定数(\frac{1}{\sqrt{2\pi}\sigma}を使うことが多いです)$

この図が意味するところは、基準となるテクセルに近いほどサンプリングの強度が強くなり、逆に遠いほどサンプリングの強度が弱くなる、ということです。平均ブラーではどの距離でも均等にサンプリングしていたところを、ガウシアンブラーでは傾斜をつけてサンプリングします

そして、このガウス値をシェーダー内で計算すると、以下のようになります。expはhlslが提供する自然対数を計算するメソッドです。

inline float GetGaussianWeight(float distance)
{
    return exp((- distance * distance) / (2 * _Dispersion * _Dispersion)) / _Dispersion;
}

重みづけを決め打ちする場合

ガウス関数について説明してきましたが、実は計算そのものをシェーダー内部に実装する必要はあまりなかったりします。

というのも、実際に標準偏差やサンプリング数を動的に変化させると、変数をすこし動かすだけでも、明るさが極端に変化しやすいためです。

これは体感的な法則になりますが、標準偏差とサンプリング数の比率が、

$標準偏差 : サンプリング数 = 2 : 1$

というような関係のときに、明るさが元の画像と同じような感じになります。

つまり、適当な数値にしてしまうと明るさがコントロールできなくなってしまうため、ガウシアンブラーにおいては、標準偏差やサンプリング数を動的に変化させるメリットはほとんどないといえるでしょう。そのため、多くの実装例では、ガウス関数を定数として決め打ちしてしまうケースが多く見受けられます。

もし、ガウス関数をあらかじめ計算しておくなら、以下のような実装にしておくといいでしょう。

//////ガウス関数を事前計算した重みテーブル
float weights[8] = 
{
    0.12445063, 0.116910554, 0.096922256, 0.070909835,
    0.04578283, 0.02608627,  0.013117,    0.0058206334
};
//////

//////ウェイトを決め打ちする場合
fixed4 color = 0;
for (int j = 0; j < 8; j++) 
{
    float2 offset = dir * ((j + 1) * _TexelInterval - 1);
    color.rgb += tex2D(_MainTex, i.uv + offset) * weights[j];
    color.rgb += tex2D(_MainTex, i.uv - offset) * weights[j];
}
color.a = 1;
//////

ブラーサイズを大きくする場合

平均ブラーと違って、ガウシアンブラーのいいところは、ブラーサイズを大きくしても粗が目立たない点です。そのため、ガウシアンブラーを実装する場合は、ブラーサイズを可変にしておいたほうがいいでしょう。

ブラーサイズを可変にする場合は、サンプリングするテクセルの間隔を動的に変化させます。具体的には、以下のようにオフセット値に変数を挟み込んでください。

float2 dir = _Direction * _MainTex_TexelSize.xy;
fixed4 color = 0;
for (int j = 0; j < _SamplingTexelAmount; j++) 
{
    float2 offset = dir * ((j + 1) * _TexelInterval - 1) ; //_TexelIntervalでサンプリング距離を調整
    float weight = GetGaussianWeight(j + 1); //ウェイトを計算
    color.rgb += tex2D(_MainTex, i.uv + offset) * weight; //順方向をサンプリング&重みづけして加算
    color.rgb += tex2D(_MainTex, i.uv - offset) * weight; //逆方向をサンプリング&重みづけして加算
}

C#によるオフスクリーン処理

平均ブラー同様に、ガウシアンブラーでもC#によるオフスクリーン処理が必要です。詳しい処理は平均ブラーとほとんど変わらないので、平均ブラーの解説(こちら)をご覧ください。詳しいコードは、以下の通りです。

using UnityEngine;

// !Cameraコンポーネントを持つゲームオブジェクトにアタッチしてください!
// ExecuteInEditMode            : プレイしなくても動作させる
// ImageEffectAllowedInSceneView: シーンビューにポストエフェクトを反映させる
[ExecuteInEditMode, ImageEffectAllowedInSceneView]
public class GaussianBlurEffectInserter : MonoBehaviour
{
    [SerializeField]
    private Material _material;

    private int _Direction;

    private void Awake()
    {
        _Direction = Shader.PropertyToID("_Direction"); //プロパティIDを取得
    }

    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        /////////横幅を半分にしたレンダーテスクチャを作成(まだ、なにも描かれていない)
        var rth = RenderTexture.GetTemporary(src.width / 2, src.height);

        var h = new Vector2(1, 0); //ブラー方向のベクトル(U方向)
        _material.SetVector(_Direction, h); //シェーダー内の変数にブラー方向を設定

        Graphics.Blit(src, rth, _material);
        /////////

        /////////先のテクスチャサイズから、さらに縦半分にしたレンダーテスクチャを作成(まだ、なにも描かれていない)
        var rtv = RenderTexture.GetTemporary(rth.width , rth.height / 2);

        var v = new Vector2(0, 1); //ブラー方向のベクトル(V方向)        
        _material.SetVector(_Direction, v); //シェーダー内の変数にブラー方向を設定

        Graphics.Blit(rth, rtv, _material); // ブラー処理を行う
        /////////

        Graphics.Blit(rtv, dest, _material); //元サイズから1/4になったレンダーテクスチャを、元のサイズに戻す

        RenderTexture.ReleaseTemporary(rtv); //テンポラリレンダーテスクチャの開放
        RenderTexture.ReleaseTemporary(rth); //開放しないとメモリリークするので注意
    }
}

シェーダー全体

ここまでの内容をまとめたシェーダーの全容は、以下の通りです。ウェイトを決め打ちするパターンはコメントアウトしていますので、必要な方はご自身で切り替えてください。なお、前述したC#スクリプトをカメラにアタッチしないと動作しないので、ご注意ください。

Shader "CAGraphicsAcademy/GaussianBlur"
{
    Properties{
        [HideInInspector] _MainTex("Texture", 2D) = "white" {}
        _Dispersion("Dispersion", float) = 1 //分散具合を調整
        _SamplingTexelAmount("Sampling Texel Amount", int) = 1 //何個先のテクセルまでサンプリングするか
        _TexelInterval("Texel Interval", float) = 2 //サンプリングするテクセルの間隔
    }

    SubShader
    {
        Cull Off        // カリングは不要
        ZTest Always    // ZTestは常に通す
        ZWrite Off      // ZWriteは不要

        Tags { "RenderType" = "Opaque" }

        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;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            // _MainTex_TexelSize
            // x : 1.0 / 幅
            // y : 1.0 / 高さ
            // z : 幅
            // w : 高さ
            float2 _MainTex_TexelSize; //テクセルサイズ

            float2 _Direction; //C#から渡されるブラーをかける方向の変数
            float _Dispersion;
            int _SamplingTexelAmount;
            float _TexelInterval;

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

            float GetGaussianWeight(float distance);

            fixed4 frag(v2f i) : SV_Target
            {
                float2 dir = _Direction * _MainTex_TexelSize.xy; //サンプリングの方向を決定

                /*
                //////ガウス関数を事前計算した重みテーブル
                float weights[8] = 
                {
                    0.12445063, 0.116910554, 0.096922256, 0.070909835,
                    0.04578283, 0.02608627,  0.013117,    0.0058206334
                };
                //////

                //////ウェイトを決め打ちする場合
                fixed4 color = 0;
                for (int j = 0; j < 8; j++) 
                {
                    float2 offset = dir * ((j + 1) * _TexelInterval - 1); //_TexelIntervalでサンプリング距離を調整
                    color.rgb += tex2D(_MainTex, i.uv + offset) * weights[j]; //順方向をサンプリング&重みづけして加算
                    color.rgb += tex2D(_MainTex, i.uv - offset) * weights[j]; //逆方向をサンプリング&重みづけして加算
                }
                color.a = 1;
                //////
                */
                
                //////ウェイトを動的に導出する場合
                fixed4 color = 0;
                for (int j = 0; j < _SamplingTexelAmount; j++) 
                {
                    float2 offset = dir * ((j + 1) * _TexelInterval - 1) ; //_TexelIntervalでサンプリング距離を調整
                    float weight = GetGaussianWeight(j + 1); //ウェイトを計算
                    color.rgb += tex2D(_MainTex, i.uv + offset) * weight; //順方向をサンプリング&重みづけして加算
                    color.rgb += tex2D(_MainTex, i.uv - offset) * weight; //逆方向をサンプリング&重みづけして加算
                }
                //////
                return color;
            }

            inline float GetGaussianWeight(float distance)
            {
                return exp((- distance * distance) / (2 * _Dispersion * _Dispersion)) / _Dispersion;
            }
            ENDCG
        }
    }
}

まとめ

ガウシアンブラーは、平均ブラーと比べてより自然なボケ味を表現できます。とくに平均ブラーはブラーサイズの調整が難しいため、ガウシアンブラーのほうが使いやすいといえるでしょう。ガウス関数という小難しい数式が登場しますが、数値は決め打ちしてしまっても問題ないので、それほど実装難度は高くありません。ブラー表現にお困りの方は、ぜひ実装してみてはいかがでしょうか。

返信を残す

メールアドレスが公開されることはありません。