OpenGL学习笔记25-Frame buffers

Frame buffers 帧缓冲器

Advanced-OpenGL/Framebuffers

到目前为止,我们已经使用了几种类型的屏幕缓冲区:用于写入颜色值的颜色缓冲区,用于写入和测试深度信息的深度缓冲区,以及允许我们根据某些条件丢弃某些片段的模板缓冲区。这些缓冲区的组合存储在GPU内存的某处,被称为framebuffer。OpenGL让我们可以灵活地定义自己的framebuffer,从而定义自己的颜色(以及可选的深度和模板)缓冲区。

到目前为止,我们所做的渲染操作都是在附加到默认framebuffer的渲染缓冲区之上完成的。当您创建窗口时,会创建并配置默认的framebuffer (GLFW为我们完成此工作)。通过创建我们自己的framebuffer,我们可以获得一个额外的渲染目标。

framebuffer的应用可能不会立即有意义,但是将场景渲染到不同的framebuffer允许我们使用该结果在场景中创建镜像,或者做一些很酷的后期处理效果。首先我们将讨论它们是如何工作的,然后我们将使用它们来实现那些很酷的后期处理效果。

Creating a framebuffer

就像OpenGL中的其他对象一样,我们可以通过使用一个名为glGenFramebuffers的函数来创建一个framebuffer对象(缩写为FBO):


unsigned int fbo;
glGenFramebuffers(1, &fbo);

这种对象创建和使用模式我们现在已经见过很多次了,因此它们的使用函数与我们见过的所有其他对象类似:首先我们创建一个framebuffer对象,将其绑定为活动的framebuffer,执行一些操作,然后解除对framebuffer的绑定。为了绑定framebuffer,我们使用glBindFramebuffer:


glBindFramebuffer(GL_FRAMEBUFFER, fbo);  

通过绑定到GL_FRAMEBUFFER目标,所有下一个读和写framebuffer操作都将影响当前绑定的framebuffer。还可以通过分别绑定到GL_READ_FRAMEBUFFER或GL_DRAW_FRAMEBUFFER来将framebuffer绑定到读或写目标。然后,绑定到GL_READ_FRAMEBUFFER的framebuffer用于所有读取操作,比如glReadPixels,绑定到GL_DRAW_FRAMEBUFFER的framebuffer用作呈现、清除和其他写入操作的目标。大多数时候,您不需要区分这种情况,通常使用GL_FRAMEBUFFER绑定到这两者。

不幸的是,我们还不能使用framebuffer,因为它还没有完成。要使framebuffer完整,必须满足以下要求:

  • 我们必须附加至少一个缓冲区(颜色,深度或模具缓冲区)。
  • 应该至少有一个颜色附件。
  • 所有的附件也应该是完整的(保留内存)。
  • 每个缓冲液应该有相同数量的样本。

如果你不知道样本是什么,不用担心,我们会在后面的章节中讲到。

从需求可以清楚地看出,我们需要为framebuffer创建某种附件,并将此附件附加到framebuffer。在我们完成所有需求之后,我们可以通过使用GL_FRAMEBUFFER调用glCheckFramebufferStatus来检查是否成功完成了framebuffer。然后,它检查当前绑定的framebuffer,并返回在规范中找到的任何这些值。如果它返回GL_FRAMEBUFFER_COMPLETE,我们就可以这样做:


if(glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE)
  // execute victory dance

所有后续的呈现操作现在都将呈现给当前绑定的framebuffer的附件。因为我们的framebuffer不是默认的framebuffer,所以呈现命令不会对窗口的可视化输出产生影响。因此,在渲染到不同的framebuffer时,它被称为off-screen渲染。如果你想让所有的渲染操作在主窗口上再次产生视觉效果,我们需要通过绑定到0来激活默认的framebuffer:


glBindFramebuffer(GL_FRAMEBUFFER, 0);   

当我们完成所有framebuffer操作时,不要忘记删除framebuffer对象:


glDeleteFramebuffers(1, &fbo);  

现在,在执行完整性检查之前,我们需要将一个或多个附件附加到framebuffer。附件是一个内存位置,可以作为framebuffer的缓冲区,可以将它看作一个映像。当创建一个附件,我们有两个选择采取:纹理或renderbuffer对象

Texture attachments

