帧缓存(Frame buffer)

帧缓存是屏幕所显示画面的一个直接映象,又称为位映射图 (Bit Map) 或光栅。帧缓存的每一存储单元对应屏幕上的一个像素,整个帧缓存对应一帧图像。

图形程序一个重要的目标,就是在屏幕上绘制图像(或者绘制到离屏的一处缓存中)。帧缓存(通常也就是屏幕)是由矩形的像素数组组成的,每个像素都可以在图像对应的点上显示一小块方形的颜色值。经过光栅化阶段,也就是执行片元着色器之后,得到的数据还不是真正的像素,只是候选的片元。每个片元都包含与像素位置对应的坐标数据,以及颜色和深度的存储值。通常来说,像素(x,y)填充的区域是以x为左侧,x+1为右侧,y为底部,而y+1为顶部的一处矩形区域。

一个支持OpenGL渲染的窗口 (即帧缓存) 可能包含以下的组合: 至多4个颜色缓存,一个深度缓存,一个模板缓存,一个积累缓存,一个多重采样缓存。

OpenGL给了我们自己定义帧缓存的自由,我们可以选择性的定义自己的颜色缓冲、深度和模板缓冲。我们目前所做的渲染操作都是是在默认的帧缓冲之上进行的。当你创建了你的窗口的时候默认帧缓冲就被创建和配置好了(GLFW为我们做了这件事)。通过创建我们自己的帧缓冲我们能够获得一种额外的渲染方式。

创建一个帧缓存

我们可以使用一个叫做glGenFramebuffers的函数来创建一个帧缓冲对象(简称FBO):

1
2
GLuint fbo;
glGenFramebuffers(1, &fbo);

这种对象的创建和使用的方式与之前见到的差不多。先创建一个帧缓冲对象,把它绑定到当前帧缓冲,做一些操作,然后解绑帧缓冲。我们使用glBindFramebuffer来绑定帧缓冲:

1
glBindFramebuffer(GL_FRAMEBUFFER, fbo);

绑定到GL_FRAMEBUFFER目标后,接下来所有的读、写帧缓冲的操作都会影响到当前绑定的帧缓冲。也可以使用GL_READ_FRAMEBUFFER或GL_DRAW_FRAMEBUFFER,把帧缓冲分开绑定到读或写目标上。

建构一个完整的帧缓冲必须满足以下条件:

  • 我们必须往里面加入至少一个附件(颜色、深度、模板缓冲)。
  • 其中至少有一个是颜色附件。
  • 所有的附件都应该是已经完全做好的(已经存储在内存之中)。
  • 每个缓冲都应该有同样数目的样本。

我们需要为帧缓冲创建一些附件,还需要把这些附件附加到帧缓冲上。然后使用下面方法检查是否完成:

1
if(glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE)

后续所有渲染操作将渲染到当前绑定的帧缓存的附加缓存中,由于我们的帧缓冲不是默认的帧缓存,渲染命令对窗口的视频输出不会产生任何影响。出于这个原因,它被称为离屏渲染(off-screen rendering),就是渲染到一个另外的缓存中。

如果要使渲染操作对窗口产生影响,要重新绑定0来使默认帧缓冲激活:

1
glBindFramebuffer(GL_FRAMEBUFFER, 0);

当做完所有帧缓冲操作,要删除帧缓冲对象:

1
glDeleteFramebuffers(1, &fbo);

在执行完成检测前,我们先把一个或更多的附件附加到帧缓冲上。一个附件就是一个内存地址,这个内存地址里面包含一个为帧缓冲准备的缓冲,它可以是个图像。当创建一个附件的时候我们有两种方式可以采用:纹理或渲染缓冲(renderbuffer)对象。

纹理附件(Texture attachments)

当把一个纹理附件加到帧缓冲上的时候,所有渲染命令会写入到纹理上,就像它是一个普通的颜色、深度或者模板缓冲一样。使用纹理的好处是,所有渲染操作的结果都会被储存为一个纹理图像,这样我们就可以简单的在着色器中使用了。

为帧缓存创建一个纹理和创建普通纹理差不多:

1
2
3
4
5
6
7
8
GLuint texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);

glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 800, 600, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

主要的区别是我们把纹理的维度设置为屏幕大小(尽管不是必须的),我们还传递NULL作为纹理的data参数。对于这个纹理,我们只分配内存,而不去填充它。纹理填充会在渲染到帧缓冲的时候去做。同样,要注意,我们不用关心环绕方式或者Mipmap,因为在大多数时候都不会需要它们的。

