LearnOpenGL Note - Advanced OpenGL - Framebuffers

高级OpenGL-帧缓冲

Posted by Tao on Saturday, April 16, 2022

一直以来,我们在使用OpenGL渲染时,最终的目的地是默认的帧缓冲区,实际上OpenGL也允许我们创建自定义的帧缓冲区。使用自定义的帧缓冲区,可以实现镜面,离屏渲染,以及很酷的后处理效果。

本章将会介绍什么是帧缓冲、FBO的两种形式:纹理和RBO、如何使用帧缓冲及帧缓冲可实现的一些特效。

FBO概念

在OpenGL中,渲染管线中的顶点、纹理等经过一系列处理后,最终显示在2D屏幕设备上,渲染管线的最终目的地就是帧缓冲区。帧缓冲包括OpenGL使用的颜色缓冲区(color buffer)、深度缓冲区(depth buffer)、模板缓冲区(stencil buffer)等缓冲区。默认的帧缓冲区由窗口系统创建,例如我们一直使用的GLFW库来完成这项任务。这个默认的帧缓冲区,就是目前我们一直使用的绘图命令的作用对象,称之为窗口系统提供的帧缓冲区(window-system-provided framebuffer)。

OpenGL也允许我们手动创建一个帧缓冲区,并将渲染结果重定向到这个缓冲区。在创建时允许我们自定义帧缓冲区的一些特性,这个自定义的帧缓冲区,称之为应用程序帧缓冲区(application-created framebuffer object )。

同默认的帧缓冲区一样,自定义的帧缓冲区也包含颜色缓冲区、深度和模板缓冲区,这些逻辑上的缓冲区(logical buffers)在FBO中称之为可附加的图像(framebuffer-attachable images),他们是可以附加到FBO的二维像素数组(2D arrays of pixels)。

FBO中包含两种类型的附加图像(framebuffer-attachable): 纹理图像和RenderBuffer图像。当纹理对象附加到帧缓冲时,OpenGL渲染该纹理;当renderBuffer对象附加到帧缓冲时,OpenGL执行离屏渲染(offscreen rendering)。(来自songho FBO)

之所以用附加这个词,表达的是FBO可以附加多个缓冲区,而且可以灵活地在缓冲区中切换,一个重要的概念是附加点(attachment points)。FBO中包含一个以上的颜色附加点,但只有一个深度和模板附加点,如下图所示(来自songho FBO):

gl_fbo01.png

一个FBO可以有(GL_COLOR_ATTACHMENT0,…, GL_COLOR_ATTACHMENTn)多个附加点,最多支持多少个附加点可以通过查询GL_MAX_COLOR_ATTACHMENTS变量获取。

值得注意的是:从上面的图中我们可以看到,FBO本身并不包含任何缓冲对象,实际上是通过附加点指向实际的缓冲对象的。这样FBO可以快速地切换缓冲对象。

创建FBO

同OpenGL中创建VAO、VBO等其他缓冲对象一样,创建和销毁FBO的步骤也很简单:

void glGenFramebuffers(GLsizei n, GLuint* ids);
void glDeleteFramebuffers(GLsizei n, const GLuint* ids);

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

创建之后,我们需要将FBO绑定为激活的(Active)帧缓冲:

void glBindFramebuffer(GLenum target, GLuint id);

glBindFramebuffer(GL_FRAMEBUFFER, fbo);

这里的target一般可以填写为GL_FRAMEBUFFER,这个缓冲区将会用来进行读和写操作;如果需要绑定到读操作的缓冲区使用GL_READ_FRAMEBUFFER,其支持glReadPixels这类读操作;如果需要绑定到写操作的缓冲区使用GL_DRAW_FRAMEBUFFER,其支持渲染、清除等操作。

OpenGL要求,一个完整的FBO需要满足以下条件(来自FrameBufffer):

  • 至少附加一个缓冲区(颜色、深度或者模板)
  • 至少有一个颜色附加
  • 所有的附加必须完整(预分配了内存)
  • 每个缓冲区的采样数需要一致

关于采样,后面的章节会进行学习,暂时不做讨论。判断一个FBO是否完整,可以如下:

if(glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE)

之后所有的渲染操作将会渲染到当前绑定帧缓冲的附件中。由于我们的帧缓冲不是默认帧缓冲,渲染指令将不会对窗口的视觉输出有任何影响。出于这个原因,渲染到一个不同的帧缓冲被叫做离屏渲染(Off-screen Rendering)。要保证所有的渲染操作在主窗口中有视觉效果,我们需要再次激活默认帧缓冲,将它绑定到0

glBindFramebuffer(GL_FRAMEBUFFER, 0);

在完成所有的帧缓冲操作之后,不要忘记删除这个帧缓冲对象:

glDeleteFramebuffers(1, &fbo);

