【Unity】【シェーダ】ブルームを独自実装する

ポピュラーなポストエフェクトであるブルームを実装してみます。

ブルーム?

ブルームとは、明るい部分から光が漏れ出るような表現です。
この現象は夜道で街灯を見たときなど、人間の目でも確認できます。

実装の考え方はシンプルで、

  1. 一定以上の明度の色を抽出
  2. それらをぼかしつつ加算
  3. 元の画像と合成

これだけです。

f:id:halya_11:20180314230921p:plain:w500

ソースコード

まずシェーダから。

Shader "Bloom"
{
    Properties{
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        CGINCLUDE
       #pragma vertex vert
       #pragma fragment frag
            
       #include "UnityCG.cginc"

        sampler2D _MainTex;
        float4 _MainTex_ST;
        float4 _MainTex_TexelSize;
        sampler2D _SourceTex;
        float _Threshold;
        float _Intensity;

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

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

        half3 sampleMain(float2 uv){
            return tex2D(_MainTex, uv).rgb;
        }

        half3 sampleBox (float2 uv, float delta) {
            float4 offset = _MainTex_TexelSize.xyxy * float2(-delta, delta).xxyy;
            half3 sum = sampleMain(uv + offset.xy) + sampleMain(uv + offset.zy) + sampleMain(uv + offset.xw) + sampleMain(uv + offset.zw);
            return sum * 0.25;
        }

        // 明度を返す
        half getBrightness(half3 color){
            return max(color.r, max(color.g, color.b));
        }

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

        ENDCG


        Cull Off
        ZTest Always
        ZWrite Off

        Tags { "RenderType"="Opaque" }

        // 0: 適用するピクセル抽出用のパス
        Pass
        {
            CGPROGRAM

            fixed4 frag (v2f i) : SV_Target
            {
                half4 col = 1;
                col.rgb = sampleBox(i.uv, 1.0);
                half brightness = getBrightness(col.rgb);

                // 明度がThresholdより大きいピクセルだけブルームの対象とする
                half contribution = max(0, brightness - _Threshold);
                contribution /= max(brightness, 0.00001);

                return col * contribution;
            }

            ENDCG
        }

        // 1: ダウンサンプリング用のパス
        Pass
        {
            CGPROGRAM

            fixed4 frag (v2f i) : SV_Target
            {
                half4 col = 1;
                col.rgb = sampleBox(i.uv, 1.0);
                return col;
            }

            ENDCG
        }

        // 2: アップサンプリング用のパス
        Pass
        {
            Blend One One

            CGPROGRAM

            fixed4 frag (v2f i) : SV_Target
            {
                half4 col = 1;
                col.rgb = sampleBox(i.uv, 0.5);
                return col;
            }

            ENDCG
        }

        // 3: 最後の一回のアップサンプリング用のパス
        Pass
        {
            CGPROGRAM

            fixed4 frag (v2f i) : SV_Target
            {
                half4 col = tex2D(_SourceTex, i.uv);
                col.rgb += sampleBox(i.uv, 0.5) * _Intensity;
                return col;
            }

            ENDCG
        }

        // 4: デバッグ用
        Pass
        {
            CGPROGRAM

            fixed4 frag (v2f i) : SV_Target
            {
                half4 col = 1;
                col.rgb = sampleBox(i.uv, 0.5) * _Intensity;
                return col;
            }

            ENDCG
        }
    }
}

1つ目のパス(インデックス0)では輝度の高い色を抽出しています。
_Thresholdを変えることで、どの程度の輝度の色までを対象とするかを変えられます。

こうして抽出した色をぼかすためのパスが2、3番目のパスです。
ぼかしポストエフェクトについては下の記事を参照してください。

light11.hatenadiary.com

4つ目のパスは一番最後に元画像と、ぼかした画像を合成するためのパスです。
また5つ目のパスはデバッグとして、ブルーム効果だけを表示できるようにするためのものです。

次にスクリプトです。

using UnityEngine;

[ExecuteInEditMode]
public class Bloom : MonoBehaviour {

