找回密码
 立即注册
查看: 530|回复: 11

【Unity】深度图(Depth Texture)的简单介绍

[复制链接]
发表于 2023-3-26 14:11 | 显示全部楼层 |阅读模式
前言

在用Unity整Hi-Z剔除的时候由于需要处理深度图,作为小萌新,整着整着发现单单深度图相关的知识就可以水一篇文章,那就愉快的水起来吧。
首先我们先随便摆一个测试场景,如下图:



查看深度图

学过图形学我们知道,在渲染管线的光栅化阶段,GPU会计算不透明物体的Mesh在对应像素上的深度(取值范围为0-1的浮点数),用它来判断像素上应该显示哪个三角形对应的数据。可参考:
那么我们能不能查看这个深度图呢?Unity提供的Frame Debugger可以帮助我们根据draw call查看每帧的渲染过程,我们点击UpdateDepthTexture项就可以查看到当前帧的深度图了,如下:



查看深度图

展开UpdateDepthTexture,我们还可以看到每个mesh的渲染顺序:



深度图写入流程

并且在RenderForwardOpaque.CollectShadows中我们可以看到深度图的应用:



RenderForwardOpaque.CollectShadows

可以发现深度图并不是在物体绘制到Render Target后(即:RenderForward.RenderLoopJob)通过深度测试对应生成的,而是会先利用一个专门的Render Pass(即:UpdateDepthTexture)来事先根据要渲染的物体生成深度图。
由于深度值只是一个浮点数,因此我们深度图的每个像素就不需要存储rgba四个浮点数了,只需要r通道即可,因此我们看见的深度图是红黑色的,这样可以减少的深度图所占用的内存空间。
从上面的深度图中我们可以看出当物体离camera越近,深度图上对应的区域越红(深度值接近1),越远则越黑(深度值接近0)。也就是说深度值从0到1代表的是从远到近
很多人点UpdateDepthTexture的时候,可能会发现看见的深度图全是黑的(如下图)。这是为什么呢,难道场景中所有物体的深度都是0,都靠近far clip plane?



深度图基本全黑

此外上面的深度图是在我们platform选择Window(DirectX 11)或iOS(Metal)时看见的结果(近红远黑)。若选择Android(OpenGL ES3)看到的深度图会是下面这么一番景象(近黑远红):



Android平台下

从Android平台下的深度图来看,深度值从0到1代表的是从近到远。
这些种种的问题或不同是为什么?我们从深度的计算入手。

深度计算