回顾上面列出的一个完整的FBO的要求:所有的附加必须完整。可知,在进行上述完整性检查之前,需要给帧缓冲附加一个附加图像(附件)。

创建纹理附加图像

当把一个纹理附加到帧缓冲的时候,所有的渲染指令将会写入到这个纹理中,就像它是一个普通的颜色/深度或模板缓冲一样。使用纹理的优点是,所有渲染操作的结果将会被储存在一个纹理图像中,我们之后可以在着色器中很方便地使用它。

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

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);

创建FBO的附加纹理如同平常使用纹理一样,不同的是,这里只是为纹理预分配空间,而不需要真正的加载纹理,因为当使用FBO渲染时渲染结果将会写入到我们创建的这个纹理上去。并且我们将该纹理的维度设置为了屏幕大小(尽管这不是必须的)。

这里需要注意的是glTexImage2D函数,末尾的NULL表示我们只预分配空间,而不实际加载纹理。glTexImage2D函数也是一个OpenGL中相对复杂的一个函数。

API void glTexImage2D(GLenum target,
GLint level,
GLint internalFormat,
GLsizei width,
GLsizei height,
GLint border,
GLenum format,
GLenum type,
const GLvoid * data);

在前面纹理一节已经介绍过这个函数,这里重点说下创建FBO纹理时需要注意的。函数中后面三个参数format、type、data表示的是内存中图像像素的信息,包括格式,类型和指向内存的指针。而internalFormat表示的是OpenGL内存存储纹理的格式,表示的是纹理中颜色成分的格式。从纹理图片的内存转移到OpenGL内存纹理存储是一个像素转移操作(Pixel Transfer),关于这个部分的细节比较多,不在这里展开,感兴趣地可以参考OpenGL wiki-Pixel Transfer

上面填写的纹理格式GL_RGB,以及GL_UNSIGNED_BYTE表示纹理包含红绿蓝三色,并且每个成分用无符号字节表示。600,800表示我们分配的纹理大小。

创建纹理后,将它附加到帧缓冲上:

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

API void glFramebufferTexture2D(GLenum target,
GLenum attachment,
GLenum textarget,GLuint texture,GLint level);

  1. target表示绑定目标,参数可选为GL_DRAW_FRAMEBUFFER, GL_READ_FRAMEBUFFER, or GL_FRAMEBUFFER。
  2. attechment表示附加点,可选值为GL_COLOR_ATTACHMENTi, GL_DEPTH_ATTACHMENT, GL_STENCIL_ATTACHMENT or GL_DEPTH_STENCIL_ATTACHMMENT。
  3. textTarget表示纹理的绑定目标,我们使用二维纹理填写GL_TEXTURE_2D即可。
  4. texture表示实际的纹理对象。
  5. level表示mipmap级别,我们填写0即可。

除了颜色附件之外,我们还可以附加一个深度和模板缓冲纹理到帧缓冲对象中。要附加深度缓冲的话,我们将附件类型设置为GL_DEPTH_ATTACHMENT。注意纹理的格式(Format)和内部格式(Internalformat)类型将变为GL_DEPTH_COMPONENT,来反映深度缓冲的储存格式。要附加模板缓冲的话,你要将第二个参数设置为GL_STENCIL_ATTACHMENT,并将纹理的格式设定为GL_STENCIL_INDEX

也可以将深度缓冲和模板缓冲附加为一个纹理中。纹理的每32位数值将包含24位的深度信息和8位的模板信息。要将深度和模板缓冲附加为一个纹理的话,我们使用GL_DEPTH_STENCIL_ATTACHMENT类型,并配置纹理的格式,让它包含合并的深度和模板值。将一个深度和模板缓冲附加为一个纹理到帧缓冲的例子可以在下面找到:(纹理格式说明参考OpenGL学习 - 纹理

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);

在利用FBO作图前,我们继续介绍另一个附加图像-RenderBuffer。

RenderBuffer Object 渲染缓冲对象附件

纹理图像附加到FBO后,执行渲染后,我们可以在后期着色器处理中访问到纹理,这给一些需要多遍处理的操作提供了很大方便。当我们不需要在后期读取纹理时,我们可以使用Renderbuffer这种附加图像,它主要用来存储深度、模板这类没有与之对应的纹理格式的缓冲区。渲染缓冲对象附加的好处是,它会将数据储存为OpenGL原生的渲染格式,其为离屏渲染到帧缓冲优化过。

Renderbuffer 对象将所有渲染数据直接存储到它们的缓冲区中,而无需转换为特定于纹理格式,是其成为一种更快的可写储存介质。然而,渲染缓冲对象通常都是只写的,所以你不能读取它们(比如使用纹理访问)。当然你仍然还是能够使用glReadPixels来读取它,这会从当前绑定的帧缓冲,而不是附件本身中返回特定区域的像素。

