
上一篇我们已经把 OpenGL 3.3 Core Profile 的窗口、上下文、GLFW 和 GLAD 环境搭建起来了。窗口能显示颜色,只能说明"上下文创建成功";要真正开始学习 OpenGL,还需要先回答几个基础问题:OpenGL 到底是什么?一条 glDrawArrays 调用之后发生了什么?VBO、VAO、Shader 和 Framebuffer 各自保存什么?为什么少写一个 glBindVertexArray,程序就可能什么也画不出来?
这篇文章按照"概念讲解 → 官方定义 → 小段代码 → 运行结果 → 常见误区"的方式,建立现代 OpenGL 3.3+ Core Profile 的整体认识。我们会用一个三角形把整条链路串起来,但重点不是记住三角形代码,而是理解每一行代码正在改变哪一份 OpenGL 状态。
本文的核心判断:现代 OpenGL 不是一个"把顶点传进去就自动出图"的函数库,而是一个**由当前 Context 持有状态、由对象保存数据、由 Shader 描述计算规则、由绘制命令启动管线的图形 API。**读懂这四件事,后面的纹理、光照、模型矩阵和帧缓冲都只是继续组合这些概念。
一、OpenGL 到底是什么?
Khronos 的 OpenGL 3.3 Core Profile 规范把 OpenGL 描述为一个面向图形硬件的软件接口 ,它由一组过程和函数组成,用来描述生成图像时涉及的对象和操作。换句话说,OpenGL 不是一个模型格式,也不是一个窗口系统,更不是一个已经替你完成渲染的引擎。
OpenGL 主要回答的是:当应用程序提交顶点、纹理和状态以后,图形实现应该如何解释这些数据、执行哪些阶段、产生什么结果。窗口由 GLFW 创建,显卡驱动提供具体实现,GLAD 帮助程序取得函数地址;OpenGL API 本身负责定义绘制行为。
可以把它拆成四层:
| 层次 | 主要问题 | 典型内容 |
|---|---|---|
| 窗口系统 | 图像显示到哪个窗口? | GLFW、WGL、GLX、CGL |
| OpenGL Context | 当前线程使用哪套 OpenGL 状态? | 版本、Profile、Framebuffer、对象绑定 |
| OpenGL 对象 | GPU 端保存哪些数据? | Buffer、Texture、Vertex Array、Framebuffer |
| OpenGL 管线 | 数据如何变成片段和像素? | Shader、Rasterization、Depth、Blend |
因此下面这段代码不是"打开 OpenGL":
cpp
#include <glad/gl.h>
#include <GLFW/glfw3.h>
它只是包含函数声明。真正开始使用 OpenGL,必须先有一个当前 Context:
cpp
GLFWwindow* window = glfwCreateWindow(960, 540, "OpenGL", nullptr, nullptr);
if (!window) {
return -1;
}
glfwMakeContextCurrent(window);
if (gladLoadGL(reinterpret_cast<GLADloadfunc>(glfwGetProcAddress)) == 0) {
return -1;
}
GLFW 官方文档明确说明,窗口对象同时封装了顶层窗口和 OpenGL Context; 在进行 OpenGL 调用之前,正确类型的 Context 必须对当前线程成为 current。glfwGetProcAddress 查询的也是当前 Context 可以使用的函数地址。
官方资料:
二、OpenGL 的核心:Context + State Machine
1. Context 是一份"当前状态环境"
OpenGL 规范说明,每一个 OpenGL Context 都包含一套完整的 GL server state。我们平时调用的很多函数,并不是把所有参数都传齐,而是在修改 Context 中的状态变量。
例如:
cpp
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LESS);
glClearColor(0.08f, 0.12f, 0.20f, 1.0f);
第一行打开深度测试,第二行选择深度比较规则,第三行设置颜色缓冲区的清除值。这些调用本身并不会立刻画出一个物体,而是在改变后续绘制会读取的状态。
所以 OpenGL 程序经常出现这样的结构:
bash
设置状态
↓
glDraw* 发出绘制命令
↓
管线按照当前状态处理数据
↓
结果写入当前绑定的 Framebuffer
也可以把一次绘制理解为"读取多个当前绑定点":

2. 为什么同一个函数在不同位置会得到不同结果?
因为 OpenGL 命令会读取调用时的当前状态。例如:
cpp
glBindBuffer(GL_ARRAY_BUFFER, positionBuffer);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,
3 * sizeof(float), nullptr);
glVertexAttribPointer 的 pointer 参数在这里不是一个 CPU 指针,而是 VBO 数据存储中的字节偏移。它会读取当前绑定的 GL_ARRAY_BUFFER,并把这个绑定关系保存到当前 VAO 对应的顶点属性状态中。
如果把顺序改成:
cpp
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,
3 * sizeof(float), nullptr);
glBindBuffer(GL_ARRAY_BUFFER, positionBuffer);
那么 glVertexAttribPointer 执行时,OpenGL 还没有看到 positionBuffer。这不是风格问题,而是状态采样时机已经不同了。
3. "绑定"不是"复制数据"
cpp
glBindBuffer(GL_ARRAY_BUFFER, positionBuffer);
这行不会把整个 VBO 复制到另一个对象,也不会把数据变成 Shader 输入。它只是把一个对象绑定到某个 Context 的目标槽位上,让后续命令知道"这次操作针对谁"。
理解绑定模型,可以先记住三个词:
| 词 | 含义 |
|---|---|
| Object | GPU 端的一份资源或状态集合,例如一个 Buffer 名称 |
| Target | 当前命令要操作的目标槽位,例如 GL_ARRAY_BUFFER |
| Binding | 当前 Target 指向哪个 Object |
这是现代 OpenGL 最容易让人误解的地方:对象名称只是句柄,真正让 API 知道"接下来操作哪个对象"的,是当前 Binding。
展开说说:
glBindBuffer 不拷贝内存、不搬运数据。只是在 OpenGL Context(状态机)里面:把「缓冲区对象 ID」挂到一个 目标槽位 (Target Slot)上。后面所有同 target 的 OpenGL 函数,都会作用到当前绑定的这个 VBO 对象。
OpenGL 对象(VBO、VAO、Texture、Program)本质:GPU 那边的一个 ID 号(整数句柄),数据存在 GPU 显存。 bind = 将 ID 填进上下文的某个 "全局变量槽位",不是拷贝数据。
cpp
// positionBuffer 是VBO的uint类型ID,GPU显存已经存好了顶点数据
glBindBuffer(GL_ARRAY_BUFFER, positionBuffer);
GL_ARRAY_BUFFER:就是槽位名称(数组缓冲区槽位)- 执行 bind:把
positionBuffer这个 ID,写入 Context 内部的GL_ARRAY_BUFFER全局槽位。 - ✅没有拷贝显存数据,没有移动字节,只是记了个 ID。
bind 之后会发生什么?
后面调用的函数,不传入对象 ID,自动拿槽位里当前绑定的对象干活。
示例:
cpp
glBindBuffer(GL_ARRAY_BUFFER, positionBuffer);
// glBufferData 没有传VBO ID!它操作【当前绑定到GL_ARRAY_BUFFER槽位的VBO】
glBufferData(GL_ARRAY_BUFFER, sizeof(data), data, GL_STATIC_DRAW);
glBufferData(GL_ARRAY_BUFFER,...)→ 看槽位里现在绑的是谁,就给谁写数据。
想象 Context 有一块公告板,上面有很多插槽:
- 插槽 A 名字叫:
GL_ARRAY_BUFFER positionBuffer= GPU 上一个文件柜子(柜子里面存顶点字节数据)
glBindBuffer(GL_ARRAY_BUFFER, positionBuffer):
把柜子的编号写在插槽 A 上,柜子还老老实实待在原来的位置,没有搬家,没有复制柜子内容。
后续 glBufferData(GL_ARRAY_BUFFER, ...):
"去看插槽 A 写的编号,找到对应的柜子,往柜子里面写入内容"。
再执行:
cpp
glBindBuffer(GL_ARRAY_BUFFER, otherBuffer);
只是把插槽 A 擦掉,改写另一个柜子编号;原来 positionBuffer 柜子还在 GPU 显存,数据完好无损。
⚠️ 关键点:bind 只是改公告板上的编号,柜子本身不动。
误区澄清
❌错误理解:bind 的时候把 VBO 数据复制到 CPU/GPU 别的地方。 ✅真相:bind 只是修改 Context 状态(填 ID 到槽位),0 字节数据拷贝。
❌错误理解:bindBuffer 之后,数据自动就送给 shader 当输入。 ✅真相:bindBuffer 仅仅标记槽位。想要成为 Shader 顶点输入,还需要:
- 绑定 VAO
glVertexAttribPointer:告诉 GPU,从当前GL_ARRAY_BUFFER槽位绑定的 VBO 里面,如何解析出顶点属性(位置、UV)glEnableVertexAttribArray开启属性通道
bind ≠ 管线接入。bind 只是选好 "我接下来操作哪个对象"。
完整小流程,把 bind 的角色看明白
cpp
//1. 创建VBO,得到ID;此时GPU已经分配对象,但公告板插槽没写它
GLuint positionBuffer;
glGenBuffers(1, &positionBuffer);
//2. bind:把ID写到 GL_ARRAY_BUFFER 插槽(仅仅改状态,无拷贝)
glBindBuffer(GL_ARRAY_BUFFER, positionBuffer);
//3. 因为插槽现在是positionBuffer,glBufferData就把顶点数据写入这个VBO的显存
glBufferData(GL_ARRAY_BUFFER, size, cpuData, GL_STATIC_DRAW);
//4. bind别的对象,插槽被改写;positionBuffer显存数据依然保留
glBindBuffer(GL_ARRAY_BUFFER, 0);
//5. 重新绑定回来,才能继续修改positionBuffer
glBindBuffer(GL_ARRAY_BUFFER, positionBuffer);
glBindXXX 全部都是:修改 Context 内部各个槽位存的 ID 值,几乎不产生 GPU 工作。真正干活,是后续 draw 调用。
OpenGL 很多 API 设计:先 bind 设置状态,再执行命令;命令读取当前绑定对象。
对比现代 Vulkan
Vulkan 没有 bind,几乎所有函数调用都直接传入对象 ID;没有全局槽位状态。 OpenGL 这套 bind 槽位设计,就是老 API 遗留的状态机风格。
三、从顶点到像素:OpenGL 管线
OpenGL 3.3 Core Profile 的顶点处理、光栅化、片段处理和帧缓冲操作,可以先用下面这张图建立全局视角:

每个阶段解决的问题不同:
- 顶点数据:应用程序准备位置、颜色、法线、纹理坐标等属性;
- Vertex Shader :逐顶点执行,必须把位置写入
gl_Position; - Primitive Assembly :根据
GL_TRIANGLES、GL_LINES等模式把顶点组装成图元; - Clipping:裁剪视锥体之外的部分;
- Rasterization:把点、线或三角形转换成一组待处理的片段;
- Fragment Shader:计算每个片段的颜色或其他输出;
- Per-Fragment Operations:进行深度、模板、混合等测试;
- Framebuffer:把最终结果写入颜色、深度和模板附件。
这里有一个很重要的用词区别:Rasterization 产生的是 fragments,不是已经确定写入屏幕的 pixels。片段还要经过深度测试、模板测试、混合和写掩码等操作,最后才可能更新 Framebuffer。
四、顶点数据:VBO、VAO 和 EBO
1. VBO 保存数据,VAO 保存解释方式
先看最小的三角形顶点数组:
cpp
float vertices[] = {
// position // color
0.0f, 0.6f, 0.0f, 1.0f, 0.2f, 0.2f,
-0.6f, -0.5f, 0.0f, 0.2f, 1.0f, 0.2f,
0.6f, -0.5f, 0.0f, 0.2f, 0.3f, 1.0f,
};
这个数组只是 CPU 内存中的数据。要让 GPU 访问它,需要创建一个 Buffer Object:
cpp
GLuint vbo = 0;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER,
sizeof(vertices),
vertices,
GL_STATIC_DRAW);
Khronos 对 glBufferData 的定义是:为当前绑定的 Buffer Object 创建并初始化数据存储。GL_STATIC_DRAW 是使用提示,表达"数据大致会被多次使用、很少修改"的意图;它不是强制 GPU 必须采用某一种物理存储方式。
**VAO 的作用不是保存顶点数组本身,而是保存顶点属性数组的启用状态、格式、步长、偏移以及相应的 Buffer 关系。**可以把它理解成一张"顶点数据说明书":
cpp
// 声明VAO(顶点数组对象)ID,初始值0
GLuint vao = 0;
// 生成1个VAO对象,将对象ID写入变量vao;仅在GPU创建对象,不做绑定
glGenVertexArrays(1, &vao);
// 绑定VAO到OpenGL上下文的VAO槽位
// 后续 glVertexAttribPointer、glEnableVertexAttribArray 的配置会记录进当前绑定的VAO内部
glBindVertexArray(vao);
// 将已创建好的VBO(顶点缓冲对象)绑定到 GL_ARRAY_BUFFER 目标槽位
// glVertexAttribPointer会读取【当前绑定在GL_ARRAY_BUFFER的VBO】的数据
glBindBuffer(GL_ARRAY_BUFFER, vbo);
/*
* 配置顶点属性0号(location = 0):顶点位置属性
* 参数说明:
* 0 : 属性location编号,对应顶点着色器 layout(location = 0) in vec3 aPos;
* 3 : 该属性由3个数值组成(x,y,z)
* GL_FLOAT : 每个数值的数据类型为float
* GL_FALSE : 是否需要把数值归一化到0~1(位置不需要归一化,填false)
* 6 * sizeof(float) : 步长(Stride):一个完整顶点占多少字节;每个顶点包含 3个位置float + 3个颜色float = 6个float
* reinterpret_cast<void*>(0) : 该属性在单个顶点内的字节偏移,位置从顶点数据起始位置(偏移0)开始读取
*/
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,
6 * sizeof(float),
reinterpret_cast<void*>(0));
// 开启0号顶点属性通道;默认属性是关闭的,不开启GPU不会读取该属性
glEnableVertexAttribArray(0);
/*
* 配置顶点属性1号(location = 1):顶点颜色属性
* 参数说明:
* 1 : 属性location编号,对应顶点着色器 layout(location = 1) in vec3 aColor;
* 3 : 颜色由3个数值组成(r,g,b)
* GL_FLOAT : 数据类型float
* GL_FALSE : 不需要归一化
* 6 * sizeof(float) : 步长,同一个顶点总字节大小,和上面保持一致
* reinterpret_cast<void*>(3 * sizeof(float)) : 字节偏移:跳过前面3个float的位置数据,从第4个float开始读颜色
*/
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE,
6 * sizeof(float),
reinterpret_cast<void*>(3 * sizeof(float)));
// 开启1号顶点属性通道
glEnableVertexAttribArray(1);
这里的两次 glVertexAttribPointer 正好对应 GLSL 中的两个输入位置:
cpp
#version 330 core
// location = 0 对应C++ glVertexAttribPointer 的第一个参数 0
layout (location = 0) in vec3 aPosition;
// location = 1 对应C++ glVertexAttribPointer 的第一个参数 1
layout (location = 1) in vec3 aColor;
2. 为什么 Core Profile 要绑定 VAO?
在 Core Profile 中,顶点属性数组属于 Vertex Array Object 的状态。绘制时,OpenGL 要从当前绑定的 VAO 中知道:哪些属性启用、每个属性如何解析、数据来自哪个 Buffer。
因此绘制前至少要有:
cpp
/**
* glUseProgram(program)
* 将着色器程序对象绑定到上下文,后续绘制就使用这套顶点+片元着色器
* program:glCreateProgram 创建、经过attachShader、link得到的程序ID
* 👉 管线的顶点着色器、片元着色器就确定下来;uniform变量也写到这个program上
*/
glUseProgram(program);
/**
* glBindVertexArray(vao)
* 绑定我们之前配置好的VAO对象到上下文
* VAO内部保存:
* 1.各个location对应的glVertexAttribPointer配置(步长、偏移、关联的VBO)
* 2.glEnableVertexAttribArray 开启/关闭状态
* 绑定完成后,GPU就知道去哪里读取顶点位置、颜色数据,不需要再重复调用glVertexAttribPointer
*/
glBindVertexArray(vao);
/**
* glDrawArrays(mode, first, count)
* 【触发GPU整条渲染流水线执行!前面全部只是设置状态】
* 参数:
* GL_TRIANGLES:图元模式,每3个顶点组成一个三角形
* 0:从顶点数组的第0号顶点开始读取
* 3:一共读取3个顶点 → 绘制1个三角形
*
* 执行流程:
* 取VAO中的顶点数据 → 送入program的顶点着色器 → 图元装配 → 裁剪 → 光栅化 → 片元着色器 → 深度/模板/混合 → 写入帧缓冲
*/
glDrawArrays(GL_TRIANGLES, 0, 3);
如果没有绑定正确的 VAO,常见结果是 GL_INVALID_OPERATION、黑屏,或者程序使用了另一个 VAO 的旧状态。
3. EBO 解决重复顶点
三角形只有三个顶点时直接使用 glDrawArrays 很直观;当多个三角形共享顶点时,可以用 Element Buffer Object 保存索引:
cpp
// 索引数组:EBO(元素缓冲对象)的数据
// 每3个数字代表一个三角形,数字代表顶点数组里的顶点下标
// 0,1,2 → 使用第0、1、2号顶点拼成第一个三角形
// 2,1,3 → 使用第2、1、3号顶点拼成第二个三角形
GLuint indices[] = {
0, 1, 2,
2, 1, 3,
};
GLuint ebo = 0; // EBO对象ID,初始置0
glGenBuffers(1, &ebo); // 生成1个缓冲区对象,将GPU返回的ID存入ebo变量,仅创建不绑定
glBindVertexArray(vao); // 绑定VAO;⚠️EBO是存储在VAO状态里面的!必须先bindVAO!
// 绑定VAO之后,再绑定GL_ELEMENT_ARRAY_BUFFER,这个EBO的ID会被记录进当前VAO
// 将ebo绑定到【元素缓冲目标槽位 GL_ELEMENT_ARRAY_BUFFER】
// 这个target专门给索引缓冲EBO使用,区别于存顶点数据的GL_ARRAY_BUFFER(VBO)
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
/**
* glBufferData:把CPU内存中的indices索引数组上传到GPU显存的EBO缓冲区
* 参数列表:
* 1. GL_ELEMENT_ARRAY_BUFFER:目标槽位,操作当前绑定的EBO
* 2. sizeof(indices):要拷贝的字节总大小
* 3. indices:CPU端索引数组的指针,数据源
* 4. GL_STATIC_DRAW:使用提示,数据几乎不会修改,GPU放到高速显存,适合静态模型
*/
glBufferData(GL_ELEMENT_ARRAY_BUFFER,
sizeof(indices), indices, GL_STATIC_DRAW);
然后使用:
cpp
/**
* glDrawElements:使用EBO索引缓冲进行绘制调用
* 👉 触发整条GPU渲染管线,前面所有bind、配置都只是设置状态,到这里GPU才真正开始工作
*
* 参数1:GL_TRIANGLES
* 图元模式,每读取3个索引,组成1个三角形图元
*
* 参数2:6
* 需要读取的索引总数量。我们indices数组一共6个元素 {0,1,2, 2,1,3},所以填6;会生成2个三角形
*
* 参数3:GL_UNSIGNED_INT
* EBO里面存储的索引的数据类型;代码中indices是GLuint,对应无符号32位整数。
* 可选还有 GL_UNSIGNED_SHORT / GL_UNSIGNED_BYTE,根据你索引数组类型选择
*
* 参数4:nullptr
* EBO内部的字节偏移地址;nullptr代表从EBO缓冲区的起始位置开始读取索引。
* 不为空的场景:一个EBO存放多组索引数据,指定从某个字节偏移处开始绘制。
*
* 依赖前提:
* 1. glUseProgram(program) 已经设置好当前着色器程序
* 2. glBindVertexArray(vao) 绑定了VAO;VAO内部已经记录了关联的EBO
* 3. EBO已经通过glBufferData上传了索引数据到GPU显存
*
* 内部工作流程:
* 1. GPU从VAO记录的EBO读取索引值(顶点下标)
* 2. 根据索引,去VBO中取出对应编号的顶点数据
* 3. 送入顶点着色器 → 图元装配 → 裁剪 → 光栅化 → 片元着色器 → 逐片元测试 → 写入帧缓冲
*/
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr);
GL_ELEMENT_ARRAY_BUFFER 的绑定关系属于 VAO 状态,所以 EBO 通常要在 VAO 已绑定时设置。这里又体现了同一条规律:先绑定保存状态的对象,再设置它所拥有的状态。
五、Shader:把"如何计算"交给 GPU
1. Shader 不是普通 C++ 函数
OpenGL 的 Shader 是运行在图形管线阶段中的小程序。OpenGL 3.3 使用 GLSL 3.30;Shader 源码通过 glCreateShader、glShaderSource、glCompileShader 编译,再附加到 Program Object 中链接。
一个最小的顶点 Shader:
cpp
#version 330 core
// 指定GLSL版本为330,对应OpenGL 3.3 Core Profile
// location=0:接收来自VAO顶点属性0的数据,对应C++ glVertexAttribPointer(0,...)
// aPosition:输入的顶点位置,来自VBO中的顶点数据
layout (location = 0) in vec3 aPosition;
// location=1:接收来自VAO顶点属性1的数据,对应C++ glVertexAttribPointer(1,...)
// aColor:输入的顶点颜色,来自VBO中的顶点数据
layout (location = 1) in vec3 aColor;
// out 输出变量:vertexColor
// 顶点着色器输出,会经过光栅器做【插值】,之后传递给片元着色器的 in 变量
out vec3 vertexColor;
void main() {
// gl_Position:内置输出变量,必须赋值;vec4 齐次坐标(x,y,z,w),w=1.0
// 这里直接把顶点位置输出到裁剪空间,没有做MVP矩阵变换
gl_Position = vec4(aPosition, 1.0);
// 将输入顶点颜色赋值给输出变量vertexColor
// 三角形内部每个片元会得到插值之后的颜色值
vertexColor = aColor;
}
一个最小的片段 Shader:
cpp
#version 330 core
// 指定着色器版本330,对应OpenGL3.3核心模式
/**
* in vec3 vertexColor
* 来自顶点着色器的 out 输出变量,名字必须完全相同 vertexColor
* ⚠️注意:这里收到的不是原始顶点的颜色!
* 经过【光栅化阶段插值计算】,得到三角形内部当前片元的平滑过渡颜色
*/
in vec3 vertexColor;
/**
* out vec4 fragColor
* 片元着色器输出变量,输出RGBA颜色,写入帧缓冲
* 这个就是最终像素颜色,RGBA四个通道,范围0.0 ~ 1.0
*/
out vec4 fragColor;
void main() {
// vec4(颜色RGB, Alpha透明度)
// Alpha = 1.0 代表完全不透明
fragColor = vec4(vertexColor, 1.0);
}
GLSL 规范把 gl_Position 定义为顶点 Shader 写出的齐次顶点位置。它的值会参与图元装配、裁剪和后续固定功能操作;如果顶点 Shader 没有写它,后续结果就是未定义的。
2. Compile 和 Link 是两次不同的检查
Shader 编译成功,只表示单个 Shader 源码符合 GLSL 语法和类型规则;Program Link 成功,才表示顶点 Shader 与片段 Shader 的接口可以组成一个可执行程序。
cpp
/**
* @brief 编译单个着色器
* @param type 着色器类型:GL_VERTEX_SHADER 顶点着色器 / GL_FRAGMENT_SHADER 片元着色器
* @param source 着色器GLSL源码字符串
* @return 成功返回shader对象ID;编译失败返回0
*/
GLuint compileShader(GLenum type, const char* source) {
// 创建一个着色器对象,返回GPU端对象ID
GLuint shader = glCreateShader(type);
/**
* glShaderSource:把C++内存中的GLSL源码字符串,传给着色器对象
* 参数:
* shader:着色器ID
* 1:源码字符串的数量(这里传入1段源码)
* &source:源码字符串指针数组
* nullptr:字符串长度,nullptr表示自动以'\0'判定字符串结尾
*/
glShaderSource(shader, 1, &source, nullptr);
// 触发GPU驱动编译GLSL源代码
glCompileShader(shader);
GLint success = GL_FALSE;
// 获取着色器的编译状态,存入success
glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
// 编译失败处理
if (!success) {
char log[1024] = {};
// 获取编译错误日志,写入log缓冲区
glGetShaderInfoLog(shader, sizeof(log), nullptr, log);
// 打印错误信息到标准错误输出
std::cerr << "Shader compile failed:\n" << log << '\n';
// 编译失败,释放该着色器对象资源,避免显存泄漏
glDeleteShader(shader);
return 0;
}
// 编译成功,返回着色器ID
return shader;
}
/**
* @brief 将顶点着色器与片元着色器链接成一个完整Program着色器程序
* @param vertexShader 已经编译好的顶点着色器ID
* @param fragmentShader 已经编译好的片元着色器ID
* @return 链接成功返回program ID;链接失败返回0
*/
GLuint linkProgram(GLuint vertexShader, GLuint fragmentShader) {
// 创建Program程序对象,后续供glUseProgram使用
GLuint program = glCreateProgram();
// 将编译完成的着色器附加(挂载)到program对象上
glAttachShader(program, vertexShader);
glAttachShader(program, fragmentShader);
// 执行链接:
// 1.把顶点着色器输出变量和片元着色器输入变量做匹配(变量名必须一致)
// 2.校验layout(location)位置匹配、类型匹配
// 3.生成GPU可以直接执行的可执行代码
glLinkProgram(program);
GLint success = GL_FALSE;
// 获取program链接状态
glGetProgramiv(program, GL_LINK_STATUS, &success);
// 链接失败处理
if (!success) {
char log[1024] = {};
// 获取链接错误日志
glGetProgramInfoLog(program, sizeof(log), nullptr, log);
std::cerr << "Program link failed:\n" << log << '\n';
// 链接失败,释放program对象
glDeleteProgram(program);
return 0;
}
return program;
}
创建 Program 的完整调用关系是:
cpp
// 编译【顶点着色器】:传入着色器类型与GLSL源码字符串,返回着色器对象ID
GLuint vertexShader = compileShader(GL_VERTEX_SHADER, vertexSource);
// 编译【片元着色器】
GLuint fragmentShader = compileShader(GL_FRAGMENT_SHADER, fragmentSource);
// 将编译好的顶点、片元着色器链接,生成完整可使用的着色器程序Program
GLuint program = linkProgram(vertexShader, fragmentShader);
/**
* glDeleteShader:删除着色器对象
* ⚠️注意:不是立刻销毁!因为着色器已经被 glAttachShader 附加到 program上,处于被引用状态。
* 仅仅标记该shader对象为"待删除";当program被销毁(glDeleteProgram)之后,才会真正释放资源。
* 链接成功之后着色器源码已经编译、组装进program内部,原始shader对象不再需要,可以做删除标记。
* 这一步是良好习惯,防止无用对象堆积造成资源泄漏,不写也能跑,但不推荐。
*/
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
删除 Shader 对象不会破坏已经成功链接的 Program;链接完成后,Program 已经拥有可以执行的 Shader 代码。
3. glUseProgram 决定当前使用哪个 Shader Program
cpp
glUseProgram(program);
OpenGL 允许创建多个 Program,但同一时刻只有一个 Program Object 处于当前使用状态。没有调用 glUseProgram,绘制命令不会使用你刚刚编译的 Shader。
4. in、out 是阶段之间的接口
顶点 Shader 的:
cpp
out vec3 vertexColor;
对应片段 Shader 的:
Go
in vec3 vertexColor;
在三角形内部,这个值默认会进行插值。因此三个顶点设置红、绿、蓝,三角形内部会得到连续变化的颜色。这个插值发生在顶点处理和片段处理之间,是"一个顶点数据如何影响多个片段"的关键机制。
六、坐标系统:gl_Position 到窗口坐标
1. 四维位置不是多余的
顶点 Shader 最终要写入:
cpp
gl_Position = vec4(aPosition, 1.0);
gl_Position 使用四维齐次坐标 (x, y, z, w)。顶点处理阶段之后,OpenGL 会执行透视除法,把前三个分量除以 w,得到 Normalized Device Coordinates:
cpp
x_ndc = x / w
y_ndc = y / w
z_ndc = z / w
对于本文直接使用 w = 1.0 的三角形,坐标已经位于常见的裁剪范围内,可以暂时把它理解为"直接写入 NDC"。当我们后面加入模型、观察和投影矩阵时,w 就会影响透视效果。
2. 从 NDC 到窗口像素
glViewport 把 NDC 映射到窗口坐标。Khronos 参考页将它描述为从 Normalized Device Coordinates 到 Window Coordinates 的仿射变换。
cpp
int width = 0;
int height = 0;
glfwGetFramebufferSize(window, &width, &height);
glViewport(0, 0, width, height);
注意两个事实:
- OpenGL 的 NDC 范围通常是
[-1, 1]; glViewport影响顶点位置映射,不是设置窗口大小的函数。
窗口尺寸改变时,需要重新设置 Viewport:
cpp
void framebufferSizeCallback(GLFWwindow*, int width, int height) {
glViewport(0, 0, width, height);
}
3. 坐标空间不要混在一起
后续学习矩阵时,可以使用这张表:
| 空间 | 谁产生 | 典型范围或特征 |
|---|---|---|
| Local/Object Space | 模型数据 | 物体自己的坐标 |
| World Space | Model 矩阵 | 所有物体共享的场景坐标 |
| View/Eye Space | View 矩阵 | 以摄像机为参考 |
| Clip Space | Projection 矩阵 | 写入 gl_Position 的空间 |
| NDC | 透视除法 | x/y/z 归一化后的坐标 |
| Window Space | glViewport |
帧缓冲中的像素位置 |
初学时最常见的错误是把窗口像素坐标直接当成 gl_Position 的输入。gl_Position = vec4(400, 300, 0, 1) 并不是"画到窗口的 400、300 像素",而是一个远超裁剪范围的 Clip Space 坐标。
七、Framebuffer、清屏和真正的输出
1. 默认 Framebuffer 不是"屏幕本身"
窗口创建时通常会得到一个默认 Framebuffer,它包含颜色缓冲区,通常还可能包含深度和模板缓冲区。glfwSwapBuffers 交换的是窗口关联的前后缓冲区,不是把 OpenGL 数据直接"发送给显示器"。
cpp
/**
* glClearColor(r, g, b, a)
* 设置【清除颜色】,也就是调用glClear时填充帧缓冲背景的颜色
* 参数取值范围:0.0 ~ 1.0
* 0.08f 红,0.12f 绿,0.20f 蓝,1.0f Alpha完全不透明
* ⚠️注意:glClearColor 只是设置状态,不会真正清空屏幕!要配合 glClear 才执行填充
*/
glClearColor(0.08f, 0.12f, 0.20f, 1.0f);
/**
* glClear:清除帧缓冲指定缓冲区
* GL_COLOR_BUFFER_BIT:清除颜色缓冲区,用上面 glClearColor 设置的背景色填充整个屏幕
* 其他可选掩码:
* GL_DEPTH_BUFFER_BIT 清除深度缓冲区
* GL_STENCIL_BUFFER_BIT 清除模板缓冲区
* 每帧渲染前一般要清除,否则会保留上一帧画面的残留内容,画面会拖影、重叠
*/
glClear(GL_COLOR_BUFFER_BIT);
/**
* glUseProgram(program)
* 将链接完成的着色器程序设置到OpenGL上下文,后续绘制使用这套顶点+片元着色器
*/
glUseProgram(program);
/**
* glBindVertexArray(vao)
* 绑定VAO对象,恢复全部顶点属性配置、以及VAO记录的EBO索引缓冲(如有)
*/
glBindVertexArray(vao);
/**
* glDrawArrays(GL_TRIANGLES, 0, 3)
* 发起绘制调用,触发整条GPU渲染管线
* GL_TRIANGLES:图元类型,三角形
* 0:从第0号顶点开始读取
* 3:一共读取3个顶点,绘制1个三角形
*/
glDrawArrays(GL_TRIANGLES, 0, 3);
/**
* glfwSwapBuffers(window)
* 交换双缓冲:把后台渲染好的颜色缓冲区内容,显示到屏幕窗口上
* OpenGL默认使用双缓冲机制:
* - 后台缓冲区:我们在这上面执行绘制、glClear、glDraw*
* - 前台缓冲区:当前屏幕正在显示的画面
* 如果不调用SwapBuffers,你渲染的内容只会留在后台缓冲区,屏幕看不到任何效果
*/
glfwSwapBuffers(window);
双缓冲简单解释
想象两张画布:A(屏幕正在显示)、B(后台画布,你在上面画画)。 你在 B 画布调用 glClear、glDrawArrays 绘图;
glfwSwapBuffers把 A、B 互换;现在 B 变成屏幕显示,A 变成新的后台画布。 避免一边画一边显示造成画面闪烁撕裂。
Khronos 的 glClear 参考页说明,glClear 会使用之前由 glClearColor、glClearDepth 和 glClearStencil 设置的值清除相应缓冲区。GL_COLOR_BUFFER_BIT、GL_DEPTH_BUFFER_BIT 和 GL_STENCIL_BUFFER_BIT 是它支持的三种清除掩码。
2. 清除颜色会被限制在 [0, 1]
cpp
glClearColor(2.0f, -1.0f, 0.5f, 1.0f);
这段代码不会写入一个"超过颜色范围两倍"的颜色。官方参考页规定,glClearColor 的分量会被限制到 [0, 1]。所以最终的清除值相当于 (1.0, 0.0, 0.5, 1.0)。
3. 深度缓冲不是自动启用的
如果要按深度关系遮挡物体,至少要准备带深度附件的 Framebuffer,并显式启用测试:
cpp
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LESS);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glClear(GL_COLOR_BUFFER_BIT) 不会清除深度缓冲;如果上一帧留下了深度值,下一帧可能出现"物体不见了"的现象。这是三维程序黑屏或闪烁时最值得先检查的一项。
4. 混合是片段写入前的组合规则
透明效果通常需要:
cpp
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
片段 Shader 输出的是源颜色,Framebuffer 中已有的是目标颜色;glBlendFunc 决定二者如何组合。混合不是 Shader 自动完成的,也不是把 Alpha 写成小于 1 就会自动透明。
八、一个完整的 Core Profile 三角形
下面代码假定已经完成上一篇文章的 GLFW、GLAD 初始化。为了把基础概念串起来,这里只展示创建 Shader、上传 VBO、配置 VAO 和绘制循环。代码中使用的接口都是 OpenGL 3.3 Core Profile 提供的接口。
1. Shader 源码
cpp
/**
* C++11 原始字符串字面量 R"GLSL( ... )GLSL"
* R"xxx( 内容 )xxx":原始字符串,字符串内部不用转义换行、引号,直接书写多行文本
* GLSL 是自定义分隔标记,可以换成别的标识符,只要前后一致即可。
* 把完整的顶点着色器源码直接写在C++代码里面,不需要单独读取外部 .glsl 文件。
*/
const char* vertexSource = R"GLSL(
#version 330 core
// 声明GLSL版本 330,对应OpenGL3.3 Core Profile
// location=0:接收C++侧glVertexAttribPointer(0,...)传入的顶点位置
layout (location = 0) in vec3 aPosition;
// location=1:接收C++侧glVertexAttribPointer(1,...)传入的顶点颜色
layout (location = 1) in vec3 aColor;
// out输出变量:向光栅器输出顶点颜色,后续插值后送入片元着色器
out vec3 vertexColor;
void main() {
// gl_Position 顶点着色器内置输出,齐次坐标vec4(x,y,z,w),w=1.0
gl_Position = vec4(aPosition, 1.0);
// 将顶点输入颜色赋值给输出变量
vertexColor = aColor;
}
)GLSL";
// 片元着色器源码,同样使用C++原始字符串
const char* fragmentSource = R"GLSL(
#version 330 core
/**
* in vec3 vertexColor
* 变量名必须和顶点着色器的 out 变量完全一致 vertexColor
* 该值不是原始顶点颜色,是光栅化插值后,当前片元(像素)的颜色
*/
in vec3 vertexColor;
// out输出:当前片元最终RGBA颜色,写入帧缓冲
out vec4 fragColor;
void main() {
// vec4(RGB, Alpha透明度),1.0代表完全不透明
fragColor = vec4(vertexColor, 1.0);
}
)GLSL";
这里的 layout (location = 0) 让应用程序可以直接用数字 0 找到位置属性,不需要再通过字符串查询属性位置。它必须与 C++ 中的 glVertexAttribPointer(0, ...) 对应。
2. 上传顶点并描述格式
cpp
/**
* CPU内存中的顶点数组
* 每个顶点占6个float:[x, y, z, r, g, b]
* 前3个float:顶点的位置坐标(NDC标准化设备坐标,范围 [-1, 1])
* 后3个float:顶点RGB颜色,范围 [0.0 ~ 1.0]
* 一共3个顶点,组成一个三角形
* 顶点0:(0.0, 0.6, 0.0),颜色红色偏 1.0,0.2,0.2
* 顶点1:(-0.6,-0.5,0.0),颜色绿色偏 0.2,1.0,0.2
* 顶点2:(0.6,-0.5,0.0),颜色蓝色偏 0.2,0.3,1.0
*/
float vertices[] = {
0.0f, 0.6f, 0.0f, 1.0f, 0.2f, 0.2f,
-0.6f, -0.5f, 0.0f, 0.2f, 1.0f, 0.2f,
0.6f, -0.5f, 0.0f, 0.2f, 0.3f, 1.0f,
};
GLuint vao = 0; // 顶点数组对象ID,初始化为0
GLuint vbo = 0; // 顶点缓冲对象ID,初始化为0
// 生成1个VAO对象,ID写入vao变量,仅在GPU创建对象,不绑定
glGenVertexArrays(1, &vao);
// 生成1个VBO缓冲区对象,ID写入vbo变量,仅创建,不绑定
glGenBuffers(1, &vbo);
// 绑定VAO到上下文;后续 glVertexAttribPointer / glEnableVertexAttribArray 配置会存入此VAO
glBindVertexArray(vao);
// 将VBO绑定到 GL_ARRAY_BUFFER 目标槽位
// glVertexAttribPointer会记录当前绑定的这个VBO,保存进VAO状态
glBindBuffer(GL_ARRAY_BUFFER, vbo);
/**
* glBufferData:把CPU上vertices数组全部字节拷贝上传到GPU显存的VBO
* 参数:
* GL_ARRAY_BUFFER:操作当前绑定在该槽位的VBO
* sizeof(vertices):要拷贝的总字节大小
* vertices:CPU数据源指针
* GL_STATIC_DRAW:使用提示,数据几乎不会修改,GPU优先放在高速显存
*/
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices),
vertices, GL_STATIC_DRAW);
/**
* 配置 location = 0 顶点属性:位置 aPosition
* 0 : location编号,对应shader layout(location=0)
* 3 : 属性由3个float组成 x,y,z
* GL_FLOAT : 数据类型float
* GL_FALSE : 不需要把数值归一化到0~1(位置不需要归一)
* 6 * sizeof(float) : 步长stride:每一个完整顶点占的字节数(3位置+3颜色,共6个float)
* reinterpret_cast<void*>(0):该属性在顶点内部的字节偏移,从顶点起始0字节处读取
*/
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,
6 * sizeof(float),
reinterpret_cast<void*>(0));
// 开启0号顶点属性通道;属性默认关闭,关闭状态GPU不会向着色器传入数据
glEnableVertexAttribArray(0);
/**
* 配置 location = 1 顶点属性:颜色 aColor
* 1 : location编号,对应shader layout(location=1)
* 3 : RGB三个分量
* GL_FLOAT : float类型
* GL_FALSE : 颜色这里已经是0‑1范围,不需要硬件归一化
* 6 * sizeof(float) : 步长和上面保持一致,单个顶点总字节
* reinterpret_cast<void*>(3 * sizeof(float)):跳过前面3个float的位置数据,从此处读取RGB颜色
*/
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE,
6 * sizeof(float),
reinterpret_cast<void*>(3 * sizeof(float)));
// 开启1号顶点属性通道
glEnableVertexAttribArray(1);
逐项对应如下:
| 调用 | 解释 |
|---|---|
glBindVertexArray(vao) |
后续顶点属性配置写入这个 VAO |
glBindBuffer(GL_ARRAY_BUFFER, vbo) |
指定顶点数据来源 |
glBufferData |
创建并填充 VBO 数据存储 |
glVertexAttribPointer(0, ...) |
告诉 OpenGL 如何读位置属性 |
glEnableVertexAttribArray(0) |
启用位置属性数组 |
glVertexAttribPointer(1, ...) |
告诉 OpenGL 如何读颜色属性 |
glEnableVertexAttribArray(1) |
启用颜色属性数组 |
stride 是相邻两个同类属性之间的字节距离。当前一个顶点包含 6 个 float,所以 stride 是 6 * sizeof(float);颜色从每个顶点的第 4 个 float 开始,所以 offset 是 3 * sizeof(float)。
3. 运行绘制循环
cpp
// 激活着色器程序,后续绘制使用这套顶点+片元着色器
glUseProgram(program);
// 绑定VAO,一次性恢复顶点属性配置和关联的VBO
glBindVertexArray(vao);
/**
* 开启深度测试
* 作用:绘制3D物体时,GPU自动比较片元深度值,只保留离相机更近的像素,解决遮挡问题
* 备注:当前只绘制单个平面三角形,没有前后遮挡,不开也能正常显示;做3D模型时必须开启
*/
glEnable(GL_DEPTH_TEST);
/**
* glViewport(x, y, width, height)
* 定义OpenGL渲染结果映射到窗口的矩形区域
* 参数:(左下角x, 左下角y, 宽, 高)
* 这里把整个渲染结果铺满整个窗口帧缓冲
* ⚠️窗口大小改变时,需要重新调用这个函数更新宽高,否则画面拉伸变形
*/
glViewport(0, 0, framebufferWidth, framebufferHeight);
// 渲染主循环:窗口没有收到关闭信号就持续循环渲染
while (!glfwWindowShouldClose(window)) {
// 处理窗口事件:键盘、鼠标、窗口大小变化等消息
glfwPollEvents();
// 设置背景清除色,RGBA;放在循环内没问题,也可以放循环外只调用一次(状态会保留)
glClearColor(0.08f, 0.12f, 0.20f, 1.0f);
// 清空颜色缓冲区 + 深度缓冲区
// GL_COLOR_BUFFER_BIT:清空画面颜色,填充上面设置的背景色
// GL_DEPTH_BUFFER_BIT:清空深度缓冲区,重置所有像素深度值,不然上一帧的深度数据会干扰本帧3D绘制
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 选中着色器程序
glUseProgram(program);
// 绑定VAO,加载顶点属性配置
glBindVertexArray(vao);
// 发起绘制调用,触发GPU整条渲染管线
// GL_TRIANGLES:以三角形图元绘制;从第0个顶点开始,一共读取3个顶点,绘制1个三角形
glDrawArrays(GL_TRIANGLES, 0, 3);
// 双缓冲交换:后台渲染好的画面交换到前台窗口,用户才能看见图像
glfwSwapBuffers(window);
}
glDrawArrays(GL_TRIANGLES, 0, 3) 的含义是:从每个已启用的顶点属性数组中,从索引 0 开始取 3 个连续元素,按 GL_TRIANGLES 组装一个三角形。Khronos 的 glDrawArrays 参考页也把这三个参数分别定义为图元模式、起始元素和要渲染的元素数量。
4. 完整可运行版本
为了避免把代码片段拼接错,下面给出一个可以直接替换 src/main.cpp 的完整版本。它假定你已经按照上一篇文章配置好了 GLFW 和 GLAD 2:
cpp
#include <glad/gl.h>
#include <GLFW/glfw3.h>
#include <iostream>
// 匿名命名空间:内部函数仅本文件可见,防止全局命名冲突
namespace {
/**
* @brief 编译单个着色器
* @param type 着色器类型 GL_VERTEX_SHADER / GL_FRAGMENT_SHADER
* @param source GLSL源码字符串
* @return 成功返回shader对象ID;失败返回0
*/
GLuint compileShader(GLenum type, const char* source) {
// 创建着色器对象
const GLuint shader = glCreateShader(type);
// 将GLSL源码传入着色器对象
glShaderSource(shader, 1, &source, nullptr);
// 驱动编译GLSL代码
glCompileShader(shader);
GLint compiled = GL_FALSE;
// 查询编译状态
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
// 编译失败,读取错误日志
char log[1024] = {};
glGetShaderInfoLog(shader, sizeof(log), nullptr, log);
std::cerr << "Shader compile failed:\n" << log << '\n';
// 释放着色器资源
glDeleteShader(shader);
return 0;
}
return shader;
}
/**
* @brief 创建完整着色器Program:编译+链接顶点、片元着色器
* @param vertexSource 顶点着色器源码
* @param fragmentSource 片元着色器源码
* @return 成功返回program ID,失败返回0
*/
GLuint createProgram(const char* vertexSource, const char* fragmentSource) {
// 编译顶点着色器
const GLuint vertexShader = compileShader(GL_VERTEX_SHADER, vertexSource);
// 编译片元着色器
const GLuint fragmentShader = compileShader(GL_FRAGMENT_SHADER, fragmentSource);
// 任意一个着色器编译失败,直接退出并清理资源
if (vertexShader == 0 || fragmentShader == 0) {
if (vertexShader != 0) glDeleteShader(vertexShader);
if (fragmentShader != 0) glDeleteShader(fragmentShader);
return 0;
}
// 创建Program程序对象
const GLuint program = glCreateProgram();
// 将两个着色器附加挂载到program
glAttachShader(program, vertexShader);
glAttachShader(program, fragmentShader);
// 执行链接操作
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
// 链接失败,打印日志并释放所有资源
char log[1024] = {};
glGetProgramInfoLog(program, sizeof(log), nullptr, log);
std::cerr << "Program link failed:\n" << log << '\n';
glDeleteProgram(program);
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
return 0;
}
// 链接成功,着色器源码已经打包进program,可以删除原始shader对象
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
return program;
}
/**
* @brief 窗口帧缓冲大小变化回调函数
* 窗口缩放时自动调用,更新视口,保证图形不会拉伸变形
* @param window 窗口指针
* @param width 帧缓冲新宽度
* @param height 帧缓冲新高度
*/
void framebufferSizeCallback(GLFWwindow*, int width, int height) {
glViewport(0, 0, width, height);
}
} // namespace
int main() {
// 初始化GLFW库
if (!glfwInit()) {
std::cerr << "glfwInit failed\n";
return 1;
}
// 设置OpenGL版本:3.3
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
// 使用核心模式Core Profile,抛弃旧版固定管线API
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
#ifdef __APPLE__
// MacOS需要开启向前兼容标记
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE);
#endif
// 创建960x540窗口,标题OpenGL Basic Concepts
GLFWwindow* window = glfwCreateWindow(
960, 540, "OpenGL Basic Concepts", nullptr, nullptr);
if (!window) {
std::cerr << "glfwCreateWindow failed\n";
glfwTerminate();
return 1;
}
// 将当前窗口的OpenGL上下文设置为当前线程激活上下文
glfwMakeContextCurrent(window);
// 加载OpenGL函数指针,glad依靠glfwGetProcAddress获取驱动函数
if (gladLoadGL(reinterpret_cast<GLADloadfunc>(glfwGetProcAddress)) == 0) {
std::cerr << "gladLoadGL failed\n";
glfwDestroyWindow(window);
glfwTerminate();
return 1;
}
// 顶点着色器源码,C++11原始字符串R"GLSL()"
const char* vertexSource = R"GLSL(
#version 330 core
layout (location = 0) in vec3 aPosition;
layout (location = 1) in vec3 aColor;
out vec3 vertexColor;
void main() {
gl_Position = vec4(aPosition, 1.0);
vertexColor = aColor;
}
)GLSL";
// 片元着色器源码
const char* fragmentSource = R"GLSL(
#version 330 core
in vec3 vertexColor;
out vec4 fragColor;
void main() {
fragColor = vec4(vertexColor, 1.0);
}
)GLSL";
// 编译+链接,生成着色器程序
const GLuint program = createProgram(vertexSource, fragmentSource);
if (program == 0) {
glfwDestroyWindow(window);
glfwTerminate();
return 1;
}
// 顶点数组:每个顶点6个float:x,y,z, r,g,b
const float vertices[] = {
0.0f, 0.6f, 0.0f, 1.0f, 0.2f, 0.2f,
-0.6f, -0.5f, 0.0f, 0.2f, 1.0f, 0.2f,
0.6f, -0.5f, 0.0f, 0.2f, 0.3f, 1.0f,
};
GLuint vao = 0;
GLuint vbo = 0;
// 创建VAO、VBO对象ID
glGenVertexArrays(1, &vao);
glGenBuffers(1, &vbo);
// 绑定VAO,后续顶点属性配置存入VAO
glBindVertexArray(vao);
// VBO绑定到数组缓冲目标
glBindBuffer(GL_ARRAY_BUFFER, vbo);
// 把CPU顶点数据上传到GPU显存VBO
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// location0:位置属性,3个float,步长6float,偏移0
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,
6 * sizeof(float), nullptr);
glEnableVertexAttribArray(0);
// location1:颜色属性,3个float,偏移3个float
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE,
6 * sizeof(float),
reinterpret_cast<void*>(3 * sizeof(float)));
glEnableVertexAttribArray(1);
// 获取初始帧缓冲大小,设置视口
int width = 0;
int height = 0;
glfwGetFramebufferSize(window, &width, &height);
glViewport(0, 0, width, height);
// 注册窗口缩放回调,窗口拉伸自动更新视口
glfwSetFramebufferSizeCallback(window, framebufferSizeCallback);
// ========== 渲染主循环 ==========
while (!glfwWindowShouldClose(window)) {
// 处理窗口事件(键盘、鼠标、窗口关闭、缩放)
glfwPollEvents();
// 设置清除背景色
glClearColor(0.08f, 0.12f, 0.20f, 1.0f);
// 清空颜色缓冲区
glClear(GL_COLOR_BUFFER_BIT);
// 启用着色器程序
glUseProgram(program);
// 绑定VAO,加载顶点属性配置
glBindVertexArray(vao);
// 绘制三角形,3个顶点
glDrawArrays(GL_TRIANGLES, 0, 3);
// 交换双缓冲,画面输出到屏幕
glfwSwapBuffers(window);
}
// ========== 资源释放(程序退出前清理GPU资源) ==========
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &vbo);
glDeleteProgram(program);
glfwDestroyWindow(window);
glfwTerminate();
return 0;
}
编译运行后,窗口中应该出现一个颜色从三个顶点向内部插值的三角形。这个程序没有使用纹理、矩阵或深度测试,但它已经完整经过了现代 OpenGL 的核心路径:Context、Buffer Object、Vertex Array Object、Vertex Shader、Primitive Assembly、Rasterization、Fragment Shader 和默认 Framebuffer。
你应该看到一个带有红、绿、蓝三种顶点颜色的渐变三角形。这个结果证明了下面的链路全部打通:
cpp
CPU 数组
→ VBO 数据存储
→ VAO 顶点属性格式
→ Vertex Shader
→ 三角形装配与光栅化
→ Fragment Shader 插值颜色
→ 默认 Framebuffer
→ glfwSwapBuffers
九、OpenGL 的错误模型
1. API 错误与 Shader 错误是两套机制
普通 API 错误可以通过 glGetError 读取:
cpp
/**
* @brief 打印OpenGL错误,循环取出所有积压的错误码
* @param label 标记字符串,用来定位是哪一段代码产生的错误(例如"after glDrawArrays")
*
* OpenGL错误是**队列机制**:一次可能产生多条错误,必须循环调用glGetError直到返回GL_NO_ERROR清空队列
* glGetError():取出队列头部的错误码;取出后该错误会从队列移除
* GL_NO_ERROR:代表当前没有待处理错误
*/
void printOpenGLErrors(const char* label) {
// 循环读取错误,直到glGetError返回GL_NO_ERROR(无错误)
for (GLenum error = glGetError();
error != GL_NO_ERROR;
error = glGetError()) {
// std::hex:以十六进制打印错误码;std::dec 切回十进制输出,避免后面数字输出乱掉
std::cerr << label << ": OpenGL error 0x"
<< std::hex << error << std::dec << '\n';
}
}
但 Shader 编译失败不能只靠 glGetError。必须分别查询:
cpp
// 定义变量存储编译状态,初始赋值为GL_FALSE
GLint compileStatus = GL_FALSE;
/**
* glGetShaderiv:iv 含义 i=int, v=vector(指针)
* 查询着色器对象的参数
* 参数1:shader 着色器ID
* 参数2:GL_COMPILE_STATUS 要查询的项:是否编译成功
* 参数3:&compileStatus,输出地址,结果写入这个变量
* 成功:compileStatus = GL_TRUE;失败:GL_FALSE
*/
glGetShaderiv(shader, GL_COMPILE_STATUS, &compileStatus);
/**
* glGetShaderInfoLog:获取着色器编译日志(警告、语法错误、报错信息)
* 参数1:shader 着色器ID
* 参数2:日志缓冲区最大字节长度
* 参数3:存放实际写入日志长度的指针,传nullptr代表不需要获取
* 参数4:char数组缓冲区,用来存放日志字符串
* 👉 注意:就算编译成功,也可能有警告,日志里会输出警告信息
*/
glGetShaderInfoLog(shader, ...);
Program 链接也要查询:
cpp
// 定义变量保存链接状态,初始值设为GL_FALSE
GLint linkStatus = GL_FALSE;
/**
* glGetProgramiv:iv = int + pointer(整数,输出到指针)
* 作用:查询Program着色器程序对象的参数
* 参数1:program,着色器程序ID
* 参数2:GL_LINK_STATUS,查询标记------是否链接成功
* 参数3:&linkStatus,输出变量地址
* 链接成功 → linkStatus = GL_TRUE;链接失败 → GL_FALSE
*/
glGetProgramiv(program, GL_LINK_STATUS, &linkStatus);
/**
* glGetProgramInfoLog
* 获取Program的链接日志,包含链接错误、变量匹配警告、类型不匹配等信息
* 参数1:program 对象ID
* 参数2:日志缓冲区最大字节数
* 参数3:保存实际日志长度的指针,传nullptr表示不需要
* 参数4:字符缓冲区,存放日志文本
* ✅ 注意:就算链接成功,也可能存在警告,同样会写入日志
*/
glGetProgramInfoLog(program, ...);
因此排查顺序应该是:
- GLFW 是否创建了窗口和 Context;
- GLAD 是否成功加载;
- Shader 是否编译成功;
- Program 是否链接成功;
- VAO、VBO 和属性数组是否配置;
- 绘制前是否绑定了正确 Program 和 VAO;
- 深度、模板、混合或裁剪状态是否意外影响结果。
2. glGetError 是一个错误队列
不要只在程序结尾调用一次 glGetError,否则很难知道错误来自哪一行。更适合在一小段 OpenGL 操作之后检查:
cpp
/**
* glBindVertexArray(vao)
* 绑定指定的VAO到OpenGL上下文。
* VAO内部保存了:VBO引用、所有glVertexAttribPointer顶点属性配置、属性启用状态。
* 绑定之后,后续绘制会直接复用这套顶点属性配置,不用重复写一长串属性设置代码。
*/
glBindVertexArray(vao);
/**
* glDrawArrays 发起绘制调用,触发完整GPU渲染管线
* 参数说明:
* GL_TRIANGLES:图元类型,每3个顶点组成1个独立三角形
* 0:从第0号顶点开始读取
* 3:一共读取3个顶点
*/
glDrawArrays(GL_TRIANGLES, 0, 3);
/**
* 自定义调试函数,抓取OpenGL错误队列
* label="draw triangle" 用来标记当前代码位置,一旦报错,就知道是绘制三角形这一步产生的错误
* 会循环读取所有积压的OpenGL错误并打印,避免错误残留在队列里干扰后续调试
*/
printOpenGLErrors("draw triangle");
同时也要知道:glGetError 只能告诉你 API 状态错误,不能证明画面一定正确。没有错误不代表顶点位置在可见范围,也不代表 Shader 逻辑符合预期。
十、OpenGL 命令不是普通的立即返回函数
很多 OpenGL 调用会先把命令提交给驱动,再由驱动安排 GPU 执行。glFlush 要求之前的命令尽快开始执行,但不会等待完成;glFinish 会阻塞到此前命令完成。普通渲染循环一般不需要每帧调用 glFinish,否则 CPU 可能无意义地等待 GPU。
cpp
// 一般不建议放在每一帧
// glFinish();
这也是为什么"函数已经返回"不等于"GPU 已经完成全部工作"。读取像素、同步对象和性能测量时,需要更谨慎地处理 CPU/GPU 的异步关系。
十一、现代 Core Profile 中的几个边界
1. Core Profile 不使用固定功能管线
下面这些旧式接口不属于现代 Core Profile 的学习主线:
cpp
glBegin(...);
glEnd();
glMatrixMode(...);
glLoadIdentity();
glVertex3f(...);
现代 Core Profile 使用:
cpp
VBO / VAO
+
GLSL Vertex Shader / Fragment Shader
+
glDrawArrays / glDrawElements
这样做的代价是初始化代码更多,但好处是顶点格式、变换、光照和材质计算都能明确地放进可控的 Shader 和对象状态中。
2. OpenGL 3.3 与更高版本的写法不要混用
OpenGL 4.5 的 Direct State Access 接口,例如 glCreateBuffers、glNamedBufferData 和 glCreateVertexArrays,可以减少绑定操作,但它们不属于 OpenGL 3.3 Core 的基础接口。
如果教程目标是 3.3+,优先使用:
cpp
glGenBuffers(...);
glBindBuffer(...);
glBufferData(...);
等真正确认 Context 版本和扩展以后,再引入更高版本的接口。否则程序可能在一台 4.6 机器上正常,却在目标 3.3 机器上无法加载。
3. OpenGL 是状态机,但资源管理仍然要有自己的抽象
"状态机"不等于所有代码都应该散落在全局。实际项目通常会封装:
ShaderProgram:编译、链接、设置 uniform;VertexArray:创建 VAO、配置顶点属性;Buffer:管理 VBO、EBO 的生命周期;Texture:管理纹理对象、采样参数和上传;Framebuffer:管理颜色、深度和模板附件。
封装的目的不是隐藏 OpenGL,而是明确谁拥有对象、谁负责绑定、谁负责释放,减少"当前绑定对象不是我以为的那个对象"这种错误。
十二、常见错误和定位方法
| 现象 | 常见原因 | 检查位置 |
|---|---|---|
| 窗口能打开但三角形不见 | 没有 glUseProgram、VAO 未绑定或顶点超出裁剪范围 |
绘制前的 Program、VAO、gl_Position |
| 三角形全黑 | Fragment Shader 没有输出颜色或 Program 链接失败 | GL_LINK_STATUS、fragColor |
| 颜色属性错位 | stride 或 offset 计算错误 | glVertexAttribPointer 参数 |
GL_INVALID_OPERATION |
Core Profile 下没有绑定 VAO,或 Buffer 绑定顺序错误 | glGetError、当前 VAO |
| 深度关系错误 | 没有启用深度测试,或没有清除深度缓冲 | glEnable(GL_DEPTH_TEST)、glClear 掩码 |
| 调整窗口后画面只占一部分 | Viewport 没有使用 framebuffer 像素尺寸 | framebuffer callback |
Shader 编译失败但 glGetError 没报错 |
Shader 日志不是普通 GL 错误 | glGetShaderInfoLog |
只改了 GL_ARRAY_BUFFER 绑定却没有效果 |
顶点属性已经在 glVertexAttribPointer 时捕获了旧绑定 |
重新绑定 VAO、VBO 并重新配置属性 |
其中最值得单独记住的一点是:绘制命令只会使用当前状态,不会自动理解你的意图。OpenGL 不知道"你刚刚想画的是这个 VBO",它只知道当前 VAO、当前 Program、当前纹理和当前 Framebuffer。
十三、基础概念速查表
| 概念 | 一句话理解 | 典型 API |
|---|---|---|
| Context | 当前线程使用的 OpenGL 状态环境 | glfwMakeContextCurrent |
| State | 后续命令读取的配置值 | glEnable、glViewport |
| Object | GPU 端资源和状态的句柄 | glGenBuffers、glGenTextures |
| Binding | 当前 Target 指向哪个 Object | glBindBuffer |
| VBO | 保存顶点或索引数据 | glBufferData |
| VAO | 保存顶点属性的解释方式 | glVertexAttribPointer |
| Shader | 管线某个阶段执行的 GLSL 程序 | glCompileShader |
| Program | 链接后的可执行 Shader 组合 | glLinkProgram、glUseProgram |
| Primitive | 点、线、三角形等图元 | GL_TRIANGLES |
| Fragment | 光栅化后待处理的候选片段 | Fragment Shader 输出 |
| Framebuffer | 颜色、深度、模板输出的集合 | glBindFramebuffer |
| Viewport | NDC 到窗口像素的映射区域 | glViewport |
| Uniform | 对一个 Draw 或多个 Draw 共享的 Shader 输入 | glUniform* |
十四、学习到这里,应该能解释什么?
如果你已经理解本文,可以不用死记代码,直接回答下面这些问题:
- 为什么必须先创建并 current Context,再调用 GLAD?
- 为什么
glBindBuffer后还要调用glVertexAttribPointer? - 为什么 VAO 要在 Core Profile 中显式绑定?
- 为什么 Vertex Shader 必须写
gl_Position? - 为什么顶点 Shader 的颜色可以在三角形内部形成渐变?
- 为什么
glClearColor和glClear是两个不同调用? - 为什么
glDrawArrays的count是顶点数量,而不是字节数? - 为什么 Shader 编译失败要查 Info Log,而不是只查
glGetError? - 为什么窗口大小和 framebuffer 大小不能简单当成一回事?
- 为什么更高版本的 DSA API 不能默认当作 OpenGL 3.3 API 使用?
这些问题的共同答案都是:OpenGL 把数据、状态、管线阶段和输出目标拆开管理,绘制命令只是在某一个时刻读取它们并启动一次管线执行。
十五、基础概念完成后的检查清单
- 能解释 OpenGL、GLFW、GLAD、GPU 驱动分别负责什么;
- 知道 OpenGL Context 是按线程 current 的状态环境;
- 能区分 Object、Target 和 Binding;
- 能说出 VBO 保存数据、VAO 保存顶点属性解释状态;
- 能按顺序完成 Shader Compile 和 Program Link;
- 能解释
gl_Position、NDC、Viewport 和窗口坐标的关系; - 能区分 Fragment 和最终写入 Framebuffer 的 Pixel;
- 知道深度测试、混合、模板测试需要显式配置;
- 知道
glGetError、Shader Info Log、Program Info Log 的适用范围; - 能用
glDrawArrays画出一个 Core Profile 三角形。
到这里,OpenGL 的基础概念就建立起来了。下一篇可以继续讲 OpenGL Shader ,把 Shader 的编译、链接、uniform、顶点属性和 GLSL 类型系统单独拆开,再通过颜色、变换和纹理坐标把这些概念串起来。
十六、官方资料
- OpenGL 3.3 Core Profile Specification
- OpenGL Shading Language 3.30 Specification
- Khronos OpenGL Reference Pages
- glClear Reference Page
- glClearColor Reference Page
- glBufferData Reference Page
- glVertexAttribPointer Reference Page
- glCompileShader Reference Page
- glLinkProgram Reference Page
- glDrawArrays Reference Page
- glViewport Reference Page
- glGetError Reference Page
- GLFW Context guide
- GLFW Window guide