关于 Sahder 中的深度图,其应用方式有很多,最常见的例如 科幻游戏中扫描线、水的 Shader、透明物体相交、屏幕后期处理的描边等。
主要就是因为在能够获得场景深度值之后,可以有许多操作空间。

# 前言

这篇文章,其实在之前整理『Unity3DSahder 关键字』及『Sahder 内置函数』的时候就在想写一下了。
不过在那之后,有几个原因导致一直没有动手:

  • 感觉只是个小东西,复杂之处在于用它来实现的各种后期效果;
  • 虽然知道原理,但自己动手搞了搞之后,感觉理解起来有点困惑,这样就更不好说写什么了;
  • 网上相关文章也挺多;
    另外也有点其他事情耽搁了。

不过后面考虑了下,不写写加深理解,总结实践下,那后面再遗忘可就真没收获了。
因此在这里,我会简单总结一下深度图分别在屏幕后期处理及单个物体 Shader 中的用法及作用。

# 获取深度图

在获取深度值方面,主要有两种方式:

  1. 通过获取整个屏幕深度图
  2. 在单个物体特殊应用时,获取自身深度值

在 Unity 中,引擎是直接提供了屏幕深度图的,使用方式也很简单:在 Shader 中声明 sampler2D _CameraDepthTexture; 变量即可,运行时 Unity 会自动为其赋值。
然后通过纹理采样函数,即可获取到指定 屏幕坐标 的深度值信息。
注意采样到的深度值,并非线性 [0,1] 深度值,可使用 Unity 提供的函数 Linear01Depth 转化。

注:仅 “不透明” 对象(这些对象的材质和着色器设置为使用小于等于 2500 的渲染队列)会自动被渲染到深度纹理中

另外在这里还需要注意两点:屏幕后期处理 Shader 与物体自身 Sahder 采样的差异。

# 屏幕后处理对深度图采样

屏幕后期处理 Sahder 采样方式比较简单,直接将 i.uv 传入采样函数,返回的即是屏幕对应位置的深度值。

片元处理函数:

float depth = Linear01Depth(tex2D(_CameraDepthTexture, i.uv).x);

# 物体对屏幕深度值采样

不过若使用者是某个物体的 Sahder,那么就不能直接通过 uv 去采用深度了;这也是理所当然,物体 uv 跟屏幕坐标可没有半分关系。
因此需要计算出顶点所在的屏幕空间坐标,然后将屏幕空间坐标当做 uv 对深度图进行采样。

Unity 同样提供了对应函数 ComputeScreenPos(x) ,其中 x 是投影空间坐标。
在顶点处理函数传入片元数据结构体中,定义一个四维变量,调用该方法即可获取屏幕坐标。

 o.screenPos=ComputeScreenPos(o.vertex);

然后在片元处理函数中,进行纹理采样。
注意此处是使用 tex2Dproj 采样。

float sceneZ = tex2Dproj(_CameraDepthTexture,i.screenPos).x;

或者使用 tex2D,手动除以 w 分量亦可:
(注:原因是 uv 采样值范围是 [0,1],因此计算出来的屏幕坐标需除以 w 分量映射至 [0,1] 区间)

float sceneZ = tex2D(_CameraDepthTexture,i.screenPos.xy/i.screenPos.w).x;

直接将采样的深度值作为颜色输出效果:

物体采样(左)后处理屏幕采样(右)
(物体采样 (左) 后处理屏幕采样 (右))

# 屏幕后处理对深度值的应用

关于屏幕后处理对深度图的应用,最简单的,大约要数 “扫描线” 这种了。
单纯地对比当前深度值及距离,就可以做出一个效果,网络上大多数关于深度图的文章,似乎也是第一个就在讲这个。

因此,秉着从简单开始原则,我也来一份。

首先,为了简单起见,这里就不使用其它什么额外效果,直接定义两个所需的基础变量:

  • _Depth 作为当前深度距离
  • _Width 作为扫描线的宽度
_Width("Width",Float) = 0.1
_Depth("Depth",Range(0,1)) = 0.1

然后,在片元处理函数中,分别采样屏幕原图和深度图。
这时候就可以通过 _Depth 参数 和深度图中的数值进行对比,计算结果。

判断标准是什么?

  • 深度值小于 _Depth
  • 深度值大于 _Depth-_Width 形成宽度
    在这个区间内,可以形成一条宽度 _Width 的横线

如下代码所示:

//采样屏幕原图
float4 col = tex2D(_MainTex, i.uv);
//采样深度图
float depth = Linear01Depth(tex2D(_CameraDepthTexture, i.uv).x);
float distance = _Depth;
//判断深度值是否处于当前设置的区间内
if (depth < distance && depth> distance - _Width) {
	return float4(1, 0, 0, 1)+col;
}
return col;

最基础的效果
(最基础的效果)

在这块代码中,判断当深度值处于当前设置区间内,则直接将红色与当前颜色融合,否则返回当前颜色。很简单的一个逻辑。

如果觉得这条横线的边太硬,想稍微加点效果的话,还可以根据当前颜色深度所处宽度比例,作边缘柔化处理:

