【Unity】ポストエフェクトをシェーダーで実装する(基礎、複数エフェクト適用、描画パス切替)【シェーダー】

Unity
マイケル
マイケル
みなさんこんにちは!
マイケルです!
エレキベア
エレキベア
こんにちクマ〜〜〜
マイケル
マイケル
今日もシェーダーを触っていきます!
今回はポストエフェクトの実装だ!
エレキベア
エレキベア
ポストエフェクトってUnityだとパッケージでかけれなかったクマ??
マイケル
マイケル
そう、UnityだとPost-Processing Version 2パッケージを使ってポストエフェクトをかけることが多いと思うけど、自分でシェーダーを実装してかけることもできるんだ!
エレキベア
エレキベア
自分で実装できたら思うようにできそうクマね
マイケル
マイケル
早速触っていこう!!
スポンサーリンク

ポストエフェクトの実装方法

マイケル
マイケル
ポストエフェクト用(2D用)のシェーダーの雛形は「Shader > Image Effect Shader」から作成することができます。
ScreenShot 2023 01 16 0 56 12↑ポストエフェクト用のシェーダーを作成
マイケル
マイケル
作成直後は下記のような状態になっていて、
デフォルトでネガポジ反転(RGB値を反転させたもの)が実装されています。
Shader "Hidden/NegaPostEffect"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        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;
            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv);
                col.rgb = 1 - col.rgb; // RGBを反転
                return col;
            }
            ENDCG
        }
    }
}
↑作成直後の状態(ネガポジ反転)
マイケル
マイケル
あとは下記スクリプトをカメラオブジェクトにアタッチして、このシェーダーを適用したマテリアルをアタッチすれば完了です!
using UnityEngine;
[ExecuteInEditMode]
public class SimplePostEffect : MonoBehaviour
{
    [SerializeField] private Material effectMaterial;
    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        // ポストエフェクトをかけない場合
        if (effectMaterial == null)
        {
            Graphics.Blit(src, dest);
            return;
        }
        // ポストエフェクトをかける場合
        Graphics.Blit(src, dest, effectMaterial);
    }
}
↑ポストエフェクトをかけるスクリプト
エレキベア
エレキベア
これだけなのクマね
マイケル
マイケル
OnRenderImage関数ではそれぞれ
src: 最終的に描画される画像(入力)
dest: 最終的に描画される画像(出力)

として渡されてくるため、Graphics.Blit関数でsrcからdestへコピーする際にマテリアルを適用することで最終的な画像にエフェクトをかける、といった流れになっています。

OnRenderImage | Unityスクリプトリファレンス

Graphics-Blit | Unityスクリプトリファレンス

エレキベア
エレキベア
なるほどクマ
最終的な描画の処理になるクマね
マイケル
マイケル
ポストエフェクトをかける前後では下記のように変化することが確認できます!
ScreenShot 2023 01 16 1 02 29↑ポストエフェクト無し
ScreenShot 2023 01 16 1 02 42↑ポストエフェクト有り
エレキベア
エレキベア
おお〜〜〜
ちゃんとエフェクトがかかっているクマ〜〜〜

いくつかポストエフェクトを作ってみる

マイケル
マイケル
ポストエフェクトのかけ方が分かったところで、いくつか実装してみましょう!

グレースケール

マイケル
マイケル
まずはグレースケール!
こちらはNTSC加重平均法というものを用いて、RGBの輝度として(0.298, 0.586, 0.114)を重みとして乗算してモノクロ化します。

参考:
より自然なグレースケール変換 | ゆるゆるプログラミング

エレキベア
エレキベア
なんという絶妙な数値クマ・・・
マイケル
マイケル
今回は数値はざっくりとしたものを指定して下記のように実装してみます。
Frag関数の処理を少し修正しただけですね
Shader "Hidden/GrayPostEffect"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        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;
            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv);
                float monochrome = 0.3 * col.r + 0.6 * col.g + 0.1 * col.b;
                return float4(monochrome.xxx, 1);
            }
            ENDCG
        }
    }
}
↑グレースケールの実装
マイケル
マイケル
こちらを適応した結果は下記のようになります。
ScreenShot 2023 01 16 23 12 30↑グレースケール変換した結果
エレキベア
エレキベア
ちゃんとグレースケールになってるクマ〜〜〜

放射状ブラー

マイケル
マイケル
次は少し難易度を上げて放射状ブラーを実装してみます。
こちらはLIGHT11さんの下記記事を参考にさせていただきました!

参考:
【Unity】【シェーダ】Radial Blur(放射状ブラー)のポストエフェクトを実装する | LIGHT11