因为其数据本身已经是原生格式,所以当写入或者复制它的数据到其它缓冲中时速度很快。所以,交换缓冲这样的操作在使用渲染缓冲对象时会非常快。我们原来在每个渲染迭代的最后使用的glfwSwapBuffers,其实也可以通过渲染缓冲对象实现:只需要写入一个渲染缓冲图像,并在最后交换到另外一个渲染缓冲就行。渲染缓冲对象非常适合这种操作。

创建和销毁RenderBuffer很简单,如下:

void glGenRenderbuffers(GLsizei n, GLuint* ids)
void glDeleteRenderbuffers(GLsizei n, const Gluint* ids)

调用代码:

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

创建完毕后,仍然需要将其绑定到目标对象,让之后所有的渲染缓冲操作影响当前的rbo:

glBindRenderbuffer(GL_RENDERBUFFER, rbo);

由于渲染缓冲对象通常都是只写的,它们会经常用于深度和模板附件,因为大部分时间我们都不需要从深度和模板缓冲中读取值,只关心深度和模板测试。我们需要深度和模板值用于测试,但不需要对它们进行采样,所以渲染缓冲对象非常适合它们。

需要注意的是,我们还需要为RBO预分配内存空间:

glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, 800, 600);

API void glRenderbufferStorage(GLenum target,
GLenum internalformat,
GLsizei width,
GLsizei height);
该函数为指定内部格式的RBO预分配空间。

创建一个渲染缓冲对象与纹理对象类似,不同的是这个对象是专门被设计作为帧缓冲附件使用的,而不是纹理那样的通用数据缓冲(General Purpose Data Buffer)。所以上式我们选择GL_DEPTH24_STENCIL8作为内部格式,它封装了24位的深度缓冲和8位的模板缓冲。

最后附加这个渲染缓冲对象:

glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);

渲染缓冲对象能为你的帧缓冲对象提供一些优化,区分何时使用渲染缓冲对象,何时使用纹理很重要。通常的规则是,如果您不需要从一个缓冲中采样数据,那么对这个缓冲使用渲染缓冲对象比较好。如果您需要从特定缓冲区(如颜色或深度值)中采样数据,则应使用纹理附件。

绘制到纹理

上面利用纹理和RBO创建的FBO,我们在OpenGL中可以用来将场景绘制到纹理中。

首先要创建一个帧缓冲对象,并绑定它:

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

接下来我们需要创建一个纹理图像,我们将它作为一个颜色附件附加到帧缓冲上。我们将纹理的维度设置为窗口的宽度和高度,并且不初始化它的数据:

// 生成纹理
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);

// 将它附加到当前绑定的帧缓冲对象
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texColorBuffer, 0);

我们还希望OpenGL能够进行深度测试(如果你需要的话还有模板测试),所以我们还需要添加一个深度(和模板)附件到帧缓冲中。由于我们只希望采样颜色缓冲,而不是其它的缓冲,我们可以为它们创建一个渲染缓冲对象。还记得当我们不需要采样缓冲的时候,渲染缓冲对象是更好的选择吗?

创建一个渲染缓冲对象不是非常复杂。我们需要记住的唯一事情是,我们将它创建为一个深度和模板附件渲染缓冲对象。我们将它的内部格式设置为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);

当我们为渲染缓冲对象分配了足够的内存之后,我们可以解绑这个渲染缓冲。

接下来,作为完成帧缓冲之前的最后一步,我们将渲染缓冲对象附加到帧缓冲的深度和模板附件上:

glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);

最后,我们希望检查帧缓冲是否是完整的,如果不是,我们将打印错误信息。

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

记得要解绑帧缓冲,保证我们不会不小心渲染到错误的帧缓冲上。

现在这个帧缓冲就完整了,我们只需要绑定这个帧缓冲对象,让渲染到帧缓冲的缓冲中而不是默认的帧缓冲中。之后的渲染指令将会影响当前绑定的帧缓冲。所有的深度和模板操作都会从当前绑定的帧缓冲的深度和模板附件中(如果有的话)读取。如果你忽略了深度缓冲,那么所有的深度测试操作将不再工作,因为当前绑定的帧缓冲中不存在深度缓冲。

所以,要想绘制场景到一个纹理上,我们需要采取以下的步骤:

  1. 将新的帧缓冲绑定为激活的帧缓冲,和往常一样渲染场景
  2. 绑定默认的帧缓冲
  3. 绘制一个横跨整个屏幕的四边形,将帧缓冲的颜色缓冲作为它的纹理。

为了绘制屏幕四边形,我们新创建一个简单的着色器。不含任何花哨的矩阵变换,因为提供的是标准化设备坐标的顶点坐标,所以我们可以直接将它们设定为顶点着色器的输出。顶点着色器如下:

#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;
}

