本文同时发布在我的个人博客上:
立方体贴图
立方体贴图是一张包含6个不同2D纹理的纹理,每个独立的纹理应用在一个立方体的每个面上。这样的立方体贴图的作用是什么?我们可以使用一个方向向量进行采样。想象一个
的立方体unity卡通天空盒,方向向量坐落于中心原点,用该方向向量采样纹理值就像下面这样:
如果有这么一个应用了立方体贴图的立方体,那这个方向向量就类似于立方体的顶点位置。这样我们就可以使用顶点位置向量来进行立方体贴图采样。因此,在贴图采样时,可以将立方体的顶点坐标作为纹理坐标使用,这样就可以得到对应于立方体贴图的每个独立面的纹理坐标。创建立方体贴图
和普通的纹理对象一样,我们使用glGenTextures和glBindTexture来创建和绑定立方体纹理对象,但我们将类别改为GL_TEXTURE_CUBE_MAP:
unsigned int textureID;
glGenTexture(1, &textureID);
glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);
由于立方体贴图包含6张纹理,我们需要调用6次glTexImage2D来生成纹理数据。OpenGL为立方体贴图设置了特定的纹理目标,代表6个面的方位:
我们可以事先定义好纹理文件的顺序unity卡通天空盒,按下面的方式遍历:
int width, height, nrChannels;
unsigned char *data;
for(GLuint i = 0; i < texture_faces.size(); i++)
{
data = stbi_load(textures_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);
}
立方体贴图的每个面的在枚举定义中使用顺序的,所以我们从GL_TEXTURE_CUBE_MAP_POSITIVE_X出发地图场景,每次+1。
和其它的纹理一样,我们为立方体贴图设置相关滤镜和映射方式(扩展到了3维,STR):
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);
设置映射方式时,我们使用GL_CLAMP_TO_EDGE来保证面与面之间的边不会有奇怪的采样值。
和普通的2d纹理一样,在渲染物体前激活并绑定纹理。
针对立方体贴图我们额外创建新的着色器。在片元着色器中,我们使用新的采样方式samplerCube来定义立方体贴图。这里,我们不再使用2维纹理坐标,而是使用vec3定义:
in vec3 textureDir; // 方向向量代表纹理坐标
uniform samplerCube cubemap; // 立方体贴图采样器
void main()
{
FragColor = texture(cubemap, textureDir);
}
立方体贴图就介绍到这里。针对立方体贴图的一个应用就是创建天空盒。
天空盒
天空盒是一个使用立方体贴图技术来展现整个场景的立方体,可以给玩家一种世界很大的感觉(其实场景不大),比如上古卷轴3的星空:
构成天空盒的6张纹理大概是像这样:
加载天空盒
我们使用立方体贴图来实现天空盒,所以加载纹理的方式就和上述一致。不过我们将代码封装起来实现一个方法,传入纹理所在的vector位置:
unsigned int loadCubemap(vector faces)
{
unsigned int textureID;
glGenTextures(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 tex 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;
}
方法返回配置好的天空盒纹理对象。
我们使用上面那6张纹理图片构成天空盒,定义一个vector,并使用上面的方法创建纹理:
vector faces;
{
"right.jpg",
"left.jpg",
"top.jpg",
"bottom.jpg",
"front.jpg",
"back.jpg"
};
unsigned int cubemapTexture = loadCubemap(faces);
绘制天空盒
由于天空盒也是一个立方体,这里我们定义它的顶点数据(只需定义位置,我们使用位置来映射纹理),并创立VAO,VBO。
我们让天空盒保持在原点,顶点着色器的定义非常简单:
#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);
}
片元着色器的配置也很简单,我们传入三维纹理坐标,定义天空盒的采样器(sanmplerCube类型):
#version 330 core
out vec4 FragColor;
in vec3 TexCoords;
uniform samplerCube skybox;
void main()
{
FragColor = texture(skybox, TexCoords);
}
那么接下来我们在渲染循环中,激活天空盒纹理并绑定,接着绘制天空盒。由于我们将天空盒的内容作为背景显示,所以我们首先绘制天空盒并禁止深度缓冲写入(天空盒是
的立方体,不这么做会在场景中显示为一个正常的立方体):
glDepthMask(GL_FALSE);
skyboxShader.use();
// ... 设置天空盒的view和projection矩阵
glBindVertexArray(skyboxVAO);
glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture);
glDrawArrays(GL_TRIANGLES, 0, 36);
glDepthMask(GL_TRUE);
// ... 绘制剩余的物体
但如果就这么运行程序会发现一个问题。我们想让天空盒在正中心,无论观察者如何接近,天空盒的显示都不会变大。但当前的view矩阵将会改变天空盒的位置,所以观察者移动时,天空盒也会移动。为了达成这一目的,我们将view矩阵降维处理来移除影响移动的部分:
glm::mat4 view = glm::mat4(glm::mat3(camera.GetViewMatrix()));
最后的结果如下:
优化
我们现在在渲染中是先绘制天空盒再绘制其它物体,但这样效率并不高。如果我们先绘制天空盒的话,屏幕上的每一个天空盒的像素都会运行片元着色器人物立绘,但之后的物体会遮挡一部分像素,造成资源浪费。为解决这一问题,我们可以使用提前深度测试来尽早丢弃这些不会显示的片段。
为提升性能,我们最后绘制天空盒,这样,深度缓冲中保存这场景中所有物体的深度,我们就可以只绘制通过提前深度测试的天空盒片段。但问题是,由于天空盒是单元立方体,大多数片段可能都不会通过深度测试。如果关闭深度测试绘制天空盒也不是一个办法,因为天空盒将会覆盖场景中所有的物体。我们需要让深度缓冲知道天空盒的深度一直是1,这样只要有物体在天空盒前面,天空盒就会无法通过深度测试。
在之前的章节我们提到,透视除法在顶点着色器运行后执行,将gl_Position中的xyz坐标与w坐标相除。我们也提到透视除法后z坐标被充当为深度值。因此,我们可以将gl_Position的z坐标的值设为w坐标的值,这样在进行透视除法后,被充当深度值的z坐标将永远为1。我们这样修改天空盒的顶点着色器:
void main()
{
TexCoords = aPos;
vec4 pos = projection * view * vec4(aPos, 1.0);
gl_Position = pos.xyww;
}
同时,我们将深度测试的选项改为GL_LEQUAL,对于天空盒,深度缓冲中的值会被设为1,所以我们需要设为≤来保证天空盒可以通过深度测试。
原文参考代码:Code。
环境映射
我们可以使用立方体贴图来为环境中的物体添加环境反射和折射效果,这一技术被称为环境映射。
反射
下图展示了如何计算反射向量,并使用这一向量来采样立方体纹理:
我们基于法线和视线方向来计算反射向量。
我们选取一个立方体来实现这一效果。在它的片元着色器中我们使用reflect方法计算反射向量,并将反射向量作为纹理坐标映射天空盒纹理:
#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);
}
由于定义了法线,我们像往常一样定义这个立方体的顶点着色器:
#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;
[图片上传中...(cubemaps_refraction_theory.png-f8f845-1589012125318-0)]
void main()
{
Normal = mat3(transpose(inverse(model))) * aNormal;
Position = vec3(model * vec4(aPos, 1.0));
gl_Position = projection * view * vec4(Position, 1.0);
}
在设置后所有常规在操作后,记得在绘制立方体前绑定天空盒的纹理:
glBindVertexArray(cubeVAO);
glBindTexture(GL_TEXTURE_CUBE_MAP, skyboxTexture);
glDrawArrays(GL_TRIANGLES, 0, 36);
运行结果如下:
折射
另一种环境映射的方式为折射,方式如下:
和反射一样,重点在于计算折射向量R。基于每种介质的折射系数我们可以算出从一个介质进入另一个介质的光的折射率。下面给出一些常见的介质的折射系数:
我们想让立方体呈现玻璃的效果,即从空气进入玻璃的折射效果。我们可以使用GLSL内建的refract函数来简单地计算折射向量,片元着色器main方法如下:
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);
}
运行结果如下: