Cubemaps 立方体贴图
Advanced-OpenGL/Cubemaps
我们已经使用2D纹理有一段时间了,但是还有更多的纹理类型我们还没有探索,在这一章我们将讨论一个纹理类型是一个多纹理的组合映射到一个:立方体贴图。
立方体贴图是一个包含6个单独的2D贴图的纹理,每个贴图形成一个立方体的一面:一个纹理立方体。你可能想知道这样一个立方体的意义是什么?为什么要把6个单独的纹理组合成一个实体而不是仅仅使用6个单独的纹理呢?立方体映射有一个有用的特性,即可以使用方向向量对它们进行索引/采样。假设我们有一个1x1x1的单位立方体,方向向量的原点在它的中心。从带有橙色方向向量的立方体贴图中采样一个纹理值,看起来有点像这样:
方向矢量的大小无关紧要。只要提供了一个方向,OpenGL就会检索方向命中的相应的纹理(最终)并返回适当采样的纹理值。
如果我们想象我们有一个立方体形状,我们附加这样一个立方体图,这个方向向量将类似于(插值的)立方体的局部顶点位置。这样,只要立方体以原点为中心,我们就可以使用立方体的实际位置向量来采样立方体图。因此,我们认为立方体的所有顶点位置都是它的纹理坐标,当采样cubemap。结果是一个纹理坐标,它访问cubemap的适当的单独的面纹理。
Creating a cubemap
cubemap是和其他纹理一样的纹理,所以要创建一个立方体,我们需要先生成一个纹理并将其绑定到合适的纹理目标上,然后再进行进一步的纹理操作。这次绑定到GL_TEXTURE_CUBE_MAP:
unsigned int textureID;
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);
因为一个cubemap包含6个纹理,每个面一个,我们必须调用glTexImage2D 6次,它们的参数设置类似于前面的章节。但是这次,我们必须设置纹理目标参数来匹配cubemap的特定面,告诉OpenGL我们为cubemap的哪一边创建纹理。这意味着我们必须对cubemap的每个面调用glTexImage2D。
因为我们有6个面,OpenGL给了我们6个特殊的纹理目标,用于目标cubemap的面:
Texture target | Orientation |
---|---|
GL_TEXTURE_CUBE_MAP_POSITIVE_X | Right |
GL_TEXTURE_CUBE_MAP_NEGATIVE_X | Left |
GL_TEXTURE_CUBE_MAP_POSITIVE_Y | Top |
GL_TEXTURE_CUBE_MAP_NEGATIVE_Y | Bottom |
GL_TEXTURE_CUBE_MAP_POSITIVE_Z | Back |
GL_TEXTURE_CUBE_MAP_NEGATIVE_Z | Front |
像许多OpenGL的枚举,他们幕后的int值是线性增加的,所以如果我们有一个数组或矢量纹理的位置我们可以遍历它们从GL_TEXTURE_CUBE_MAP_POSITIVE_X和递增的enum 1每次迭代,有效地遍历所有的纹理的目标:
int width, height, nrChannels;
unsigned char *data;
for(unsigned int i = 0; i < textures_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
);
}
这里我们有一个叫做textures_faces的向量,它包含cubemap所需的所有纹理的位置,顺序如表中所示。这将为当前绑定的cubemap的每个面生成纹理。
因为cubemap是一个和其他纹理一样的纹理,我们也会指定它的包装和过滤方法:
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_TEXTURE_WRAP_R吓到,这只是为纹理的R坐标设置包装方法,R坐标对应纹理的第三维度(比如位置的z)。我们将包装方法设置为GL_CLAMP_TO_EDGE,因为恰好在两个面之间的纹理坐标可能不会到达一个确切的面(由于一些硬件限制),所以通过使用GL_CLAMP_TO_EDGE,每当我们在面之间采样时,OpenGL总是返回它们的边缘值。
然后在绘制使用cubemap的对象之前,我们激活相应的纹理单元,并在渲染前绑定cubemap;与普通的2D纹理相比没有太大的区别。
在fragment shader中,我们也必须使用一个不同的samplerCube类型的采样器,我们使用的是纹理函数采样,但是这次使用的是vec3方向向量而不是vec2。一个使用cubemap的片段着色器的例子是这样的:
in vec3 textureDir; // direction vector representing a 3D texture coordinate
uniform samplerCube cubemap; // cubemap texture sampler
void main()
{
FragColor = texture(cubemap, textureDir);
}
这仍然很好,但为什么要麻烦呢?好吧,这只是凑巧,有相当多有趣的技术,更容易实现cubemap。其中一项技术就是创造一个天空盒。
Skybox
skybox是一个包含了整个场景的(大的)立方体,包含了周围环境的6张图片,给玩家一种他所处的环境实际上比实际要大得多的错觉。视频游戏中使用的天空盒的一些例子是山的图像,云的图像,或星空的图像。下面是第三个《上古卷轴》游戏的截图,这是一个使用星空图像的天空盒的例子:
现在你可能已经猜到了像这样的空中盒子立方体是完美的:我们有一个立方体,它有6个面,每个面都需要纹理。在之前的图片中,他们使用了几张夜空的图片,给玩家一种置身于某个大宇宙的错觉,而他实际上是在一个小盒子里。
通常在网上有足够的资源,你可以找到这样的天空盒。这些skybox图像通常有以下模式:
如果你将这6个面折叠成一个立方体,你会得到一个完全有纹理的立方体,模拟一个大的景观。有些资源以这样的格式提供skybox,在这种情况下,你必须手动提取6张人脸图像,但在大多数情况下,他们提供了6张单一的纹理图像。
这个特殊的(高质量的)skybox就是我们将在场景中使用的,可以在这里here. 下载。
Loading a skybox
由于skybox本身只是一个cubemap,加载skybox与我们在本章开始时看到的没有太大区别。要加载skybox,我们将使用以下函数,它接受6个纹理位置的矢量:
unsigned int loadCubemap(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++)
{
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;
}
这个函数本身不应该太令人惊讶。它基本上是我们在上一节中看到的所有cubemap代码,但合并在一个可管理的函数中
现在,在我们调用这个函数之前,我们将按照cubemap枚举指定的顺序在一个矢量中加载合适的纹理路径:
vector<std::string> faces;
{
"right.jpg",
"left.jpg",
"top.jpg",
"bottom.jpg",
"front.jpg",
"back.jpg"
};
unsigned int cubemapTexture = loadCubemap(faces);
我们将skybox加载为一个cubemap, id为cubemapTexture,现在我们终于可以将它绑定到一个立方体来替换我们一直使用的那种蹩脚的清晰颜色了。
Displaying a skybox
因为skybox是在立方体上绘制的,所以我们需要另一个VAO、VBO和一组新的顶点,就像任何其他3D对象一样。你可以在这里here. 得到它的顶点数据。
可以使用立方体的本地位置作为纹理坐标来采样用于纹理三维立方体的cubemap。当一个立方体以原点为中心(0,0,0)时,它的每个位置向量也是一个来自原点的方向向量。这个方向向量正是我们在特定立方体位置获得相应纹理值所需要的。因此,我们只需要提供位置向量而不需要纹理坐标。
为了渲染skybox,我们需要一套新的不太复杂的着色器。因为我们只有一个顶点属性,顶点着色器非常简单:
#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);
}
这个顶点着色器有趣的部分是,我们设置输入的本地位置向量作为输出纹理坐标,用于(插值)在片段着色器中使用。然后片段着色器将这些作为输入来采样一个samplerCube:
#version 330 core
out vec4 FragColor;
in vec3 TexCoords;
uniform samplerCube skybox;
void main()
{
FragColor = texture(skybox, TexCoords);
}
片段着色器是相对简单的。我们将顶点属性的插值位置向量作为纹理的方向向量,并使用它从cubemap中采样纹理值。
现在我们有了cubemap纹理,渲染skybox很容易,我们只需绑定cubemap纹理,skybox采样器就会自动填充skybox的cubemap。为了绘制skybox,我们将绘制它作为场景中的第一个对象,并禁用深度写入。这样,天空盒将总是绘制在所有其他对象的背景上,因为单位立方体很可能比场景的其他部分小。
glDepthMask(GL_FALSE);
skyboxShader.use();
// ... set view and projection matrix
glBindVertexArray(skyboxVAO);
glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture);
glDrawArrays(GL_TRIANGLES, 0, 36);
glDepthMask(GL_TRUE);
// ... draw rest of the scene
如果你运行这个,你会遇到困难。我们希望skybox围绕着玩家,这样无论玩家移动多远,skybox都不会再靠近玩家,从而给人一种周围环境非常大的感觉。然而,当前的视图矩阵通过旋转、缩放和转换来改变skybox的所有位置,所以如果玩家移动,cubemap也会移动!我们想移除视图矩阵的平移部分,这样只有旋转才会影响天空盒的位置向量。
你可能还记得在基本照明一章中,我们可以通过取左上角的4x4矩阵的3x3矩阵来移除变换矩阵的平移部分。我们可以通过将视图矩阵转换为3x3矩阵(去掉平移)并将其转换为4x4矩阵来实现这一点:
glm::mat4 view = glm::mat4(glm::mat3(camera.GetViewMatrix()));
这删除了所有的平移,但是保留了所有的旋转变换,这样用户仍然可以查看场景。
由于我们的skybox,结果是一个瞬间看起来巨大的场景。如果你绕着基本的容器飞行,你会立刻得到一种比例感,这极大地提高了场景的真实性。结果是这样的:
试着用不同的天空盒进行实验,看看它们是如何对你的场景的外观和感觉产生巨大的影响。
An optimization 优化
现在我们在渲染场景中所有其他对象之前先渲染了skybox。这种方法非常有效,但效率不高。如果我们首先渲染skybox我们会为屏幕上的每个像素运行fragment shader即使最终只有一小部分skybox是可见的;使用早期深度测试可以轻易丢弃的片段可以节省宝贵的带宽。
为了稍微提高性能,我们将最后渲染skybox。这样,深度缓冲区就完全被场景的所有深度值填满了,所以我们只需要在早期深度测试通过的地方渲染skybox的片段,大大减少了片段着色器调用的次数。问题是skybox很可能会在所有其他物体上渲染,因为它只是一个1x1x1的立方体,在大多数深度测试中都是如此。简单地渲染它而不进行深度测试不是一个解决方案,因为在最后渲染时,skybox仍然会覆盖场景中的所有其他对象。我们需要欺骗深度缓冲区,让它相信skybox的最大深度值为1.0,这样当它前面有一个不同的对象时,它就不能通过深度测试。
在坐标系统一章中,我们说透视分割是在顶点着色器运行后执行的,用它的w组件分割gl_Position的xyz坐标。我们还从深度测试一章知道,结果分割的z分量等于那个顶点的深度值。利用这个信息,我们可以设置输出位置的z分量等于它的w分量,这将导致z分量总是等于1.0,因为当应用透视分法时,它的z分量转换为w / w = 1.0:
void main()
{
TexCoords = aPos;
vec4 pos = projection * view * vec4(aPos, 1.0);
gl_Position = pos.xyww;
}
由此得到的标准化设备坐标将总是有一个等于1.0的z值:最大深度值。skybox只会在没有可见物体的地方渲染(只有这样它才能通过深度测试,其他的都在skybox的前面)。
我们必须稍微改变depth函数,将其设置为GL_LEQUAL,而不是默认的GL_LESS。对于skybox,深度缓冲区将被填充为1.0的值,因此我们需要确保skybox通过深度测试的值小于或等于深度缓冲区,而不是小于。
您可以在这里here. 找到更优化的源代码版本。
Environment mapping
现在我们已经把整个周围环境映射到一个纹理对象中,我们可以将这些信息用于更多的天空盒。在环境中使用cubemap,我们可以赋予物体反射或折射属性。使用像这样的环境cubemap的技术被称为环境映射技术,其中最流行的两种是反射和折射。
Reflection
反射是物体(或物体的一部分)反射其周围环境的属性。例如,物体的颜色或多或少与周围环境的颜色相等,这取决于观看者的角度。例如,镜子就是一个反射物体:它根据观看者的角度来反射周围的环境。
反射的基本原理并没有那么难。下面的图像显示了我们如何计算反射向量,并使用该向量从cubemap采样:
我们根据视图方向向量计算一个反射向量
¯围绕物体的法向量
。我们可以使用GLSL的内置反射函数来计算这个反射向量。然后将得到的矢量
作为方向矢量对cubemap进行索引/采样,返回环境的颜色值。由此产生的效果是,物体似乎反映了天空盒。
因为我们已经在场景中设置了skybox,创建反射并不太难。我们将改变容器使用的碎片着色器,赋予容器反射属性:
#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);
}
我们首先计算视图/摄像机方向向量I,然后用它来计算反射向量R,然后我们用它从skybox cubemap中采样。注意,我们有插值的片段的法线和位置变量,所以我们需要调整顶点着色器同样:
#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 * vec4(Position, 1.0);
}
我们用的是法向量所以我们要再用法向量矩阵变换它们。位置输出向量为世界空间位置向量。这个顶点着色器的位置输出用于计算片段着色器中的视图方向向量。
因为我们在使用法线,你会想要更新顶点数据vertex data和属性指针。还要确保设置好相机的制服。
然后我们还想在渲染容器之前绑定cubemap纹理:
glBindVertexArray(cubeVAO);
glBindTexture(GL_TEXTURE_CUBE_MAP, skyboxTexture);
glDrawArrays(GL_TRIANGLES, 0, 36);
编译和运行代码将为您提供一个充当完美镜像的容器。周围的skybox被完美地反射在容器上:
您可以在这里找到完整的源代码。
当反射应用到整个对象(比如容器)时,对象看起来就像有一个高反射的材料,比如钢或铬。如果我们加载一个更有趣的对象(像来自模型加载章节的背包模型),我们会得到这样的效果,这个对象看起来是完全由铬制成的:
这看起来相当棒,但实际上大多数模型都不是完全反射的。例如,我们可以引入反射映射,为模型提供另一层额外的细节。就像漫反射贴图和高光贴图一样,反射贴图是纹理图像,我们可以通过采样来确定一个片段的反射率。使用这些反射映射,我们可以确定模型的哪个部分显示反射以及反射强度。
Refraction
另一种形式的环境映射称为折射,与反射类似。折射是由于光流经的材料的变化而引起的光方向的变化。折射是我们通常在类水表面看到的现象,光线不是直接进入,而是稍微弯曲。这就像在水里看你的手臂一样。
折射是由斯涅尔定律描述的,在环境地图中,它看起来有点像这样:
同样,我们有一个视图向量,一个法向量
这次得到的是折射向量
。正如你所看到的,视图矢量的方向是稍微弯曲的。然后使用得到的弯曲向量
从cubemap中进行采样。
使用GLSL内置的折射函数,折射很容易实现,它需要法向量、视图方向和两种材料的折射率的比率。
折射率决定了一种材料中光扭曲/弯曲的程度,而每种材料都有自己的折射率。下表列出了最常见的折射率:
Material | Refractive index |
---|---|
Air | 1.00 |
Water | 1.33 |
Ice | 1.309 |
Glass | 1.52 |
Diamond | 2.42 |
我们用这些折射率来计算光通过两种材料的比率。在我们的例子中,光线/视图射线从空气到玻璃(如果我们假设物体是由玻璃制成的),所以比率变成
我们已经有了cubemap边界,为顶点数据提供了法线,并将摄像机位置设置为统一。我们唯一要改变的是fragment shader:
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);
}
通过改变折射率,你可以创造出完全不同的视觉效果。编译应用程序并在容器对象上运行结果并不是那么有趣,因为它并没有真正显示折射的效果,它现在起到了放大镜的作用。在加载的3D模型上使用相同的着色器,我们可以得到我们想要的效果:一个类似玻璃的物体。
你可以想象,通过光线、反射、折射和顶点移动的正确组合,你可以创建非常整洁的水图形。请注意,为了获得精确的物理结果,我们应该在光线离开物体时再次进行折射;现在我们只用单侧折射这在大多数情况下都是可以的。
Dynamic environment maps
现在我们使用了一个静态的图像组合作为skybox,看起来很不错,但是它没有包含实际的3D场景和可能移动的对象。到目前为止,我们并没有真正注意到这一点,因为我们只使用了一个对象。如果我们有一个周围有多个物体的像镜子一样的物体,只有天空盒在镜子中是可见的,就好像它是场景中唯一的物体一样。
使用framebuffers可以为对象的所有6个不同角度创建场景的纹理,并将它们存储在cubemap中。然后我们可以使用这个(动态生成的)cubemap来创建包括所有其他物体的真实的反射和折射表面。这被称为动态环境映射,因为我们动态地创建一个对象环境的cubemap,并使用它作为它的环境映射。
虽然它看起来很棒,但它有一个巨大的缺点:我们必须使用环境映射为每个对象渲染6次场景,这对你的应用程序是一个巨大的性能损失。现代应用程序会尽可能多地使用skybox,并尽可能地预渲染cubemaps,以创建动态环境地图。虽然动态环境映射是一项伟大的技术,但它需要很多巧妙的技巧和技巧才能使它在实际的渲染应用程序中工作,而不会造成太多的性能下降。