OpenGL [ 基础概念 ]

上一篇我们已经把 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);
  1. GL_ARRAY_BUFFER:就是槽位名称(数组缓冲区槽位)
  2. 执行 bind:把 positionBuffer 这个 ID,写入 Context 内部的 GL_ARRAY_BUFFER 全局槽位。
  3. ✅没有拷贝显存数据,没有移动字节,只是记了个 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 顶点输入,还需要:

  1. 绑定 VAO
  2. glVertexAttribPointer:告诉 GPU,从当前 GL_ARRAY_BUFFER 槽位绑定的 VBO 里面,如何解析出顶点属性(位置、UV)
  3. 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 的顶点处理、光栅化、片段处理和帧缓冲操作,可以先用下面这张图建立全局视角:

每个阶段解决的问题不同:

  1. 顶点数据:应用程序准备位置、颜色、法线、纹理坐标等属性;
  2. Vertex Shader :逐顶点执行,必须把位置写入 gl_Position;
  3. Primitive Assembly :根据 GL_TRIANGLES、GL_LINES 等模式把顶点组装成图元;
  4. Clipping:裁剪视锥体之外的部分;
  5. Rasterization:把点、线或三角形转换成一组待处理的片段;
  6. Fragment Shader:计算每个片段的颜色或其他输出;
  7. Per-Fragment Operations:进行深度、模板、混合等测试;
  8. 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 没有写它,后续结果就是未定义的。

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, ...);

因此排查顺序应该是:

  1. GLFW 是否创建了窗口和 Context;
  2. GLAD 是否成功加载;
  3. Shader 是否编译成功;
  4. Program 是否链接成功;
  5. VAO、VBO 和属性数组是否配置;
  6. 绘制前是否绑定了正确 Program 和 VAO;
  7. 深度、模板、混合或裁剪状态是否意外影响结果。

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*

十四、学习到这里,应该能解释什么?

如果你已经理解本文,可以不用死记代码,直接回答下面这些问题:

  1. 为什么必须先创建并 current Context,再调用 GLAD?
  2. 为什么 glBindBuffer 后还要调用 glVertexAttribPointer?
  3. 为什么 VAO 要在 Core Profile 中显式绑定?
  4. 为什么 Vertex Shader 必须写 gl_Position?
  5. 为什么顶点 Shader 的颜色可以在三角形内部形成渐变?
  6. 为什么 glClearColor 和 glClear 是两个不同调用?
  7. 为什么 glDrawArrays 的 count 是顶点数量,而不是字节数?
  8. 为什么 Shader 编译失败要查 Info Log,而不是只查 glGetError?
  9. 为什么窗口大小和 framebuffer 大小不能简单当成一回事?
  10. 为什么更高版本的 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 类型系统单独拆开,再通过颜色、变换和纹理坐标把这些概念串起来。

十六、官方资料

相关推荐
周杰偷奶茶2 小时前
【Java】运算符指南
java·开发语言
随性而行3602 小时前
企业微信二次开发如何接入大模型工具?API接口实现智能任务调用的技术思路
java·前端·人工智能·python·微信·机器人·企业微信
IMPYLH2 小时前
HTML 的 <td> 元素
前端·html
gnip3 小时前
Flutter GetX 三件套开发规范(Skill)
前端·flutter
温暖小土3 小时前
Spring AI 接入 DeepSeek Chat 模型
数据库·人工智能·spring
张小姐的猫3 小时前
【AI大模型接入SDK】 —— 前端页面 & 项目总结与拓展
前端·数据结构·数据库·c++·人工智能·chatgpt
IvorySQL3 小时前
去 IOE 的最后一公里:IvorySQL 5.4 × RISC-V 实测
数据库·人工智能·ai·postgresql·risc-v
web打印社区3 小时前
远程打印:WebSocket 与 HTTP 轮询怎么选
前端·vue.js·websocket·网络协议·http·electron·pdf
Json____3 小时前
基于 Spring Boot + Vue3 的在线考试管理系统实战
java·spring boot·后端·it学习·wwwoop.com