6 - 立方体贴图与环境映射

本文将讨论立方体贴图及其应用——天空盒,然后讨论光的折射和反射是怎么进行的,并将其应用在模型上。

立方体贴图(Cubemap)

立方体贴图(Cube Map),是一个包含了6个2D纹理的纹理:

我们只需要一个方向向量,就能采样到立方体贴图的某个纹素:

创建立方体贴图

和普通的2D纹理类似,只不过glBindTexture()的类型变为GL_TEXTURE_CUBE_MAP

接下来要给每个面读取材质,并调用glTexImage2D(),其中6个target参数如下表:

纹理目标方位
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

可以用一个for循环完成此任务,因为参数是从GL_TEXTURE_CUBE_MAP_POSITIVE_X开始递增的。

也别忘了设定立方体贴图的纹理环绕方式,注意立方体贴图是3D的,多了个GL_TEXTURE_WARP_R选项。

综上,得到读取材质的代码:

unsigned int loadCubemap(const std::vector<std::string>& faces)
{
    unsigned int textureID;
    glGenTextures(1, &textureID);
    glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);
    glCheckError();

    int width, height, nrComponents;
    for (unsigned int i = 0; i < faces.size(); ++i)
    {
        unsigned char* data = stbi_load(faces[i].c_str(), &width, &height, &nrComponents, 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);
            glCheckError();
        }
        else
        {
            std::cout << "ERROR::TEXTURE::Failed load cubemap texture in:" << faces[i] << std::endl;
            stbi_image_free(data);
            glCheckError();
        }
    }
    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);
    glCheckError();

    return textureID;
}

别忘了使用它:

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

天空盒(Skybox)

显示天空盒

天空盒也是一个立方体,我们需要它的VAO,VBO以及新的一组顶点。顶点数据在这里

然后为它编写一个顶点着色器,立方体贴图的方向向量可以被用作纹理坐标以进行采样。当立方体处于原点时,这个方向向量就是位置向量,因而可以这样写:

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

接下来就能绘制天空盒了,绘制天空盒时,需要将它变为场景中第一个被渲染的物体,并且禁用深度写入。这样子,天空盒就会永远背绘制在其他物体的背后了:

glDepthMask(GL_FALSE);
skyboxShader.use();
skyboxShader.setMat4("view", glm::mat4(glm::mat3(camera.GetViewMatrix())));
skyboxShader.setMat4("projection", proj);
glBindVertexArray(skyboxVAO);
glBindTexture(GL_TEXTURE_CUBE_MAP, skyboxTexture);
glDrawArrays(GL_TRIANGLES, 0, 36);
glDepthMask(GL_TRUE);

需要注意的是,天空盒的view矩阵必须设置成上边那样子的,上面这种操作移除了观察矩阵的位移特性,否则天空盒会随着玩家移动而变化,且不把玩家包围起来。目前的效果如下图:

性能优化

在前面的操作中,我们先渲染天空盒,然后再渲染场景。可以发现,天空盒被场景挡住的地方还是被渲染出来了,为了节省这一部分的片段,我们应好好利用深度测试提升一点性能。

所以应 最后渲染天空盒,并且为了欺骗深度测试,我们得让它的深度值一直是1.0。由于 透视除法是在顶点着色器运行后执行的,我们可以在顶点着色器做手脚,将天空盒的深度值z用永远为1的w替换。

// 在顶点着色器中:
vec4 pos = u_VP * vec4(Position, 1.0);
gl_Position = pos.xyww;

最后将深度函数的GL_LESS改为GL_EQUAL,让天空盒能通过深度测试,别忘了渲染完天空盒后把深度函数恢复原状:

GLCall(glDepthFunc(GL_LEQUAL));

