5 - 帧缓冲

之前学过的用于写入颜色值的颜色缓冲、写入深度信息的深度缓冲和允许根据特定条件丢弃片段的模板缓冲,结合起来就叫做 帧缓冲(Frame Buffer)。一个帧缓冲可以拥有多个颜色附件和一个深度附件,但是默认最终只有 0 号帧缓冲会被输出到屏幕。

目前我们所做的所有操作都是在默认帧缓冲上进行的,在创建窗口时被生成和配置(GLFW自动帮我们做好了)。有了我们自己的帧缓冲,就能有更多方式来渲染场景。

帧缓冲的创建

创建与绑定帧缓冲对象

使用glGenFramebuffers()来创建一个帧缓冲对象(FBO, Framebuffer Object):

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

接着用glBindFramebuffer()来将之前创建的FBO绑定起来:

glBindFramebuffer(GL_FRAMEBUFFER, FBO);

绑定好GL_FRAMEBUFFER后,所有的 读取(如glReadPixels()操作) 和 写入(如渲染、清除写入等操作) 帧缓冲的操作都会影响当前绑定的帧缓冲(当然,要想分开控制它们,可以绑定GL_READ_FRAMEBUFFER/GL_WRITE_FRAMEBUFFER)。

检查完整性

在使用帧缓冲前,需要了解一个 完整的 帧缓冲需要满足什么条件:

  • 附加至少一个缓冲(颜色/深度/模板缓冲)
  • 至少有一个颜色附件(Attachment)
  • 所有的附件必须是完整的(不能是空指针)
  • 每个缓冲都有相同的样本数

可以通过glCheckFramebufferStatus()来检查当前绑定的帧缓冲是否完整:

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

这里可以查找该函数的返回值释义,其中GL_FRAMEBUFFER_COMPLETE表示当前绑定的帧缓冲是完整的,可以使用它。

添加附件

为了让帧缓冲完整,可以被使用,我们需要在检查完整性之前给它添加一些附件。附件是一个内存位置,主要有两种形式:纹理或渲染缓冲对象(RBO,Renderbuffer Object)

纹理附件

当把一个纹理附加到帧缓冲时,所有的渲染指令将会写入到这个纹理中。使用纹理的优点是,所有渲染操作的结果将会储存在一个纹理图像中,之后能在着色器中很方便地使用它。

为帧缓冲创建一个纹理附件,只需开辟一块没有纹理数据的纹理内存即可:

// 纹理附件
unsigned int textureAttach;
glGenTextures(1, &textureAttach);
glBindTexture(GL_TEXTURE_2D, textureAttach);
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);

如果你想将你的屏幕渲染到一个更小或更大的纹理上,你需要(在渲染到你的帧缓冲之前)再次调用glViewport(),使用纹理的新维度作为参数,否则只有一小部分的纹理或屏幕会被渲染到这个纹理上。

然后使用glFramebufferTexture2D()将它附加到帧缓冲上:

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

该函数的参数含义如下:

  • target:要设置的帧缓冲
  • attachment:想要附加的附件类型。这里是颜色附件,从末尾的0可以发现我们能附加多个颜色附件
  • textarget:希望附加的纹理类型
  • texture:要附加的纹理对象本身
  • level:Mipmap的级别,目前将它保留为0

除了颜色附件外,也能附加深度纹理(GL_DEPTH_ATTACHMENT, GL_DEPTH_COMPONENT)和模板纹理(GL_STENCIL_ATTACHMENT, GL_STENCIL_INDEX)到帧缓冲中。同时注意到深度信息一般为24位,模板信息一般为8位,将它们组合起来刚好满足纹理信息的32位,因此也能将它们组合起来添加到帧缓冲中:

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

渲染缓冲对象部件

和纹理附件一样,RBO也是一个真正的缓冲(即一块内存)。它将数据储存为OpenGL原生的渲染格式,且被离屏渲染相关环节优化过。

离屏渲染(Off-screen Rendering):由于我们当前操作的帧缓冲不是默认帧缓冲,渲染指令将不会对窗口的视觉输出有任何影响,因此渲染这个帧缓冲的操作就叫做离屏渲染。

RBO直接储存所有的原生渲染数据,不会做任何针对纹理格式的转换,因而读写效率更快(可以用它实现glfwSwapBuffers())。虽然它是只写的,但能通过glReadPixels()在当前绑定的帧缓冲中间接读取到特定区域的像素。

创建并绑定一个RBO的代码如下:

unsigned int RBO;
glGenRenderbuffers(1, &RBO);
glBindRenderbuffer(GL_RENDERBUFFER, RBO);

接下来想想应该把什么东西放到RBO里。考虑到RBO是只写的,而深度/模板值也只是需要写入,而没必要拿出来采样,因此RBO十分适合它们。

可以用glRenderbufferStorage()来创建一个包含深度和模板缓冲对象的RBO:

glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, 800, 600);

