LearnOpenGL Note - Advanced OpenGL - Cubemaps And Skybox

高级OpenGL-立方体纹理及天空包围盒

Posted by Tao on Monday, April 18, 2022

我们已经使用2D纹理很长时间了,除此之外仍有更多的纹理类型等着我们探索。在本章,我们将讨论是一种将多个纹理图片复合到一个立方体表面的技术:立方体贴图(Cube Map)。

创建Cubemap

立方体贴图是和其它纹理一样的,所以如果想创建一个立方体贴图的话,我们需要生成一个纹理,并将其绑定到纹理目标上,之后再做其它的纹理操作。这次要绑定到GL_TEXTURE_CUBE_MAP

unsigned int textureID;
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);

因为立方体贴图包含有6个纹理,每个面一个,我们需要调用glTexImage2D函数6次,参数和之前教程中很类似。但这一次我们将纹理目标(target)参数设置为立方体贴图的一个特定的面,告诉OpenGL我们在对立方体贴图的哪一个面创建纹理。这就意味着我们需要对立方体贴图的每一个面都调用一次glTexImage2D

由于我们有6个面,OpenGL给我们提供了6个特殊的纹理目标,专门对应立方体贴图的一个面。

绑定目标 纹理方向
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 前面

如下图所示,形成一个立方体纹理:(来源Cubemaps) cube_map2.png

需要注意的是:OpenGL中相机默认朝向\(-z\)方向,因此GL_TEXTURE_CUBE_MAP_NEGATIVE_Z表示前面,而GL_TEXTURE_CUBE_MAP_POSITIVE_Z表示背面。在构建cubemaps,一般利用枚举常量递增的特性,一次绑定到上述6个目标。例如在glew中枚举常量定义为:

#define GL_TEXTURE_CUBE_MAP_POSITIVE_X 0x8515
#define GL_TEXTURE_CUBE_MAP_NEGATIVE_X 0x8516
#define GL_TEXTURE_CUBE_MAP_POSITIVE_Y 0x8517
#define GL_TEXTURE_CUBE_MAP_NEGATIVE_Y 0x8518
#define GL_TEXTURE_CUBE_MAP_POSITIVE_Z 0x8519
#define GL_TEXTURE_CUBE_MAP_NEGATIVE_Z 0x851A

可知其int值是线性递增的,我们就可以从GL_TEXTURE_CUBE_MAP_POSITIVE_X开始遍历它们,在每个迭代中对枚举值加1,循环创建立方体纹理,将这个函数封装到函数中如下:

unsigned int loadCubemap(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++)
    {
        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个面纹理路径的数组:

std::vector<std::string> faces{
    "skybox/right.jpg",
    "skybox/left.jpg",
    "skybox/top.jpg",
    "skybox/bottom.jpg",
    "skybox/front.jpg",
    "skybox/back.jpg",
};
unsigned int cubemapTexture = loadCubemap(faces);

需要注意加载图片的顺序 我们使用GL_TEXTURE_CUBE_MAP_POSITIVE_X + i的方式来一次创建了6个2D纹理,加载图片时的顺序以需要对应枚举变量定义的顺序。

使用cubemaps

cubemaps创建了一个立方体纹理,那么如何对纹理进行采样呢? 与2D纹理使用的纹理坐标(s,t)不同,我们这里需要使用三维纹理坐标(s,t,r),如下图所示

cubemaps_sampling.png

图中橙色的为方向向量,当立方体中心处于原点时,即代表的是立方体表面顶点的位置,这个向量即是三维纹理坐标。

三维纹理映射分为两步:

  1. 利用(s,t,r)决定纹理采样时,首先根据(s,t,r)中模最大的分量决定在哪个面采样,并通过该分量的正负确定选中的面。然后使用剩下的2个坐标在对应的面上做2D纹理采样。例如根据(s,t,r)中模最大的为s分量,并且符号为正,则决定选取\(+x\)面作为采样的2D纹理,然后使用(t,r)坐标计算UV坐标在\(+x\)面上做2D纹理采样。

  2. 选中的面通过常规2维纹理坐标UV进行采样,UV通过另外两个分量t,r进行计算: \[U = ((-r/|s|) + 1)/2\\V = ((-t/|s|) + 1)/2\] 由于s模最大,将t和r除以\(|s|\)使其在(-1,1)范围内,然后将其转换到(0,2),最终缩放到(0,1)范围内。现在我们就可以应用常规的2D纹理了(使用常规的二维纹理映射)。(参考文献1)

与二维纹理一样,我们也需要设置它的环绕和过滤方式:

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_CLAMP_TO_EDGE主要用于指定,当(s,t,r)坐标没有落在哪个面,而是落在两个面之间时的纹理采样,使用GL_CLAMP_TO_EDGE参数表明,当在两个面之间采样时使用边缘的纹理值。

Skybox 天空盒

天空包围盒的主要实现思路是:在场景中绘制一个cubemap纹理采样的立方体,并将其显示在最外层,使游戏玩家感觉到好像场景非常大。常见的天空盒如下图:

cubemaps_skybox.png

由于天空盒是绘制在一个立方体上的,和其它物体一样,我们需要另一个VAO、VBO以及新的一组顶点。

用于贴图3D立方体的立方体贴图可以使用立方体的位置作为纹理坐标来采样。当立方体处于原点\((0,0,0)\)时,它的每一个位置向量都是从原点出发的方向向量。这个方向向量正是获取立方体上特定位置的纹理值所需要的。正是因为这个,我们只需要提供位置向量而不用纹理坐标了。

这样只用传入一个顶点属性,顶点着色器如下:

#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);
}

