概念:
帧缓冲可以理解为着色器渲染之后将要显示在窗口上的所有颜色信息,深度信息和模版信息的数据集合,这些数据都保存在内存中,最后经由显示器显示在窗口中。窗口都有一个默认的帧缓冲,来存放最终要显示的所有信息。
OpenGL允许用户自定义帧缓冲,用户自定义帧缓冲有什么用呢?试想,如果你想实现这样一个功能,就是希望能够在最终显示在窗口中的画面的基础上再对每一帧图片进行处理,也就相当于动态的后处理,对于这种需求,自定义帧缓冲就能实现。
原理:
具体的实现可以是这样:
- 自定义一个帧缓冲;
- 绑定自定义帧缓冲(默认情况下绑定的是默认帧缓冲);
- 场景渲染(渲染结束之后我们自定义帧缓冲中就有了帧缓冲数据);
- 切换到默认帧缓冲(绑定默认帧缓冲,最终窗口要通过交换默认帧缓冲数据来显示的);
- 将自定义帧缓冲中的数据以一个纹理的形式传给着色器,在片段着色器中就可以进行相应处理;
就像是GDI中的双缓冲绘图一样,先在内存DC里面处理所有的绘图操作,然后再通过Bitblt函数将要显示的东西copy到窗口DC。其实自定义帧缓冲相当于内存DC,默认帧缓冲相当于窗口DC。
代码:
在OpenGL中,帧缓存里面包含了颜色缓存,深度缓存和模板缓存,它是这些数据的集合。颜色缓冲我们可以理解为:它里面包含了像素颜色信息;深度缓冲里面是深度测试相关的信息;模板缓冲里面是模板测试所需的信息。所以在代码里面这些都会有涉及到,下面我们看一下如何定义这些缓冲:
1.创建帧缓冲
GLuint fbo;
glGenFramebuffers(1, &fbo); //生成帧缓冲对象
这里创建了帧缓冲对象,此时这个帧缓冲是不完整的,它需要满足一下条件才可以算定义完成:
- 必须往里面加入至少一个附件(颜色、深度、模板缓冲);
- 其中至少有一个是颜色附件;
- 所有的附件都应该是已经完全做好的;
- 每个缓冲都应该有同样数目的样本;
所以我们还需要至少定义一个颜色缓冲才可以使用我们的自定义帧缓冲。
2.创建颜色缓冲
GLuint texture;
glGenTextures(1, & texture); //和创建普通纹理一样
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, WIDTH, 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);
我们看到在glTextImage2D函数中我们的最后一个参数给了NULL,在我们以往设置普通纹理的时候这个参数都传的是一个指针(图片数据的地址),这里只是分配了内存,并没有实际数据填入,实际数据会在渲染过程中填入,最后才显示在窗口中。
3.将纹理(颜色缓冲)附加到帧缓冲上
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHENT0, GL_TEXTURE_2D, texture, 0);
注意上面函数中的参数,GL_FRAMEBUFFER,即帧缓冲类型,GL_COLOR_ATTACHENT0指颜色缓冲类型,texture就是我们实际绑定的一个纹理,该函数实现了将颜色缓冲附加到帧缓冲上。实际上,我们这个缓冲现在就可以用了,因为已经满足了他的使用条件,但是如果我们的渲染需要深度测试和模版测试,我们还需要将深度测试和模板测试附加到帧缓冲上。
4.将深度测试缓冲和模板测试缓冲附加到帧缓冲上
渲染缓冲对象:渲染缓冲对象将所有渲染数据直接储存到它们的缓冲里,而不会进行针对特定纹理格式的任何转换,这样它们就成了一种快速可写的存储介质了。因为它们的数据已经是原生格式了,在写入或把它们的数据简单地到其他缓冲的时候非常快。当使用渲染缓冲对象时,像切换缓冲这种操作变得异常高速。
创建渲染缓冲对象:
GLuint rbo;
glGenRenderbuffers(1, &rbo);
相似地,我们打算把渲染缓冲对象绑定,这样所有后续渲染缓冲操作都会影响到当前的渲染缓冲对象:
glBindRenderbuffer(GL_RENDERBUFFER, rbo);
由于渲染缓冲对象通常是只写的,它们经常作为深度和模板附件来使用,由于大多数时候,我们不需要从深度和模板缓冲中读取数据,但仍关心深度和模板测试。我们就需要有深度和模板值提供给测试,但不需要对这些值进行采样(sample),所以深度缓冲对象是完全符合的。当我们不去从这些缓冲中采样的时候,渲染缓冲对象通常很合适,因为它们等于是被优化过的。
调用glRenderbufferStorage函数可以创建一个深度和模板渲染缓冲对象:
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, WIDTH, WEIGHT);
附加到帧缓冲对象上:
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);
5.检查帧缓冲是否可用
if(glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
cout << "ERROR::FRAMEBUFFER:: Framebuffer is not complete!" << endl;
通过glCheckFramebufferStatus函数我们可以判断我们所定义的帧缓冲是否可用。
实际代码调用如下:
// 绑定我们自定义的帧缓冲,开始渲染,渲染的结果会保存到我们自定义的帧缓冲中。
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);
// 我们自定义的帧缓冲中的颜色缓冲中已经有了我们最终的渲染结果,并已经以纹理的方式保存在了Colorbuffer中,
// 这里用另外一个着色器程序,再次渲染,就是把我们得到的纹理贴到一个四边形上,所以在片段着色器中我们就可以再次处理这个结果,
// 最终得到一些特殊的效果。
screenShader.Use();
glBindVertexArray(quadVAO);
glDisable(GL_DEPTH_TEST);
glBindTexture(GL_TEXTURE_2D, textureColorbuffer);
glDrawArrays(GL_TRIANGLES, 0, 6);
glBindVertexArray(0);
6.后期处理
前面我们说过通过帧缓冲我们可以实现一些有趣的效果,现在我们应该明白所谓的后期处理就是当场景第一次在我们自定义的帧缓冲中渲染结束之后,再次将这个渲染结果以纹理的形式传给片段着色器,片段着色器再次对图片进行加工。我们常见的后期处理如反相、灰度、模糊、边检测,这些效果都可以通过后期的处理得到。下面我们就了解一下他们都是如何实现的:
反相:我们已经取得了渲染输出的每个颜色,所以在片段着色器里返回这些颜色的反色(Inversion)并不难。我们得到屏幕纹理的颜色,然后用1.0减去它。
void main()
{
color = vec4(vec3(1.0 - texture(screenTexture, TexCoords)), 1.0);
}
在片段着色器里面我们做如上代码操作就可以实现反相的效果,下图中右上角表示反向后的效果。
灰度:移除所有除了黑白灰以外的颜色作用,是整个图像成为黑白的。实现它的简单的方式是获得所有颜色元素,然后将它们平均化。
void main()
{
color = texture(screenTexture, TexCoords);
float average = (color.r + color.g + color.b) / 3.0;
color = vec4(average, average, average, 1.0);
}
这已经创造出很赞的效果了,但是人眼趋向于对绿色更敏感,对蓝色感知比较弱,所以为了获得更精确的符合人体物理的结果,我们需要使用加权通道:
void main()
{
color = texture(screenTexture, TexCoords);
float average = 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b;
color = vec4(average, average, average, 1.0);
}
最终的效果如下所示:
我们在后期处理的时候都是对已有的一个纹理进行一些操作,所以我们就可以采样纹理上的颜色来实现一些效果,如何采样这些颜色并对这些颜色怎样操作,最终能产生怎样的效果。首先我们先了解一下 Kernel,它其实就像是一个矩阵,如下是一个Kernel的例子:
这个Kernel是一个3×3的矩阵,中间那个-15代表我们要处理的像素,在它周围有8个采样点,这8个采样点通过2这个因子来影响我们中间那个像素的颜色。假设我们在相应位置采样得到的这些点上的颜色如下:
我们把a、b、c这些都看成是一个个颜色向量,那么我们最终得到中间点的颜色就是这样的:
我们可以看到Kernel里面的这些因子(矩阵中每个元素)都会影响到最终的颜色,所以不同的Kernel就能对最终颜色产生不同的效果,网上也有一些其他Kernel的例子,当然我们也可以自己设置满足自己需求的Kernel。如果对Kernel有什么疑问,可以从 这里了解相关原理。
kernel对于后处理来说非常管用,因为用起来简单。网上能找到有很多实例,为了能用上kernel我们还得改改片段着色器。这里假设每个kernel都是3×3(实际上大多数都是3×3):
const float offset = 1.0 / 300;
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);
//注意这里定义的临时col一定要初始化,否则会出问题,当时调试也是在这里出的问题,还是一位前辈给我指出了问题,非常感谢。
for(int i = 0; i < 9; i++)
{
col += sampleTex[i] * kernel[i];
}
color = vec4(col, 1.0);
}
片段着色器中的代码如上所示,这是一个锐化效果的Kernel,效果如下图所示:
模糊:通过Kernel我还可以实现模糊的效果,模糊Kernel如下所示:
在片段着色器中我们只需要把之前锐化的那个Kernel替换成模糊Kernel就可以实现模糊效果了,模糊Kernel代码如下:
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
);
下面是一个边检测Kernel与锐化Kernel类似:
同样我们改变片段着色器中的Kernel代码:
float kernel[9] = float[](
1, 1, 1,
1, -8, 1,
1, 1, 1
);
通过以上内容我们可以看到,帧缓冲给我们提供了后期处理的机会;同样,Kernel提供了一种更加方便的后期处理方式。因此,我们可以使用以上技术来制作我们想要的后期效果。