// 渲染天空盒
skyboxShader.use();
skyboxShader.setMat4("view", glm::mat4(glm::mat3(camera.GetViewMatrix())));
skyboxShader.setMat4("projection", proj);
glBindVertexArray(skyboxVAO);
glBindTexture(GL_TEXTURE_CUBE_MAP, skyboxTexture);
glDrawArrays(GL_TRIANGLES, 0, 36);

GLCall(glDepthFunc(GL_LESS));

环境映射(Environment Mapping)

我们可以将立方体贴图上的环境属性应用到场景的物体上,这种技术被称为 环境映射, 常见的两种技术是 反射(Reflection)和 折射(Refraction)

反射

反射的原理图如下:

已知观察方向向量\(\mathbf{\hat{I}}\)和着色点表面法向量\(\mathbf{\hat{N}}\),可以快速求得反射向量\(\mathbf{\hat{R}}\)。在GLSL中,有个内建函数reflect()来帮我们计算反射向量,这个反射向量将会作为索引/采样立方体贴图的方向向量,返回环境的颜色值。

接下来修改场景物体的片段着色器,让物体有反射性:

#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;
uniform mat3 normalMat;

void main()
{
    Normal = normalMat * aNormal;
    Position = vec3(model * vec4(aPos, 1.0));
    gl_Position = projection * view * model * vec4(aPos, 1.0);
}

最后在渲染物体的时候先绑定立方体贴图纹理就好了:

glBindTexture(GL_TEXTURE_CUBE_MAP, skyboxTexture);    

效果如下:

对于模型来说,它们通常有 反射贴图(Reflection Map) 来增添它们的细节,而不是直接这样搞。引入反射贴图的时候需要注意 GL_TEXTUREx的绑定是否正确,否则会引发INVALID_OPERATION错误。

接下来尝试给NanoSuit模型引入反射贴图,地址在这里,作者说他为了欺骗Assimp,把反射贴图贴上了环境光贴图的标签,于是让AssimpaiTextureType_AMBIENT类贴图识别为反射贴图就行了。注意到这个贴图是黑白的,意味着它是个 布尔值,即在白色处才进行反射,因此要这样使用它:

// 片段着色器
vec3 processReflection()
{
	vec3 viewDir = normalize(Position - CameraPos);
	vec3 reflectDir = reflect(viewDir, normalize(Normal));
	return texture(texture_reflect1, TexCoords).rgb * texture(Skybox, reflectDir).rgb;
}

最终效果如下(这里使用了我近期写的个人项目,可在”项目”页面查看):

折射

光的折射通过斯涅尔定律描述,它的示意图如下:

根据斯涅尔定律,可以用观察向量\(\mathbf{\hat{I}}\)和着色点平面法向量\(\mathbf{\hat{N}}\)得到折射方向向量\(\mathbf{\hat{R}}\)。GLSL中提供内建函数refract()来求解折射方向向量,参数除了两个向量外,还要两个材质之间的折射率。常见折射率表如下:

实现折射,只需修改物体的片段着色器:

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

效果如下,就像玻璃那样:

现在我们使用的只是 单面折射(Single-Side Refraction),如果想获得物理上精确的结果,还需要在光线离开物体时再次折射。

动态环境映射

在之前的反射部分中,我们用静态纹理组成天空盒实现反射,但它没有在场景中包括可移动的物体,显得有些不真实。这里我们利用 帧缓冲立方体贴图 来实现动态环境映射。接下来看看详细的步骤是什么(代码拆解自我最近更新的项目,可能会有错,可在项目的LearnOpenGL-Tests/Test/TestEnvMapping.cpp中查看源代码)。

首先,先让我们创建一个空的立方体贴图(CubeMap)吧:

// 创建一个空的立方体贴图
unsigned int dynamicCubeMap = 0;
glGenTextures(1, &dynamicCubeMap);
glBindTexture(GL_TEXTURE_CUBE_MAP, dynamicCubeMap);
// 设定贴图 
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);
// 生成6个方向的贴图
for (int i = 0; i < 6; ++i)
{
	glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB, 1024, 1024, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL);
}

