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["屏幕"]
每一步做的事情:
- CPU → VBO:顶点数据(位置、法线、UV)从
float数组拷贝到 GPU 显存 - VBO → 顶点着色器:GPU 从显存里取顶点数据,喂给着色器程序
- 顶点着色器:你写的
main()函数,负责把顶点从模型空间变换到裁剪空间 - 光栅化:GPU 的固定硬件,把三角形拆成像素(片段),自动插值顶点属性
- 片段着色器:你写的另一个
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