[Unity] HDRPで輪郭抽出ポストエフェクト例

#HDRP
というのはUnityの「かっちょいい絵を出すモード」です(?)。最近はHDRPであれこれできる環境も揃いつつありますね。輪郭抽出はいまさら珍しくもないですが、HDRPのポストエフェクトの一部として組み込んでみましょう。HDRPに最初から備わっているポストエフェクトと併用できるようになり、制御しやすい輪郭抽出が期待できます。
なお本稿は解説ではなく手順を示すもので、深く考えなくても書いてある通りに作業すれば実現できるようにしているつもりです。

#新規プロジェクト作成
Unity Hub から新規プロジェクトを選択し、High Definition RP を選びます。なお使用したUnityのバージョンは 2019.3.0f5 です。
![Kobito.u8uyBd.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/fd788ec5-7659-a986-216f-0d03710fc7bc.png "Kobito.u8uyBd.png")



プロジェクトを作成してUnityが開くと、サンプルのオブジェクトが置いてあるのが見えます。

![Kobito.RK6TAS.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/8898c17b-8b49-2312-9754-0fe3a5907d1f.png "Kobito.RK6TAS.png")



#スクリプトを作成（コピペ）
どこでもいいですが Scripts フォルダの下がいいでしょう
![Kobito.klXZlk.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/c76cd4a4-f4dc-6547-1123-ed22570a5e23.png "Kobito.klXZlk.png")

Create > C# Script で EdgeDetect という名前でスクリプトを生成し、中身を以下の内容にします：

