1. 程式人生 > >Unity Shader-邊緣檢測效果(基於顏色,基於深度法線,邊緣流光效果,轉場效果)

Unity Shader-邊緣檢測效果(基於顏色,基於深度法線,邊緣流光效果,轉場效果)

前言

週末通關了一個小遊戲,流程很短,6個小時左右就通關,但是遊戲的畫風,視角,玩法都比較新奇,對了,遊戲的名字也很奇特《12 Is Better Than 6》(12比6好是有什麼梗嗎?)。

遊戲採用的是俯視角,人物在活著的時候基本只能看到個帽子,玩法類似很早玩的《奪寶奇兵》《盟軍敢死隊》《1937特種兵》,但是遊戲是西部牛仔的背景,畫風采用的黑白素描的風格。

遊戲玩法雖然簡單,主要是射擊+暗殺,但是非常硬核。前期有左輪手槍的時候,需要開一槍,按一下右鍵轉動左輪再開下一槍,上彈也是需要手動按。有幾關過了很久才過去。後期拿到雙左輪之後難度就大大降低啦。

遊戲通關之後,留給我印象最深刻的還是遊戲本身的渲染風格,這種類似鉛筆描繪的風格看起來也挺舒服的。也不由得讓我想嘗試一下類似的效果,今天主要來玩一下基於後處理的邊緣檢測效果。

簡介

邊緣檢測,在影象處理,計算機視覺中都是很重要的一個概念。在影象處理領域,由於輸入只有一張圖片,所以一般是將圖片轉成灰度,然後判斷圖片畫素間的梯度來判斷影象中的邊界的;而在3D渲染領域,除了場景渲染結果圖片外,我們還可以得到場景的深度以及法線等資訊,讓我們可以得到更加精確的邊緣檢測結果。邊緣檢測在渲染中雖然可能沒有影象處理領域那樣出名,但是也是可以用來實現一些特殊渲染風格,渲染效果,以及後處理AA等功能。

邊緣檢測的方式是使用一些邊緣檢測的運算元對影象進行卷積操作,和之前玩過的高斯模糊,雙邊濾波類似,都是通過當前畫素點及其周圍畫素點按照一定的規則權重計算得到結果。

基於影象的邊緣檢測

首先,我們看一下基於影象的邊緣檢測。也就是隻在後處理階段使用邊緣檢測運算元針對影象的灰度計算梯度。我們能看到影象的邊界,在於影象中的亮度等因素有明顯差異,我們可以用梯度來表示這種邊界的權重,梯度越大,邊緣就越明顯。在影象處理領域已經有了很成熟的邊緣檢測卷積方式,比如Roberts運算元和Sobel運算元。主要的思想就是使用橫豎兩個方向的兩個矩陣對原圖進行卷積運算,得到兩個方向的亮度的梯度,兩個運算元如下(與常見的影象處理中定義可能稍微有一些區別,主要在於行矩陣和列矩陣的差異,表現的結果是一樣的):

我們在Shader中同時包含兩種邊緣檢測的運算元,對比效果。Shader程式碼如下:

