第十五章 天空盒与环境映射:立方体贴图
说实话,每次讲到天空盒这部分,我都会想起自己第一次在手机上做出完整天空盒的那个下午。当时看着那个虚拟世界里的蓝天白云,真的有种「我创造了一个小宇宙」的错觉。嗯,虽然现在回头看那个代码写得挺糙的,但那种成就感一直留到现在。
天空盒和环境映射,说白了就是让3D场景看起来更真实、更完整的一套技术。你想想看,如果场景里只有几个模型在飘,背景是黑的,那多假啊。天空盒就是给整个场景套上一个「全景背景」,而环境映射则是让物体表面反射出周围的环境。
15.1 立方体贴图是什么
立方体贴图,英文叫 Cube Map。它不是一张普通的纹理,而是由6张正方形纹理组成的「盒子」。
这6张图分别对应:
- 正X(右)
- 负X(左)
- 正Y(上)
- 负Y(下)
- 正Z(前)
- 负Z(后)
采样的时候,不是用普通的UV坐标,而是用一个三维方向向量。这个向量从立方体中心出发,指向某个方向,OpenGL会自动算出它穿过了哪张面、在哪个位置采样。
核心概念:立方体贴图 = 6张纹理 + 3D方向采样
我在项目中遇到过一个问题:有些设备上天空盒的接缝处会出现细线。后来发现是纹理边缘没处理好,把GL_TEXTURE_WRAP_S/T/R都设成GL_CLAMP_TO_EDGE就解决了。
15.2 加载天空盒纹理
加载立方体贴图比普通纹理麻烦一点。你需要准备6张图,然后按顺序绑定到GL_TEXTURE_CUBE_MAP的6个目标上。
我个人习惯把图片按这个顺序命名:
- right.jpg
- left.jpg li>
- top.jpg
- bottom.jpg
- front.jpg
- back.jpg
加载代码大概长这样:
int textureId = glGenTexture();
glBindTexture(GL_TEXTURE_CUBE_MAP, textureId);
int[] targets = {
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
};
String[] files = {"right", "left", "top", "bottom", "front", "back"};
for (int i = 0; i < 6; i++) {
Bitmap bitmap = BitmapFactory.decodeFile(files[i] + ".jpg");
glTexImage2D(targets[i], 0, GL_RGB,
bitmap.getWidth(), bitmap.getHeight(),
0, GL_RGB, GL_UNSIGNED_BYTE, bitmap);
bitmap.recycle();
}
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);
小技巧:图片尺寸最好是2的幂,比如1024x1024。不是必须的,但兼容性更好。
15.3 绘制天空盒
天空盒的绘制有个关键点:它要跟着相机走,但不能被缩放或旋转影响。说白了,就是让天空盒始终以相机为中心,但位置永远在远处。
实现方式很简单:
- 把模型矩阵的平移部分去掉
- 只保留旋转
- 把天空盒放在远裁剪面附近
顶点着色器里这样处理:
#version 300 es
layout(location = 0) in vec3 aPos;
uniform mat4 uView;
uniform mat4 uProjection;
out vec3 vTexCoords;
void main() {
// 去掉平移,只保留旋转
mat4 view = mat4(mat3(uView));
vec4 clipPos = uProjection * view * vec4(aPos, 1.0);
gl_Position = clipPos.xyww; // 强制z为1.0,放到最远
vTexCoords = aPos;
}
片段着色器:
#version 300 es
precision highp float;
in vec3 vTexCoords;
uniform samplerCube uSkybox;
out vec4 fragColor;
void main() {
fragColor = texture(uSkybox, vTexCoords);
}
注意那个gl_Position = clipPos.xyww。这招很巧妙,把z强制设成w,这样深度值就变成1.0了。配合深度测试的GL_LEQUAL,天空盒永远在最远处,不会挡住其他物体。
注意:绘制天空盒时要关闭深度写入,或者用GL_LEQUAL深度测试。否则其他物体会被天空盒挡住。
15.4 环境反射
环境反射,就是让物体表面像镜子一样反射周围环境。原理其实不复杂:
- 计算视线方向
- 根据法线计算反射方向
- 用反射方向采样立方体贴图
片段着色器里这样写:
#version 300 es
precision highp float;
in vec3 vNormal;
in vec3 vPosition;
uniform vec3 uCameraPos;
uniform samplerCube uSkybox;
out vec4 fragColor;
void main() {
vec3 viewDir = normalize(uCameraPos - vPosition);
vec3 reflectDir = reflect(-viewDir, normalize(vNormal));
vec4 reflectColor = texture(uSkybox, reflectDir);
fragColor = reflectColor;
}
我曾经在一个金属材质的模型上试过这个效果,反射出来的天空和周围环境,看起来特别真实。不过要注意,法线一定要在模型空间或者世界空间统一,别搞混了。
15.4 环境折射
折射和反射很像,只是计算方向的时候用折射公式代替反射公式。OpenGL内置了refract函数,需要传入一个折射率比值。
float ratio = 1.0 / 1.52; // 空气到玻璃
vec3 refractDir = refract(-viewDir, normalize(vNormal), ratio);
vec4 refractColor = texture(uSkybox, refractDir);
折射率比值决定了光线弯曲的程度。常见的材质折射率:
| 材质 | 折射率 |
|---|---|
| 空气 | 1.00 |
| 水 | 1.33 |
| 玻璃 | 1.52 |
| 钻石 | 2.42 |
你可以把反射和折射混合起来,做出更丰富的效果。比如玻璃球,既有反射又有折射,还带一点菲涅尔效应。
15.5 知识结构图
下面这张图把天空盒和环境映射的核心流程串起来了:
15.6 避坑指南
做天空盒和环境映射,有几个坑我踩过,分享给你:
- 纹理方向搞反:我曾经把top和bottom搞反了,结果天空跑到脚底下去了。建议先画一个简单的测试场景确认方向。
- 法线没归一化:反射和折射都对法线方向很敏感,不归一化的话效果会扭曲。
- 深度测试顺序:先画天空盒再画其他物体,或者先画物体再画天空盒但用LEQUAL。我习惯先画物体再画天空盒,这样能省一次深度清除。
- 纹理尺寸不一致:6张图尺寸必须一样,否则采样会出问题。
我的经验:调试天空盒的时候,可以先在片段着色器里直接输出纹理坐标的颜色值,看看采样是否正确。比如把vTexCoords直接当颜色输出,如果看到红绿蓝渐变,说明方向没问题。
环境映射的效果,很大程度上取决于天空盒图片的质量。我建议用HDR格式的图片,能保留更多亮度信息,反射出来的高光会更真实。不过HDR在移动设备上处理起来有点麻烦,需要用到浮点纹理,这个后面再细聊。
好了,天空盒和环境映射就讲到这里。你动手试试看,在场景里加一个反射球或者玻璃球,效果会非常直观。