如果你打算把整个屏幕渲染到一个或大或小的纹理上,你需要用新的纹理的尺寸再次调用glViewport(在渲染到你的帧缓冲前),否则只有一小部分纹理或屏幕能够绘制到纹理上。

现在我们已经创建了一个纹理,最后一件要做的事情是把它附加到帧缓冲上:

1
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,GL_TEXTURE_2D, texture, 0);

glFramebufferTexture2D 函数的参数:

  • target:我们所创建的帧缓冲类型的目标(绘制、读取或两者都有)。
  • attachment:我们所附加的附件的类型。现在我们附加的是一个颜色附件。需要注意,最后的那个0是暗示我们可以附加1个以上颜色的附件。
  • textarget:你希望附加的纹理类型。
  • texture:附加的实际纹理。
  • level:Mipmap level。我们设置为0。

除颜色附件以外,我们还可以附加一个深度和一个模板纹理到帧缓冲对象上。若要附加深度缓冲类型,使用GL_DEPTH_ATTACHMENT设置附件类型。若要附加模板缓冲,要使用 GL_STENCIL_ATTACHMENT设置附加类型,同时把glTexImage2D中纹理格式指定为 GL_STENCIL_INDEX。

也可以同时附加一个深度缓冲和一个模板缓冲为一个单独的纹理。这样纹理的每32位数值就包含了24位的深度信息和8位的模板信息。可以使用GL_DEPTH_STENCIL_ATTACHMENT类型设置,下面是一个附加了深度和模板缓冲为单一纹理的例子:

1
2
glTexImage2D( GL_TEXTURE_2D, 0, GL_DEPTH24_STENCIL8, 800, 600, 0, GL_DEPTH_STENCIL, GL_UNSIGNED_INT_24_8, NULL );
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, texture, 0);

渲染缓冲对象附件(Renderbuffer object attachments)

帧缓存的附件方式除了纹理,还有渲染缓冲对象(Renderbuffer objects)。和纹理一样,渲染缓冲对象也是一个缓冲,它可以是一堆字节、整数、像素或者其他东西。渲染缓冲对象的一大优点是,它以OpenGL原生渲染格式储存它的数据,因此在离屏渲染到帧缓冲的时候,这些数据就相当于被优化过的了,在写入或把它们的数据简单地到其他缓冲的时候非常快。

渲染缓冲对象将所有渲染数据直接储存到它们的缓冲里,而不会进行针对特定纹理格式的任何转换,这样它们就成了一种快速可写的存储介质了。然而,渲染缓冲对象通常是只写的,不能修改它们(就像获取纹理,不能写入纹理一样)。可以用glReadPixels函数去读取,函数返回一个当前绑定的帧缓冲的特定像素区域,而不是直接返回附件本身。

创建一个渲染缓冲对象和创建帧缓冲代码差不多:

1
2
GLuint rbo;
glGenRenderbuffers(1, &rbo);

相似地,把渲染缓冲对象绑定,这样所有后续渲染缓冲操作都会影响到当前的渲染缓冲对象:

1
glBindRenderbuffer(GL_RENDERBUFFER, rbo);

由于渲染缓冲对象通常是只写的,它们经常作为深度和模板附件来使用。因为我们需要把深度值和模板值提供给测试,但不需要对这些值采样,所以深度和模板缓冲对象是完全符合的。当我们不去从这些缓冲中采样的时候,渲染缓冲对象通常很合适,因为它们等于是被优化过的。

调用glRenderbufferStorage函数可以创建一个深度和模板渲染缓冲对象,我们选择GL_DEPTH24_STENCIL8作为内部格式:

1
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, 800, 600);

最后一件还要做的事情是把帧缓冲对象附加上:

1
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);

比较

在帧缓冲项目中,渲染缓冲对象可以提供一些优化,但更重要的是知道何时使用渲染缓冲对象,何时使用纹理。通常的规则是,如果你永远都不需要从特定的缓冲中进行采样,渲染缓冲对象对特定缓冲是更明智的选择。如果哪天需要从比如颜色或深度值这样的特定缓冲采样数据的话,你最好还是使用纹理附件。从执行效率角度考虑,它不会对效率有太大影响。

渲染到纹理

现在我们知道了一些帧缓冲工作原理,开始尝试使用它们。我们把场景渲染到一个颜色纹理上,这个纹理附加到一个我们创建的帧缓冲上,然后把这个纹理绘制到一个铺满屏幕的四边形上。输出的图像看似和没用帧缓冲一样,但其实是直接打印到了一个单独的四边形上面。为什么这很有用呢?下一部分我们会看到原因。

我们先来创建帧缓存,具体思路是: 先创建帧缓存,然后创建一个颜色纹理附件用于绘制,再创建一个深度和模板 渲染缓存对象用于深度测试(本例先不使用模板测试),还要把它们都附加到帧缓存上。

