US3L11L7——利用深度法线纹理实现边缘检测效果

知识回顾 边缘检测屏幕后处理效果

  1. 边缘检测效果是什么

    是一种用于突出图像中的边缘,使物体的轮廓更加明显的图像处理技术,利用 Shader 代码自动给屏幕图像进行描边处理

  2. 边缘检测效果的基本原理

    1. 得到 当前像素以及其上下左右、左上左下、右上右下共9个像素的灰度值
    2. 用这 9 个灰度值和 Sobel 算子进行卷积计算得到梯度值 G = abs(Gx) + abs(Gy)
    3. 最终颜色 = lerp(原始颜色,描边颜色,梯度值)
  3. 如何得到当前像素周围 8 个像素位置

    利用 float4 纹理名_TexelSize​ 纹素信息得到当前像素周围8个像素位置

为什么要基于深度法线纹理实现边缘检测

我们之前利用 Sobel 算子基于像素灰度值计算出来的边缘检测效果,其实总体上来说不太理想。
因为这种计算方式依赖于像素的颜色(灰度值)变化来识别边缘,会受到物体纹理和阴影颜色等因素影响。
所以这种制作方式往往不能准确的反应出物体的真实轮廓!

image

因此我们才会来学习 基于深度+法线纹理来实现边缘检测屏幕后期处理效果
通过这种方式实现的边缘检测,不会受纹理和光照的影响,
只会根据渲染物体的模型信息(深度、法线)来进行判断检测,这样检测出来的边缘更加的可靠!

image

不过要注意:

  • 对于 2D 图片,基于灰度值的边缘检测更合适
  • 对于 3D 场景,基于深度+法线纹理的边缘检测更合适

主要原因是 2D 图片中的深度和法线信息往往是一致的,不存在差异性

利用深度+法线纹理实现边缘检测的基本原理

一句话概括它的基本原理:基于Roberts(罗伯茨)交叉算子,通过比较对角线上的的像素的深度和法线值,判断是否在边缘上。 它的关键点是:

  1. 如何得到对角线上的像素
  2. 如何进行深度和法线值的比较
  3. 如何决定是否在边缘

image

注意:这里我们不会使用算子去进行卷积运算

针对每个关键点进行分析:

  1. 如何得到对角线上的像素

    在顶点着色器中,利用纹素进行 UV 坐标偏移计算
    并且我们可以添加一个可控的采样偏移距离变量 _SampleDistance​,它可以用来决定描边的粗细,值越大描边越粗,它的原理是:

    • 采样离中心像素越近,检测的变化更细微,深度和法线值变化小,边缘会较细
    • 采样离中心像素越远,检测的范围较大,深度和法线值变化大,边缘会较粗
    1
    2
    3
    4
    5
    6
    7
    half2 uv = v.texcoord;
    o.uv[0] = uv; // 当前中心点位置UV坐标
    // 沿着对角线获取周围像素的UV坐标
    o.uv[1] = uv + _MainTex_TexelSize.xy * half2(1, 1) * _SampleDistance;
    o.uv[2] = uv + _MainTex_TexelSize.xy * half2(-1, -1) * _SampleDistance;
    o.uv[3] = uv + _MainTex_TexelSize.xy * half2(-1, 1) * _SampleDistance;
    o.uv[4] = uv + _MainTex_TexelSize.xy * half2(1, -1) * _SampleDistance;
  2. 如何进行深度和法线值的比较

    在片元着色器中,利用顶点着色器中得到的UV坐标,
    在深度+法线纹理中进行采样,得到深度值和法线值,再求出对角线上对角的两个像素的深度值差和法线值差。
    如果其中一个的差值大于了自定义的阈值,那么我们认为该像素点在物体的边缘上,否则不是边缘

  3. 如何决定是否在边缘

    根据刚才进行的深度和法线值的比较,只要其中之一(深度或者法线)满足了大于自定义阈值的条件,
    那么该像素就在边缘上,就使用描边颜色进行颜色处理,关键计算规则如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    half CheckSame(half4 point1, half4 point2)
    {
    // 得到两个点的法线xy(不需要解码,在同一空间下,只使用xy计算就够用了)和深度值
    half2 point1Normal = point1.xy;
    float point1Depth = DecodeFloatRG(point1.zw);
    half2 point2Normal = point2.xy;
    float point2Depth = DecodeFloatRG(point2.zw);

    // 法线差异计算,并判断是否在同一法线区间
    half2 diffNormal = ads(point1Normal - point2Normal) * 自定义法线敏感度变量;
    int isSameNormal = (diffNormal.x + diffNormal.y) < 0.1; // 0.1是一个常用的阈值
    // 深度差异计算,并判断是否在同一深度区间
    float diffDepth = abs(point1Depth - point2Depth) * 自定义深度敏感度变量;
    int isSameDepth = diffDepth < 0.1 * point1Depth; // 0.1是一个常用的阈值

    // 返回值:1 - 法线和深度基本相似、0 - 不相似,证明中心像素在物体边缘处
    return isSameNormal * isSameDepth ? 1.0 : 0.0;
    }