```C#
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.HighDefinition;
using System;

using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.HighDefinition;
using System;

namespace MyPostProcess {

    [Serializable, VolumeComponentMenu("Post-processing/Custom/EdgeDetect")]
    public sealed class EdgeDetect : CustomPostProcessVolumeComponent, IPostProcessComponent
    {
        [Tooltip("Use ColorBuffer.")]
        public BoolParameter enableColorBuffer = new BoolParameter(false);
        [Tooltip("Controls the intensity of the ColorBuffer.")]
        public ClampedFloatParameter intensityColor = new ClampedFloatParameter(1f, 0f, 32f);
        [Tooltip("Controls the thickness of the ColorBuffer.")]
        public ClampedFloatParameter thicknessColor = new ClampedFloatParameter(1f, 0.5f, 4f);
        [Tooltip("Controls the range of the detected difference of the ColorBuffer.")]
        public FloatRangeParameter rangeColor = new FloatRangeParameter(new Vector2(0, 1), 0, 1);

        [Tooltip("Use DepthBuffer.")]
        public BoolParameter enableDepthBuffer = new BoolParameter(false);
        [Tooltip("Controls the intensity of the DepthBuffer.")]
        public ClampedFloatParameter intensityDepth = new ClampedFloatParameter(1f, 0f, 32f);
        [Tooltip("Controls the thickness of the DepthBuffer.")]
        public ClampedFloatParameter thicknessDepth = new ClampedFloatParameter(1f, 0.5f, 4f);
        [Tooltip("Controls the range of the detected difference of the DepthBuffer.")]
        public FloatRangeParameter rangeDepth = new FloatRangeParameter(new Vector2(0, 1), 0, 1);

        [Tooltip("Use NormalBuffer.")]
        public BoolParameter enableNormalBuffer = new BoolParameter(false);
        [Tooltip("Controls the intensity of the NormalBuffer.")]
        public ClampedFloatParameter intensityNormal = new ClampedFloatParameter(1f, 0f, 32f);
        [Tooltip("Controls the thickness of the NormalBuffer.")]
        public ClampedFloatParameter thicknessNormal = new ClampedFloatParameter(1f, 0.5f, 4f);
        [Tooltip("Controls the range of the detected difference of the NormalBuffer.")]
        public FloatRangeParameter rangeNormal = new FloatRangeParameter(new Vector2(0, 1), 0, 1);

        [Tooltip("Background color.")]
        public ColorParameter bgColor = new ColorParameter(new Color(1f,1f,1f,0.5f));
        [Tooltip("Foreground color.")]
        public ColorParameter fgColor = new ColorParameter(Color.black);

        static readonly int material_IntensityColor = Shader.PropertyToID("_IntensityColor");
        static readonly int material_ThicknessColor = Shader.PropertyToID("_ThicknessColor");
        static readonly int material_RangeMinColor = Shader.PropertyToID("_RangeMinColor");
        static readonly int material_RangeMaxColor = Shader.PropertyToID("_RangeMaxColor");
        static readonly int material_IntensityDepth = Shader.PropertyToID("_IntensityDepth");
        static readonly int material_ThicknessDepth = Shader.PropertyToID("_ThicknessDepth");
        static readonly int material_RangeMinDepth = Shader.PropertyToID("_RangeMinDepth");
        static readonly int material_RangeMaxDepth = Shader.PropertyToID("_RangeMaxDepth");
        static readonly int material_IntensityNormal = Shader.PropertyToID("_IntensityNormal");
        static readonly int material_ThicknessNormal = Shader.PropertyToID("_ThicknessNormal");
        static readonly int material_RangeMinNormal = Shader.PropertyToID("_RangeMinNormal");
        static readonly int material_RangeMaxNormal = Shader.PropertyToID("_RangeMaxNormal");
        static readonly int material_BGColor = Shader.PropertyToID("_BGColor");
        static readonly int material_FGColor = Shader.PropertyToID("_FGColor");

        Material m_Material;
        public bool IsActive() => m_Material != null;
        public override CustomPostProcessInjectionPoint injectionPoint => CustomPostProcessInjectionPoint.AfterPostProcess;
        public override void Setup()
        {
            if (Shader.Find("Hidden/Shader/EdgeDetect") != null)
                m_Material = new Material(Shader.Find("Hidden/Shader/EdgeDetect"));
        }
    
        public override void Render(CommandBuffer cmd, HDCamera camera, RTHandle source, RTHandle destination)
        {
            if (m_Material == null)
                return;
            m_Material.SetFloat(material_IntensityColor, enableColorBuffer.value ? intensityColor.value : 0f);
            m_Material.SetFloat(material_ThicknessColor, thicknessColor.value);
            m_Material.SetFloat(material_RangeMinColor, rangeColor.value.x);
            m_Material.SetFloat(material_RangeMaxColor, rangeColor.value.y);
            m_Material.SetFloat(material_IntensityDepth, enableDepthBuffer.value ? intensityDepth.value : 0f);
            m_Material.SetFloat(material_ThicknessDepth, thicknessDepth.value);
            m_Material.SetFloat(material_RangeMinDepth, rangeDepth.value.x);
            m_Material.SetFloat(material_RangeMaxDepth, rangeDepth.value.y);
            m_Material.SetFloat(material_IntensityNormal, enableNormalBuffer.value ? intensityNormal.value : 0f);
            m_Material.SetFloat(material_ThicknessNormal, thicknessNormal.value);
            m_Material.SetFloat(material_RangeMinNormal, rangeNormal.value.x);
            m_Material.SetFloat(material_RangeMaxNormal, rangeNormal.value.y);
            m_Material.SetColor(material_BGColor, bgColor.value);
            m_Material.SetColor(material_FGColor, fgColor.value);
            m_Material.SetTexture("_InputTexture", source);
            HDUtils.DrawFullScreen(cmd, m_Material, destination);
        }
        public override void Cleanup() => CoreUtils.Destroy(m_Material);
    }
}
```

#シェーダを作成（コピペ）
これは Resources フォルダをあらかじめ作成してその下に作成します。（Resourcesフォルダに入れておくと、ビルド時に、シェーダが使用されてないとみなされてストリップされるのを防げるらしい）
![Kobito.LDnEjZ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/9535889e-dbd3-e469-de33-74222d3b5f4e.png "Kobito.LDnEjZ.png")
Create > Shader > Image Effect Shader あたりを選んでおいて、名前はまた EdgeDetect としておきましょう。生成されたファイルの中身を以下の内容にします：

