LoginSignup
11
9

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-03-14

はじめに

最近の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 HubからUniversal Render Pipelineテンプレートから新規プロジェクトを作成して上記の2つのRenderFeatureを組み込んでみたのが下記。

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

UnityのPost Processing Stackについて

Unity 2018を触っていた頃Post Processing Stack v2というものがあることを知り、これを使えば比較的簡単にポストエフェクトを実装できる凄いやつ、程度の認識をしていました(元々はPostFx v2というものだったそうです)。

さらに遡るとUnity 5.xの頃のUnityマニュアルを見ると、この頃はそもそもデフォルトでBlurがあったようです。当時はRenderTextureベースでスクリーンに対するImage Effectという位置付けで実装されていたようです。

一方Post Processing Stack v2のリファレンスのEffectsを見るとでは、カメラの動きによるモーションブラーは実装されているものの、ガウシアンフィルタのようなものはありません。
Post Processing Stack v2でこれを自前実装することなしに実現したい場合はUnityToolsetを導入する必要があるとのことでした。

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

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

では、Post Processing Stack v3ではどうなのかというと、Unity Forumでも同様の質問があり、特に将来的に実装予定でもなさそうです。代わりにURPのScriptableRenderFeatureで実装したデモがこのフォーラムで紹介されており、参考になりそうです。

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

デモの内容を見る

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

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

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

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

kawase_blur_inspector.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

なお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上では下記のようなことまでできました。(解像度的に見辛いかもしれませんが...)

実機検証

普段は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への期待は業界内で高まっていくのが自然な流れかなと思っています。

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

11
9
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
9