LearnOpenGL学习笔记(八) - 测试与混合

本文最后更新于 2024年8月5日 上午

重新开始。

深度测试

深度缓冲(Depth Buffer,or Z-Buffer)用于放置被阻挡的面被渲染到其他面的前面。

在每个Fragment中都存储有Depth Buffer信息,它由程序自动创建,一般情况下是24位的float。

当深度测试(Depth Test)被启用时,OpenGL会把Fragment的深度值和深度缓冲内容进行对比,这个过程被称为深度测试。测试通过时,深度缓冲就会更新为这个片段的深度值,否则这个片段会被剔除。

深度缓冲运行在模板测试后,作用于屏幕空间

gl_FragCoord是GLSL内建变量,它是一个vec3,x和y分量代表了片段的屏幕坐标(左下角为原点),z分量为片段的深度值。

提前深度测试(Early Depth Testing, Early-Z)允许深度测试在Fragment着色器之前运行。只要判断该片段在其他物体之后,便会将他提前剔除。

使用Early-Z的条件是,Fragment Shader里不能有写入深度值的操作。

使用glEnable(GL_DEPTH_TEST)开启深度测试。

开启深度测试后,在每个渲染迭代开始之前还应当使用glClear(GL_DEPTH_BUFFER_BIT)清除深度缓冲。

使用glDepthMask(GL_FALSE)禁用深度缓冲写入,深度缓冲将不会更新,作为只读属性。

深度测试函数

glDepthFunc函数用于控制OpenGL什么时候通过、丢弃片段,以及什么时候更新深度缓冲。它接收一个比较符。

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

默认比较符为GL_LESS

我该如何理解深度缓冲值的更新?

GL_LESS为例:当视口中还未渲染任何东西时,深度缓冲值是无穷大。

当我们渲染了两个方块时,这两个方块的z值必然比无穷大要小,它们通过了深度测试,两个方块占据的片段位置的深度缓冲被更新为新的深度值。

当渲染位于两个方块底下的地板时,对于没被方块遮挡的部分的片段深度值,它们的深度缓冲值依然是无穷大,所以它们能通过深度测试,正常显示。而被方块遮挡的部分,很明显深度值要大于方块的深度值,所以未通过测试,被剔除。

深度值精度

只要一个float表示的是深度,那么它的范围必定是[0.0, 1.0]。深度缓冲存储的float的范围都是如此。

但我们知道,片段的z值可不是这样。为了把z值转换为深度值,我们使用方程:$$\begin{equation} F_{depth} = \frac{z - near}{far - near} \end{equation}$$

其中,nearfar是平截头体的近平面z值和远平面z值。

上面的公式被称为线性深度缓冲(Linear Depth Buffer)。这种方法实际上不是很好,因为对于透视投影的观察者来说,极远处物体的z轴变化是很难观察到的,而近处物体z轴很微小的变化都会很明显。为了体现这点,我们引入非线性深度缓冲方程:\begin{equation} F_{depth} = \frac{1/z - 1/near}{1/far - 1/near} \end{equation}

对于这个方程,z值和最终深度的变化如下图:

img

可以看到,深度值的很大一部分都是由很小的z值决定的。

非线性深度值转换方程被嵌入到了投影矩阵中,在观察空间->裁剪空间的转换过程中被应用。

这意味着,我们使用gl_FragCoord.z得到的值就是非线性深度值。

zview=2farnear(far+near)(farnear)zndcz_{view} = \frac{2 \cdot far \cdot near}{(far + near)-(far - near) \cdot z_{ndc} }

上述方程用于把非线性深度值转换为线性。它是使用投影矩阵推导得出的。其中Zndc是NDC坐标下的z值,由原深度值*2-1变换得到。在shader中可以这么写:

1
2
float z = depth * 2.0 - 1.0;
float linearDepth = (2.0 * near * far) / (far + near - z * (far - near));

深度冲突

深度冲突(Z-fight)指两个片段的深度值非常接近,以至于深度缓冲没有足够的精度来决定该显示哪个片段的情况。深度冲突发生时,可以看到锯齿状的贴图闪烁。

一般我们采用下列方法防止深度冲突:

  • 不要把两个物体摆的太近。
  • 提高近平面的值,从而让整个平截头体的深度缓冲精度提高。代价是近处物体可能会被剔除。

可以这么理解:让非线性转换中,z-深度曲线曲率最大的部分向后移动,从而让z值稍大的部分也能以高精度进行深度测试。

  • 使用高精度深度缓冲。

模板测试

模板测试(Stencil Test)紧接着Fragment Shader处理完一个片段后执行。

模板缓冲类似于一个遮罩。当片元的模板缓冲值为1时,通过测试,否则剔除。