```hlsl
Shader "Hidden/Shader/EdgeDetect"
{
    HLSLINCLUDE
    #pragma target 4.5
    #pragma only_renderers d3d11 ps4 xboxone vulkan metal switch
    #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
    #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Color.hlsl"
    #include "Packages/com.unity.render-pipelines.high-definition/Runtime/ShaderLibrary/ShaderVariables.hlsl"
    #include "Packages/com.unity.render-pipelines.high-definition/Runtime/PostProcessing/Shaders/FXAA.hlsl"
    #include "Packages/com.unity.render-pipelines.high-definition/Runtime/PostProcessing/Shaders/RTUpscale.hlsl"
    #include "Packages/com.unity.render-pipelines.high-definition/Runtime/Material/NormalBuffer.hlsl"

    struct Attributes
    {
        uint vertexID : SV_VertexID;
        UNITY_VERTEX_INPUT_INSTANCE_ID
    };
    struct Varyings
    {
        float4 positionCS : SV_POSITION;
        float2 texcoord   : TEXCOORD0;
        UNITY_VERTEX_OUTPUT_STEREO
    };
    Varyings Vert(Attributes input)
    {
        Varyings output;
        UNITY_SETUP_INSTANCE_ID(input);
        UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);
        output.positionCS = GetFullScreenTriangleVertexPosition(input.vertexID);
        output.texcoord = GetFullScreenTriangleTexCoord(input.vertexID);
        return output;
    }

    // List of properties to control your post process effect
    float _IntensityColor;
    float _ThicknessColor;
    float _RangeMinColor;
    float _RangeMaxColor;
    float _IntensityDepth;
    float _ThicknessDepth;
    float _RangeMinDepth;
    float _RangeMaxDepth;
    float _IntensityNormal;
    float _ThicknessNormal;
    float _RangeMinNormal;
    float _RangeMaxNormal;
    float4 _BGColor;
    float4 _FGColor;
    TEXTURE2D_X(_InputTexture);

    float bw(float4 c)
    {
        return dot(c.rgb, float3(0.3, 0.59, 0.11));
    }

	float sobelColor(Texture2D tex, float2 uv)
    {
        float2 delta = float2(_ThicknessColor, _ThicknessColor);
		float hr = 0;
		float vt = 0;
		float c;
        c = bw(LOAD_TEXTURE2D_X(tex, (uv + float2(-1.0, -1.0) * delta)));
		hr += c *  1.0;
		vt += c *  1.0;
        c = bw(LOAD_TEXTURE2D_X(tex, (uv + float2( 0.0, -1.0) * delta)));
 		vt += c *  2.0;
        c = bw(LOAD_TEXTURE2D_X(tex, (uv + float2( 1.0, -1.0) * delta)));
		hr += c * -1.0;
		vt += c *  1.0;
        c = bw(LOAD_TEXTURE2D_X(tex, (uv + float2(-1.0,  0.0) * delta)));
		hr += c *  2.0;
        c = bw(LOAD_TEXTURE2D_X(tex, (uv + float2( 1.0,  0.0) * delta)));
		hr += c * -2.0;
        c = bw(LOAD_TEXTURE2D_X(tex, (uv + float2(-1.0,  1.0) * delta)));
		hr += c *  1.0;
		vt += c * -1.0;
        c = bw(LOAD_TEXTURE2D_X(tex, (uv + float2( 0.0,  1.0) * delta)));
		vt += c * -2.0;
        c = bw(LOAD_TEXTURE2D_X(tex, (uv + float2( 1.0,  1.0) * delta)));
		hr += c * -1.0;
		vt += c * -1.0;
        float s = sqrt(hr * hr + vt * vt);
        s *= _IntensityColor;
        s = s < _RangeMinColor ? 0 : s;
        s = s > _RangeMaxColor ? 1 : s;
		return s;
	}
	
	float sobelDepth(float2 uv)
    {
        float2 delta = float2(_ThicknessDepth, _ThicknessDepth);
		float hr = 0;
		float vt = 0;
		float c;
        c = LoadCameraDepth(uv + float2(-1.0, -1.0) * delta);
		hr += c *  1.0;
		vt += c *  1.0;
        c = LoadCameraDepth(uv + float2( 0.0, -1.0) * delta);
		vt += c *  2.0;
        c = LoadCameraDepth(uv + float2( 1.0, -1.0) * delta);
		hr += c * -1.0;
		vt += c *  1.0;
        c = LoadCameraDepth(uv + float2(-1.0,  0.0) * delta);
		hr += c *  2.0;
        c = LoadCameraDepth(uv + float2( 1.0,  0.0) * delta);
		hr += c * -2.0;
        c = LoadCameraDepth(uv + float2(-1.0,  1.0) * delta);
		hr += c *  1.0;
		vt += c * -1.0;
        c = LoadCameraDepth(uv + float2( 0.0,  1.0) * delta);
		vt += c * -2.0;
        c = LoadCameraDepth(uv + float2( 1.0,  1.0) * delta);
		hr += c * -1.0;
		vt += c * -1.0;
        float s = sqrt(hr * hr + vt * vt);
        s *= _IntensityDepth * 100;
        s = s < _RangeMinDepth ? 0 : s;
        s = s > _RangeMaxDepth ? 1 : s;
		return s;
	}

    float3 getNormalFromGBuffer(float2 uv)
    {
        NormalData normalData;
        DecodeFromNormalBuffer(uv, normalData);
        return normalData.normalWS;
    }

    float sobelNormal(float2 uv)
    {
        float2 delta = float2(_ThicknessNormal, _ThicknessNormal);
        float3 o = getNormalFromGBuffer(uv);
		float hr = 0;
		float vt = 0;
		float3 c;
        c = getNormalFromGBuffer(uv + float2(-1.0, -1.0) * delta);
		hr += distance(o, c) * 1.0;
		vt += distance(o, c) * 1.0;
        c = getNormalFromGBuffer(uv + float2( 0.0, -1.0) * delta);
		vt += distance(o, c) * 2.0;
        c = getNormalFromGBuffer(uv + float2( 1.0, -1.0) * delta);
		hr += distance(o, c) * -1.0;
		vt += distance(o, c) *  1.0;
        c = getNormalFromGBuffer(uv + float2(-1.0,  0.0) * delta);
		hr += distance(o, c) * 2.0;
        c = getNormalFromGBuffer(uv + float2( 1.0,  0.0) * delta);
		hr += distance(o, c) *-2.0;
        c = getNormalFromGBuffer(uv + float2(-1.0,  1.0) * delta);
		hr += distance(o, c) * 1.0;
		vt += distance(o, c) *-1.0;
        c = getNormalFromGBuffer(uv + float2( 0.0,  1.0) * delta);
		vt += distance(o, c) *-2.0;
        c = getNormalFromGBuffer(uv + float2( 1.0,  1.0) * delta);
		hr += distance(o, c) *-1.0;
		vt += distance(o, c) *-1.0;
        float s = sqrt(hr * hr + vt * vt);
        s *= _IntensityNormal;
        s = s < _RangeMinNormal ? 0 : s;
        s = s > _RangeMaxNormal ? 1 : s;
		return s;
    }

    float4 CustomPostProcess(Varyings input) : SV_Target
    {
        UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
        float s = 0;
        s += sobelColor(_InputTexture, input.texcoord*_ScreenSize.xy);
        s += sobelDepth(input.positionCS.xy);
        s += sobelNormal(input.texcoord*_ScreenSize.xy);
        float4 org = LOAD_TEXTURE2D_X(_InputTexture, input.texcoord*_ScreenSize.xy);
        float3 col = lerp(lerp(_BGColor, org, 1-_BGColor.a), _FGColor, clamp(s, 0, 1));
        return float4(col.rgb, 1);
    }
    ENDHLSL

    SubShader
    {
        Pass
        {
            Name "EdgeDetect"
            ZWrite Off
            ZTest Always
            Blend Off
            Cull Off
            HLSLPROGRAM
                #pragma fragment CustomPostProcess
                #pragma vertex Vert
            ENDHLSL
        }
    }
    Fallback Off
}
```