实现 利用深度+法线纹理实现边缘检测屏幕后期处理效果 对应 Shader

  1. 新建 Shader,取名 EdgeDetectionWithDepthNormalsTexture​,删除无用代码

  2. 声明属性,属性映射

    • 主纹理 _MainTex
    • 边缘检测强度 _EdgeOnly​,其中:0​ 显示场景、1​ 只显示边缘,用于控制自定义背景色程度
    • 描边颜色:_EdgeColor
    • 背景颜色:_BackgroundColor
    • 采样偏移距离:_SampleDistance
    • 深度敏感度:_SensitivityDepth
    • 法线敏感度:_SensitivityNormal

    注意:映射属性时需要加入:

    • 纹素:_MainTex_TexelSize
    • 深度+法线纹理:_CameraDepthNormalsTexture
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    Properties
    {
    _MainTex("Texture", 2D) = "white"{}
    _EdgeOnly("EdgeOnly", Float) = 0 // 控制自定义背景颜色显示程度的属性
    _EdgeColor("EdgeColor", Color) = (0,0,0,0) // 边缘的描边颜色
    _BackgroundColor("BackgroundColor", Color) = (1,1,1,1) // 自定义背景颜色
    _SampleDistance("SampleDistance", Float) = 1 // 采样偏移程度,用于控制描边的粗细,值越大越粗
    _SansitivityDepth("SansitivityDepth", Float) = 1 // 深度变化敏感度
    _SansitivityNormal("SansitivityNormal", Float) = 1 // 法线变化敏感度
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    sampler2D _MainTex;
    half4 _MainTex_TexelSize; // 纹素,用于进行UV坐标偏移和取周围像素时使用
    sampler2D _CameraDepthNormalsTexture; // 屏幕的深度+法线纹理
    fixed _EdgeOnly; // 控制自定义背景颜色显示程度的属性
    fixed4 _EdgeColor; // 边缘的描边颜色
    fixed4 _BackgroundColor; // 自定义背景颜色
    float _SampleDistance; // 采样偏移程度,用于控制描边的粗细,值越大越粗
    float _SansitivityDepth; // 深度变化敏感度
    float _SansitivityNormal; // 法线变化敏感度
  3. 屏幕后处理标配设置

    ZTest Always​、Cull Off​、ZWrite Off

    1
    2
    3
    4
    5
    6
    7
    8
    SubShader
    {
    ZTest Always
    Cull Off
    ZWrite Off

    Pass {/*...*/}
    }
  4. 结构体

    • 顶点坐标
    • UV 数组,5个空间(存储中心点、对角线4个点)
    1
    2
    3
    4
    5
    struct v2f
    {
    half2 uv[5] : TEXCOORD0; // 用于存储5个像素的UV坐标
    float4 vertex : SV_POSITION;
    };
  5. 顶点着色器

    1. 顶点坐标转换
    2. 5 个 UV 坐标赋值,按这样的顺序:中心点、左上角、右下角、右上角、左下角
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    v2f vert (appdata_base v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    half2 uv = v.texcoord;
    o.uv[0] = uv; // 中心点
    // 沿着对角线获取周围像素的UV坐标
    o.uv[1] = uv + _MainTex_TexelSize.xy * half2(1, 1) * _SampleDistance; // 左上角
    o.uv[2] = uv + _MainTex_TexelSize.xy * half2(-1, -1) * _SampleDistance; // 右下角
    o.uv[3] = uv + _MainTex_TexelSize.xy * half2(-1, 1) * _SampleDistance; // 右上角
    o.uv[4] = uv + _MainTex_TexelSize.xy * half2(1, -1) * _SampleDistance; // 左下角

    return o;
    }
  6. 片元着色器

    1. 直接采样得到对角线四个点的深度法线信息
    2. 实现一个用于比较两点深度、法线信息的函数,返回 0 或 1,方便进行插值计算
    3. 声明一个插值变量 0 - 代表使用边缘色,1 - 代表使用源颜色
    4. 考虑背景色差值
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    // 用于比较两个点的深度和法线纹理中的采样得到的消息,用来判断是否是边缘
    // 返回值:1 - 法线和深度基本相同,处于同一平面上,0 - 差异大,不在一个平面上
    half CheckSame(half4 depthNormal1, half4 depthNormal2)
    {
    // 法线消息在这个场景下不需要解码就可以对比了
    float depth1 = DecodeFloatRG(depthNormal1.zw);
    float2 normal1 = depthNormal1.xy;
    float depth2 = DecodeFloatRG(depthNormal2.zw);
    float2 normal2 = depthNormal2.xy;

    // 法线的差异计算,计算两点法线xy的差值,并乘以自定义的敏感度,如果差异不大,返回1,反之为0
    float2 normalDiff = abs(normal1 - normal2) * _SensitivityNormal;
    int isSameNormal = (normalDiff.x + normalDiff.y) < 0.1;

    // 深度的差异计算,判断两点深度是否接近,并乘以自定义的敏感度,如果差异不大,返回1,反之为0
    float depthDiff = abs(depth1 - depth2) * _SensitivityDepth;
    int isSameDepth = depthDiff < 0.1 * depth1;

    return isSameNormal * isSameDepth ? 1 : 0;
    }

    fixed4 frag (v2f i) : SV_Target
    {
    // 获取四个点的深度和法线消息
    half4 topLeft = tex2D(_CameraDepthNormalsTexture, i.uv[1]);
    half4 bottomRight = tex2D(_CameraDepthNormalsTexture, i.uv[2]);
    half4 topRight = tex2D(_CameraDepthNormalsTexture, i.uv[3]);
    half4 bottomLeft = tex2D(_CameraDepthNormalsTexture, i.uv[4]);

    // 根据深度+法线消息,去判断是否是边缘
    half edgeLerpValue = 1;
    edgeLerpValue *= CheckSame(topLeft, bottomRight);
    edgeLerpValue *= CheckSame(topRight, bottomLeft);

    // 通过插值进行颜色变化
    fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[0]), edgeLerpValue);
    fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edgeLerpValue);

    // 通过_EdgeOnly决定屏幕显示纯色还是原背景颜色
    return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
    }
  7. FallBack Off

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
Shader "PostEffect/EdgeDetectionWithDepthNormalsTexture"
{
Properties
{
_MainTex("Texture", 2D) = "white"{}
_EdgeOnly("EdgeOnly", Float) = 0 // 控制自定义背景颜色显示程度的属性
_EdgeColor("EdgeColor", Color) = (0,0,0,0) // 边缘的描边颜色
_BackgroundColor("BackgroundColor", Color) = (1,1,1,1) // 自定义背景颜色
_SampleDistance("SampleDistance", Float) = 1 // 采样偏移程度,用于控制描边的粗细,值越大越粗
_SensitivityDepth("SensitivityDepth", Float) = 1 // 深度变化敏感度
_SensitivityNormal("SensitivityNormal", Float) = 1 // 法线变化敏感度
}
SubShader
{
Pass
{
ZTest Always
Cull Off
ZWrite Off

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

struct v2f
{
half2 uv[5] : TEXCOORD0; // 用于存储5个像素的UV坐标
float4 vertex : SV_POSITION;
};

sampler2D _MainTex;
half4 _MainTex_TexelSize; // 纹素,用于进行UV坐标偏移和取周围像素时使用
sampler2D _CameraDepthNormalsTexture; // 屏幕的深度+法线纹理
fixed _EdgeOnly; // 控制自定义背景颜色显示程度的属性
fixed4 _EdgeColor; // 边缘的描边颜色
fixed4 _BackgroundColor; // 自定义背景颜色
float _SampleDistance; // 采样偏移程度,用于控制描边的粗细,值越大越粗
float _SensitivityDepth; // 深度变化敏感度
float _SensitivityNormal; // 法线变化敏感度

v2f vert (appdata_base v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
half2 uv = v.texcoord;
o.uv[0] = uv; // 中心点
// 沿着对角线获取周围像素的UV坐标
o.uv[1] = uv + _MainTex_TexelSize.xy * half2(1, 1) * _SampleDistance; // 左上角
o.uv[2] = uv + _MainTex_TexelSize.xy * half2(-1, -1) * _SampleDistance; // 右下角
o.uv[3] = uv + _MainTex_TexelSize.xy * half2(-1, 1) * _SampleDistance; // 右上角
o.uv[4] = uv + _MainTex_TexelSize.xy * half2(1, -1) * _SampleDistance; // 左下角

return o;
}

// 用于比较两个点的深度和法线纹理中的采样得到的消息,用来判断是否是边缘
// 返回值:1 - 法线和深度基本相同,处于同一平面上,0 - 差异大,不在一个平面上
half CheckSame(half4 depthNormal1, half4 depthNormal2)
{
// 法线消息在这个场景下不需要解码就可以对比了
float depth1 = DecodeFloatRG(depthNormal1.zw);
float2 normal1 = depthNormal1.xy;
float depth2 = DecodeFloatRG(depthNormal2.zw);
float2 normal2 = depthNormal2.xy;

// 法线的差异计算,计算两点法线xy的差值,并乘以自定义的敏感度,如果差异不大,返回1,反之为0
float2 normalDiff = abs(normal1 - normal2) * _SensitivityNormal;
int isSameNormal = (normalDiff.x + normalDiff.y) < 0.1;

// 深度的差异计算,判断两点深度是否接近,并乘以自定义的敏感度,如果差异不大,返回1,反之为0
float depthDiff = abs(depth1 - depth2) * _SensitivityDepth;
int isSameDepth = depthDiff < 0.1 * depth1;

return isSameNormal * isSameDepth ? 1 : 0;
}

fixed4 frag (v2f i) : SV_Target
{
// 获取四个点的深度和法线消息
half4 topLeft = tex2D(_CameraDepthNormalsTexture, i.uv[1]);
half4 bottomRight = tex2D(_CameraDepthNormalsTexture, i.uv[2]);
half4 topRight = tex2D(_CameraDepthNormalsTexture, i.uv[3]);
half4 bottomLeft = tex2D(_CameraDepthNormalsTexture, i.uv[4]);

// 根据深度+法线消息,去判断是否是边缘
half edgeLerpValue = 1;
edgeLerpValue *= CheckSame(topLeft, bottomRight);
edgeLerpValue *= CheckSame(topRight, bottomLeft);

// 通过插值进行颜色变化
fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[0]), edgeLerpValue);
fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edgeLerpValue);

// 通过_EdgeOnly决定屏幕显示纯色还是原背景颜色
return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
}
ENDCG
}
}
}

