1. 原理
深度纹理的本质是一张RenderTexture,只不过其中记录的不是颜色值,而是一个深度值
这些深度值来自于顶点在空间变换后得到的归一化设备坐标(NDC)的Z值
由于NDC坐标的分量取值范围在[-1, 1]之间,要使颜色值能够覆盖所有范围,需要对其进行映射:d = (ZNDC + 1) / 2
- 当 d 为0时,距离摄像机最近,此时位于近剪裁面上
- 当 d 为1时,距离摄像机最远,此时位于远剪裁面上
2. 数据来源
在延迟渲染中,由于第一个 Pass 会将深度/法线等信息都渲染到 G-Buffer 中,因此对于延迟渲染来讲,要生成深度纹理,可以直接从G缓冲区中读取数据
在前向渲染中,没有生成 G-Buffer 数据的过程,此时 Unity 会使用着色器替换技术,选择所有 Pass 设置了标签 “RenderType” = “Opaque” 的物体,然后检查"Queue"标签,如果该标签设置的渲染队列所对应的值小于2500,该物体就会参与深度纹理的计算,并使用一个单独的 Pass 渲染深度纹理。
也就是说,无论前向渲染还是延迟渲染,在生成深度纹理时,都需要先计算深度信息,此时Unity会查找参与深度计算的物体身上是否有“LightMode” = “ShadowCaster” 的 Pass,如果有,则使用该 Pass 进行计算,否则不计算。
如果设置的是生成深度 + 法线纹理,还会使用另外一个特定的Pass生成法线信息。
如果生成的是深度纹理,根据所用的深度缓存的精度,深度纹理的精度通常是24或16位,如果生成的是深度 + 法线纹理,Unity会创建一张和屏幕相同分辨率的32位纹理,其中,观察空间的法线写入RG通道,深度写入BA通道。
3. 获取纹理
3.1 获取深度纹理
在脚本中设置摄像机的深度纹理类型:_camera.depthTextureMode = DepthTextureMode.Depth
在Shader中声明变量:_CameraDepthTexture
3.2 获取深度+法线纹理
在脚本中设置摄像机的深度纹理类型:_camera.depthTextureMode = DepthTextureMode.DepthNormals
在Shader中声明变量:_CameraDepthNormalsTexture
4. 采样纹理
4.1 采样深度纹理
可以通过tex2D对深度纹理直接进行采样,Unity也提供了一系列采样深度纹理的方法,通过使用这些方法,可以兼容各个平台的差异
float d = SMAPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
我们上面说过,深度纹理中存储的是NDC坐标映射到[0, 1]范围内的值,我们这里可以把它大体等同于NDC坐标来分析。NDC坐标是怎么来的呢?是观察空间内的坐标先经过投影变换,然后除以w得到的。投影变换的矩阵 ( Mfrustum ) 如下:
( X X X 0 0 0 0 Y Y Y 0 0 0 0 − ( F a r + N e a r ) / ( F a r − N e a r ) − 2 ( F a r ∗ N e a r / ( F a r − N e a r ) ) 0 0 − 1 0 ) \left( \begin{matrix} XXX & 0 & 0 & 0\\ 0 & YYY & 0 & 0\\ 0 & 0 & -(Far + Near)/(Far - Near) & -2(Far * Near/(Far - Near))\\ 0 &0&-1&0 \end{matrix} \right) XXX0000YYY0000−(Far+Near)/(Far−Near)−100−2(Far∗Near/(Far−Near))0
假设观察空间内有一点Pview = (Xview, Yview, Zview),我们用 Mfrustum * Pview 即可得到该点在齐次裁剪空间下的对应坐标Pclip = (Xclip, YClip, Zclip, WClip) = ( _, _, -(Far + Near)/(Far - Near) * Zview - 2(Far * Near/(Far - Near)), -Zview)
然后对该坐标进行齐次除法得到NDC坐标,这里我们只看Z分量: ZNDC = (Far + Near)/(Far - Near) + 2(Far * Near/(Far - Near)) * (1 / Zview)
因为Far和Near都是常数,为了使式子看起来更清晰,我们用A、B代替其中常数的部分,于是得到: ZNDC = A + B / Zview
而上面通过 SMAPLE_DEPTH_TEXTURE 方法采样得到的深度值 d 就是 ZNDC 映射到 [0, 1] 区间得到的值:d = 0.5 * (A + B / Zview) + 0.5 = (0.5A + 0.5) + 0.5B / Zview
我们这里不需要关心常数的值,依然用AB代替,因此 d 也可表达成 d = A + B / Zview
可见,深度纹理(包括深度缓冲区)中记录的深度值 d 与点在观察空间中的实际深度 Zview 并不成线性关系。这就导致在实现一些效果时,直接对d插值会得到错误的结果。
比如有两个点A、B,它们在观察空间中真实的深度为ZA、ZB,转换成深度纹理中的深度值为 dA、dB,同时在AB的中间有一点C,其在观察空间的真实深度为 ZC = (ZB + ZA)/ 2,通过上面的分析我们已经知道,d 与 Zview 并不成线性关系,也就是说 C 点在深度纹理中记录的深度值 dC ≠ (dB + dA)/ 2。因此,当需要求C点的真实深度时(比如根据法线重构世界坐标),不能直接对dA、dB进行线性插值。我们需要先将 d 转换到一个线性空间中,然后在这个线性空间中再进行插值。Unity为此提供了两个方法:
- LinearEyeDepth:将 d 转换到观察空间的线性值,由于观察空间的Z向范围是从近剪裁面到远剪裁面,因此该方法得到的值也在[Near, Far]的范围内
- Linear01Depth:将 d 值转换到观察空间的线性值,但是结果除以了Far,因此最终值被限定到了[0, 1]的范围内
除此以外,Unity还提供了其他类似的宏方法,如SAMPLE_DEPTH_TEXTURE_PROJ 和 SAMPLE_DEPTH_TEXTURE_LOD。
4.2 采样深度+法线纹理
对于深度+法线纹理,通常直接使用 tex2D 方法对 _CameraDepthNormalsTexture 进行采样,采样得到的颜色值包括了深度和法线两部分信息,Unity提供了函数帮我们对其进行解码:
inline void DecodeDepthNormal( float4 enc, out float depth, out float3 normal)
{depth = DecodeFloatRG (enc.zw);normal= DecodeViewNormalStereo(enc);
}
其中:
- enc 为对深度 + 法线纹理的采样结果
- depth 用于接收解码得到的深度,这个深度值为[0, 1]之间的线性值,相当于直接解码出一个 Linear01Depth 的值,因此不需要再手动处理
- normal 用于接收解码得到的法线,该法线同样是观察空间下的法线
5. 基于深度纹理重建世界坐标的两种方式
5.1 NDC坐标逆向变换
回想【Unity Shader入门精要 第4章】数学基础(二)中提到的Unity的五个空间,对于世界空间中的一个点,经过 VP 变换后转换到齐次剪裁空间,然后通过齐次除法得到NDC坐标,最后通过屏幕映射映射到屏幕上。
第一种重建世界坐标的思路就是将上述过程逆向进行。
首先需要通过屏幕像素构建出NDC坐标。
- 在Unity中,NDC坐标的范围在[-1, 1],我们在片元着色器中采样使用的uv坐标的范围在[0, 1],其实就是NDC坐标的XY分量经过(NDC + 1)/ 2 得到的,因此:XYNDC = 2 * XYUV - 1
- 对深度纹理进行采样得到深度值d,上面说过,d = (ZNDC + 1) / 2,因此:ZNDC = 2*d - 1
- NDC坐标的W分量固定为1:WNDC = 1
- 最终得到:PNDC = ( 2 * XUV - 1, 2 * YUV - 1, 2*d - 1, 1 )
构建出NDC坐标后,就可以推导出重建世界坐标的公式,整个推导过程是建立在如下四条已知条件上的:
- Pclip = Matrixvp * Pworld
- XYZNDC = XYZclip / Wclip
- WNDC = 1
- Wworld = 1
推导过程:
-
XYZNDC = XYZclip / Wclip ➡
XYZclip = Wclip * XYZNDC ➡
Pclip = ( XYZclip, Wclip ) = ( Wclip * XYZNDC, Wclip ) -
由 Pclip = Matrixvp * Pworld 可得:
Matrixvp -1 * Pclip = Pworld ➡
Matrixvp -1 * ( Wclip * XYZNDC, Wclip ) = Pworld ➡
Wclip * Matrixvp -1 * ( XYZNDC, 1 ) = Pworld -
由于 WNDC = 1,因此:
Wclip * Matrixvp -1 * ( XYZNDC, 1 ) = Pworld ➡
Wclip * Matrixvp -1 * ( XYZNDC, WNDC ) = Pworld ➡
Wclip * Matrixvp -1 * PNDC = Pworld -
我们只看W分量:
Wclip * ( Matrixvp -1 * PNDC ).W = Wworld = 1 ➡
Wclip = 1 / ( Matrixvp -1 * PNDC ).W -
将Wclip代入上面标黄的式子得到:
Matrixvp -1 * PNDC / ( Matrixvp -1 * PNDC ).W = Pworld
最终得到: Pworld = Matrixvp -1 * PNDC / ( Matrixvp -1 * PNDC ).W
5.2 射线插值
射线插值重建像素世界坐标的原理基于下图:
对于屏幕上的一点P’,假设其对应的3D空间中的真实点的位置为P,则P点的位置可以通过摄像机的位置O加上向量OP来求得:
P = O + OP
O可以直接通过 _WorldSpaceCameraPos 变量获得,那么如何获得OP向量呢?
可以看到,上图中的黄色虚线部分是两个相似三角形,根据相似三角形的性质可知:
OP = Ray * LinearEyeDepth / Near
其中 LinearEyeDepth 可以通过深度纹理获得,Near为摄像机近剪裁面距离,也可以通过摄像机获得,于是问题只剩下求Ray向量。
首先我们想一下,屏幕后处理中处理的是什么?
屏幕后处理所处理的对象,是当前摄像机渲染的 RenderTexture,其实就是一个由四个顶点、两个三角面构成的四边形网格,如下图所示:
在屏幕后处理引用的 Shader 中,顶点着色器要处理的只有上图中 LeftUp、LeftDown、RightDown、RightUp 四个顶点。
那 P’ 又是什么?
P’ 是在片元着色器中处理的一个片元,它对应的是某个三角面覆盖的一个像素,如上图所示。我们在顶点着色器中并没有(也没有办法)对 P’ 直接设置数据,但是在片元着色器中依然可以获得 P’ 的uv坐标、法线等信息。之所以 P’ 有这些信息,是因为我们为每个顶点设置了这些信息,并且将这些信息放到了 v2f 结构的各种插值寄存器中(v2f 中定义的各种字段)。在后续三角形遍历阶段,引擎发现 P’ 被 LeftUp、RightDown 和 RgihtUp 三个顶点围成的三角面覆盖到了,然后就会将三个顶点插值寄存器中的各种数据进行插值,计算出 P’ 点对应每个字段的值。
所以摄像机到 P’ 的射线可以通过摄像机到LeftUp、RightDown 和 RgihtUp三个顶点的射线插值获得(下方三角面同理),于是问题又变成求摄像机到四个顶点的射线。
摄像机到四个顶点的射线很好求,就是向量的加减乘除:
上图蓝色四边形代表摄像机的近剪裁面,ToRight 和 ToTop分别表示近剪裁面中心到最右边和最上边的向量,则从摄像机到近剪裁面右上角的向量:
O_RU = Camera.Forward * Near + RoRight + ToTop
同理:
O_LU = Camera.Forward * Near - RoRight + ToTop
O_LD = Camera.Forward * Near - RoRight - ToTop
O_RD = Camera.Forward * Near + RoRight - ToTop
注意,与上面一张图不同,这张图里紫线表示的是距离而不是向量,根据图中所示,定义:
HalfHeight = | ToTop | = Near * Tangent(Fov / 2)
则:
ToTop = Camera.Up * HalfHeight
ToRight = Camera.Right * HalfHeight * aspect
将 ToTop 和 ToRight 代入即可求出O_RU,同理还可求出 O_LU、O_LD、O_RD
然后我们再看一下最初要求的射线Ray:
OP = Ray * LinearEyeDepth / Near
这一部分是需要在片元着色器中逐像素计算的,为了节省性能,可以把式子中 Ray/Near 的部分合并成一个 ScaledRay,也就是说我们提供给顶点着色器的就是一个经过了( /Near) 处理的射线。
最终,整理一下涉及到的代码
HalfHeight = Near * Tangent(Fov / 2)
ToTop = Camera.Up * HalfHeight
ToRight = Camera.Right * HalfHeight * aspect
Scale = 1 / Near
Scaled_O_LD = ( Camera.Forward * Near - ToRight - ToTop ) * Scale
Scaled_O_RD = ( Camera.Forward * Near + ToRight - ToTop ) * Scale
Scaled_O_RU = ( Camera.Forward * Near + ToRight + ToTop ) * Scale
Scaled_O_LU = ( Camera.Forward * Near - ToRight + ToTop ) * ScaleWorldPos = WorldSpaceCameraPos + ScaledRay * LinearEyeDepth