帧缓冲的一个render loop将会有以下的结构:

// 第一处理阶段(Pass)
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // 我们现在不使用模板缓冲
glEnable(GL_DEPTH_TEST);
DrawScene();    

// 第二处理阶段
glBindFramebuffer(GL_FRAMEBUFFER, 0); // 返回默认
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);  

得到效果如下图所示:

basic_result.png

采用线框模式绘制:

glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);

显示为一个矩形:

basic_frame_result.png

从上面结果我们可以看到,利用FBO将场景绘制到纹理,在后期绘制矩形时使用这个纹理。这种方式可以制作镜子等效果,十分有用。

后处理效果(postprocessing)

既然整个场景都被渲染到了一个纹理上,我们可以简单地通过修改纹理数据创建出一些非常有意思的效果。

反色

从屏幕纹理中取颜色值,然后用1.0减去它,对它进行反色:

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

可得到如下效果:

reverse_result.png

灰度

我们还可以使图像灰度化(Grayscale)。一个简单实现是:取所有颜色分量的平均值。

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

取平均已经可以构建比较好的效果了,但人眼会对绿色更加敏感一些,所以为了获取物理上更精确的效果,我们需要使用加权的(Weighted)通道:

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);
}

gray_result.png

卷积操作相关效果

后处理也可以采取图像处理的方式,例如使用kernel矩阵。kernel矩阵一般取为3x3矩阵,这个矩阵的和一般为1。通过kernel矩阵,将当前纹理坐标处的纹理扩展到周围9个坐标处的纹理,然后通过权重计算出最终纹理的像素。

核(Kernel)又叫卷积矩阵(Convolution Matrix),它的作用是取目标像素周围像素颜色的加权平均来覆盖原来的目标像素颜色。读取相邻像素的颜色我们需要偏移量(offset),因此我们要知道像素大小(来源:Framebuffers - Offscreen Rendering and Post Processing)。其可以通过下式计算:\(pass\_TexCoord = pixel\_size · gl\_FragCoord\),其中\(pass\_TexCoord\)是纹素在纹理空间的位置,\(gl\_FragCoord\)是在视口空间的位置。

下面是核函数的一个例子:

\[\begin{bmatrix}2 & 2 & 2 \\ 2 & -15 & 2 \\ 2 & 2 & 2 \end{bmatrix}\]

这个核取了8个周围像素值,将它们乘以2,而把当前的像素乘以-15。这个核的例子将周围的像素乘上了一个权重,并将当前像素乘以一个比较大的负权重来平衡结果。

产生锐化(Sharpen)核是这样的:

\[\begin{bmatrix} -1 & -1 & -1 \\ -1 & 9 & -1 \\ -1 & -1 & -1 \end{bmatrix}\]

对应的我们修改着色器代码,在着色器中,我们定义当前纹理位置的9个周围位置如下:其中偏移量是一个常量,你可以按照你的喜好自定义它。

const float offset = 1.0 / 300; // 9个位置的纹理坐标偏移量
// 确定9个位置的偏移量
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  底部右边
);

然后使用kernel矩阵中的权系数,计算最终的纹理像素:

// 计算9个位置的纹理
vec3 sampleText[9];
for (int i = 0; i < 9; ++i)
{
    sampleText[i] = vec3(texture(text, TextCoord.st + offsets[i]));
}
// 利用权值求最终纹理颜色
vec3 result = vec3(0.0);
for (int i = 0; i < 9; ++i)
{
    result += sampleText[i] * kernel[i];
}

锐化核效果如下:

sharpen_result.png

例如产生模糊(Blur)效果的核是这样的:

\[\begin{bmatrix} 1 & 2 & 1 \\ 2 & 4 & 2 \\ 1 & 2 & 1 \end{bmatrix} / 16\]

各种核函数对图像所产生的不同影响可参考数字图像处理相关内容或参考卷积相关操作。

当着色器中计算纹理坐标的偏移量offset不同时,效果会有所改变。

Addition

相关概念在本章仅进行了简单的梳理,关于选择附加纹理还是附加RBO,详细参考Difference between Frame buffer object, Render buffer object and texture?

另外注意,核在对屏幕纹理的边缘进行采样的时候,由于还会对中心像素周围的8个像素进行采样,其实会取到纹理之外的像素。由于环绕方式默认是GL_REPEAT,所以在没有设置的情况下取到的是屏幕另一边的像素,而另一边的像素本不应该对中心像素产生影响,这就可能会在屏幕边缘产生很奇怪的条纹。为了消除这一问题,我们可以将屏幕纹理的环绕方式都设置为GL_CLAMP_TO_EDGE。这样子在取到纹理外的像素时,就能够重复边缘的像素来更精确地估计最终的值了。

Reference

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

Heisenberg Blog

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

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


comments powered by Disqus