本文主要解决3个问题:
1、纹理盒(Cubemaps)如何使用?
2、如何实现天空盒?
3、如何实现环境纹理映射效果?
引言
到目前为止,我们使用的都是2D的纹理,可能你已经习惯了,也觉得2D纹理使用起来非常顺手。不过,在这章中,我要向你推荐另一种纹理的格式:纹理盒(Cubemaps).
有人把Cubemaps翻译成立方体贴图,笔者觉得这个翻译太粗糙了,不如翻译成纹理盒更让人容易接受。
纹理盒(Cubemaps)
本质上,纹理盒就是在一个立方体盒子上贴了6张2D纹理图。你可能会奇怪,这样做比使用6张纹理有什么优势吗?当然有优势,最大的优势就是可以使用一个方向向量来对纹理进行采样,像这样:
如果我们有一个盒子绑定了纹理盒,显示纹理的时候,我们用盒子的顶点位置就可以对绑定其上的纹理盒进行采样,只要盒子的中心在原点的位置。
创建纹理盒
创建纹理盒的方式和创建一张纹理图的方式非常相似,首先必须要创建一个纹理ID,然后绑定到纹理盒类型:
unsigned int textureID;
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);
因为纹理盒有6个张独立的纹理图,我们就要调用glTexImage2D函数6次来设置好每一个面的纹理。设置纹理的方式是:
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X,0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
GL_TEXTURE_CUBE_MAP_POSITIVE_X表示设置的是x轴正方向的纹理图,也就是盒子右边的纹理。其他方向的纹理可以通过下表中的参数来设置:
纹理对象参数 | 方向 |
---|---|
GL_TEXTURE_CUBE_MAP_POSITIVE_X | 右方 |
GL_TEXTURE_CUBE_MAP_NEGATIVE_X | 左方 |
GL_TEXTURE_CUBE_MAP_POSITIVE_Y | 上方 |
GL_TEXTURE_CUBE_MAP_NEGATIVE_Y | 下方 |
GL_TEXTURE_CUBE_MAP_POSITIVE_Z | 后方 |
GL_TEXTURE_CUBE_MAP_NEGATIVE_Z | 前方 |
好消息是,上表中的这些参数数据都是连续的,这就意味着我们可以用一个for循环来设置这些纹理:
int width, height, nrComponents;
for (GLuint i = 0; i < fullpathVec.size(); ++i) {
unsigned char * data = stbi_load(fullpathVec[i].c_str(), &width, &height, &nrComponents, 0);
if (data) {
GLenum format;
if (nrComponents == 1)
format = GL_RED;
else if (nrComponents == 3)
format = GL_RGB;
else if (nrComponents == 4)
format = GL_RGBA;
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, format, width, height, 0, format, GL_UNSIGNED_BYTE, data);
stbi_image_free(data);
}
else {
std::cout << "纹理加载失败,路径是:" << fullpathVec[i] << std::endl;
stbi_image_free(data);
}
}
我们把要用到的纹理路径按顺序放到fullPathVec容器中,然后循环加载纹理,不出意外的话,纹理盒的每个面都会被成功加载。
最后,还要记得设置纹理的过滤和环绕方式,因为我们用的是纹理盒,所以除了平常使用的GL_TEXTURE_WRAP_S和GL_TEXTURE_WRAP_T之外,还需要设置GL_TEXTURE_WRAP_R的属性。设置方式没变,设置的值也和GL_TEXTURE_WRAP_S一样,这点无需担心:
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);
使用纹理盒的方法和使用普通纹理一样,在需要用到的地方调用glBindTexture,把参数指明为GL_TEXTURE_CUBE_MAP即可。
在片元着色器内,我们的采样源从2D纹理变成了纹理盒,与之对应的,输入坐标需要使用vec3格式,采样变量需要使用samplerCube格式,采样的方式不变,综合起来的结果就是这样:
in vec3 textureDir;
uniform samplerCube cubemap;
void main()
{
FragColor = texture(cubemap, textureDir);
}
好了,创建和使用纹理盒的方法都已经介绍过了,下面就开始使用,我们的第一个目标是实现一个天空盒。
天空盒
这是纹理盒最重要的一个应用案例。天空盒就是一个非常大的纹理盒,将整个场景都包在里面,只要玩家在场景里移动,他往各个方向看去都可以看到天空。天空盒使用的纹理可以不只有天空,也可以有树木、海洋、高山等等的纹理,只要让置身其中的玩家无法察觉是假的就可以了。
上面这是一幅天空盒的示意图,你可以到这个网站上去下载自己喜欢的天空盒使用。在本文中笔者就使用上面这个纹理,喜欢的童鞋尽管到这里下载使用。
下载的天空盒资源包通常会是6张不同方位的纹理图,我们在加载的时候要严格按照顺序将不同的纹理图对应到纹理盒的不同方位上。
为了方便重用,笔者将加载纹理盒的代码封装到一个函数中,输入的参数是纹理路径的数组,输出加载完成后的纹理ID:
//加载纹理盒
unsigned int loadCubemapsTexture(const std::vector<std::string>& fullpathVec) {
unsigned int textureID;
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);
int width, height, nrComponents;
for (GLuint i = 0; i < fullpathVec.size(); ++i) {
unsigned char * data = stbi_load(fullpathVec[i].c_str(), &width, &height, &nrComponents, 0);
if (data) {
GLenum format;
if (nrComponents == 1)
format = GL_RED;
else if (nrComponents == 3)
format = GL_RGB;
else if (nrComponents == 4)
format = GL_RGBA;
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, format, width, height, 0, format, GL_UNSIGNED_BYTE, data);
stbi_image_free(data);
}
else {
std::cout << "纹理加载失败,路径是:" << fullpathVec[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;
}
实现的代码非常容易理解。我们接着讲讲怎么使用。
根据上面的参数表,纹理文件顺序应该是:右、做、上、下、后、前。并且存放到数组中的必须是文件的完整路径方便读取,这样,我们使用的方法也就渐渐明确了:
//加载纹理盒
std::vector<std::string> fullpathVec;
fullpathVec.push_back(getFullPath("right.jpg"));
fullpathVec.push_back(getFullPath("left.jpg"));
fullpathVec.push_back(getFullPath("top.jpg"));
fullpathVec.push_back(getFullPath("bottom.jpg"));
fullpathVec.push_back(getFullPath("back.jpg"));
fullpathVec.push_back(getFullPath("front.jpg"));
unsigned int cubemapTexture = loadCubemapsTexture(fullpathVec);
至此,我们就可以绑定cubemapTexture来使用天空盒了。
绘制天空盒
要绘制天空盒,我们需要一组新的VAO,VBO和顶点数据,顶点数据请到这里获取,VAO和VBO就需要你自己创建了。
要对一个3D盒子使用纹理盒有一个巨大的好处就是不需要额外指定纹理坐标。只要盒子是被放置在世界原点上,盒子本身的坐标就可以作为纹理坐标使用,因为在3D世界中位置本身就是一个向量,表示一个方向,我们要的就是这个方向。
在线,我们需要一组新的着色器用来绘制天空盒。因为我们的点只有一个属性,所以顶点着色器就非常简单了:
#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);
}
好,顶点着色器和片元着色器都准备完后,接着就是绘制的操作。在绘制天空盒的时候,我们一定要先进行绘制,因为天空总是在最远处的。并且,我们要不能把其深度值写入到深度缓存中。为啥?因为我们是假造一种天空在很远出的感觉,但其真实的位置就只是在原点处,大小为1的一个盒子。如果此时将深度写入缓存,那么离得远的物体就无法通过深度测试,进而无法绘制了。具体实现方式如下:
glDepthMask(GL_FALSE);
skyboxShader.use();
skyboxShader.setMat4("view", glm::value_ptr(view));
skyboxShader.setMat4("projection", glm::value_ptr(projection));
glBindVertexArray(skyboxVAO);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture);
glDrawArrays(GL_TRIANGLES, 0, 36);
glBindVertexArray(0);
编译运行,可以看到如下的结果:
结果不对劲。我们期待的结果是蓝天白云下的一个盒子,可不是这种连天空盒都能看的清清楚楚的穿帮效果。查看一下代码,注意力停留在这一行代码上:'skyboxShader.setMat4("view", glm::value_ptr(view));'。天空盒随着摄像机的位置进行变化,如果摄像机移动到天空盒之外,那么可以看到整个天空盒就不足为奇了。
怎样才能不移动到天空盒之外去呢?再看这行代码,view矩阵中包含了平移、旋转的操作,我们把平移的操作去掉不就行了?之前我们学到的坐标转换的原理就有用了:平移功能是在矩阵的最后一列,我们可以强制取其前三行三列,在用无效值补齐最后一行一列,这样就把它的平移操作去掉了。说干就干,将下面这行代码放到设置view矩阵前面:
view = glm::mat4(glm::mat3(camera.GetViewMatrix()));
再次编译运行,看到如下效果:
这正是我们想要的结果!换用你喜欢的天空盒,随意创造无限世界!
附上源码以供校对。
环境纹理映射(Environment mapping)
实现了天空盒之后,就可以进行一些更帅气的操作了。在一个有天空的场景中,我们可以让其中的物体反射天空的样子。这个操作就叫做环境纹理映射(environment mapping)。环境纹理映射有两种最常用的效果:反射和折射。
反射
如果物体的表面光滑,如同镜子一般,那么我们就应该能看到物体反射出天空和山川的样子。反射的原理非常简单,看下图就知道了:
我们很容易知道观察点(眼睛)的位置和物体顶点的位置,顶点位置又包含着法线信息,通过GLSL的reflect函数就可以非常容易的计算反射向量R,进而确定可以看到哪一片天空。
修改盒子使用的片元着色器,我们需要的输入信息有:法线、顶点位置、摄像机位置以及天空盒纹理:
#version 330 core
out vec4 FragColor;
in vec3 Normal;
in vec3 Position;
uniform vec3 cameroPos;
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 Position;
out vec3 Normal;
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);
}
最先要注意的就是法线需要进行变换,然后就是模型的世界坐标位置,法线变换操作在之前的章节中就已经学过,坐标变换也非常简单,就是将模型原有的位置转换到世界坐标的位置,只是不需要齐次分量罢了。
因为要使用法线信息,我们需要更新盒子的顶点数据结构,并且不能忘了要设置cameraPos变量。
最后,绑定纹理不能指定GL_TEXTURE_2D而是指定GL_TEXTURE_CUBE_MAP:
glBindVertexArray(cubeVAO);
glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture);
glDrawArrays(GL_TRIANGLES, 0, 36);
一切准备就绪,编译运行,看到如下的效果:
咋样?这光滑的盒子效果很赞吧?源码可以到这里下载。
将反射效果应用到物体上之后,感觉物体都是钛合金的,顿时高大上。接着我们来看折射的效果:
折射
回忆一下高中课本的知识,折射模拟的是一种光从一种介质进入另一种介质时发生的光路偏移的现象。我们把手放到水中时,可以看到自己的手好像是被“折弯”了一样,这就是折射现象。具体原理如下:
类似的,我们有一个视线向量I,一个法向量N,以及一个折射向量R。如你所见,折射向量R并不和视线向量一致,而是稍稍偏移了一定的角度,这个角度我们姑且称之为“折射系数”。
在GLSL中,折射我们也有单独的函数来实现,函数名是:refract。它需要一个视线向量、一个法向量和一个折射系数的倒数作为参数。具体的折射系数可以参考下表:
材质 | 折射系数 |
---|---|
空气 | 1 |
水 | 1.33 |
冰 | 1.309 |
玻璃 | 1.52 |
钻石 | 2.42 |
有了这些系数后,我们就可以修改片元着色器了:
void main()
{
float ratio = 1.00 / 1.52;
vec3 I = normalize(Position - cameraPos);
//vec3 R = reflect (I, normalize(Normal));
vec3 R = refract(I, normalize(Normal), ratio);
FragColor = vec4(texture(skybox, R).rgb, 1.0);
}
我们采用玻璃的折射系数做实验,看看效果如何:
要的就是这个效果!透过盒子看天空就像是透过玻璃看天空一样。有一点要注意,就是当光从玻璃里进入到空气中时一样会发生折射,这点我们出于简单的目的并没有实现它,但是在实际的应用之中可能会要实现。
动态环境纹理映射
相对于实际应用场景,我们的场景实在是太简单了,它仅有一个物体。在实际应用中,我们几乎找不到这样简单的场景,那么如何去对一个充满物体的场景进行环境纹理映射呢?这就要祭出动态环境纹理映射技术了。
有了帧缓存的帮助,我们可以从物体的角度出发渲染6个不同角度纹理,将其组合起来形成一个纹理盒。然后我们就可以用这纹理盒来创建一个逼真的反射和折射效果。这就是动态环境纹理映射技术。
但是,这个看起来很诱人的想法有一个巨大的缺陷,那就是资源消耗巨大。不仅是渲染次数过多,还是复杂场景每次渲染的消耗都非常大的缘故。所以,现在的APP中还是以使用天空盒为主,尽量使用天空盒,有需要动态环境纹理映射的时候稍微使用预编译的纹理盒。动态环境纹理映射虽然是一个非常诱人的想法,但在实现的道路上还有很长的一段路要走。
总结
本章中,我们学习了一个重要的工具:纹理盒。使用纹理盒我们可以用具体的3D坐标对其进行采样,可以减少顶点数据结构的尺寸。紧接着,我们就用纹理盒实现了天空盒的效果,让我们置身于青天白云下。最后,我们实现了环境纹理映射效果,是物体可以反射和折射整个环境,感觉太棒了。
参考资料
www.learnopengl.com(非常好的网站,建议学习)
Cube Maps: Sky Boxes and Environment Mapping