与深度缓冲类似,模板缓冲通过glEnable(GL_STENCIL_TEST)开启,每次渲染循环通过glClear(GL_STENCIL_BUFFER_BIT)清除上帧缓存,通过glStencilMask设置位掩码。

glStencilFunc(GLenum func, GLint ref, GLuint mask)用于告诉程序如何进行模板测试。

func:设置模板缓冲函数。可用的选项有:GL_NEVER、GL_LESS、GL_LEQUAL、GL_GREATER、GL_GEQUAL、GL_EQUAL、GL_NOTEQUAL和GL_ALWAYS。

ref:参考值,之后的模板缓冲将与此值比较。

mask:掩码,一般都是0xFF。

glStencilFunc(GL_EQUAL, 1, 0xFF)为例,这个语句代表:只要模板值等于1,就通过模板测试。

glStencilOp(GLenum sfail, GLenum dpfail, GLenum dppass)用于告诉程序如何更新模板缓冲值。

sfail:模板测试失败时采取的行为。

dpfail:模板测试通过,但深度测试失败的行为。

dppass:全部通过时采取的行为。

行为选项有:

行为 描述
GL_KEEP 保持当前储存的模板值
GL_ZERO 将模板值设置为0
GL_REPLACE 将模板值设置为glStencilFunc函数设置的ref
GL_INCR 如果模板值小于最大值则将模板值加1
GL_INCR_WRAP 与GL_INCR一样,但如果模板值超过了最大值则归零
GL_DECR 如果模板值大于最小值则将模板值减1
GL_DECR_WRAP 与GL_DECR一样,但如果模板值小于0则将其设置为最大值
GL_INVERT 按位翻转当前的模板缓冲值

描边

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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();
DrawFloor()

glStencilFunc(GL_ALWAYS, 1, 0xFF);
glStencilMask(0xFF);
DrawTwoContainers();

glStencilFunc(GL_NOTEQUAL, 1, 0xFF);
glStencilMask(0x00);
glDisable(GL_DEPTH_TEST);
shaderSingleColor.use();
DrawTwoScaledUpContainers();
glStencilMask(0xFF);
glEnable(GL_DEPTH_TEST);

我该如何理解这段代码?

我们使用模板测试来实现描边的效果。

这里用的方法是:在原物体的位置,复制一个物体,将其略微放大一些。这个物体应用的Shader应当是不受光照影响的纯色Shader。

描边就是要让原物体好好地显示出来,而原物体所占据片元以外的地方,允许大物体显示。

首先,我们定义模板测试失败和成功后的结果:glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE); 当模板测试通过时,替换片元模板值为ref值。否则保留原本的模板值。

我们需要先绘制不需要描边的物体。需要注意,绘制这些物体时,应当禁用模板缓冲(可以直接glDisable,也可以glStencilMask(0x00))。

当绘制到原物体时,开启模板缓冲,让原物体占据片元区域的模板值变为1。

glStencilFunc(GL_ALWAYS,1,0XFF)执行后,对于新渲染的片段,模板测试始终通过。

glStencilMask(0xFF)开启模板值写入。

然后绘制原物体。

原物体绘制完毕后,片元的模板值更新完毕。改变模板测试规则:glStencilFunc(GL_NOTEQUAL,1,0XFF),使得片段所处位置的模板值只有不为1时,才通过。

然后绘制大物体。因为原物体片元区域的模板值都是1,所以大物体的模板测试不会通过,这些片元不会被渲染。这就达到了描边的效果。

为什么要禁用深度测试呢?因为描边区域通常不可被障碍遮挡。如果有这个需求,也可以不禁用。

混合

png图片是四通道的,第四通道的值代表透明度(alpha)。

通过在Fragment Shader中对采样的alpha值进行判断并剔除(discard),可以实现“透明的地方不渲染”的效果:

1
2
3
4
5
vec4 col = texture(tex,TexCoord);
if(col.a<0.1f){
discard;
}
FragColor = col;

采用这种方法实现透明显示时,需要把纹理环绕方式设置为GL_CLAMP_TO_EDGE,否则当实际渲染物体的大小超过纹理大小时,底部uv会重复到顶部,导致物体的重复渲染。

采用discard方案的缺点是,无法实现半透明物体的渲染。同时,使用discard以后Early-Z将失效。

为实现半透明物体的渲染,我们引入Blend技术。

glEnable(GL_BLEND)

Blend借助alpha值实现”物体本身“和”后方物体“颜色的混合。让我们举一个具体例子:

img