第一件要做的事情是创建一个帧缓冲对象,并绑定它,这比较明了:

1
2
3
GLuint framebuffer;
glGenFramebuffers(1, &framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);

下一步我们创建一个纹理图像,这是我们将要附加到帧缓冲的颜色附件。我们把纹理的尺寸设置为窗口的宽度和高度,并保持数据未初始化:

1
2
3
4
5
6
7
8
9
10
11
// Generate texture
GLuint texColorBuffer;
glGenTextures(1, &texColorBuffer);
glBindTexture(GL_TEXTURE_2D, texColorBuffer);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 800, 600, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR );
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glBindTexture(GL_TEXTURE_2D, 0);

// 把颜色纹理附加到帧缓存上
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texColorBuffer, 0);

接下来创建一个渲染缓冲对象来进行深度测试和模板测试。记住,当你不打算从指定缓冲采样的的时候,渲染缓冲对象是不错的选择。我们把它设置为GL_DEPTH24_STENCIL8,对于我们的目的来说这个精确度已经足够了。

1
2
3
4
5
6
7
8
GLuint rbo;
glGenRenderbuffers(1, &rbo);
glBindRenderbuffer(GL_RENDERBUFFER, rbo);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, 800, 600);  
glBindRenderbuffer(GL_RENDERBUFFER, 0);

// 把渲染缓冲对象附加到 帧缓冲
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);

然后我们检查帧缓冲是否完成了:

1
2
3
4
5
if(glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE){
    cout << "ERROR::FRAMEBUFFER:: Framebuffer is not complete!" << endl;
}
// 解绑帧缓冲,确保不会意外渲染到错误的帧缓冲上。
glBindFramebuffer(GL_FRAMEBUFFER, 0);

现在完成帧缓存了,要做的就是渲染到帧缓存上。具体流程如下:

  1. 绑定我们创建的帧缓存来激活它,开启深度测试。
  2. 绘制场景内容。此时都绘制到了帧缓存的纹理上。
  3. 再绑定默认的帧缓存来激活它。
  4. 使用上面的纹理来绘制四边形,同时关闭深度测试(因为绘制一个四边形不需要深度测试)。

最后,我们能看到场景内容,结果和之前不使用自定义的帧缓存是一样的。然而这有什么好处呢?那就是场景中的任何像素已经被当作一个纹理图像了,我们可以在片段着色器中对其创建一些有意思的效果。所有这些有意思的效果统称为后处理特效。


参考 LearnOpenGL-CN 帧缓冲

Face culling:

对于一个3D立方体,从任何一个方向去看它,你会发现最多能同时看到的面是3个。所以我们为何还要去绘制那三个不会显示出来的面呢。如果我们可以以某种方式丢弃它们,我们会提高片段着色器超过50%的性能! 我们说的是超过50%而不是50%,是因为有时从一个角度只有2个或1个面能够被看到。这种情况下我们就能够提高50%以上性能了。

这的确是个好主意,但是有个问题需要解决:我们如何知道某个面在观察者的视野中不会出现呢?如果我们去想象任何封闭的几何平面,它们都有两面,一面面向用户,另一面背对用户。假如我们只渲染面向观察者的面会怎样?

这正是面剔除(Face culling)所要做的。OpenGL允许检查所有正面朝向(Front facing)观察者的面,并渲染它们,而丢弃所有背面朝向(Back facing)的面,这样就节约了我们很多片段着色器的命令(它们很昂贵!)。我们必须告诉OpenGL我们使用的哪个面是正面,哪个面是反面。OpenGL使用一种聪明的手段解决这个问题——分析顶点数据的连接顺序(Winding order)。

顶点连接顺序(Winding order):

当我们定义一系列的三角顶点时,我们会把它们定义为一个特定的连接顺序,它们可能是顺时针的或逆时针的。每个三角形由3个顶点组成,我们从三角形的中间去看,从而把这三个顶点指定一个连接顺序。

正如你所看到的那样,我们先定义了顶点1,接着我们定义顶点2或3,这个不同的选择决定了这个三角形的连接顺序。下面的代码展示出这点:

1
2
3
4
5
6
7
8
9
10
GLfloat vertices[] = {
    //顺时针
    vertices[0], // vertex 1
    vertices[1], // vertex 2
    vertices[2], // vertex 3
    // 逆时针
    vertices[0], // vertex 1
    vertices[2], // vertex 3
    vertices[1] // vertex 2
};

