LearnOpenGL Note - Advanced OpenGL - Depth testing

高级OpenGL-深度测试

Posted by Tao on Wednesday, April 6, 2022

通过本节可以了解到

  • 什么是深度缓冲区以及为什么需要深度缓冲区?
  • OpenGL中怎么使用深度缓冲区?
  • 可视化深度值
  • 深度值的精度问题Z-Fighting

1 Depth-Buffer

Depth-Buffer(深度缓存)有两种:Z-Buffer 和 W-Buffer。它们是做什么用的呢?主要目的就是去除隐藏面,也就是Hidden surface elimination(或是找出可见面,Visible surface detemination,这是同样意思)。在3D绘图中,只要有两个以上的三角面,就可能会出现某个三角面会遮住另一个三角面的情形。这是很明显的现象,因为近的东西总是会遮住远的(假设这些三角面都是不透明的)。所以,在绘制3D场景时,要画出正确的结果,就一定要处理这个问题。

要做到去除隐藏面的最简单方法,就是「画家算法」(Painter’s algorithm)。这个方法的原理非常简单,就是先画远的东西,再画近的东西。这样一来,近的东西自然就会盖住远的东西了。因为油画的画家通常会用这样的方法,所以这个方法被称为「画家算法」。下图是一个例子:

hsr_painter.jpg

上图中,照远近的顺序来画,先画红色矩形,再画黄色圆形,最后画灰色圆角矩形,就可以达到去除隐藏面的效果。所以,只要把3D场景中的三角面,以对观察者的距离远近排序,再从远的三角面开始画,应该就可以画出正确的结果了。

下图是一个「画家算法」无法解决的情形:

hsr_failed.jpg

图中,三个三角面互相遮住对方,所以不管用什么顺序去画,都无法得到正确的结果。

当然,如果相当确定场景中不会出现这么奇怪的情形,那「画家算法」一般还是可以用的。不过,它还有一个很大的问题,就是效率不佳。首先,画家算法需要对场景中视角范围内所有的三角面进行排序。最好的排序算法也需要\(O(nlogn)\)的时间。也就是说,如果三角面的数目从一千个变一万个,排序需要的时间会变成原来的约13.3倍(大致上来说)。而且,因为这需要对场景中所有的三角面来做,因此也不适合用特别的硬件来做加速。另外,这个方法还有一个很大的问题,就是它会花很多时间去画一些根本就会被遮住的部分,因为每个三角面的每个pixel都需要画出来。这也会让效率变差。

如果场景是静态(不动)的,只有观察者会变动的话,那是有方法可以加快排序的速度。一个很常用的方法是binary space paritioning(BSP)。这个方法需要事先对场景建立一个树状结构。建立这个结构后,不管观察者的位置、角度是如何,都可以很快找出正确的绘制顺序。而且,BSP会视需要切开三角面,以处理像上图那样,三个三角面互相遮住对方的情形。
不过,BSP结构在建立时要花很多时间,所以不太可能即时运算。因此,通常只能用在场景中的静态部分,而会动的部分还是需要另外排序。而且,BSP常会需要切开三角面,也会让三角面的数目增加。另外,BSP仍然无法解决需要画出那些被遮住的pixel的问题。

另一种去除隐藏面的方法,是直接以pixel为单位,而不是以三角面为单位来考虑这个问题。其中最简单的方法是由Catmull在1974年时提出来的,也就是Z-buffer(或称depth buffer)。这个方法非常简单,又容易由显卡来执行,所以在显存容量不再是问题后,就变得非常受欢迎。

Z-buffer的原理非常简单。在绘制3D场景时,除了存放绘制结果的frame buffer外,另外再使用一个额外的空间,也就是Z buffer。Z buffer记录frame buffer上,每个pixel和观察者的距离,也就是Z值。在开始绘制场景前,先把Z-buffer中所有的值先设定成无限远。然后,在绘制三角面时,对三角面的每个pixel计算该pixel的Z值,并和Z-buffer中存放的Z值相比较。如果Z-buffer中的Z值较大,就表示目前要画的pixel是比较近的,所以应该要画上去,并同时更新Z-buffer中的Z值。如果Z-buffer中的Z值较小,那就表示目前要画的pixel是比较远的,会被目前frame buffer中的pixel遮住,所以就不需要画,也不用更新Z值。这样一来,就可以用任意的顺序去画这些三角面,即可得到正确的绘制结果。下图是一个例子:

hsr_zbuffer.jpg

实际上Z-buffer中能存放的数字当然会有一定的限度,所以通常会把Z值缩小到0 ~ 1的范围。因此,在绘制3D场景时,就会需要把可能出现的Z值限制在某个范围内。通常是用两个和投影平面平行的平面,把所有超出这两个平面范围的三角面都切掉。这两个平面通常分别称为Z near和Z far,分别表示较近的平面和较远的平面。而在Z near平面的Z值为0,在Z far的Z值为1。