实现 利用深度+法线纹理实现边缘检测屏幕后期处理效果 对应 C# 脚本

  1. 新建 C# 脚本,取名和 Shader 相同

  2. 继承 PostEffect

    具体代码详见:US3S10L4——屏幕后处理基类

  3. 声明可控变量

  4. Start​ 函数中 开启深度+法线纹理

    注意:不要直接 =​,要 |=​,避免关闭深度纹理

  5. 重写 UpdateProperty​ 函数,在其中设置属性即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
using UnityEngine;

public class EdgeDetectionWithDepthNormalsTexture : PostEffect
{
[Range(0, 1)] public float edgeOnly = 0; // 控制自定义背景颜色显示程度的属性
public Color edgeColor = Color.black; // 边缘的描边颜色
public Color backgroundColor = Color.white; // 自定义背景颜色
public float sampleDistance = 1f; // 采样偏移程度,用于控制描边的粗细,值越大越粗
public float sensitivityDepth = 1f; // 深度变化敏感度
public float sensitivityNormal = 1f; // 法线变化敏感度

void Start()
{
// 使用 |= 避免关闭深度纹理,影响其他屏幕后处理效果
Camera.main.depthTextureMode |= DepthTextureMode.DepthNormals;
}

protected override void UpdateProperty()
{
if (PostEffectMaterial == null)
return;

PostEffectMaterial.SetFloat("_EdgeOnly", edgeOnly);
PostEffectMaterial.SetColor("_EdgeColor", edgeColor);
PostEffectMaterial.SetColor("_BackgroundColor", backgroundColor);
PostEffectMaterial.SetFloat("_SampleDistance", sampleDistance);
PostEffectMaterial.SetFloat("_SensitivityDepth", sensitivityDepth);
PostEffectMaterial.SetFloat("_SensitivityNormal", sensitivityNormal);
}
}

显示效果

image

可见,基于深度+法线纹理的边缘检测能够更好的根据物体的轮廓来描边