#余談：実装について
シェーダの実装内容は Sobel filter です。とくにトリックめいたことはしていません。シェーダの実装を変更すれば、別のアルゴリズムも試せるでしょう。

# Custom Post Process Orders
コンソールにコンパイルエラーがないことを確認したら、Edit > Project Settings を開いて HDRP Default Settings の Custom Post Process Orders （いちばん下）の After Post Process に MyPostProcess.EdgeDetect （上の手順で作られたもの）を追加します。
![Kobito.cxPmHx.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/f352956a-728b-76c0-233a-ee54480ac548.png "Kobito.cxPmHx.png")
この段階でGame Viewにはエッジ抽出の効果が現れるでしょう。

# Post Process Volume に追加
設定をいじれるようにします。Hierarchy にある Post Process Volume を選択し、Inspector から Volume の Add Override を押して、EdgeDetect を選びます。(edge などと入力すると出てきます）

![Kobito.9RGOQu.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/33a8ac02-52a3-5f48-0a53-ba0dbecdcd15.png "Kobito.9RGOQu.png")

以上で設定は完了です。

# 調整項目

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/73c0a13b-bb52-a20c-c6a5-92ffc75ade91.png)

Enable Color Buffer : エッジ抽出にカラーバッファ（通常画像）を使用します
Intensity Color : カラーバッファ（通常画像）から検出したエッジの強さを増幅します。ゼロは無効と同義です
Thickness Color : カラーバッファ（通常画像）の検出で使用するデルタ値で、単位はピクセルです。小さくするのはいいとして、1 より大きくしてもあまり綺麗にはならないでしょう
Range Color : カラーバッファ（通常画像）から検出したエッジの強さをこの範囲に収めます。輪郭線の濃さを均一にしたい場合はこれを狭くすると良いです

