1. 流程
达成了各种渲染效果以后,现在我们想要更进一步,对屏幕的每一个像素进行精准着色,或者对渲染后的图像再进行全局效果处理(比如镜子、模糊等效果),这些做法在之前是没有办法实现的,我们该怎么办?
其实OpenGL给出了一种数据缓冲——帧缓冲(Framebuffer),我们之前用的颜色缓冲、深度缓冲和模板缓冲就是包含在帧缓冲之中的,我们之前所做的渲染也都是在默认的帧缓冲中做的,完成一切渲染后,OpenGL就会从帧缓冲中读取数据,然后渲染到屏幕上的每一个像素
当然,想要做出自己的效果,我们肯定需要定义一个自己的帧缓冲,一个完整的帧缓冲包含:
附加至少一个缓冲(颜色、深度或模板缓冲)。
至少有一个颜色附件(Attachment)。
所有的附件都必须是完整的(保留了内存)。
每个缓冲都应该有相同的样本数。
附件其实就是相当于一个缓冲,可以是图像之类的,图像的一个像素就可以看作缓冲的一个元素,目前,OpenGL给出了两种附件,分别是纹理和渲染缓冲对象
1.1 纹理附件
创建纹理附件的过程和我们之前创建纹理的过程大同小异,只是我们这次是把它当作一个缓冲,而暂时不向其中写入任何数据
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);
可以看到,在glTexImage2D这句代码中,我们把宽和高设置为了屏幕大小,这样它就能覆盖屏幕的每个像素,然后我们把数据的参数设置为了NULL,这就使得它暂时是一个空的缓冲,然后设置完纹理,我们又利用glFramebufferTexture2D将这个附件附加到了帧缓冲上,其中第二个参数表明这个附件是个颜色类附件(代表我们将这个纹理作为我们帧缓冲中的颜色缓冲)
当然,我们也可以将这个纹理作为深度和模板缓冲:
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);
因为缓冲的数据每一位是32位,所以这里将深度缓冲和模板缓冲的存取格式设置为了深度24位,模板8位,这就使得它们可以在一个缓冲内读取
1.2 渲染缓冲对象
渲染缓冲对象是在纹理之后引入到OpenGL中的,它和纹理的区别就是它的数据都是原生的,也就是存取和处理都要比纹理附件要快,但它通常都是只写的,所以你不能读取它们(比如使用纹理访问)。当然你仍然还是能够使用glReadPixels来读取它,这会从当前绑定的帧缓冲,而不是附件本身,中返回特定区域的像素。
渲染缓冲对象的创建和纹理附件差不多,接下来我们就将深度和模板数据写入它
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);
1.3 帧缓冲
之前我们完成了两个附件的创建,将颜色、深度和模板缓冲都附加到了帧缓冲上,但是一直没有对帧缓冲的创建做说明,其实它的创建也和其他缓冲差不多:
unsigned int framebuffer;
glGenFramebuffers(1, &framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
完成帧缓冲创建之后,我们就接着完成之前提到的附件创建,并把它们附加到我们自己的帧缓冲上
接着我们检测一下这个帧缓冲完不完整:
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
cout << "ERROR::FRAMEBUFFER:: Framebuffer is not complete!" << endl;
如果完整的话,我们就可以进行下面的渲染环节了
1.4 渲染
其实利用我们自己的帧缓冲渲染是一件很简单的事,具体的基本渲染和我们之前的做法都相同,只是在做渲染之前,我们需要激活我们自己的帧缓冲:
while (!glfwWindowShouldClose(window))
{
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
做完各种场景渲染之后,颜色、深度和模板都被写入了我们的帧缓冲之中,但是这时候我们会发现没有渲染出来画面,这是怎么回事呢?
这是因为我们的帧缓冲不是默认帧缓冲,渲染指令将不会对窗口的视觉输出有任何影响。出于这个原因,渲染到一个不同的帧缓冲被叫做离屏渲染(Off-screen Rendering)。要保证所有的渲染操作在主窗口中有视觉效果,我们需要再次激活默认帧缓冲,将它绑定到0。
glBindFramebuffer(GL_FRAMEBUFFER, 0);
绑定完之后,我们接下来做什么呢?还记得我们之前将颜色缓冲绑定在了纹理附件上吗?我们在渲染时将所有屏幕的像素都存入了这个颜色缓冲,没错,接下来我们用画一个填充屏幕的矩形的方法,将之前已经写入的纹理画在这个矩形上,
首先当然是创建这个矩形的VAO,VBO:
float quadVertices[] = { // vertex attributes for a quad that fills the entire screen in Normalized Device Coordinates.
// positions // texCoords
-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
};
glGenVertexArrays(1, &quadVAO);
glGenBuffers(1, &quadVBO);
glBindVertexArray(quadVAO);
glBindBuffer(GL_ARRAY_BUFFER, quadVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(quadVertices), &quadVertices, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)(2 * sizeof(float)));
然后我们创建一个简单的输出纹理的着色器:
#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);
}
最后我们在完成场景渲染后绑定默认帧缓冲,画出这个矩形:
while (!glfwWindowShouldClose(window))
{
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
...
...
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDisable(GL_DEPTH_TEST); // disable depth test so screen-space quad isn't discarded due to depth test.
// clear all relevant buffers
glClearColor(1.0f, 1.0f, 1.0f, 1.0f); // set clear color to white (not really necessary actually, since we won't be able to see behind the quad anyways)
glClear(GL_COLOR_BUFFER_BIT);
shader.use();
shader.setInt("screenTexture", 0);
glActiveTexture(GL_TEXTURE0);
glBindVertexArray(quadVAO);
glBindTexture(GL_TEXTURE_2D, textureColorbuffer); // use the color attachment texture as the texture of the quad plane
glDrawArrays(GL_TRIANGLES, 0, 6);
效果:
和之前一样,没有区别,但是如果我们使用线框模式的话:
就会发现,我们其实画的是一个矩形,只是我们把之前渲染的画面作为纹理附加给这个矩形了而已
1.5 后期处理
1.5.1 反相
有了自己的帧缓冲,我们添加更多后期效果就更方便了,比如我们对颜色反相:
void main()
{
FragColor = vec4(vec3(1.0 - texture(screenTexture, TexCoords)), 1.0);
}
1.5.2 灰度图
比如我们做成灰度图:
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);
}
1.5.3 核效果
我们还可以在当前纹理坐标的周围取一小块区域,对当前纹理值周围的多个纹理值进行采样。
1.5.3.1 核卷积
比如核卷积,它是一个类矩阵的数值数组,它的中心为当前的像素,它会用它的核值乘以周围的像素值,并将结果相加变成一个值。所以,基本上我们是在对当前像素周围的纹理坐标添加一个小的偏移量,并根据核将结果合并。下面是核的一个例子:
这个核取了8个周围像素值,将它们乘以2,而把当前的像素乘以-15。这个核的例子将周围的像素乘上了一个权重,并将当前像素乘以一个比较大的负权重来平衡结果。
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[](
-2, -2, -2,
-2, 15, -2,
-2, -2, -2
);
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);
}
在片段着色器中,我们首先为周围的纹理坐标创建了一个9个vec2偏移量的数组。偏移量是一个常量,你可以按照你的喜好自定义它。之后我们定义一个核,在这个例子中是一个锐化(Sharpen)核,它会采样周围的所有像素,锐化每个颜色值。最后,在采样时我们将每个偏移量加到当前纹理坐标上,获取需要采样的纹理,之后将这些纹理值乘以加权的核值,并将它们加到一起。
因为权重和是-1,所以大部分是黑色的:
可以做一个权重和为1的效果
1.5.3.2 模糊
创建模糊(Blur)效果的核是这样的:
因为是模糊,所以不要忘了取加权平均:
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
);
1.5.3.3 边缘检测
下面的边缘检测(Edge-detection)核和锐化核非常相似:
这个核高亮了所有的边缘,而暗化了其它部分,且权重和为0,在我们只关心图像的边角的时候是非常有用的。
(注意,核在对屏幕纹理的边缘进行采样的时候,由于还会对中心像素周围的8个像素进行采样,其实会取到纹理之外的像素。由于环绕方式默认是GL_REPEAT,所以在没有设置的情况下取到的是屏幕另一边的像素,而另一边的像素本不应该对中心像素产生影响,这就可能会在屏幕边缘产生很奇怪的条纹。为了消除这一问题,我们可以将屏幕纹理的环绕方式都设置为GL_CLAMP_TO_EDGE。这样子在取到纹理外的像素时,就能够重复边缘的像素来更精确地估计最终的值了。)
1.5.4 马赛克像素风
实现马赛克风的原理很简单,马赛克就是将一块像素的颜色都设置为中心像素的颜色,当然,有一种简单的方法,那就是在渲染场景到我们的帧缓冲之前,先按比例缩小窗口,然后将我们自己的帧缓冲也设置为缩小的尺寸,接下来渲染场景到帧缓冲上,这一步相当于手动减小分辨率,然后还原视口大小,渲染帧缓冲
(注意,因为是像素风,所以帧缓冲的纹理采样必须是NEAREST,不能线性插值)