详解对数深度

原文在此。

为什么需要对数深度?

在透视投影矩阵中,NDC 的 z 值和 view space 的 z 值的关系是:
详解对数深度_第1张图片
其中的 n 和 f 分别是近平面和远平面,zv 是 view space 的 -Z 轴,即相机的方向。当 n=0.1,f=100 时,zNDC 的曲线是这样的:
详解对数深度_第2张图片
这种形状的曲线意味着,前面一小范围的 zv 值映射到了一个大范围的 zNDC 中。当然,这在数学上没有什么问题,但在计算机中,浮点数是由精度限制的。单精度的 32 位浮点数一般使用 IEEE754 方法来表示,它的有效位只有为 7~8 位。例如,如果你的输入为 123456789.1,那计算机会保存为 123456792,输入为 123456789.2,也会保存为 123456792,能够准确保存的有效位数只有前 7 位。更何况一般的 depth buffer 只有 24 位。
不过,对于小场景来说,普通的非线性映射也可以将厘米级的深度值分别映射到不同的 zNDC 上,例如上面的 f=100,n=0.1 时:
详解对数深度_第3张图片
但对于需要将远平面设为上万米的的场景来说,就不能一一映射了,更不用说要渲染整个地球的行星级别场景了(地球半径约等于 6,600,000m)。
当远平面为 f=109,近平面 n=0.1 时:
详解对数深度_第4张图片
很显然,从 10,000,000 m 一直到 1000,000,000 m 的范围内,它们的深度值都会被保存为 1。
归根到底,是因为 view space 的 z 值是按负反比例函数被分布到 NDC 的 z 坐标中,导致非常小的一部分 zv 值被映射到大部分的 zNDC 中(在 f=100,n=0.1 中,0.1~0.2 的 zv 就瓜分了一半的 zNDC),导致在大场景中产生所谓的 z-fighting 现象。如果可以把这种映射关系调整为不是那么极端的,应该可以改善 z-fighting 的问题。而这种更好的分布函数就是对数函数。

如何计算对数深度

对数深度的工作原理是:在顶点着色器中输出想要的对数深度值给 zNDC,且因为图形 API 会进行隐式的透视除法,所以我们还要乘以 clip space 的 w 值。
我们使用的对数函数是:
image.png
这个函数的范围是 [0,1],其中 f 是远平面,C 是一个常量,可以自己指定,它的值决定近平面附近深度值的分辨率。因为 OpenGL 的 NDC 范围为 [−1,1],所以我们还需要把这个函数映射到 [−1,1]:
image.png
在上式中,除了 n、f 和 C 这 3 个常量之外,我们还需要知道 view space 的深度值 zv,而在顶点乘以透视矩阵之后,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;

即可实现对数分布的深度值。
下图是标准的深度分布和对数深度分布的比较:
详解对数深度_第5张图片
其中 n=0.1,f=100,C=0.5,可以看到蓝色的标准深度分布非常的极端。远平面 f 越大,标准的深度分布就越陡。
对于给定的 C 值、远平面值 f 和 n 位的 depth buffer,距离 x 处的分辨率为:
详解对数深度_第6张图片
然而,上述代码只在顶点处计算的深度值是对数分布的,而在像素处插值出来的深度值会偏离我们期望的值(主要是近距离的物体)。**因为图形 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 的裁剪问题,我们实际上使用的对数函数是 :
image.png
修改成这样的原因有:

  1. shader 的 log 函数是使用 log2 来实现的,所以最好直接使用 log2,避免额外的乘法
  2. 移除之前使用的常量 C 来控制精度分布,因为得到的深度精度通常比需要的精度高得多,所以我们直接取 C=1,效果也很好
  3. 裁剪问题:小于或等于 0 的值的 log 函数是 undefined 的,当一个三角形的某个顶点位于相机后面更远的地方时(即 ≤−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 传入着色器,避免每个片段都重复计算相同的值。

你可能感兴趣的:(图形学,着色器,图形渲染,图形学)