Enable Depth Buffer : エッジ抽出にデプスバッファを使用します
Intensity Depth : デプスバッファから検出したエッジの強さを増幅します。ゼロは無効と同義です
Thickness Depth : デプスバッファの検出で使用するデルタ値で、単位はピクセルです。小さくするのはいいとして、1 より大きくしてもあまり綺麗にはならないでしょう
Range Depth : デプスバッファから検出したエッジの強さをこの範囲に収めます。輪郭線の濃さを均一にしたい場合はこれを狭くすると良いです

Enable Normal Buffer : エッジ抽出にノーマルバッファを使用します
Intensity Normal : ノーマルバッファから検出したエッジの強さを増幅します。ゼロは無効と同義です
Thickness Normal : ノーマルバッファの検出で使用するデルタ値で、単位はピクセルです。小さくするのはいいとして、1 より大きくしてもあまり綺麗にはならないでしょう
Range Normal : ノーマルバッファから検出したエッジの強さをこの範囲に収めます。輪郭線の濃さを均一にしたい場合はこれを狭くすると良いです

BG Color : 輪郭じゃない部分の色です。アルファ値に意味があり、アルファを 0.5 にしてみると元々の色と合成したものになります
FG Color : 輪郭の色です。アルファ値に意味はありません

# 結果
![Kobito.7SI1Ep.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/869e12a1-9f1a-e9c6-6680-dcbbef4f3663.png "Kobito.7SI1Ep.png")
この例ではデフォルトのポストエフェクトにTonemappingを加えて調整しています。このように他のポストエフェクトも組み合わせた結果で輪郭抽出できるので、いろいろ工夫できるのではないでしょうか。あと、宿命としてソフトシャドウとは相性がよくないですね。
また、BG Color の設定で元の色と混合できるようにしたので、この例ではヘルメットの黄色が出ていますね。なかなかアジのある結果が得られるのではないでしょうか。めでたしめでたし。

追記：デプスバッファおよびノーマルバッファも使用したエッジ抽出に対応し、コードおよび設定の説明を更新しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90046/3e4e1bb9-5622-772b-7763-e11314e374f0.png)

追記２：環境によっては GPU が対応していないなどのメッセージが出てしまうかもしれません。デプスやノーマルを無効にするにはシェーダの
        s += sobelDepth(input.positionCS.xy);
        s += sobelNormal(input.texcoord*_ScreenSize.xy);
この２行をコメントアウトすると（カラーバッファのみになりますが）動作するかもしれません。