当将纹理附加到framebuffer时,所有渲染命令都将写入纹理,就像它是一个普通的颜色/深度或模板缓冲区一样。使用纹理的好处是渲染输出存储在纹理图像中,这样我们就可以很容易地在着色器中使用。

为framebuffer创建纹理与创建普通纹理大致相同:


unsigned int 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作为纹理的数据参数。对于这个纹理,我们只分配内存而不是填充它。当我们渲染到framebuffer时填充纹理就会发生。还要注意,我们并不关心任何包装方法或mipmapping,因为我们在大多数情况下都不需要它们。

如果您想要将您的整个屏幕呈现为一个更小或更大的纹理,您需要再次调用glViewport(在呈现到framebuffer之前)与纹理的新维度,否则渲染命令将只填充纹理的一部分。

现在我们已经创建了一个纹理,最后我们需要做的就是将它附加到framebuffer上:


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

glFrameBufferTexture2D函数有以下参数:

  • target:我们目标的framebuffer类型(绘制、读取或同时进行)。
  • attachment:我们将要附加的附件类型。现在我们附加了一个颜色附件。请注意,最后的0表示我们可以附加一个以上的颜色附件。我们将在后面的章节中讲到。
  • textarget:你想要附加的纹理类型。
  • texture:要附加的实际纹理。
  • level:mipmap级别。保持这个值为0。

在颜色附件的旁边,我们还可以将深度和模板纹理附加到framebuffer对象。要附加深度附件,我们将附件类型指定为GL_DEPTH_ATTACHMENT。注意,纹理的格式和内部格式类型应该变成GL_DEPTH_COMPONENT,以反映深度缓冲区的存储格式。要附加一个模板缓冲区,您可以使用GL_STENCIL_ATTACHMENT作为第二个参数,并将纹理的格式指定为GL_STENCIL_INDEX。

也可以同时附加一个深度缓冲区和一个模板缓冲区作为单一纹理。纹理的每个32位值包含24位深度信息和8位模板信息。要将深度和模板缓冲区附加为一个纹理,我们使用GL_DEPTH_STENCIL_ATTACHMENT类型并配置纹理的格式以包含深度和模板值的组合。下面给出了一个将深度和模板缓冲作为纹理附加到framebuffer的例子:


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对象是在纹理之后被引入到OpenGL的,作为一种可能的framebuffer附件类型,就像纹理图像一样,Renderbuffer对象是一个实际的缓冲区,例如字节数组,整数,像素或其他。但是,renderbuffer对象不能直接读取。这给了它额外的优势,OpenGL可以做一些内存优化,这可以使它在屏幕外渲染到framebuffer时比纹理更有性能优势。

Renderbuffer对象将所有的渲染数据直接存储到它们的缓冲区中,而不需要转换到特定的纹理格式,这使得它们作为可写存储介质的速度更快。您不能直接读取它们,但可以通过慢速的glReadPixels读取它们。这将从当前绑定的framebuffer返回指定的像素区域,但不直接从附件本身返回。

因为它们的数据是原生格式的,所以在将数据写入或将数据复制到其他缓冲区时速度非常快。因此,在使用renderbuffer对象时,像切换缓冲区这样的操作是相当快的。我们在每一帧末尾使用的glfwSwapBuffers函数也可以通过renderbuffer对象来实现:我们只需写入一个renderbuffer图像,然后在结束时交换到另一个图像。Renderbuffer对象非常适合这类操作。

创建一个renderbuffer对象看起来类似于framebuffer的代码:


unsigned int rbo;
glGenRenderbuffers(1, &rbo);

类似地,我们想要绑定renderbuffer对象,这样所有后续的renderbuffer操作都会影响当前的rbo:


glBindRenderbuffer(GL_RENDERBUFFER, rbo);  

由于renderbuffer对象是只写的,它们经常被用作深度和模板附件,因为大多数时候我们并不需要从它们中读取值,但是我们确实关心深度和模板测试。我们需要深度和模板值来进行测试,但不需要对这些值进行采样,因此renderbuffer对象非常适合这一点。当我们不从这些缓冲区采样时,renderbuffer对象通常是首选。

通过调用glRenderbufferStorage函数来创建一个depth和stencil renderbuffer对象:


glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, 800, 600);