每三个顶点都形成了一个包含着连接顺序的基本三角形。OpenGL使用这个信息在渲染基本图形的时候决定这个三角形是正面还是背面。默认情况下,逆时针的顶点连接顺序被定义为三角形的正面。

所以如果你要定义一个能够看到的三角形,就应该是逆时针的。实际计算的顶点连接顺序是在光栅化阶段(Rasterization stage)进行的,所以当顶点着色器已经运行后,顶点就能够在观察者的观察点被看到。

我们指定了它们以后,观察者面对的所有的三角形的顶点的连接顺序都是正确的,但是现在渲染的立方体另一面的三角形的顶点的连接顺序被反转。最终,我们所面对的三角形被视为正面朝向的三角形,后部的三角形被视为背面朝向的三角形。下图展示了这个效果:

在顶点数据中,我们定义的是两个逆时针顺序的三角形。然而,从观察者的方面看,后面的三角形是顺时针的,如果我们仍以1、2、3的顺序以观察者当面的视野看的话。即使我们以逆时针顺序定义后面的三角形,它现在还是变为顺时针。它正是我们打算剔除(丢弃)的不可见的面。

面剔除:

现在我们知道了如何设置顶点的连接顺序,可以开始使用OpenGL默认关闭的面剔除选项了。 我们要重新定义立方体的顶点数据,以使它反应为一个逆时针链接顺序。把所有三角的顶点都定义为逆时针是一个很好的习惯。

开启OpenGL的GL_CULL_FACE选项就能开启面剔除功能:

1
glEnable(GL_CULL_FACE);

从这儿以后,所有不是正面朝向的面都会被丢弃。目前,在渲染片段上我们节约了超过50%的性能,但记住这只对像立方体这样的封闭形状有效。当我们绘制如草这样的平板时,我们必须关闭面剔除,这是因为它的前、后面都必须是可见的。

OpenGL允许我们改变剔除面的类型。要是我们剔除正面而不是背面会怎样?我们可以调用glCullFace来做这件事:

1
glCullFace(GL_BACK);

glCullFace函数有三个可用的选项:

  • GL_BACK:只剔除背面。
  • GL_FRONT:只剔除正面。
  • GL_FRONT_AND_BACK:剔除背面和正面。

glCullFace的初始值是GL_BACK。另外,我们还可以告诉OpenGL使用顺时针而不是逆时针来表示正面,这通过glFrontFace来设置:

1
glFrontFace(GL_CCW);

默认值是GL_CCW,它代表逆时针,GL_CW代表顺时针顺序。


参考 LearnOpenGL-CN 面剔除

透明

如果一个输入的片元通过了所有相关的片元测试,那么它就可以与颜色缓存中的内容以某种方式进行合并了。最简单的,也是默认的方式,就是直接覆盖已有的值,实际上这样不能称作是合并。除此之外,我们也可以将帧缓存中已有的颜色与输入的片元颜色进行混合(blending)。

大多数情况下,混合是与片元的alpha值直接相关的,不过这也并不是一个硬性的要求。alpha是颜色的第四个分量,OpenGL中的所有颜色都会带有alpha值(无论你是否显式地设置了它)。它是透明度的一种度量方式,我们可以用它来实现各种半透明物体的模拟。

对物体来说透明并不是纯色而是混合色,因为这种颜色来自于不同浓度的自身颜色和它后面的物体颜色。如果需要在OpenGL中使用alpha值,那么管线就需要得到更多有关当前图元颜色(也就是片元着色器输出的颜色值)的信息。

透明物体可以是完全透明(它使颜色完全穿透)或者半透明的(它使颜色穿透的同时也显示自身颜色)。我们之前所使用的纹理都是由RGB这3个元素组成的,但是有些纹理同样有一个内嵌的alpha通道,它为每个纹理像素(Texel)包含着一个alpha值。这个alpha值告诉我们纹理的哪个部分有透明度,以及这个透明度有多少。

忽略片段

有些图像并不关心半透明度,但也想基于纹理的颜色值显示一部分。例如,创建草只需要把一个草的纹理贴到2D四边形上,然后把这个四边形放置到你的场景中。 对于这样一种alpha值只有0和1的图片,我们要忽略纹理透明部分的像素,不必将这些片段储存到颜色缓冲中。 我们可以在片段着色器中这样设置:

1
if(texColor.a < 0.1)  discard;

我们检查被采样纹理颜色是否包含一个低于0.1的alpha值,如果有,就丢弃这个片段。

混合

上述丢弃片段的方式,不能使我们渲染半透明图像,我们要么渲染出像素,要么完全地丢弃它。为了渲染出不同的透明度级别,我们需要开启混合(Blending)。像大多数OpenGL的功能一样,我们可以开启GL_BLEND来启用混合功能: glEnable(GL_BLEND);

