前言
周末通關了一個小遊戲,流程很短,6個小時左右就通關,但是遊戲的畫風,視角,玩法都比較新奇,對了,遊戲的名字也很奇特《12 Is Better Than 6》(12比6好是有什麼梗嗎?)。
遊戲采用的是俯視角,人物在活着的時候基本隻能看到個帽子,玩法類似很早玩的《奪寶奇兵》《盟軍敢死隊》《1937特種兵》,但是遊戲是西部牛仔的背景,畫風采用的黑白素描的風格。
遊戲玩法雖然簡單,主要是射擊+暗殺,但是非常硬核。前期有左輪手槍的時候,需要開一槍,按一下右鍵轉動左輪再開下一槍,上彈也是需要手動按。有幾關過了很久才過去。後期拿到雙左輪之後難度就大大降低啦。
遊戲通關之後,留給我印象最深刻的還是遊戲本身的渲染風格,這種類似鉛筆描繪的風格看起來也挺舒服的。也不由得讓我想嘗試一下類似的效果,今天主要來玩一下基于後處理的邊緣檢測效果。
簡介
邊緣檢測,在圖像處理,計算機視覺中都是很重要的一個概念。在圖像處理領域,由于輸入隻有一張圖檔,是以一般是将圖檔轉成灰階,然後判斷圖檔像素間的梯度來判斷圖像中的邊界的;而在3D渲染領域,除了場景渲染結果圖檔外,我們還可以得到場景的深度以及法線等資訊,讓我們可以得到更加精确的邊緣檢測結果。邊緣檢測在渲染中雖然可能沒有圖像處理領域那樣出名,但是也是可以用來實作一些特殊渲染風格,渲染效果,以及後處理AA等功能。
邊緣檢測的方式是使用一些邊緣檢測的算子對圖像進行卷積操作,和之前玩過的高斯模糊,雙邊濾波類似,都是通過目前像素點及其周圍像素點按照一定的規則權重計算得到結果。
本文的效果實際上也算是一種描邊效果的實作,關于其他類型的描邊效果,可以參考本人之前的blog-《Unity Shader-描邊效果》。
基于圖像的邊緣檢測
首先,我們看一下基于圖像的邊緣檢測。也就是隻在後處理階段使用邊緣檢測算子針對圖像的灰階計算梯度。我們能看到圖像的邊界,在于圖像中的亮度等因素有明顯差異,我們可以用梯度來表示這種邊界的權重,梯度越大,邊緣就越明顯。在圖像處理領域已經有了很成熟的邊緣檢測卷積方式,比如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》(艾迪芬奇的記憶),神作啊!!下篇的開頭繼續安利!