对数深度
摘要
对数深度是什么?为什么需要它
为什么需要对数深度?
在透视投影矩阵中,NDC 的 z 值 $z_d$和 view space 的 -z 值 $z_v$的关系是:
$$ \begin{alignedat}{13} z_d & = \frac{f+n}{f-n} + \frac{ \frac{2fn}{n-f} }{ z_v } \\ & = a + \frac{b}{z_v}, z_v \in [n, f] \end{alignedat} $$
其中的 $n$ 和 $f$ 分别是近平面和远平面,$z_v$ 是 view space 的 -Z 轴,即相机的方向。当 $n=0.1, f=100$ 时,$z_d$ 的曲线是这样的:
这种形状的曲线意味着,超过一半的 $z_d$ 值仅映射到了 $z_v$ 的 $[0.1, 0.2]$ 中。当然,这在数学上没有什么问题,但在计算机中,浮点数是由精度限制的。单精度的 32 位浮点数一般使用 IEEE 754 方法来表示,它的有效位只有为 7~8 位。
例如,如果你的输入为 8,158,151.1,那计算机会保存为 8,158,151.0,能够准确保存的有效位数只有前 7 位。这是因为 $[2^{22}, 2^{23}]$(即 $[4194304, 8388608]$)范围内的浮点数的 ULP(Unit in the Last Place)是 $(2^{23} - 2^{22}) / 2^{23} = 0.5$,即 8158151 的下一个浮点数是 8158151.5,没有 8158151.1 这个浮点数。
而一般 depth buffer 使用的是 24 位或 32 位 normalized unsigned integer,或 32 位浮点数来保存深度值。以 24 位为例,在着色器内写入的深度值浮点数会 cast 为 unsigned integer 类型,再保存到 depth buffer 中。读取时,再把 unsigned integer 类型 cast 为 float 类型。
不过,对于小场景来说,普通的非线性映射也可以将厘米级的深度值一一映射到不同的 $z_d$ 上,例如上面的 $f=100, n=0.1$ 时:
| $z_v$ | $z_d$ |
|---|---|
| 0.1 | -1 |
| ... | ... |
| 99.001 | $\frac{100.1-\frac{20}{\red{99.001}}}{99.9} = 0.9999797981838565 ≈ 0.999979794025421142578125 \text{(和 99.002 相等)}$ |
| 99.002 | $\frac{100.1-\frac{20}{\red{99.002}}}{99.9} = 0.9999798186097453 ≈ 0.999979794025421142578125$ |
| 99.51 | $\frac{100.1-\frac{20}{\red{99.51}}}{99.9} = 0.9999901418854287 ≈ 0.999990165233612060546875$ |
| 99.52 | $\frac{100.1-\frac{20}{\red{99.52}}}{99.9} = 0.9999903440417908 ≈ 0.99999034404754638671875$ |
| 100 | $\frac{100.1-\frac{20}{100}}{99.9} = 1.0 $ |
但对于需要将远平面设为上万米的的场景来说,就不能一一映射了,更不用说要渲染整个地球的行星级别场景了(地球半径约等于 6,600,000m)。
当需要将远平面设为 $f=10^9$,近平面设为 $n = 0.1$ 时:
| $z_v$ | $z_d$ |
|---|---|
| ... | ... |
| 1000.01 | $0.99980000219996 ≈ 0.999800026416778564453$ |
| 1001.02 | $0.9998002000001799 ≈ 0.999800026416778564453$ |
| 700,000,000 | $0.9999999999142857 ≈ 1$ |
| 800,000,000 | $0.99999999995 ≈ 1$ |
| ... | ... |
很显然,从 700,000,000 m 一直到 1,000,000,000 m 的范围内,它们的深度值都会被保存为 1。实际情况会更差,因为计算深度值时的加减乘除操作,也会丢失精度。
归根到底,是因为 view space 的 z 值是按负反比例函数被分布到 NDC 的 z 坐标中(单精度的计算和数据类型也是原因之一),非常小的一部分 $z_v$ 值就瓜分了大部分的 $z_d$,导致后面的多个值 $z_v$ 都映射到了同一个 $z_d$ 值上,最终在大场景中产生所谓的 z-fighting 现象。如果可以把这种映射关系调整为不那么极端,应该可以改善 z-fighting 的问题,而这种更好的分布函数就是对数函数。
如何计算对数深度
对数深度的工作原理是:在顶点着色器中输出想要的深度值给 NDC。但我们只能设置 clip space 的坐标,图形 API 会对 clip space 的坐标进行隐式的透视除法算出 NDC,所以我们还要对要输出的深度值乘以 clip space 的 w 值。
我们使用的对数函数是 [1] :
$$ z = \frac{\ln{(z_v \cdot C + 1)}}{\ln{(f \cdot C + 1)}} $$
这个函数的范围是 $[0,1]$,其中 f 是远平面,C 是一个常量,可以自己指定,它的值决定近平面附近深度值的分辨率。因为 OpenGL 的 NDC 范围为 $[-1,1]$,所以我们还需要把这个函数映射到 $[-1, 1]$:
$$ z = 2 \frac{\ln{(z_v \cdot C + 1)}}{\ln{(f \cdot C + 1)}} - 1 $$
在上式中,除了 $n$、$f$ 和 $C$ 这 3 个常量之外,我们还需要知道 view space 的深度值 $z_v$,而 clip space 坐标的 w 值就是 view space 在 -z 轴方向上的值,所以,我们在顶点着色器中将顶点坐标乘以 MVP 矩阵之后,再修改 clip space 的 z 值:
void main ()
{
gl_Position = uMVP * vec4(aPosition, 1.0);
float z_v = gl_Position.w;
gl_Position.z = log(z_v*C + 1.0) / log(f*C + 1.0);
gl_Position.z = 2.0 * gl_Position.z - 1.0; // 把 [0, 1] 映射到 [-1, 1]
gl_Position.z *= gl_Position.w; // 补偿图形 API 的透视除法
}
即可实现对数分布的深度值。
下图是标准的深度分布和对数深度分布的比较:
其中 $n=0.1,f=100,C=0.5$,可以看到蓝色的标准深度分布非常的极端。远平面 $f$ 越大,标准的深度分布就越陡。
对于给定的 $C$ 值、远平面值 $f$ 和 $n$ 位的 depth buffer,距离 $x$ 处的分辨率为:
$$ \frac{\ln{(C \cdot f + 1)}}{(2^{n} - 1) \cdot \frac{C}{C \cdot x + 1}} $$
然而,上述代码只在顶点处计算的深度值是对数分布的,而在像素处插值出来的深度值会偏离我们期望的值(主要是近距离的物体)。因为图形 API 在光栅化时,对深度值是按普通的线性进行插值的,而不是像 varying 变量那样使用透视矫正的线性插值。因此,我们需要利用 varying 变量的插值,且在片段着色器中通过 gl_FragDepth 把正确的深度值写入到像素中。虽然使用 gl_FragDepth 的缺点是会增加带宽,且会破坏掉和深度值相关的优化(early-z)。
我们知道,fragment 的深度值是线性插值的,而 varying 变量的插值是透视矫正的线性插值,会考虑 clip space 的 w 值。
顶点着色器利用 varying 变量进行透视矫正的线性插值:
out float vDepthPlusOne;
void main ()
{
gl_Position = uMVP * vec4(aPosition, 1.0);
vDepthPlusOne = gl_Position.w*C + 1.0;
}
然后在片段着色器中使用透视矫正的插值修改深度值:
in float vDepthPlusOne;
void main()
{
outColor = xxx;
gl_FragDepth = log(vDepthPlusOne) / log(f*C + 1);
}
对数函数的更新
为了着色器能有更好的性能以及解决图形 API 的裁剪问题,我们实际上使用的对数函数是 [2] :
$$ z = 2 \frac{\log_{2}{(z_v + 1)}}{\log_{2}{(f + 1)}} - 1 $$
修改成这样的原因有:
- shader 的
log函数是使用log2来实现的,所以最好直接使用log2,避免额外的乘法 - 移除之前使用的常量 C 来控制精度分布,因为得到的深度精度通常比需要的精度高得多,所以我们直接取 $C=1$,效果也很好
- 裁剪问题:小于或等于 0 的值的
log函数是 undefined 的,当一个三角形的某个顶点位于相机后面更远的地方时(即 $\leq -1$ 时),这会导致在裁剪之前就拒绝整个三角形的渲染。
因此,需要把顶点着色器的 C 改为 1.0:
out float vDepthPlusOne;
void main ()
{
gl_Position = uMVP * vec4(aPosition, 1.0);
vDepthPlusOne = gl_Position.w + 1.0;
}
片段着色器改成:
in float vDepthPlusOne;
uniform float uOneOverLog2FarPlusOne;
void main()
{
outColor = xxx;
gl_FragDepth = log2(max(1e-6, vDepthPlusOne)) * uOneOverLog2FarPlusOne;
}
其中的 uOneOverLog2FarPlusOne 是 $1.0 / \log_2(far + 1.0)$,避免每个 fragment 都重复计算相同的值,且还能避免过多的操作而提高精度。
已知问题
根据保存的对数深度值恢复为对应的普通深度值。