OpenGL 的 API 很容易让人迷失在细节里——创建一个 Buffer、绑定一个 VAO、设置一堆 vertex attribute pointer、再绑定 Shader……每一步都知道「怎么写」,但连起来「为什么这么写」经常是模糊的。

这篇文章不教 API——API 你可以在官方文档找到。这篇文章把一条三角形数据从 CPU 内存到屏幕像素的完整旅程走一遍。

1. 全景图:一条数据的 5 个站点

graph LR
    CPU["CPU 端<br/>顶点数据准备"]
    GPU_MEM["GPU 显存<br/>VBO 存储"]
    VERTEX["顶点着色器<br/>坐标变换"]
    RASTER["光栅化<br/>三角形→像素"]
    FRAG["片段着色器<br/>逐像素颜色"]

    CPU -->|"glBufferData"| GPU_MEM
    GPU_MEM -->|"glDrawArrays"| VERTEX
    VERTEX -->|"组装图元"| RASTER
    RASTER -->|"生成片段"| FRAG
    FRAG -->|"写入帧缓冲"| SCREEN["屏幕"]

每一步做的事情:

  1. CPU → VBO:顶点数据(位置、法线、UV)从 float 数组拷贝到 GPU 显存
  2. VBO → 顶点着色器:GPU 从显存里取顶点数据,喂给着色器程序
  3. 顶点着色器:你写的 main() 函数,负责把顶点从模型空间变换到裁剪空间
  4. 光栅化:GPU 的固定硬件,把三角形拆成像素(片段),自动插值顶点属性
  5. 片段着色器:你写的另一个 main(),决定每个像素最终是什么颜色

VAO/EBO 在哪? VAO 在步骤 1-2 之间充当「接线说明书」,告诉 GPU 显存里的数据怎么解读。EBO 在步骤 1-2 之间告诉 GPU 「哪些顶点组成三角形」,避免顶点重复存储。


2. 第 0 站:CPU 准备数据

一切从一段内存开始:

float vertices[] = {
    // x,    y,    z,      nx,   ny,   nz,     u,    v
    -0.5f, -0.5f, 0.0f,   0.0f, 0.0f, 1.0f,   0.0f, 0.0f,  // 左下
     0.5f, -0.5f, 0.0f,   0.0f, 0.0f, 1.0f,   1.0f, 0.0f,  // 右下
     0.5f,  0.5f, 0.0f,   0.0f, 0.0f, 1.0f,   1.0f, 1.0f,  // 右上
    -0.5f,  0.5f, 0.0f,   0.0f, 0.0f, 1.0f,   0.0f, 1.0f,  // 左上
};

unsigned int indices[] = {
    0, 1, 2,   // 第一个三角形
    0, 2, 3    // 第二个三角形
};

一个矩形有 4 个顶点,2 个三角形。每个顶点 8 个 float(3 位置 + 3 法线 + 2 UV),4 个顶点 = 32 个 float = 128 字节。

为什么需要 indices(索引/EBO)? 因为两个三角形共享了 2 个顶点(0 和 2)。有索引的情况下:只存 4 个顶点,索引数组告诉 GPU 怎么组三角形。没有索引:必须存 6 个独立顶点(每个三角形 3 个),浪费 2 个顶点的存储。在体素引擎里,一个 Chunk 有几万个顶点,索引省下的内存非常可观。


3. 第 1 站:VBO — 把数据搬到 GPU

GLuint vbo;
glGenBuffers(1, &vbo);                         // 1. 要一个 buffer 编号
glBindBuffer(GL_ARRAY_BUFFER, vbo);            // 2. 绑定到目标(ARRAY_BUFFER = 顶点数据)
glBufferData(GL_ARRAY_BUFFER,                  // 3. 把数据拷过去
             sizeof(vertices), vertices,
             GL_STATIC_DRAW);                   // 4. 告诉 GPU 使用模式
graph TD
    subgraph CPU["CPU 内存"]
        DATA["float vertices[32]"]
    end
    subgraph GPU["GPU 显存"]
        VBO["VBO (Buffer Object)<br/>编号: 1<br/>大小: 128 bytes<br/>用途: GL_ARRAY_BUFFER"]
    end
    DATA -->|"glBufferData<br/>DMA 传输"| VBO

关键概念:

  • glGenBuffers 只是要个编号,不分配显存
  • glBindBuffer 把编号和「用途」绑定(GL_ARRAY_BUFFER 表示顶点数据)
  • glBufferData 真正分配显存 + 拷贝数据
  • GL_STATIC_DRAW 告诉 GPU:「数据不会经常改,主要用于画」——GPU 可能把它放到读写速度最优的位置

解绑: glBindBuffer(GL_ARRAY_BUFFER, 0) 解绑 VBO。这在你配 VAO 时很重要——解绑后才能安全切换 Buffer。


4. 第 2 站:VAO — 接线说明书

VBO 只是一坨二进制数据。GPU 不知道哪 3 个 float 是 position、哪 2 个 float 是 UV。

VAO(Vertex Array Object)就是接线说明书——它记录 VBO 的格式定义。

GLuint vao;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);                        // 激活这个 VAO

glBindBuffer(GL_ARRAY_BUFFER, vbo);            // 绑 VBO

// location = 0: position (3 floats)
glEnableVertexAttribArray(0);
glVertexAttribPointer(0,                       // location(和 shader 里的 layout(location=0) 对应)
                      3,                       // 几个分量:x, y, z
                      GL_FLOAT,               // 类型
                      GL_FALSE,               // 不需要 normalize
                      8 * sizeof(float),      // stride:跳过一个顶点要跨越多少字节
                      (void*)0);              // offset:从开头偏移多少

// location = 1: normal (3 floats)
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE,
                      8 * sizeof(float),
                      (void*)(3 * sizeof(float)));  // 跳过 3 个 float (position)

// location = 2: texCoord (2 floats)
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE,
                      8 * sizeof(float),
                      (void*)(6 * sizeof(float)));  // 跳过 6 个 float (position + normal)

glBindVertexArray(0);                          // 配好了,解绑
graph TD
    VAO["VAO 接线说明书"]
    subgraph VBO_DATA["VBO: 128 bytes 原始数据"]
        P1["pos xy z | nxnyn z | uv"]
        P2["pos xy z | nxnyn z | uv"]
        P3["pos xy z | nxnyn z | uv"]
        P4["pos xy z | nxnyn z | uv"]
    end
    VAO -->|"attr 0: pos, 3 floats, offset 0"| P1
    VAO -->|"attr 1: normal, 3 floats, offset 12"| P1
    VAO -->|"attr 2: uv, 2 floats, offset 24"| P1

为什么需要 VAO? 没有 VAO 的话,每次 glDrawArrays 之前都要重复设置所有 glVertexAttribPointer。有了 VAO,设置一次,以后只需 glBindVertexArray(vao)

一个类比: VBO 是硬盘里的原始文件,VAO 是文件系统的索引表——它不存数据,但告诉你文件从哪开始、什么格式、占多少字节。


5. 第 3 站:EBO — 顶点复用

前面说过,4 个顶点能画出 2 个三角形,靠的就是索引。

GLuint ebo;
glGenBuffers(1, &ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER,
             sizeof(indices), indices, GL_STATIC_DRAW);

EBO 的特殊之处: EBO 和 VAO 自动绑定。glBindVertexArray(vao) 时会自动记录当前绑定的 EBO,解绑 VAO 不会解绑 EBO。这意味着你不能在绑了 VAO 的情况下 glDeleteBuffers 删掉 EBO——要先解绑 VAO。

graph LR
    VAO2["VAO"]
    VBO2["VBO<br/>4 个顶点"]
    EBO["EBO<br/>indices: [0,1,2, 0,2,3]"]

    VAO2 --> VBO2
    VAO2 --> EBO

    EBO -->|"第 1 个三角形: 顶点 0→1→2"| VBO2
    EBO -->|"第 2 个三角形: 顶点 0→2→3"| VBO2

6. 第 4 站:顶点着色器 — 把顶点放到空间里

数据准备好了,GPU 开始干活。第一步是顶点着色器。

GPU 对 EBO 里的每个索引取一次顶点数据,并行执行顶点着色器。N 个顶点 → N 次执行,互不干扰。

#version 330 core

// 输入:来自 VAO/VBO
layout(location = 0) in vec3 a_position;   // ← VAO 的 location 0
layout(location = 1) in vec3 a_normal;     // ← VAO 的 location 1
layout(location = 2) in vec2 a_texCoord;   // ← VAO 的 location 2

// 输入:来自 CPU 代码(uniform)
uniform mat4 u_view;       // 相机矩阵
uniform mat4 u_proj;       // 投影矩阵
uniform mat4 u_model;      // 模型矩阵(方块在世界里的位置)

// 输出:传给片段着色器
out vec3 v_normal;
out vec2 v_texCoord;

void main() {
    // 把顶点从模型空间 → 世界空间 → 相机空间 → 裁剪空间
    gl_Position = u_proj * u_view * u_model * vec4(a_position, 1.0);

    // 法线也要变换(只旋转,不平移)
    v_normal = mat3(u_model) * a_normal;

    // UV 直接透传
    v_texCoord = a_texCoord;
}
graph TD
    INPUT["输入: 8 个 float 的顶点数据"]
    UNIFORM["Uniform: view / proj / model 矩阵"]
    SHADER["顶点着色器 main()"]
    OUTPUT["输出: gl_Position<br/>裁剪空间坐标<br/>+ v_normal, v_texCoord"]

    INPUT --> SHADER
    UNIFORM --> SHADER
    SHADER --> OUTPUT

gl_Position 是唯一的强制输出。 其他 out 变量是可选的,会自动插值后传给片段着色器。


7. 第 5 站:图元装配与光栅化 — GPU 的固定硬件

这两步不能编程——它们是 GPU 里的固定功能电路。

graph LR
    VS["3 个顶点<br/>从顶点着色器"]
    ASSEMBLY["图元装配<br/>连成三角形"]
    CLIP["裁剪<br/>去掉屏幕外的部分"]
    RAST["光栅化<br/>三角形 → 像素片段"]

    VS --> ASSEMBLY
    ASSEMBLY --> CLIP
    CLIP --> RAST

图元装配:把 3 个顶点组成一个三角形(或 2 个一组组成线段,或 1 个一组组成点)。这时 GPU 会做背面剔除——如果这个三角形面朝背面,直接丢弃。

光栅化:确定三角形覆盖了哪些屏幕像素,每个被覆盖的像素生成一个「片段」。片段的属性(颜色、UV、法线)是三个顶点的插值结果——三角形中心附近的片段取的是三个顶点的平均值。

一个例子: 三角形的三个顶点颜色是红、绿、蓝。光栅化后,三角形中心的片段颜色是灰色(红+绿+蓝/3),靠近红顶点的片段偏红,靠近绿顶点的偏绿。这就是 barycentric 插值,全自动。


8. 第 6 站:片段着色器 — 每个像素画什么

GPU 对每个片段执行一次片段着色器。一个 1920×1080 的屏幕上一个中等大小的三角形,可能产生几万个片段——每个都走一遍着色器。

#version 330 core

in vec3 v_normal;        // 从顶点着色器插值来的法线
in vec2 v_texCoord;      // 插值来的 UV

uniform sampler2D u_atlas;    // 纹理图集

out vec4 fragColor;           // 最终颜色

void main() {
    // 从纹理采样
    vec4 texColor = texture(u_atlas, v_texCoord);

    // 简单光照
    vec3 lightDir = normalize(vec3(0.5, 1.0, 0.3));
    float diff = max(dot(normalize(v_normal), lightDir), 0.0);
    float ambient = 0.4;
    float lighting = ambient + diff * 0.6;

    fragColor = vec4(texColor.rgb * lighting, texColor.a);
}

注意: v_normal 经过光栅化插值后可能不再长度为 1——所以在片段着色器里要 normalize(v_normal)


9. 终点:帧缓冲 → 屏幕

