从0开始的OpenGL学习(二十二)-帧缓存
本文主要解决两个问题:
1、什么是帧缓存?
2、如何使用帧缓存?
引言
至今为止,我们已经用过了3种缓存:颜色缓存、深度缓存和模板缓存。这些缓存可以包含在另外一个总的缓存之中,这就是我们这章要介绍的帧缓存。OpenGL给了我们很大的灵活性来创建帧缓存,并且创建附加在帧缓存上的颜色缓存、模板缓存和深度缓存等等。
在我们的应用之中,GLFW会默认创建一个帧缓存,我们所有的绘制都是绘制在这个帧缓存上的。所有的窗口库都会创建一个默认的帧缓存,这个帧缓存中包括了颜色、模板和深度缓存。但是,我们自己创建的帧缓存就没那么智能了,我们需要自己创建颜色、模板和深度缓存。
帧缓存可以用来创建一个场景的镜像或者一些非常酷的后期处理效果。这也是它存在的意义。
创建帧缓存
创建缓存的工作我们已经很熟悉了,和创建VAO和VBO一样,就三个步骤:生成-绑定-分配。
unsigned int fbo;
glGenFramebuffer(1,&fbo);
glBindFramebuffer(GL_FRAMEBUFFER,fbo);
绑定之后,所有的渲染操作,都是渲染到这个绑定的帧缓存上了。
顺便一提,除了GL_FRAMEBUFFER之外,还有GL_READ_FRAMEBUFFER,GL_DRAW_FRAMEBUFFER可以绑定,只能用来读或者是绘制的。但是我们大多数情况下只要用GL_FRAMEBUFFER就行了。
不幸的是,只有生成和绑定两步操作还不能使用这个帧缓存,我们需要未它分配内存空间。为帧缓存分配空间不像给VAO和VBO分配空间那样简单,帧缓存本质上并不是一个独立的概念,它像是一个管理员,管理着手下的各个缓存,比如颜色缓存、模板缓存、深度缓存等等。如果它没有这些缓存手下,它就是废物一个。它的这些手下被称为是附件(Attachment)。我们需要创建颜色、模板和深度缓存,把它们安排给帧缓存做手下才能让帧缓存起作用。
一个帧缓存如果可用,那么它就处于一种完成状态。一个完成状态的帧缓存具有以下特征:
- 至少有一个缓存附加到帧缓存上(颜色、深度或者模板缓存)
- 至少需要有一个颜色附加物
- 所哟的附加物都必须分配好了内存以供使用
- 每个缓存都必须有相同的采样数量
采样数量可能不好理解,我们会在之后的例子中讲。
当我们把这些东西准备好之后,我们就可以通过一个函数来检查帧缓存是否处于完成状态了。这个函数是:glCheckFramebufferStatus。通过传入GL_FRAMEBUFFER,获得的值与宏GL_FRAMEBUFFER_COMPLETE比较,就可以轻易地判断出帧缓存是否完成:
if(glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE)
当帧缓存完成之后,我们就可以使用了。所有在这个帧缓存上的绘制操作都不会对主帧缓存有什么影响,就像是渲染到一个离屏缓存上一样。如果需要显示,我们只需要把这个帧缓存上的数据复制到主帧缓存上就可以了。
最后,记得在不需要使用帧缓存时将其删除:
glDeleteFramebuffers(1, &fbo);
说了半天,还是没有说到重点,如何把缓存附加到帧缓存上。一般的,我们有两种方法可以生成这种附件,分别是纹理和渲染缓存对象。详细说说这两种东西如何操作,以及各自的优点:
纹理附件
当我们把一张纹理附加到帧缓存上是,这个纹理可以作为颜色缓存、深度缓存或者模板缓存来使用。使用纹理作附件的好处是所有的渲染操作都会被保存到纹理图中,我们可以在着色器中非常方便地进行采样。
创建纹理附件的方法和之前使用纹理的时候没什么区别,只有一点,就是最后的纹理数据需要设置成NULL:
unsigned int textureColorbuffer;
glGenTextures(1, &textureColorbuffer);
glBindTexture(GL_TEXTURE_2D, textureColorbuffer);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, SCR_WIDTH, SCR_HEIGHT, 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是因为我们只需要分配内存而不要填充它,填充工作是要之后渲染时做的。当然,我们也不关心它的环绕方式或者mipmap设置,所以设置成最简单的OK了。
生成纹理图之后,接下来的一步操作就是附加到帧缓存上:
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, textureColorbuffer, 0);
glFramebufferTexture2D的原型如下:void glFramebufferTexture2D( GLenum target,GLenum attachment,GLenum textarget,GLuint texture,GLint level);
详细介绍一下每个参数的作用:
- target 帧缓存类型,一般设置成GL_FRAMEBUFFER
- attachment 指明纹理想附加到哪种附件上,可选项有GL_ATTACHMENT0~n,GL_DEPTH_ATTACHMENT和GL_STENCIL_ATTACHMENT
- textarget 纹理类型。现在我们只有一个GL_TEXTURE_2D,以后可就不好说了。
- level 纹理的map层级。设置成0.
从上面的解释中可以看出,我们还能将将纹理设置成深度/模板缓存。要注意,如果将纹理设置成GL_DEPTH_ATTACHMENT,那么它的内部格式就变成了GL_DEPTH_COMPONENT。同样,设置成GL_STENCIL_ATTACHMENT时,内部格式变成GL_STENCIL_INDEX。
还有一种方法,我们可以同时这是深度和模板缓存,就是使用GL_DEPTH_AND_STENCIL_ATTACHMENT参数。但是我们不能使用上面的纹理,需要在生成纹理的时候对其做一些格式上的改变,如下:
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH24_STENCIL8, SCR_WIDTH, SCR_HEIGHT, 0, GL_DEPTH_STENCIL, GL_UNSIGNED_INT_24_8, NULL);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, texture, 0);
渲染缓存对象附件
纹理附件的存在历史悠久,被使用的次数也非常多,是旧时代的宠儿。但是现在,我们有一个新的东西可以与之媲美,就是将要介绍的渲染缓存对象。渲染缓存对象的优势是它保存的格式是OpenGL本身的渲染格式,这样就让它在性能和使用方便程度上更胜一筹。
渲染缓存对象将渲染数据直接保存到缓存中,不需要转换成指定的纹理格式,这让它在写数据的方面更加迅速,成为一种保存数据的良好媒介。但是,渲染缓存对象通常是只写的,你无法通过类似访问纹理数据的方式去读取其中的数据。一种访问渲染缓存对象数据的方式是使用glReadPixels,它会返回当前帧缓存的一片指定像素区域,而不是渲染缓存对象的一个数据块。
事实上,我们一直在使用的glfwSwapBuffers函数可能也是通过渲染缓存对象实现的。
创建渲染缓存对象也是同样的三部曲:生成-绑定-分配:
unsigned int rbo;
glGenRenderbuffers(1, &rbo);
glBindRenderbuffer(GL_RENDERBUFFER, rbo);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, SCR_WIDTH, SCR_HEIGHT);
渲染缓存对象的特性决定了它更适合用来作为深度和模板缓存,因此我们创建它的时候就指明它是用作深度和模板缓存的。
创建完成后,下一步自然是要附加到帧缓存上:
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);
根据纹理和渲染缓存对象的特性,如果你想要一个经常读取数据的附件,那么纹理附件更合适。而如果你想要一个不常读取的附件,那么渲染缓存对象更合适。
实战演练
新知识学完了,接下来就是实际运用的环节。我们把场景渲染到帧缓存的纹理附件上,然后将纹理显示到一个覆盖整个窗口的四边形中。看上去这个操作和没有帧缓存时的效果一样,不过你很快就会知道这样做的原因。
第一步,先把帧缓存对象创建出来:
/** 帧缓存配置 */
unsigned int framebuffer;
glGenFramebuffers(1, &framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
第二步,创建一个纹理附件,并将其附加到帧缓存上:
//创建一个颜色纹理
unsigned int textureColorbuffer;
glGenTextures(1, &textureColorbuffer);
glBindTexture(GL_TEXTURE_2D, textureColorbuffer);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, SCR_WIDTH, SCR_HEIGHT, 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);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, textureColorbuffer, 0);
第三步,创建一个渲染缓存附件,附加到帧缓存上。因为我们只将其作为深度和模板缓存所以这里用渲染缓存对象比较合适:
//创建一个渲染缓存作为帧缓存的深度和模板缓存
unsigned int rbo;
glGenRenderbuffers(1, &rbo);
glBindRenderbuffer(GL_RENDERBUFFER, rbo);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, SCR_WIDTH, SCR_HEIGHT);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);
最后一步,检查帧缓存的完整性,并把帧缓存切回到默认帧缓存:
//检查绑定是否成功
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
std::cout << "ERROR:FRAMEBUFFER:: 帧缓存不完整!" << std::endl;
glBindFramebuffer(GL_FRAMEBUFFER, 0);
帧缓存创建完成后,我们就要渲染场景了。绘制场景的过程有以下三步:
- 按照平常的步骤渲染场景到新帧缓存中
- 绑定回默认缓存
- 使用新帧缓存的纹理作为采样源,绘制四边形
我们用深度测试中的代码,但是用更早之前的盒子纹理。
盒子纹理
为了绘制四边形,我们需要一个新的顶点着色器和片元着色器,还要一组新的顶点数据结构,代码实现都非常简单,笔者这里直接给出:
//四边形的顶点数据,会填充整个窗口
float quadVertices[] = {
// 位置 // 纹理
-1.0f, 1.0f, 0.0f, 1.0f,
-1.0f, -1.0f, 0.0f, 0.0f,
1.0f, -1.0f, 1.0f, 0.0f,
-1.0f, 1.0f, 0.0f, 1.0f,
1.0f, -1.0f, 1.0f, 0.0f,
1.0f, 1.0f, 1.0f, 1.0f
};
//顶点着色器
#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec2 aTexCoords;
out vec2 TexCoords;
void main() {
TexCoords = aTexCoords;
gl_Position = vec4(aPos.x, aPos.y, 0.0, 1.0);
}
//片元着色器
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D screenTexture;
void main() {
vec3 col = texture (screenTexture, TexCoords).rgb;
FragColor = vec4(col, 1.0);
}
接下来,为四边形创建一个VAO备用,在渲染循环中走正常的流程将场景绘制到新帧缓存中,然后切换回默认帧缓存,绘制四边形:
// 第一步
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);
//绘制场景
...
// 第二步
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);
在第二步中,我们把深度测试关闭,因为我们根本不在乎深度测试,只是绘制一个四边形而已。其他的代码都很熟悉了,没啥好讲的,快编译运行看效果:
为了证明我们确实使用了帧缓存,调用
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);
开启线框模式,我们应该看到的是四边形线框而不是场景中的盒子和地板:线框模式运行效果
非常好,这就是我们要的效果。如果有问题,下载这里的源码进行比对。
有了帧缓存这个大杀器之后,我们可以在片元着色器中实现一些组合效果了,这些效果我们称之为后期处理特效。
后期处理特效
有了场景的渲染纹理之后,我们就能修改纹理数据实现特效了。本节中,我们会试着实现一些常用的特效,你也开动脑筋创造自己的效果。
好了,我们开始!
反相特效
这个特效的实现非常简单,只需要用1减去采样值就可以了,翻译成代码就是:
FragColor = vec4 (vec3 (1.0 - texture(texture1, TexCoords)), 1.0);
反转特效
整个场景因为一行代码的改动而面目全非,很爽,是不是?
灰度图特效
灰度图特效的实现方法也非常简单,将采样得到的红绿蓝分量(差点写成红黄蓝,只怪最近红黄蓝太火)相加,然后计算平均值作为新的红绿蓝分量:
FragColor = texture(texture1, TexCoords);
float average = (FragColor.r + FragColor.g + FragColor.b) / 3.0;
FragColor = vec4(average, average, average, 1.0);
运行效果
上面的图是用刚刚的代码渲染出来的效果,下面的图是用了另一个计算平均值的公式计算的,是不是感觉下面的图更加柔和?因为人眼对绿色更加敏感,而对蓝色却不太敏感,修正之后的计算公式调大了绿色分量的获取系数,减少了蓝色分量的获取系数从而调整了最终的显示效果:
float average = 0.2126 * FragColor.r + 0.7152 * FragColor.g + 0.0722 * FragColor.b;
核效果(Kernel Effects)
在一个纹理图像上做后期处理的另外一个好处是,我们可以从纹理的其它地方采样颜色值。比如说我们可以在当前纹理坐标的周围取一小块区域,对当前纹理值周围的多个纹理值进行采样。通过一些公式去计算就可以得到一些非常有趣的效果。
核(Kernel)(或者叫卷积矩阵(Convolution Matrix))是一个类似矩阵的数组,它相当于一个权重的矩阵,当我们采样了当前像素以及周围8个像素的值后,每个像素都会乘上这个矩阵中相应的权重值,然后将所有乘积都加起来作为当前像素的颜色值,最后输出。下面是核的一个例子:
一个核例子
你可以在网上找一些其他的核矩阵,不出意外的话,这些矩阵内的所有值加起来的和都是1,如果不是1那么就会有一个増亮或者调暗的效果。
下面是一个使用核的例子,阅读并且理解代码,你就能很好地掌握核效果的原理:
const float offset = 1.0 / 300.0;
void main()
{
//采样偏移
vec2 offsets[9] = vec2[] (
vec2(-offset, offset), //左上
vec2(0.0f, offset), //正上
vec2(offset, offset), //右上
vec2(-offset, 0.0f), //左
vec2(0.0f, 0.0f), //当前位置
vec2(offset, 0.0f), //右
vec2(-offset, -offset), //左下
vec2(0.0f, -offset), //正下
vec2(offset, -offset) //右下
);
//核矩阵
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(texture1, 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);
}
下面是运行效果:
锐化运行效果
可以看到,和之前的效果不一样,它有一个非常明显的锐化效果,尝试把代码中的核矩阵修改,你可以看到很多稀奇古怪的特效。
高斯模糊
这也是核效果中的一种,它的核矩阵是:
核矩阵
修改片元着色器的代码如下:
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
);
高斯模糊
这种模糊的效果非常有用,我们可以用来模拟有人喝醉酒后的世界,或者玩家摘掉眼镜后的效果。模糊效果也是一种非常好的平滑颜色的方法,这点我们会在之后的章节中介绍。
边缘检测
这也是核效果中的一种,核矩阵为:
边缘检测核矩阵
这个矩阵会将物体边缘高亮显示,其余地方变暗,如果我们只关心边缘部分,这对我们来说非常有用。
边缘检测效果
见识到核效果的强大之后,对于一些常见的图像处理工具(例如PS)将其用在图像处理上想必你也不会觉得奇怪了。由于显卡可以同时处理大量片元,因此我们更加容易处理图像的每个像素。图像编辑工具往往也更多地使用显卡来处理图像。
总结
很长的一篇文章,我们终于来到了尾声。本文中,我们学习了帧缓存的原理以及如何创建使用帧缓存。我们创建了纹理和渲染缓存对象供帧缓存使用。将场景渲染到帧缓存中后,我们还对其进行了一些后期处理以实现不同的效果,尤其是核效果,通过不同的核矩阵,计算出不同的像素颜色显示,实现了酷炫的特效。
参考资料
www.learnopengl.com(非常好的网站,建议学习)