前面学习的屏幕后处理技术都只是在屏幕颜色图像上进行各种操作,但当希望得到深度和法线信息时,就无能为力;边缘检测 ,另外一种更好的办法就是利用深度和法线信息可以准确的得到边缘信息。
一.获取深度和法线纹理的原理
在 Unity 里获取深度和法线纹理的代码非常简单, 但是我们有必要在这之前首先了解它。
1.1.原理
深度纹理实际上就是一张渲染纹理,深度值范围在【0,1】,而且是非线性分布的。 这些深度值来自顶点变换后得到的归一化设备坐标(Normalized Device Coordinates NDC)。回顾这个过程:一个模型要想最终被绘制在屏幕上,需要把顶点从模型空间变换到其次裁剪坐标系。在顶点着色器中乘以MVP变换矩阵得到的。在变换的最后一步,我们需要使用一个投影矩阵来变换顶点。当我们使用的是透视投影类型的摄像机时,这个投影矩阵就是非线性的。
下图显示了之前给出的Unity中透视投影对顶点的变换过程,下图最左侧的图显示了投影变换前,即观察空间下视锥体的结果以及相应的顶点位置,中间的图显示了应用透视裁剪矩阵后的变换结果,即顶点着色器阶段输出的顶点变换结果,最右侧的图则是底层硬件进行了透视除法后得到的归一化的设备坐标。需要注意的是,这里的投影过程是建立在Unity对坐标系的假定上的,也就是说,我们针对的是观察空间为右手坐标系,使用列矩阵在矩阵右侧进行相乘,且变换到NDC后z分量范围将在[-1,1]之间的情况。而类似DirectX 这样的图形接口中,变换后z分量范围将在[0,1]之间。
在得到NDC后,深度纹理中的像素值就可以很方便的计算得到了,深度值对应了NDC中的顶点坐标Z分量的值。由于其值是在【-1,1】之间,需要一个公式对其映射,原来就用过类似的。乘以一半在加0.5。
其中, d 对应了深度纹理中的像素值, Zndc 对应了NDC 坐标中的z 分量的值。
那么Unity 是怎么得到这样一张深度纹理的呢?
在Unity 中,深度纹理可以直接来自于真正的深度缓存,也可以是由一个单独的Pass 渲染而得,这取决于使用的渲染路径和硬件。通常来讲,当使用延迟渲染路径(包括遗留的延迟渲染路径)时,深度纹理理所当然可以访问到,因为延迟渲染会把这些信息渲染到G-buffer 中。而当无法直接获取深度缓存时,深度和法线纹理是通过一个单独的Pass 渲染而得的。具体实现是, Unity 会使用着色器替换( Shader Replacement )技术选择那些渲染类型〈即SubShader 的RenderType 标签)为Opaque 的物体,判断它们使用的渲染队列是否小于等于2500 (内置的Background 、Geometry 和AlphaTest 渲染队列均在此范围内),如果满足条件,就把它渲染到深度和法线纹理中。因此,要想让物体能够出现在深度和法线纹理中,就必须在Shader 中设置正确的RenderType 标签。
在Unity 中,我们可以选择让一个摄像机生成一张深度纹理或是一张深度+法线纹理。当选择前者,即只需要一张单独的深度纹理时, Unity 会直接获取深度缓存或是按之前讲到的着色器替换技术,选取需要的不透明物体,并使用它投射阴影时使用的Pass (即LightMode 被设置为ShadowCaster 的Pass,详见9.4 节)来得到深度纹理。如果Shader 中不包含这样一个Pass,那么这个物体就不会出现在深度纹理中(当然,它也不能向其他物体投射阴影)。深度纹理的精度通常是24 位或16 位,这取决于使用的深度缓存的精度。
如果选择生成一张深度+法线纹理, Unity 会创建一张和屏幕分辨率相同、精度为32 位〈每个通道为8 位)的纹理,其中观察空间下的法线信息会被编码进纹理的R 和G 通道,而深度信息会被编码进B 和A 通道。法线信息的获取在延迟渲染中是可以非常容易就得到的, Unity 只需要合并深度和法线缓存即可。而在前向渲染中,默认情况下是不会创建法线缓存的,因此Unity 底层使用了一个单独的Pass 把整个场景再次渲染一遍来完成。这个Pass 被包含在Unity 内置的一个Unity Shader 中,我们可以在内置的builtin_shaders-xxx/DefaultResources/Camera-DepthNormalTexture.shader 文件中找到这个用于渲染深度和法线信息的Pass。
这样看实时获得法线纹理还是一个很耗时的操作。
1.2.如何获取
Unity中,获取深度纹理 在脚本中设置摄像机的depthTextureMode来完成的
camera.depthTextureMode = DepthTextureMode.Depth;
一旦设置好了上面的摄像机模式后,我们就可以在Shader 中通过声明 _CameraDepthTexture变量来访问它。这个过程非常简单,但我们需要知道这两行代码的背后, Unity 为我们做了许多工作 上一节说明了。
同样的 要想获得深度+法线纹理,脚本中设置
camera.depthTextureMode = DepthTextureMode.DepthNormals;
然后在Shader 中通过声明 _CameraDepthNormalsTexture 变量来访问它。
我们还可以组合这些模式,让一个摄像机同时产生一张深度和深度+法线纹理:
camera.depthTextureMode |= DepthTextureMode.Depth;
camera.depthTextureMode |= DepthTextureMode.DepthNormals;
在Unity5中,我们还可以在摄像机的Camera组件上看到当前摄像机是否需要渲染深度或深度+法线纹理。当在Shader中访问到深度纹理_CameraDepthTexture 后,我们就可以使用当前像素的纹理坐标对它进行采样。绝大多数情况下,我们直接使用tex2D函数采样即可,但在某些平台上,我们需要一些特殊处理。Unity为我们提供了一个统一的宏SAMPLE_DEPTH_TEXTURE,用来处理这些由于平台差异造成的问题。而我们只需要在Shader中使用SAMPLE_DEPTH_TEXTURE宏对深度纹理进行采样,例如:
float d = SAMPLE_DEPTH_TEXTURE(_CarneraDepthTexture, i.uv);
其中, i.uv 是一个float2 类型的变量,对应了当前像素的纹理坐标。类似的宏还有SAMPLE_DEPTH_TEXTURE_PROJ 和
SAMPLE_DEPTH_TEXTURE_LOD、 SAMPLE_DEPTH_TEXTURE_PROJ 宏同样接受两个参数一一深度纹理和一个float3 或float4 类型的纹理坐标,它的内部使用了tex2Dproj 这样的函数进行投影纹理采样,纹理坐标的前两个分量首先会除以最后一个分量,再进行纹理采样。如果提供了第四个分量,还会进行一次比较,通常用于阴影的实现中。SAMPLE_DEPTH_TEXTURE PROJ 的第二个参数通常是由顶点着色器输出插值而得的屏幕坐标,例如:
float d = SAMPLE_DEPTH_TEXTURE_PROJ(_CarneraDepthTexture,UNITY_PROJ_COORD(i.scrPos));
其中, i.scrPos 是在顶点着色器中通过调用ComputeScreenPos(o.pos)得到的屏幕坐标。
当通过纹理采样得到深度值后,这些深度值往往是非线性的,这种非线性来自于透视投影使用的裁剪矩阵。然而,在我们的计算过程中通常是需要线性的深度值,也就是说,我们需要把投影后的深度值变换到线性空间下,例如视角空间下的深度值。那么,我们应该如何进行这个转换呢?实际上,我们只需要倒推顶点变换的过程即可。
Unity提供了两个辅助函数来为我们进行上述的计算过程——LinearEyeDepth 和 Linear01Depth。LinearEyeDepth 负责把深度纹理的采样结果转换到视角空间下的深度值,也 就是我们上面得到的Z(visw)。而 Linear01Depth 则会返回一个范围在[0, 1]的线性深度值,也就是我们上面得到的Z(01),这两个函数内部使用了内置的_ZBufferParams变量来得到远近裁剪平面的距离。
如果我们需要获取深度+法线纹理,可以直接使用tex2D函数对_CameraDepthNormalsTexture 进行采样,得到里面存储的深度和法线信息。Unity提供了辅助函数来为我们队这个采样结果进行解码,从而得到深度值和法线方向。这个函数是DecodeDepthNormal,它在UnityCG.cginc里被定义:
inline void DecodeDepthNormal(float4 enc, out float depth,out float3 normal){
depth = DecodeFloatRG(enc.zw);
normal = DecodeViewNormalStereo(enc);
}
DecodeDepthNormal 的第一个参数是对深度+法线纹理的采样结果,这个采样结果是Unity对深度和法线信息编码后的结果,它的xy分量存储的是视角空间下的法线信息,而深度信息被编码进了zw分量。通过调用DecodeDepthNormal 函数对采样结果解码后,我们就可以得到解码后的深度值和法线。这个深度值是范围在[0, 1]的线性深度值(这与单独的深度纹理中存储的深度值不同),而得到的法线则是视角空间下的法线方向。同样,我们也可以通过调用DecodeFloatRG 和 DecodeViewNormalStereo来解码深度+法线纹理中的深度和法线信息。
1.2.查看深度和法线纹理
利用Frame Debugger 可以查看到深度纹理和深度+法线纹理。下图显示了帧调试器查看到的深度纹理和深度+法线纹理。
使用帧调试器查看到的深度纹理是非线性空间的深度值,而深度+法线纹理都是由Unity编码后的结果。有时,显示出线性空间下的深度信息或解码后的法线方向会更加有用。此时,我们可以自行在片元着色器中输出转换或解码后的深度和法线值,如下图所示。
输出代码非常简单,我们可以使用类似下面的代码来输出线性深度值:
float depth = SMAPLE_DEPTH_TEXTURE(_CameraDepthTexture,i.uv);
float linearDepth = Linear01Depth(depth);
return fixed4(linearDepth,linearDepth,linearDepth,1.0);
或是输出法线方向:
fixed3 normal = DecodeViewNormalStereo(tex2D(_CameraDepthNormalsTexture, i.uv).xy);
return fixed4(normal * 0.5 + 0.5, 1.0);
在查看深度纹理时,我们得到的画面可能几乎是全黑或全白的。这时我们可以把摄像机的远裁剪平面的距离(Unity默认为1000)调小,使视锥体的范围刚好覆盖场景的所在区域。这是因为,由于投影变换时需要覆盖从近裁剪平面到远裁剪平面的所有深度区域,当远裁剪平面的距离过大时,会导致离摄像机较近的距离被映射到非常小的深度值,如果场景是一个封闭的区域,那么这就会导致画面看起来几乎是全黑的。相反,如果场景是一个开放区域,且物体离摄像机的距离较远,就会导致画面几乎是全白的。
二.再谈边缘检测
之前我们曾介绍如何使用Sobel算子对屏幕图像进行边缘检测,实现描边的效果。但是,这种直接利用颜色信息进行边缘检测的方法会产生很多我们不希望得到的边缘线,如下图所示。我们使用Robert算子来进行边缘检测。它使用的卷积核如下图所示。
构建一个包含3面墙的房间,放置两个立方体和两个球体。
摄像机上添加EdgeDetectNormalsAndDepth.cs 脚本
pulic class EdgeDetectNormalsAndDepth: PostEffectsBase{
public Shader edgeDetectShader;
private Material edgeDetectMaterial = null;
public Material material{
get{
edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial);
return edgeDetechMaterial;
}
}
[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;
}//需要获取摄像机的深度 +法线纹理
[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);
}
}
}
这里我们为OnRenderlmage 函数添加了[ImageEffectOpaque]属性。我们曾在12.1节中提到过该属性的含义。在默认情况下,OnRenderlmage 函数会在所有的不透明和透明的Pass 执行完毕后被调用,以便对场最中所有游戏对象都产生影响。但有时,我们希望在不透明的Pass (即渲染队列小于等于2 500 的Pass,内置的Background、Geometry 和AlphaTest 渲染队列均在此范围内)执行完毕后立即调用该函数,而不对透明物体(渲染队列为Transparent 的Pass )产生影响,此时,我们可以在OnRenderlmage 函数前添加ImageEffectOpaque 属性来实现这样的目的。在本例中,我们只希望对不透明物体迸行描边,而不希望透明物体也被描边, 因此需要添加该属性。
Shader 代码
Shader "Unlit/Chapter13-MyEdgeDetectNormalAndDepth"
{
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 ("Sensitivity", Vector) = (1, 1, 1, 1)
// _SenSitivity 的 xy 分量分别对应了法线和深度的检测灵敏度, zw 分量则没有实际用途。
}
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
//存储 Roberts 算子
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;
}
//CheckSame 函数来分别计算对角线上两个纹理值的差值 返回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);
// difference in normals
// do not bother decoding normals - there's no need here
half2 diffNormal = abs(centerNormal - sampleNormal) * _Sensitivity.x;
int isSameNormal = (diffNormal.x + diffNormal.y) < 0.1;
// difference in depth
float diffDepth = abs(centerDepth - sampleDepth) * _Sensitivity.y;
// scale the required threshold by the distance
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);
}
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函数将该像素剔除掉,从而显示原来的物体颜色。