片段着色器的输出 fragColor 进入帧缓冲。帧缓冲是一个内存区域,存储每个像素的最终颜色值 + 深度值 + 模板值。

graph TD
    FRAG["片段着色器<br/>输出 fragColor"]
    TEST["逐片段测试<br/>深度测试 / 模板测试 / 混合"]
    FB["帧缓冲<br/>颜色缓冲 + 深度缓冲"]
    SCREEN2["屏幕"]

    FRAG --> TEST
    TEST -->|"通过"| FB
    FB -->|"双缓冲交换<br/>glutSwapBuffers<br/>或 Qt 自动"| SCREEN2

深度测试: 如果新片段的 z 值比深度缓冲里已有的值更远(更大),丢弃它。这保证了远处的物体不会盖住近处的物体。

双缓冲: Qt 的 QOpenGLWidget 默认开启了双缓冲。一帧渲染完,Qt 自动交换前后缓冲区。你不会看到「正在画到一半」的画面。


10. 一张图串起来

graph TD
    CPU_DATA["CPU: float vertices[],<br/>unsigned int indices[]"]

    VBO["VBO: 顶点数据 → GPU 显存"]
    EBO["EBO: 索引数据 → GPU 显存"]
    VAO["VAO: 接线说明书<br/>记录 VBO+EBO 的格式"]

    VS["顶点着色器<br/>逐顶点变换坐标"]
    ASSEMBLY["图元装配<br/>顶点→三角形"]
    RASTER2["光栅化<br/>三角形→像素片段"]
    FS["片段着色器<br/>逐像素计算颜色"]
    FB2["帧缓冲<br/>颜色+深度+模板"]
    SCREEN3["屏幕"]

    CPU_DATA --> VBO
    CPU_DATA --> EBO
    VBO --> VAO
    EBO --> VAO
    VAO --> VS
    VS --> ASSEMBLY
    ASSEMBLY --> RASTER2
    RASTER2 --> FS
    FS --> FB2
    FB2 --> SCREEN3

    style CPU_DATA fill:#e74c3c,stroke:#c0392b,color:#fff
    style VBO fill:#f39c12,stroke:#e67e22,color:#fff
    style EBO fill:#f39c12,stroke:#e67e22,color:#fff
    style VAO fill:#f1c40f,stroke:#f39c12,color:#333
    style VS fill:#2ecc71,stroke:#27ae60,color:#fff
    style ASSEMBLY fill:#3498db,stroke:#2980b9,color:#fff
    style RASTER2 fill:#3498db,stroke:#2980b9,color:#fff
    style FS fill:#9b59b6,stroke:#8e44ad,color:#fff
    style FB2 fill:#1abc9c,stroke:#16a085,color:#fff
    style SCREEN3 fill:#e74c3c,stroke:#c0392b,color:#fff

三个阶段:

  • 🟠 准备阶段(CPU) — VBO/EBO/VAO 设置,数据从 CPU 搬到 GPU
  • 🟢 可编程阶段 — 顶点着色器和片段着色器,你写的代码在这里跑
  • 🔵 固定功能阶段 — 图元装配、光栅化、深度测试,GPU 硬件自动完成

11. 常见坑

原因 现象 解决
什么都没画出来 VAO 没绑,或 VBO 没上传数据 黑屏 检查 glBindVertexArray + glBufferData 是否都执行了
三角形变形 stride 写错了 顶点错位 stride 应该是 sizeof(Vertex),不是某个属性的大小
纹理是黑的 uniform 名称写错了或没传 黑纹理 检查 setUniformValue("u_atlas", ...) 中名字和 shader 里一致
只画了第一个三角形 glDrawElements 的 count 参数写死为 3 缺三角形 第二个参数是索引数量,不是三角形数量
程序崩溃在 glDraw* VAO 解绑后 EBO 被删了 段错误 解绑 VAO 前不要 glDeleteBuffers 删 EBO
EBO 没生效 VAO 绑 EBO 要在 bind VAO 之后 顶点重复 bindVertexArray(vao),再 bindBuffer(ELEMENT_ARRAY_BUFFER, ebo)

系列文章:体素引擎从零构建 | 作者:Logic