LearnOpenGL——帧缓冲
帧缓冲
用于写入颜色值的颜色缓冲、用于写入深度信息的深度缓冲和允许我们根据一些条件丢弃特定片段的模板缓冲。这些缓冲结合起来叫做帧缓冲(Framebuffer),它被储存在GPU内存中的某处。我们可以自己定义frame buffer来实现独特的效果。
一、创建一个帧缓冲
与VBO、VAO类似,我们需要定义一个Unsigned int 类型的fbo,使用glGenFramebuffers函数来创建帧缓冲对象FBO
unsigned int fbo;
glGenFramebuffers(1, &fbo);
我们创建FBO对象之后,需要使用glBindFramebuffer函数将它绑定为激活的帧缓冲,再经过一系列操作之后,解绑帧缓冲
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
......
glDeleteFramebuffers(1, &fbo);
在绑定到GL_FRAMEBUFFER目标之后,所有的读取和写入帧缓冲的操作将会影响当前绑定的帧缓冲。但是我们现在还不能使用这个帧缓冲(不完整),一个完整的帧缓冲需要满足以下的条件:
- 附加至少一个缓冲(颜色、深度或者模板缓冲)
- 至少一个颜色附件
- 所有附件都必须是完整的(保留了内存)
- 每个缓冲都应该有相同的样本数(sample)
我们需要为帧缓冲附加一个附件,附件是一个内存位置,作为帧缓冲的其中一个缓冲,可以想象为一张图片。附件有两种:纹理附件、渲染缓冲对象。见后面介绍。
我们可以使用glCheckFramebufferStatus函数来检查帧缓冲是否完整,如果它返回的是GL_FRAMEBUFFER_COMPLETE,帧缓冲就是完整的了。
if(glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE)
检查资源完整之后所有的渲染操作将会渲染到当前绑定帧缓冲的附件中。此时帧缓冲还不是默认帧缓冲,所以不会有任何效果,我们可以通过“离屏渲染”——需要再次激活帧缓冲,绑定到0
glBindFramebuffer(GL_FRAMEBUFFER, 0);
纹理附件
当把一个纹理附加到帧缓冲的时候,所有的渲染指令将会写入到这个纹理中,就像它是一个普通的颜色/深度或模板缓冲一样。所有的渲染操作结果会被存在一个纹理图像中,在着色器中很方便用。
为帧缓冲创建一个纹理和普通创建纹理差不多。
主要区别是,我们将维度设置为屏幕大小(不是必须的)、没有真的图像纹理来填充(在glTexImage2D函数width和height是屏幕宽高、data处值为null);我们并不关心环绕方式或多级渐远纹理
unsigned int texture;
glGenTexture(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);
如果你想将你的屏幕渲染到一个更小或更大的纹理上,你需要(在渲染到你的帧缓冲之前)再次调用glViewport,使用纹理的新维度作为参数,否则只有一小部分的纹理或屏幕会被渲染到这个纹理上。
创建好纹理后,就可以附加到帧缓冲上。
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
- target:帧缓冲的目标(绘制、读取都有)
- attachment:附加的类型(此处为颜色附件)。如果是深度缓冲GL_DEPTH_ATTACHMENT,此时纹理格式和内部格式为GL_DEPTH_COMPONENT;若是模板缓冲GL_STENCIL_ATTACHMENT,纹理格式设置为GL_STENCIL_INDEX
- textarget:附加的纹理类型
- texture:附加的纹理本身
- level:mipmap级别,此处为0
也可以将深度和模板缓冲设置为单独的纹理,该纹理32位数值中24位为深度值,8位为模板信息。
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);
渲染缓冲对象附件
渲染缓冲对象(Renderbuffer Object)是在纹理之后引入到OpenGL中,和纹理图像一样,渲染缓冲对象是一个真正的缓冲,即一系列的字节、整数、像素等。渲染缓冲对象附加的好处是,它会将数据储存为OpenGL原生的渲染格式,它是为离屏渲染到帧缓冲优化过的。
渲染缓冲对象 直接将所有的渲染数据储存到它的缓冲中,让它变为一个更快的可写储存介质。渲染缓冲对象通常都是只写的,不能通过纹理访问读取,可以使用glReadPixels来读取它,这会从当前绑定的帧缓冲,而不是附件本身,中返回特定区域的像素
创建一个渲染缓冲对象,然后再绑定激活
unsigned int rbo;
glGenRenderbuffers(1,&rbo);
glBindRenderbuffer(GL_RENDERBUFFER,rbo);
由于渲染缓冲对象通常都是只写的,它们会经常用于深度和模板附件,当我们不需要从这些缓冲中采样的时候,通常都会选择渲染缓冲对象,因为它会更优化一点。创建一个深度和模板渲染缓冲对象可以通过调用glRenderbufferStorage函数来完成:
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, 800, 600);
最后一件事就是附加这个渲染缓冲对象
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);
如果你不需要从一个缓冲中采样数据,那么对这个缓冲使用渲染缓冲对象会是明智的选择。如果你需要从缓冲中采样颜色或深度值等数据,那么你应该选择纹理附件。
二、渲染到纹理
我们将会将场景渲染到一个附加到帧缓冲对象上的颜色纹理中,之后将在一个横跨整个屏幕的四边形上绘制这个纹理。
先创建帧缓冲,并绑定
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能够进行深度测试(如果你需要的话还有模板测试),所以我们还需要添加一个深度(和模板)附件到帧缓冲中。创建一个渲染缓冲对象,并将渲染缓冲对象附加到帧缓冲的深度和模板附件上:
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);
要想绘制场景到一个纹理上,我们需要采取以下的步骤:
- 将新的帧缓冲绑定为激活的帧缓冲,和往常一样渲染场景
- 绑定默认的帧缓冲
- 绘制一个横跨整个屏幕的四边形,将帧缓冲的颜色缓冲作为它的纹理
对于这个新四边形(纹理)我们会创建新的顶点和片元着色器
#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);
}
我们需要为这个屏幕四边形创建一个VAO来绘制
// screen quad VAO
unsigned int quadVAO, quadVBO;
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)));
在渲染循环中,首先配置帧缓冲区,这意味着接下来的渲染操作都会被绘制到这个帧缓冲区所附加的纹理上。
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
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);
接下来渲染后处理效果,绑定前面渲染场景所附加的颜色纹理texColorBuffer,然后将其绘制到一个屏幕空间四边形(即全屏四边形)上。screenShader通常会执行某些后处理效果,比如将场景转换为灰度、模糊等。
screenShader.use();
glBindVertexArray(quadVAO);
glBindTexture(GL_TEXTURE_2D, texColorBuffer);
glDrawArrays(GL_TRIANGLES, 0, 6);
三、后期处理
后处理效果通常是写在 screenShader 的片元着色器中。
1.反相
在片元着色器中,我们将会从屏幕纹理中取颜色值,然后用1.0减去它,对它进行反相
void main()
{
FragColor = vec4(vec3(1.0 - texture(screenTexture, TexCoords)), 1.0);
}
2.灰度
将颜色RGB分量平均,并重新赋值给fragColor,人眼会对绿色更加敏感一些,而对蓝色不那么敏感,所以为了获取物理上更精确的效果,我们需要使用加权的(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);
}
3.核效果
核(Kernel)(或卷积矩阵(Convolution Matrix))是一个类矩阵的数值数组,它的中心为当前的像素,它会用它的核值乘以周围的像素值,并将结果相加变成一个值。 核在对屏幕纹理的边缘进行采样的时候,由于还会对中心像素周围的8个像素进行采样,其实会取到纹理之外的像素。我们需要将环绕方式设置为GL_CLAMP_TO_EDGE
我们需要稍微修改一下片段着色器,让它能够支持核。我们假设使用的核都是3x3核(实际上大部分核都是):
锐化
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(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);
}
其中使用的核是锐化核
模糊
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
);
边缘检测
float kernel[9] = float[](
1, 1, 1,
1, -8, 1,
1, 1, 1
);
立方体贴图
立方体贴图就是一个包含了6个2D纹理的纹理,每个2D纹理都组成了立方体的一个面,可以通过方向向量(原点在立方体中心)来进行索引/采样。
一、创建立方体贴图
与其他纹理一样,我们需要创建一个纹理并绑定激活,只不过我们这次绑定到GL_TEXTURE_CUBE_MAP上
unsigned int textureID;
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);
因为立方体纹理有6个面,所以我们需要调用glTexImage2D函数6次,OpenGL提供了6个纹理目标
这个纹理坐标和很多OpenGL枚举一样,都是int线性传递的,所以可以从GL_TEXTURE_CUBE_MAP_POSITIVE_X开始遍历它们,在每个迭代中对枚举值加1,遍历了整个纹理目标
int width,heigth,nrChannels;
unsigned int *data;
for(unsigned int i = 0; i < texture_faces.size(); i++)
{
data = stbi_load(texture_faces[i].c_str(),
&width, &height, &nrChannels, 0);
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0,
GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
}
我们也需要设定其环绕和过滤方式
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
在片元着色器中,我们的采样器改为samplerCube,并且我们使用的纹理坐标向量是vec3而不是vec2
in vec3 textureDir; // 代表3D纹理坐标的方向向量
uniform samplerCube cubemap; // 立方体贴图的纹理采样器
void main()
{
FragColor = texture(cubemap, textureDir);
}
二、天空盒
1.加载天空盒
我们定义了一个加载天空盒的函数
unsigned int loadCubemap(vector<std::string> faces)
{
unsigned int textureID;
glGenTexture(1,&textureID);
glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);
int width, height, nrChannels;
for(unsigned int i = 0;i < faces.size(); i++)
{
unsigned char *data = stbi_load(faces[i].c_str(),
&width, &height, &nrChannels, 0);
if (data)
{
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i,
0, GL_RGB, width, height, 0, GL_RGB,
GL_UNSIGNED_BYTE, data
);
stbi_image_free(data);
}
else
{
std::cout << "Cubemap texture failed to load at path: " << faces[i] << std::endl;
stbi_image_free(data);
}
}
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
return textureID;
}
在调用函数之前我们需要将纹理路径指定到faces中
vector<std::string> faces
{
"right.jpg",
"left.jpg",
"top.jpg",
"bottom.jpg",
"front.jpg",
"back.jpg"
};
unsigned int cubemapTexture = loadCubemap(faces);
2.显示天空盒
针对天空盒,我们需要一组新的着色器。当立方体处于原点位置时,它每一个位置向量都是从原点出发的方向向量,所以我们只需要提供方向向量而不需要纹理坐标了。
对于顶点着色器,我们将输入的位置向量作为输出给片段着色器的纹理坐标。
#version 330 core
layout (location = 0) in vec3 aPos;
out vec3 TexCoords;
uniform mat4 projection;
uniform mat4 view;
void main()
{
TexCoords = aPos;
gl_Position = projection * view * vec4(aPos, 1.0);
}
对于片元着色器,我们将顶点着色器传过来的纹理方向向量用作采样
#version 330 core
out vec4 FragColor;
in vec3 TexCoords;
uniform samplerCube skybox;
void main()
{
FragColor = texture(skybox, TexCoords);
}
绘制天空盒时,我们需要将它变为场景中的第一个渲染的物体,并且禁用深度写入。这样子天空盒就会永远被绘制在其它物体的背后了。
glDepthMask(GL_FALSE);
skyboxShader.use();
//设置观察、投影矩阵
glBindVertexArray(skyboxVAO);
glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture);
glDrawArrays(GL_TRIANGLES, 0, 36);
glDepthMask(GL_TRUE);
// ... 绘制剩下的场景
但是,在运行时会发生玩家移动时,天空盒也会跟着移动,所以我们希望移除移动:将4×4的观察矩阵,取3×3的(移除位移部分),再变回4×4矩阵
glm::mat4 view = glm::mat4(glm::mat3(camera.GetViewMatrix()));
3.优化
相较于先把天空盒全部渲染出来再渲染其他物体,可以使用 提前深度测试(Early Depth Testing) 轻松丢弃掉的片段能够节省我们很多宝贵的带宽。所以,我们将会最后渲染天空盒,在提前深度测试通过的地方渲染天空盒的片段就可以了。(现代 OpenGL 驱动中,early-Z 是默认启用的,并且是自动进行的)
由于天空盒是一个1×1×1的立方体,也就意味着距离摄像机的距离也只有1,这样难免会有物体会绘制到立方体的后面,所以我们应该将立方体的深度值保持为最大1.0。
透视除法是在顶点着色器之后运行的,将gl_Position的xyz分量分别除以w,z/w即为顶点的深度值,我们可以将位置的z = w,这样深度值就会一直是1.0
void main()
{
TexCoords = aPos;
vec4 pos = projection * view * vec4(aPos, 1.0);
gl_Position = pos.xyww;
}
我们还要改变一下深度函数,将它从默认的GL_LESS改为GL_LEQUAL,我们需要保证天空盒在值小于或等于深度缓冲而不是小于时通过深度测试。
//skybox
glDepthFunc(GL_LEQUAL);
skyboxShader.use();
view = mat4(mat3(camera.GetViewMatrix()));
skyboxShader.setMat4("view", view);
skyboxShader.setMat4("projection", projection);
glBindVertexArray(skyboxVAO);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, skyboxTexture);
glDrawArrays(GL_TRIANGLES, 0, 36);
glBindVertexArray(0);
glDepthFunc(GL_LESS); // set depth function back to default
三、环境映射
通过使用环境的立方体贴图,我们可以给物体反射和折射的属性。这样使用环境立方体贴图的技术叫做环境映射(Environment Mapping),其中最流行的两个是反射(Reflection)和折射(Refraction)。
1.反射
根据观察方向I和法线向量N,可以通过内置函数Reflect函数来求得反射方向,然后通过这个反射方向来索引/采样立方体贴图,返回颜色值。
修改物体的顶点着色器。
- 因为使用了法向量,所以需要使用法线矩阵来变换
- Position输出向量是一个世界空间位置向量,用来在片段着色器内计算观察方向向量
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
out vec3 Normal;
out vec3 Position;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
Normal = mat3(transpose(inverse(model))) * aNormal;
Position = vec3(model * vec4(aPos, 1.0));
gl_Position = projection * view * model * vec4(aPos, 1.0);
}
然后修改物体的片元着色器
- 声明uniform类型的相机位置向量,以及将顶点着色器中世界空间中的位置坐标作为输入,计算观察方向
- 使用I来表示观察方向,使用反射方向R来采样天空盒贴图
#version 330 core
out vec4 FragColor;
in vec3 Normal;
in vec3 Position;
uniform vec3 cameraPos;
uniform samplerCube skybox;
void main()
{
vec3 I = normalize(Position - cameraPos);
vec3 R = reflect(I, normalize(Normal));
FragColor = vec4(texture(skybox, R).rgb, 1.0);
}
在绘制物体时,也要把skyboxTexture绑定绘制
2.折射
折射是通过斯涅尔定律(Snell’s Law)来描述的,使用环境贴图的话看起来像是这样:
折射方向可以通过内置函数refract来实现,该函数需要一个法向量、一个观察方向、两个材质的折射率比值(折射率决定了光线的弯曲程度),比如光线从空气进入玻璃,那么折射率比就是1.00/1.52
直接修改物体的片元着色器就好
#version 330 core
out vec4 FragColor;
in vec3 Normal;
in vec3 Position;
uniform vec3 cameraPos;
uniform samplerCube skybox;
void main()
{
float ratio = 1.00/1.52;
vec3 I = normalize(Position - cameraPos);
vec3 R = refract(I,normalize(Normal),ratio);
FragColor = vec4(texture(skybox,R).rgb, 1.0);
}
如果要想获得物理上精确的结果,我们还需要在光线离开物体的时候再次折射,现在我们使用的只是单面折射(Single-side Refraction)