/********************************************************************
 FileName: EdgeEffect.shader
 Description: 後處理描邊效果,使用Roberts和Sobel運算元,可調強度&檢測距離
 history: 11:11:2018 by puppet_master
 https://blog.csdn.net/puppet_master
*********************************************************************/
Shader "Edge/EdgeEffect"
{
	Properties
	{
		_MainTex ("Texture", 2D) = "white" {}
	}
	
	CGINCLUDE
	#include "UnityCG.cginc"
	struct appdata
	{
		float4 vertex : POSITION;
		float2 uv : TEXCOORD0;
	};
	
	struct v2f
	{
		float2 uvRoberts[5] : TEXCOORD0;
		float2 uvSobel[9] : TEXCOORD5;
		
		float4 vertex : SV_POSITION;
	};
	
	sampler2D _MainTex;
	float4 _MainTex_TexelSize;
	fixed4 _EdgeColor;
	fixed4 _NonEdgeColor;
	float _EdgePower;
	float _SampleRange;
	
	float Sobel(v2f i)
	{
		const float Gx[9] = 
		{
			-1, -2, -1,
			0,  0,  0,
			1,  2,  1
		};
		
		const float Gy[9] =
		{
			1, 0, -1,
			2, 0, -2,
			1, 0, -1
		};
		
		float edgex, edgey;
		for(int j = 0; j < 9; j++)
		{
			fixed4 col = tex2D(_MainTex, i.uvSobel[j]);
			float lum = Luminance(col.rgb);
			
			edgex += lum * Gx[j];
			edgey += lum * Gy[j];
		}
		return 1 - abs(edgex) - abs(edgey);
	}
	
	float Roberts(v2f i)
	{
		const float Gx[4] = 
		{
			-1,  0,
			0,  1
		};
		
		const float Gy[4] =
		{
			0, -1,
			1,  0
		};
		
		float edgex, edgey;
		for(int j = 0; j < 4; j++)
		{
			fixed4 col = tex2D(_MainTex, i.uvRoberts[j]);
			float lum = Luminance(col.rgb);
			
			edgex += lum * Gx[j];
			edgey += lum * Gy[j];
		}
		return 1 - abs(edgex) - abs(edgey);
	}
	
	v2f vert_Sobel (appdata v)
	{
		v2f o;
		o.vertex = UnityObjectToClipPos(v.vertex);
		o.uvSobel[0] = v.uv + float2(-1, -1) * _MainTex_TexelSize * _SampleRange;
		o.uvSobel[1] = v.uv + float2( 0, -1) * _MainTex_TexelSize * _SampleRange;
		o.uvSobel[2] = v.uv + float2( 1, -1) * _MainTex_TexelSize * _SampleRange;
		o.uvSobel[3] = v.uv + float2(-1,  0) * _MainTex_TexelSize * _SampleRange;
		o.uvSobel[4] = v.uv + float2( 0,  0) * _MainTex_TexelSize * _SampleRange;
		o.uvSobel[5] = v.uv + float2( 1,  0) * _MainTex_TexelSize * _SampleRange;
		o.uvSobel[6] = v.uv + float2(-1,  1) * _MainTex_TexelSize * _SampleRange;
		o.uvSobel[7] = v.uv + float2( 0,  1) * _MainTex_TexelSize * _SampleRange;
		o.uvSobel[8] = v.uv + float2( 1,  1) * _MainTex_TexelSize * _SampleRange;
		return o;
	}
	
	fixed4 frag_Sobel (v2f i) : SV_Target
	{
		fixed4 col = tex2D(_MainTex, i.uvSobel[4]);
		float g = Sobel(i);
		g = pow(g, _EdgePower);
		col.rgb = lerp(_EdgeColor, _NonEdgeColor, g);
	
		return col;
	}
	
	v2f vert_Roberts (appdata v)
	{
		v2f o;
		o.vertex = UnityObjectToClipPos(v.vertex);
		o.uvRoberts[0] = v.uv + float2(-1, -1) * _MainTex_TexelSize * _SampleRange;
		o.uvRoberts[1] = v.uv + float2( 1, -1) * _MainTex_TexelSize * _SampleRange;
		o.uvRoberts[2] = v.uv + float2(-1,  1) * _MainTex_TexelSize * _SampleRange;
		o.uvRoberts[3] = v.uv + float2( 1,  1) * _MainTex_TexelSize * _SampleRange;
		o.uvRoberts[4] = v.uv;
		return o;
	}
	
	fixed4 frag_Roberts (v2f i) : SV_Target
	{
		fixed4 col = tex2D(_MainTex, i.uvRoberts[4]);
		float g = Roberts(i);
		g = pow(g, _EdgePower);
		col.rgb = lerp(_EdgeColor, _NonEdgeColor, g);
	
		return col;
	}
	
	ENDCG
	
	SubShader
	{
		// No culling or depth
		Cull Off ZWrite Off ZTest Always

		//Pass 0 Sobel Operator
		Pass
		{
			CGPROGRAM
			#pragma vertex vert_Sobel
			#pragma fragment frag_Sobel
			ENDCG
		}
		
		//Pass 1 Roberts Operator
		Pass
		{
			CGPROGRAM
			#pragma vertex vert_Roberts
			#pragma fragment frag_Roberts
			ENDCG
		}
		
		
	}
}

C#程式碼如下:

/********************************************************************
 FileName: EdgeEffect.cs
 Description: 後處理描邊效果,使用Roberts和Sobel運算元,可調強度&檢測距離
 history: 11:11:2018 by puppet_master
 https://blog.csdn.net/puppet_master
*********************************************************************/
using UnityEngine;

[ExecuteInEditMode]
public class EdgeEffect : MonoBehaviour
{
    public enum EdgeOperator
    {
        Sobel = 0,
        Roberts = 1,
    }

    private Material edgeEffectMaterial = null;
    public Color edgeColor = Color.black;
    public Color nonEdgeColor = Color.white;
    [Range(1.0f, 10.0f)]
    public float edgePower = 1.0f;
    [Range(1, 5)]
    public int sampleRange = 1;

    public EdgeOperator edgeOperator = EdgeOperator.Sobel;

    private void Awake()
    {
        var shader = Shader.Find("Edge/EdgeEffect");
        edgeEffectMaterial = new Material(shader);
    }

