深度测试和模板测试


深度测试

现在我们来到了一个非常重要的专题:深度测试,它关乎着渲染过程中所有的遮挡关系,之前在画箱子的时候我们就开启了深度测试,下面来更加深入地学习深度缓冲中的深度值。

深度缓冲存在每一个片段中,当开启了深度测试后glEnable(GL_DEPTH_TEST),在绘制每个片元时就会去进行深度测试,通过则被绘制,否则就被丢弃,他是在片段着色器工作完成之后在屏幕空间运行的。屏幕空间坐标与通过OpenGL的glViewport所定义的视口密切相关,并且可以直接使用gl_FragCoord从片段着色器中直接访问。gl_FragCoord的x和y分量代表了片段的屏幕空间坐标(其中(0, 0)位于左下角),还包含了一个z分量,它包含了片段真正的深度值,z值就是需要与深度缓冲内容所对比的那个值。

如果你启用了深度缓冲,你还应该在每个渲染迭代之前使用GL_DEPTH_BUFFER_BIT来清除深度缓冲,否则你会仍在使用上一次渲染迭代中的写入的深度值glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

还有一个小技巧是当你想要使用深度缓存,但是不想去更新深度值的时候,可以使用glDepthMask(GL_FALSE),去禁止深度缓冲的写入。

深度测试函数

深度测试函数实际上就是告诉OpenGL在做深度测试的时候如何去舍弃片段glDepthFunc(GL_LESS)。默认的是GL_LESS,它会丢弃深度值大于等于当前深度缓冲值的所有片段。

我们将之前的代码重写一遍,顺便巩固一下基础的知识,把LearnOpenGL上的箱子和平面坐标先直接复制上去,然后我们来写顶点着色器,需要两个位置,一个去存顶点一个去存纹理坐标。还需要输出一个纹理坐标。片段着色器就需要得到纹理坐标的输入然后用一个纹理采样器去接收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//顶点着色器
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 aTexCoord;

out vec2 texCoord;

uniform mat4 model;
uniform mat4 projection;
uniform mat4 view;

void main(){
texCoord = aTexCoord;
gl_Position = projection * view * model * vec4(aPos, 1.0f);
}

//片段着色器
in vec2 texCoord;

out vec4 fragColor;

uniform sampler2D texture1;

void main(){
fragColor = texture(texture1, texCoord);
}

在main函数里去编译这两个着色器,然后设置VBO、VAO去画出箱子和平面。对于VBO是一个Buffer,我们需要进行三部曲:创建、生成、绑定,赋予数据,对于VAO,我们只需要创建、生成、绑定,然后用glDrawArray去画就行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//分别拿到箱子和平面的坐标并且画出来
unsigned int cubeVAO, cubeVBO;
glGenBuffers(1, &cubeVBO);
glGenVertexArrays(1, &cubeVAO);
glBindBuffer(GL_ARRAY_BUFFER, cubeVBO);
glBindVertexArray(cubeVAO);
glBufferData(GL_ARRAY_BUFFER, sizeof(cubeVertices), &cubeVertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);

unsigned int planeVAO, planeVBO;
glGenBuffers(1, &planeVBO);
glGenVertexArrays(1, &planeVAO);
glBindBuffer(GL_ARRAY_BUFFER, planeVBO);
glBindVertexArray(planeVAO);
glBufferData(GL_ARRAY_BUFFER, sizeof(planeVertices), &planeVertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * (sizeof(float))));
glEnableVertexAttribArray(1);

至于纹理我们直接把它加载到两个位置,然后再分别在画方块和画地板的时候给纹理采样器绑上不同的纹理就行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//将图片加载到一号位
unsigned int cubeTexture = LoadImageToGPU("marble.jpg", GL_RGB, GL_RGB, 0);
//将图片加载到二号位
unsigned int floorTexture = LoadImageToGPU("floor.png", GL_RGB, GL_RGB, 1);

