计算机图形学 | 实验九:纹理贴图和天空盒
- 计算机图形学 | 实验九:纹理贴图和天空盒
- 实验概述
- 顶点数据
- 立方体顶点数据
- 天空盒顶点数组
- 纹理载入
- 创建纹理
- 纹理读取
- 纹理绑定
- 使用纹理
- 立方体着色器
- 顶点着色器
- 片元着色器
- 天空盒着色器
- 顶点着色器
- 片元着色器
- 立方体贴图和平面纹理对比
- 着色器中的区别
- 定义和设置上的区别
- 纹理定义
- 资源载入
- 天空盒位置的控制
- 总结
华中科技大学《计算机图形学》课程
MOOC地址:计算机图形学(HUST)
计算机图形学 | 实验九:纹理贴图和天空盒
实验概述
这次实验我们主要学习如何绘制带有平面纹理的立方体,以及运用立方体贴图实现的天空盒。实验要求:
- 平面纹理(之前实验的基础上,在立方体贴上纹理)
- 立方体贴图(天空盒,背景的天空盒采用立方体贴图)
实验最终的实现效果:
顶点数据
立方体顶点数据
这是立方体顶点数组,可以看到前三个float量是我们熟悉的顶点坐标位置,后面两个float量是顶点所对应的纹理UV坐标,通过这个UV坐标,我们可以控制将纹理上的哪一部分贴在三角形片元上。
const float vertices[] = { //立方体数组
-0.5f, -0.5f, -0.5f, 0.0f, 0.0f,
0.5f, -0.5f, -0.5f, 1.0f, 0.0f,
0.5f, 0.5f, -0.5f, 1.0f, 1.0f,
0.5f, 0.5f, -0.5f, 1.0f, 1.0f,
-0.5f, 0.5f, -0.5f, 0.0f, 1.0f,
-0.5f, -0.5f, -0.5f, 0.0f, 0.0f,
-0.5f, -0.5f, 0.5f, 0.0f, 0.0f,
0.5f, -0.5f, 0.5f, 1.0f, 0.0f,
0.5f, 0.5f, 0.5f, 1.0f, 1.0f,
0.5f, 0.5f, 0.5f, 1.0f, 1.0f,
-0.5f, 0.5f, 0.5f, 0.0f, 1.0f,
-0.5f, -0.5f, 0.5f, 0.0f, 0.0f,
-0.5f, 0.5f, 0.5f, 1.0f, 0.0f,
-0.5f, 0.5f, -0.5f, 1.0f, 1.0f,
-0.5f, -0.5f, -0.5f, 0.0f, 1.0f,
-0.5f, -0.5f, -0.5f, 0.0f, 1.0f,
-0.5f, -0.5f, 0.5f, 0.0f, 0.0f,
-0.5f, 0.5f, 0.5f, 1.0f, 0.0f,
0.5f, 0.5f, 0.5f, 1.0f, 0.0f,
0.5f, 0.5f, -0.5f, 1.0f, 1.0f,
0.5f, -0.5f, -0.5f, 0.0f, 1.0f,
0.5f, -0.5f, -0.5f, 0.0f, 1.0f,
0.5f, -0.5f, 0.5f, 0.0f, 0.0f,
0.5f, 0.5f, 0.5f, 1.0f, 0.0f,
-0.5f, -0.5f, -0.5f, 0.0f, 1.0f,
0.5f, -0.5f, -0.5f, 1.0f, 1.0f,
0.5f, -0.5f, 0.5f, 1.0f, 0.0f,
0.5f, -0.5f, 0.5f, 1.0f, 0.0f,
-0.5f, -0.5f, 0.5f, 0.0f, 0.0f,
-0.5f, -0.5f, -0.5f, 0.0f, 1.0f,
-0.5f, 0.5f, -0.5f, 0.0f, 1.0f,
0.5f, 0.5f, -0.5f, 1.0f, 1.0f,
0.5f, 0.5f, 0.5f, 1.0f, 0.0f,
0.5f, 0.5f, 0.5f, 1.0f, 0.0f,
-0.5f, 0.5f, 0.5f, 0.0f, 0.0f,
-0.5f, 0.5f, -0.5f, 0.0f, 1.0f
};
天空盒顶点数组
紧接着我们看到天空盒的顶点数组。我们可以发现,天空盒的顶点数组是没有之前的纹理坐标的。
float skybox_vertices[] = { //天空盒顶点数组
-1.0f, 1.0f, -1.0f,
-1.0f, -1.0f, -1.0f,
1.0f, -1.0f, -1.0f,
1.0f, -1.0f, -1.0f,
1.0f, 1.0f, -1.0f,
-1.0f, 1.0f, -1.0f,
-1.0f, -1.0f, 1.0f,
-1.0f, -1.0f, -1.0f,
-1.0f, 1.0f, -1.0f,
-1.0f, 1.0f, -1.0f,
-1.0f, 1.0f, 1.0f,
-1.0f, -1.0f, 1.0f,
1.0f, -1.0f, -1.0f,
1.0f, -1.0f, 1.0f,
1.0f, 1.0f, 1.0f,
1.0f, 1.0f, 1.0f,
1.0f, 1.0f, -1.0f,
1.0f, -1.0f, -1.0f,
-1.0f, -1.0f, 1.0f,
-1.0f, 1.0f, 1.0f,
1.0f, 1.0f, 1.0f,
1.0f, 1.0f, 1.0f,
1.0f, -1.0f, 1.0f,
-1.0f, -1.0f, 1.0f,
-1.0f, 1.0f, -1.0f,
1.0f, 1.0f, -1.0f,
1.0f, 1.0f, 1.0f,
1.0f, 1.0f, 1.0f,
-1.0f, 1.0f, 1.0f,
-1.0f, 1.0f, -1.0f,
-1.0f, -1.0f, -1.0f,
-1.0f, -1.0f, 1.0f,
1.0f, -1.0f, -1.0f,
1.0f, -1.0f, -1.0f,
-1.0f, -1.0f, 1.0f,
1.0f, -1.0f, 1.0f
};
因为我们使用的是立方体贴图,天空盒的顶点坐标即可对应它纹理坐标。我们可以直接将顶点坐标作为立方体贴图的纹理坐标。
纹理载入
我们用平面纹理包裹住之前实验实现的立方体,用立方体贴图包裹住天空盒,即可得到我们最后需要的结果。
为了实现纹理贴图,我们需要进行几步操作:首先进行纹理定义和设置,然后进行纹理资源载入,生成多级纹理,在使用前进行纹理绑定,以及最后在着色器中进行采样。
创建纹理
首先我们定义一个纹理,绑定在GL_TEXTURE_2D上,然后就开始设置它的纹理属性,最后四行中,前两行是用来设置纹理的环绕方式。后两行则是设置纹理的过滤方式。设置这些纹理的属性有很多参数,下面我们就来介绍一下这里的参数有哪些,分别都是什么作用。
GLuint texture1; glGenTextures(1, &texture1);
glBindTexture(GL_TEXTURE_2D, texture1);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
上面的代码中,glTexParameteri函数是负责设置纹理的属性。
其中第四、第五行代码:
- GL_TEXTURE_WRAP_S表示在s轴上纹理的环绕方式;
- GL_TEXTURE_WRAP_T表示在T轴上纹理的环绕方式,这里s和t等价于平面纹理图片的x轴和y轴;
- GL_REPEAT是表示纹理重复出现,它也是在不设置的情况下默认环绕方式。GL_MIRRORED_REPEAT也是重复图片,但是他表示以镜像方式重复出现;
- GL_CLAMP_TO_EDGE表示纹理坐标会被约束在0-1之间,超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果。这种环绕方式通常会在我们设置纹理坐标超过0-1的范围时被使用到。
其中第六第七行代码:
- GL_TEXTURE_MIN_FILTER是设置纹理在缩小时的过滤方式
- GL_TEXTURE_MAG_FILTER是设置纹理在放大时的过滤方式。
过滤方式我们主要使用的有两种,一种是GL_NEAREST即线性过滤,这种过滤方法会产生颗粒状的图案,但是也能更清晰的看到组成纹理的像素。GL_LINEAR即临近过滤,它能够产生更平滑的图案,但是也有更真实的输出。
纹理读取
接下来我们学习如何进行资源载入,即把图片读入内存,最终绑定到着色器。我们使用#include <stb/stb_image.h>中的_stbi_load,根据路径读取纹理图片,并读取纹理的宽高和通道数,将纹理数据存入data数组中,最后判断是否读取成功,若失败,则报错,若成功,则进行下一步操作。部分代码如下:
//加载纹理
int width, height, nrchannels;//纹理长宽,通道数
stbi_set_flip_vertically_on_load(true);
unsigned char *data = stbi_load("res/texture/CG_Sprite.jpg", &width, &height, &nrchannels, 0);
if (data)
{
//生成纹理
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
}
else
std::cout << "Failed to load texture" << std::endl;
在读取纹理之后,我们还要将data中的纹理数据存入GL_TEXTURE_2D,存入的时候我们需要同时输入纹理的宽高,接着我们使用glGenerateMipmap(GL_TEXTURE_2D)生成多级渐远纹理。
纹理绑定
由于可能出现多个纹理,所以我们每次使用纹理绘制之前,最好进行一次绑定操作。glActiveTexture(GL_TEXTURE0)为选择GL_TEXTUERE0纹理单元,将要要使用的纹理绑定到GL_TEXTURE0纹理单元中,计算机有很多纹理单元GL_TEXTURE1 GL_TEXTURE0 GL_TEXTURE2 GL_TEXTURE3 GL_TEXTURE4,使用时每个纹理最好对一个纹理单元。
接着我们使用glBindTexture(GL_TEXTURE_2D, texture1);去绑定纹理,绑定后即可进行绘制。
glActiveTexture(GL_TEXTURE0); //绑定纹理
glBindTexture(GL_TEXTURE_2D, texture1);
...
glDrawArrays(GL_TRIANGLES, 0, 36);//绘制
使用纹理
后面介绍如何在着色器中使用纹理,需要注意的是,立方体和天空盒使用纹理的方式是不一样的,我们先看看着色器中如何使用平面纹理。
立方体着色器
顶点着色器
立方体的顶点着色器负责的纹理方面的功能不多,只是将顶点对应的纹理坐标传入片元着色器。
#version 330 core
layout (location = 0)
in vec3 aPos;
layout (location = 1) in vec2 aTexCoord;
out vec2 TexCoord;
...
void main()
{
gl_Position = projection * view * model * vec4(aPos, 1.0);
TexCoord=vec2(aTexCoord.x,aTexCoord.y);
}
片元着色器
片元着色器则是根据传入的sampler2D类型的纹理和纹理对应坐标,使用texture进行采样,获取当前片元的颜色值。
在这里插入代码片
#version 330 core
out vec4 FragColor;
in vec2 TexCoord;
uniform sampler2D texture1;
void main()
{
FragColor = texture(texture1,TexCoord);
}
天空盒着色器
顶点着色器
接着我们看到天空盒的着色器具体跟立方体的着色器到底有哪些不同。
天空盒的顶点着色器将顶点坐标直接作为纹理坐标传入片元着色器,可以发现平面纹理的纹理坐标是vec2类型,而立方体的纹理坐标则是vec3类型,并且pvm矩阵也发生了变化,这个我们后面再提,还有我们可以看到的是我们把z变量替换成了w,这是为了使天空盒永远在所有物体之后,作为背景,使其深度值最大,不遮挡任何物体。
#version 330 core
layout (location = 0) in vec3 aPos;
out vec3 TexCoords;
uniform mat4 projection;
uniform mat4 view;
void main()
{
TexCoords = aPos;
vec4 pos = projection * view * vec4(aPos, 1.0);
//这是因为天空盒会在之后覆盖所有的场景中其他物体。我们需要耍个花招让深度缓冲相信天空盒的深度缓冲有着最大深度值1.0,如此只要有个物体存在深度测试就会失败,看似物体就在它前面了
//透视除法(perspective division)是在顶点着色器运行之后执行的,把gl_Position的xyz坐标除以w元素。我们从深度测试教程了解到除法结果的z元素等于顶点的深度值。利用这个信息,我们可以把输出位置的z元素设置为它的w元素,这样就会导致z元素等于1.0了,因为,当透视除法应用后,它的z元素转换为w/w = 1.0:
gl_Position = pos.xyww;
}
片元着色器
天空盒的片元着色器也与立方体的片元着色器有些不同,它传入的是samplerCube类型的立方体贴图,且纹理坐标也是vec3类型,但是最终还是通过texture进行采样。
#version 330 core
out vec4 FragColor;
in vec3 TexCoords;
uniform samplerCube skybox;
void main()
{
FragColor = texture(skybox, TexCoords);
}
立方体贴图和平面纹理对比
着色器中的区别
我们刚才这是对立方体和天空盒着色器的区别进行了分析,发现天空盒着色器传入的纹理是samplerCube类型,那我们怎样向着色器传入samplerCube类型的纹理呢?它与立方体纹理的区别又在那里呢?
立方体贴图的六个面,每个面都对应一个纹理,且有其相应的顺序。有人可能会觉得为什么不能贴六个纹理而非要用立方体贴图呢?那是因为立方体贴图有其独特的属性,我们有时候可以直接使用方向向量对立方体贴图进行索引和采样。
我们可以设想一下,有一束光线从立方体的中心向任意一个方向射出,我们知道了射出光线的方向向量,即可在在立方体贴图上找到对应的纹理坐标,获取到相应的颜色值,通过这种方法我们可以实现光的折射和反射,通过出射光和入射光的方向找到对应的颜色值。
定义和设置上的区别
我们之前讲了立方体贴图和平面纹理在着色器中的区别,那他们在别的方面还有什么区别吗?因为立方体贴图包括六个相关的平面纹理,所以他们在纹理的定义和设置和资源载入上还是有一些区别的。
纹理定义
首先设置纹理属性值,首先是定义纹理id时,我们将GLuint换成了unsigned int,这两种方式其实是完全等价的,所以使用的时候我们可以根据自己的需要来,接着就是绑定纹理,因为是立方体纹理,所以之前的绑定在GL_TEXTURE_2D变成了绑定在GL_TEXTURE_CUBE_MAP,其他属性的设置基本相同,但是需要注意的是,由于以及是立体纹理而不仅仅是平面纹理了,所以他的环绕方式也多出了一个R轴的环绕方式,对应的是现实中的z轴。
unsigned int load_cubemap(std::vector<std::string> 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++)
{
stbi_set_flip_vertically_on_load(false);
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;
}
资源载入
资源载入时我们也需要一次性载入6张纹理,一般我们使用for循环载入,循环六次,同时每次载入纹理存储的地址也有所不同,不同的纹理存储在不同的地址,不过这些地址都是相邻的,所以每次只需要加i即可。
unsigned int load_cubemap(std::vector<std::string> 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++)
{
stbi_set_flip_vertically_on_load(false);
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;
}
天空盒位置的控制
那么我们该如何摆放天空盒呢?首先天空盒是一个包裹着相机的大盒子,它不需要进行世界坐标系的模型变换,其次相机不管如何移动,应该始终在天空盒的中心点,那么我们如何取消天空盒着色器中view矩阵的位移而保存相机视角变换之类的操作呢?
相机view矩阵,主要包含了位移和旋转操作,如图4.3.1所示,第一行公式为位移矩阵如何发挥其功能,而位移部分则是在Tx,Ty,Tz中体现, Tx,Ty,Tz为位移量,我们只需要把view矩阵从4x4先转换为3x3矩阵,去掉位移部分,再转换回4x4矩阵,则可完全去掉位移量,并保持旋转效果。
体现在程序中则是如下操作,先转换成mat3矩阵,再转换成mat4矩阵。
//去除相机位移
view = glm::mat4(glm::mat3(glm::lookAt(camera_position, camera_position + camera_front, camera_up)));
总结
以上为本次实验的要点解析,下面是实验结果演示。
运行之后我们移动视角可以发现,不管我们怎样移动相机的位置,天空盒相对于摄像机的位置始终保持不变。