绘制立方体:顶点索引与纹理映射

好,咱们今天来聊聊立方体。别小看这个形状,它可是 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 轴转,会显得很呆板。

知识体系总览

下面这张图帮你理清本章的核心逻辑:

立方体绘制核心流程 顶点数据 位置 + 纹理坐标 索引数据 (EBO) 36 个索引 VAO 绑定 VBO + EBO 纹理加载 Bitmap → GL纹理 着色器程序 顶点 + 片段 矩阵变换 模型/视图/投影 glDrawElements(GL_TRIANGLES, 36, ...) 最终渲染

避坑指南

我曾经在纹理映射上踩过一个坑:纹理图片的尺寸不是 2 的幂次方。在旧设备上,纹理根本显示不出来,或者显示为黑色。后来我统一把纹理图片缩放到 512×512 或 1024×1024,问题就解决了。

另外,索引数组的类型一定要和 glDrawElements 中指定的类型一致。如果你用 unsigned short 存索引,那就要用 GL_UNSIGNED_SHORT。类型不匹配会导致崩溃或绘制错误。

还有一个容易忽略的点:纹理坐标的 Y 轴方向。OpenGL 中纹理坐标原点在左下角,而 Bitmap 的原点在左上角。加载纹理时,GLUtils.texImage2D 会自动翻转,但如果你用其他方式加载,可能需要手动翻转 Y 轴。

总结

这一章我们做了三件事:

  • 用索引缓冲(EBO)减少顶点重复,提升性能
  • 为每个面定义独立的纹理坐标,实现正确的纹理映射
  • 通过模型矩阵旋转,让立方体动起来

掌握了这些,你已经可以画出带纹理的 3D 物体了。接下来,你可以尝试加载更复杂的模型,或者加上光照效果。

嗯,今天就到这里。代码写完了记得跑一下,看看你的立方体转起来没有。


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