//给MVP矩阵赋值
glm::mat4 model = glm::mat4(1.0f);
glUniformMatrix4fv(glad_glGetUniformLocation(myShader->ID, "model"), 1, GL_FALSE, glm::value_ptr(model));
glUniformMatrix4fv(glad_glGetUniformLocation(myShader->ID, "view"), 1, GL_FALSE, glm::value_ptr(viewMat));
glUniformMatrix4fv(glad_glGetUniformLocation(myShader->ID, "projection"), 1, GL_FALSE, glm::value_ptr(projMat));

//把一号位的纹理送给纹理采样器
glUniform1i(glGetUniformLocation(myShader->ID, "texture1"), 0);
glBindVertexArray(cubeVAO);//在画之前绑定相应的VAO
glDrawArrays(GL_TRIANGLES, 0, 36);

//把二号位的纹理送给纹理采样器
glUniform1i(glGetUniformLocation(myShader->ID, "texture1"), 1);
glBindVertexArray(planeVAO);//绑定相应的VAO
glDrawArrays(GL_TRIANGLES, 0, 6);

这样就可以画出我们想要的场景,是不是对基础的操作又多了一点理解?

glUniform1i实际上是给片段着色器里的纹理采样器一个我们刚刚加载的纹理的位置,我们可以加载很多纹理到我们指定的位置,然后把某个位置送给纹理采样器,就可以把这个位置上储存的纹理给采样器。所以加载纹理实际上是很简单的,具体看一看之前文章的纹理章节。

现在我们开启深度测试并且设置舍弃的方式为GL_ALWAYS,这样在时间上后绘制的片元永远会把先绘制的片元挡住,所以你会看到地板把箱子遮住:

改为GL_LESS,他就会回到原来的状态。

深度值精度

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

深度缓冲可视化

片段着色器中有一个内建函数:gl_FragCoord,它的Z值包含了输出的片段的深度值,我们直接将这个深度值作为颜色输出,就可以看到场景中所有片段的深度值fragColor = vec4(vec3(gl_FragCoord.z),1.0f)。粗略一看全是白色:

但是当我们靠近时,颜色就会逐渐变深:

这很清楚地展示了之前说过的深度值的非线性性质。近处的物体比起远处的物体对深度值有着更大的影响。只需要移动几厘米就能让颜色从暗完全变白。现在我们来强行把非线性的转化为线性的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#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;
fragColor = vec4(vec3(depth), 1.0);
}

这样一来,你再去移动视角,就会发现深度值的改变和距离是成正比的。

深度冲突(Z-Fighting)

相信你在之前就可能发现一个奇怪的东西,当你进入箱子内部,你会发现箱子的底面和地板的纹理在不停闪烁:

这是因为两个平面或者三角形非常紧密地平行排列在一起时,深度缓冲没有足够的精度来决定两个形状哪个在前面。结果就是这两个形状不断地在切换前后顺序,特别是当你的视角移动时,会产生非常奇怪的视觉效果。

这里箱子被放置在地板的同一高度上,这也就意味着箱子的底面和地板是共面的。这两个面的深度值都是一样的,所以深度测试没有办法决定应该显示哪一个。

深度冲突是深度缓冲的一个常见问题,当物体在远处时效果会更明显(因为深度缓冲在z值比较大的时候有着更小的精度)

这里可以采用多种方法去处理:

代码强行改

我们在两个会有冲突的面先后绘制的时候,使用glPolygonOffset函数去设置偏移量,就可以让这两个面的z有细微的差别,当前一个面绘制完成后使用这个函数,然后在下一个面的绘制时,就会采用这个函数带来的深度偏移。

函数的原型为void APIENTRY glPolygonOffset (GLfloat factor, GLfloat units),

设置后深度偏移量的计算公式是Offset = DZ * factor+r * units,DZ和r是当前系统跟深度测试相关的系数,其中r是两个深度缓冲区间的最小间隔,一般情况下,factor和units都设置为1.0(或-1.0)个单位,基本上是一个比较稳妥的设定。