要将包围盒置于场景中的最外层,基本的方式是:暂时关闭深度缓存写入并首先绘制包围盒。同时实现的时候需要注意在玩家移动时,使包围盒看起来有很远很大的感觉。

方法是去掉视变换中移动的部分(translate部分),但保留旋转等其他成分,这样当你在场景内移动,转动相机时,包围盒仍然在以正常角度显示,只是包围盒没有因为玩家的前进后退而发生移动。

我们可以将观察矩阵转换为3x3矩阵(移除位移),再将其转换回4x4矩阵,让移动不会影响天空盒的位置向量。

glm::mat4 view = glm::mat4(glm::mat3(camera.GetViewMatrix()));
glDepthMask(GL_FALSE);
skyboxShader.use();
// ... 设置观察和投影矩阵
glBindVertexArray(skyboxVAO);
glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture);
glDrawArrays(GL_TRIANGLES, 0, 36);
glDepthMask(GL_TRUE);
// ... 绘制剩下的场景

效果如下:

result

当前绘制流程的优化

上面在绘制天空包围盒时,我们首先关闭深度缓存写入,绘制包围盒,让它处于场景最外围,这样做当然能正常工作,缺点是如果场景中物体需要显示在包围盒前面,最终包围盒的某些部分会被遮挡住,按上述绘制方式我们还是绘制了这部分内容,导致了不必要的着色器调用,这是一种性能上的损失(没有利用提前深度测试(Early Depth Testing)技术)。

一种改进的策略是首先绘制场景中物体,然后根据利用包围盒的深度值和当前深度值进行比较,如果通过深度测试就绘制包围盒。我们知道默认情况下,清除深度缓存时使用的值为1.0表示深度最大,因此我们也想用1.0来表示包围盒的深度值,这样它就始终处于场景中最外围,当进行深度测试时,我们改变默认的测试函数,从GL_LESS变为GL_LEQUAL,如下:

glDepthFunc(GL_LEQUAL); // 深度测试条件 小于等于

那么如何让包围盒的深度值总是1.0呢? 我们知道,在顶点着色器中,gl_Position表示的是当前顶点的裁剪坐标系坐标(对应的z分量为\(Z_{clip}\)),而一个顶点最终的深度值是通过透视除法得到NDC坐标(对应的z分量为\(Z_{ndc}\)),以及最后的视口变换后得到窗口坐标的\(Z_{win}\)值决定的。这里使用的技巧是,手动将gl_Position的z值设定为w,即在顶点主色器中输出:

void main()
{
    vec4 pos = projection * view * model * vec4(position, 1.0); 
    gl_Position = pos.xyww;  // 此处让z=w 则对应的深度值变为depth = w / w = 1.0
    TextCoord = position;  // 当立方体中央处于原点时 立方体上位置即等价于向量
}

这样通过OpenGL默认执行的透视除法和视口变换后,得到的深度值就是\(\Z_{win}=1.0),达到我们的目的。这种方式首先绘制场景中物体,最后渲染包围盒。需要注意的是,绘制包围盒时将深度测试函数变为:

glDepthFunc(GL_LEQUAL);

绘制完毕后,再恢复默认的GL_LESS。

环境纹理贴图

通过使用环境的立方体贴图,我们可以给物体反射和折射的属性。这样使用环境立方体贴图的技术叫做环境映射(Environment Mapping),其中最流行的两个是反射(Reflection)和折射(Refraction)。

Reflection 反射贴图

在上一节cubemaps中,我们提到对立方体纹理进行采样,需要使用3维向量(s,t,r),而当立方体中心处于原点时,立方体的顶点位置就可以作为这个采样的坐标。对于反射贴图,我们也同样需要一个纹理坐标,不过这个向量的计算过程如下图所示:

cubemaps_reflection_theory.png

图中向量\(I\)表示观察向量,注意它从观察者位置指出,\(N\)表示片元该处的法向量,而计算出来的反射向量\(R\)则作为纹理坐标采样Cubemap的纹素(上图仅做示意,计算出的反射向量仍需移到cube重心)。我们计算向量的过程都可以在世界坐标系或者相机坐标系,只要统一一个坐标系即可。这里我们使用世界坐标系。顶点着色器如下:

#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和pos都变换到世界坐标当中
    Normal = mat3(transpose(inverse(model))) * aNormal;
    Position = vec3(model * vec4(aPos, 1.0));
    gl_Position = projection * view * model * vec4(aPos, 1.0);
}

片元着色器为:

#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);
}

输入的环境纹理依然是我们的天空包围盒cubemap纹理。效果如下:

reflect_result

Refraction 折射贴图

折射原理:光在不同的介质中传播,频率不变,但是波长不同,这会影响到光传播的速度,所以我们定义:假设光在某种媒介当中的传播速度为v,在真空中的速度为c,那么绝对折射率定义为: \[n=\frac c v\]

Refraction_at_interface.svg
图中左a材质,右b材质

相对折射率(b相对于a的折射率)\(n_r=\sin a/\sin b=n_b/n_a\)。部分常见材质折射率列表

通过折射在环境贴图中采样如下图:

cubemaps_refraction_theory.png

折射\(\color{green}{\bar{R}}\)可以使用GLSL的内建refract函数来轻松实现,它需要一个法向量\(\color{red}{\bar{N}}\)、一个观察方向\(\color{gray}{\bar{I}}\)和两个材质之间的折射率(Refractive Index)。(其实应该有两次折射,该计算是不准确的)

从一种材质进入另一种材质,实际计算时使用两种材质的折射率的比例。实现refraction效果是,顶点着色器部分与上面相同,片元着色器需要修改,计算折射向量,如下:

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);
}

上面着色器中,refractive的第三个参数是折射率的比例,这里我们模拟的是从空气进入玻璃。绘制上面的立方体,得到的效果像是透明玻璃:

refract_result.png

动态环境贴图

通过使用帧缓冲,我们能够为物体的6个不同角度创建出场景的纹理,并在每个渲染迭代中将它们储存到一个立方体贴图中。之后我们就可以使用这个(动态生成的)立方体贴图来创建出更真实的,包含其它物体的,反射和折射表面了。这就叫做动态环境映射(Dynamic Environment Mapping),因为我们动态创建了物体周围的立方体贴图,并将其用作环境贴图。

这种动态贴图方式,由于在framebuffer中要为每个物体执行6次场景渲染,在保持较好性能开销下使用它需要很多技巧,没有这里介绍的天空包围盒这么容易使用。

Reference

「如果这篇文章对你有用,请随意打赏」

Heisenberg Blog

如果这篇文章对你有用,请随意打赏

使用微信扫描二维码完成支付


comments powered by Disqus