Unity の UniversalRP で エッジ検出

Unity の UniversalRP でオブジェクトの エッジを検出して描画するパスを追加する行為をやってみた。

ポストエフェクト的な、各ピクセルごとに輪郭かどうか判定する手法をとっていますが、同時に対象のレイヤを制限できるようにしてみました。↑

Unity に SRP(ScriptableRenderPipeline) が入る以前は、追加の描画パスの実行や、自前で特別なバッファをつくる行為など、レンダリングまわりの改造行為を理解することが自分にはかなりむつかしかったのですが、SRP上では制御フローを調べる手掛かりがかなり増えてやりやすくなりました。 計算やアルゴリズム自体がむずかしい、というよりも、Unityビルトインのブラックボックスなパイプラインの断片的な情報から、いつ何が何のために実行されているのか把握するための前提知識がなかったことが大きいです。 時折、ビルトインのシェーダを差し替える機能(Replacement Shader)とかを使いこなしている人をみかけましたが、そもそも差し替え対象のパスがいつ何を何のためにレンダリングしているのかよくわからない。Unityのビルトインパイプラインの各種機能について詳細に解説しているブログなんかは、どうやってあそこまで詳細な情報を得ていたのか謎です。描画パイプラインに慣れ親しんでいる人にとっては、Unityの公式ドキュメントや断片的なソースから全体の流れを想像することが容易なのだろうか。

( Unityの公式ドキュメントをはじめとする機能リファレンスの域を出ない説明、「大鷲 = 大きい鷲のことです」みたいな局所的すぎる説明では、どのように使うのが妥当なのか、いつ何のために使われているのか、カラスと比べて大きいのか小さいのか、全体的なモデルやフローがわかってないものを理解するのが大変だなあと思ってます。最近は Unity も リファレンス以外の読み物が充実してきている雰囲気があるので、今後に期待です。Web界隈のように、実装よりも抽象概念を語る者達がさらに増えてくれるとうれしい )

しかし、なんと SRP は、パッケージのディレクトリをおもむろに開けると、 実装をすべて読むことができるようになっているではありませんか。 パスを追加したい場合、 ScriptableRenderPass をつくることが要求されますが、UniversalRP などの提供されている実装もそもそも ScriptableRenderPass が使用されている。つまり、独自のパスを追加するためのリファレンス情報がかなり豊富になりました。

今回は、エッジを描画する機能をScriptableRendererFeatureとして実装して、UniversalRPに追加する行為をしてみました。

エッジ検出

オブジェクトの輪郭線を描くための方向性は大きく2つあると思います。

  1. モデルごとにアウトラインを描画対象に追加する
  2. ポストプロセスで、各ピクセルが物と物との境界になっているか判定しまくっていく

今回実装してみたのは2です。

1は、頂点シェーダで法線方向にモデルを膨らませて塗り潰したものを最初に描き、その後で上からあらためて通常のパスの描画を重ねることで、オブジェクトの外側部分だけが追加される、といった「ポリゴン反転押し出し法」なるトラディショナルな手法が簡単で有名です。 見た目上の欠点は、アウトラインが前面をカリングしたオブジェクトであるために、オブジェクトの外側を描きたい場合にはうまくいくけれども、オブジェクトの表面のエッジを描きたい場合にうまくいかないというところかなと思います。 また、パフォーマンス面では、シェーダは単純なものの マテリアルの数だけパスが増えるという特徴があります。

2は、できた画像を入力にとって、各ピクセルの深度や法線などの形に関する情報について近傍と連続していない箇所をみつけます。それを物と物との境界だとみなして塗る、そんな方向性です。 色のみの情報からこれをするのは難しく精度も得られない場合もありそうですが、 UnityのようなCG環境ではレンダリングする過程で「深度」と「法線」という情報が使用さるため、これを入力に使うことでけっこう単純な実装でもさくっと結果を得ることができます。

オブジェクト前面のエッジも描けるし、「入り」「抜き」みたいなものが出しやすいので結果的に品質は高いのかなとおもいます。

