【Unity】【ポストエフェクト】ビュー空間の法線とソーベルフィルタを使った輪郭線表示

Unityでビュー空間の法線とソーベルフィルタを使って輪郭線を表示するポストエフェクトを実装する方法をまとめました。

Unity2019.2.11

はじめに

本ブログではアウトラインの表示方法を過去にいくつか紹介してきました。
この記事では法線とソーベルフィルタリングを使ってポストエフェクトで輪郭線表示を行ってみます。

下記の記事によると、Gravity Dazeの背景に使われた手法のようです。

game.watch.impress.co.jp

なお、ビュー空間における法線の取得方法やソーベルフィルタリングについては説明を省略しますが、
それぞれ下記の記事にまとめていますので必要に応じて参照してください。

light11.hatenadiary.com

light11.hatenadiary.com

シェーダ

それでは早速シェーダを書いてみます。

Shader "NormalOutline"
{
    Properties
    {
        _Thickness ("Thickness", float) = 1.0
        _Threshold ("Threshold", float) = 0.0
    }
    SubShader
    {
        Cull Off
        ZTest Always
        ZWrite Off

        Tags{ "RenderType" = "Opaque" }

        Pass
        {
            HLSLPROGRAM
            #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 _CameraDepthNormalsTexture;
            float _Thickness;
            float _Threshold;

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

            float3 sampleNormal(float2 uv)
            {
                float4 cdn = tex2D(_CameraDepthNormalsTexture, uv);
                float3 n = DecodeViewNormalStereo(cdn) * float3(1.0, 1.0, -1.0);
                return n;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                float2 diffUV = _Thickness / 1000;
                diffUV.y *= _ScreenParams.x / _ScreenParams.y;

                half3 norm00 = sampleNormal(i.uv + half2(-diffUV.x, -diffUV.y));
                half3 norm01 = sampleNormal(i.uv + half2(-diffUV.x, 0.0));
                half3 norm02 = sampleNormal(i.uv + half2(-diffUV.x, diffUV.y));
                half3 norm10 = sampleNormal(i.uv + half2(0.0, -diffUV.y));
                half3 norm12 = sampleNormal(i.uv + half2(0.0, diffUV.y));
                half3 norm20 = sampleNormal(i.uv + half2(diffUV.x, -diffUV.y));
                half3 norm21 = sampleNormal(i.uv + half2(diffUV.x, 0.0));
                half3 norm22 = sampleNormal(i.uv + half2(diffUV.x, diffUV.y));

                half3 horizontalValue = 0;
                horizontalValue += norm00 * -1.0;
                horizontalValue += norm01 * -2.0;
                horizontalValue += norm02 * -1.0;
                horizontalValue += norm20;
                horizontalValue += norm21 * 2.0;
                horizontalValue += norm22;
                
                half3 verticalValue = 0;
                verticalValue += norm00;
                verticalValue += norm10 * 2.0;
                verticalValue += norm20;
                verticalValue += norm02 * -1.0;
                verticalValue += norm12 * -2.0;
                verticalValue += norm22 * -1.0;
                
                // この値が大きく正の方向を表す部分がアウトライン
                half3 outlineValues = verticalValue * verticalValue + horizontalValue * horizontalValue;
                half outlineValue = outlineValues.x + outlineValues.y + outlineValues.z;
                return outlineValue - _Threshold > 0 ? 0 : 1;
            }
            ENDHLSL
        }
    }
}

特に難しいことはしていません。
普通に法線を取得してソーベルフィルタリングを行っているだけです。

スクリプト

次にこれを適用するスクリプトを書きます。
ポストエフェクトの掛け方の基本的な知識は下記を参照してください。

light11.hatenadiary.com

スクリプトは以下のようになります。

using UnityEngine;

[ExecuteAlways]
public class Example : MonoBehaviour
{
    [SerializeField, Range(0.5f, 1.0f)]
    private float _thickness;
    [SerializeField, Range(0.1f, 3.0f)]
    private float _threshold;

    [SerializeField]
    private Shader _shader;
    private Material _material;

    void Start()
    {
        GetComponent<Camera>().depthTextureMode |= DepthTextureMode.DepthNormals;

        _material = new Material(_shader);
    }

    private void OnRenderImage(RenderTexture source, RenderTexture dest)
    {
        _material.SetFloat("_Thickness", _thickness);
        _material.SetFloat("_Threshold", _threshold);
        Graphics.Blit(source, dest, _material);
    }
}

レンダリング結果

それではこのスクリプトをアタッチしてアウトラインを適用します。
まず適用前です。適当なモデルをいくつか表示しておきます。

f:id:halya_11:20200109002516p:plain

次に適用後です。

f:id:halya_11:20200109002501p:plain
Thickness: 1
Threshold: 2.14

アウトラインが表示されていることが確認できました。

より低負荷にする

さてソーベルフィルタはちょっとテクスチャのサンプリング数が多いので軽量化してみます。
方針としては次の記事のようにしてサンプリング数を4回に減らします。

light11.hatenadiary.com

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

Shader "NormalOutline"
{
    Properties
    {
        _Thickness ("Thickness", float) = 1.0
        _Threshold ("Threshold", float) = 0.0
    }
    SubShader
    {
        Cull Off
        ZTest Always
        ZWrite Off

        Tags{ "RenderType" = "Opaque" }

        Pass
        {
            HLSLPROGRAM
            #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 _CameraDepthNormalsTexture;
            float _Thickness;
            float _Threshold;

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

            float3 sampleNormal(float2 uv)
            {
                float4 cdn = tex2D(_CameraDepthNormalsTexture, uv);
                float3 n = DecodeViewNormalStereo(cdn) * float3(1.0, 1.0, -1.0);
                return n;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                float2 diffUV = _Thickness / 1000;
                diffUV.y *= _ScreenParams.x / _ScreenParams.y;

                half3 norm00 = sampleNormal(i.uv + half2(-diffUV.x, -diffUV.y));
                half3 norm10 = sampleNormal(i.uv + half2(diffUV.x, -diffUV.y));
                half3 norm01 = sampleNormal(i.uv + half2(-diffUV.x, diffUV.y));
                half3 norm11 = sampleNormal(i.uv + half2(diffUV.x, diffUV.y));
                half3 diff00_11 = norm00 - norm11;
                half3 diff10_01 = norm10 - norm01;

                // 対角線の値同士を比較して差分が多い部分をアウトラインとみなす
                half3 outlineValues = diff00_11 * diff00_11 + diff10_01 * diff10_01;
                half outlineValue = outlineValues.x + outlineValues.y + outlineValues.z;
                return outlineValue - _Threshold > 0 ? 0 : 1;
            }
            ENDHLSL
        }
    }
}

レンダリング結果(低負荷版)

それではこの低負荷版のポストエフェクトを適用してみます。

f:id:halya_11:20200109003549p:plain
Thickness: 0.71
Threshold: 0.2

大抵の場合はこれで十分そうなクオリティになりました。

参考

game.watch.impress.co.jp

関連

light11.hatenadiary.com

light11.hatenadiary.com

light11.hatenadiary.com