マイケル
マイケル
こちらの実装は下記のようになります。
Shader "Hidden/RadialBlurPostEffect"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _SampleCount ("Sample Count", Range(4, 16)) = 8
        _Strength ("Strength", Range(0.0, 1.0)) = 0.5
    }
    SubShader
    {
        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 _SampleCount;
            half _Strength;

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = 0;
                // 中心からの距離
                const half2 symmetryUv = i.uv - 0.5;
                const half distance = length(symmetryUv);
                // 繰り返すごとにより内側のピクセルをサンプリングする
                for (int j = 0; j < _SampleCount; j++)
                {
                    const float uvOffset = 1 - _Strength * j / _SampleCount * distance;
                    col += tex2D(_MainTex, symmetryUv * uvOffset + 0.5);
                }
                col /= _SampleCount;
                return col;
            }
            ENDCG
        }
    }
}
マイケル
マイケル
frag関数内の処理を見れば分かる通り、指定回数ループするごとに内側のピクセルをサンプリングして加算、最終的に平均値を求めることでブラーを表現しています。
パラメータはとりあえず下記のようにサンプル数:12、強さ:0.7を指定してみると、下記のように描画されることが確認できます。
ScreenShot 2023 01 16 23 24 17↑パラメータの指定
ScreenShot 2023 01 16 23 23 38↑ブラーをかけた結果
エレキベア
エレキベア
ちゃんと外側に引き伸ばされたようなブラーになったクマ〜〜
マイケル
マイケル
こんな感じで繰り返しながらサンプリングしていく手法はいろいろと応用が効きそうだね!

複数エフェクトの適用

マイケル
マイケル
次にこれらのエフェクトを複数かけたい場合にどうするか?についてですが、
これはエフェクトをかける用のRenderTextureを自前で用意して最終的にdestにかける方法で適用することができます。
実装は下記のようになります。
using UnityEngine;

[ExecuteInEditMode]
public class SomePostEffect : MonoBehaviour
{
    [SerializeField] private Material[] effectMaterials;
    private readonly RenderTexture[] _rts = new RenderTexture[2];

    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        if (effectMaterials == null || effectMaterials.Length == 0)
        {
            Graphics.Blit(src, dest);
            return;
        }

        // RenderTextureが未生成であれば生成する
        CheckAndCreateRTs(src);

        // レンダーテクスチャをスワップしながらエフェクトをかける
        Graphics.Blit(src, _rts[0]);
        foreach (var effectMaterial in effectMaterials)
        {
            Graphics.Blit(_rts[0], _rts[1], effectMaterial);
            (_rts[0], _rts[1]) = (_rts[1], _rts[0]); // Swap
        }
        Graphics.Blit(_rts[0], dest);
    }

    private void CheckAndCreateRTs(RenderTexture src)
    {
        if (_rts[0] == null
            || _rts[0].width != src.width
            || _rts[0].height != src.height)
        {
            for (var i = 0; i < _rts.Length; i++)
            {
                var rt = new RenderTexture(src.width, src.height, 16, RenderTextureFormat.ARGB32);
                rt.enableRandomWrite = true;
                rt.wrapMode = TextureWrapMode.Repeat;
                rt.filterMode = FilterMode.Bilinear;
                rt.Create();
                _rts[i] = rt;
            }
        }
    }
}
エレキベア
エレキベア
なるほどクマ
エフェクトをかけている間は自作のRenderTextureで退避しておくクマね
マイケル
マイケル
ちょっと面倒くさいけど、下記のように複数かけれることが確認できました!
ScreenShot 2023 01 26 0 37 35↑グレースケール + 放射状ブラー
エレキベア
エレキベア
段々使いこなしてきたクマね

描画パスの切替とワイプ処理