开启混合后,我们还需要告诉OpenGL它该如何混合。 OpenGL以下面的方程进行混合:

  • C¯source:源颜色向量。这是来自纹理的本来的颜色向量。
  • C¯destination:目标颜色向量。这是储存在颜色缓冲中当前位置的颜色向量。
  • Fsource:源参数。设置了对源颜色的alpha值影响。
  • Fdestination:目标参数。设置了对目标颜色的alpha影响。

片段着色器运行完成并且所有的测试都通过以后,混合方程才能自由执行片段的颜色输出,当前它在颜色缓冲中(前面片段的颜色在当前片段之前储存)。源和目标颜色会自动被OpenGL设置,而源和目标参数可以让我们自由设置。我们来看一个简单的例子:

我们有两个方块,我们希望在红色方块上绘制绿色方块。红色方块会成为源颜色(它会先进入颜色缓冲),我们将在红色方块上绘制绿色方块。

那么怎样来设置参数呢?我们把Fsource设置为源颜色向量的alpha值:0.6。接着,让目标方块的浓度等于剩下的alpha值。方程将变成:

最终方块结合部分包含了60%的绿色和40%的红色。最后的颜色被储存到颜色缓冲中,取代先前的颜色。

使用glBlendFunc的函数可以设置参数。

1
void glBlendFunc(GLenum sfactor, GLenum dfactor)

两个参数,来设置源(source)和目标(destination)参数。 glBlendFunc 参数

上面的例子,我们打算把源颜色的alpha给源因子,1-alpha给目标因子。调整到glBlendFunc之后就像这样:

1
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

也可以使用glBlendFuncSeparate函数,为RGB和alpha通道各自设置不同的选项:

1
glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE,  GL_ZERO);

现在,源和目标元素已经相加了。如果我们愿意的话,我们还可以把它们相减。 void glBlendEquation(GLenum mode)允许我们设置这个操作,有3种可行的选项:

  • GL_FUNC_ADD:默认的,彼此元素相加:C¯result = Src + Dst.
  • GL_FUNC_SUBTRACT:彼此元素相减: C¯result = Src – Dst.
  • GL_FUNC_REVERSE_SUBTRACT:彼此元素相减,但顺序相反:C¯result = Dst – Src.

通常我们可以省略glBlendEquation因为GL_FUNC_ADD在大多数时候就是我们想要的。

渲染半透明纹理

此时我们只要开启深度测试,并设置合适的混合方程:

1
2
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

在绘制一系列半透明窗子时,出现了问题,前面的窗子透明部分阻塞了后面的。 这是因为深度测试在与混合一同工作时出现了点状况。当写入深度缓冲的时候,深度测试不关心片段是否有透明度,所以透明部分被写入深度缓冲,就和其他值没什么区别。结果是整个四边形的窗子被检查时都忽视了透明度。即便透明部分应该显示出后面的窗子,深度缓冲还是丢弃了它们。

要让混合在多物体上有效,我们必须先绘制最远的物体,最后绘制最近的物体。普通的无混合物体仍然可以使用深度缓冲正常绘制,所以不必给它们排序。我们一定要保证它们在透明物体前绘制好。当无透明度物体和透明物体一起绘制的时候,通常要遵循以下原则:

  1. 先绘制所有不透明物体。
  2. 为所有透明物体排序。
  3. 按顺序绘制透明物体。

一种排序透明物体的方式是,获取一个物体到观察者透视图的距离。这可以通过获取摄像机的位置向量和物体的位置向量来得到。

虽然这种按照距离对物体进行排序的方法在特定的场景中能够良好工作,但它不能进行旋转、缩放或者进行其他的变换,奇怪形状的物体需要一种不同的方式,而不能简单的使用位置向量。

在场景中排序物体是个有难度的技术,它很大程度上取决于你场景的类型,更不必说会耗费额外的处理能力了。完美地渲染带有透明和不透明的物体的场景并不那么容易。有更高级的技术例如次序无关透明度(order independent transparency)。


参考 LearnOpenGL-CN 混合

深度值(Z值):

我们知道,OpenGL的glViewport函数给定了屏幕空间坐标的视区。而我们可以通过片段着色器中内置的gl_FragCoord变量对其访问。gl_FragCoord 的 X 和 y 表示该片段的屏幕空间坐标 ((0,0) 在左下角)。gl_FragCoord 还包含一个 z 坐标,它包含了片段的实际深度值。

所谓深度,就是在openGL坐标系中,像素点Z坐标距离摄像机的距离。摄像机可能放在坐标系的任何位置,那么,就不能简单的说Z数值越大或越小,就是越靠近摄像机。

