绘制立方体:顶点索引与纹理映射
好,咱们今天来聊聊立方体。别小看这个形状,它可是 3D 图形学里的“Hello World”。
上一章我们画了个三角形,那是基础。但立方体有 6 个面,8 个顶点。如果每个面都用独立的三角形去拼,你得定义 36 个顶点(每个面 2 个三角形,6 个面就是 12 个三角形,每个三角形 3 个顶点)。
这太浪费了。明明只有 8 个点,为什么要重复定义?
所以,我们需要 顶点索引 和 索引缓冲对象(EBO)。
为什么需要索引?
你想想看,一个立方体有 6 个面。每个面由 2 个三角形组成。每个三角形 3 个顶点。总共就是 6 × 2 × 3 = 36 个顶点数据。
但立方体实际上只有 8 个不同的顶点位置。剩下的 28 个都是重复的。
我在项目中遇到过这种情况:一个复杂的 3D 模型,顶点数量动辄几万甚至几十万。如果每个三角形都独立存储顶点,内存占用会爆炸。而且 GPU 处理重复顶点时,缓存命中率也会下降。
索引缓冲 就是来解决这个问题的。我们只存储 8 个不重复的顶点,然后用一个索引数组告诉 GPU:“第 0、1、2 号顶点组成第一个三角形,第 0、2、3 号顶点组成第二个三角形……”
核心思想:顶点数据只存一份,通过索引复用。节省内存,提升性能。
定义立方体的 8 个顶点
我们先定义顶点位置。为了简单,让立方体中心在原点,边长设为 2。这样每个坐标分量就是 -1 或 1。
// 8 个顶点位置
float vertices[] = {
// 前面四个顶点 (z = 1.0)
-1.0f, -1.0f, 1.0f, // 0: 左下前
1.0f, -1.0f, 1.0f, // 1: 右下前
1.0f, 1.0f, 1.0f, // 2: 右上前
-1.0f, 1.0f, 1.0f, // 3: 左上前
// 后面四个顶点 (z = -1.0)
-1.0f, -1.0f, -1.0f, // 4: 左下后
1.0f, -1.0f, -1.0f, // 5: 右下后
1.0f, 1.0f, -1.0f, // 6: 右上后
-1.0f, 1.0f, -1.0f // 7: 左上后
};
嗯,这里要注意:顶点的顺序是有讲究的。我习惯按“前面、后面、左面、右面、上面、下面”的顺序来组织索引,这样不容易乱。
定义索引数组
现在我们来定义 36 个索引。每个面 2 个三角形,每个三角形 3 个索引。
unsigned int indices[] = {
// 前面 (z = 1.0)
0, 1, 2, // 第一个三角形
0, 2, 3, // 第二个三角形
// 后面 (z = -1.0)
4, 5, 6, // 注意:从后面看,顶点顺序要逆时针
4, 6, 7,
// 左面 (x = -1.0)
4, 0, 3,
4, 3, 7,
// 右面 (x = 1.0)
1, 5, 6,
1, 6, 2,
// 上面 (y = 1.0)
3, 2, 6,
3, 6, 7,
// 下面 (y = -1.0)
4, 5, 1,
4, 1, 0
};
注意:每个三角形的顶点顺序必须是逆时针方向(从外部看)。这是 OpenGL 的默认设置,用于背面剔除。顺序反了,面就看不见了。
创建索引缓冲对象(EBO)
创建 EBO 的步骤和 VBO 很像。说白了就是多了一个缓冲区,专门存索引。
unsigned int VBO, VAO, EBO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glGenBuffers(1, &EBO);
// 绑定 VAO
glBindVertexArray(VAO);
// 绑定并填充 VBO
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 绑定并填充 EBO
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
// 设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
绘制的时候,不再用 glDrawArrays,而是用 glDrawElements:
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_INT, 0);
36 就是索引总数。GL_UNSIGNED_INT 表示每个索引是 4 字节的无符号整数。
纹理映射:给每个面穿上衣服
光有颜色太单调了。我们给立方体贴上纹理。
纹理映射的原理很简单:每个顶点除了位置,还要有一个纹理坐标(UV 坐标)。纹理坐标范围是 [0, 1],(0,0) 对应纹理左下角,(1,1) 对应右上角。
但这里有个坑:立方体有 6 个面,每个面需要独立的纹理坐标。你不能让 8 个顶点共享同一套 UV,因为同一个顶点在不同面上对应的纹理位置是不同的。
举个例子:顶点 0(-1, -1, 1)在前面是左下角,UV 是 (0,0)。但它在左面是右下角,UV 是 (1,0)。
所以,每个面需要 4 个独立的顶点。虽然位置坐标可以复用,但纹理坐标不能复用。
我在项目中遇到过这个问题:一开始偷懒,只定义了 8 个顶点带 UV,结果纹理贴出来是扭曲的。后来老老实实为每个面定义了 4 个独立的顶点(位置相同但 UV 不同),才搞定。
最终,我们需要 24 个顶点(6 个面 × 4 个顶点),每个顶点包含位置和 UV:
float vertices[] = {
// 前面
-1.0f, -1.0f, 1.0f, 0.0f, 0.0f,
1.0f, -1.0f, 1.0f, 1.0f, 0.0f,
1.0f, 1.0f, 1.0f, 1.0f, 1.0f,
-1.0f, 1.0f, 1.0f, 0.0f, 1.0f,
// 后面
-1.0f, -1.0f, -1.0f, 0.0f, 0.0f,
1.0f, -1.0f, -1.0f, 1.0f, 0.0f,
1.0f, 1.0f, -1.0f, 1.0f, 1.0f,
-1.0f, 1.0f, -1.0f, 0.0f, 1.0f,
// 左面
-1.0f, -1.0f, -1.0f, 0.0f, 0.0f,
-1.0f, -1.0f, 1.0f, 1.0f, 0.0f,
-1.0f, 1.0f, 1.0f, 1.0f, 1.0f,
-1.0f, 1.0f, -1.0f, 0.0f, 1.0f,
// 右面
1.0f, -1.0f, 1.0f, 0.0f, 0.0f,
1.0f, -1.0f, -1.0f, 1.0f, 0.0f,
1.0f, 1.0f, -1.0f, 1.0f, 1.0f,
1.0f, 1.0f, 1.0f, 0.0f, 1.0f,
// 上面
-1.0f, 1.0f, 1.0f, 0.0f, 0.0f,
1.0f, 1.0f, 1.0f, 1.0f, 0.0f,
1.0f, 1.0f, -1.0f, 1.0f, 1.0f,
-1.0f, 1.0f, -1.0f, 0.0f, 1.0f,
// 下面
-1.0f, -1.0f, -1.0f, 0.0f, 0.0f,
1.0f, -1.0f, -1.0f, 1.0f, 0.0f,
1.0f, -1.0f, 1.0f, 1.0f, 1.0f,
-1.0f, -1.0f, 1.0f, 0.0f, 1.0f
};
索引数组也相应调整为 36 个(6 个面 × 6 个索引):
unsigned int indices[] = {
0, 1, 2, 0, 2, 3, // 前面
4, 5, 6, 4, 6, 7, // 后面
8, 9, 10, 8, 10, 11, // 左面
12, 13, 14, 12, 14, 15, // 右面
16, 17, 18, 16, 18, 19, // 上面
20, 21, 22, 20, 22, 23 // 下面
};
加载纹理
Android 中加载纹理,我习惯用 BitmapFactory 配合 GLUtils.texImage2D:
int textureId;
glGenTextures(1, &textureId, 0);
glBindTexture(GL_TEXTURE_2D, textureId);
// 设置纹理参数
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// 加载位图
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.texture);
GLUtils.texImage2D(GL_TEXTURE_2D, 0, bitmap, 0);
bitmap.recycle();
在顶点着色器中,我们需要传递纹理坐标:
// 顶点着色器
#version 300 es
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 aTexCoord;
out vec2 TexCoord;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main() {
gl_Position = projection * view * model * vec4(aPos, 1.0);
TexCoord = aTexCoord;
}
片段着色器采样纹理:
// 片段着色器
#version 300 es
precision mediump float;
in vec2 TexCoord;
out vec4 FragColor;
uniform sampler2D ourTexture;
void main() {
FragColor = texture(ourTexture, TexCoord);
}
让立方体旋转起来
旋转很简单。在每一帧更新模型矩阵的旋转角度就行:
float angle = (float)System.currentTimeMillis() / 1000.0f * 50.0f; // 每秒转 50 度
Matrix.setIdentityM(modelMatrix, 0);
Matrix.rotateM(modelMatrix, 0, angle, 0.5f, 1.0f, 0.3f); // 绕任意轴旋转
然后把模型矩阵传给着色器中的 uniform 变量。
小技巧:旋转轴用 (0.5, 1.0, 0.3) 这样不规则的向量,看起来更自然。如果只绕 Y 轴转,会显得很呆板。
知识体系总览
下面这张图帮你理清本章的核心逻辑:
避坑指南
我曾经在纹理映射上踩过一个坑:纹理图片的尺寸不是 2 的幂次方。在旧设备上,纹理根本显示不出来,或者显示为黑色。后来我统一把纹理图片缩放到 512×512 或 1024×1024,问题就解决了。
另外,索引数组的类型一定要和 glDrawElements 中指定的类型一致。如果你用 unsigned short 存索引,那就要用 GL_UNSIGNED_SHORT。类型不匹配会导致崩溃或绘制错误。
还有一个容易忽略的点:纹理坐标的 Y 轴方向。OpenGL 中纹理坐标原点在左下角,而 Bitmap 的原点在左上角。加载纹理时,GLUtils.texImage2D 会自动翻转,但如果你用其他方式加载,可能需要手动翻转 Y 轴。
总结
这一章我们做了三件事:
- 用索引缓冲(EBO)减少顶点重复,提升性能
- 为每个面定义独立的纹理坐标,实现正确的纹理映射
- 通过模型矩阵旋转,让立方体动起来
掌握了这些,你已经可以画出带纹理的 3D 物体了。接下来,你可以尝试加载更复杂的模型,或者加上光照效果。
嗯,今天就到这里。代码写完了记得跑一下,看看你的立方体转起来没有。