ポストエフェクトとして実装すると、オブジェクトの数に依存せず追加の描画は+1回になりますが、各ピクセルについて近傍のピクセルも調べる必要があるため 1 よりもシェーダは高価です。

深度と法線のバッファを元にエッジを検出するシェーダはこの辺の記事が非常にわかりやすかったです。 Unity Outline Shader Tutorial - Roystan

スクリーンスペースの深度と法線のバッファをつくる

深度バッファと法線バッファさえ手に入れば、エッジの検出はけっこう簡単にいくということがわかりました。 しかし、UniversalRP は、深度バッファを取得する機能はありますが、法線バッファは用意してくれません。 ( フォワードレンダリングでは、法線だけをどこかのテクスチャに一括して描き込む、という処理が必要ないため)

そこでまずは自前でスクリーンスペースの法線バッファをつくってみました。

輪郭を描きたいオブジェクトに、以下のように法線だけを描画するパスを追加します。

        Pass
        {
            Name "NormalOnly"
            Tags { "LightMode" = "NormalOnly" }

            HLSLPROGRAM

            #pragma vertex NormalsPassVertex
            #pragma fragment NormalsPassFragment

            #include "Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            struct Attributes
            {
                float4 positionOS : POSITION;
                float3 normalOS : NORMAL;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct Varyings
            {
                float4 positionCS : SV_POSITION;
                half3 normalVS : TEXCOORD1;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            Varyings NormalsPassVertex(Attributes input)
            {
                Varyings output;
                UNITY_SETUP_INSTANCE_ID(input);

                output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
                output.normalVS = normalize(mul((float3x3)UNITY_MATRIX_IT_MV, input.normalOS));
                return output;
            }

            half4 NormalsPassFragment(Varyings input) : SV_TARGET
            {
                half3 normal = normalize(input.normalVS) * 0.5 + 0.5;
                return half4(normal, 0);
            }
            ENDHLSL
        }
  • Tags { "LightMode" = "NormalOnly" } のように、LightMode タグに名前を設定すると、後から この名前で シェーダ中のパスを指定することができます。
  • 法線は2次元のデータの圧縮することもできると思いますが、ここでは素朴に3要素を描き込んでいます。

法線だけを描き込むシェーダができました。

UniversalRP では、内部的に想定されていない タグのパスを追加しても、暗黙のうちに使用されることはありません。 追加のパスを使ってもらうには、いつどのようにこの追加のパスが実行されるかを SRPによってC#でプログラミングしていきます。

描画パイプラインを拡張するためには、ScriptableRendererFeature を作成します。 ScriptableRendererFeature を継承したクラスをつくると、Rendererのアセットに追加できる Renderer Features のリストに自動で表示されます。

    public sealed class EdgeDetectionRendererFeature : ScriptableRendererFeature
    {
        [System.Serializable]
        public sealed class EdgeDetectionSettings
        {
            public bool Enabled;
            public RenderPassEvent Event = RenderPassEvent.BeforeRenderingPostProcessing;
            public RenderQueueRange RenderQueueRange = RenderQueueRange.all;
            public LayerMask LayerMask;
        }

        [SerializeField]
        EdgeDetectionSettings settings = new EdgeDetectionSettings();

        EdgeDetectionPass edgeDetectionPass;

        public override void Create()
        {
            edgeDetectionPass = new EdgeDetectionPass(
                settings.Event,
                settings.RenderQueueRange,
                settings.LayerMask);
        }

        public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
        {
            edgeDetectionPass.SourceIdentifier = renderer.cameraColorTarget;
            renderer.EnqueuePass(edgeDetectionPass);
        }
    }


    public sealed class EdgeDetectionPass : ScriptableRenderPass
    {
        public RenderTargetIdentifier SourceIdentifier { get; set; }

        readonly RenderTargetHandle normalTargetHandle;
        readonly ShaderTagId shaderTagId = new ShaderTagId("NormalOnly");

        FilteringSettings filteringSettings;

        public EdgeDetectionPass(
            RenderPassEvent renderPassEvent,
            RenderQueueRange renderQueueRange,
            LayerMask layerMask)
        {
            this.renderPassEvent = renderPassEvent;

            filteringSettings = new FilteringSettings(renderQueueRange, layerMask);

            normalTargetHandle.Init("_NormalTexture");
        }

        public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
        {
            var descriptor = new RenderTextureDescriptor(
                cameraTextureDescriptor.width / 2,
                cameraTextureDescriptor.height / 2,
                RenderTextureFormat.ARGB32,
                8,
                0);

            cmd.GetTemporaryRT(depthNormalTargetHandle.id, descriptor);
            ConfigureTarget(depthNormalTargetHandle.id);
            ConfigureClear(ClearFlag.All, Color.black);
        }

        public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
        {
            var cmd = CommandBufferPool.Get("EdgeDetection");
            using (new ProfilingSample(cmd, "DepthNormals Pass"))
            {
                var sortFlags = renderingData.cameraData.defaultOpaqueSortFlags;
                var drawSettings = CreateDrawingSettings(shaderTagId, ref renderingData, sortFlags);
                drawSettings.perObjectData = PerObjectData.None;

                context.DrawRenderers(renderingData.cullResults, ref drawSettings, ref filteringSettings);
                cmd.SetGlobalTexture("_DepthNormalTexture", depthNormalTargetHandle.id);
                // cmd.Blit(depthNormalTargetHandle.id, SourceIdentifier);
            }
            context.ExecuteCommandBuffer(cmd);
            CommandBufferPool.Release(cmd);
        }

        public override void FrameCleanup(CommandBuffer cmd)
        {
            cmd.ReleaseTemporaryRT(edgeDetectionHandle.id);
        }
    }
  • ScriptableRenderPass を継承したクラスの Execute を実装することで、描画パイプラインに差し込まれる追加の処理を指定することができます。
  • 描画処理は、連続したひとかたまりをまとめて表現できる CommandBuffer によって記述してあげて、一括して実行します。
  • ここでは、法線を描き込むためのバッファを取得、 "NormalOnly" のタグがついたパスで、指定したレイヤのオブジェクトを描画する、という命令を実行します。
  • ※他のサンプルコードを見る限り、Configure(..) でRTなどリソースのセットアップ、FrameCleanup(...) で解放をするのが行儀が良さそうです。Execute に全て書いた場合との実質的な違いはあまりよくわかってません。Bloomなど、Execute 中にRTを解放するPassなども世の中には存在しているようです。

f:id:hadashia:20200112182048p:plain

こんなかんじで法線だけのバッファがつくれたら成功です。

深度バッファについては、UniversalRP の 「Depth Texture」 設定を有効にすることで、自動的に 描画したオブジェクトのDepthバッファが _CameraDepthTexture という名前でコピーされる機能を使うことができます。

あるいは、UniversalRP の DepthOnlyPass とかを参考に、自前で似たようなことをすることもできます。

今回は、特定のオブジェクトのみを対象にして法線をバッファに描く処理を差し込んでいるので、ついでにここで同じバッファに深度も描き込む行為をしてみました。

法線のみを描くパスに、深度も追加します。

通常の描画パスにおける深度バッファが、 _CameraDepthAttachment という名前で存在しているので、これをサンプリングすれば良いようです。

さきほどの頂点シェーダに、スクリーンスペースのuv の計算を追加します。

                #if UNITY_UV_STARTS_AT_TOP
                    float scale = -1.0;
                #else
                    float scale = 1.0;
                #endif
                output.uvScreen.xy = (float2(output.positionCS.x, output.positionCS.y * scale) + output.positionCS.w) * 0.5;
        output.uvScreen.zw = output.positionCS.zw;

フラグメントシェーダで _CameraDepthAttachment をサンプリングして、法線と深度が両方いれたものを最終結果にします。

half depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthAttachment, sampler_CameraDepthAttachment, input.uvScreen.xy / input.uvScreen.w);
half3 normal = normalize(input.normalVS) * 0.5 + 0.5;
return half4(normal, depth);

できあがった法線&深度バッファを利用してエッジ検出をして重ねる。

なんやかんやの末、欲しい入力データの作成を描画パイプラインに追加することができました。 これで材料が揃ったので、あとはエッジ検出のパスを追加するだけです。

ScriptableRendererFeature の設定で、エッジ検出用のシェーダを登録でけるようにします。

    public sealed class EdgeDetectionRendererFeature : ScriptableRendererFeature
    {
        [System.Serializable]
        public sealed class EdgeDetectionSettings
        {
            public RenderPassEvent Event = RenderPassEvent.BeforeRenderingPostProcessing;
            public RenderQueueRange RenderQueueRange = RenderQueueRange.all;
            public LayerMask LayerMask;
            public Shader EdgeDetectionShader;
            public Color EdgeColor = Color.black;
            public Shader DepthNormalShader;
            public int EdgeScaleFactor = 2;

            [Range(0f, 1f)]
            public float DepthThreshold = 0.2f;

            [Range(0f, 1f)]
            public float NormalThreshold = 0.4f;
        }

        [SerializeField]
        EdgeDetectionSettings settings = new EdgeDetectionSettings();

        EdgeDetectionPass edgeDetectionPass;

        public override void Create()
        {
            var edgeDetectionMaterial = CoreUtils.CreateEngineMaterial(settings.EdgeDetectionShader);
            edgeDetectionMaterial.SetColor("_EdgeColor", settings.EdgeColor);
            edgeDetectionMaterial.SetFloat("_EdgeScaleFactor", settings.EdgeScaleFactor);
            edgeDetectionMaterial.SetFloat("_DepthThreshold", settings.DepthThreshold);
            edgeDetectionMaterial.SetFloat("_NormalThreshold", settings.NormalThreshold);

            edgeDetectionPass = new EdgeDetectionPass(
                settings.Event,
                settings.RenderQueueRange,
                settings.LayerMask,
                edgeDetectionMaterial);
        }

        public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
        {
            edgeDetectionPass.SourceIdentifier = renderer.cameraColorTarget;
            renderer.EnqueuePass(edgeDetectionPass);
        }
    }

エッジ検出のパスを追加します。

        public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
        {
            var cmd = CommandBufferPool.Get("EdgeDetection");
            using (new ProfilingSample(cmd, "DepthNormals Pass"))
            {
                var sortFlags = renderingData.cameraData.defaultOpaqueSortFlags;
                var drawSettings = CreateDrawingSettings(shaderTagId, ref renderingData, sortFlags);
                drawSettings.perObjectData = PerObjectData.None;

                context.DrawRenderers(renderingData.cullResults, ref drawSettings, ref filteringSettings);
                cmd.SetGlobalTexture("_DepthNormalTexture", depthNormalTargetHandle.id);
                cmd.Blit(depthNormalTargetHandle.id, SourceIdentifier);
            }

            using (new ProfilingSample(cmd, "EdgeDetection"))
            {
                cmd.Blit(SourceIdentifier, SourceIdentifier, edgeDetectionMaterial);
            }
            context.ExecuteCommandBuffer(cmd);
            CommandBufferPool.Release(cmd);
        }

エッジ検出のシェーダ

ここまで材料が揃ったので、あとは純粋にポストプロセスのシェーダを書けば完成。 僕としてはここまでくるやりかたがよくわかってなかったのでけっこう大変でした。 全体の制御フローと、機能追加のやりかたを見出すことができれば、あとは世にあるテクニックなどを追加したりするのはインクリメンタルにやっていけそうな気がします。

今回やってみたエッジ検出のシェーダはこれです↓

EdgeDetection.shader · GitHub

やっていることはけっこう単純です。

  • 近傍の深度と法線の2点間を比較して、閾値以上の差があればそこを輪郭線とみなします
  • 「近傍」とする距離を長くすることによって、輪郭として入る範囲が広くなるので、結果的に輪郭線の太さを制御することができます