OpenGL 入门复习笔记
1. CMake 和第三方库
OpenGL 入门通常会用到这些库:
GLFW:创建窗口、创建 OpenGL context、处理键盘鼠标输入。GLAD:加载 OpenGL API 函数指针。stb_image:读取图片文件,用于纹理。glm:处理向量、矩阵、相机和投影变换。spdlog:打印日志,方便排错。
find_package 和 target_link_libraries
一个容易混淆的点:
find_package(glfw3 REQUIRED)
target_link_libraries(BaseProj PRIVATE glfw)
这里的 glfw3 和 glfw 不是写错了,它们表示的层级不同。
find_package(glfw3 REQUIRED)里的glfw3是包名。target_link_libraries(... glfw)里的glfw是 CMake 导入目标名。glfw3.lib是磁盘上的真实库文件名。
这三个名字不一定相同。
判断 target_link_libraries 应该链接什么名字时,可以去安装目录下找 *Targets.cmake,例如:
D:/tools/CodeLib/GLFW/lib/cmake/glfw3/glfw3Targets.cmake
如果里面有:
# Create imported target glfw
add_library(glfw STATIC IMPORTED)
那就说明 CMake 导出的目标名是 glfw,所以应该写:
target_link_libraries(BaseProj PRIVATE glfw)
现代 CMake 里,target_link_libraries 优先链接的是“目标 target”,不是单纯盯着 .lib 文件名。这个 target 内部会记录真实库文件路径、头文件 include 路径以及其他依赖信息。
CMAKE_PREFIX_PATH
find_package 不是下载库,它只是找已经安装好的包。CMAKE_PREFIX_PATH 用来告诉 CMake 去哪些安装前缀目录下找包。
例如 GLFW 安装在:
D:/tools/CodeLib/GLFW
并且里面有:
D:/tools/CodeLib/GLFW/lib/cmake/glfw3/glfw3Config.cmake
那就可以把 D:/tools/CodeLib/GLFW 作为 prefix。
注意:
CMAKE_INSTALL_PREFIX:安装某个库时用,决定库安装到哪里。CMAKE_PREFIX_PATH:使用某个库时用,告诉 CMake 去哪里找它。
GLAD 的引入方式
GLAD 通常没有单独的 .lib 文件,它一般由:
external/glad/include/glad/glad.h
external/glad/src/glad.c
组成。
所以 CMake 里需要做两件事:
- 把
glad.c加入可执行目标,作为一个编译单元。 - 把
external/glad/include加入 include 路径。
GLAD 的 include 顺序一般是:
#include <glad/glad.h>
#include <GLFW/glfw3.h>
GLAD 放在 GLFW 前面,避免系统 OpenGL 头文件先被包含造成冲突。
2. GLFW 窗口和 OpenGL Context
基本流程:
glfwInit
设置 OpenGL 版本
glfwCreateWindow
glfwMakeContextCurrent
gladLoadGLLoader
进入渲染循环
glfwTerminate
glfwMakeContextCurrent
glfwCreateWindow 创建窗口,也创建了这个窗口对应的 OpenGL context。但是 OpenGL API 调用本身通常不带 window 参数,例如:
glClear(...)
glDrawArrays(...)
glCreateShader(...)
OpenGL 怎么知道这些调用作用在哪个窗口上?
答案是:OpenGL 调用作用于当前线程的 current context。
glfwMakeContextCurrent(window);
意思是:
把这个 window 对应的 OpenGL context 设置为当前线程正在使用的 context。
所以 GLAD 必须在它之后初始化:
先有 current context
GLAD 才能加载当前 OpenGL 环境里的函数地址
双缓冲
渲染循环里会调用:
glfwSwapBuffers(window);
这是双缓冲机制:
front buffer:当前显示器正在显示的画面
back buffer:当前帧正在绘制的画面
OpenGL 先把一帧画到 back buffer,glfwSwapBuffers 再把它交换到前面显示。这样可以避免直接在屏幕上边画边显示造成闪烁。
glViewport 和窗口大小
窗口变大不等于 OpenGL 自动更新绘制区域。OpenGL 把 NDC 坐标映射到屏幕像素区域,这个区域由 glViewport 决定。
窗口大小变化时,应通过 framebuffer callback 更新:
窗口 framebuffer 尺寸变化
-> glViewport(0, 0, width, height)
3. Shader 类
OpenGL 需要 shader 才能绘制图形。最基础的是:
- 顶点着色器:处理每个顶点的位置、纹理坐标等。
- 片段着色器:决定每个片段/像素的颜色。
一个 Shader 类通常负责:
读取 shader 文件
编译 vertex shader
编译 fragment shader
链接 shader program
使用 glUseProgram
设置 uniform
析构时删除 program
编译和链接流程
完整流程是:
读取 vertex shader 源码
读取 fragment shader 源码
glCreateShader(GL_VERTEX_SHADER)
glShaderSource
glCompileShader
检查 GL_COMPILE_STATUSglCreateShader(GL_FRAGMENT_SHADER)
glShaderSource
glCompileShader
检查 GL_COMPILE_STATUSglCreateProgram
glAttachShader
glLinkProgram
检查 GL_LINK_STATUSglDeleteShader(vertex)
glDeleteShader(fragment)
链接成功后,单独的 vertex shader 和 fragment shader 对象可以删除,因为 program 已经包含了链接后的结果。
Shader 资源生命周期
OpenGL 的 shader program 是 GPU 资源,不是普通整数。ID 只是资源句柄。
所以 Shader 类要注意:
ID初始化为0。- 析构时
glDeleteProgram(ID)。 - 如果编译或链接失败,要清理已经创建的 shader/program。
- 禁止拷贝,避免两个对象持有同一个 OpenGL program,导致重复删除。
一个实用原则:
谁 glCreate 了资源,失败返回前就要对应 glDelete。
4. 顶点数据、VAO、VBO、EBO
OpenGL 绘制图形时,需要把顶点数据上传到 GPU。
常见对象:
VBO:存储顶点数据。VAO:记录顶点属性布局。EBO:存储索引数据,避免重复顶点。
VBO
VBO 保存原始顶点数据,例如:
position: x y z
color: r g b
texCoord: u v
如果一个顶点有 3 + 3 + 2 = 8 个 float,那么 stride 就是:
8 * sizeof(float)
VAO
VAO 记录“如何解释 VBO 里的数据”。
例如:
location 0 -> position,3 个 float,从 offset 0 开始
location 1 -> color,3 个 float,从 offset 3 * sizeof(float) 开始
location 2 -> texCoord,2 个 float,从 offset 6 * sizeof(float) 开始
对应 shader:
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aColor;
layout(location = 2) in vec2 aTexCoord;
EBO 的易错点
EBO 的绑定状态会被 VAO 记录。
所以如果你这样做:
绑定 VAO
绑定 EBO
设置数据
在 VAO 仍然绑定时解绑 EBO
等于把 VAO 里的 EBO 记录清掉了。使用 glDrawElements 时就会没有有效索引缓冲。
正确记法:
VBO 可以在设置完顶点属性后解绑
EBO 不要在 VAO 绑定期间解绑
5. 纹理
纹理的学习流程:
用 stb_image 读取图片
glGenTextures 创建纹理对象
glBindTexture 绑定纹理
glTexParameteri 设置纹理参数
glTexImage2D 上传图片数据
glGenerateMipmap 生成 mipmap
stbi_image_free 释放 CPU 图片数据
stb_image
stb_image.h 是单头文件库,但实现只能在一个 .cpp 中启用:
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
不能在多个 .cpp 里都定义 STB_IMAGE_IMPLEMENTATION,否则会重复定义。
图片路径
图片和 shader 文件路径是相对于程序运行时 working directory,不是相对于 .cpp 文件。
如果 VS Code/CMake Tools 的工作目录是项目根目录,那么可以写:
assets/shaders/basic.vert
assets/textures/container.jpg
纹理坐标
纹理坐标通常是:
左下角: (0.0, 0.0)
右下角: (1.0, 0.0)
右上角: (1.0, 1.0)
左上角: (0.0, 1.0)
OpenGL 纹理坐标通常把 (0,0) 当左下,而图片文件很多以左上为原点,所以经常需要:
stbi_set_flip_vertically_on_load(true);
纹理单元和 sampler
多纹理时要理解:
sampler uniform 存的是纹理单元编号,不是 texture 对象 ID。
例如:
shader.setInt("texture1", 0);
shader.setInt("texture2", 1);
意思是:
shader 里的 texture1 去 GL_TEXTURE0 采样
shader 里的 texture2 去 GL_TEXTURE1 采样
真正把纹理对象放到纹理单元上,需要:
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture1);glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, texture2);
渲染前明确绑定纹理是好习惯,因为 OpenGL 是状态机,别的代码可能改变当前绑定。
6. Transform 和矩阵顺序
2D 矩形进入 3D 前,先学习 transform:
- 平移
translate - 旋转
rotate - 缩放
scale
GLM/OpenGL 常用列向量约定:
gl_Position = transform * vec4(aPos, 1.0);
如果:
transform = T * R * S
实际作用到顶点的顺序是从右往左:
先 S 缩放
再 R 旋转
最后 T 平移
GLM 写法通常是:
先 translate
再 rotate
再 scale
最终得到:
T * R * S
实际效果就是:
先缩放,再旋转,最后移动
一句话:
代码调用顺序决定矩阵从左到右生成;
顶点实际变换顺序看矩阵从右到左作用。
每帧从单位矩阵开始
动画里通常每一帧都从单位矩阵重新计算:
当前姿态 = 时间函数
如果把 transform 定义在循环外,每帧都在旧矩阵上继续 rotate,就会变成累计变换。再加上直接使用 glfwGetTime(),旋转会越来越夸张。
区分:
glfwGetTime():绝对时间
deltaTime:两帧之间经过的时间
7. 3D 坐标系统
3D 绘制的核心是 MVP:
gl_Position = projection * view * model * vec4(aPos, 1.0);
实际作用顺序:
先 model
再 view
最后 projection
完整坐标流程:
局部坐标-> model
世界坐标-> view
观察坐标/相机坐标-> projection
裁剪坐标-> 透视除法
NDC-> viewport
屏幕坐标
Model 矩阵
model 作用于每个模型,把模型从局部坐标变换到世界坐标。
同一份立方体顶点数据可以通过不同的 model 矩阵摆到不同位置:
第一个 cube 在原点
第二个 cube 在远处
第三个 cube 旋转 40 度
所以绘制多个物体时,通常复用同一个 VAO/VBO,但每次 draw 前换一个 model 矩阵。
View 矩阵
view 表示相机观察变换,它把世界坐标变换到相机坐标。
OpenGL 里通常不是真的移动相机,而是反过来移动整个世界。
例如相机在:
(0, 0, 3)
看向原点,可以理解成:
把整个世界往 z 负方向移动 3 个单位
自由相机里常用:
glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp)
其中:
cameraPos:相机在哪里
cameraFront:相机朝哪个方向看
cameraPos + cameraFront:构造一个临时目标点
cameraUp:哪边是上
front 不是目标点,而是方向向量。
Projection 矩阵
projection 定义相机能看到的空间范围,并把 3D 观察空间投影到裁剪空间。
透视投影常用:
glm::perspective(fov, aspect, near, far)
含义:
fov:视野角
aspect:宽高比
near:近裁剪面
far:远裁剪面
如果加了 view 把物体放到 z = -3 附近,但 projection 仍然是单位矩阵,物体很可能会被裁掉。进入 3D 后,要同时使用 view 和真正的 perspective projection。
深度测试
3D 物体需要深度测试,否则前后关系会混乱。
开启:
glEnable(GL_DEPTH_TEST);
每帧清屏时要同时清颜色和深度:
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
8. Camera
Camera 的核心状态:
position:相机位置
front:相机朝向
up:相机上方向
yaw:左右转头
pitch:抬头低头
fov:视野角
WASD 移动
前后移动:
W:position += front * speed * deltaTime
S:position -= front * speed * deltaTime
左右移动需要右方向:
right = normalize(cross(front, up))
A:position -= right * speed * deltaTime
D:position += right * speed * deltaTime
这里必须使用 deltaTime,否则不同帧率下移动速度不同。
Yaw 和 Pitch
yaw 控制左右转头,pitch 控制抬头低头。
用 yaw/pitch 计算 front:
front.x = cos(yaw) * cos(pitch)
front.y = sin(pitch)
front.z = sin(yaw) * cos(pitch)
GLM 的 sin/cos 使用弧度,所以要用 glm::radians。
初始 yaw = -90 是为了让默认 front 指向负 Z:
yaw = -90 -> front 大约是 (0, 0, -1)
pitch 通常限制在:
-89 到 89
避免看向正上/正下时方向计算不稳定。
鼠标回调和 user pointer
GLFW 的回调函数签名是固定的,例如鼠标移动:
void callback(GLFWwindow* window, double xpos, double ypos)
不能额外加一个 Camera& 参数,因为 GLFW 只知道窗口和鼠标坐标,不知道你的 C++ 对象。
解决方式是:
glfwSetWindowUserPointer(window, &camera);
这相当于给 GLFWwindow 挂一个自定义 void* 指针。
回调里再取出来:
auto* camera = static_cast<Camera*>(glfwGetWindowUserPointer(window));
完整理解:
main 创建 camera
把 &camera 存进 window user pointer
鼠标移动时 GLFW 调用 callback(window, xpos, ypos)
callback 通过 window 找回 camera
调用 camera->processMouseMovement(...)
camera 更新 front
下一帧 getViewMatrix 使用新 front
画面视角改变
这是一种 C API 和 C++ 对象配合的常见方式。
滚轮 FOV
滚轮缩放通常不是移动相机,而是改变 fov:
fov 变小 -> 视野变窄 -> 看起来放大
fov 变大 -> 视野变宽 -> 看起来缩小
处理滚轮时:
fov -= yoffset
fov clamp 到 1 到 45
注意 glm::clamp 返回结果,不会原地修改变量,所以要赋回去:
fov = glm::clamp(fov, 1.0f, 45.0f);
9. 常见错误总结
路径错误
shader 和 texture 文件路径是相对于运行时 working directory。
如果读取失败,不要只看源码目录,要先确认程序从哪里运行。
OpenGL context 顺序错误
GLAD 必须在:
glfwMakeContextCurrent
之后初始化。否则 OpenGL 函数地址没有有效 context 可查。
include 顺序错误
一般写:
#include <glad/glad.h>
#include <GLFW/glfw3.h>
EBO 被解绑
EBO 绑定被 VAO 记录。在 VAO 绑定期间不要解绑 EBO。
sampler 误解
sampler2D 的 uniform 值是纹理单元编号,不是 texture 对象 ID。
string_view::data
std::string_view::data() 不保证以 \0 结尾。传给需要 C 字符串的 API 时要小心。
如果传入的是字符串字面量,通常没问题;如果是任意 string_view,最好转成 std::string。
矩阵顺序
projection * view * model * vec4(aPos, 1.0) 实际先作用 model,再 view,最后 projection。
不要只看代码从左往右,要记住矩阵作用在列向量上时是从右往左。
忘记清深度缓冲
开启深度测试后,每帧要清:
GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT
deltaTime 放错位置
lastFrame 要放在循环外。如果放在循环内,每帧都会重置,deltaTime 就不是两帧间隔。
10. 当前阶段总结
到这里,基础部分已经完成:
CMake 引入 GLFW/GLAD/stb/glm/spdlog
创建窗口和 OpenGL context
加载 OpenGL API
编译和使用 shader
绘制三角形、矩形、立方体
使用 VAO/VBO/EBO
加载纹理和多纹理混合
理解 model/view/projection
开启深度测试
绘制多个 3D 物体
实现 WASD 相机移动
实现鼠标视角
实现滚轮 FOV
基础阶段最重要的收获不是记住某段代码,而是理解 OpenGL 是状态机:当前绑定的 VAO、VBO、EBO、texture、shader program、viewport、depth test 等状态共同决定下一次 draw call 的结果。