创建一个renderbuffer对象类似于纹理对象,不同之处在于这个对象是专门设计用来作为framebuffer附件使用的,而不是像纹理这样的通用数据缓冲区。这里我们选择了GL_DEPTH24_STENCIL8作为内部格式,它分别保存24位和8位的深度缓冲区和模板缓冲区。

最后要做的是实际附加renderbuffer对象:


glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);  

Renderbuffer对象可以在你的屏幕外渲染项目中更有效地使用,但是重要的是要意识到什么时候使用Renderbuffer对象,什么时候使用纹理。一般的规则是,如果您从不需要从特定缓冲区取样数据,那么明智的做法是为该特定缓冲区使用renderbuffer对象。如果您需要从一个特定的缓冲区采样数据,如颜色或深度值,您应该使用纹理附件代替。

Rendering to a texture

现在我们知道了framebuffer(某种程度上)是如何工作的,是时候好好利用它们了。我们将把场景渲染成附在我们创建的framebuffer对象上的颜色纹理,然后在跨越整个屏幕的简单四边形上绘制这个纹理。这样,视觉输出与没有framebuffer时完全相同,但这一次它都打印在单个四轴上。为什么这个有用呢?在下一节中,我们将看到原因。

首先要做的是创建一个实际的framebuffer对象并绑定它,这是相对简单的:


unsigned int framebuffer;
glGenFramebuffers(1, &framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);    

接下来,我们创建一个纹理图像,并将其作为颜色附件附加到framebuffer。我们设置纹理的尺寸等于窗口的宽度和高度,并保持其数据未初始化:


// generate texture
unsigned int 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);

// attach it to currently bound framebuffer object
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texColorBuffer, 0);  

我们还想确保OpenGL能够进行深度测试(和可选的模板测试),因此我们必须确保在framebuffer中添加深度(和模板)附件。因为我们将只采样颜色缓冲区,而不是其他缓冲区,我们可以为此目的创建一个renderbuffer对象。

创建一个renderbuffer对象并不太难。我们必须记住的唯一一件事是,我们正在创建它作为一个深度和模板附件renderbuffer对象。我们将其内部格式设置为GL_DEPTH24_STENCIL8,这对于我们的目的是足够的精度:


unsigned int rbo;
glGenRenderbuffers(1, &rbo);
glBindRenderbuffer(GL_RENDERBUFFER, rbo); 
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, 800, 600);  
glBindRenderbuffer(GL_RENDERBUFFER, 0);

一旦我们为renderbuffer对象分配了足够的内存,我们就可以解除renderbuffer的绑定。

然后,在完成framebuffer之前的最后一步,我们将renderbuffer对象附加到framebuffer的深度和模板附件上:


glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);

然后,我们要检查framebuffer是否完成,如果没有完成,则打印一条错误消息。