    [SerializeField, Range(1, 30)]
    private int _iteration = 1;
    [SerializeField, Range(0.0f, 1.0f)]
    private float _threshold = 0.0f;
    [SerializeField, Range(0.0f, 10.0f)]
    private float _intensity = 1.0f;
    [SerializeField]
    private bool _debug;

    // 4点をサンプリングして色を作るマテリアル
    [SerializeField]
    private Material _material;

    private RenderTexture[] _renderTextures = new RenderTexture[30];

    private void OnRenderImage(RenderTexture source, RenderTexture dest){
            
        _material.SetFloat("_Threshold", _threshold);
        _material.SetFloat("_Intensity", _intensity);
        _material.SetTexture("_SourceTex", source);

        var width = source.width;
        var height = source.height;
        var currentSource = source;

        var pathIndex = 0;
        var i = 0;
        RenderTexture currentDest = null;

        // ダウンサンプリング
        for (; i < _iteration; i++) {
            width /= 2;
            height /= 2;
            if (width < 2 || height < 2) {
                break;
            }
            currentDest = _renderTextures[i] = RenderTexture.GetTemporary(width, height, 0, source.format);

            // 最初の一回は明度抽出用のパスを使ってダウンサンプリングする
            pathIndex = i == 0 ? 0 : 1;
            Graphics.Blit(currentSource, currentDest, _material, pathIndex);

            currentSource = currentDest;
        }

        // アップサンプリング
        for (i -= 2; i >= 0; i--) {
            currentDest = _renderTextures[i];

            // Blit時にマテリアルとパスを指定する
            Graphics.Blit(currentSource, currentDest, _material, 2);

            _renderTextures[i] = null;
            RenderTexture.ReleaseTemporary(currentSource);
            currentSource = currentDest;
        }

        // 最後にdestにBlit
        pathIndex = _debug ? 4 : 3;
        Graphics.Blit(currentSource, dest, _material, pathIndex);
        RenderTexture.ReleaseTemporary(currentSource);
    }
}

説明はコメントに書いた通りです。
シェーダの説明時にも書いた通り、最初と最後のぱすで特殊な処理をしていて、その他のパスではぼかしをかけています。

これを適用するとレンダリング結果は次のようになります。

f:id:halya_11:20180314232708p:plain:w500

ソフトニー

ここまでの実装ではThresholdが低いとブルームがかかる部分とかからない部分が急激に切り替わってしまいます。
そのため、Soft kneeのためのSoft Thresholdで調整できるようにします。

Soft kneeについてはこの記事を参照してください。

light11.hatenadiary.com

シェーダは次のように変更します。

Shader "Bloom"
{
    Properties{
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        CGINCLUDE
       #pragma vertex vert
       #pragma fragment frag
            
       #include "UnityCG.cginc"

        sampler2D _MainTex;
        float4 _MainTex_ST;
        float4 _MainTex_TexelSize;
        sampler2D _SourceTex;
        // _Thresholdを削除して_FilterParamsを追加
        half4 _FilterParams;
        float _Intensity;

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

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

        half3 sampleMain(float2 uv){
            return tex2D(_MainTex, uv).rgb;
        }

        half3 sampleBox (float2 uv, float delta) {
            float4 offset = _MainTex_TexelSize.xyxy * float2(-delta, delta).xxyy;
            half3 sum = sampleMain(uv + offset.xy) + sampleMain(uv + offset.zy) + sampleMain(uv + offset.xw) + sampleMain(uv + offset.zw);
            return sum * 0.25;
        }

        half getBrightness(half3 color){
            return max(color.r, max(color.g, color.b));
        }

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

        ENDCG


        Cull Off
        ZTest Always
        ZWrite Off

        Tags { "RenderType"="Opaque" }

        // 0: 適用するピクセル抽出用のパス
        Pass
        {
            CGPROGRAM

            fixed4 frag (v2f i) : SV_Target
            {
                // 色抽出にソフトニーを適用
                half4 col = 1;
                col.rgb = sampleBox(i.uv, 1.0);
                half brightness = getBrightness(col.rgb);

                half soft = brightness - _FilterParams.y;
                soft = clamp(soft, 0, _FilterParams.z);
                soft = soft * soft * _FilterParams.w;
                half contribution = max(soft, brightness - _FilterParams.x);
                contribution /= max(brightness, 0.00001);
                return col * contribution;
            }

            ENDCG
        }

        // 1: ダウンサンプリング用のパス
        Pass
        {
            CGPROGRAM

            fixed4 frag (v2f i) : SV_Target
            {
                half4 col = 1;
                col.rgb = sampleBox(i.uv, 1.0);
                return col;
            }

            ENDCG
        }

        // 2: アップサンプリング用のパス
        Pass
        {
            Blend One One

            CGPROGRAM

            fixed4 frag (v2f i) : SV_Target
            {
                half4 col = 1;
                col.rgb = sampleBox(i.uv, 0.5);
                return col;
            }

            ENDCG
        }

        // 3: 最後の一回のアップサンプリング用のパス
        Pass
        {
            CGPROGRAM

            fixed4 frag (v2f i) : SV_Target
            {
                half4 col = tex2D(_SourceTex, i.uv);
                col.rgb += sampleBox(i.uv, 0.5) * _Intensity;
                return col;
            }

            ENDCG
        }

        // 4: デバッグ用
        Pass
        {
            CGPROGRAM

            fixed4 frag (v2f i) : SV_Target
            {
                half4 col = 1;
                col.rgb = sampleBox(i.uv, 0.5) * _Intensity;
                return col;
            }

            ENDCG
        }
    }
}