我们把绿色半透明Quad放在红色不透明Quad前面。绿色Quad的alpha值是0.6,那么当二者叠加时,叠加区域的最终颜色中,绿色Quad对颜色的贡献值就是60%,红色则是(1-60%)=40%。最终颜色就是:

\begin{equation}\bar{C}_{result} = \begin{pmatrix} \color{red}{0.0} \\ \color{green}{1.0} \\ \color{blue}{0.0} \\ \color{purple}{0.6} \end{pmatrix} * \color{green}{0.6} + \begin{pmatrix} \color{red}{1.0} \\ \color{green}{0.0} \\ \color{blue}{0.0} \\ \color{purple}{1.0} \end{pmatrix} * \color{red}{(1 - 0.6)} \end{equation}

其中,0.6被称为源因子值,(1-0.6)被称为目标因子值

glBlendFunc(GLenum sfactor, GLenum dfactor)用于设置源因子和目标因子值。

选项
GL_ZERO 因子等于0
GL_ONE 因子等于1
GL_SRC_COLOR 因子等于源颜色向量C¯source
GL_ONE_MINUS_SRC_COLOR 因子等于1−C¯source1
GL_DST_COLOR 因子等于目标颜色向量C¯destination
GL_ONE_MINUS_DST_COLOR 因子等于1−C¯destination
GL_SRC_ALPHA 因子等于C¯source的alpha分量
GL_ONE_MINUS_SRC_ALPHA 因子等于1−C¯source的alpha分量
GL_DST_ALPHA 因子等于C¯destination的alpha分量
GL_ONE_MINUS_DST_ALPHA 因子等于1− C¯destination的alpha分量
GL_CONSTANT_COLOR 因子等于常数颜色向量C¯constant
GL_ONE_MINUS_CONSTANT_COLOR 因子等于1−C¯constant1
GL_CONSTANT_ALPHA 因子等于C¯constant的alpha分量
GL_ONE_MINUS_CONSTANT_ALPHA 因子等于1− C¯constant的alpha分量

默认混合方式为glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)。C_constant使用glBlendColor函数设置。

glBlendFuncSeparate可以分别对RGBA通道使用不同的混合方式。

glBlendEquation(GLEnum mode)可以改变混合的计算方式:

  • GL_FUNC_ADD:默认选项,将两个分量相加:C¯result=Src+Dst
  • GL_FUNC_SUBTRACT:将两个分量相减: C¯result=Src−Dst
  • GL_FUNC_REVERSE_SUBTRACT:将两个分量相减,但顺序相反:C¯result=Dst−SrcC¯

混合与深度测试结合时,会出现问题。若一个物体深度值大于半透明物体,但在半透明物体后面渲染,深度测试不会管物体是不是半透明的,而是一刀切地把这个物体的片元全部丢弃了。

为了解决这一问题,我们必须把深度值大的物体放在渲染顺序的前面。

一般渲染顺序如下:

  1. 先绘制所有不透明的物体。(因为不透明物体无需混合,无所谓渲染顺序)
  2. 对所有透明的物体排序。
  3. 按顺序绘制所有透明的物体。

可以采用STL map自动排序的方式,管理所有透明物体:

1
2
3
4
5
6
std::map<float, glm::vec3> sorted;
for (unsigned int i = 0; i < windows.size(); i++)
{
float distance = glm::length(camera.Position - windows[i]);
sorted[distance] = windows[i];
}

但这种方法也只是简单地以物体的中心值作为位置顺序。当物体形状很复杂时,这种方法就不太好了,需要手动微调。一种较高级的解决这类问题的技术叫做次序无关透明度(Order Independent Transparency, OIT)。

面剔除

每个封闭形状的面都有正反之分。在OpenGL中,通过三角形片段三个顶点的绘制顺序判断该三角形所在面的正反。默认情况下,逆时针顶点所定义的三角形为正向三角形。

背向观察者的面通常不会被渲染。如果能够取消这些面的渲染,程序速度将会提高约50%。

glEnable(GL_CULL_FACE)用于开启面剔除。

glCullFace(GLEnum mode)用于指定剔除的面。

  • GL_FRONT:剔除正面
  • GL_BACK:剔除背面
  • GL_FRONT_AND_BACK:正反面都剔除

glFrontFace(GLEnum mode)用于指定正向三角形的定义。

  • GL_CCW:逆时针三角形为正
  • GL_CW:顺时针三角形为正。

使用面缓冲时,必须确保顶点数据的定义是”逆时针为正“的顺序。否则会出现渲染错误。


LearnOpenGL学习笔记(八) - 测试与混合
http://example.com/2024/08/05/LearnOpenGL学习笔记(八)-测试与混合/
作者
Yoi
发布于
2024年8月5日
许可协议