立方体贴图由6个2D纹理(Texture2D)组成的立方体纹理,每个纹理对应一个面。可以通过一个方向向量进行索引采样,2D纹理是通过一个2D纹理坐标进行采样。
注意是方向向量,即不需要知道大小,OpenGL通过方向向量获取“击中”的纹理像素,并返回对应的采样纹理值。假设立方体的中心刚好位于坐标原点,则可由用立方体表面任意坐标值作为方向向量进行采样。代码示例如下:
// 立方体贴图的加载,和普通2D纹理加载过程类似,有些许不同的地方
GLuint createTextureCubemap( const std::vector<std::string> &path)
{
GLuint textureID;
glGenTextures( 1, &textureID );
glBindTexture( GL_TEXTURE_CUBE_MAP, textureID ); // 对应普通2D纹理的GL_TEXTURE_2D
glTexParameteri( GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR );
glTexParameteri( GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR );
// 方向向量可能刚好指向两个纹理面之间,由于硬件限制,可能会导致无法击中这两个面。
// 设置为GL_CLAMP_TO_EDGE,返回边界值。
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 );
// 纹理第三个维度(位置的Z坐标)
glTexParameteri( GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE );
GLint width, height, nrChannels;
for( size_t i = 0; i < path.size(); i++ ) // 需要加载6张2D纹理
{
unsigned char *data = stbi_load( path[i].c_str(),
&width, &height,
&nrChannels, 0 );
if( data )
{
GLenum format = nrChannels == 1 ? GL_RED :
nrChannels == 3 ? GL_RGB :
nrChannels == 4 ? GL_RGBA :
GL_RGB;
// 6个纹理的ID
// “右”纹理:GL_TEXTURE_CUBE_MAP_POSITIVE_X 或 GL_TEXTURE_CUBE_MAP_POSITIVE_X + 0
// “左”纹理:GL_TEXTURE_CUBE_MAP_NEGATIVE_X 或 GL_TEXTURE_CUBE_MAP_POSITIVE_X + 1
// “上”纹理:GL_TEXTURE_CUBE_MAP_POSITIVE_Y 或 GL_TEXTURE_CUBE_MAP_POSITIVE_X + 2
// “下”纹理:GL_TEXTURE_CUBE_MAP_NEGATIVE_Y 或 GL_TEXTURE_CUBE_MAP_POSITIVE_X + 3
// “前”纹理:GL_TEXTURE_CUBE_MAP_POSITIVE_Z 或 GL_TEXTURE_CUBE_MAP_POSITIVE_X + 4
// “后”纹理:GL_TEXTURE_CUBE_MAP_NEGATIVE_Z 或 GL_TEXTURE_CUBE_MAP_POSITIVE_X + 5
glTexImage2D( GL_TEXTURE_CUBE_MAP_POSITIVE_X + i,
0, // mipmap level,0表示一级,如果有多级,需要挨个单独设置
format, // 告诉 OpenGL 我们希望把纹理储存为何种格式
width, height, // 纹理宽高
0, // 总为0,历史遗留问题
format, // 图像像素的分量组成
GL_UNSIGNED_BYTE, // 每个像素分量的大小
data ); // 内存中的图像数据
}
else
{
cout << "Failed to load texture::" << path[i] << endl;
}
stbi_image_free( data ); // 纹理数据已经上传到显存中,内存中的数据可以删除了。
}
glBindTexture( GL_TEXTURE_2D, 0 ); // 恢复成默认
return textureID;
}
在片段着色器中:
in vec3 textureDir; // 代表3D纹理坐标的方向向量
uniform samplerCube cubemap; // 立方体贴图的纹理采样器
void main()
{
FragColor = texture(cubemap, textureDir);
}
一、天空盒(Skybox)
立体贴图技术完全可以用2D纹理贴图来实现,那它有什么用?有一些技术通过立体贴图实现起来会非常简单,其中一个就是天空盒。
天空盒是一个包含了整个场景的(大)立方体,它包含周围环境的6个图像,让玩家以为他处在一个比实际大得多的环境当中。游戏中使用天空盒的例子有群山、白云或星空。下面这张截图中展示的是星空的天空盒,它来自于『上古卷轴3』
立方体贴图能完美满足天空盒的需求:我们有一个6面的立方体,每个面都需要一个纹理。在上面的图片中,他们使用了夜空的几张图片,让玩家产生其位于广袤宇宙中的错觉,但实际上他只是在一个小小的盒子当中。
天空盒的实例代码见:https://github.com/JackieLong/OpenGL/tree/main/project_cubemap_test
二、环境映射
另外一个使用到立方体贴图的技术是环境映射(Environment Mapping),指的是将环境的颜色映射到物体表面,其中最流行的是:反射(Reflection)和折射(Refraction)。
1、反射
反射这个属性表现为物体(或物体的一部分)反射它周围环境,即根据观察者的视角,物体的颜色或多或少等于它的环境。镜子就是一个反射性物体:它会根据观察者的视角反射它周围的环境。物理模型如下:
代码实现示例如下:
在片段着色器中:
#version 330 core
layout (location = 0) in vec3 aPos; // 顶点坐标
layout (location = 1) in vec3 aNormal; // 顶点法向量
out vec3 normal;
out vec3 fragPos;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
normal = mat3(transpose(inverse(model))) * aNormal; // 消除物体不规则缩放对法向量的影响
fragPos = vec3(model * vec4(aPos, 1.0)); // 统一转换到世界坐标中计算。
gl_Position = projection * view * model * vec4(aPos, 1.0);
}
在顶点着色器中:
#version 330 core
in vec3 normal; // 顶点法向量
in vec3 fragPos; // 观察点(顶点或者片段)
uniform vec3 viewPos; // 眼睛位置(摄像机位置)
uniform samplerCube cubemap; // 立方体贴图
out vec4 FragColor;
void main()
{
vec3 I = normalize(fragPos - viewPos); // I向量
normal = normalize(normal); // 单位法向量
vec3 R = reflect(I, normal); // 立方体贴图方向向量
FragColor = vec4(texture(cubemap, R).rgb, 1.0);
}
2、折射
环境映射的另一种形式是折射,它和反射很相似。折射是光线由于传播介质的改变而产生的方向变化。折射是通过斯涅尔定律(Snell’s Law)来描述的:
代码示例如下,在顶点着色器中:
#version 330 core
layout (location = 0) in vec3 aPos; // 顶点坐标
layout (location = 1) in vec3 aNormal; // 顶点法向量
out vec3 normal;
out vec3 fragPos;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
normal = mat3(transpose(inverse(model))) * aNormal; // 消除物体不规则缩放对法向量的影响
ragPos = vec3(model * vec4(aPos, 1.0)); // 统一转换到世界坐标中计算。
gl_Position = projection * view * model * vec4(aPos, 1.0);
}
在片段着色器中:
#version 330 core
in vec3 normal; // 顶点法向量
in vec3 fragPos; // 观察点(顶点或者片段)
uniform vec3 viewPos; // 眼睛位置(摄像机位置)
uniform samplerCube cubemap; // 立方体贴图
out vec4 FragColor;
const float refract_air = 1.00; // 光在空气中的折射率
const float refract_glass = 1.52; // 光在玻璃中的折射率
const float ratio = refract_air / refract_glass; // 光从空气射入玻璃时的折射率
void main()
{
vec3 I = normalize(fragPos - viewPos); // I向量
normal = normalize(normal); // 单位法向量
vec3 R = refract(I, normal, ratio); // 立方体贴图方向向量
FragColor = vec4(texture(cubemap, R).rgb, 1.0);
}
常见物质的折射率如下:
材质 | 折射率 |
---|---|
空气 | 1.00 |
水 | 1.33 |
冰 | 1.309 |
玻璃 | 1.52 |
钻石 | 2.42 |
三、动态环境贴图
现在我们使用的都是静态图像的组合来作为天空盒,看起来很不错,但它没有在场景中包括可移动的物体。我们一直都没有注意到这一点,因为我们只使用了一个物体。如果我们有一个镜子一样的物体,周围还有多个物体,镜子中可见的只有天空盒,看起来就像它是场景中唯一一个物体一样。
通过使用帧缓冲,我们能够为物体的6个不同角度创建出场景的纹理,并在每个渲染迭代中将它们储存到一个立方体贴图中。之后我们就可以使用这个(动态生成的)立方体贴图来创建出更真实的,包含其它物体的,反射和折射表面了。这就叫做动态环境映射(Dynamic Environment Mapping),因为我们动态创建了物体周围的立方体贴图,并将其用作环境贴图。
虽然它看起来很棒,但它有一个很大的缺点:我们需要为使用环境贴图的物体渲染场景6次,这是对程序是非常大的性能开销。现代的程序通常会尽可能使用天空盒,并在可能的时候使用预编译的立方体贴图,只要它们能产生一点动态环境贴图的效果。虽然动态环境贴图是一个很棒的技术,但是要想在不降低性能的情况下让它工作还是需要非常多的技巧的。