コメントの通りパス0を変更しています。
また、SoftKneeは計算量がそこそこ多いため、一部計算をスクリプトで行ってシェーダに渡しています。

using UnityEngine;

[ExecuteInEditMode]
public class Bloom : MonoBehaviour {

    [SerializeField, Range(1, 30)]
    private int _iteration = 1;
    [SerializeField, Range(0.0f, 1.0f)]
    private float _threshold = 0.0f;
    [SerializeField, Range(0.0f, 1.0f)]
    private float _softThreshold = 0.0f;
    [SerializeField, Range(0.0f, 10.0f)]
    private float _intensity = 1.0f;
    [SerializeField]
    private bool _debug;

    // 4点をサンプリングして色を作るマテリアル
    [SerializeField]
    private Material _material;

    private RenderTexture[] _renderTextures = new RenderTexture[30];

    private void OnRenderImage(RenderTexture source, RenderTexture dest){
        var filterParams = Vector4.zero;
        var knee = _threshold * _softThreshold;
        filterParams.x = _threshold;
        filterParams.y = _threshold - knee;
        filterParams.z = knee * 2.0f;
        filterParams.w = 0.25f / (knee + 0.00001f);
        _material.SetVector("_FilterParams", filterParams);
        _material.SetFloat("_Intensity", _intensity);
        _material.SetTexture("_SourceTex", source);

        var width = source.width;
        var height = source.height;
        var currentSource = source;

        var pathIndex = 0;
        var i = 0;
        RenderTexture currentDest = null;

        // ダウンサンプリング
        for (; i < _iteration; i++) {
            width /= 2;
            height /= 2;
            if (width < 2 || height < 2) {
                break;
            }
            currentDest = _renderTextures[i] = RenderTexture.GetTemporary(width, height, 0, source.format);

            // 最初の一回は明度抽出用のパスを使ってダウンサンプリングする
            pathIndex = i == 0 ? 0 : 1;
            Graphics.Blit(currentSource, currentDest, _material, pathIndex);

            currentSource = currentDest;
        }

        // アップサンプリング
        for (i -= 2; i >= 0; i--) {
            currentDest = _renderTextures[i];

            // Blit時にマテリアルとパスを指定する
            Graphics.Blit(currentSource, currentDest, _material, 2);

            _renderTextures[i] = null;
            RenderTexture.ReleaseTemporary(currentSource);
            currentSource = currentDest;
        }

        // 最後にdestにBlit
        pathIndex = _debug ? 4 : 3;
        Graphics.Blit(currentSource, dest, _material, pathIndex);
        RenderTexture.ReleaseTemporary(currentSource);
    }
}

これで、SoftThresholdの値を大きくすることでなだらかにブルームがかかるようになりました。

関連

light11.hatenadiary.com

light11.hatenadiary.com

light11.hatenadiary.com