マイケル
マイケル
最後に、シェーダーコード内に複数パスを記述して切り替える方法についても紹介します!
描画するパスに関しては下記のようにGraphics.Blitの第4引数に渡すことで指定することができます。
using UnityEngine;
[ExecuteInEditMode]
public class SelectPostEffect : MonoBehaviour
{
    [SerializeField] private Material selectEffectMaterial;
    private enum UsePass
    {
        UsePass1,
        UsePass2
    }
    [SerializeField] private UsePass usePass;
    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        Graphics.Blit(src, dest, selectEffectMaterial, (int) usePass);
    }
}
↑描画パスの指定
エレキベア
エレキベア
なるほどクマ
あとは渡すパスを切り替えればいいクマね
マイケル
マイケル
今回は3種類のワイプエフェクトを切り替える処理として、下記のように実装してみました!
合わせて_WipeSizeパラメータにワイプ量を渡すようにも実装しています。
using System.Collections;
using UnityEngine;
[ExecuteInEditMode]
public class WipePostEffect : MonoBehaviour
{
    [SerializeField] private Material effectMaterial;
    [SerializeField] private bool isStartWipe;
    private enum UsePass
    {
        WipeX,         // X方向のワイプ
        WipeDirection, // 方向指定のワイプ
        WipeChecker,   // チェッカーボードのワイプ
    }
    [SerializeField] private UsePass usePass;
    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        Graphics.Blit(src, dest, effectMaterial, (int) usePass);
    }
    private static readonly int WipeSize = Shader.PropertyToID("_WipeSize");
    private float _wipeSize = 0.0f;
    private void Start()
    {
        _wipeSize = 0.0f;
        effectMaterial.SetFloat(WipeSize, _wipeSize);
    }
    private void Update()
    {
        // フラグが立ったらワイプ開始
        if (!isStartWipe) return;
        isStartWipe = false;;
        StartCoroutine(StartWipeCoroutine());
    }
    private bool _isDoWipe = false;
    private IEnumerator StartWipeCoroutine()
    {
        if (_isDoWipe) yield break;
        _isDoWipe = true;
        // 一定間隔でワイプ量を変化させる
        _wipeSize = 0.0f;
        while (_wipeSize < 1.0f)
        {
            _wipeSize += 0.8f * Time.deltaTime;
            effectMaterial.SetFloat(WipeSize, _wipeSize);
            yield return null;
        }
        _isDoWipe = false;
    }
}
↑3種類のワイプエフェクト適用処理
マイケル
マイケル
シェーダー側のワイプエフェクトのコードは下記のように実装してみました!
3種類のエフェクトをPassとして分けて実装しています。
Shader "Hidden/WipePostEffect"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _WipeSize ("Wipe Size", Range(0.0, 1.0)) = 0.0
        [IntRange] _WipeDirectionX ("Wipe Direction X", Range(-1, 1)) = 1
        [IntRange] _WipeDirectionY ("Wipe Direction Y", Range(-1, 1)) = 1
    }
    SubShader
    {
        Tags { "Queue"="AlphaTest" "RenderType"="TransparentCutout" }
        Cull Off ZWrite Off ZTest Always
        // 共通部分
        CGINCLUDE
        #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;
        float _WipeSize;
        int _WipeDirectionX;
        int _WipeDirectionY;
        ENDCG
        // X方向
        Pass
        {
            CGPROGRAM
            fixed4 frag (v2f i) : SV_Target
            {
                // ワイプサイズを超えた場合、クリップする
                if (i.uv.x - _WipeSize < 0)
                {
                    return float4(0, 0, 0, 0);
                }
                return tex2D(_MainTex, i.uv);
            }
            ENDCG
        }
        // 方向指定
        Pass
        {
            CGPROGRAM
            fixed4 frag (v2f i) : SV_Target
            {
                float2 pos = i.uv.xy;
                // 指定された向きによって反転させる
                if (_WipeDirectionX < 0) pos.x = 1.0 - pos.x;
                if (_WipeDirectionY < 0) pos.y = 1.0 - pos.y;
                // 斜め方向の場合、ワイプサイズを倍にする
                if (_WipeDirectionX != 0 && _WipeDirectionY != 0) _WipeSize *= 2.0;
                // 向きと位置の内積からクリップするかを求める
                const float2 wipeDirection = float2(abs(_WipeDirectionX), abs(_WipeDirectionY));
                const float t = dot(wipeDirection, pos);
                if (t - _WipeSize < 0)
                {
                    return float4(0, 0, 0, 0);
                }
                return tex2D(_MainTex, i.uv);
            }
            ENDCG
        }
        // チェッカーボード
        Pass
        {
            CGPROGRAM
            fixed4 frag (v2f i) : SV_Target
            {
                // セルのサイズ
                const float cellSizeX = 0.2f;
                const float cellSizeY = 0.2f;
                _WipeSize *= cellSizeX;
                // 行番号を求めて0or1にする
                float t = floor(i.uv.y / cellSizeY);
                t = fmod(t, 2.0f);
                // 奇数行ならX座標をシフトさせて縦じまワイプをかける
                t = fmod(i.uv.x + cellSizeX/2.0 * t, cellSizeX);
                // ワイプサイズを超えた場合、クリップする
                if (t - _WipeSize < 0)
                {
                    return float4(0, 0, 0, 0);
                }
                return tex2D(_MainTex, i.uv);
            }
            ENDCG
        }
    }
}
↑3種類のワイプエフェクト
エレキベア
エレキベア
共通部分は外に出してるクマね
マイケル
マイケル
なお、ワイプエフェクトに関しては下記の書籍を参考にさせていただきました!
内容はC++ですが、Unityにも応用することができるので一読して損無しです!