然后,我们创建一个帧缓冲对象,它拥有一个深度缓冲和一个用来存储立方体贴图的颜色附件:

// 创建一个FBO
unsigned int FBO = 0;
glGenFramebuffers(1, &FBO);

// 创建深度缓冲RBO
unsigned int RBO;
glBindRenderbuffer(GL_RENDERBUFFER, RBO);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT, 1024, 1024);
// 绑定RBO到FBO上
glBindFramebuffer(GL_FRAMEBUFFER, FBO);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, RBO);

// 先只绑定CubeMap的一个面到FBO上, 为了让FBO "完整"
glBindFramebuffer(GL_FRAMEBUFFER, FBO);
glBindTexture(GL_TEXTURE_CUBE_MAP, dynamicCubeMap);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_CUBE_MAP_POSITIVE_X, dynamicCubeMap, 0);

// 完事后解绑相应对象
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glBindRenderbuffer(GL_RENDERBUFFER, 0);
glBindTexture(GL_TEXTURE_CUBE_MAP, 0)

接下来是重头戏,要分别朝不同方向渲染除反射立方体本身外的所有物体6次,并更新FBO绑定的颜色和深度附件。这里可以修改摄像机的旋转矩阵/LookAt矩阵以便更正摄像机的朝向,这里我选择前者:

// 这里根据实际情况确认6个方向, 例如我摄像机初始朝向-z, 那么应用选择的结果如下
glm::vec2 rotations[] =
{
    // 使用欧拉角
    glm::vec2(180.0f, 90.0f),	// 右
    glm::vec2(180.0f, -90.0f),	// 左
    glm::vec2(90.0f, 0.0f),		// 上 +y
    glm::vec2(-90.0f, 0.0f),	// 下 
    glm::vec2(180.0f, 0.0f),	// 前 +z
    glm::vec2(180.0f, 180.0f),	// 后 -z
};

for (int i = 0; i < 6; ++i)
{
    // 通过旋转设置朝向, 单位pitch, yaw
    camera.setRotation(rotations[i].x, rotations[i].y);
    // 设置好朝向后获取view矩阵
    glm::mat4 view = camera.getViewMat();
    // 将宽高比设为1, 将Fov设置为90°后, 获取透视投影矩阵
    camera.setAspect(1.0f);
    camera.setFov(90.0f);
    glm::mat4 proj = camera.getPersProjMat();
    
    // 绑定相关参数, 开始渲染
    glBindFramebuffer(GL_FRAMEBUFFER, FBO);
    glBindTexture(GL_TEXTURE_CUBE_MAP, dynamicCubeMap);
    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, dynamicCubeMap, 0);
    glActiveTexture(GL_TEXTURE7);	// 这里我用了7
    glBindTexture(GL_TEXTURE_CUBE_MAP, dynamicCubeMap);
    
    glViewport(0, 0, 1024, 1024);	// 如果画面大小和贴图大小一致, 可省略这一步
    glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
     
    updateObjs();	// 不渲染反射的物体
    renderObjs();
}

最后进行一次通常渲染就行了,结果如下:

从上面的动图可以发现,它虽然实现了比较真实的结果,但是:

  • 反射的位置只能是(0, 0, 0),调整Env立方体的位置后效果还和在(0, 0, 0)处的一样;
  • 一个动态环境映射需要进行6次渲染,十分耗费性能(上图直接从165帧降到40帧);

现代的程序通常会尽可能使用天空盒,并在可能的时候使用预编译的立方体贴图,只要它们能产生一点动态环境贴图的效果。除此之外,还能通过减少drawcall来优化,例如只在场景中物体移动/光照更新后才更新动态环境贴图、使用多线程加速等。

参考资料

  • 立方体贴图 - LearnOpenGL CN (learnopengl-cn.github.io)
  • Dynamic cubemaps with OpenGL - mbroecker.com