【Unity】カスタムポストエフェクトでURPに入門してみる(ぼかし処理)

## はじめに

最近のUnityの傾向として、かつてからある伝統的なBuilt-in Render Pipelineから、Universal Render Pipeline(URP)に移行せよ...という圧力を感じます。Built-in Render Pipelineを選択するのもアリだよとも聞くので、悩みどころとなります。

一方自分は実務ではここ1年程Unity 2019/Built-in Redner Pipelineを開発で使ってきました。
Unity2018の頃はLWRPと呼ばれていましたが採用せず(正直に言えばこれは正解だったと思う)、後にURPと改名されたことを知りまだまだ安定してないのでは等の不安感が強く、実務での利用を控えてきました。なによりただでさえ遅れがちなゲーム開発の進捗が遅れることを恐れたためです。

しかしながらShader Graph使いたい、というデザイナーさんからの要望も高まっており(現に使っていてデザイナーさん凄いなと思うのですが)、これがURPでないと利用できないためShader Graphを使いたいがために「各社頑張って対応してそうだな」などと想像しています。

また、先日Unity 2020がLTSとなったことを受け「このままではゲームプログラマとして不味い」という強迫観念を感じ、いよいよ自分もBuilt-in Render Pipelineを捨ててURPに乗り換える気になってきた、というのが最近の状況です。(つまりURP完全初心者！)

調査がてらポストエフェクトないしブラー処理から入門してみることにしました。ブラー処理は手軽に実装できかつ比較的よくある要望の1つであるためです。なによりポストエフェクトにはロマンがある。

なお、ここでいうブラー処理とは移動ベクトルを使うモーションブラーのことではなく、画像処理でいうところのガウシアンフィルタのようなぼかし処理です。

## 実行環境

Unity: 2020.3.0f1
Core RP Library: 10.3.2
Universal RP: 10.3.2

## 手始めに先人の知恵を借りる

まずどこから手をつけたら良いか分からなかったので、ググったところ下記の2つが大変参考になりました。

