对数深度 2022-8-7
为什么需要对数深度? 在透视投影矩阵中,NDC 的 z 值和 view space 的 z 值的关系是:
z N D C = f + n f − n + 2 f n n − f z v , z v ∈ [ n , f ] z_{NDC} = \frac{f+n}{f-n} + \frac{ \frac{2fn}{n-f} }{ z_{v} }, z_{v} \in [n, f] z N D C = f − n f + n + z v n − f 2 f n , z v ∈ [ n , f ]
其中的 n n n 和 f f f 分别是近平面和远平面,z v z_{v} z v 是 view space 的 -Z 轴,即相机的方向。当 n = 0.1 , f = 100 n=0.1, f=100 n = 0 . 1 , f = 1 0 0 时,z N D C z_{NDC} z N D C 的曲线是这样的:
这种形状的曲线意味着,前面一小范围的 z v z_v z v 值映射到了一个大范围的 z N D C z_{NDC} z N D C 中。当然,这在数学上没有什么问题,但在计算机中,浮点数是由精度限制的。单精度的 32 位浮点数一般使用 IEEE754 方法来表示,它的有效位只有为 7~8 位。例如,如果你的输入为 123456789.1,那计算机会保存为 123456792,输入为 123456789.2,也会保存为 123456792,能够准确保存的有效位数只有前 7 位。 更何况一般的 depth buffer 只有 24 位。
不过,对于小场景来说,普通的非线性映射也可以将厘米级的深度值一一映射到不同的 z N D C z_{NDC} z N D C 上,例如上面的 f = 100 , n = 0.1 f=100, n=0.1 f = 1 0 0 , n = 0 . 1 时:
z v z_v z v z N D C z_{NDC} z N D C 0.1 -1 0.2 0.1 99.9 = 0.001001001001001001 \frac{0.1}{99.9} = 0.001001001001001001 9 9 . 9 0 . 1 = 0 . 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0.4 55.1 99.9 = 0.5015015015015015 \frac{55.1}{99.9} = 0.5015015015015015 9 9 . 9 5 5 . 1 = 0 . 5 0 1 5 0 1 5 0 1 5 0 1 5 0 1 5 1.0 80.1 99.9 = 0.8018018018018 \frac{80.1}{99.9} = 0.8018018018018 9 9 . 9 8 0 . 1 = 0 . 8 0 1 8 0 1 8 0 1 8 0 1 8 ... ... 80.0 99.85 99.9 = 0.9994994994994993 \frac{99.85}{99.9} = 0.9994994994994993 9 9 . 9 9 9 . 8 5 = 0 . 9 9 9 4 9 9 4 9 9 4 9 9 4 9 9 3 80.01 100.1 − 20 80.01 99.9 = 0.9994998122732155 \frac{100.1-\frac{20}{\red{80.01}}}{99.9} = 0.9994998122732155 9 9 . 9 1 0 0 . 1 − 8 0 . 0 1 2 0 = 0 . 9 9 9 4 9 9 8 1 2 2 7 3 2 1 5 5 ... ... 99.001 100.1 − 20 99.001 99.9 = 0.9999797981838565 ≈ 0.999979794025421142578125 (和 99.002 相等) \frac{100.1-\frac{20}{\red{99.001}}}{99.9} = 0.9999797981838565 ≈ 0.999979794025421142578125 \text{(和 99.002 相等)} 9 9 . 9 1 0 0 . 1 − 9 9 . 0 0 1 2 0 = 0 . 9 9 9 9 7 9 7 9 8 1 8 3 8 5 6 5 ≈ 0 . 9 9 9 9 7 9 7 9 4 0 2 5 4 2 1 1 4 2 5 7 8 1 2 5 ( 和 99.002 相等 ) 99.002 100.1 − 20 99.002 99.9 = 0.9999798186097453 ≈ 0.999979794025421142578125 \frac{100.1-\frac{20}{\red{99.002}}}{99.9} = 0.9999798186097453 ≈ 0.999979794025421142578125 9 9 . 9 1 0 0 . 1 − 9 9 . 0 0 2 2 0 = 0 . 9 9 9 9 7 9 8 1 8 6 0 9 7 4 5 3 ≈ 0 . 9 9 9 9 7 9 7 9 4 0 2 5 4 2 1 1 4 2 5 7 8 1 2 5 99.51 100.1 − 20 99.51 99.9 = 0.9999901418854287 ≈ 0.999990165233612060546875 \frac{100.1-\frac{20}{\red{99.51}}}{99.9} = 0.9999901418854287 ≈ 0.999990165233612060546875 9 9 . 9 1 0 0 . 1 − 9 9 . 5 1 2 0 = 0 . 9 9 9 9 9 0 1 4 1 8 8 5 4 2 8 7 ≈ 0 . 9 9 9 9 9 0 1 6 5 2 3 3 6 1 2 0 6 0 5 4 6 8 7 5 99.52 100.1 − 20 99.52 99.9 = 0.9999903440417908 ≈ 0.99999034404754638671875 \frac{100.1-\frac{20}{\red{99.52}}}{99.9} = 0.9999903440417908 ≈ 0.99999034404754638671875 9 9 . 9 1 0 0 . 1 − 9 9 . 5 2 2 0 = 0 . 9 9 9 9 9 0 3 4 4 0 4 1 7 9 0 8 ≈ 0 . 9 9 9 9 9 0 3 4 4 0 4 7 5 4 6 3 8 6 7 1 8 7 5 100 100.1 − 20 100 99.9 = 1.0 \frac{100.1-\frac{20}{100}}{99.9} = 1.0 9 9 . 9 1 0 0 . 1 − 1 0 0 2 0 = 1 . 0
但对于需要将远平面设为上万米的的场景来说,就不能一一映射了,更不用说要渲染整个地球的行星级别场景了(地球半径约等于 6,600,000m)。
当远平面为 f = 1 0 9 f=10^9 f = 1 0 9 ,近平面 n = 0.1 n = 0.1 n = 0 . 1 时:
z v z_v z v z N D C z_{NDC} z N D C ... ... 1000 0.99980000019998 ≈ 0.999800026416778564453125 0.99980000019998 ≈ 0.999800026416778564453125 0 . 9 9 9 8 0 0 0 0 0 1 9 9 9 8 ≈ 0 . 9 9 9 8 0 0 0 2 6 4 1 6 7 7 8 5 6 4 4 5 3 1 2 5 1001 0.9998002000001799 ≈ 0.999800205230712890625 0.9998002000001799 ≈ 0.999800205230712890625 0 . 9 9 9 8 0 0 2 0 0 0 0 0 1 7 9 9 ≈ 0 . 9 9 9 8 0 0 2 0 5 2 3 0 7 1 2 8 9 0 6 2 5 10,000,0000 0.9999999802 ≈ 1 0.9999999802 ≈ 1 0 . 9 9 9 9 9 9 9 8 0 2 ≈ 1 10,000,0010 0.99999998020002 ≈ 1 0.99999998020002 ≈ 1 0 . 9 9 9 9 9 9 9 8 0 2 0 0 0 2 ≈ 1 ... ...
很显然,从 10,000,000 m 一直到 1000,000,000 m 的范围内,它们的深度值都会被保存为 1。
归根到底,是因为 view space 的 z 值是按负反比例函数被分布到 NDC 的 z 坐标中,导致非常小的一部分 z v z_v z v 值被映射到大部分的 z N D C z_{NDC} z N D C 中(在 f = 100 , n = 0.1 f=100, n=0.1 f = 1 0 0 , n = 0 . 1 中,0.1~0.2 的 z v z_v z v 就瓜分了一半的 z N D C z_{NDC} z N D C ),导致在大场景中产生所谓的 z-fighting 现象。如果可以把这种映射关系调整为不是那么极端的,应该可以改善 z-fighting 的问题。而这种更好的分布函数就是对数函数。
如何计算对数深度 对数深度的工作原理是:在顶点着色器中输出想要的对数深度值给 z N D C z_{NDC} z N D C ,且因为图形 API 会进行隐式的透视除法,所以我们还要乘以 clip space 的 w 值。
我们使用的对数函数是 [1] :
z = ln ( z v ⋅ C + 1 ) ln ( f ⋅ C + 1 ) z = \frac{\ln{(z_v \cdot C + 1)}}{\ln{(f \cdot C + 1)}} z = ln ( f ⋅ C + 1 ) ln ( z v ⋅ C + 1 )
这个函数的范围是 [ 0 , 1 ] [0,1] [ 0 , 1 ] ,其中 f 是远平面,C 是一个常量,可以自己指定,它的值决定近平面附近深度值的分辨率。因为 OpenGL 的 NDC 范围为 [ − 1 , 1 ] [-1,1] [ − 1 , 1 ] ,所以我们还需要把这个函数映射到 [ − 1 , 1 ] [-1, 1] [ − 1 , 1 ] :
z = 2 ln ( z v ⋅ C + 1 ) ln ( f ⋅ C + 1 ) − 1 z = 2 \frac{\ln{(z_v \cdot C + 1)}}{\ln{(f \cdot C + 1)}} - 1 z = 2 ln ( f ⋅ C + 1 ) ln ( z v ⋅ C + 1 ) − 1
在上式中,除了 n n n 、f f f 和 C C C 这 3 个常量之外,我们还需要知道 view space 的深度值 z v z_v 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 n=0.1,f=100,C=0.5 n = 0 . 1 , f = 1 0 0 , C = 0 . 5 ,可以看到蓝色的标准深度分布非常的极端。远平面 f f f 越大,标准的深度分布就越陡。
对于给定的 C C C 值、远平面值 f f f 和 n n n 位的 depth buffer,距离 x x x 处的分辨率为:
ln ( C ⋅ f + 1 ) ( 2 n − 1 ) ⋅ C C ⋅ x + 1 \frac{\ln{(C \cdot f + 1)}}{(2^{n} - 1) \cdot \frac{C}{C \cdot x + 1}} ( 2 n − 1 ) ⋅ C ⋅ x + 1 C ln ( C ⋅ f + 1 )
然而,上述代码只在顶点处计算的深度值是对数分布的,而在像素处插值出来的深度值会偏离我们期望的值(主要是近距离的物体)。因为图形 API 在光栅化时,对深度值是按普通的线性进行插值的,而不是像 varying 变量那样使用透视矫正的线性插值。 因此,我们需要利用 varying 变量的插值,且在片段着色器中通过 gl_FragDepth
把正确的深度值写入到像素中。虽然使用 gl_FragDepth
的缺点是会增加带宽,且会破坏掉和深度值相关的优化(early-z),但这些缺点在一定程度上影响不大。
我们知道,fragment 的深度值是线性插值的,而 varying 变量的插值是透视矫正的线性插值,会考虑 clip space 的 w 值。
顶点着色器利用 varying 变量进行透视矫正的线性插值:
out float depthPlusOne;
depthPlusOne = gl_Position .w*C + 1.0 ;
然后在片段着色器中使用透视矫正的插值修改深度值:
in float depthPlusOne;
gl_FragDepth = log (depthPlusOne) / log (f*C + 1 );
对数函数的更新 为了着色器能有更好的性能以及解决图形 API 的裁剪问题,我们实际上使用的对数函数是 [2] :
z = 2 log 2 ( z v + 1 ) log 2 ( f + 1 ) − 1 z = 2 \frac{\log_{2}{(z_v + 1)}}{\log_{2}{(f + 1)}} - 1 z = 2 log 2 ( f + 1 ) log 2 ( z v + 1 ) − 1
修改成这样的原因有:
shader 的 log
函数是使用 log2
来实现的,所以最好直接使用 log2
,避免额外的乘法 移除之前使用的常量 C 来控制精度分布,因为得到的深度精度通常比需要的精度高得多,所以我们直接取 C = 1 C=1 C = 1 ,效果也很好 裁剪问题:小于或等于 0 的值的 log
函数是 undefined 的,当一个三角形的某个顶点位于相机后面更远的地方时(即 ≤ − 1 \leq -1 ≤ − 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 传入着色器,避免每个片段都重复计算相同的值。