在URP實現局部後處理描邊

歡迎參與討論,轉載請註明出處。

前言

最近在Demo開發的過程中,遇到了一個細節問題,場景模型之間的邊界感很弱:
在這裏插入圖片描述
  這樣就會導致玩家難以分辨接下來面對的究竟是可以跳下去的臺階,亦或是要跳過去的臺階了。我們想到的解決方法便是給場景模型加個外描邊,以此區分:
在這裏插入圖片描述
  整挺好,於是本文就來介紹一下實現思路。首先按照慣性我們直接採用了與人物相同的法線外擴描邊,但是效果卻不盡人意:
在這裏插入圖片描述
  這完全就牛頭不對馬嘴,既然老辦法不好使那就看看後處理描邊吧。不過由於Demo使用的渲染管線是URP,在後處理這塊與原生完全不同。於是乎再一次踏上了踩坑之旅……
另附源碼地址:https://github.com/MusouCrow/TypeOutline

RenderFeature

經過調查發現,URP除了Post-processing之外,並沒有直接提供屏幕後處理的方案。而URP的Post-processing尚不穩定(與原生產生了版本分裂),所以還是去尋找更穩妥的方式。根據官方例程找到了實現屏幕後處理描邊的方式,當然它們的描邊實現方式很搓,並不適合我們項目。於是取其精華去其糟粕,發現了其實現後處理的關鍵:RenderFeature
在這裏插入圖片描述
  RenderFeature系屬於URP的配置三件套之一的Forward Renderer,你可以在該配置文件裏添加想要的RenderFeature,可以將它看做是一種自定義的渲染行爲,通過CommandBuffer提交自己的渲染命令到任一渲染時點(如渲染不透明物體後、進行後處理之前)。URP默認只提供了RenderObjects這一RenderFeature,作用是使用特定的材質,在某個渲染時機,對某些Layer的對象進行一遍渲染。這顯然不是我們所需要的,所幸官方例程裏提供了我們想要的RenderFeature——Blit,它提供了根據材質、且材質可獲取屏幕貼圖,並渲染到屏幕上的功能:

Shader "Custom/Test"
{
    Properties
    {
        [HideInInspector]_MainTex("Base (RGB)", 2D) = "white" {}
    }
    SubShader
    {
        Pass
        {
            HLSLPROGRAM

            #pragma vertex vert
			#pragma fragment frag

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

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);

            struct Attributes
            {
                float4 positionOS : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct Varyings
            {
                float4 vertex : SV_POSITION;
                float2 uv : TEXCOORD0;
            };

            Varyings vert(Attributes input)
            {
                Varyings output;

                VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
                output.vertex = vertexInput.positionCS;
                output.uv = input.uv;

                return output;
            }

            float4 frag(Varyings input) : SV_Target
            {
                float4 color = 1 - SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv);
                
                return color;
            }

            ENDHLSL
        }
    }
    FallBack "Diffuse"
}

在這裏插入圖片描述
  如此這般便實現了經典的反色效果,只要引入Blit的相關代碼,然後在Forward Renderer文件進行RenderFeature的相關配置,並實現Shader與材質,即可生效。較之原生在MonoBehaviour做這種事,URP的設計明顯更爲合理。

Outline

後處理部署完畢,接下來便是描邊的實現了。按照正統的屏幕後處理做法,應該是基於一些屏幕貼圖(深度、法線、顏色等),使用Sobel算子之類做邊緣檢測。然而也有一些雜技做法,如官方例程以及此篇。當然相同的是,它們都需要使用屏幕貼圖作爲依據來進行處理,不同的屏幕貼圖會導致不一樣的效果,如上文那篇就使用深度與法線結合的貼圖,產生了內描邊的效果。然而我們只需要外描邊而已,所以使用深度貼圖即可。
  深度貼圖在URP的獲取相當簡單,只需要在RenderPipelineAsset文件將Depth Texture勾選,然後便可在後處理Shader通過_CameraDepthTexture變量獲取:
在這裏插入圖片描述
  有了深度貼圖,那麼接下來逮着別人的Shader抄就完事了——然而那些雜技做法的效果通通不行:官方的更適合美式風格,上文那篇的做法在某些場合會產生奇怪的斑點。於是只好按照《UnityShader入門精要》的寫法來了:

Shader "Custom/Outline"
{
    Properties
    {
        [HideInInspector]_MainTex("Base (RGB)", 2D) = "white" {}
        _Rate("Rate", Float) = 0.5
        _Strength("Strength", Float) = 0.7
    }
    SubShader
    {
        Pass
        {
            HLSLPROGRAM

            #pragma vertex vert
			#pragma fragment frag

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

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);

            TEXTURE2D(_CameraDepthTexture);
            SAMPLER(sampler_CameraDepthTexture);
            float4 _CameraDepthTexture_TexelSize;

            float _Rate;
            float _Strength;

            struct Attributes
            {
                float4 positionOS : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct Varyings
            {
                float4 vertex : SV_POSITION;
                float2 uv[9] : TEXCOORD0;
            };

            Varyings vert(Attributes input)
            {
                Varyings output;

                VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
                output.vertex = vertexInput.positionCS;

                output.uv[0] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(-1, -1) * _Rate;
                output.uv[1] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(0, -1) * _Rate;
                output.uv[2] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(1, -1) * _Rate;
                output.uv[3] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(-1, 0) * _Rate;
                output.uv[4] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(0, 0) * _Rate;
                output.uv[5] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(1, 0) * _Rate;
                output.uv[6] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(-1, 1) * _Rate;
                output.uv[7] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(0, 1) * _Rate;
                output.uv[8] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(1, 1) * _Rate;

                return output;
            }

            float4 frag(Varyings input) : SV_Target
            {
                const half Gx[9] = {
                    -1,  0,  1,
                    -2,  0,  2,
                    -1,  0,  1
                };

                const half Gy[9] = {
                    -1, -2, -1,
                    0,  0,  0,
                    1,  2,  1
                };
                
                float edgeY = 0;
                float edgeX = 0;    
                float luminance = 0;

                float4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv[4]);

                for (int i = 0; i < 9; i++) {
                    float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, input.uv[i]);
                    luminance = LinearEyeDepth(depth, _ZBufferParams) * 0.1;
                    edgeX += luminance * Gx[i];
                    edgeY += luminance * Gy[i];
                }
                
                float edge = (1 - abs(edgeX) - abs(edgeY));
                edge = saturate(edge);

                return lerp(color * _Strength, color, edge);
            }

            ENDHLSL
        }
    }
    FallBack "Diffuse"
}

在這裏插入圖片描述
  很棒,但是可以看到,身爲一般物件的方磚也被描邊了,可我們想要的只是場景描邊而已——於是進入了最後的難題:對特定對象的後處理。

Mask

首先我們參考原生下的做法,利用模板測試的特性,對特定對象的Shader寫入模板值,然後在後處理時根據模板值做判斷是否處理,確實是個絕妙的做法——很可惜,在URP下我找不到能夠生效的做法。根據上文那篇需要渲染出深度法線結合的屏幕貼圖的需要,作者實現了一個新的RenderFeature:根據渲染對象們的某個Pass,渲染成一張新的屏幕貼圖(可選擇使用特定的材質,若不使用則是Pass的結果)。並可作爲全局變量供後續的後處理Shader使用。我將之命名爲RenderToTexture,這也是後處理常用的一種技術。
  有了這個便有了新的想法:爲所有渲染對象的Shader添加新的Pass(名爲Mask),該Pass根據參數配置決定渲染成怎樣的顏色(需要描邊爲白色,不需要爲黑色)。如此渲染成屏幕貼圖後便可作爲描邊Shader的參考(下稱Mask貼圖),決定是否需要描邊:
在這裏插入圖片描述
  注意要爲Mask貼圖的底色設置爲非黑色,否則與底色接壤的物件會描邊失敗。那麼見證成果吧:

Shader "Custom/Outline"
{
    Properties
    {
        [HideInInspector]_MainTex("Base (RGB)", 2D) = "white" {}
        _Rate("Rate", Float) = 0.5
        _Strength("Strength", Float) = 0.7
    }
    SubShader
    {
        Pass
        {
            HLSLPROGRAM

            #pragma vertex vert
			#pragma fragment frag

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

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);

            TEXTURE2D(_CameraDepthTexture);
            SAMPLER(sampler_CameraDepthTexture);
            float4 _CameraDepthTexture_TexelSize;

            TEXTURE2D(_MaskTexture);
            SAMPLER(sampler_MaskTexture);

            float _Rate;
            float _Strength;

            struct Attributes
            {
                float4 positionOS : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct Varyings
            {
                float4 vertex : SV_POSITION;
                float2 uv[9] : TEXCOORD0;
            };

            Varyings vert(Attributes input)
            {
                Varyings output;

                VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
                output.vertex = vertexInput.positionCS;

                output.uv[0] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(-1, -1) * _Rate;
                output.uv[1] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(0, -1) * _Rate;
                output.uv[2] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(1, -1) * _Rate;
                output.uv[3] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(-1, 0) * _Rate;
                output.uv[4] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(0, 0) * _Rate;
                output.uv[5] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(1, 0) * _Rate;
                output.uv[6] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(-1, 1) * _Rate;
                output.uv[7] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(0, 1) * _Rate;
                output.uv[8] = input.uv + _CameraDepthTexture_TexelSize.xy * half2(1, 1) * _Rate;

                return output;
            }

            float4 frag(Varyings input) : SV_Target
            {
                const half Gx[9] = {
                    -1,  0,  1,
                    -2,  0,  2,
                    -1,  0,  1
                };

                const half Gy[9] = {
                    -1, -2, -1,
                    0,  0,  0,
                    1,  2,  1
                };
                
                float edgeY = 0;
                float edgeX = 0;    
                float luminance = 0;

                float4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv[4]);
                float mask = 1;
                
                for (int i = 0; i < 9; i++) {
                    mask *= SAMPLE_DEPTH_TEXTURE(_MaskTexture, sampler_MaskTexture, input.uv[i]);
                }

                if (mask == 0) {
                    return color;
                }

                for (int i = 0; i < 9; i++) {
                    float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, sampler_CameraDepthTexture, input.uv[i]);
                    luminance = LinearEyeDepth(depth, _ZBufferParams) * 0.1;
                    edgeX += luminance * Gx[i];
                    edgeY += luminance * Gy[i];
                }
                
                float edge = (1 - abs(edgeX) - abs(edgeY));
                edge = saturate(edge);

                return lerp(color * _Strength, color, edge);
            }

            ENDHLSL
        }
    }
    FallBack "Diffuse"
}

在這裏插入圖片描述
  很棒,這下一般物件不會被描邊了,局部後處理描邊完成!當然隨後遇到一個新的問題:
在這裏插入圖片描述
  這是因爲透明(Transparent)模式下的對象按照通用做法是不會寫入深度信息的(爲了透明時能看到模型內部),然而我們描邊需要的正是深度信息,由於樹葉沒有寫入深度信息,所以在描邊時當它不存在了,於是產生了這樣的結果。解決方法也好辦,在透明模式也寫入深度信息(ZWrite)即可,畢竟我們的透明模型不需要看到內部,一舉兩得。

後記

其實期間還產生了投機心理,想着把角色自帶的描邊給廢了,統一後處理,豈不美哉?很可惜搞出來的效果始終是不滿意,法線外擴 is Good,沒辦法嘍——
  順帶一提,對於後處理的貼圖創建記得將msaaSamples屬性設爲1,否則就會進行抗鋸齒處理,那可真的炸裂……

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章