- [【Unity】URP・PostProccessing Stack V3でカスタムポストエフェクトを実装する](https://www.ravypop.com/entry/2020/09/21/014835)
- [【Unity】UniversalRPでカスタムポストプロセスを作る【ZoomBlur】](https://qiita.com/t-matsunaga/items/09343ae7c683269374c4)

Unity HubからUniversal Render Pipelineテンプレートから新規プロジェクトを作成して上記の2つのRenderFeatureを組み込んでみたのが下記。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">Unity URPを探り始めた。とりあえずポストプロセスとしてグレースケール変換&amp;ズームブラーを試す。楽しい<br>参考:<br>グレースケール変換 👉<a href="https://t.co/zsJ3mNS2iX">https://t.co/zsJ3mNS2iX</a><br>ズームブラー<br>👉 <a href="https://t.co/T2F8RIqJ67">https://t.co/T2F8RIqJ67</a> <a href="https://t.co/BpWScXwtY1">pic.twitter.com/BpWScXwtY1</a></p>&mdash; 山下 貴史 (@kishi_yama) <a href="https://twitter.com/kishi_yama/status/1368593838239969280?ref_src=twsrc%5Etfw">March 7, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

ちょっとバトルシーンに入る前のエフェクトみたいになって楽しいです。やっぱりポストエフェクトにはロマンがある！
これはこれで素晴らしいのですが当初やりたかったのは、ぼかし処理なのだ、ということを思い出す。

## UnityのPost Processing Stackについて

Unity 2018を触っていた頃[Post Processing Stack v2](https://blogs.unity3d.com/jp/2018/06/11/postfx-v2-amazing-visuals-upgraded)というものがあることを知り、これを使えば比較的簡単にポストエフェクトを実装できる凄いやつ、程度の認識をしていました(元々はPostFx v2というものだったそうです)。

さらに遡ると[Unity 5.xの頃のUnityマニュアル](https://docs.unity3d.com/530/Documentation/Manual/script-BlurOptimized.html)を見ると、この頃はそもそもデフォルトでBlurがあったようです。当時はRenderTextureベースでスクリーンに対するImage Effectという位置付けで実装されていたようです。

一方[Post Processing Stack v2のリファレンス](https://docs.unity3d.com/Packages/com.unity.postprocessing@3.1/manual/index.html)のEffectsを見るとでは、カメラの動きによるモーションブラーは実装されているものの、ガウシアンフィルタのようなものはありません。
Post Processing Stack v2でこれを自前実装することなしに実現したい場合は[UnityToolset](https://grrava.blogspot.com/2019/01/blur-with-unitys-post-fx-v2.html)を導入する必要があるとのことでした。

なるほど、一体何故かつて提供していたにも関わらずデフォルトで実装されていないのか。
それは自分で実装してくださいよ、というメッセージなのだろうか。やりましょう。

さらにURPの進展からかPost Processing Stack v2はレガシーとのこと。僅か2年程でレガシーというのが何ともアレなのですが、Post Processing Stack v3に引き継がれる流れのようです。消えなくて良かった。

では、Post Processing Stack v3ではどうなのかというと、[Unity Forumでも同様の質問](https://forum.unity.com/threads/postprocessing-v3-and-blur.844231/)があり、特に将来的に実装予定でもなさそうです。代わりに[URPのScriptableRenderFeatureで実装したデモ](https://github.com/sebastianhein/urp_kawase_blur)がこのフォーラムで紹介されており、参考になりそうです。

やりたかったのはコレですよコレ！勉強になります！(新しいオモチャを見つけた子供のような心で)

## デモの内容を見る

デモを見ると分かるのですが、このデモでは`blur`マテリアルに`Custom/RenderFeature/KawaseBlur`シェーダ、`glass`マテリアルに`ShaderGraphs/BathroomGlass`シェーダが付けれており、グラス表現を適用したPlaneに対して川瀬ブラーを施す、というものです。

初見どこを弄ればブラー効果が確認できるのか分からなかったのですが、`ForwardRenderer`でプロパティが露出していました。

`手始めに先人の知恵を借りる`では共に`VolumeComponent`を継承したクラスでプロパティが実装されており`Volume`コンポーネントのインスペクターにプロパティが露出していたため多少混乱しました。

一方こちらのデモでは`Volume framework`(URP 10.1.0以前では`Volume system`と書かれていた)は関係なく、`KawaseBlurSettings`が`ScriptableRendererFeature`内で定義されており、これが設定項目として`FowardRenderer`に露出しているようです。

![kawase_blur_inspector.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/54268/f989549c-21ff-750e-3a0e-f19d0bc52df8.png)

各設定項目を見てみましょう。

__Render Pass Event__

Render Passを差し込むタイミングの指定です。
どのタイミングが良いかは`RenderPassEvent.BeforeRenderingPostProcessing`もしくは`RenderPassEvent.AfterRenderingTransparents`あたりかな、と思いました。

__Blur Material__

KawaseBlurに紐づくマテリアルを指定します。

__Blur Passes__

ブラーの強さです。値を大きくすればするほど強いブラーとなりますが、負荷が気になるところです。

__Downsample__

負荷低減のためダウンサンプリングする際のテクスチャ縮小具合です。
下記の計算でテクスチャを縮小しているようです。つまり2を指定すればテクスチャサイズは1/4となりその分負荷が軽くなりそうです。

```
var width = cameraTextureDescriptor.width / downsample;
var height = cameraTextureDescriptor.height / downsample;
```

ある程度負荷を抑える制御ができる点が玄人的で素晴らしいです。

__Copy To Framebuffer__

このデモは`BathroomGlass.shadergraph`で参照しているテクスチャに対してブラーをかけるだけかと思いきやこのオプションを有効にするとPlaneだけでなくスクリーン全体にブラーが効きました(これが本当にやりたかったこと)。

__Target Name__

`_blurTexture`という文字列が指定されています。
これは`BathroomGlass.shadergraph`で参照されるプロパティのようです。
この辺りはまだShaderGraphを調査していないため雰囲気で言っています。
`BathroomGlass.shadergraph`のインスペクターで「Edit」を押すとShaderGraphのエディタが開き、中身を確認できました。

![_blurTexture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/54268/1b456241-edc4-7137-ec65-04bf0f1edf00.png)

なお`KawaseShader.shader`については何故か`multi_compile_fog`というpragmaがあり本当にコレが必要なのかどうなのかは良く分かりませんでした(フォーラムでも質問が上がっているが特に返答は得られていない様子)。

## 川瀬ブラーのみ取り出してVolumeComponentで扱えるようにする

上記のデモがあれば十分なのですが、このままだと`Volume framework`に適応していません。

`Volume framework`に則ると`weight`で「ポストエフェクトがどの程度影響するか」を調整できるため、便利そうです。
また、`volume.profile.TryGet<MyVolumeComponent>(out var volumeComponent)`のように自作した`VolumeComponent`にアクセスしてプロパティをプログラムから制御できるため、都合が良さそうです。

そこで`手始めに先人の知恵を借りる`で紹介されているような方法で書き直してみます。
今回スクリーン全体に適用されたら十分だったため必要なさそうな箇所は適当に調整して簡略化しました。


```KawaseBlur.cs
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

public class KawaseBlur : VolumeComponent, IPostProcessComponent
{
    // RangeではなくClampedIntParameterを使うことでインスペクター上でスライダーとなることに注意。
    public ClampedIntParameter passes     = new ClampedIntParameter(2, 2, 20);
    public IntParameter        downsample = new ClampedIntParameter(1, 1, 10);

    public bool IsActive() => passes.value >= 2;
    public bool IsTileCompatible() => false;
}
```

```KawaseBlurRenderFeature.cs
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

public class KawaseBlurRenderFeature : ScriptableRendererFeature
{
    class CustomRenderPass : ScriptableRenderPass
    {
        static readonly string RenderTag = "Kawase Blur Effect";

        KawaseBlur kawaseBlur = default;
        Material   material   = default;

        RenderTargetIdentifier source = default;
        RenderTargetIdentifier tmpRT1 = default;
        RenderTargetIdentifier tmpRT2 = default;

        int tmpId1 = default;
        int tmpId2 = default;

        public CustomRenderPass(RenderPassEvent evt)
        {
            renderPassEvent = evt;
            var shader = Shader.Find("Custom/RenderFeature/KawaseBlur");
            if (shader == default)
            {
                return;
            }
            material = CoreUtils.CreateEngineMaterial(shader);
        }

        public void Setup(RenderTargetIdentifier source)
        {
            this.source = source;
        }

        public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
        {
            if (kawaseBlur == default)
            {
                var stack = VolumeManager.instance.stack;
                kawaseBlur = stack.GetComponent<KawaseBlur>();
            }

            if (!IsValid()) return;

            var downsample = kawaseBlur.downsample.value;
            var width      = cameraTextureDescriptor.width / downsample;
            var height     = cameraTextureDescriptor.height / downsample;

            tmpId1 = Shader.PropertyToID("tmpBlurRT1");
            tmpId2 = Shader.PropertyToID("tmpBlurRT2");
            cmd.GetTemporaryRT(tmpId1, width, height, 0, FilterMode.Bilinear, RenderTextureFormat.ARGB32);
            cmd.GetTemporaryRT(tmpId2, width, height, 0, FilterMode.Bilinear, RenderTextureFormat.ARGB32);

            tmpRT1 = new RenderTargetIdentifier(tmpId1);
            tmpRT2 = new RenderTargetIdentifier(tmpId2);

            ConfigureTarget(tmpRT1);
            ConfigureTarget(tmpRT2);
        }

        public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
        {
            if (!renderingData.cameraData.postProcessEnabled) return;
            if (!IsValid()) return;

            var passes = kawaseBlur.passes.value;

            CommandBuffer cmd = CommandBufferPool.Get(RenderTag);

            RenderTextureDescriptor opaqueDesc = renderingData.cameraData.cameraTargetDescriptor;
            opaqueDesc.depthBufferBits = 0;

            // first pass
            cmd.SetGlobalFloat("_offset", 1.5f);
            cmd.Blit(source, tmpRT1, material);

            for (var i = 1; i < passes - 1; i++) {
                cmd.SetGlobalFloat("_offset", 0.5f + i);
                cmd.Blit(tmpRT1, tmpRT2, material);

                // pingpong
                var rttmp = tmpRT1;
                tmpRT1 = tmpRT2;
                tmpRT2 = rttmp;
            }

            // final pass
            cmd.SetGlobalFloat("_offset", 0.5f + passes - 1f);
            cmd.Blit(tmpRT1, source, material);

            context.ExecuteCommandBuffer(cmd);
            cmd.Clear();

            CommandBufferPool.Release(cmd);
        }

        bool IsValid()
        {
            if (material == default)
            {
                Debug.LogError("material is not found.");
                return false;
            }

            if (kawaseBlur == default)
            {
                Debug.LogError("kawaseBlur is not found.");
                return false;
            }

            return kawaseBlur.IsActive();
        }
    }

    CustomRenderPass scriptablePass = default;

    public override void Create()
    {
        scriptablePass = new CustomRenderPass(RenderPassEvent.AfterRenderingTransparents);
    }

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        scriptablePass.Setup(renderer.cameraColorTarget);
        renderer.EnqueuePass(scriptablePass);
    }
}
```

```KawaseBlur.shader
Shader "Custom/RenderFeature/KawaseBlur"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }

    SubShader
    {
        Tags { "RenderPipeline" = "UniversalPipeline" }
        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;
            };

            sampler2D _MainTex;
            float4    _MainTex_TexelSize;
            float4    _MainTex_ST;
            float     _offset;

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

            fixed4 frag (v2f input) : SV_Target
            {
                float2 res = _MainTex_TexelSize.xy;
                float  i   = _offset;
                fixed4 col = float4(0, 0, 0, 1);

                col.rgb  = tex2D(_MainTex, input.uv ).rgb;
                col.rgb += tex2D(_MainTex, input.uv + float2( i,  i) * res).rgb;
                col.rgb += tex2D(_MainTex, input.uv + float2( i, -i) * res).rgb;
                col.rgb += tex2D(_MainTex, input.uv + float2(-i,  i) * res).rgb;
                col.rgb += tex2D(_MainTex, input.uv + float2(-i, -i) * res).rgb;
                col.rgb /= 5.0f;

                return col;
            }
            ENDCG
        }
    }
}
```

Editor上では下記のようなことまでできました。(解像度的に見辛いかもしれませんが...)

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ふぅ...できた。 <a href="https://t.co/3888qsvsJe">pic.twitter.com/3888qsvsJe</a></p>&mdash; 山下 貴史 (@kishi_yama) <a href="https://twitter.com/kishi_yama/status/1370966839585435650?ref_src=twsrc%5Etfw">March 14, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## 実機検証

普段はiOS/Androidで開発していますが、AndroidではVulkan/OpenGL ESの差でハマりがちなので、警戒が必要です。

手元のiPhone 12, Google Pixel XL(Vulkan)では無事動作しましたが、Open GL ES 3.0にフォールバックされた場合は手元に検証端末がなく、確認していません。

## まとめ

簡単なポストエフェクト実装からURPに入門してみました。

Scriptable Rendering Pipelineまわりは開発が活発で次から次へと状況が変わっており、ただでさえ敷居の高いGraphicsに拍車をかけて追いかけるのが難しいです。

また作業中何かの拍子にGameViewに何も映らなくなりUnity再起動で直ったり、`VolumeComponent`で公開する`ClampedIntParameter`のmin,maxの値を変更して再コンパイルしたときに正常に反映されず一度`VolumeComponent`を外して付け直すと直る、見慣れない警告が出たりといったような事が発生して少し驚きます。

とはいえ最近はモバイルゲームといえど、ハイクオリティなタイトルも珍しくなくなってきているため、今後もURPへの期待は業界内で高まっていくのが自然な流れかなと思っています。

ある程度手応えを得ることができたので、これを皮切りに今後もリファレンスやサンプルを読み漁りつつ追いかけてみようと思います。