为何需要深度:

在不使用深度测试的时候,如果我们先绘制一个距离较近的物体,再绘制距离较远的物体,则距离远的物体因为后绘制,会把距离近的物体覆盖掉,这样的效果并不是我们所希望的。而有了深度缓冲以后,绘制物体的顺序就不那么重要了,都能按照远近(Z值)正常显示,这很关键。 实际上,只要存在深度缓冲区,无论是否启用深度测试,OpenGL在像素被绘制时都会尝试将深度数据写入到缓冲区内,除非调用了glDepthMask(GL_FALSE)来禁止写入。这些深度数据除了用于常规的测试外,还可以有一些有趣的用途,比如绘制阴影等等。

深度缓存(Z Buffer):

深度值是存储在深度缓存里面的。深度缓存由窗口系统自动创建并将其深度值存储为 16、24或32位浮点数。在大多数系统中深度缓冲区为24位。深度缓存位数越高,则精确度越高。 深度缓存原理就是把一个距离观察平面(近裁剪面)的深度值(或距离)与窗口中的每个像素相关联。

深度测试(Depth testing):

在绘制时,如果屏幕上当前像素要绘制新的候选颜色,只有对应物体比之前的物体更靠近观察者,我们才能绘制它。 初始状态下,深度缓存的值是一个距视点尽可能远的最大值,而所有物体的深度值都要比这个值更靠近视点。每帧重绘场景时都要清除深度缓存数据。

首先,使用glClear(GL_DEPTH_BUFFER_BIT),把所有像素的深度值设置为最大值(一般是远裁剪面)。 然后,在场景中以任意次序绘制所有物体。硬件或者软件所执行的图形计算把每一个绘制表面转换为窗口上一些像素的集合,此时并不考虑是否被其他物体遮挡。 其次,OpenGL会计算这些表面和观察平面的距离。如果启用了深度缓冲区,在绘制每个像素之前,OpenGL会把它的深度值和已经存储在这个像素的深度值进行比较。新像素深度值小于原先像素深度值,则新像素值会取代原先的;反之,新像素值被遮挡,其颜色值和深度将被丢弃。

深度测试默认是关闭的,要启用深度测试的话,我们需要用GL_DEPTH_TEST选项来打开它:glEnable(GL_DEPTH_TEST)。

在某些情况下我们需要进行深度测试并相应地丢弃片段,但我们不希望更新深度缓冲区,即使用一个只读的深度缓冲区。可以将深度掩码设置为GL_FALSE禁用深度缓冲区写入:glDepthMask(GL_FALSE)。注意这只在深度测试被启用的时候有效。

深度测试函数:

OpenGL 允许我们修改深度测试使用的比较运算符(comparison operators)。这样我们可以控制是通过还是丢弃碎片,以及如何更新深度缓冲区。可以通过深度函数来设置:

1
void glDepthFunc(GLenum func)

该函数接受下列几个比较运算符:

glDepthFunc运算符

默认情况下使用GL_LESS,这将丢弃深度值高于或等于当前深度缓冲区的值的片段。

深度值精度

深度缓冲区中的深度值介于0.0和1.0之间,其值与场景中的所有物体Z值进行比较。这些视图空间中的Z值可以是透视投影平截头体的近平面和远平面之间的任意值。因此我们需要一些方法将这些视图空间Z值转换到[0,1]范围内。

方法之一就是线性转换:

这里far和near是投影矩阵设置可见视图截锥的远近值。方程取frustum内的Z值,并将其转换到[0,1]范围内。下图给出 z 值和其相应的深度值的关系:

然而,实践中是几乎从不使用这样的线性深度缓冲区。正确的投影特性的非线性深度方程是和1/z成正比的 。这样基本上做的是,在Z很近时精度很高,而Z很远的时候精度低。我们并没必要让1000单位远的物体和1单位远的物体有相同的精度,而线性方程没有考虑这一点。 由于非线性函数是和 1/z 成正比,例如1.0 和 2.0 之间的 z 值,将变为 1.0 到 0.5之间, 这样在z非常小的时候给了我们很高的精度。这类方程,也需要视图截锥的远近值,下面给出:

要记住的重要一点是深度缓冲区的值在屏幕空间中不是线性的(它们在视图空间投影矩阵应用之前是线性的)。值为 0.5 在深度缓冲区并不意味着该对象的 z 值是投影平头截体的中间。你可以看到 Z值和深度缓冲区的值在下列图中的非线性关系:

