对数深度

目录

  1. 为什么需要对数深度?
  2. 如何计算对数深度
  3. 对数函数的更新

为什么需要对数深度?

在透视投影矩阵中,NDC 的 z 值和 view space 的 z 值的关系是:

$$ z_{NDC} = \frac{f+n}{f-n} + \frac{ \frac{2fn}{n-f} }{ z_{v} }, z_{v} \in [n, f] $$

其中的 $n$ 和 $f$ 分别是近平面和远平面,$z_{v}$ 是 view space 的 -Z 轴,即相机的方向。当 $n=0.1, f=100$ 时,$z_{NDC}$ 的曲线是这样的:

这种形状的曲线意味着,前面一小范围的 $z_v$ 值映射到了一个大范围的 $z_{NDC}$ 中。当然,这在数学上没有什么问题,但在计算机中,浮点数是由精度限制的。单精度的 32 位浮点数一般使用 IEEE754 方法来表示,它的有效位只有为 7~8 位。例如,如果你的输入为 123456789.1,那计算机会保存为 123456792,输入为 123456789.2,也会保存为 123456792,能够准确保存的有效位数只有前 7 位。更何况一般的 depth buffer 只有 24 位。

不过,对于小场景来说,普通的非线性映射也可以将厘米级的深度值一一映射到不同的 $z_{NDC}$ 上,例如上面的 $f=100, n=0.1$ 时:

$z_v$$z_{NDC}$
0.1-1
0.2$\frac{0.1}{99.9} = 0.001001001001001001$
0.4$\frac{55.1}{99.9} = 0.5015015015015015$
1.0$\frac{80.1}{99.9} = 0.8018018018018 $
......
80.0$\frac{99.85}{99.9} = 0.9994994994994993 $
80.01$\frac{100.1-\frac{20}{\red{80.01}}}{99.9} = 0.9994998122732155 $
......
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_{NDC}$
......
1000$0.99980000019998 ≈ 0.999800026416778564453125$
1001$0.9998002000001799 ≈ 0.999800205230712890625$
10,000,0000$0.9999999802 ≈ 1$
10,000,0010$0.99999998020002 ≈ 1$
......

很显然,从 10,000,000 m 一直到 1000,000,000 m 的范围内,它们的深度值都会被保存为 1。

归根到底,是因为 view space 的 z 值是按负反比例函数被分布到 NDC 的 z 坐标中,导致非常小的一部分 $z_v$ 值被映射到大部分的 $z_{NDC}$ 中(在 $f=100, n=0.1$ 中,0.1~0.2 的 $z_v$ 就瓜分了一半的 $z_{NDC}$),导致在大场景中产生所谓的 z-fighting 现象。如果可以把这种映射关系调整为不是那么极端的,应该可以改善 z-fighting 的问题。而这种更好的分布函数就是对数函数。

如何计算对数深度

对数深度的工作原理是:在顶点着色器中输出想要的对数深度值给 $z_{NDC}$,且因为图形 API 会进行隐式的透视除法,所以我们还要乘以 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 矩阵之后,再修改 NDC 的 z 值:

  float z = gl_Position.w;
  gl_Position.z = 2.0*(log(z*C+1.0) / log(f*C+1.0)) - 1.0;
  gl_Position.z *= gl_Position.w;

即可实现对数分布的深度值。

下图是标准的深度分布和对数深度分布的比较:

其中 $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 depthPlusOne; // 或 GLSL 100 的 varying float depthPlusOne

  depthPlusOne = gl_Position.w*C + 1.0;

然后在片段着色器中使用透视矫正的插值修改深度值:

in float depthPlusOne; // 或 GLSL 100 的 varying float depthPlusOne

  gl_FragDepth = log(depthPlusOne) / log(f*C + 1);

对数函数的更新

为了着色器能有更好的性能以及解决图形 API 的裁剪问题,我们实际上使用的对数函数是 [2]

$$ z = 2 \frac{\log_{2}{(z_v + 1)}}{\log_{2}{(f + 1)}} - 1 $$

修改成这样的原因有:

  1. shader 的 log 函数是使用 log2 来实现的,所以最好直接使用 log2,避免额外的乘法
  2. 移除之前使用的常量 C 来控制精度分布,因为得到的深度精度通常比需要的精度高得多,所以我们直接取 $C=1$,效果也很好
  3. 裁剪问题:小于或等于 0 的值的 log 函数是 undefined 的,当一个三角形的某个顶点位于相机后面更远的地方时(即 $\leq -1$ 时),这会导致在裁剪之前就拒绝整个三角形的渲染。

因此,顶点着色器着色器改成:

out float depthPlusOne;

  depthPlusOne = gl_Position.w + 1.0;

片段着色器改成:

in float depthPlusOne;
uniform float oneOverLog2FarPlusOne;

  gl_FragDepth = log2(max(1e-6, depthPlusOne)) * oneOverLog2FarPlusOne;

其中的 oneOverLog2FarPlusOne 是 1.0 / log2(far + 1.0),可作为 uniform 传入着色器,避免每个片段都重复计算相同的值。

[1] Logarithmic Depth Buffer, Outerra, August 12, 2009, https://outerra.blogspot.com/2009/08/logarithmic-z-buffer.html.
[2] Logarithmic depth buffer optimizations & fixes, Outerra, July 18, 2013, https://outerra.blogspot.com/2013/07/logarithmic-depth-buffer-optimizations.html.

Disqus 评论加载中...