    private void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        edgeEffectMaterial.SetColor("_EdgeColor", edgeColor);
        edgeEffectMaterial.SetColor("_NonEdgeColor", nonEdgeColor);
        edgeEffectMaterial.SetFloat("_EdgePower", edgePower);
        edgeEffectMaterial.SetFloat("_SampleRange", sampleRange);
        Graphics.Blit(source, destination, edgeEffectMaterial, (int)edgeOperator);
    }
}

依然是本人最常用的場景,原始的場景效果如下:

使用Roberts運算元的邊緣檢測效果如下,x1 Power:

效果不是很清晰,而使用Sobel運算元的邊緣檢測效果,x1 Power:

很明顯,Sobel運算元的效果會更好一些,但是我們如果將其都乘以一定的Power,實際上二者可以達到接近的效果,而Roberts的效能是要由於Sobel的。下面為Sobel x10Power後的效果:

頗有一些《12 is Better than 6》風格化的感覺了,我們再換個顏色,調整一下檢測的半徑,使線條變粗,則又是一種宣紙毛筆畫的風格:

基於深度法線的邊緣檢測

基於顏色的邊緣檢測的主要優點在於無需額外資訊,只需要場景圖本身,但是也有一定的缺點,如果兩個物件的顏色差異不明顯,即使有邊界也檢測不出來,可能出現一些瑕疵。如果我們想要純正的邊緣的效果的話,就需要用另一種更加準確的邊緣檢測方式。3D渲染相對於普通的二維影象處理的優勢就在於我們還可以得到一些其他的資訊,比如場景的深度,場景的法線,通過這兩者,我們可以在當前取樣點的周圍畫素點計演算法線的差異以及深度的差異,如果超過一定的閾值,就認為是邊界。

Shader程式碼如下:

/********************************************************************
 FileName: EdgeEffectDepthNormal.shader
 Description: 後處理描邊效果,使用DepthNormalTexture檢測
 history: 13:11:2018 by puppet_master
 https://blog.csdn.net/puppet_master
*********************************************************************/
Shader "Edge/EdgeEffectDepthNormal"
{
	Properties
	{
		_MainTex ("Texture", 2D) = "white" {}
	}
	
	CGINCLUDE
	#include "UnityCG.cginc"
	struct appdata
	{
		float4 vertex : POSITION;
		float2 uv : TEXCOORD0;
	};
	
	struct v2f
	{
		float2 uv[5] : TEXCOORD0;
		float4 vertex : SV_POSITION;
	};
	
	sampler2D _MainTex;
	float4 _MainTex_TexelSize;
	sampler2D _CameraDepthNormalsTexture;
	fixed4 _EdgeColor;
	fixed4 _NonEdgeColor;

	float _SampleRange;
	float _NormalDiffThreshold;
	float _DepthDiffThreshold;
	
	float CheckEdge(fixed4 s1, fixed4 s2)
	{
		float2 normalDiff = abs(s1.xy - s2.xy);
		float normalEdgeVal = (normalDiff.x + normalDiff.y) < _NormalDiffThreshold;
		
		float s1Depth = DecodeFloatRG(s1.zw);
		float s2Depth = DecodeFloatRG(s2.zw);
		float depthEdgeVal = abs(s1Depth - s2Depth) < 0.1 * s1Depth * _DepthDiffThreshold;
		return depthEdgeVal * normalEdgeVal;
	}
	
	v2f vert (appdata v)
	{
		v2f o;
		o.vertex = UnityObjectToClipPos(v.vertex);
		o.uv[0] = v.uv + float2(-1, -1) * _MainTex_TexelSize * _SampleRange;
		o.uv[1] = v.uv + float2( 1, -1) * _MainTex_TexelSize * _SampleRange;
		o.uv[2] = v.uv + float2(-1,  1) * _MainTex_TexelSize * _SampleRange;
		o.uv[3] = v.uv + float2( 1,  1) * _MainTex_TexelSize * _SampleRange;
		o.uv[4] = v.uv;
		return o;
	}
	
	fixed4 frag (v2f i) : SV_Target
	{
		fixed4 col = tex2D(_MainTex, i.uv[4]);
		fixed4 s1 = tex2D(_CameraDepthNormalsTexture, i.uv[0]);
		fixed4 s2 = tex2D(_CameraDepthNormalsTexture, i.uv[1]);
		fixed4 s3 = tex2D(_CameraDepthNormalsTexture, i.uv[2]);
		fixed4 s4 = tex2D(_CameraDepthNormalsTexture, i.uv[3]);
		
		float result = 1.0;
		result *= CheckEdge(s1, s4);
		result *= CheckEdge(s2, s3);
		col.rgb = lerp(_EdgeColor, _NonEdgeColor, result);
		return col;
	}
	
	ENDCG
	
	SubShader
	{
		// No culling or depth
		Cull Off ZWrite Off ZTest Always
		
		//Pass 0 Roberts Operator
		Pass
		{
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			ENDCG
		}
		
		
	}
}

