在透视投影矩阵中,NDC 的 z 值和 view space 的 z 值的关系是:
其中的 n 和 f 分别是近平面和远平面,zv 是 view space 的 -Z 轴,即相机的方向。当 n=0.1,f=100 时,zNDC 的曲线是这样的:
这种形状的曲线意味着,前面一小范围的 zv 值映射到了一个大范围的 zNDC 中。当然,这在数学上没有什么问题,但在计算机中,浮点数是由精度限制的。单精度的 32 位浮点数一般使用 IEEE754 方法来表示,它的有效位只有为 7~8 位。例如,如果你的输入为 123456789.1,那计算机会保存为 123456792,输入为 123456789.2,也会保存为 123456792,能够准确保存的有效位数只有前 7 位。更何况一般的 depth buffer 只有 24 位。
不过,对于小场景来说,普通的非线性映射也可以将厘米级的深度值分别映射到不同的 zNDC 上,例如上面的 f=100,n=0.1 时:
但对于需要将远平面设为上万米的的场景来说,就不能一一映射了,更不用说要渲染整个地球的行星级别场景了(地球半径约等于 6,600,000m)。
当远平面为 f=109,近平面 n=0.1 时:
很显然,从 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 值。
我们使用的对数函数是:
这个函数的范围是 [0,1],其中 f 是远平面,C 是一个常量,可以自己指定,它的值决定近平面附近深度值的分辨率。因为 OpenGL 的 NDC 范围为 [?1,1],所以我们还需要把这个函数映射到 [?1,1]:
在上式中,除了 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;
即可实现对数分布的深度值。
下图是标准的深度分布和对数深度分布的比较:
其中 n=0.1,f=100,C=0.5,可以看到蓝色的标准深度分布非常的极端。远平面 f 越大,标准的深度分布就越陡。
对于给定的 C 值、远平面值 f 和 n 位的 depth buffer,距离 x 处的分辨率为:
然而,上述代码只在顶点处计算的深度值是对数分布的,而在像素处插值出来的深度值会偏离我们期望的值(主要是近距离的物体)。**因为图形 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 的裁剪问题,我们实际上使用的对数函数是 :
修改成这样的原因有:
因此,顶点着色器着色器改成:
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 传入着色器,避免每个片段都重复计算相同的值。