13.4 再谈边缘检测
在12.3中,我们曾使用Sobel算子对屏幕图像进行边缘测试,实现描边的效果。但是,这种直接利用颜色信息进行边缘检测的方法会产生很对我们不希望得到的边缘线,如图13.8所示。
可以看出,物体的纹理、阴影等位置也被描上黑边,而这往往不是我们希望看到的。在本节中,我们将学习如何在深度和法线上进行边缘检测,这些图像不会受纹理和光照的影响,而仅仅保存了当前渲染物体的模型信息,通过这样的方式检测出来的边缘更加可靠。最终实现效果如下图(13.9)的效果。
本节将使用Roberts(罗伯茨)算子来进行边缘检测。
Roberts算子的本质就是计算左上角和右下角的插值,乘以右上角和左下角的插值 ,作为评估边缘的依据。在下面的实现中,我们也会按这样的方式,取对角方向的深度或法线值,比较他们之间的差值,如果超过某个阀值(可由参数控制),就认为他们之间存在一条边。
实现
(1)新建场景(Scene_13_4)。
(2)搭建一个测试边缘检测的场景,同时,将Translating.cs脚本拖拽给摄像机。
(3)新建脚本(EdgeDetectNormalsAndDepth.cs)。并拖拽到摄像机上。
(4)新建Unity Shader (Chapter13-EdgeDetectNormalsAndDepth)
using UnityEngine;
using System.Collections;
public class EdgeDetectNormalsAndDepth : PostEffectsBase {
public Shader edgeDetectShader;
private Material edgeDetectMaterial = null;
public Material material {
get {
edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial);
return edgeDetectMaterial;
}
}
[Range(0.0f, 1.0f)]
//调整边缘线强度描边颜色以及背景颜色的参数。同时添加了控制采样距离以及对深度和法线进行边缘检测时的灵敏度参数
public float edgesOnly = 0.0f;
//
public Color edgeColor = Color.black;
public Color backgroundColor = Color.white;
//用于控制对深度+法线纹理采样时,使用的采样距离。数值越大描边越宽。
public float sampleDistance = 1.0f;
//当领域的深度值相差多少时,会被认为存在一条边界。
public float sensitivityDepth = 1.0f;
public float sensitivityNormals = 1.0f;
//设置摄像机的相应状态:
void OnEnable() {
GetComponent().depthTextureMode |= DepthTextureMode.DepthNormals;
}
//这个属性的任何图像效果都将在不透明的几何图形之后呈现出来在透明的几何。
//在默认情况下,OnRenderImage函数会在所有的不透明和透明的Pass执行完毕后被调用,
//以便对场景中所有游戏对象都产生影响。但有时,我们希望在不透明的Pass(即渲染队列小于等于1500的Pass
//,内置的Background、Geometry和AlphaTest渲染队列均在此范围内)执行完毕后立即调用该函数,
//而不对透明物体(渲染队列为Transparent的Pass)产生影响,此时,我们可以在OnRenderImage函数前添加
//ImageEffectOpaque属性来实现这样的目的。在本例中,我们只希望对不透明物体进行描边,而不希望透明
//物体也被描边,因此需要添加该属性。
[ImageEffectOpaque]
void OnRenderImage (RenderTexture src, RenderTexture dest) {
if (material != null) {
material.SetFloat("_EdgeOnly", edgesOnly);
material.SetColor("_EdgeColor", edgeColor);
material.SetColor("_BackgroundColor", backgroundColor);
material.SetFloat("_SampleDistance", sampleDistance);
material.SetVector("_Sensitivity", new Vector4(sensitivityNormals, sensitivityDepth, 0.0f, 0.0f));
Graphics.Blit(src, dest, material);
} else {
Graphics.Blit(src, dest);
}
}
}
Shader "Unity Shaders Book/Chapter 13/Edge Detection Normals And Depth" {
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_EdgeOnly ("Edge Only", Float) = 1.0
_EdgeColor ("Edge Color", Color) = (0, 0, 0, 1)
_BackgroundColor ("Background Color", Color) = (1, 1, 1, 1)
_SampleDistance ("Sample Distance", Float) = 1.0
//_Sensitivity的xy分量分别对应了法线和深度的检测灵敏度,zw分量则没有实际用途。
_Sensitivity ("Sensitivity", Vector) = (1, 1, 1, 1)
}
SubShader {
CGINCLUDE
#include "UnityCG.cginc"
sampler2D _MainTex;
//存储纹素大小的变量
half4 _MainTex_TexelSize;
fixed _EdgeOnly;
fixed4 _EdgeColor;
fixed4 _BackgroundColor;
float _SampleDistance;
half4 _Sensitivity;
//需要获取的深度+法线纹理
sampler2D _CameraDepthNormalsTexture;
//定义顶点着色器:
struct v2f {
float4 pos : SV_POSITION;
half2 uv[5]: TEXCOORD0;
};
v2f vert(appdata_img v) {
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
half2 uv = v.texcoord;
o.uv[0] = uv;
#if UNITY_UV_STARTS_AT_TOP
if (_MainTex_TexelSize.y < 0)
uv.y = 1 - uv.y;
#endif
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;
}
//在v2f结构体中定义了一个维数为5的纹理坐标数组。这个数组的第一个坐标存储了屏幕颜色图像的采样纹理。
//我们对深度纹理的采样坐标进行了平台差异化处理,在必要情况下对它的竖直方向进行了翻转。
//数组中剩余的4个坐标则存储了使用Roberts算子时需要采样的纹理坐标,我们还使用了
//_SampleDistance来控制采样距离。通过把计算采样纹理坐标的代码从片元着色器中转移到顶点
//着色器中,可以减少运算,提高性能。由于从顶点着色器到片元着色器到片元着色器的
//插值是线性的,因此这样的转移并不会影响纹理坐标的计算结果。
//首先对输入参数进行处理,得到两个采样点的法线和深度值。值的注意的是这里我们
//并没有解码得到真正的法线值,而是直接使用了xy分量。这是因为我们只需要比较两个
//采样值之间的差异度,而并不需要知道他们真正的法线值。然后,我们把两个采样点的对应值相减并
//取绝对值,在乘以灵敏度参数,把差异值的每个分量相加再和一个阀值比较,如果他们的和小于
//阀值,则返回1,说明差异度不明显,不存在一条边界;否则返回0.最后把法线和深度的检查结果
//相乘,作为组合后的返回值。
half CheckSame(half4 center, half4 sample) {
half2 centerNormal = center.xy;
float centerDepth = DecodeFloatRG(center.zw);
half2 sampleNormal = sample.xy;
float sampleDepth = DecodeFloatRG(sample.zw);
// 法线上的差异
// 不用麻烦解码法线-这里没有必要
half2 diffNormal = abs(centerNormal - sampleNormal) * _Sensitivity.x;
int isSameNormal = (diffNormal.x + diffNormal.y) < 0.1;
// 不同的深度
float diffDepth = abs(centerDepth - sampleDepth) * _Sensitivity.y;
// 按距离对所需阈值进行缩放
int isSameDepth = diffDepth < 0.1 * centerDepth;
// return:
// 1 - if normals and depth are similar enough
// 0 - otherwise
return isSameNormal * isSameDepth ? 1.0 : 0.0;
}
fixed4 fragRobertsCrossDepthAndNormal(v2f i) : SV_Target {
half4 sample1 = tex2D(_CameraDepthNormalsTexture, i.uv[1]);
half4 sample2 = tex2D(_CameraDepthNormalsTexture, i.uv[2]);
half4 sample3 = tex2D(_CameraDepthNormalsTexture, i.uv[3]);
half4 sample4 = tex2D(_CameraDepthNormalsTexture, i.uv[4]);
half edge = 1.0;
edge *= CheckSame(sample1, sample2);
edge *= CheckSame(sample3, sample4);
fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[0]), edge);
fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edge);
return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
}
//我们首先使用4个纹理坐标对深度+法线纹理进行采样,再调用CheckSame函数来分别计算对
//角线上两个纹理值的差值。CheckSame函数的返回值要么是0,要么是1,返回0时表明这两点
//之间存在一条边界,反之则返回1。
ENDCG
Pass {
ZTest Always Cull Off ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment fragRobertsCrossDepthAndNormal
ENDCG
}
}
FallBack Off
}
实现的描边效果是基于整个屏幕空间进行的,也就是说,场景内的所有物体都会被添加描边效果。
如果想要对单一物体添加描边效果。这时,我们可以使用Unity提供的Graphics.DrawMesh或Graphics.DrawMeshNow 函数把需要描边的物体再次渲染一遍,然后再使用边缘检测算法计算深度或法线纹理中每个像素的梯度值,判断它们是否小于某个阀值,如果是,就在Shader中使用clip()函数将该像素剔除掉,从而显示出原来的物体颜色。
13.5 扩展阅读
实际上我们可以在Unity中创建任何需要的缓存纹理。这可以通过使用Unity的着色器替换(Shader Replacement)功能(即调用Camera.renderWithShadr(shadr,replacementTag)函数)把整个场景再次渲染一遍来得到,而在很对时候,这实际也是Unity创建深度好法线纹理是使用的方法。
Unity曾在2011年的SIGGRAPH(计算图形学的顶级会议)上做了一个关于使用深度纹理实现各种特效的演讲(http://blogs.unity3d.com/2011/09/08/special-effects-with-depth-talk-at-siggraph/),演讲中,解释了利用深度纹理来实现特定物体的描边、角色护盾、相交线的高光模拟等效果。在Unity的Image Effect(http://docs.unity3d.com/Manual/comp-ImageEffects.html)包中,也能找到一些传统的使用深度纹理实现屏幕特效的例子,例如屏幕空间的环境遮挡(SSAO)等效果。