正数表示当前的深度更深一些,显示的时候会被前景覆盖,设为负数表示深度较浅,会被绘制到屏幕上去。

1
2
3
4
5
6
7
8
9
10
11
12
glUniform1i(glGetUniformLocation(myShader->ID, "texture1"), 0);
glBindVertexArray(cubeVAO);
glDrawArrays(GL_TRIANGLES, 0, 36);

glEnable(GL_POLYGON_OFFSET_FILL);//开启offset的使用
glPolygonOffset(0.1f, 0.0f);//将后面的z变深

glUniform1i(glGetUniformLocation(myShader->ID, "texture1"), 1);
glBindVertexArray(planeVAO);
glDrawArrays(GL_TRIANGLES, 0, 6);

glDisable(GL_POLYGON_OFFSET_FILL);//绘制完后要马上关掉

这样就可以解决掉Z-Fighting:

控制坐标

不要把多个物体摆得太靠近,就可以避免它们的一些三角形会重叠。我们将位置设置一点看不出来的偏移量,比如让箱子往上画0.01f,就可以解决这个问题。

更高精度的深度缓冲

大部分深度缓冲的精度都是24位的,但现在大部分的显卡都支持32位的深度缓冲,这将会极大地提高精度。所以,牺牲掉一些性能,你就能获得更高精度的深度测试,减少深度冲突。

这些就是常用的解决方法,后面会深入去了解一下工业界或者在引擎实际开发中是用的什么方法去解决这个Z-Fighting。


模板测试

模板测试(Stencil Test)和深度测试一样,它也可能会丢弃片段。接下来,被保留的片段会进入深度测试,它可能会丢弃更多的片段。

一个模板缓冲中,每个模板值(Stencil Value)是8位的。所以每个像素/片段一共能有256种不同的模板值。模板缓冲操作允许我们在渲染片段时将模板缓冲设定为一个特定的值。通过在渲染时修改模板缓冲的内容,我们写入了模板缓冲。在同一个(或者接下来的)渲染迭代中,我们可以读取这些值,来决定丢弃还是保留某个片段。使用模板缓冲的的步骤如下:

  • 启用模板缓冲的写入。
  • 渲染物体,更新模板缓冲的内容。
  • 禁用模板缓冲的写入。
  • 渲染(其它)物体,这次根据模板缓冲的内容丢弃特定的片段。

所以,通过使用模板缓冲,我们可以根据场景中已绘制的其它物体的片段,来决定是否丢弃特定的片段。

模板函数

和深度测试一样,我们也可以通过使用glStencilFunc和glStencilOp这两个函数去指定什么时候该让模板缓冲通过,即什么时候片段需要被丢弃。

glStencilFunc(GLenum func, GLint ref, GLuint mask)一共包含三个参数,它指定了如何让接下来的片段进行模板缓冲:

  • func:设置模板测试函数(Stencil Test Function)。这个测试函数将会应用到已储存的模板值上和glStencilFunc函数的ref值上。可用的选项有:GL_NEVER、GL_LESS、GL_LEQUAL、GL_GREATER、GL_GEQUAL、GL_EQUAL、GL_NOTEQUAL和GL_ALWAYS。它们的语义和深度缓冲的函数类似。
  • ref:设置了模板测试的参考值(Reference Value)。模板缓冲的内容将会与这个值进行比较。这个数值完全由你自己设置。
  • mask:设置一个掩码,它将会与参考值和储存的模板值在测试比较它们之前进行与(AND)运算。初始情况下所有位都为1。

比如glStencilFunc(GL_EQUAL, 1, 0xFF),它的参数会告诉OpenGL,只要一个片段的模板值等于(GL_EQUAL)参考值1,片段将会通过测试并被绘制,否则会被丢弃。

但是glStencilFunc仅仅描述了OpenGL应该对模板缓冲内容做什么,而不是我们应该如何更新缓冲。这就需要glStencilOp这个函数。