正如你所看到,一个附近的物体的小的Z值因此给了我们很高的深度精度。变换Z值的方程式被嵌入在投影矩阵,所以当我们变换顶点坐标从视图到裁剪然后到屏幕空间,非线性方程将被应用。如果你好奇投影矩阵究竟做了什么,建议阅读这篇文章

深度缓冲区的可视化

我们知道在片段着色器的内置gl_FragCoord向量的z值包含那个片段的深度值。如果我们要把深度值作为颜色输出,那么我们可以在场景中显示的所有片段的深度值。我们可以返回基于片段的深度值的颜色向量: color = vec4(vec3(gl_FragCoord.z), 1.0f);

如果再次运行程序你可能会发现一切都是白的,看起来像我们的深度值都是最大值1.0。这是因为屏幕空间的深度值是非线性的,几乎都接近1.0,如果你小心靠近物体,会发现颜色越来越暗,即z值越来越小。

深度冲突:

有一种常见的视觉现象可能发生,就是当两个平面或三角形对齐的过于紧凑,深度缓冲区没有足够的精度来区分哪个靠前哪个靠后。结果是,这两个形状看起来像不断切换顺序导致奇怪的样子。这被称为深度冲突,因为它看上去像形状争夺顶靠前的位置。

深度冲突是深度缓冲区的常见问题,当对象的距离越远一般越强(因为深度缓冲区在z值非常大的时候没有很高的精度)。

防止深度冲突

首先也是最重要的技巧是让物体之间不要离得太近,以至于他们的三角形重叠。通过在物体之间制造一点用户无法察觉到的偏移,可以完全解决深度冲突。然而这需要人工的干预每个物体,并进行彻底地测试,以确保这个场景的物体之间没有深度冲突。

另一个技巧是尽可能把近平面设置得远一些。前面我们讨论过越靠近近平面的位置精度越高。所以我们移动近平面远离观察者,我们可以在椎体内很有效的提高精度。然而把近平面移动的太远会导致近处的物体被裁剪掉。所以应不断调整测试近平面的值,为你的场景找出最好的近平面的距离。

还有是放弃一些性能来得到更高的深度值的精度。大多数的深度缓冲区都是24位。但现在显卡支持32位深度值,这让深度缓冲区的精度提高了一大节。所以牺牲一些性能你会得到更精确的深度测试,减少深度冲突。


参考 LearnOpenGL-CN 深度测试

模板缓存:

当片段着色器处理完片段之后,模板测试(stencil test) 就开始执行了,它能丢弃一些片段。模板测试基于一个缓冲,叫做模板缓冲(stencil buffer),我们被允许在渲染时更新它来获取有意思的效果。 模板缓冲的用途之一,就是将绘图范围限制在屏幕的特定区域,用来进行复杂的掩模(masking)操作。一个复杂的形状可以存储在模板缓存里,然后绘制子序列操作可以使用模板缓存里的内容来决定是否更新象素。

OpenGL在模板缓冲区中为每个像素保存了一个模板值,当像素需要进行模板测试时,将设定的模板参考值与该像素的模板值进行比较,符合条件的通过测试,不符合条件的则被丢弃。 模板缓冲中的模板值(stencil value)通常是8位的,因此每个片段(像素)共有256种不同的模板值。

注:只有在创建窗口过程中预先请求一个模板缓存区,才能够使用模板测试(如果没有模板缓存,模板测试总是通过的)。如果使用GLFW,它会自动做了这件事。

无论我们在渲染哪里的片段,模板缓冲操作都允许我们把模板缓冲设置为一个特定值。改变模板缓冲的内容实际上就是对模板缓冲进行写入。在同一次(或接下来的)渲染迭代我们可以读取这些值来决定丢弃还是保留这些片段。当使用模板缓冲的时候,你可以随心所欲,但是需要遵守下面的原则:

  • 开启模板缓冲写入。
  • 渲染物体,更新模板缓冲。
  • 关闭模板缓冲写入。
  • 渲染(其他)物体,这次基于模板缓冲内容丢弃特定片段。

使用模板缓冲我们可以基于场景中已经绘制的片段,来决定是否丢弃特定的片段。

启用模板测试:

使用GL_STENCIL_TEST来开启模板测试: glEnable(GL_STENCIL_TEST);

要注意的是,像颜色和深度缓冲一样,在每次循环,你也得清空模板缓冲: glClear(GL_STENCIL_BUFFER_BIT);

同时,glStencilMask允许我们给模板值设置一个位遮罩(bitmask),它与模板值进行按位与(and)决定缓冲是否可写。默认设置的位遮罩都是1,这样就不会影响输出,但是如果我们设置为0x00,所有写入深度缓冲最后都是0,此时模板缓冲不可写,使用的是只读的模板缓存,这与深度缓冲的glDepthMask(GL_FALSE)很类似。

