前言
周末通关了一个小游戏,流程很短,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》(艾迪芬奇的记忆),神作啊!!下篇的开头继续安利!