1. 流程
之前我们使用了不少2D形式的贴图,那么现在有没有其他类型的贴图呢?当然有,比如立方体贴图,它就是由6个2D贴图组合而成的:
那么为什么要把6张纹理合并到一张纹理中,而不是直接使用6个单独的纹理呢?立方体贴图有一个非常有用的特性,它可以通过一个方向向量来进行索引/采样。假设我们有一个1x1x1的单位立方体,要求某个点的纹理颜色是什么该怎么办呢?现在从这个立方体的中心出发,与立方体上要求的点之间能够形成一个方向向量,那么通过这个向量,我们就可以通过立方体贴图很方便的查到纹理颜色(因为也是可以从立方体贴图原点出发,加上该向量得到一条射线,射线就可以与立方体贴图求交)
1.1 天空盒
立方体贴图的一个很广泛的用途就是天空盒,如果我们将场景贴图应用到覆盖整个场景的111的立方体上,我们的场景就会变得真实许多:
1.1.1 创建
天空盒的场景能很大程度上增强我们渲染的真实性,那么应该怎么去创建一个天空盒呢?我们首先从创建立方体贴图说起:
首先是创建纹理ID,不同的是纹理类型我们给它设置为了GL_TEXTURE_CUBE_MAP
unsigned int textureID;
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);
接下来就是常规的设定属性:
因为是立方体贴图,所以们对环绕方式的设定多了一维R
而且我们将环绕方式设置为GL_CLAMP_TO_EDGE,这是因为正好处于两个面之间的纹理坐标可能不能击中一个面(由于一些硬件限制),所以通过使用GL_CLAMP_TO_EDGE,OpenGL将在我们对两个面之间采样的时候,永远返回它们的边界值。
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);
然后接下来我们给这个纹理绑定六个天空图片数据:
注意,在 opengl 中,传送 2D 纹理数据的时候,data 的起始位置应该是图片的左下角像素(这里的纹理坐标是 0,0),然后逐行向上扫描。但是:cubemap 却是个例外,据说 cubemap 是沿用了一个叫做 RenderMan 的约定,即传送 cubemap 的 face 的时候,data 的起始位置应该是图片的左上角像素(然后逐行向下扫描)!
也就是说,cubemap 和 普通 2d 纹理坐标对于 data 从左下角还是左上角那一行开始,约定完全是反的。那么纹理图片读取时本来就是正向的,所以一定要把之前的纹理上下颠倒关闭
stbi_set_flip_vertically_on_load(false);
int width, height, nrChannels;
unsigned char* data;
for (unsigned int i = 0; i < textures_faces.size(); i++)
{
unsigned char* data = stbi_load(textures_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);
}
}
需要注意的是,我们生成每个2D纹理的时候参数GL_TEXTURE_CUBE_MAP_POSITIVE_X + i是对应的6个立方体贴图的纹理目标:
然后就是给这个天空盒一系列顶点数据(按顺时针存储,因为我们应该是永远在天空盒内部,这样面剔除后看到的应该是所谓的“背面”),绑定它的VAO,VBO:
float skyboxVertices[] = {
// positions
-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
};
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glGenTextures(1, &texture);
//绑定VAO
glBindVertexArray(VAO);
//把顶点数组复制到缓冲中供OpenGL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(skyboxVertices), skyboxVertices, GL_STATIC_DRAW);
//设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);//启用位置0顶点属性
1.1.2 着色器
接下来是编写天空盒的片段着色器,因为贴图类型变了,我们的采样器类型也要变为samplerCube :
#version 330 core
out vec4 FragColor;
in vec3 TexCoords;
uniform samplerCube skybox;
void main()
{
FragColor = texture(skybox, TexCoords);
}
最后,我们该怎么在顶点着色器里传入方向向量呢?我们假设天空盒在原点,那么每个位置的坐标其实就可以当作方向向量了:
#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);
gl_Position = pos.xyww;
}
注意,我们为了效率,一般把天空盒放在最后一个渲染(为了让之前通过禁用深度测试,启用模板测试画出的物体边框生效,这里我是在边框渲染前渲染的天空盒)。
由于天空盒只是一个1x1x1的立方体,它很可能会不通过大部分的深度测试,导致渲染失败。不用深度测试来进行渲染不是解决方案,因为天空盒将会复写场景中的其它物体。我们需要欺骗深度缓冲,让它认为天空盒有着最大的深度值1.0,只要它前面有一个物体,深度测试就会失败。
在坐标系统小节中我们说过,透视除法是在顶点着色器运行之后执行的,将gl_Position的xyz坐标除以w分量。我们又从深度测试小节中知道,相除结果的z分量等于顶点的深度值。使用这些信息,我们可以将输出位置的z分量等于它的w分量,让z分量永远等于1.0,这样子的话,当透视除法执行之后,z分量会变为w / w = 1.0。
最终的标准化设备坐标将永远会有一个等于1.0的z值:最大的深度值。结果就是天空盒只会在没有可见物体的地方渲染了(只有这样才能通过深度测试,其它所有的东西都在天空盒前面)。
我们还要改变一下深度函数,将它从默认的GL_LESS改为GL_LEQUAL。深度缓冲将会填充上天空盒的1.0值,所以我们需要保证天空盒在值小于或等于深度缓冲而不是小于时通过深度测试。
glDepthFunc(GL_LEQUAL);
shader.use();
shader.setInt("skybox", 0);
// ... 设置观察和投影矩阵
glBindVertexArray(VAO);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, texture);
shader.setMat4("view", view);
shader.setMat4("projection", projection);
glDrawArrays(GL_TRIANGLES, 0, 36);
glBindVertexArray(0);
glDepthFunc(GL_LESS);
不过有个问题,我们发现在场景里移动时,会导致相机离开天空盒,这好像和我们的初衷不太符合吧?
为了让天空盒始终以相机为原点,我们从天空盒的View矩阵出发,因为View矩阵包含了世界坐标系到观察坐标系的旋转和平移,而我们实际上只需要旋转,保证视角移动正常就行,所以我们就将View矩阵的平移属性设为0(只取内层3*3的旋转矩阵,外层设为0):
glm::mat4 view = glm::mat4(glm::mat3(camera.GetViewMatrix()));
1.2 环境映射
有了天空盒,我们可不可以让这个环境融入到场景的光照中,使得场景更加真实呢?
1.2.1 反射
当然可以,其中一种应用就是反射,我们把天空盒的立方体贴图作为反射贴图,然后纹理坐标通过计算反射光线向量即可:
#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;
void main()
{
Normal = mat3(transpose(inverse(model))) * aNormal;
Position = vec3(model * vec4(aPos, 1.0));
gl_Position = projection * view * model * vec4(aPos, 1.0);
}
1.2.2 折射
同样的应用还有折射,利用斯涅尔法则,我们可以让物体拥有类似玻璃的材质:
#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);
}
2. 练习
尝试在我们之前在模型加载小节中创建的模型加载器中引入反射贴图。你可以在这里找到升级后有反射贴图的纳米装模型。
要达成这种效果,首先在材质里添加反射贴图和天空盒
struct Material {
sampler2D texture_diffuse1;
sampler2D texture_specular1;
sampler2D texture_reflection1;
samplerCube texture1;
sampler2D texture_normal1;
sampler2D texture_height1;
float shininess;
};
然后在计算反射颜色时将反射贴图颜色和天空盒颜色相乘,加在结果上
vec3 R = reflect(- viewDir, norm);
vec3 reflectMap = vec3(texture(material.texture_reflection1, TexCoords));
vec3 reflection = vec3(texture(material.texture1, R).rgb) * reflectMap * 2;
那么读取的时候怎么加载这个纹理呢?
Assimp在大多数格式中都不太喜欢反射贴图,所以我们需要欺骗一下它,将反射贴图储存为漫反射贴图。你可以在加载材质的时候在model.h里面将反射贴图的纹理类型设置为aiTextureType_AMBIENT,加入textures
// 1. diffuse maps
vector<Texture> diffuseMaps = loadMaterialTextures(material, aiTextureType_DIFFUSE, "texture_diffuse");
textures.insert(textures.end(), diffuseMaps.begin(), diffuseMaps.end());
// 2. specular maps
vector<Texture> specularMaps = loadMaterialTextures(material, aiTextureType_SPECULAR, "texture_specular");
textures.insert(textures.end(), specularMaps.begin(), specularMaps.end());
// 3. normal maps
std::vector<Texture> normalMaps = loadMaterialTextures(material, aiTextureType_HEIGHT, "texture_normal");
textures.insert(textures.end(), normalMaps.begin(), normalMaps.end());
// 4. height maps
std::vector<Texture> heightMaps = loadMaterialTextures(material, aiTextureType_AMBIENT, "texture_height");
textures.insert(textures.end(), heightMaps.begin(), heightMaps.end());
// 5. reflection maps
std::vector<Texture> reflectionMaps = loadMaterialTextures(material, aiTextureType_AMBIENT, "texture_reflection");
textures.insert(textures.end(), reflectionMaps.begin(), reflectionMaps.end());
然后在mesh.h里面激活绑定反射贴图和天空盒贴图,由于模型加载器本身就已经在着色器中占用了3个纹理单元了,我们需要将天空盒绑定到第4个纹理单元上,因为我们要从同一个着色器中对天空盒采样。
shader.setInt("material.texture1", 3);
glActiveTexture(GL_TEXTURE3);
glBindTexture(GL_TEXTURE_CUBE_MAP, texture);
if (name == "texture_diffuse")
number = std::to_string(diffuseNr++);
else if (name == "texture_specular")
number = std::to_string(specularNr++); // transfer unsigned int to string
else if (name == "texture_normal")
number = std::to_string(normalNr++); // transfer unsigned int to string
else if (name == "texture_height")
number = std::to_string(heightNr++); // transfer unsigned int to string
else if (name == "texture_reflection")
number = std::to_string(reflectionNr++); // transfer unsigned int to string
// now set the sampler to the correct texture unit
shader.setInt(("material." + name + number).c_str(), i);
// and finally bind the texture
glBindTexture(GL_TEXTURE_2D, textures[i].id);