C#程式碼如下:

/********************************************************************
 FileName: EdgeEffectDepthNormal.cs
 Description: 後處理描邊效果,使用DepthNormalTexture進行檢測
 history: 13:11:2018 by puppet_master
 https://blog.csdn.net/puppet_master
*********************************************************************/
using UnityEngine;

[ExecuteInEditMode]
public class EdgeEffectDepthNormal : MonoBehaviour
{
    private Material edgeEffectMaterial = null;
    public Color edgeColor = Color.black;
    public Color nonEdgeColor = Color.white;
    [Range(1, 5)]
    public int sampleRange = 1;
    [Range(0, 1.0f)]
    public float normalDiffThreshold = 0.2f;
    [Range(0, 5.0f)]
    public float depthDiffThreshold = 2.0f;

    private void Awake()
    {
        var shader = Shader.Find("Edge/EdgeEffectDepthNormal");
        edgeEffectMaterial = new Material(shader);
    }

    private void OnEnable()
    {
        var cam = GetComponent<Camera>();
        cam.depthTextureMode |= DepthTextureMode.DepthNormals;
    }

    private void OnDisable()
    {
        var cam = GetComponent<Camera>();
        cam.depthTextureMode = DepthTextureMode.None;
    }

    private void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        edgeEffectMaterial.SetColor("_EdgeColor", edgeColor);
        edgeEffectMaterial.SetColor("_NonEdgeColor", nonEdgeColor);
        edgeEffectMaterial.SetFloat("_SampleRange", sampleRange);
        edgeEffectMaterial.SetFloat("_NormalDiffThreshold", normalDiffThreshold);
        edgeEffectMaterial.SetFloat("_DepthDiffThreshold", depthDiffThreshold);
        Graphics.Blit(source, destination, edgeEffectMaterial);
    }
}

還是之前的場景圖:

使用邊緣檢測的效果如下(Depth + Normal同時檢測):

單獨使用Depth檢測的效果:

單獨使用Normal檢測的效果:

實際上,單獨使用Depth和單獨使用Normal都可以實現邊緣檢測,但是二者結合起來使用可能效果更好一些,正好CameraDepthNormalTexture中二者都包含,索性一起用啦。

邊緣檢測實現高亮流光效果

實現了基本的邊緣檢測效果,我們除了可以用這個技術做一些特殊的渲染風格外,還可以實現一些特殊的效果。比如加一個Flash貼圖,類似之前的流光效果:

float v = tex2D(_FlashTexture, i.uvSobel[4] + float2(_EffectPercentage * _Time.y, 0.0)).r * 10;
fixed3 edge = lerp(_EdgeColor, _NonEdgeColor, g);
col.rgb = lerp(edge, col.rgb, saturate(v));

效果如下:

反過來也可以:

當然,我們也可以只讓描邊本身和原始效果融合,達到僅顯示高兩部分邊緣的效果:

float v = tex2D(_FlashTexture, i.uv[4] + float2(_EffectPercentage * _Time.y, 0.0)).r;
col.rgb = v * (1 - result) * _EdgeColor + col.rgb;

效果如下:

如果使用DepthNormalMap檢測,可以獲得更精準的邊緣流動效果:

邊緣檢測實現過渡效果

有了邊緣檢測的基本效果,下面就是發揮想象力的時間了。我們可以再做一些其他的效果,比如轉場的效果,把邊緣效果和場景原始效果做一個基本的插值,實現一個最基本的轉場:

fixed3 edge = lerp(_EdgeColor, _NonEdgeColor, g);
col.rgb = lerp(edge, col.rgb, _EffectPercentage);

效果動圖如下:

不夠酷炫,那麼我們就讓這個轉場實現一個按照方向來的漸變,根據uv控制漸變的方向,再用噪聲新增一些隨機效果:

fixed3 edge = lerp(_EdgeColor, _NonEdgeColor, g);
float noise = tex2D(_FlashTexture, i.uvSobel[4]).r * _NoiseFactor;
float control = _EffectPercentage > (i.uvSobel[4].x + noise);
control = saturate(control);
col.rgb = lerp(edge, col.rgb, control);

效果如下:

總結

本文主要實現了基於顏色以及基於深度和法線的邊緣檢測效果,然後使用邊緣檢測實驗了一些特殊的渲染風格,以及流光,轉場等特殊效果。

最後,最近通關了《What Remains of Edith Finch》(艾迪芬奇的記憶),神作啊!!下篇的開頭繼續安利!