模板函数(stencil functions):

我们有几个不同控制权,决定何时模板测试通过或失败以及它怎样影响模板缓冲。一共有两种函数可供我们使用去配置模板测试:glStencilFunc 和 glStencilOp。

1
void glStencilFunc(GLenum func, GLint ref, GLuint mask)

函数有三个参数:

  • func:设置模板测试操作。这个操作设置模板缓存中的值和ref值如何比较,可用的选项是:GL_NEVER、GL_LEQUAL、GL_GREATER、GL_GEQUAL、GL_EQUAL、GL_NOTEQUAL、GL_ALWAYS。
  • ref:指定模板测试的引用值。模板缓冲的内容会与这个值对比。
  • mask:指定一个遮罩,在模板测试对比引用值和储存的模板值前,对它们进行按位与(and)操作,初始设置为1。

比如:glStencilFunc(GL_EQUAL, 1, 0xFF)。它表示,一个片段模板值等于(GL_EQUAL)引用值1,片段就能通过测试被绘制了,否则就会被丢弃。

但是glStencilFunc只描述了OpenGL对模板缓冲做什么,而不能描述我们如何更新缓冲。这就需要glStencilOp登场了。

1
void glStencilOp(GLenum sfail, GLenum dpfail, GLenum dppass)

函数包含三个选项:

  • sfail: 如果模板测试失败将采取的动作。
  • dpfail: 如果模板测试通过,但是深度测试失败时采取的动作。
  • dppass: 如果深度测试和模板测试都通过,将采取的动作。

每个选项都可以使用下列任何一个动作:

glStencilOp选项

glStencilOp函数默认设置为 (GL_KEEP, GL_KEEP, GL_KEEP) ,所以任何测试的任何结果,模板缓冲都会保留它的值。默认行为不会更新模板缓冲,所以如果你想写入模板缓冲的话,你必须向任意选项指定至少一个不同的动作。

使用glStencilFunc和glStencilOp,我们就可以指定在什么时候以及我们打算怎么样去更新模板缓冲了,我们也可以指定何时让测试通过或不通过。什么时候片段会被抛弃。

绘制物体轮廓

我们展示一个用模板测试实现的一个特别的和有用的功能,叫做物体轮廓(object outlining)。它能够给每个(或一个)物体创建一个有颜色的边,步骤如下:

  1. 清空所有模板缓存值为0。
  2. 在绘制物体前,把模板方程设置为GL_ALWAYS,用1更新物体所有绘制片段的模板缓存。
  3. 渲染物体。
  4. 关闭模板写入和深度测试。
  5. 每个物体放大一点点。
  6. 使用一个不同的片段着色器用来输出一个纯颜色。
  7. 再次绘制物体,但只是当它们的片段的模板值不为1时才进行。
  8. 开启模板写入和深度测试。

这个过程对于每个物体片段都将模板缓存值设为1,当我们绘制边框的时候,我们基本上绘制的是放大版物体的通过测试的地方,这样放大版绘制后物体就会有一个边框。我们基本会使用模板缓冲丢弃所有的不是原来物体的片段的放大的版本内容。

我们需要创建一个基本的片段着色器,它输出一个边框颜色。

然后先用glClear设置所有像素的模板值为0。 接着开启模板测试,设置模板、深度测试通过或失败时才采取动作:

1
2
glEnable(GL_DEPTH_TEST);
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);

如果任何测试失败我们都什么也不做。如果片段的模板测试和深度测试都成功了,我们就将储存着的模板值替换为1,我们要用glStencilFunc来做这件事:

1
2
glStencilFunc(GL_ALWAYS, 1, 0xFF); //所有片段都要写入模板缓冲
glStencilMask(0xFF); // 设置模板缓冲为可写状态

使用GL_ALWAYS模板测试函数,这样片段总会通过测试,在我们绘制它们的地方,模板缓存就会被替换为参考值1。

现在箱子绘制之处,模板缓冲更新为1了,我们将要绘制放大的箱子,但是这次关闭模板缓冲的写入:

1
2
3
glStencilFunc(GL_NOTEQUAL, 1, 0xFF);
glStencilMask(0x00); // 禁止修改模板缓冲
glDisable(GL_DEPTH_TEST);

我们把模板方程设置为GL_NOTEQUAL,它保证我们只绘制模板缓存不等于1的部分,即只绘制之前箱子外围的那部分。注意,我们也要关闭深度测试,这样放大的的箱子也就是边框才不会被地面覆盖。

做完之后还要保证再次开启深度缓冲。


参考 LearnOpenGL-CN 模板测试