在效率上Z-buffer并不一定会比「画家算法」要快。但是,它比较简单。而且,它的效率和三角面的数目并没有太大的关系,而是和绘制的pixel数目有关。所以,而且可以很容易设计出特定的硬体来做这个动作,而不需要由CPU来做。而Z-buffer所需要的额外显存,在今天已经显得不是很重要。所以现在几乎所有的显卡都是使用Z-buffer。

Z-Buffer与W-Buffer的区别

简单的说,Z-Buffer与W-Buffer的区别就是前者保存的是点的z坐标,而后者保存的是点的w坐标。

Z-Buffer保存的是经过投影变换后的z坐标。投影后(透视校正后)的z坐标在其值域上,不是线性变化的(即非均匀分布),存储z坐标的一个好处是近处的物体得到了较高的深度分辨率,但是远处物体的深度判断可能会出错。

W-Buffer保存的是经过投影变换后的w坐标,而w坐标通常跟世界坐标系中的z坐标成正比,所以变换到投影空间中之后,其值依然是线性分布的。这样无论远处还是近处的物体,都有相同的深度分辨率,这是它的优点,当然,缺点就是不能用较高的深度分辨率来表现近处的物体。

从硬件实现角度来说,W-Buffer的支持没有Z-Buffer那么广泛。

2 Depth Testing in OpenGL

深度缓冲就像颜色缓冲(Color Buffer)(储存所有的片段颜色:视觉输出)一样,在每个片段中储存了信息,并且(通常)和颜色缓冲有着一样的宽度和高度。深度缓冲是由窗口系统自动创建的,它会以16、24或32位float的形式储存它的深度值。在大部分的系统中,深度缓冲的精度都是24位的。

深度缓冲是在片段着色器运行之后(以及模板测试(Stencil Testing)运行之后,我们将在下一节中讨论)在屏幕空间中运行的。屏幕空间坐标与通过OpenGL的glViewport所定义的视口密切相关,并且可以直接使用GLSL内建变量gl_FragCoord从片段着色器中直接访问。gl_FragCoord的x和y分量代表了片段的屏幕空间坐标(其中(0, 0)位于左下角)。gl_FragCoord中也包含了一个z分量,它包含了片段真正的深度值。z值就是需要与深度缓冲内容所对比的那个值。

现在大部分的GPU都提供一个叫做提前深度测试(Early Depth Testing)的硬件特性。提前深度测试允许深度测试在片段着色器之前运行。只要我们清楚一个片段永远不会是可见的(它在其他物体之后),我们就能提前丢弃这个片段。
片段着色器通常开销都是很大的,所以我们应该尽可能避免运行它们。当使用提前深度测试时,片段着色器的一个限制是你不能写入片段的深度值。如果一个片段着色器对它的深度值进行了写入,提前深度测试是不可能的。OpenGL不能提前知道深度值。

首先我们需要开启深度测试,默认是关闭的:

glEnable(GL_DEPTH_TEST);

另外还需要在绘制场景前,清除颜色缓冲区时,清除深度缓冲区:

glClearColor(0.18f, 0.04f, 0.14f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

某些情况下你会需要对所有片段都执行深度测试并丢弃相应的片段,但不希望更新深度缓冲(比如简单的丢弃某个深度后的片元)。这时需要只读的(Read-only)深度缓冲。OpenGL允许我们禁用深度缓冲的写入,只需要设置它的深度掩码(Depth Mask)设置为GL_FALSE就可以了:

glDepthMask(GL_FALSE);

Depth test function 深度测试函数

我们可以调用glDepthFunc函数来设置比较运算符(或者说深度函数(Depth Function))来控制OpenGL什么时候该通过或丢弃一个片段,什么时候去更新深度缓冲:

glDepthFunc(GL_LESS);

函数包括一个参数,具体的参数如下表所示:

函数 描述
GL_ALWAYS 永远通过深度测试
GL_NEVER 永远不通过深度测试
GL_LESS 在片段深度值小于缓冲的深度值时通过测试
GL_EQUAL 在片段深度值等于缓冲区的深度值时通过测试
GL_LEQUAL 在片段深度值小于等于缓冲区的深度值时通过测试
GL_GREATER 在片段深度值大于缓冲区的深度值时通过测试
GL_NOTEQUAL 在片段深度值不等于缓冲区的深度值时通过测试
GL_GEQUAL 在片段深度值大于等于缓冲区的深度值时通过测试

默认情况下使用的深度函数是GL_LESS,它将会丢弃深度值大于等于当前深度缓冲值的所有片段。

我们搭建一个场景,源码在这里。我们将深度函数改为GL_ALWAYS

这将会模拟我们没有启用深度测试时所得到的结果。深度测试将会永远通过,所以最后绘制的片段将会总是会渲染在之前绘制片段的上面,效果如下:

without_depth_testing.png

将其重新设置为GL_LESS:

with_depth_testing.png

Depth value precision 深度值精度

深度缓冲包含了一个介于0.0和1.0之间的深度值,它将会与观察者视角所看见的场景中所有物体的z值进行比较。观察空间的z值可能是投影平截头体的近平面(Near)和远平面(Far)之间的任何值。我们需要一种方式来将这些观察空间的z值变换到[0, 1]范围之间。要想有正确的投影性质,需要使用一个非线性的深度方程,它是与 1/z 成正比的。它做的就是在z值很小的时候提供非常高的精度,而在z值很远的时候提供更少的精度。

由于非线性方程与 1/z 成正比,在1.0和2.0之间的z值将会变换至1.0到0.5之间的深度值,这就是一个float提供给我们的一半精度了,这在z值很小的情况下提供了非常大的精度。在50.0和100.0之间的z值将会只占2%的float精度,这正是我们所需要的。这样的一个考虑了远近距离的方程是这样的:

\[\begin{equation} F_{depth} = \frac{1/z - 1/near}{1/far - 1/near} \end{equation}\]

z值和最终的深度缓冲值之间的非线性关系:

depth_non_linear_graph.png

3 Visualizing the depth buffer 可视化深度值

在可视化深度值之前,首先我们要明白,这里的深度值,实际上是屏幕坐标系下的\(z_{win}\)坐标,屏幕坐标系下的(x,y)坐标分别表示屏幕坐标系下以左下角(0,0)为起始点的坐标。\(z_{win}\)我们如何获取呢? 可以通过着色器的输入变量gl_FragCoord.z来获取,这个gl_FragCoord的z坐标表示的就是深度值。如果我们将这个深度值输出为颜色,我们可以显示场景中所有片段的深度值。我们可以根据片段的深度值返回一个颜色向量来完成这一工作:

void main()
{
    FragColor = vec4(vec3(gl_FragCoord.z), 1.0);
}

输出后的效果如下图所示:

depth_visual_linear

可以看到图中,只有离观察者较近的部分有些黑色,其余大部分的都是白色。原因是屏幕空间中的深度值是非线性的,即它在z值很小的时候有很高的精度,而z值很大的时候有较低的精度。片段的深度值会随着距离迅速增加,所以几乎所有的顶点的深度值都是接近于1.0的。

我们也可以让片段非线性的深度值变换为线性的。要实现这个,我们需要仅仅反转深度值的投影变换。这也就意味着我们需要首先将深度值从[0, 1]范围重新变换到[-1, 1]范围的标准化设备坐标(裁剪空间)。接下来我们需要像投影矩阵那样反转这个非线性方程(方程1),并将这个反转的方程应用到最终的深度值上。最终的结果就是一个线性的深度值了。

首先我们将深度值变换为NDC,不是非常困难:

float z = depth * 2.0 - 1.0;

接下来使用获取到的z值,应用逆变换来获取线性的深度值:

float linearDepth = (2.0 * near * far) / (far + near - z * (far - near));

这个方程是用投影矩阵推导得出的,它使用了方程1来非线性化深度值,返回一个near与far之间的深度值。

将屏幕空间中非线性的深度值变换至线性深度值的完整片段着色器如下:

#version 330 core
out vec4 FragColor;

float near = 0.1; 
float far  = 100.0; 

float LinearizeDepth(float depth) 
{
    float z = depth * 2.0 - 1.0; // back to NDC 
    return (2.0 * near * far) / (far + near - z * (far - near));    
}

void main()
{             
    float depth = LinearizeDepth(gl_FragCoord.z) / far; // 为了演示除以 far
    FragColor = vec4(vec3(depth), 1.0);
}

depth_visual

颜色大部分都是黑色,因为深度值的范围是0.1的近平面到100的远平面,它离我们还是非常远的。结果就是,我们相对靠近近平面,所以会得到更低的(更暗的)深度值。

4 Z-fighting 深度冲突

深度冲突是深度缓冲的一个常见问题,当物体在远处时效果会更明显(因为深度缓冲在z值比较大的时候有着更小的精度)。深度冲突不能够被完全避免,但有一些技巧有助于减轻或者完全避免深度冲突。

  • 第一个也是最重要的技巧是永远不要把多个物体摆得太靠近,以至于它们的一些三角形会重叠。
  • 第二个技巧是尽可能将近平面设置远一些。
  • 另外一个很好的技巧是牺牲一些性能,使用更高精度的深度缓冲。比如32位的

5 Reference

「如果这篇文章对你有用,请随意打赏」

Heisenberg Blog

如果这篇文章对你有用,请随意打赏

使用微信扫描二维码完成支付


comments powered by Disqus