float diff = 1 - (distance - depth) / _Width;
return float4(1, 0, 0, 1)*diff+col;

由于上面是深度位于 distance - _Width 区间才会进来,所以 distance - depth 取值范围为 [0,_Width]。
因此 distance - depth 便是当前所处区间的位置,除以宽度,得出当前所处位置的比例。根据比例逐步提高线条颜色值,可以形成一个简易的柔化效果。

加柔边效果
(加柔边效果)

屏幕后期处理中,上述只是最简单和基础的一个应用,深度图的作用还有做屏幕描边啊、模糊,做雾效等等,在此就不多说了。

# 物体 Shader 对深度值的应用

在物体 Sahder 中,如果需要获取深度图,那么一般来说,自身深度都是不写入深度图的,ZWrite 通常为 ZWrite Off

因此主要有两个操作:

  • 计算自身深度值
  • 采样自身位置原本深度值

然后通过两者相差,进行深度比较执行其它逻辑。
最常见的例如水的 Shader,在靠近岸边的时候波浪、渐变效果,物体相交检测等。应用方面,这里主要就说一下物体相交检测的效果。

为了计算自身深度,可以直接使用 Unity 提供函数 COMPUTE_EYEDEPTH (x),需要在顶点处理函数中调用,返回当前顶点深度值。

为了采样自身原本位置深度值,需要计算出当前顶点对应屏幕坐标,然后对深度图进行采样。

v2f:
	float4 screenPos: TEXCOORD3;

vert:
	o.screenPos = ComputeScreenPos(o.vertex);
	COMPUTE_EYEDEPTH(o.screenPos.z);

由于计算出来的屏幕坐标,z 值实际上是没用的 (投影纹理查询只用到 xy/w),因此可以直接将自己的深度值存放至 screenPos 的 z 值中,传入片元处理函数。

来到片元处理函数后,采样深度图中的深度值,并将其与物体当前深度值进行对比,直接输出,可得到如下效果:

效果

代码如下:

//场景深度
float sceneZ = LinearEyeDepth(tex2Dproj(_CameraDepthTexture,i.screenPos).x);
//自身深度
float selfZ = (i.screenPos.z);
//取得差值
float diff =  (sceneZ-selfZ );
//输出差值
return float4(diff, diff, diff,1);

我们需要实现一个 相交描边 的效果,那么可以定义一个描边强度值 _OutLineValue("RimValue", Range(0,1)) = 0.1
大于该值输出描边颜色,否则可以输出黑色。
将直接返回深度差值修改为:

return float4(step(diff, _OutLineValue) , 0, 0, 0.5);

step 为 shder 内置函数,step (diff, _OutLineValue) 表示,当 diff 小于_OutLineValue 时,返回 1,diff 大于_OutLineValue 时,返回 0。
直接当做 R 通道颜色放进去,可以方便低实现:深度值低于_OutLineValue 显示红色描边 的需求。

效果

如此,最简单的一个 相交描边 效果就实现了,代码很简单,所以效果也很简陋。

为了优化点显示的话,可以加一点其它效果:

//场景深度
float sceneZ = LinearEyeDepth(tex2Dproj(_CameraDepthTexture,i.screenPos).x);
//自身深度
float selfZ = (i.screenPos.z);
//取得差值
float diff =  (sceneZ-selfZ);
//计算相交的描边强度
float outLineStrenth =1- saturate(diff / _OutLineValue);
//计算视线夹角,实现轮廓高亮
float rimStrenth=1-abs(dot(i.normal, i.viewDir))*_RimValue;
return float4(0,0,0,0.5)+float4(1,0,0,0)*max(outLineStrenth, rimStrenth);

因为这里的物体是透明且双面显示,因此需要处理法线与视线为负的情况,这里直接将点乘结果转化为绝对值的正数。
最终返回值为半透明黑色叠加相交描边或轮廓颜色:在这里取两者最大值,可以保证相交和轮廓因为视角原因叠在一块的时候,可以优先展示程度更深的一方,效果更好。

另外外边缘轮廓还可以直接使用菲涅尔效果展示 (比上面单纯视线和法线夹角好用,当然计算也稍微多一点)。

一般游戏中,使用的简化版菲涅尔公式为:

fresnel=(1(NV))5强度fresnel=(1-(N \cdot V))^5*强度

代码可修改为:

float rim=pow(1-abs(dot(i.normal, i.viewDir)), 5)*_RimValue;

当然,其中 5 这个定量也可以修改为变量,已进行更细致的效果调整。

效果 直接轮廓高亮(左) 菲涅尔(右)
(效果 直接轮廓高亮 (左) 菲涅尔 (右))

# 总结

以上便是深度图最简答的两个不同应用。
这次也是一边实践一边 Bing 一边写,花了两天多时间,总算收尾了。
怎么说呢,这一趟下来,还是有收获的。之前虽然知道大概怎么做,但实际理解起来还是有点糊的,因为自己不理解的也不敢直接写,所以从易到难一遍想一边慢慢再实现一遍,至少很多之前不太理解的地方也算想通了。

后边除非许久不再研究这一块,就算忘了,看看自己写的文章,应该也能很快拾起来吧。