if(glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
	std::cout << "ERROR::FRAMEBUFFER:: Framebuffer is not complete!" << std::endl;
glBindFramebuffer(GL_FRAMEBUFFER, 0);  

一定要解除对framebuffer的绑定,以确保不会意外地呈现到错误的framebuffer。

既然framebuffer已经完成,那么要渲染到framebuffer的缓冲区而不是默认的framebuffer,我们所需要做的就是绑定framebuffer对象。随后的所有呈现命令将影响当前绑定的framebuffer。所有深度和模板操作也将从当前绑定的framebuffer的深度和模板附件(如果它们可用的话)中读取。例如,如果您要省略一个深度缓冲区,那么所有深度测试操作将不再工作。

因此,要将场景绘制成一个单一的纹理,我们必须采取以下步骤:

  1. 将新的framebuffer绑定为活动的framebuffer,像往常一样渲染场景。
  2. 绑定到默认的framebuffer。
  3. 用新的framebuffer的颜色缓冲区作为它的纹理绘制一个横跨整个屏幕的四轴图形。

我们将渲染在深度测试一章中使用的相同场景,但是这次使用老式的容器container 纹理。

为了渲染这个四边形,我们将创建一组新的简单着色器。我们不打算包括花哨的矩阵变换,因为我们将提供顶点坐标作为标准化的设备坐标vertex coordinates as normalized device coordinates,所以我们可以直接将它们作为顶点着色器的输出。顶点着色器是这样的:


#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec2 aTexCoords;

out vec2 TexCoords;

void main()
{
    gl_Position = vec4(aPos.x, aPos.y, 0.0, 1.0); 
    TexCoords = aTexCoords;
}  

没有什么太复杂。碎片着色器甚至是更基本的,因为我们要做的唯一的事情是从一个纹理样本:


#version 330 core
out vec4 FragColor;
  
in vec2 TexCoords;

uniform sampler2D screenTexture;

void main()
{ 
    FragColor = texture(screenTexture, TexCoords);
}

然后由您为屏幕四轴创建和配置VAO。framebuffer过程的一次渲染迭代具有以下结构:


// first pass
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // we're not using the stencil buffer now
glEnable(GL_DEPTH_TEST);
DrawScene();	
  
// second pass
glBindFramebuffer(GL_FRAMEBUFFER, 0); // back to default
glClearColor(1.0f, 1.0f, 1.0f, 1.0f); 
glClear(GL_COLOR_BUFFER_BIT);
  
screenShader.use();  
glBindVertexArray(quadVAO);
glDisable(GL_DEPTH_TEST);
glBindTexture(GL_TEXTURE_2D, textureColorbuffer);
glDrawArrays(GL_TRIANGLES, 0, 6);  

有几件事需要注意。首先,由于我们使用的每个framebuffer都有自己的一组缓冲区,所以我们希望通过调用glClear来清除每个缓冲区,并设置适当的位。第二,当绘制方块时,我们禁用了深度测试,因为我们想要确保方块总是在其他渲染前渲染;当我们绘制正常的场景时,我们必须再次启用深度测试。

这里有相当多的步骤可能出错,因此如果没有输出,请尝试在可能的地方进行调试,并重新阅读本章的相关章节。如果一切顺利,你会得到一个可视化的结果,看起来像这样:

左边显示了视觉输出,和我们在深度测试一章中看到的完全一样,但是这次是在一个简单的四轴上呈现的。如果我们用线框渲染场景,很明显我们在默认的framebuffer中只绘制了一个四边形。

您可以在这里here. 找到应用程序的源代码。

这有什么用呢?好了,因为我们现在可以自由地访问完全渲染的场景的每一个像素作为一个纹理图像,我们可以在fragment shader中创建一些有趣的效果。

Post-processing

现在整个场景被渲染成一个纹理,我们可以通过操作场景纹理来创建很酷的后期处理效果。在本节中,我们将向您展示一些更流行的后期处理效果,以及如何添加一些创造性来创建您自己的效果。

让我们从最简单的后处理效果开始。

Inversion 反向

我们可以访问渲染输出的每一种颜色,所以在fragment shader中返回这些颜色的逆并不难。我们可以从1.0中减去屏幕纹理的颜色


void main()
{
    FragColor = vec4(vec3(1.0 - texture(screenTexture, TexCoords)), 1.0);
}  

虽然反演是一种相对简单的后处理效果,但它已经创造了时髦的结果:

 

现在,在fragment shader中,通过一行代码,整个场景的所有颜色都被颠倒了。很酷吧?

Grayscale 灰度

另一个有趣的效果是去除场景中除白色、灰色和黑色之外的所有颜色;有效的灰度化整个图像。一种简单的方法就是取所有的颜色成分并平均它们的结果:


void main()
{
    FragColor = texture(screenTexture, TexCoords);
    float average = (FragColor.r + FragColor.g + FragColor.b) / 3.0;
    FragColor = vec4(average, average, average, 1.0);
}   

这已经创造了相当好的结果,但是人眼对绿色更敏感,对蓝色最不敏感。因此,为了获得最准确的物理结果,我们将需要使用加权通道:


void main()
{
    FragColor = texture(screenTexture, TexCoords);
    float average = 0.2126 * FragColor.r + 0.7152 * FragColor.g + 0.0722 * FragColor.b;
    FragColor = vec4(average, average, average, 1.0);
}   

 

你可能不会立刻注意到其中的差别,但是对于更复杂的场景,这样的加权灰度效果往往更真实。

Kernel effects

对单一纹理图像进行后处理的另一个好处是,我们可以从纹理的其他部分采样颜色值,而不是特定于那个片段。例如,我们可以在当前纹理坐标周围取一个小区域,然后在当前纹理值周围采样多个纹理值。然后,我们可以通过创造性的方式将它们结合起来,创造出有趣的效果。

核(或卷积矩阵)是一个以当前像素为中心的小型类似矩阵的值数组,它将周围的像素值与核值相乘,并将它们相加形成一个单独的值。我们在当前像素周围的纹理坐标上增加了一个小的偏移量,并基于内核结合结果。下面给出一个内核的例子:

这个内核取周围8个像素值,并将它们乘以2,将当前像素乘以-15。这个示例内核将周围的像素乘以核中确定的几个权重,并通过将当前像素乘以一个很大的负权重来平衡结果。

如果你把所有的重量加在一起,你会在网上找到的大多数玉米粒的总和都是1。如果它们加起来不等于1,就意味着最终得到的纹理颜色会比原始纹理的颜色更亮或更暗。

内核对于后期处理来说是非常有用的工具,因为它们非常容易使用和试验,而且可以在网上找到很多示例。我们确实需要稍微调整片段着色器来支持内核。我们假设我们将使用的每个内核都是3x3内核(大多数内核都是):


const float offset = 1.0 / 300.0;  

void main()
{
    vec2 offsets[9] = vec2[](
        vec2(-offset,  offset), // top-left
        vec2( 0.0f,    offset), // top-center
        vec2( offset,  offset), // top-right
        vec2(-offset,  0.0f),   // center-left
        vec2( 0.0f,    0.0f),   // center-center
        vec2( offset,  0.0f),   // center-right
        vec2(-offset, -offset), // bottom-left
        vec2( 0.0f,   -offset), // bottom-center
        vec2( offset, -offset)  // bottom-right    
    );

    float kernel[9] = float[](
        -1, -1, -1,
        -1,  9, -1,
        -1, -1, -1
    );
    
    vec3 sampleTex[9];
    for(int i = 0; i < 9; i++)
    {
        sampleTex[i] = vec3(texture(screenTexture, TexCoords.st + offsets[i]));
    }
    vec3 col = vec3(0.0);
    for(int i = 0; i < 9; i++)
        col += sampleTex[i] * kernel[i];
    
    FragColor = vec4(col, 1.0);
}  

在fragment shader中,我们首先为每个周围的纹理坐标创建一个9个vec2偏移量的数组。偏移量是一个恒定值,您可以根据自己的喜好定制它。然后我们定义内核,在本例中是锐化内核,它通过以一种有趣的方式采样周围的所有像素来锐化每个颜色值。最后,我们在采样时将每个偏移量添加到当前纹理坐标,并将这些纹理值与我们相加的加权核值相乘。

这个特别的锐化内核看起来是这样的:

这可能是一些有趣的效果的基础,你的玩家可能是在麻醉冒险。

Blur 模糊

创建模糊效果的内核定义如下:

因为所有的值加起来是16,所以直接返回组合的采样颜色会得到一个非常明亮的颜色,所以我们必须将每个内核值除以16。得到的内核数组为:


float kernel[9] = float[](
    1.0 / 16, 2.0 / 16, 1.0 / 16,
    2.0 / 16, 4.0 / 16, 2.0 / 16,
    1.0 / 16, 2.0 / 16, 1.0 / 16  
);

通过只改变片段着色器中的内核数组,我们可以完全改变后处理效果。现在看起来是这样的:

这种模糊的效果创造了有趣的可能性。我们可以随着时间的推移改变模糊的程度来创造醉酒的效果,或者在主角不戴眼镜的时候增加模糊的程度。模糊也可以是平滑颜色值的有用工具,我们将在后面的章节看到使用。

您可以看到,一旦我们有了这样一个小的内核实现,就很容易创建很酷的后期处理效果。让我们向您展示最后一个流行的效果来结束讨论。

Edge detection 边缘检测

下面你可以找到一个类似锐化内核的边缘检测内核:

这个内核突出了所有的边,并使其余的边变暗,当我们只关心图像中的边时,这是非常有用的。

在Photoshop这样的工具中,这样的内核被用作图像处理工具或过滤器,这可能并不令人惊讶。由于图形卡能够以极端并行的能力处理片段,所以我们可以相对轻松地实时处理每个像素的图像。图像编辑工具因此倾向于使用图形卡进行图像处理。

Exercises

  • Can you use framebuffers to create a rear-view mirror? For this you'll have to draw your scene twice: one with the camera rotated 180 degrees and the other as normal. Try to create a small quad at the top of your screen to apply the mirror texture on, something like this; solution.
  • Play around with the kernel values and create your own interesting post-processing effects. Try searching the internet as well for other interesting kernels.
  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值