第十五章 天空盒与环境映射:立方体贴图

说实话,每次讲到天空盒这部分,我都会想起自己第一次在手机上做出完整天空盒的那个下午。当时看着那个虚拟世界里的蓝天白云,真的有种「我创造了一个小宇宙」的错觉。嗯,虽然现在回头看那个代码写得挺糙的,但那种成就感一直留到现在。

天空盒和环境映射,说白了就是让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
  • 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 知识结构图

下面这张图把天空盒和环境映射的核心流程串起来了:

天空盒与环境映射核心流程 准备6张纹理 绑定立方体贴图 设置纹理参数 绘制天空盒 计算反射/折射方向 采样立方体贴图 混合反射 + 折射 + 菲涅尔效果 关键点:天空盒跟随相机、深度测试用LEQUAL、法线方向统一

15.6 避坑指南

做天空盒和环境映射,有几个坑我踩过,分享给你:

  • 纹理方向搞反:我曾经把top和bottom搞反了,结果天空跑到脚底下去了。建议先画一个简单的测试场景确认方向。
  • 法线没归一化:反射和折射都对法线方向很敏感,不归一化的话效果会扭曲。
  • 深度测试顺序:先画天空盒再画其他物体,或者先画物体再画天空盒但用LEQUAL。我习惯先画物体再画天空盒,这样能省一次深度清除。
  • 纹理尺寸不一致:6张图尺寸必须一样,否则采样会出问题。

我的经验:调试天空盒的时候,可以先在片段着色器里直接输出纹理坐标的颜色值,看看采样是否正确。比如把vTexCoords直接当颜色输出,如果看到红绿蓝渐变,说明方向没问题。

环境映射的效果,很大程度上取决于天空盒图片的质量。我建议用HDR格式的图片,能保留更多亮度信息,反射出来的高光会更真实。不过HDR在移动设备上处理起来有点麻烦,需要用到浮点纹理,这个后面再细聊。

好了,天空盒和环境映射就讲到这里。你动手试试看,在场景里加一个反射球或者玻璃球,效果会非常直观。


公众号:蓝海资料掘金营,微信deep3321