HLSL シェーダーの魔導書 シェーディングの基礎からレイトレーシングまで

エレキベア
エレキベア
シェーダーの日本語書籍はありがたいクマね〜〜〜
マイケル
マイケル
それぞれのワイプエフェクトの実装と適用結果は下記のようになっています。

X方向のワイプ

マイケル
マイケル
まず単純にX方向にワイプする処理についてですが、これは単純にワイプ量をuvのx座標から引いてマイナスなら黒を返却、という形で実装しています。
        // X方向
        Pass
        {
            CGPROGRAM
            fixed4 frag (v2f i) : SV_Target
            {
                // ワイプサイズを超えた場合、クリップする
                if (i.uv.x - _WipeSize < 0)
                {
                    return float4(0, 0, 0, 0);
                }
                return tex2D(_MainTex, i.uv);
            }
            ENDCG
        }
01 wipe x
エレキベア
エレキベア
おお〜〜ちゃんといい感じにワイプしてるクマね

方向指定のワイプ

マイケル
マイケル
そして次に応用範囲を広げてワイプの方向を指定できるようにしたものが下記になります。
指定した方向とUV座標の内積結果を見てクリップするかどうかを判定していますが、指定した向きによって座標位置を反転させないといけない点には注意しましょう。
        // 方向指定
        Pass
        {
            CGPROGRAM
            fixed4 frag (v2f i) : SV_Target
            {
                float2 pos = i.uv.xy;
                // 指定された向きによって反転させる
                if (_WipeDirectionX < 0) pos.x = 1.0 - pos.x;
                if (_WipeDirectionY < 0) pos.y = 1.0 - pos.y;
                // 斜め方向の場合、ワイプサイズを倍にする
                if (_WipeDirectionX != 0 && _WipeDirectionY != 0) _WipeSize *= 2.0;
                // 向きと位置の内積からクリップするかを求める
                const float2 wipeDirection = float2(abs(_WipeDirectionX), abs(_WipeDirectionY));
                const float t = dot(wipeDirection, pos);
                if (t - _WipeSize < 0)
                {
                    return float4(0, 0, 0, 0);
                }
                return tex2D(_MainTex, i.uv);
            }
            ENDCG
        }
02 wipe direction
エレキベア
エレキベア
だいぶ実用的になったクマね

チェッカーボードのワイプ

マイケル
マイケル
そして最後はチェッカーボード模様のワイプ処理です!
これは行番号を求めて奇数行の場合にはX座標をシフトさせて縦じまワイプをかけることで実装しています。
少しややこしいですが一つ一つ見ていくと理解できてくるはずです!
         // チェッカーボード
        Pass
        {
            CGPROGRAM
            fixed4 frag (v2f i) : SV_Target
            {
                // セルのサイズ
                const float cellSizeX = 0.2f;
                const float cellSizeY = 0.2f;
                _WipeSize *= cellSizeX;
                // 行番号を求めて0or1にする
                float t = floor(i.uv.y / cellSizeY);
                t = fmod(t, 2.0f);
                // 奇数行ならX座標をシフトさせて縦じまワイプをかける
                t = fmod(i.uv.x + cellSizeX/2.0 * t, cellSizeX);
                // ワイプサイズを超えた場合、クリップする
                if (t - _WipeSize < 0)
                {
                    return float4(0, 0, 0, 0);
                }
                return tex2D(_MainTex, i.uv);
            }
            ENDCG
        }
    }
03 wipe checker
エレキベア
エレキベア
しゃれおつクマ〜〜〜〜
行ごとにオフセットさせるやり方は応用が効きそうクマね

おわりに

マイケル
マイケル
というわけで今回はポストエフェクトの実装でした!
どうだったかな??
エレキベア
エレキベア
かけるの自体は意外と簡単だったクマが、いろいろクセもあるから注意クマね
マイケル
マイケル
一回覚えてしまえばシェーダーで自由にいじれるから覚えておいて損はないね!
これからガンガン活用していこう!!
マイケル
マイケル
それでは今日はこの辺で!
アデューー!!
エレキベア
エレキベア
クマ〜〜〜〜〜

【Unity】ポストエフェクトをシェーダーで実装する(基礎、複数エフェクト適用、描画パス切替)【シェーダー】〜完〜

コメント