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
,把反射贴图贴上了环境光贴图的标签,于是让Assimp
将aiTextureType_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