我们知道物体要显示在屏幕上要进过MVP变换到裁剪空间(Clip Space),在裁剪空间做完裁剪后,再做一次透视除法变成标准化设备坐标(NDC),我们来看看这个过程坐标发生了什么变换。
注:由于OpenGL和DirectX的投影矩阵和NDC范围有所不同,所以分开来说。有关OpenGL和DirectX的投影变换矩阵可参考:
由于我们一开始在Window平台,所以先来看看DirextX的深度计算方式。
假设某个顶点在MV变换后坐标为 (x_v,y_v,z_v) (其中 z_v 的值也被称之为:eye Z value),那么P变换后得到的齐次坐标为:
Clip Space=\begin{bmatrix}x_v&y_v&z_v&1\end{bmatrix}\begin{bmatrix} \frac{2n}{r-l} &0 & 0 & 0 \\ 0 &\frac{2n}{t-b} & 0 &0 \\ 0&0 &\frac{f}{f-n} & 1\\ 0 &0 &\frac{-fn}{f-n}  &0 \end{bmatrix}=\begin{bmatrix}\frac{2nx_v}{r-l} &\frac{2ny_v}{t-b}&\frac{fz_v-fn}{f-n}&z_v\end{bmatrix}
上面得到的齐次坐标即是顶点在齐次裁剪空间下的坐标。
注:DirectX的视图空间是左手坐标系,式子中的 f 代表 far clip plane的z的值,n 代表 near clip plane的z的值, 0<n<z_v<f 。
我们将 z_v=n 和 z_v=f 分别代入z的值中
\left\{\begin{matrix} \frac{fn-fn}{f-n}=0 &&z_v=n\\ \frac{ff-fn}{f-n}=f &&z_v=f \end{matrix}\right.
因此在裁剪空间中,z值的取值范围为(0,f)
裁剪空间的坐标要转成NDC坐标还需要做一次透视除法,即齐次坐标(x,y,z,w)内所有值都除以w。那么上面的齐次坐标做完透视除法后的值即为:
NDC=\begin{bmatrix}\frac{2nx_v}{(r-l)z_v} &\frac{2ny_v}{(t-b)z_v}&\frac{fz_v-fn}{(f-n)z_v}&1\end{bmatrix}
由于要计算的是深度,我们不用管x,y的值,只需要关注NDC中z的值即可:
z_{NDC}=\frac{fz_v-fn}{(f-n)z_v}
将它们分别代入到上面的式子中可得到:
\left\{\begin{matrix} \frac{fn-fn}{(f-n)n}=0 &&z_v=n\\ \frac{ff-fn}{(f-n)f}=1&&z_v=f \end{matrix}\right.  
因此DirectX下 z_{NDC} 的取值范围为0到1,其中0代表物体在near clip plane上,1代表在far clip plane上。而DirectX下的深度值即是 z_{NDC} 的值, depth=z_{NDC} 。
但是不对啊?按照前面深度图的显示,明明是1代表物体在near clip plane上,0代表在far clip plane上,怎么和计算出来的结果正好相反,这是因为Unity为我们做了一次反转的操作,即:
depth=1-z_{NDC}
官方文档说明如下:
在 DirectX 11, DirectX 12, PS4, Xbox One, Metal 这些平台上反转了深度的方向,使得在near clip plane上深度值为1.0,逐渐减小到far clip plane上深度值为0.0。并且在裁剪空间下z的范围也由 (0,far) 变为了 (near,0) 。
此外我们在Unity5.5的版本更新中,也能看见相关的介绍,链接如下:
为什么要进行反转操作呢?我们来看一个例子,假设 n=0.1,f=10,那么不反转的话, z_{NDC} 和 z_v 的关系如下:


从曲线图中看出深度随着距离的增长是一个非线性的增长(有点像log函数),当 0.1<z_v<1 时, z_{NDC} 的取值范围大概在 (0, 0.95)之间,而当 1<z_v<10 时, z_{NDC} 的取值范围大概在 (0.95, 1)之间。也就是说当物体越接近near clip plane那么深度值的精度就越高,而越接近far clip plane精度就越低。例如当两个物体距离分别是9和9.1的时候,如果精度不够,就很难通过深度来判断两个物体到底谁在前面,就可能会发生闪烁的现象(一下认为你在前面,一下子认为它在前面),这种由精度产生的问题我们称之为z-fight
然后我们来看看反转后的曲线,如下:


好像没什么软用,不还是接近near clip plane时精度越高么,只不过值从接近0变成了接近1。事情并不是这么简单,这里我们要引入浮点数精度分布的知识,即浮点数本身在越接近于0的时候精度越高,也就是说越接近0浮点数的分布越密集。这样你就会发现反转后,虽然当 1<z_v<10 时, z_{NDC} 的取值范围大概在 (0.05, 0)之间,但是这个区间内分布的浮点数却非常的多,从而保证深度值的精度不会受到特别的大的影响。
参考:
因此反转操作有利于在near特别小而far特别大时,整体保证不错的精度效果,从而减少z-fight现象。
其他避免z-fight的方法:

  • 物体不要放的太近,防止使用深度无法区分两者的远近关系。
  • near clip plane设置的尽可能远。但这样可能造成离camera很近的物体被裁剪掉,需要多测试找到一个适合的距离。
  • 使用更高精度的depth buffer,例如Unity5.5的更新里说到将24 bit的depth buffer更换为了32 bit,当然这样会增加内存的开销。
  • 尽量缩短n和f之间的距离,例如n=1,f=8的曲线示意图如下。



n=1,f=8

因此最终结论就是Unity在DirectX平台上(Metal与之一样),depth的取值范围是1到0,当在near clip plane上时depth=1,在far clip plane上时depth=0,其计算公式为:
depth=\frac{(f-z_v)n}{(f-n)z_v}
我们可以简单的验证下是否正确,例如之前的测试场景,我们设置Camera的Clipping Planes的Near为6,Far为100,对应公式中的n和f:


然后创建一个Cube使其离摄像机的距离为10.5,因为Cube的宽度为0.5,因此此时Cube离Camera最近的一个面的距离正好为10,对应场景和深度图如下:



n=6,f=100

将这些值代入公式得到depth为:
depth=\frac{(100-10)*6}{(100-6)*10}=0.574
换算成r通道的值即为:0.574*255=146,我们采样一下深度图中小方块的颜色,可以发现正好是对应的,如下图:


同时这个公式也可以解释为什么有些时候深度图是全黑色的,因为默认的Camera的n=0.3,f=1000,如果代入公式会发现小方块的depth变为了0.03,接近黑色。对应示意图如下:



n=0.3,f=1000

接下来我们再来看看OpenGL的,同样假设在MV变换后坐标为 (x_v,y_v,z_v) ,那么P变换后得到的齐次坐标为:
Clip Space=\begin{bmatrix} \frac{2n}{r-l} &0 & 0 & 0 \\ 0 &\frac{2n}{t-b} & 0 &0 \\ 0 &0 &\frac{-(f+n)}{f-n} & \frac{-2fn}{f-n} \\ 0 &0 &-1 &0 \end{bmatrix}\begin{bmatrix}x_v\\y_v\\z_v\\1\end{bmatrix}=\begin{bmatrix}\frac{2n}{r-l} x_v\\\frac{2n}{t-b}y_v\\\frac{-(f+n)z_v-2fn}{f-n}\\-z_v\end{bmatrix}
这里需要注意的是OpenGL的视图空间是右手坐标系,而式子中的 f 和 n 分别代表 far clip plane 和 near clip plane离原点的距离, 0<n<f 且 -f<z_v<-n
我们将 z_v=-n 和 z_v=-f 分别代入z的公式中得:
\left\{\begin{matrix} \frac{-(f+n)*(-n)-2fn}{f-n}=\frac{n*n-fn}{f-n}=-n &&z_v=-n\\ \frac{-(f+n)*(-f)-2fn}{f-n}=\frac{f*f-fn}{f-n}=f &&z_v=-f \end{matrix}\right.
因此在裁剪空间中,z值的取值范围为(-n,f)
然后转换为NDC坐标即为:
NDC=\begin{bmatrix}\frac{-2nx_v}{z_v(r-l)} \\\frac{-2ny_v}{z_v(t-b)}\\\frac{(f+n)z_v+2fn}{(f-n)z_v}\\1\end{bmatrix}
z_{NDC}=\frac{(f+n)z_v+2fn}{(f-n)z_v}
继续将 z_v=-n 和 z_v=-f 分别代入公式中得:
\left\{\begin{matrix} \frac{(f+n)*(-n)+2fn}{(f-n)*(-n)}=\frac{-n^2+fn}{-nf+n^2}=-1&&z_v=-n\\ \frac{(f+n)*(-f)+2fn}{(f-n)*(-f)}=\frac{-f^2+fn}{-f^2+nf}=1&&z_v=-f \end{matrix}\right.
因此 z_{NDC}\in(-1,1) ,由于深度值的范围为(0,1),所以我们还需要做一个转换,即: depth=\frac{z_{NDC}+1}{2} ,并且由于Unity并没有对OpenGL的深度值进行反转(可见之前发的官方文档连接),因此Unity在OpenGL下最终的深度值计算公式为
depth=\frac{(f+n)z_v+2fn+(f-n)z_v}{2(f-n)z_v}=\frac{(z_v+n)f}{(f-n)z_v}
depth的取值范围是0到1,当在near clip plane上时depth=0,在far clip plane上时depth=1。
同样我们可以用之前的方法切到Android平台下验证一下,将n=6,f=100, z_v=-10 (因为OpenGL是右手,Unity是左手,所以在Unity中Cube相对Camera的z为+10但是代入OpenGL的公式时要取反)代入公式当中得到:
depth=\frac{(-10+6)*100}{(100-6)*(-10)}=0.426
对应的r通道值即为 0.426*255=108,参考图如下:



n=6,f=100



总结一下:
在类似Direct3D的平台上,例如Direct3D, Metal 和 consoles,它们的裁剪空间z值范围为(0, f),深度值范围为(0,1)。Unity平台对DirectX 11, DirectX 12, PS4, Xbox One, Metal进行的深度反转,使得这些平台下裁剪空间z值范围为(n, 0),深度值范围为(1,0)。
在类似OpenGL的平台上,例如OpenGL, OpenGL ES2和 OpenGL ES3,它们的裁剪空间z值范围为(-n, f),深度值范围为(0,1)。
上面所有的范围,第一个数指的都是near clip plane所在的值,第二个数为far clip plane所在的值。

获取/采样深度图

前面简单的介绍了下深度图的计算方式,而当我们要使用深度图制作一些特殊效果的时候,例如深度剔除,景深效果等,就需要我们对深度图进行采样,然后根据获取到的深度做处理。
那么我们怎么才能获取到深度图并且采样呢?官方给出的答案如下:
简单来说在Unity中,Camera可以生成depth,depth+normals,motion vector三种texture。这些texture在可以帮助我们实现一些很牛的效果,而其中的depth texture就是我们要的深度图。
我们可以通过设置Camera的depthTextureMode属性来使Camera生成对应的texture,例如下面代码可以使Camera生成depth texture(开启后会造成一定的性能消耗):
Camera.main.depthTextureMode |= DepthTextureMode.Depth;
运行后,可以从Camera组件中看到如下提示:info:renders Depth texture


如果我们的Rendering Path使用的是Deferred或是Legacy Deferred(如下图),那么生成深度图并不会造成额外的消耗,因为使用Deferred Shading本身就会把深度信息写入到G-buffer中。



Camera Rendering Path

Depth texture的大小和屏幕大小相同,其中每个像素的值的范围在0到1之间(计算方式前面介绍了),非线性分布(参考前面的曲线图,后面会细说),精度根据不同的平台可能是16bit 或 32bit。支持深度图的平台有如下几种:
Direct3D 11+ (Windows), OpenGL 3+ (Mac/Linux), OpenGL ES 3.0+ (iOS), Metal (iOS) and consoles like PS4/Xbox One
注:OpenGL ES 2.0 (Android) 需要有 GL_OES_depth_texture 扩展,WebGL 需要有 WEBGL_depth_texture 扩展才能支持深度图。
我们的深度图在Shader的ShadowCaster Pass中被渲染,因此如果shader不支持shadow casting(即shader中不包含ShadowCaster的Pass并且fallbacks的shader也没有),那么使用这些shader的物体不会被渲染在深度图上。
举个例子,我们新建一个SurfaceShader,然后场景中的建个Cube使用该Shader,可以看到该Cube被渲染到了深度图中,如下图:



渲染了Cube

这是因为我们新建的shader中,默认fallback了Diffuse shader,而Diffuse 中就带有ShadowCaster Pass,因此我们的Cube会被写入到深度图中。
FallBack "Diffuse"如果我们删掉这句再看看,就会发现深度图中没有了这个Cube,如下图:



没有Cube了

除了通过fallback别的带有shadow casting pass的shader的方法外,对于SurfaceShader我们还可以通过添加 addshadow 指令来使得系统自动生成对应的shadow casting pass,如下:
#pragma surface surf Standard fullforwardshadows addshadow当然了,除了上面两种方法外,我们也可以自己写一个ShadowCaster Pass来使得物体能够被渲染进深度图。除此之外,只有不透明物体(render queue <= 2500)会被渲染进深度图。
那么当我们设置了Camera的depthTextureMode为Depth,生成了Depth texture,怎么使用(采样)呢?Unity在shader中为我们提供了一个名为 _CameraDepthTexture 的全局变量,我们对其进行采样即可。
例如在fragment shader中采样深度图:
sampler2D _CameraDepthTexture;
float4 frag(v2f input) : Color
{
    float depth = tex2D(_CameraDepthTexture, input.uv);
    return float4(depth, 0.0f, 0.0f, 1.0f);
}采样的代码,一般也会写成下面这种形式:
float depth = UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, input.uv));其中 UNITY_SAMPLE_DEPTH 的作用就是取r通道的值:
//HLSLSupport.cginc
#define UNITY_SAMPLE_DEPTH(value) (value).r当然了,我们也可以在C#端利用 Shader.GetGlobalTexture 来获取深度图,然后用 Graphics.Blit 方法将其复制到RenderTexture上(这也是做Hiz剔除时重要的一环)。
这里需要注意的是,在Unity的生命周期中有很多的事件函数,例如Start,Update,OnRenderObject等等,官方文档介绍如下:
那么我们应该在哪个事件中获取_CameraDepthTexture才能保证是当前帧的深度图呢?经过测试,建议在OnPostRender中获得到当前帧的深度图,如下:
void OnPostRender()
{
    Graphics.Blit(Shader.GetGlobalTexture("_CameraDepthTexture"), renderTexture);
}



RenderTexture

注:有时在OnPreRender或者Update函数中获取_CameraDepthTexture,那么得到的深度图将会是Scene窗口下的深度图,具体原因暂时不明~



右边的深度图是Scene视角的

有关 _LastCameraDepthTexture 的用法自己还没整明白,后续再研究研究,按文档的意思可以用来生成低分辨率的DepthTexture。
当然了,我们做东西肯定要考虑跨平台,前面提到了不同平台生成的深度图是不同的,如DirctX 近到远是1到0,OpenGL 近到远是0到1,那么怎么统一采样的值呢?根据前面的介绍我们知道 DirctX 等平台之所以是1到0是因为unity为其做了反转,那么我们再把它们转回来不就得了么。而对于这些进行了深度反转的平台,unity都定义了名为 UNITY_REVERSED_Z 的宏,因此如果想要各个平台近到远都是0到1,就可以这么处理:
sampler2D _CameraDepthTexture;
float4 frag(v2f input) : Color
{
    float depth = tex2D(_CameraDepthTexture, input.uv);
    #if defined(UNITY_REVERSED_Z)
        depth = 1.0f - depth; //d3d, metal to do it
    #endif
    return float4(depth, 0.0f, 0.0f, 1.0f);
}
非线性转线性

Depth texture中每个像素代表的深度值是一个非线性的变化,例如前面n=0.1,f=10的深度变化曲线如下图:



n=0.1,f=10,非线性

这样有一个坏处,比方说我们想用深度图做一个扫描线的效果,如下图:



扫描线效果

我们的扫描线应该随着深度的变化而变化,那么就会有个问题,例如曲线图所示,当我们深度从1变化到0.05的时候,实际上代表的距离仅仅离相机不到1,我们的扫描线只能匍匐前进,甚至都看不到。而深度从0.05变化到0的时候,啪的一下就瞬移的。而我们期望的肯定是随着深度的变化,扫描线的移动(也就是距离)也匀速的变化,这就是所谓的线性关系
为了解决这个问题,Unity为我们提供了一个 Linear01Depth 的方法,可以得到一个线性变化的深度值。
//UnityCG.cginc
// Z buffer to linear 0..1 depth
inline float Linear01Depth( float z )
{
    return 1.0 / (_ZBufferParams.x * z + _ZBufferParams.y);
}里面的_ZBufferParams的参数定义如下:
//UnityShaderVariables.cginc
// Values used to linearize the Z buffer (http://www.humus.name/temp/Linearize%20depth.txt)
// x = 1-far/near
// y = far/near
// z = x/far
// w = y/far
// or in case of a reversed depth buffer (UNITY_REVERSED_Z is 1)
// x = -1+far/near
// y = 1
// z = x/far
// w = 1/far
float4 _ZBufferParams;z值的计算公式我们前面已经提到过了,我们代入到Linear01Depth方法中来看看为什么它返回的结果是线性的。
对于DirectX这类深度反转的,_ZBufferParams.x = -1+far/near,_ZBufferParams.y = 1,得到公式:
depth=\frac{1}{\frac{(f-z_v)n}{(f-n)z_v}*(-1+\frac{f}{n})+1}=\frac{z_v}{f}
对于OpenGL,_ZBufferParams.x = 1-far/near,_ZBufferParams.y = far/near,得到公式:
depth=\frac{1}{\frac{(z_v+n)f}{(f-n)z_v}*(1-\frac{f}{n})+\frac{f}{n}}=\frac{z_v}{f}
好家伙,就是视图空间下的z值除以far clip plane的值,对应的函数图即为:



n=0.1,f=10,非线性

从中可以看出,使用Linear01Depth需要注意的有两点:

  • 不管深度是否反转,得到的结果都是从近到远是从0到1。
  • 深度和near clip plane的值无关,等于0时,代表在相机原点,而不是在near clip plane。
除了Linear01Depth方法外还有个LinearEyeDepth方法,其实就是通过深度反推出视图空间下z的值( z_v ),方法体如下:
//UnityCG.cginc
// Z buffer to linear depth
inline float LinearEyeDepth( float z )
{
    return 1.0 / (_ZBufferParams.z * z + _ZBufferParams.w);
}简单的套一下Direct的公式:
eyedepth=\frac{1}{\frac{(f-z_v)n}{(f-n)z_v}*\frac{-1+\frac{f}{n}}{f}+\frac{1}{f}}=z_v

使用深度图


深度图的一些使用场景,先贴几个参考,后续抽空自己实现下再完善:

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
发表于 2023-3-26 14:21 | 显示全部楼层
感谢分享
发表于 2023-3-26 14:27 | 显示全部楼层
思路清晰 理解简单 谢谢分享!
发表于 2023-3-26 14:35 | 显示全部楼层
所以hi-z是个啥
发表于 2023-3-26 14:37 | 显示全部楼层
https://zhuanlan.zhihu.com/p/396979267
发表于 2023-3-26 14:41 | 显示全部楼层
严谨
发表于 2023-3-26 14:51 | 显示全部楼层
Camera.main.depthTextureMode |= DepthTextureMode.Depth;
这句话我没写_cameradepthtexture 里面依然是对的,我开了frame debugger看了,为什么。。。
发表于 2023-3-26 14:54 | 显示全部楼层
经过测试,Camera.main.depthTextureMode |= DepthTextureMode.Depth;在PC平台写或不写没关系,但在别的平台(比如安卓),很有关系!
发表于 2023-3-26 14:56 | 显示全部楼层
感谢
发表于 2023-3-26 15:04 | 显示全部楼层
讲的狠清晰,配合例子,很值得给个大大的赞
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-5-29 07:21 , Processed in 0.109549 second(s), 28 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表