glStencilOp(GLenum sfail, GLenum dpfail, GLenum dppass)一共包含三个选项,我们能够设定每个选项应该采取的行为:

  • sfail:模板测试失败时采取的行为。
  • dpfail:模板测试通过,但深度测试失败时采取的行为。
  • dppass:模板测试和深度测试都通过时采取的行为。

具体有哪些行为可以去看官方文档,但是有一些是很难用到的,默认情况下glStencilOp是设置为(GL_KEEP, GL_KEEP, GL_KEEP),所以不论任何测试的结果是如何,模板缓冲都会保留它的值。默认的行为不会更新模板缓冲,所以如果你想写入模板缓冲的话,你需要至少对其中一个选项设置不同的值。这个一般在模板测试的开始指定一次就可以了。

你还可以通过mask去设置是否对下面绘制的物体使用写入:

glStencilMask(0xFF); // 每一位写入模板缓冲时都保持原样

glStencilMask(0x00); // 每一位在写入模板缓冲时都会变成0(禁用写入)

实战:物体轮廓

为了使用一下模板测试这个知识,我们来实战为箱子做一个轮廓效果。想一想你在打一些经营类小游戏的时候,当你选到某一个物体,它的轮廓会加深,提醒玩家选到了这个物体。

为物体创建轮廓的步骤如下:

  1. 在绘制(需要添加轮廓的)物体之前,将模板函数设置为GL_ALWAYS,每当物体的片段被渲染时,将模板缓冲更新为1。
  2. 渲染物体。
  3. 禁用模板写入以及深度测试。
  4. 将每个物体缩放一点点。
  5. 使用一个不同的片段着色器,输出一个单独的(边框)颜色。
  6. 再次绘制物体,但只在它们片段的模板值不等于1时才绘制。
  7. 再次启用模板写入和深度测试。

主要的做法就是一开始我们正常绘制物体,把不想加边框的物体的模板值写入关掉,把模板值写入打开之后再画想要加边框的物体,这时我们启动模板缓冲写入,把所有要绘制的片段的模板值更新为1。然后我们再绘制放大了一点的箱子,同时把模板缓冲写入禁用,然后就只画模板缓冲值不为1的部分就可以画出边框了。

所以这个画边框算法的框架看起来就像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
glEnable(GL_DEPTH_TEST);
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);//如果其中的一个测试失败了,我们什么都不做,我们仅仅保留当前储存在模板缓冲中的值。如果模板测试和深度测试都通过了,那么我们希望将储存的模板值设置为参考值,参考值能够通过

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); //清除上一次的缓存

glStencilMask(0x00); // 保证我们在绘制我们不想加边框的时候不会更新模板缓冲,禁止模板写入
normalShader.use();
DrawSomething_WeDontNeedOutline();

glStencilFunc(GL_ALWAYS, 1, 0xFF); //所有片段总是更新缓冲为1,所有片段全部通过并且被绘制,这里不是1也可以,是你自己设置的模板值
glStencilMask(0xFF); //启动模板写入
DrawSomthing_WeNeedOutline();

//绘制大一点的物体
glStencilFunc(GL_NOTEQUAL, 1, 0xFF);//等于1的所有片段都被丢弃
glStencilMask(0x00); //关闭缓冲写入
glDisable(GL_DEPTH_TEST);
shaderDrawOutline.use();
DrawSomthing_WeNeedOutline();
glStencilMask(0xFF);
glEnable(GL_DEPTH_TEST);

代码如下:

1
2
3
4
5
6
7
8
//只画边框的片段着色器
#version 330 core
out vec4 FragColor;

void main()
{
FragColor = vec4(0.04, 0.28, 0.26, 1.0);
}

main:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
Shader* myShader = new Shader("vertexSource-Depth.vert", "fragmentSource-Depth.frag");
Shader* drawOutline = new Shader("vertexSource-Depth.vert", "fragmentSource-Stencil.frag");//画边框的Shader

//初始设置
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LESS);
glEnable(GL_STENCIL_TEST);
glStencilFunc(GL_NOTEQUAL, 1, 0xFF);
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);//提前定义好如何更新模板缓存