最后用glFramebufferRenderbuffer()将RBO附加到帧缓冲上:

glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, RBO);

RBO可为帧缓冲对象提供一些优化,通常的规则是,如果你不需要从一个缓冲中采样数据,就用RBO;反之则选择纹理附件

最后做的事

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

glBindFramebuffer(GL_FRAMEBUFFER, 0);

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

glDeleteFramebuffers(1, &fbo);

帧缓冲的使用

现在这个帧缓冲就完整了,只要绑定这个帧缓冲对象,接下来的渲染指令将会影响这个帧缓冲而不是默认的。使用自己创建的帧缓冲步骤如下:

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

// 片段着色器
#version 330 core
out vec4 FragColor;

in vec2 TexCoords;

uniform sampler2D screenTexture;

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

这里是读取帧缓冲的纹理附件;如果想要写入纹理附件的话,可以用gl_FragData[x]向当前 draw call 对应的帧缓冲的第 x 号颜⾊附件进⾏写⼊。

在渲染循环中,开始编写相关代码:

// 第一步: 绑定新的帧缓冲
glBindFramebuffer(GL_FRAMEBUFFER, FBO);
glEnable(GL_DEPTH_TEST);
glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);   

// 画场景......

// 第二步: 绑定默认帧缓冲
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDisable(GL_DEPTH_TEST);   // 关闭深度测试,防止屏幕四边形被丢掉
glClearColor(1.0f, 1.0f, 1.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);

// 第三步: 画屏幕四边形
screenShader.use();
glBindVertexArray(quadVAO);
glBindTexture(GL_TEXTURE_2D, textureAttach);
glDrawArrays(GL_TRIANGLES, 0, 6);

结果如下:

后期处理

接下来就能利用四边形的screen.frag片段着色器进行各种各样的后期处理了。

直接处理

反色

实现反色很简单,只需用1.0减去颜色值就好了:

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

灰度

移除场景中除了黑白灰以外所有的颜色,让整个图像灰度化(Grayscale)。可以通过取所有颜色分量的平均值来实现:

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

效果如下:

使用平均值可能不科学,由于人眼对绿色更敏感,对蓝色不为敏感,可以对它们分别加权以显得更加真实:

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

最终效果如下:

卷积处理

可以使用一个卷积核(Convolution Kernel)(也称卷积矩阵(Convolution Matrix))来对图像进行卷积处理。它通常是3x3的矩阵,中心为当前像素,它会用它的核值乘以对应的像素值,并相加,最后权值必须为1,否则会造成能量不守恒(变亮/暗了)

例如一个卷积核如下: \[ \nonumber \begin{bmatrix} 2 & 2 & 2\\ 2 & -15 & 2\\ 2 & 2 & 2 \end{bmatrix} \] 这个核取了8个周围像素值,将它们乘以2,而把当前的像素乘以-15。这个核的例子将周围的像素乘上了一个权重,并将当前像素乘以一个比较大的负权重来平衡结果。

有了核的实现,创建炫酷的后期处理特效是非常容易的事情。

为了能在着色器中使用卷积处理,先定义一个偏移量数组,它用于取中心像素周围的像素值:

const float offset = 1.0 / 300.0;  
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[](
    // ...
);

接下来就能按卷积算法来算了:

// 先算分量
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);

锐化

一个锐化(Sharpen)核的示例如下: \[ \nonumber \begin{bmatrix} -1 & -1 & -1\\ -1 & 9 & -1\\ -1 & -1 & -1 \end{bmatrix} \] 它会采样周围的所有像素,锐化每个颜色值。结果如下:

模糊

一个模糊(Blur)核的示例如下: \[ \nonumber \begin{bmatrix} 1 & 2 & 1\\ 2 & 4 & 2\\ 1 & 2 & 1 \end{bmatrix} /16 \] 由于该核的权值和为16,为了防止画面过亮,需要将核的每个值都除以16。它的效果如下:

边缘检测

边缘检测(Edge-detection)核的示例如下,它和锐化核很相似: \[ \nonumber \begin{bmatrix} 1 & 1 & 1\\ 1 & -8 & 1\\ 1 & 1 & 1 \end{bmatrix} \] 该核高亮了所有边缘,暗化了其他部分,效果如下:

注意到屏幕的边缘出现了一些彩色条纹,原因是卷积的时候还对屏幕外的像素进行采样,由于设置纹理环绕方式是GL_REPEAT,它会采样到对面的纹理。解决这类问题的一个方案就是禁止对屏幕外的像素进行采样:

vec2 edge = TexCoords.st + offsets[i];
if (edge.x < 0 || edge.x > 1 || edge.y < 0 || edge.y > 1)
    sampleTex[i] = vec3(0.0);
else
    sampleTex[i] = vec3(texture(screenTexture, TexCoords.st + offsets[i]));

效果如下:

参考资料

  • 帧缓冲 - LearnOpenGL CN (learnopengl-cn.github.io)