//边框绘制
glm::mat4 model = glm::mat4(1.0f);
viewMat = camera.GetViewMatrix();
projMat = glm::perspective(glm::radians(45.0f), 800.0f / 600.0f, 0.1f, 100.0f);

//设置相关Uniform
drawOutline->use();
glUniformMatrix4fv(glad_glGetUniformLocation(drawOutline->ID, "view"), 1, GL_FALSE, glm::value_ptr(viewMat));
glUniformMatrix4fv(glad_glGetUniformLocation(drawOutline->ID, "projection"), 1, GL_FALSE, glm::value_ptr(projMat));

myShader->use();
glUniformMatrix4fv(glad_glGetUniformLocation(myShader->ID, "view"), 1, GL_FALSE, glm::value_ptr(viewMat));
glUniformMatrix4fv(glad_glGetUniformLocation(myShader->ID, "projection"), 1, GL_FALSE, glm::value_ptr(projMat));

glStencilMask(0x00);
glUniform1i(glGetUniformLocation(myShader->ID, "texture1"), 1);
glBindVertexArray(planeVAO);
glUniformMatrix4fv(glad_glGetUniformLocation(myShader->ID, "model"), 1, GL_FALSE, glm::value_ptr(model));
glDrawArrays(GL_TRIANGLES, 0, 6);

glStencilFunc(GL_ALWAYS, 1, 0xFF);
glStencilMask(0xFF);
glUniform1i(glGetUniformLocation(myShader->ID, "texture1"), 0);
model = glm::translate(model, glm::vec3(-1.0f, 0.0f, -1.0f));
glUniformMatrix4fv(glad_glGetUniformLocation(myShader->ID, "model"), 1, GL_FALSE, glm::value_ptr(model));
glBindVertexArray(cubeVAO);
glDrawArrays(GL_TRIANGLES, 0, 36);
model = glm::mat4(1.0f);
model = glm::translate(model, glm::vec3(2.0f, 0.0f, 0.0f));
glUniformMatrix4fv(glad_glGetUniformLocation(myShader->ID, "model"), 1, GL_FALSE, glm::value_ptr(model));
glDrawArrays(GL_TRIANGLES, 0, 36);

glStencilFunc(GL_NOTEQUAL, 1, 0xFF);
glStencilMask(0x00);
glDisable(GL_DEPTH_TEST);
drawOutline->use();
float scale = 1.1;

glBindVertexArray(cubeVAO);
glUniform1i(glGetUniformLocation(myShader->ID, "texture1"), 0);
model = glm::mat4(1.0f);
model = glm::translate(model, glm::vec3(-1.0f, 0.0f, -1.0f));
model = glm::scale(model, glm::vec3(scale, scale, scale));
glUniformMatrix4fv(glad_glGetUniformLocation(drawOutline->ID, "model"), 1, GL_FALSE, glm::value_ptr(model));
glDrawArrays(GL_TRIANGLES, 0, 36);

model = glm::mat4(1.0f);
model = glm::translate(model, glm::vec3(2.0f, 0.0f, 0.0f));
model = glm::scale(model, glm::vec3(scale, scale, scale));
glUniformMatrix4fv(glad_glGetUniformLocation(drawOutline->ID, "model"), 1, GL_FALSE, glm::value_ptr(model));
glDrawArrays(GL_TRIANGLES, 0, 36);
glStencilMask(0xFF);
glStencilFunc(GL_ALWAYS, 0, 0xFF);
glEnable(GL_DEPTH_TEST);

这样就可以得到画了边框的物体:


仔细想一想这个东西比深度测试更好玩,它可以用来实现物体边缘一些好玩的效果,比如发光、边框等等。它还可以在一个后视镜中绘制纹理,让它能够绘制到镜子形状中,或者使用一个叫做阴影体积(Shadow Volume)的模板缓冲技术渲染实时阴影。后面我在练习的时候会都做一做康康。


Author: cdc
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source cdc !
  TOC