MikeShah-OpenGL-笔记-全-
MikeShah OpenGL 笔记(全)
001:课程介绍与目标 🎬

在本节课中,我们将要学习OpenGL系列课程的整体介绍,了解课程的目标、使用的技术栈以及适合的学习人群。
大家好,我是Mike。欢迎来到激动人心的OpenGL编程系列课程。
在本系列课程中,我将讨论OpenGL编程API。OpenGL是最流行的图形编程API之一,与Direct3D、Metal和Vulkan并列。它允许你创建各种游戏应用、虚拟现实、CAD软件、3D建模以及众多其他2D软件。
在本系列中,我将从零开始讲解OpenGL编程。这意味着如果你是初学者,这个系列非常适合你。
我们将使用C++编程语言进行本系列课程的教学。如果你想提升相关技能,也欢迎查看我的其他系列课程。
我们将要学习的OpenGL是现代OpenGL,即版本3.3及更高版本。这样你就能学到最新、最前沿的技术,这些知识在你实际工作中或进一步学习时很可能会用到。
本课程展示的许多材料都适用于C++和OpenGL。但如果你使用其他语言,例如Python,甚至是WebGL,你仍将学习到该API工作原理的基础知识。
我喜欢从基础开始讲解,这样你才能真正理解事物是如何运作的,以及在学习OpenGL的过程中我使用了哪些文档。
我很高兴能与你们一同踏上这段学习旅程。欢迎来到本系列课程,话不多说,让我们进入下一个视频,开始学习吧。我们很快再见。
002:什么是OpenGL(规范与历史)

在本节课中,我们将要学习OpenGL究竟是什么。我们将了解OpenGL的核心定义——它是一份规范,并简要回顾其发展历史。理解这些背景知识,将为我们后续的实际编程学习打下坚实的基础。
什么是OpenGL规范?
上一节我们介绍了课程概述,本节中我们来看看OpenGL的本质。OpenGL并非一个简单的库,其核心是一份详细的规范。
如果你搜索“OpenGL specification”,会找到一份长达数百页的官方文档(例如OpenGL 4.6规范约有850页)。这份文档定义了OpenGL API应该如何工作,包括所有可用的函数、参数和行为。
以下是关于这份规范你需要了解的几个要点:
- 程序员视角:对于我们程序员而言,OpenGL是一系列图形命令(即函数)的集合。它是一个基于C语言的API,允许我们与GPU交互,将数据发送到GPU并命令其进行高速渲染。其核心优势在于利用GPU进行并行计算,速度远超CPU。公式可以简单理解为:渲染速度 (GPU) >> 渲染速度 (CPU)。
- 实现者视角:对于硬件厂商或驱动开发者来说,这份规范是他们必须遵循的蓝图。来自不同公司和大学的专家共同制定了这套标准,然后各家厂商根据此标准,确保自己的硬件和驱动程序能够支持规范中定义的所有功能。
简而言之,OpenGL规范是一个标准接口定义,而各家厂商(如NVIDIA、AMD)提供的驱动和库,才是这个规范的具体实现。
OpenGL的历史与发展
了解了OpenGL是一份规范之后,我们来看看它的历史脉络,这有助于理解其在图形学中的地位。
OpenGL最初发布于1992年6月30日,是一个非常成熟且历史悠久的API。尽管近年来更新速度放缓(例如4.6版本于2017年发布),但它依然被广泛用于商业应用和教学中,并将在未来一段时间内继续得到支持。
学习OpenGL至今仍有重要价值,原因如下:
- 优秀的学习起点:OpenGL抽象层次适中,能够帮助我们理解现代图形编程的核心概念(如着色器、管线),而无需过早陷入底层细节。
- 承上启下:理解OpenGL后,更容易过渡到Vulkan等更现代、更底层的API。
- 生态稳定:它拥有丰富的学习资源、稳定的驱动支持和跨平台特性。
如果你想深入了解其完整历史,包括与其他API的竞争(如DirectX),维基百科的OpenGL页面提供了非常详细的资料。
总结

本节课中我们一起学习了OpenGL的基础定义与发展历程。我们明确了OpenGL的核心是一份由行业共同维护的规范,而非某个特定的库。从程序员角度看,它提供了一套用于与GPU通信、实现高效图形渲染的函数接口。尽管历史悠久,OpenGL因其教育意义和稳定性,仍然是进入图形编程世界的绝佳起点。
在接下来的课程中,我们将开始接触代码,逐步探索如何使用这套强大的工具。
003:OpenGL简史与现代OpenGL 🚀

在本节课中,我们将继续学习OpenGL的历史,以帮助你理解我们是如何发展到现代OpenGL的。了解这段历史对于成为一名图形程序员至关重要。
起源与多平台特性

上一节我们介绍了OpenGL的诞生背景,本节中我们来看看它的早期发展。OpenGL拥有非常丰富的历史,因为它自20世纪90年代就已存在。大约在1991年,一家名为SGI的公司开始开发OpenGL。


OpenGL最初真正的关键或杀手级特性是它可以在多个平台上运行。这意味着Windows、Linux、Mac机器、游戏机等,OpenGL都能在其上运行。时至今日,这仍然是学习OpenGL和本系列课程的关键原因之一。
功能演进与扩展机制
随着OpenGL的发展,其功能不断增强。在1.1和1.2版本期间,开始添加诸如纹理等新功能,这使得我们的图形应用程序变得更加有趣。如果你长期关注电子游戏,你可能会看到这种演变:随着图形API的改进,游戏及其图形效果也随着硬件等的进步而变得更好。
但其中一个重大的飞跃是在大约1.5版本,OpenGL开始支持扩展。这是我们开始接触着色器等概念的起点。基本上,一个名为架构审查委员会的机构允许公司根据其硬件提交扩展,从而能够用OpenGL实现更酷的效果。
可编程管线的革命
现在,OpenGL开始变得真正有趣并进入现代阶段是在大约2.1版本,我们获得了一种称为可编程管线的东西。这意味着我们作为程序员,实际上可以编写在显卡上编译和执行的程序。
以下是其核心变化:
- CPU:你习惯在其上编译和运行程序。
- GPU:现在也可以编译和运行程序。
这赋予了我们程序员创造出色图形效果的能力,并将大量工作从CPU卸载到GPU上,让GPU处理它更擅长的事情。这就是你在2.1版本左右看到的图形技术飞跃。
现代OpenGL:3.3版本及更高
正如我们将在本系列中做的,我们将使用3.3及更高版本。这确实是OpenGL的现代版本,因为我们开始移除一些自90年代以来就存在的旧功能,这些功能涉及我们无法更改或无法真正编程的固定功能管线。这就是我们在本系列课程中将要学习的内容,一直到4.6版本。
现代OpenGL引入了许多强大功能,以下是部分关键特性:
- 计算着色器:用于通用目的计算。
- 几何着色器:用于生成新的几何图形。
- 曲面细分着色器:用于添加更多细节。
这些都是过去几年添加到OpenGL中的新功能。因此,OpenGL确实拥有强大的能力,并且作为一个API,它正在为我们程序员持续演进,变得越来越好。

总结

本节课中我们一起学习了OpenGL从起源到现代的发展简史。我们了解到OpenGL拥有丰富的历史,并且作为一个持续演进的API,其核心优势在于跨平台能力和强大的可编程性。如果你今天学习OpenGL,应该将重点放在现代版本上。
希望你喜欢这节课,并对OpenGL的文化和历史有了一点了解。我们下节课再见。


004:可编程图形管线(面试题)🎮
在本节课中,我们将学习计算机图形学中一个极其重要的概念——图形渲染管线。我们将以OpenGL为例进行讲解,但其中原理同样适用于其他图形API。理解图形管线至关重要,它不仅为我们后续学习OpenGL绘制模型奠定基础,也是面试中经常被问及的问题。掌握了它,你就能清晰地解释图形是如何一步步被绘制到屏幕上的。
从三角形到复杂模型 🎨
在计算机图形学中,我们通常通过渲染三角形来表示几何形状。当然,我们也可以绘制点和线,但三角形是最常用且最有趣的图元。

例如,使用足够多的三角形,我们可以近似表示任何形状,比如这只兔子。

使用更多的三角形,我们可以获得更精确的模型细节。例如,你可能会认出这是《指环王》中的角色咕噜。你可以看到构成这个角色模型的众多三角形,以及覆盖其上的纹理(我们后续会讨论纹理)。


左侧模型使用了更多几何体(三角形),因此边缘更平滑,细节更丰富。那么,我们如何将这类模型渲染到屏幕上呢?这个过程,即渲染每一个三角形或其他图元,正是通过图形渲染管线来完成的。

图形渲染管线概览 🔄
让我们来看一下图形管线。下图展示了来自Khronos OpenGL Wiki的渲染管线流程。

本节课的重点就是讲解这个流程图,以便我们理解图形绘制的每一步发生了什么。首先,让我简要总结一下什么是图形渲染管线。
图形渲染管线描述了图元(例如一个顶点、一条线或一个三角形,还有其他图元)从3D数据到最终显示在2D屏幕上的完整旅程。请记住,我们正在观看的屏幕是一个2D平面。
这个过程的起点是我们在程序中创建一些顶点数据。例如,我们可能有一个表示点的结构体:
struct Point3D {
float x, y, z;
};
我们可以创建一个点:Point3D p = {1.0f, 0.0f, -5.0f};。这将在我们的场景中定义一个三维空间点。
但当我们有一个包含X、Y、Z轴的3D坐标系时,这个点如何被投影到我们的2D屏幕上呢?如果我们连接多个点形成一个立方体,又该如何确保这个3D形状能正确投影到2D显示器上呢?
接下来,我们将沿着管线流程,一步步解答这些问题。
管线步骤详解 🛠️
1. 顶点指定 (Vertex Specification)
正如我们刚才所做的,顶点指定就是我们在CPU上设置几何数据。可以想象为:3D艺术家在Blender等软件中创建模型,然后你的CPU程序加载、解析这些数据,最终得到所有顶点信息以及它们的连接方式。
顶点指定即:在CPU上设置几何体数据。
2. 顶点着色器 (Vertex Shader) ⚙️
现在进入管线的下一部分:顶点着色器。“着色器”是现代OpenGL管线中的新术语,你会看到它出现多次。有趣的是,图中有些框是绿色的,有些是蓝色的,这暗示着管线中不同部分的行为可能不同。
让我们来定义一下着色器:
- 着色器是管线中可编程的部分。
- 这是现代OpenGL的一个特性,即我们可以在GPU上编写程序来控制图形管线的行为。
在过去,我们只能向GPU发送数据并切换一些固定功能。而现在,作为程序员,我们有责任编写代码来控制从第一步输入的几何体的行为。
顶点着色器的任务相对简单,但它是必需的:
- 顶点着色器在每个顶点上执行一次。
- 它的工作是定位该顶点。
例如,如果我们有一个点,这个特殊的GPU程序就会在那个点上运行一次。如果我们有一千个点,它就会运行一千次,分别定位每个顶点。
3. 曲面细分着色器 (Tessellation Shader) 📐
在顶点着色器定位空间中的点之后,我们可以通过曲面细分着色器做更多有趣的事情,比如增加几何细节。

例如,如果我有一个由两个三角形组成的四边形,我可以使用曲面细分着色器进一步细分它,从而在场景中获得更多细节。回顾之前的咕噜模型,左侧细节更丰富的模型就是通过细分或曲面细分实现的。
注意:曲面细分着色器是管线中的可选部分。
4. 几何着色器 (Geometry Shader) 🔺
接下来是几何着色器,它也是管线的另一个可选部分。
几何着色器的目标是从现有几何体生成更多几何体。例如,假设我在程序中只指定了一个点。使用几何着色器,我可以在GPU上从这个点生成更多几何体,比如生成一系列点构成一个四边形。这对于粒子系统等效果非常有用,你只需要一个点,然后动态生成其他数据。另一个例子是创建爆炸效果,你可能想动态生成一些几何体,在场景中添加更多三角形。
5. 图元装配 (Primitive Assembly) 🧩
在几何着色器(可选)之后,我们可能希望对生成的数据进行一些额外的后处理。不过,我们更关注下一步:图元装配。
这一步是:组装最终的几何体。它需要确定我们拥有的所有顶点如何组装在一起——是组装成线、三角形、点,还是其他如三角形扇等图元?尤其在我们生成了新几何体之后,这一步尤为重要。
此外,这个阶段还包括其他操作,例如:
- 裁剪:如果三角形在屏幕外,它会被裁剪掉。
- 剔除:如果我们不想绘制立方体中不可见的面,它们会被剔除。
本质上,图元装配是说:“嘿,这里是所有在视野内的东西。让我们把这些三角形、线或点组装起来并显示出来。”
6. 光栅化 (Rasterization) 🖼️
接下来进入光栅化阶段。假设这是我的屏幕,我把它画成一个网格,每个小格子代表一个像素。

光栅化的过程就是根据几何形状,确定并填充哪些像素。如果你仔细观察或眯起眼睛看,你会发现这些被填充的像素在近似地表示一个三角形。
这就是光栅化的思想:确定哪些像素应该被填充。
光栅化还涉及其他步骤,例如:
- 深度测试:如果两个形状前后重叠,哪个应该被绘制在前面?这涉及到深度缓冲区(或称Z缓冲区),我们后续会讨论。
这个阶段的最终结果是我们得到了一些被填充的像素,它们可能具有不同的颜色。
7. 片元着色器 (Fragment Shader) 🎨
光栅化之后,在现代OpenGL管线中,我们迎来了最后一个着色器步骤:片元着色器。
片元着色器的任务与顶点着色器类似:
- 它在每个片元上执行一次。
- 你可以将“片元”近似理解为像素(严格来说,片元是最终可能写入像素的候选数据,但我们可以先这样理解)。
- 它的工作是确定在光栅化过程中每个将被填充的“像素”的最终颜色。
8. 逐样本操作 (Per-Sample Operations) ✅
最后,可能还有一些额外的逐样本操作,例如:
- 深度测试(再次提及)。
- 剪裁测试:如果你不需要屏幕的某一部分,可以将其剪裁掉。
- 这些操作在实现反射、阴影等效果时会发挥作用。
总结 📝
本节课我们一起学习了图形渲染管线。让我们回顾一下这个“顶点旅程”:
- 顶点指定:在CPU上设置3D几何数据。
- 顶点着色器(必需):在每个顶点上执行,负责定位顶点。
- 曲面细分着色器(可选):细分几何体,增加模型细节。
- 几何着色器(可选):从现有几何体生成新的几何体。
- 图元装配:组装最终图元,并进行裁剪、剔除等操作。
- 光栅化:将几何图元转换为要填充的像素(片元)。
- 片元着色器(必需):在每个片元上执行,决定其最终颜色。
- 逐样本操作:进行深度测试、混合等最终处理。
理解这个管线流程非常有益。在OpenGL中,每当我们进行绘制调用(例如 glDrawArrays、glDrawElements),数据都必须以有序的方式通过这个管线。
这是一个已知的、可理解的过程。随着我们在本系列中深入学习现代OpenGL计算机图形学,我们将能够逐步探索管线的每个阶段。继续学习图形学,你还可以在每个阶段挖掘更多有趣的细节和操作。
请确保不要错过后续课程,并订阅本系列,以便在我们开始实际OpenGL编程时能够跟上进度。我们很快将进入OpenGL实战,但在此之前,我们需要先建立正确的思维框架,真正理解为了将信息从CPU传递到显卡,需要经历一系列怎样的管线步骤。
希望你对本系列即将学习到的图形学知识感到兴奋!如果本节课帮助你揭开了图形管线的某些细节,请为视频点赞。我们下节课再见!
005:设置SDL2与OpenGL及首个OpenGL函数


在本节课中,我们将学习如何设置一个基础的OpenGL应用程序。我们将使用SDL2框架来创建窗口并初始化OpenGL上下文,最后调用我们的第一个OpenGL函数来验证环境是否配置成功。
概述与准备工作
上一节我们介绍了OpenGL的基本概念。本节中,我们来看看如何搭建一个实际可运行的OpenGL开发环境。


首先,我们需要一个能够创建窗口和处理系统事件的框架。这里我们选择SDL2,因为它跨平台且被许多专业项目使用。SDL2将负责创建窗口、处理输入输出,并为OpenGL渲染提供上下文。
以下是开始前需要完成的步骤:
- 安装SDL2开发库。
- 准备一个C++项目并配置好编译环境。

初始化程序结构
一个典型的图形应用程序遵循几个基本阶段。我们将代码结构分为初始化、主循环和清理三个部分。
以下是我们的程序框架:
#include <SDL2/SDL.h>
#include <iostream>
// 函数声明
void InitializeProgram();
void MainLoop();
void Cleanup();

int main() {
InitializeProgram();
MainLoop();
Cleanup();
return 0;
}
// 函数定义(暂为空)
void InitializeProgram() {}
void MainLoop() {}
void Cleanup() {}
初始化SDL与创建窗口

初始化阶段的首要任务是启动SDL并创建一个用于OpenGL的窗口。我们还需要定义一些全局变量来管理窗口和OpenGL上下文。


以下是初始化SDL和创建窗口的关键步骤:
- 使用
SDL_Init(SDL_INIT_VIDEO)初始化SDL视频子系统。 - 设置OpenGL属性,如版本号和核心模式。
- 使用
SDL_CreateWindow创建带有SDL_WINDOW_OPENGL标志的窗口。 - 使用
SDL_GL_CreateContext为窗口创建OpenGL上下文。

相关代码片段如下:
// 全局变量
int gScreenWidth = 640;
int gScreenHeight = 480;
SDL_Window* gGraphicsApplicationWindow = nullptr;
SDL_GLContext gOpenGLContext = nullptr;

void InitializeProgram() {
// 初始化SDL
if (SDL_Init(SDL_INIT_VIDEO) < 0) {
std::cout << "SDL2无法初始化视频子系统。" << std::endl;
exit(1);
}
// 设置OpenGL属性
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 4);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 1);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
// 创建窗口
gGraphicsApplicationWindow = SDL_CreateWindow("OpenGL窗口",
0, 0,
gScreenWidth, gScreenHeight,
SDL_WINDOW_OPENGL);
if (gGraphicsApplicationWindow == nullptr) {
std::cout << "SDL窗口无法创建。" << std::endl;
exit(1);
}
// 创建OpenGL上下文
gOpenGLContext = SDL_GL_CreateContext(gGraphicsApplicationWindow);
if (gOpenGLContext == nullptr) {
std::cout << "OpenGL上下文不可用。" << std::endl;
exit(1);
}
}
配置OpenGL函数加载器(Glad)


OpenGL的函数指针需要在运行时从显卡驱动中获取。我们将使用Glad库来简化这个过程。
以下是配置Glad的步骤:
- 访问 Glad在线生成器,选择语言为C/C++,指定OpenGL版本(例如4.1 Core Profile),然后生成并下载文件。
- 将生成的
glad.c和glad/glad.h等文件放入项目目录。 - 在代码中包含
glad.h头文件。 - 在初始化SDL和OpenGL上下文之后,调用
gladLoadGLLoader来加载所有OpenGL函数。
相关代码和编译命令如下:
// 在InitializeProgram函数中,创建上下文之后
#include <glad/glad.h>
...
if (!gladLoadGLLoader((GLADloadproc)SDL_GL_GetProcAddress)) {
std::cout << "Glad未能初始化。" << std::endl;
exit(1);
}
编译时需要链接Glad源文件并指定头文件路径:
g++ main.cpp glad.c -I./include -lSDL2 -ldl
实现应用程序主循环
主循环是程序的核心,它持续运行以处理用户输入、更新状态和渲染画面。我们采用一个由布尔变量控制的 while 循环。

以下是主循环的基本结构:
bool gQuit = false; // 全局控制变量

void MainLoop() {
while (!gQuit) {
Input(); // 处理输入
PreDraw(); // 渲染前准备(暂空)
Draw(); // 执行渲染(暂空)
// 交换前后缓冲区,更新屏幕显示
SDL_GL_SwapWindow(gGraphicsApplicationWindow);
}
}

void Input() {
SDL_Event e;
// 持续轮询事件
while (SDL_PollEvent(&e) != 0) {
if (e.type == SDL_QUIT) {
gQuit = true; // 用户点击关闭窗口时退出循环
}
}
}
void PreDraw() {}
void Draw() {}
调用首个OpenGL函数


为了验证OpenGL已正确设置,我们可以在初始化后调用 glGetString 函数来查询显卡和驱动信息。



以下是查询并打印OpenGL信息的函数:
void GetOpenGLVersionInfo() {
std::cout << "OpenGL厂商: " << glGetString(GL_VENDOR) << std::endl;
std::cout << "OpenGL渲染器: " << glGetString(GL_RENDERER) << std::endl;
std::cout << "OpenGL版本: " << glGetString(GL_VERSION) << std::endl;
std::cout << "着色语言版本: " << glGetString(GL_SHADING_LANGUAGE_VERSION) << std::endl;
}
在 InitializeProgram 函数末尾调用此函数,如果能在控制台看到显卡信息输出,则证明OpenGL环境配置成功。
程序清理
当主循环退出后,我们需要释放所有分配的资源,包括销毁OpenGL上下文、SDL窗口并退出SDL。


以下是清理函数的实现:
void Cleanup() {
// 销毁OpenGL上下文
SDL_GL_DeleteContext(gOpenGLContext);
// 销毁SDL窗口
SDL_DestroyWindow(gGraphicsApplicationWindow);
// 退出SDL所有子系统
SDL_Quit();
}



总结
本节课中我们一起学习了搭建现代OpenGL开发环境的核心流程。
我们首先使用SDL2创建了一个与OpenGL兼容的应用程序窗口。接着,通过设置属性指定使用OpenGL 4.1核心模式。然后,我们利用Glad库加载了OpenGL的函数指针,这是调用任何OpenGL API的前提。之后,我们构建了包含事件处理的主循环框架。最后,通过成功调用 glGetString() 函数并获取到显卡信息,验证了整个开发环境已正确配置。


现在,你已经拥有了一个可以运行OpenGL代码的基础程序框架,为后续学习图形渲染打下了坚实的基础。
006:三角形、顶点数组对象(VAO)与顶点缓冲对象(VBO) 🎯

在本节课中,我们将学习OpenGL中用于渲染几何图形的核心概念:三角形,以及两个关键的OpenGL对象——顶点数组对象(VAO)和顶点缓冲对象(VBO)。理解这些是绘制任何3D图形的基础。
为什么使用三角形?🔺
上一节我们介绍了OpenGL的基本设置,本节中我们来看看构成3D模型的基本单元——三角形。
三角形在3D图形中扮演着核心角色。一个重要的特性是它的刚性:一个完美的平面三角形,当你尝试弯曲或扭转它的一个角时,其余部分会随之移动,但它本身始终保持平面状态,不会产生曲线或扭曲。
这个特性使得图形硬件在将三角形转换为屏幕上的像素(即光栅化)时,计算变得高效且可预测。在交互式计算机图形学(如游戏)中,我们主要使用三角形来构建模型。虽然电影制作(如皮克斯或梦工厂)可能使用其他渲染技术,但本系列课程将专注于交互式程序和三角形。
三角形的构成 📐
为了更好地理解后续内容,我们先明确三角形的基本术语。
一个三角形由以下部分构成:
- 顶点:三角形的三个角点。每个顶点在3D空间中都有一个位置(X, Y, Z坐标)。
- 边:连接两个顶点的线段。
- 面:由三条边围成的区域。一个三角形有一个面(通常指正面),背面是另一个面。
在图形学中,我们通常将数据存储在顶点上,例如位置、颜色、纹理坐标等。
OpenGL 对象:VAO 与 VBO 🧱
现在我们已经了解了三角形,接下来探讨OpenGL中用于管理和渲染三角形数据的两个核心对象。
OpenGL是一个基于C语言的API,它通过一系列函数来操作各种“对象”。你可以将这些对象理解为封装了特定数据和状态的结构。我们将要使用的两个关键对象是:
- 顶点缓冲对象:用于存储实际的顶点数据(如位置、颜色)。
- 顶点数组对象:用于描述如何访问和解释VBO中的数据。
顶点缓冲对象 (VBO) 💾
VBO用于在显卡的内存中存储原始的顶点数据。你可以把它想象成一个数组或数据块。
例如,一个包含三个顶点的三角形,其位置数据在VBO中可能这样排列:
[x1, y1, z1, x2, y2, z2, x3, y3, z3]
在代码中,我们通常会用到以下几个关键函数来操作VBO:
glGenBuffers(): 生成一个或多个缓冲对象,并获取其标识符(ID)。glBindBuffer(): 绑定一个缓冲对象到当前上下文,表示后续操作将针对此缓冲。glBufferData(): 将我们的顶点数据复制到当前绑定的缓冲中。
顶点数组对象 (VAO) 🗺️
VAO的名称可能有些令人困惑。你可以将它理解为VBO的配置说明或访问指南。它记录了:
- 顶点数据存储在哪个(些)VBO中。
- 如何解析VBO中的数据(例如,数据的格式、步长、起始偏移量)。
一个VAO可以包含多个属性。最常见的属性是顶点位置(Attribute 0)。但我们可以定义更多属性。
例如,除了位置,我们可能还想为每个顶点指定颜色。那么数据在VBO中可能这样排列:
[x1, y1, z1, r1, g1, b1, x2, y2, z2, r2, g2, b2, ...]
对应的VAO就需要配置两个属性:
- 属性 0:指向位置数据(每3个浮点数为一组)。
- 属性 1:指向颜色数据(每3个浮点数为一组,紧跟在位置数据之后)。
在渲染时,我们通过绑定不同的VAO,来告诉OpenGL管线当前应该使用哪套数据解析规则。
操作VAO的常用函数与VBO类似:
glGenVertexArrays(): 生成一个顶点数组对象。glBindVertexArray(): 绑定一个VAO,后续的顶点属性设置将与此VAO关联。
核心关系图示与总结 📊
让我们通过一个图示来总结VAO和VBO的关系:
顶点数组对象 (VAO 1)
├── 属性 0 配置:指向 VBO1 中的位置数据
│ └── 数据格式:3个浮点数 (X, Y, Z)
│ └── 从 VBO1 的字节偏移 0 开始
│
└── (可以添加更多属性,如颜色、法线等)
顶点缓冲对象 (VBO 1)
└── 原始数据:[x1, y1, z1, x2, y2, z2, x3, y3, z3]
另一个包含颜色属性的例子:
顶点数组对象 (VAO 2)
├── 属性 0 配置:指向 VBO2 中的位置数据
│ └── 数据格式:3个浮点数
│ └── 步长:6 * sizeof(float) (跳到下一个顶点的位置)
│ └── 偏移:0
│
└── 属性 1 配置:指向 VBO2 中的颜色数据
└── 数据格式:3个浮点数 (R, G, B)
└── 步长:6 * sizeof(float) (跳到下一个顶点的颜色)
└── 偏移:3 * sizeof(float) (跳过前3个位置浮点数)
顶点缓冲对象 (VBO 2)
└── 交错数据:[x1, y1, z1, r1, g1, b1, x2, y2, z2, r2, g2, b2, ...]
本节课中我们一起学习了:
- 三角形是交互式3D图形的基础图元,因其刚性平面特性而便于光栅化。
- OpenGL通过对象来管理渲染状态和数据,尽管它是C语言API。
- 顶点缓冲对象负责在GPU上存储原始的顶点数据。
- 顶点数组对象负责描述如何组织和访问VBO中的数据,它定义了数据的布局(属性)。

这些概念是OpenGL渲染管线的基石。好消息是,核心思想并不比这更复杂,关键在于理解它们是如何协同工作的。在接下来的课程中,我们将通过实际编码来应用VAO和VBO的概念,并反复巩固这些重要思想。
007:着色器在渲染管线中的作用


在本节课中,我们将学习现代OpenGL渲染管线中,在指定顶点数据之后发生的关键步骤。我们将重点探讨顶点着色器和片段着色器的作用、工作原理以及它们如何协同工作来渲染图形。
上一节我们介绍了顶点数组对象和顶点缓冲对象,它们负责在CPU端组织和存储顶点数据。本节中,我们来看看这些数据如何通过GPU上的渲染管线进行处理,特别是通过我们编写的着色器程序。
渲染管线概览
首先,我们需要回顾一下GPU上的图形渲染管线。下图展示了这一流程:

请注意,这个管线运行在GPU上。顶点指定阶段对应我们上节课讨论的VAO和VBO。在这个阶段,我们在CPU端(例如C++文件中)定义顶点数据,然后通过glBufferData等命令将其发送到GPU的顶点缓冲对象中,并使用顶点数组对象设置属性布局。
然而,仅有顶点数据还无法形成图形。我们需要管线的其余部分来完成渲染。其中,顶点着色器和片段着色器是两个我们必须拥有的核心环节。管线中的其他阶段,如曲面细分、几何着色器或计算着色器,则是可选的。
顶点着色器详解
顶点着色器是管线中处理每个顶点的程序。它的主要任务是对顶点位置进行变换。
以下是一个简单的顶点着色器示例代码:
#version 410 core
layout (location = 0) in vec4 position;
void main()
{
gl_Position = position;
}
这段代码看起来可能有些奇怪,因为它被嵌入在C++字符串中,但让我们逐步分析。
#version 410 core声明了使用的OpenGL着色语言版本。layout (location = 0) in vec4 position;定义了一个输入变量。它从顶点缓冲对象中接收位置数据(一个包含x, y, z, w坐标的四维向量)。location = 0指定了该属性在VAO中对应的索引。void main()是着色器的入口函数,类似于C/C++程序。gl_Position = position;是核心操作。gl_Position是一个内置的输出变量,用于设置该顶点在裁剪空间中的最终位置。这里我们直接将输入的位置赋值给它,未做任何变换。
顶点着色器对每个顶点执行一次。例如,对于一个三角形,它会对三个顶点各运行一次(顺序不确定)。我们可以在顶点着色器中实现强大的功能,例如通过数学函数(如正弦函数)随时间移动顶点,或者应用模型、视图、投影矩阵进行复杂的空间变换。
一旦顶点着色器运行完毕,我们渲染的图元(例如三角形)的顶点位置就被确定了。
从顶点到像素:光栅化与片段着色器
处理完顶点后,数据会沿着管线向下传递。可能会经过一些可选阶段(如曲面细分),然后进行图元装配,将顶点连接成三角形。
接下来是关键的光栅化步骤。光栅化器会确定哪些屏幕像素位于这些三角形内部,并为每个需要填充的像素(或更准确地说,片段)生成工作项。
最后,我们到达片段着色器。片段着色器为每个片段(可以粗略理解为每个像素)计算最终的颜色。
以下是一个简单的片段着色器示例:
#version 410 core
out vec4 color;
void main()
{
color = vec4(1.0f, 0.5f, 0.2f, 1.0f); // 输出一个橙色
}
out vec4 color;定义了一个输出变量。这个四维向量将包含该片段的最终颜色(RGBA值,A代表透明度)。- 在
main函数中,我们直接将颜色设置为一个固定的橙色vec4(1.0, 0.5, 0.2, 1.0)。
片段着色器同样功能强大。由于它对每个像素执行,我们可以实现精细的效果,例如每像素光照计算,这将在本系列后续涉及光照模型时进行探讨。
在代码中整合着色器
在OpenGL应用程序中,着色器代码以字符串形式存在于C++源码中。我们需要在运行时编译它们并将其链接成一个完整的着色器程序。
以下是整合流程的概述:
- 创建着色器对象:分别为顶点着色器和片段着色器源码字符串创建着色器对象(使用
glCreateShader和glShaderSource)。 - 编译着色器:编译每个着色器对象(使用
glCompileShader)。此阶段会产生语法错误,需要调试。 - 创建程序对象:创建一个程序对象(使用
glCreateProgram)。 - 附着与链接:将编译好的着色器对象附着到程序对象上,然后链接它们(使用
glAttachShader和glLinkProgram)。链接后的程序对象代表了完整的渲染管线。 - 使用程序:在渲染时,通过
glUseProgram激活这个程序对象。 - 绘制调用:最后,发出绘制命令(如
glDrawArrays或glDrawElements)。这个调用会触发整个渲染管线的执行,使用我们指定的顶点和片段着色器来处理顶点数据并填充像素。
这种在运行时将字符串编译为GPU代码的方式,是现代OpenGL赋予开发者灵活控制渲染流程的核心机制。
总结
本节课中我们一起学习了现代OpenGL渲染管线中着色器的核心作用。
- 我们回顾了渲染管线,明确了顶点着色器和片段着色器是必备的可编程阶段。
- 顶点着色器负责处理每个顶点的位置变换。
- 片段着色器负责计算每个片段(像素)的最终颜色。
- 在C++代码中,着色器以字符串形式存在,并通过OpenGL API在运行时编译、链接成一个着色器程序,最终在绘制调用时驱动整个渲染流程。
我们看到的着色器示例非常简单,只有几行代码。随着本系列的深入,当我们开始添加光照、多纹理贴图等高级特性时,着色器会变得更加复杂,从而实现你在现代游戏中看到的那些炫酷效果。


008:文档资源与帮助工具


在本节课中,我们将学习一些对OpenGL编程非常有帮助的工具。这些工具不是集成开发环境或编辑器,而是关键的文档资源。在我们准备开始编程之前,先来熟悉几个有用的资源。

📚 OpenGL API参考卡片


首先,我想让你查看的是OpenGL API参考卡片。

你可以点击它并适当放大查看。这张卡片可能会让人有点望而生畏,因为它列出了所有的OpenGL函数。但我想让你知道,这是一个有用的工具。当我们遇到各种OpenGL命令时,你可以使用 Ctrl+F 来搜索,例如搜索“顶点数组对象”。
我喜欢这张卡片,因为它对内容进行了分类,让我能理解API的结构。例如,我可以了解顶点数组是什么,顶点是什么,以及OpenGL中其他我可能不知道的东西,比如帧缓冲对象能做什么。不过,这很可能不是你最常用的资源。
🔍 首选资源:docs.gl
接下来,让我介绍你最常用的资源:docs.gl。这个网站几乎等同于Khronos的帮助页面,但搜索起来更友好。
例如,这里列出了所有的OpenGL命令。我可以输入像 glBufferData 这样的函数名。当我输入时,它会自动匹配并简化显示结果。然后我可以点击我们想要的OpenGL版本(本系列大部分内容将使用4.x版本)。
在这里,我们可以实际看到函数调用、参数以及每个参数的实际描述。我发现这非常有帮助,描述部分让我了解函数的功能。
更棒的是,这里还有“注意事项”、“错误信息”,甚至“示例”。有时还会链接到其他教程。我非常喜欢docs.gl,我们将在整个系列中经常使用它,你偶尔也会看到我打开它。
📖 OpenGL规范
当然,如果你愿意,在查找这些命令时,也可以参考OpenGL规范。我们在最初的视频中见过它。这可以作为另一个资源,用于搜索和理解OpenGL。
再次回顾封面上的图片,其中一些内容会变得更加熟悉,比如我们甚至可以看到中间部分的图形渲染管线。
💻 终端用户的资源:手册页
最后一个资源,因为我知道很多用户和我一样是终端用户,可能想要OpenGL的手册页。如果你在飞机上、没有网络,或者像我一样喜欢快速搜索和浏览,你可以安装OpenGL的手册页。

如果你在Linux或Mac系统上,可以使用类似下面的命令来安装:

sudo apt-get install manpages-dev # 对于基于Debian的系统
# 或使用其他适合你包管理器的命令
输入密码后,等待片刻下载完成。完成后,我们可以尝试查看手册页,例如 glBufferData 的手册页。这样,无论身在何处,我们都能快速访问这些描述命令工作原理的手册页,而且它同样易于搜索,这是我非常喜欢的一点。


🎯 总结
本节课中,我们一起学习了几个关键的OpenGL文档资源。首先介绍了OpenGL API参考卡片,它可以作为快速分类查找的辅助工具。然后,我们重点介绍了docs.gl网站,它是搜索OpenGL函数、查看参数和示例的首选友好资源。此外,我们还提到了OpenGL官方规范作为深入参考,以及为终端用户准备的手册页,便于离线或快速命令行查询。
掌握了这些工具,你现在已经具备了进行OpenGL编程的文档查询能力。当然,你还需要一些指导,因为OpenGL是一个相当庞大的API,而这正是本系列课程的目的——帮助你学习。现在,是时候开始编程了。
009:绘制第一个三角形 🎨

在本节课中,我们将通过编写代码,在屏幕上绘制出第一个三角形。我们将从第5课的代码基础上继续,学习如何指定顶点数据、创建图形管线,并最终发出绘制指令。
概述
本节课的目标是渲染一个简单的三角形。我们将完成以下核心步骤:
- 顶点规格化:在CPU端定义三角形的顶点数据,并将其传输到GPU。
- 创建图形管线:编写并编译顶点着色器和片段着色器,将它们链接成一个可执行的着色器程序。
- 绘制:在主循环中设置OpenGL状态,绑定所需对象,并发出绘制调用。
代码回顾与项目结构
上一节我们介绍了如何搭建OpenGL环境。本节中我们来看看具体的代码结构。我们使用GLAD来加载OpenGL函数,并使用SDL2创建窗口和管理输入。

以下是项目的主要文件结构:
main.cpp:包含程序的主入口和主要逻辑。glad.c:GLAD的实现文件。



我们主要在main.cpp文件中进行编码。
编译命令示例如下(Linux环境):
g++ main.cpp src/glad.c -Iinclude -lSDL2 -ldl -o program

编译成功后,运行程序应能显示一个SDL2窗口和OpenGL的版本信息。


第一步:顶点规格化
在进入主循环绘制之前,我们需要准备要绘制的几何数据。这个过程称为顶点规格化。
我们将创建一个函数 vertexSpecification() 来完成这项工作。它的职责是定义顶点数据并将其上传到GPU。
首先,我们在CPU端使用一个数组来存储三个顶点的位置(X, Y, Z坐标)。
std::vector<GLfloat> vertexPositions = {
// 顶点 1
-0.5f, -0.5f, 0.0f, // X, Y, Z
// 顶点 2
0.5f, -0.5f, 0.0f,
// 顶点 3
0.0f, 0.5f, 0.0f
};
坐标范围通常在-1.0到1.0之间,这是OpenGL标准化设备坐标的范围。
接下来,我们需要在GPU上创建对象来存储和管理这些数据。这涉及两个核心对象:
- 顶点数组对象 (Vertex Array Object, VAO):记录顶点数据格式和顶点缓冲对象的关联。
- 顶点缓冲对象 (Vertex Buffer Object, VBO):实际存储顶点数据的内存区域。
以下是创建和设置它们的步骤:

首先,生成并绑定一个VAO。
GLuint gVertexArrayObject = 0;
glGenVertexArrays(1, &gVertexArrayObject);
glBindVertexArray(gVertexArrayObject);
接着,生成并绑定一个VBO,然后将CPU数据复制到VBO中。
GLuint gVertexBufferObject = 0;
glGenBuffers(1, &gVertexBufferObject);
glBindBuffer(GL_ARRAY_BUFFER, gVertexBufferObject);
glBufferData(GL_ARRAY_BUFFER,
vertexPositions.size() * sizeof(GLfloat),
vertexPositions.data(),
GL_STATIC_DRAW);
GL_ARRAY_BUFFER表示此缓冲区用于存储顶点属性数据。glBufferData的参数依次是:目标、数据大小(字节)、数据指针、使用提示(GL_STATIC_DRAW表示数据几乎不变)。
然后,告诉OpenGL如何解析VBO中的数据。我们启用顶点属性数组中的第0个属性(对应位置),并设置其格式。
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, // 属性索引,与`glEnableVertexAttribArray`对应
3, // 每个顶点属性的分量数(X,Y,Z是3个)
GL_FLOAT, // 数据类型
GL_FALSE, // 是否标准化
0, // 步长(连续顶点属性之间的偏移)
(void*)0 // 起始位置的偏移量
);
最后,进行清理,解绑VAO并禁用属性数组。
glBindVertexArray(0);
glDisableVertexAttribArray(0);
至此,顶点数据已成功上传至GPU并配置完毕。


第二步:创建图形管线

顶点数据准备就绪后,我们需要一个“流水线”来处理它们。这个流水线就是图形管线,它由着色器程序控制。
我们创建一个函数 createGraphicsPipeline() 来构建这个管线。管线的核心是着色器程序,它由顶点着色器和片段着色器链接而成。
首先,我们定义两个字符串,分别包含顶点着色器和片段着色器的GLSL源代码。
顶点着色器负责处理顶点位置:
std::string vertexShaderSource = R"(
#version 460 core
layout(location = 0) in vec3 position;
void main()
{
gl_Position = vec4(position, 1.0);
}
)";
片段着色器负责决定像素的颜色:
std::string fragmentShaderSource = R"(
#version 460 core
out vec4 fragColor;
void main()
{
fragColor = vec4(1.0, 0.5, 0.0, 1.0); // 橙色
}
)";


为了模块化,我们创建两个辅助函数:
createShaderProgram:接收着色器源代码字符串,返回链接好的程序对象。compileShader:编译单个着色器(顶点或片段)。
compileShader函数的主要流程如下:
GLuint compileShader(GLenum type, const std::string& source) {
GLuint shaderObject;
if (type == GL_VERTEX_SHADER) {
shaderObject = glCreateShader(GL_VERTEX_SHADER);
} else if (type == GL_FRAGMENT_SHADER) {
shaderObject = glCreateShader(GL_FRAGMENT_SHADER);
}
const char* src = source.c_str();
glShaderSource(shaderObject, 1, &src, nullptr);
glCompileShader(shaderObject);
// 此处应添加错误检查(下节课详述)
return shaderObject;
}
createShaderProgram函数的主要流程如下:
GLuint createShaderProgram(const std::string& vsSource, const std::string& fsSource) {
GLuint programObject = glCreateProgram();
GLuint vertexShader = compileShader(GL_VERTEX_SHADER, vsSource);
GLuint fragmentShader = compileShader(GL_FRAGMENT_SHADER, fsSource);
glAttachShader(programObject, vertexShader);
glAttachShader(programObject, fragmentShader);
glLinkProgram(programObject);
// 链接后可以删除着色器对象
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
return programObject;
}
在createGraphicsPipeline函数中,我们调用createShaderProgram,并将返回的程序句柄存储在一个全局变量gGraphicsPipelineShaderProgram中,以备后续使用。
第三步:绘制三角形
所有准备工作完成后,我们进入主循环进行绘制。主循环通常包含处理输入、预绘制设置和正式绘制。

在预绘制函数 preDraw() 中,我们设置每一帧的OpenGL状态:
void preDraw() {
glDisable(GL_DEPTH_TEST); // 本例简单,先禁用深度测试
glViewport(0, 0, screenWidth, screenHeight);
glClearColor(0.0f, 0.0f, 1.0f, 1.0f); // 设置清屏颜色为蓝色
glClear(GL_COLOR_BUFFER_BIT); // 清除颜色缓冲区
}
注意:必须调用glClear才能将清屏颜色应用到窗口。
在绘制函数 draw() 中,我们执行实际的绘制命令:
- 使用我们创建好的着色器程序。
- 绑定我们配置好的顶点数组对象(VAO)。
- 发出绘制指令。
void draw() {
glUseProgram(gGraphicsPipelineShaderProgram); // 激活着色器程序
glBindVertexArray(gVertexArrayObject); // 绑定VAO
glDrawArrays(GL_TRIANGLES, 0, 3); // 绘制三角形,从第0个顶点开始,共3个顶点
glBindVertexArray(0); // 绘制完成后解绑
}
glDrawArrays 命令指示OpenGL使用当前绑定的VAO和激活的着色器程序,以三角形图元方式绘制3个顶点。
运行结果与总结
将以上所有步骤整合到程序中并运行,你将看到一个蓝色背景的窗口中央,绘制着一个橙色的三角形。


本节课中我们一起学习了现代OpenGL渲染一个三角形的完整流程:
- 顶点规格化:在CPU定义数据,创建并配置VAO和VBO,将数据上传至GPU。
- 创建图形管线:编写GLSL着色器代码,编译并链接成着色器程序。
- 绘制:在主循环中设置帧状态,激活管线,绑定顶点数据,并发出绘制调用。

虽然步骤繁多,但这是所有OpenGL渲染的基础。成功绘制出第一个三角形是一个重要的里程碑。在此基础上,你可以通过添加更多顶点、变换、颜色和纹理属性来创建更复杂的图形。

在接下来的课程中,我们将优化代码结构,增加错误处理,并开始探索3D变换。恭喜你完成了这一关键步骤!
010:代码回顾 - 第一个OpenGL三角形



在本节课中,我们将对之前编写的代码进行一次回顾。我们将逐段分析代码,确保理解每个独立部分的功能。本次回顾将遵循图形渲染管线的流程,从程序初始化、顶点数据设置、管线创建到最终的绘制循环。






全局变量声明


首先,我们来看代码顶部的全局变量声明。在入门系列中,为了方便理解,我们暂时使用全局变量来管理状态。这些变量主要涉及SDL窗口设置和OpenGL管线对象。





以下是部分关键全局变量:
gGraphicsPipelineProgramID: 图形管线程序对象的ID。gVertexArrayObject: 顶点数组对象(VAO)的ID。gVertexBufferObject: 顶点缓冲对象(VBO)的ID。gVertexPositions: 存储顶点位置数据的向量。gVertexShaderSource和gFragmentShaderSource: 存储顶点和片段着色器源代码的字符串。


程序初始化


上一节我们介绍了全局变量,本节中我们来看看程序初始化函数 InitializeProgram。这个函数负责设置SDL窗口和OpenGL上下文。

以下是初始化程序的主要步骤:
- 初始化SDL的视频子系统。
- 设置OpenGL版本为4.1,并选择核心模式(Core Profile),以移除旧版兼容功能。
- 配置深度缓冲区等渲染设置。
- 创建SDL窗口并检查是否成功。
- 创建OpenGL上下文。上下文是一个封装了OpenGL状态的大对象。
- 使用GLEW库加载所有OpenGL函数指针。


顶点数据规范



初始化窗口和上下文后,下一步是准备要渲染的几何数据。这是 VertexSpecification 函数的工作。




该函数主要完成以下任务:
- 定义顶点数据: 将三角形的三个顶点坐标(X, Y, Z)存入
gVertexPositions向量。 - 生成顶点数组对象(VAO): VAO用于描述顶点数据在缓冲区中的布局。调用
glGenVertexArrays生成。 - 生成顶点缓冲对象(VBO): VBO用于在GPU上存储顶点数据。调用
glGenBuffers生成。 - 绑定并传输数据: 绑定VAO和VBO,然后使用
glBufferData将CPU上的顶点数据复制到GPU的VBO中。需要计算数据的总字节大小:sizeof(float) * 顶点数量 * 每个顶点的分量数。 - 设置顶点属性指针: 通过
glVertexAttribPointer告诉OpenGL如何解析VBO中的数据。例如,我们的数据是连续的浮点数,每3个值(X, Y, Z)构成一个顶点属性(位置)。 - 启用顶点属性: 调用
glEnableVertexAttribArray启用对应的顶点属性位置。 - 清理状态: 解绑VAO和VBO,避免意外修改。

创建图形管线







设置好顶点数据后,我们需要定义如何处理这些数据,这就是创建图形管线。CreateGraphicsPipeline 函数负责编译、链接着色器,最终生成一个可用的着色器程序。




以下是创建图形管线的过程:
- 编译着色器: 分别编译顶点着色器(
gVertexShaderSource)和片段着色器(gFragmentShaderSource)。CompileShader函数内部会创建着色器对象、附加源代码并进行编译。 - 错误检查: 在
CompileShader函数中,包含了对编译是否成功的检查。如果着色器代码有语法错误(如拼写错误、缺少分号),可以通过日志获取错误信息,这对调试至关重要。 - 创建着色器程序: 调用
glCreateProgram创建一个程序对象。 - 附加并链接: 将编译好的顶点和片段着色器附加到程序对象上,然后调用
glLinkProgram进行链接。链接成功后,就得到了一个完整的图形管线程序gGraphicsPipelineProgramID。




主渲染循环
一切准备就绪后,程序进入主渲染循环。这是实时图形应用的核心,通常遵循“处理输入 -> 更新状态 -> 绘制 -> 呈现”的模式。



主循环的结构如下:
- 处理输入:
ProcessInput函数轮询SDL事件(如键盘、鼠标事件),并更新程序状态(例如,设置退出标志gQuit)。 - 预绘制设置:
PreDraw函数设置OpenGL的渲染状态。这通常包括:- 使用
glUseProgram激活我们的图形管线程序。 - 设置视口(
glViewport)。 - 用特定颜色清除颜色缓冲区和深度缓冲区(
glClear)。
- 使用
- 执行绘制:
Draw函数发出实际的绘制命令。- 绑定我们之前设置的VAO(
glBindVertexArray),告诉OpenGL顶点数据的格式。 - 调用
glDrawArrays函数。这个函数是启动图形管线的关键,它指示OpenGL使用当前绑定的VAO和激活的着色器程序,从第0个顶点开始,绘制3个顶点(即一个三角形)。 - 绘制完成后,可以选择解绑VAO和程序对象以进行清理。
- 绑定我们之前设置的VAO(
- 交换缓冲区: 调用
SDL_GL_SwapWindow。由于SDL使用双缓冲机制,glDrawArrays的绘制结果先提交到后缓冲区(Back Buffer),此函数将后缓冲区的内容交换到前缓冲区(Front Buffer)显示在屏幕上,从而避免画面撕裂。

总结


本节课中我们一起回顾了创建第一个OpenGL三角形的完整代码流程。我们按照图形渲染管线的顺序,逐步分析了程序初始化、顶点数据规范、着色器程序创建以及主渲染循环的各个步骤。理解这个基础流程对于后续学习更复杂的OpenGL概念至关重要。在接下来的课程中,我们将以此为基础,添加更多功能。
011:OpenGL对象、上下文与状态机

在本节课中,我们将深入理解OpenGL的核心工作机制。我们将探讨OpenGL中的“对象”概念、关键的“上下文”以及整个系统如何作为一个“状态机”运行。通过分析C语言风格的代码和Mesa开源实现,你将建立起对OpenGL底层逻辑的清晰直觉,这会让后续的学习和应用变得更加轻松。
OpenGL对象:C语言视角
上一节我们介绍了OpenGL的基本概念,本节中我们来看看OpenGL中的“对象”到底是什么。它与Java或C++等面向对象语言中的“对象”概念不同。OpenGL是一个基于C语言的API,因此它的“对象”本质上是通过结构体(struct)和函数指针来模拟的。
以下是一个简化的C语言示例,展示了如何模拟一个“程序对象”:
// 在头文件 object.h 中定义结构体
typedef struct {
int id;
char* name;
void (*printHello)(void);
} program_object_t;
// 在源文件 object.c 中实现功能
program_object_t* create_program_object(int id, const char* name) {
program_object_t* obj = malloc(sizeof(program_object_t));
obj->id = id;
obj->name = strdup(name);
obj->printHello = &sayHello; // 指向一个函数
return obj;
}
void sayHello() {
printf("Hello from OpenGL object!\n");
}
在上面的代码中,program_object_t 结构体包含了一些数据成员和一个函数指针。创建和使用这个“对象”需要手动调用类似构造函数的函数(如 create_program_object)并管理其生命周期。这就是OpenGL底层操作对象的方式:它通过一系列函数(如 glGenBuffers, glBindBuffer, glBufferData)来创建、配置和操作代表缓冲区、着色器等资源的内部结构体,而不是通过调用某个对象的成员方法。

探索OpenGL上下文:Mesa源码实例
理解了对象的概念后,我们来看看OpenGL中一个更全局、更重要的概念——上下文。OpenGL上下文是一个包含了所有渲染状态(如当前使用的着色器、绑定的缓冲区、启用的功能等)的大型数据结构。你可以把它想象成一个控制台或仪表盘,上面布满了控制渲染管线的各种开关和旋钮。
为了更具体地理解,我们可以查看Mesa的源代码,它是一个开源的OpenGL实现。在Mesa的GitHub仓库中,我们可以找到定义上下文的头文件。

// Mesa源码中 GLcontext 结构体的简化示意
struct __GLcontext {
// ... 大量成员变量,用于存储所有OpenGL状态 ...
GLboolean lineSmooth; // 是否启用线段抗锯齿
GLint currentProgram; // 当前使用的着色器程序ID
struct gl_buffer_object* array_buffer_binding; // 当前绑定的数组缓冲区
// ... 更多状态,如深度测试、混合模式、视口设置等 ...
};
在Mesa的 context.c 文件中,可以找到初始化这个上下文的函数,它负责为这个庞大结构体中的所有状态设置默认值。这个上下文对象是全局的,OpenGL的所有函数调用都会读取或修改这个上下文中的状态。这就是为什么在切换渲染目标或配置时,需要小心管理状态。
OpenGL状态机:可视化理解
我们已经知道OpenGL上下文管理着所有状态,那么整个OpenGL系统就可以被理解为一个状态机。状态机是一种行为模型,它根据当前状态和输入(即我们的API调用)来决定下一步做什么并转移到新的状态。

一个极佳的理解方式是使用WebGL Fundamentals网站提供的“状态图”可视化工具。该工具动态展示了调用WebGL(与OpenGL ES概念相同)API时,内部全局状态(即上下文)如何变化。
以下是使用该工具观察一个典型绘制流程时,状态机的变化步骤:
- 创建着色器:调用
glCreateShader和glShaderSource后,状态机中会创建一个新的着色器对象并存储其源代码。 - 编译与链接着色器程序:调用
glCompileShader和glLinkProgram后,状态机将着色器编译链接成一个完整的着色器程序对象,并更新“当前使用程序”的状态。 - 配置顶点数据:调用
glGenBuffers,glBindBuffer,glBufferData后,状态机创建并绑定一个顶点缓冲区对象(VBO),并将数据上传至GPU。 - 设置顶点属性指针:调用
glVertexAttribPointer和glEnableVertexAttribArray后(通常在VAO内进行),状态机记录了如何从当前绑定的VBO中解析出顶点数据。 - 绘制:调用
glDrawArrays时,状态机检查当前所有状态——当前着色器程序、绑定的缓冲区、启用的顶点属性等——然后命令GPU依据这些状态执行整个渲染管线,最终生成屏幕上的三角形。
通过一步步执行代码并观察状态图中连线的建立与高亮,你可以直观地看到:OpenGL的绘制结果是由发出绘制命令那一瞬间的整个上下文状态所决定的。这强调了在正确的时间绑定正确的对象和设置正确的状态至关重要。
核心概念总结
本节课中我们一起学习了OpenGL的三个核心底层概念:
- OpenGL对象:本质上是C语言结构体,通过独立的函数(如
glGen*,glBind*)进行创建、绑定和操作,而非面向对象语言中的类实例。 - OpenGL上下文:一个存储所有渲染状态(当前着色器、缓冲区、功能开关等)的全局数据结构。它是OpenGL状态机的具体实现载体。
- OpenGL状态机:OpenGL的行为模型。绘制命令(如
glDrawArrays)会基于当前上下文中的全部状态来执行渲染管线。理解“当前状态”是编写正确OpenGL代码的关键。

记住这个比喻:OpenGL上下文就像一个体育场中央的巨型记分牌,它实时追踪并显示着所有重要的比赛信息(状态)。作为程序员,你的任务就是通过API调用来设置这个记分牌,然后发出“开始比赛”(绘制)的指令。希望这节课建立的直觉能帮助你更自信地驾驭后续的OpenGL编程之旅。
012:从文件加载着色器(改进工作流)

在本节课中,我们将学习如何改进OpenGL着色器代码的工作流程。我们将把硬编码在C++程序中的着色器字符串,改为从外部文件动态加载。这样做的好处是,修改着色器代码后,无需重新编译整个C++项目,只需保存文件并重新运行程序即可,从而大大提高开发效率。

项目回顾与目标
上一节我们成功渲染了一个三角形,但着色器代码是直接以字符串形式写在C++源文件中的。本节中,我们来看看如何优化这一点。
一个典型的图形应用程序主要包含以下几个步骤:
- 初始化:负责设置窗口、OpenGL版本等。
- 顶点规范:定义顶点数据及其用途。
- 创建图形管线:编译和链接着色器程序,这是本节课的重点。
- 主应用循环:负责持续绘制、处理用户输入等。
目前,我们的着色器代码(顶点着色器和片段着色器)是作为字符串常量嵌入在main.cpp文件顶部的。这意味着每次修改着色器,哪怕只是一个颜色值,都需要重新编译整个C++项目。我们的目标是改为在程序运行时从文件中加载这些着色器代码。
实现文件加载函数
为了实现从文件加载着色器,我们需要编写一个辅助函数。以下是该函数的具体实现步骤。
我们将创建一个名为loadShaderAsString的自由函数,它接收一个文件名(字符串),并返回该文件的内容(字符串)。
#include <string>
#include <fstream>
std::string loadShaderAsString(const std::string& filename) {
std::string result; // 存储最终文件内容的字符串
std::string line; // 用于读取每一行的缓冲区
std::ifstream myFile(filename); // 创建输入文件流
// 检查文件是否成功打开
if (myFile.is_open()) {
// 逐行读取文件内容
while (std::getline(myFile, line)) {
result += line + "\n"; // 将每一行拼接到结果字符串中,并添加换行符
}
myFile.close(); // 读取完成后关闭文件
}
return result; // 返回文件内容字符串
}
这个函数使用C++标准库中的<fstream>和<string>来读取文件。它逐行读取指定文件,并将所有内容拼接成一个完整的字符串返回。如果文件无法打开,函数将返回一个空字符串。
重构图形管线创建代码
有了加载函数后,我们现在可以修改创建图形管线的代码,用从文件加载的字符串替换之前硬编码的字符串。
首先,我们需要将着色器代码从main.cpp中移出,保存为独立的文件。建议在项目目录中创建一个shaders文件夹来管理它们。
例如,创建两个文件:
shaders/vert.glsl:用于存放顶点着色器代码。shaders/frag.glsl:用于存放片段着色器代码。
接着,在原来调用createShaderProgram函数的地方进行修改:
// 从文件加载着色器代码
std::string vertexShaderSource = loadShaderAsString("shaders/vert.glsl");
std::string fragmentShaderSource = loadShaderAsString("shaders/frag.glsl");
// 使用加载的代码创建着色器程序
unsigned int shaderProgram = createShaderProgram(vertexShaderSource, fragmentShaderSource);
这样,createShaderProgram函数接收的参数就不再是硬编码的字符串,而是从外部文件动态读取的内容。
验证与工作流优势
完成代码修改并编译运行后,程序应该能像之前一样正确渲染出三角形。此时,工作流的改进就体现出来了。
例如,如果你想将三角形的颜色从橙色改为红色,只需打开shaders/frag.glsl文件,修改颜色值:
#version 330 core
out vec4 FragColor;
void main() {
FragColor = vec4(1.0f, 0.0f, 0.0f, 1.0f); // 改为红色
}
保存文件后,无需重新编译C++项目,直接重新运行程序,就能立即看到三角形变成了红色。
这种分离带来了显著优势:
- 提升效率:着色器艺术家或技术美术可以独立修改和调试着色器代码,无需等待冗长的C++项目编译。
- 职责清晰:引擎程序员负责C++端的逻辑,着色器编写者负责GPU端的着色逻辑,两者通过文件接口协作。
- 易于管理:着色器代码作为独立的资源文件,更容易进行版本控制和资源管理。
总结

本节课中我们一起学习了如何优化OpenGL着色器开发的工作流程。核心内容是将着色器代码从C++源代码中分离出来,保存为独立的.glsl文件,并通过一个loadShaderAsString函数在运行时动态加载。这样做之后,修改着色器代码只需保存文件并重新运行程序,无需重新编译整个C++项目,极大地提高了迭代效率。这种模式也为团队协作提供了清晰的分工界面。
013:绘制彩色三角形(使用多个顶点缓冲对象)

在本节课中,我们将学习如何为三角形添加颜色属性。我们将使用多个顶点缓冲对象,为三角形的每个顶点指定不同的颜色,最终得到一个颜色平滑过渡的彩色三角形。
项目结构概述

首先,我们快速回顾一下项目结构。我们的程序主要包含几个部分:初始化窗口(如SDL2)、顶点规范(定义几何体)、创建图形管线(定义渲染流程)以及主应用循环。本节课的重点将放在修改顶点规范和着色器上,以支持多个顶点属性。
修改顶点规范
上一节我们介绍了如何定义顶点的位置数据。本节中,我们来看看如何为顶点添加颜色数据。
1. 准备颜色数据
我们需要创建一个新的向量来存储每个顶点的颜色值(R, G, B)。颜色值范围应在0.0到1.0之间。
std::vector<GLfloat> vertexColors = {
1.0f, 0.0f, 0.0f, // 第一个顶点:红色
0.0f, 0.0f, 1.0f, // 第二个顶点:蓝色
0.0f, 1.0f, 0.0f // 第三个顶点:绿色
};
2. 创建第二个顶点缓冲对象
接下来,我们需要创建第二个顶点缓冲对象来存储颜色数据。这个过程与创建位置缓冲对象类似。
以下是创建和设置颜色顶点缓冲对象的关键步骤:
// 生成缓冲对象
glGenBuffers(1, &vertexColorBufferObject);
// 绑定到当前上下文
glBindBuffer(GL_ARRAY_BUFFER, vertexColorBufferObject);
// 将颜色数据复制到缓冲中
glBufferData(GL_ARRAY_BUFFER,
vertexColors.size() * sizeof(GLfloat),
vertexColors.data(),
GL_STATIC_DRAW);

3. 在顶点数组对象中链接颜色属性
创建好缓冲后,我们需要在顶点数组对象中启用并链接这个新的颜色属性。
以下是链接颜色属性的步骤:
// 启用顶点属性数组的索引1(用于颜色)
glEnableVertexAttribArray(1);
// 指定索引1处属性数据的格式
glVertexAttribPointer(1, // 属性索引
3, // 每个顶点包含3个分量(R, G, B)
GL_FLOAT, // 数据类型
GL_FALSE, // 是否标准化
0, // 步长(紧密排列)
nullptr); // 偏移量

完成设置后,记得在适当的时候禁用属性数组。
修改着色器
顶点数据准备就绪后,我们需要修改着色器来接收和使用这些新的颜色数据。
1. 修改顶点着色器
在顶点着色器中,我们需要使用 layout 限定符明确指定输入变量的位置。
以下是修改后的顶点着色器核心部分:

#version 330 core
// 指定位置0的属性是顶点位置
layout (location = 0) in vec3 position;
// 指定位置1的属性是顶点颜色
layout (location = 1) in vec3 vertexColor;


// 输出到片段着色器的颜色变量
out vec3 v_vertexColor;
void main() {
gl_Position = vec4(position, 1.0);
// 将输入的颜色传递给下一阶段
v_vertexColor = vertexColor;
}
2. 修改片段着色器


片段着色器需要接收从顶点着色器传递过来的颜色值,并将其作为最终输出。
以下是修改后的片段着色器:
#version 330 core
// 从顶点着色器输入的颜色值
in vec3 v_vertexColor;
// 最终输出的颜色
out vec4 fragColor;

void main() {
// 使用传入的颜色值设置输出
fragColor = vec4(v_vertexColor.r, v_vertexColor.g, v_vertexColor.b, 1.0);
}
运行结果

完成以上所有修改并编译运行程序后,你将看到一个顶点分别为红、蓝、绿色的三角形,并且颜色在三角形表面平滑地插值过渡。
总结



本节课中我们一起学习了如何为OpenGL图形添加多个顶点属性。我们主要完成了以下工作:
- 在C++程序中创建了第二个顶点缓冲对象来存储颜色数据。
- 在顶点数组对象中链接了这个新的颜色属性。
- 修改了顶点着色器和片段着色器,使其能够接收、传递并应用颜色数据。
通过这次实践,我们掌握了为顶点附加额外信息(如颜色、法线、纹理坐标)的基本方法,这是构建复杂且视觉效果丰富的图形场景的重要基础。
OpenGL导论:14:使用单一顶点缓冲对象绘制彩色三角形

在本节课中,我们将学习如何使用一个单一的顶点缓冲对象来存储多个顶点属性(如位置和颜色),并通过交错布局的方式将它们打包在一起。这种方法可以减少状态切换,并可能简化数据管理。
概述
上一节我们介绍了使用多个顶点缓冲对象来分别存储位置和颜色数据。本节中,我们将看看如何将这两种属性交错存储在一个顶点缓冲对象中。核心在于理解 glVertexAttribPointer 函数中 步长 和 偏移量 参数的正确设置。
顶点数据布局
首先,我们需要重新组织顶点数据。不再使用两个独立的数组,而是将每个顶点的位置和颜色数据连续存放。
以下是我们的新数据布局,每个顶点包含6个浮点数(X, Y, Z, R, G, B):
std::vector<GLfloat> vertexData = {
// 顶点1
-0.5f, -0.5f, 0.0f, // 位置 (X, Y, Z)
1.0f, 0.0f, 0.0f, // 颜色 (R, G, B)
// 顶点2
0.5f, -0.5f, 0.0f, // 位置
0.0f, 1.0f, 0.0f, // 颜色
// 顶点3
0.0f, 0.5f, 0.0f, // 位置
0.0f, 0.0f, 1.0f // 颜色
};
配置顶点缓冲对象
接下来,我们只需要生成并绑定一个顶点缓冲对象,然后将 vertexData 的数据传入。
GLuint VBO;
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, vertexData.size() * sizeof(GLfloat), vertexData.data(), GL_STATIC_DRAW);

设置顶点属性指针
这是最关键的一步。我们需要告诉OpenGL如何从这一个缓冲中解析出位置和颜色两种属性。


位置属性(属性索引0)
- 大小:3(代表X, Y, Z三个分量)。
- 步长:到下一个顶点位置数据的字节数。我们需要跳过6个浮点数(位置3个 + 颜色3个),因此步长为
6 * sizeof(GLfloat)。 - 偏移量:位置数据从缓冲区的开头开始,所以偏移量为
(void*)0。
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (void*)0);
glEnableVertexAttribArray(0);
颜色属性(属性索引1)
- 大小:3(代表R, G, B三个分量)。
- 步长:与位置属性相同,为
6 * sizeof(GLfloat)。 - 偏移量:颜色数据在位置数据之后开始。我们需要跳过3个浮点数(X, Y, Z),因此偏移量为
(void*)(3 * sizeof(GLfloat))。
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (void*)(3 * sizeof(GLfloat)));
glEnableVertexAttribArray(1);
核心概念总结

理解 glVertexAttribPointer 的参数至关重要:
stride:从一个属性的开始到下一个属性的开始之间的字节数。在我们的例子中,一个完整的顶点数据块是6个浮点数,所以步长是6 * sizeof(GLfloat)。pointer:当前属性在每个数据块内部的起始偏移量。位置属性偏移0字节,颜色属性偏移3 * sizeof(GLfloat)字节。


注意事项


- 交错存储数据在某些情况下可能因数据局部性更好而提升缓存效率,但最佳实践需要通过性能测试来确定。
- 这种方法减少了需要管理的缓冲对象数量,使代码在某些场景下更简洁。
- 如果顶点属性结构发生变化(例如增加纹理坐标),需要同步更新所有相关属性的步长和偏移量。
总结

本节课中我们一起学习了如何使用单一顶点缓冲对象来存储交错的顶点属性。我们重新组织了数据布局,并重点掌握了如何正确计算和设置 glVertexAttribPointer 的步长和偏移量参数。这为我们管理顶点数据提供了另一种灵活的选择。
015:渲染四边形(理解环绕顺序) 🎨

在本节课中,我们将学习如何渲染一个四边形(即矩形或正方形),并理解一个关键概念:顶点环绕顺序。我们将从渲染单个三角形开始,逐步添加顶点数据来构建一个四边形,并解释为什么顶点的排列顺序如此重要。

概述
上一节我们介绍了如何渲染一个带颜色的三角形。本节中,我们来看看如何通过组合两个三角形来渲染一个四边形。为了实现这一点,我们需要理解几何图形的构成以及一个特定的渲染要求:顶点的环绕顺序。
从三角形到四边形
首先,让我们回顾一下当前的三角形渲染代码。为了给四边形腾出空间,我们先调整一下三角形的大小和位置。
以下是当前定义三角形顶点数据的代码片段:
// 第一个三角形(左下、右下、顶部)
// 位置 (x, y, z) // 颜色 (r, g, b)
-0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // 左下顶点 (红色)
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // 右下顶点 (绿色)
0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f // 顶部顶点 (蓝色)
我们将顶点的Y坐标从0.8改为0.5,使三角形变小一些,并在X轴上调整顶部顶点的位置,为第二个三角形留出空间。
调整后,我们的三角形位于一个虚拟的笛卡尔坐标系中。我们可以这样想象:
- X轴水平,右为正。
- Y轴垂直,上为正。
- 我们的三角形占据了左下、右下和左上三个象限的部分区域。
理解环绕顺序
在添加第二个三角形之前,必须理解环绕顺序的概念。环绕顺序指的是定义三角形三个顶点的顺序。
观察我们当前的三角形顶点顺序:左下 -> 右下 -> 顶部。如果我们沿着这个顺序画线,方向是逆时针的。
在OpenGL中(默认情况下),逆时针环绕顺序表示三角形的正面(即面向观察者的一面)。这是一个重要的约定,因为它决定了哪一面是“可见”的。我们可以用右手定则来记忆:伸出右手,让手指沿着顶点顺序弯曲,拇指所指的方向就是三角形的正面(在默认的右手坐标系中,正Z轴方向指向屏幕外,即观察者)。
添加第二个三角形
为了形成一个四边形,我们需要添加第二个三角形。这个三角形将与第一个三角形共享一条边。关键在于,第二个三角形的顶点也必须按照逆时针顺序排列,以确保其正面也朝向观察者。
假设我们要绘制一个由两个三角形组成的正方形,其四个角分别为:左下(A)、右下(B)、右上(C)、左上(D)。
- 第一个三角形顺序是:A -> B -> D (逆时针)。
- 第二个三角形顺序是:B -> C -> D (逆时针)。
注意,两个三角形都共享了B和D这两个顶点。
现在,让我们在代码中添加第二个三角形的数据。我们需要在顶点数据数组中追加三个新的顶点(每个顶点包含位置和颜色)。
以下是添加第二个三角形后的顶点数据:
// 第一个三角形
-0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // 左下顶点 (A)
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // 右下顶点 (B)
-0.5f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f, // 左上顶点 (D)
// 第二个三角形
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // 右下顶点 (B) - 重复
0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, // 右上顶点 (C) - 新顶点
-0.5f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f // 左上顶点 (D) - 重复
注意,顶点B和D被重复使用了。目前我们的顶点总数是6个。
更新绘制调用
由于我们的顶点缓冲区现在包含了6个顶点的数据(两个三角形),我们需要更新绘制命令,告诉OpenGL绘制全部6个顶点。
找到绘制调用 glDrawArrays,将顶点数量参数从 3 改为 6:

glDrawArrays(GL_TRIANGLES, 0, 6); // 绘制6个顶点,构成2个三角形
编译并运行程序,你现在应该能看到一个由两个三角形组成的四边形了!它可能看起来不是完美的正方形,这是因为窗口的宽高比(视口)问题,我们将在后续课程中解决。
关于环绕顺序的灵活性


OpenGL允许你自定义哪个环绕方向代表正面。你可以使用 glFrontFace 函数进行设置:
GL_CCW: 逆时针代表正面(默认值)。GL_CW: 顺时针代表正面。
这在需要与其他图形API(如DirectX,其默认值可能不同)保持一致时非常有用。

总结

本节课中我们一起学习了如何渲染一个四边形。关键要点如下:
- 四边形由三角形构成:在光栅化图形中,复杂形状通常分解为三角形进行渲染。
- 环绕顺序至关重要:顶点的定义顺序(顺时针或逆时针)决定了三角形的哪一面是“正面”。默认情况下,OpenGL将逆时针顺序定义为正面。
- 数据与调用需匹配:当向顶点缓冲区添加更多数据后,必须更新
glDrawArrays等绘制调用中的顶点计数参数。 - 基础已就绪:掌握了渲染四边形的方法,你实际上已经具备了制作2D图形应用(如2D游戏)的核心能力,因为2D图形对象大多可以基于四边形(或精灵)进行构建。

在接下来的课程中,我们将继续优化,并添加更多细节,例如纹理映射,让四边形显示图像。
016:使用索引缓冲对象高效绘制四边形


在本节课中,我们将学习如何使用索引缓冲对象来更高效地绘制四边形。我们将通过复用顶点数据来减少向GPU传输的数据量,这是现代图形编程中提升性能的关键技术。
回顾与问题引入
上一节我们学习了如何通过绘制两个独立的三角形来组成一个四边形。但细心的你可能已经发现,这种方法存在效率问题。
观察上节课的图表,你会发现构成四边形的两个三角形共享了两个顶点。这意味着我们重复存储了相同顶点数据。例如,左上角的顶点在数据中出现了两次。
理想情况下,我们只需要四个顶点就能描述这个四边形,而不是六个。这就是我们本节课要实现的目标。
核心概念:索引缓冲对象
为了复用顶点数据,我们将引入一种称为索引缓冲对象的技术。
我们将创建一个顶点缓冲对象,它只包含四个唯一的顶点数据。
// 顶点数据 (位置, 颜色)
VBO = [V0, V1, V2, V3]
同时,我们将创建一个独立的索引缓冲对象。
// 索引数据
IBO/EBO = [0, 1, 2, 2, 1, 3]
索引缓冲对象并不存储实际的顶点属性(如位置、颜色),而是存储指向顶点缓冲对象中顶点位置的索引。通过这种方式,我们可以用四个顶点和六个索引来定义两个三角形,从而避免数据重复。
绘制原理

假设四个顶点按顺序排列。要绘制第一个三角形,我们可以使用索引 [0, 1, 2]。要绘制第二个三角形,我们可以使用索引 [2, 1, 3]。这样,顶点 V1 和 V2 就被两个三角形共享了。
这种方法的优势在顶点数据包含更多属性(如法线、纹理坐标)时尤为明显,它能显著减少内存占用和数据传输量。
代码实现
现在,让我们看看如何在代码中实现索引缓冲。
首先,我们需要定义索引数据。我们将使用一个 std::vector<GLuint> 来存储索引。
const std::vector<GLuint> indexBufferData = {0, 1, 2, 2, 1, 3};
接下来,我们需要生成、绑定并填充索引缓冲对象。这个过程与创建顶点缓冲对象类似,但使用的目标枚举不同。
以下是创建索引缓冲对象的关键步骤:
- 生成缓冲对象ID。
- 将其绑定到
GL_ELEMENT_ARRAY_BUFFER目标。 - 将索引数据上传到GPU。
GLuint indexBufferObject;
glGenBuffers(1, &indexBufferObject);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indexBufferObject);
glBufferData(GL_ELEMENT_ARRAY_BUFFER,
indexBufferData.size() * sizeof(GLuint),
indexBufferData.data(),
GL_STATIC_DRAW);
重要提示:索引缓冲对象(GL_ELEMENT_ARRAY_BUFFER)的绑定状态是存储在顶点数组对象中的。因此,通常在设置VAO时,在绑定VAO之后绑定EBO。
绘制调用
设置好索引缓冲后,我们需要改变绘制函数。不再使用 glDrawArrays,而是使用 glDrawElements。
glDrawElements 函数需要以下参数:
mode: 图元类型,例如GL_TRIANGLES。count: 要渲染的索引数量(对于两个三角形是6)。type: 索引的数据类型,例如GL_UNSIGNED_INT。indices: 索引数据在缓冲中的偏移量(通常为0,表示从开头开始)。

正确的绘制调用如下:

glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
一个常见的错误是将 count 参数误设为要绘制的三角形数量(2),而不是索引数量(6),这会导致渲染不完整。
注意事项与调试
使用索引绘制时,必须确保数据类型匹配。例如,如果索引是 GLuint(无符号整数),那么在 glDrawElements 中就必须指定 GL_UNSIGNED_INT。指定错误的数据类型(如 GL_INT)不会导致程序崩溃,但会使得什么也渲染不出来,这在图形编程调试中是一个需要留意的点。
总结
本节课中,我们一起学习了如何使用索引缓冲对象来高效绘制四边形。
我们首先指出了重复顶点数据的问题,然后引入了索引缓冲的概念。我们了解到,索引缓冲通过存储指向顶点数据的索引,允许不同图元共享相同的顶点,从而节省内存和带宽。
在实现部分,我们学习了如何创建、绑定索引缓冲对象,以及如何使用 glDrawElements 进行绘制。我们还强调了参数匹配和常见错误。


掌握索引绘制是优化OpenGL应用程序性能的基础。当场景中的几何体包含大量共享顶点时(这在复杂模型和游戏场景中非常普遍),这项技术将带来巨大的性能提升。在接下来的课程中,我们将继续探索更高级的OpenGL特性。
017:使用glError调试OpenGL状态机错误


在本节课中,我们将学习如何在OpenGL中检测和处理错误。由于图形编程不像CPU编程那样可以直接使用printf来输出信息,因此我们需要借助OpenGL内置的API工具来获取错误状态。本节课将介绍glGetError函数,并演示如何构建一个简单的错误检查工具来辅助调试。
发现问题


上一节我们介绍了OpenGL的基本绘制流程。本节中,我们来看看一个常见的开发场景:程序运行后,屏幕上没有出现预期的图形。

运行程序后,窗口背景色正常显示,但本应出现的四边形却没有渲染出来。面对这种情况,如果没有合适的调试工具,定位问题会非常困难。
OpenGL的错误检查机制
OpenGL提供了一个内置函数来帮助我们获取错误信息:glGetError。这个函数可以返回错误代码,提示我们哪个函数调用出了问题以及错误的类型。
以下是OpenGL中一些常见的错误代码示例:
GL_INVALID_ENUM:传递了无效的枚举值。GL_INVALID_VALUE:传递了无效的数值参数。GL_OUT_OF_MEMORY:内存不足。
glGetError函数的工作原理是:当错误发生时,OpenGL状态机会设置一个错误标志。调用glGetError可以获取这个错误代码,并且在调用后,该错误标志会被清除,以便记录后续的错误。
这意味着,如果程序中有多个连续的错误,简单地调用一次glGetError可能无法获取全部信息。因此,我们需要编写一个例程来循环检查并清除所有错误状态。
构建错误检查函数
为了更方便地使用glGetError,我们将围绕它构建一个辅助函数。这个函数将循环调用glGetError,直到错误状态被清空(即返回GL_NO_ERROR)。
首先,我们编写一个函数来清除所有错误记录并检查是否有错误发生。
static bool GLCheckErrorStatus(const char* function, int line) {
while(GLenum error = glGetError()) {
std::cout << "OpenGL Error: " << error
<< " at " << function << ":" << line << std::endl;
return true; // 发现错误
}
return false; // 没有错误
}
这个函数接受函数名和行号作为参数,以便在输出错误信息时能精确定位。它会循环调用glGetError,打印每一个错误代码及其发生的位置。
使用宏包装OpenGL调用
为了在每次OpenGL函数调用后自动执行错误检查,我们可以定义一个宏。这个宏会在调用目标函数前后,执行错误清除和检查操作。
#define GL_CHECK(x) \
GLClearAllErrors(); \
x; \
GLCheckErrorStatus(#x, __LINE__)
宏中的#x会将传入的函数名转换为字符串,__LINE__是预处理器宏,会被替换为当前行号。这样,当我们用GL_CHECK(glDrawElements(...))包装函数调用时,任何错误都能被捕获并报告具体位置。
现在,我们可以将这个宏应用到可能出错的OpenGL函数调用上,例如绘制命令glDrawElements。
调试并修复错误
应用宏并重新编译运行程序后,控制台输出了错误信息:“OpenGL Error: 1280 at glDrawElements:421”。错误代码1280本身没有直接意义,我们需要查找其对应的枚举常量。


通过查询OpenGL文档或在线资料,可以得知1280(十六进制0x500)对应GL_INVALID_ENUM错误。这表明我们在某个函数调用中传递了无效的枚举值。
错误发生在glDrawElements的第421行。检查该函数的参数:第一个是绘制模式(GL_TRIANGLES),第二个是索引数量,第三个是索引数据类型。对比文档,发现常见的索引类型是GL_UNSIGNED_INT或GL_UNSIGNED_SHORT。检查代码后发现,第三个参数误写为GL_UNSIGNED,这是一个无效的枚举值。
将GL_UNSIGNED修正为GL_UNSIGNED_INT后,重新运行程序。错误信息消失,四边形成功渲染在屏幕上。


总结

本节课中我们一起学习了OpenGL的基本错误调试方法。我们介绍了glGetError函数,它用于查询OpenGL状态机中的错误。为了高效利用它,我们构建了一个循环检查错误的函数GLCheckErrorStatus,并创建了一个宏GL_CHECK来自动包装OpenGL调用,从而在开发过程中快速定位错误源和类型。
这种方法是针对OpenGL 3.3等较旧版本的一种实用调试手段。现代OpenGL(4.3+)提供了更先进的调试回调机制,我们将在后续课程中探讨。


018:GLM数学库入门 🧮

在本节课中,我们将学习一个名为GLM(OpenGL Mathematics)的数学库。这是一个免费的开源库,它允许我们进行计算机图形学中所需的许多数学运算,例如处理向量、矩阵和四元数。虽然你可以使用自己的数学库,但GLM是一个非常好用的选择,特别是因为它与GLSL(OpenGL着色语言)的函数命名和默认设置(如列优先矩阵)高度一致,这可以减少学习成本。
下载与设置
首先,我们需要获取并设置GLM库。


以下是获取和设置GLM库的步骤:
- 访问GLM的GitHub页面。
- 下载源代码的ZIP文件。
- 将ZIP文件解压到你项目目录中合适的位置,例如一个名为
third_party的文件夹内。
设置完成后,你的项目目录结构可能如下所示:
your_project/
├── main.cpp
└── third_party/
└── glm-master/ (解压后的GLM库内容)
第一个GLM程序



现在,让我们创建一个简单的程序来验证GLM库是否设置正确。
我们将从一个基本的示例开始,它创建了两个三维向量并计算它们的点积。


#include <iostream>
#include <glm/glm.hpp>

int main() {
// 创建两个三维向量
glm::vec3 a(1.0f, 1.0f, 1.0f);
glm::vec3 b(0.5f, 0.5f, 0.5f);
// 计算点积
float dotProduct = glm::dot(a, b);
std::cout << "Dot product of a and b is: " << dotProduct << std::endl;
return 0;
}
使用以下命令编译(请根据你的目录结构调整 -I 参数):
g++ -std=c++20 main.cpp -I./third_party/glm-master -o main
运行程序,如果输出点积结果(例如 1.5),则说明GLM库已成功集成。


探索GLM功能
上一节我们验证了GLM的基本设置,本节中我们来看看GLM库提供的一些核心功能。

向量运算
GLM提供了丰富的向量运算。以下是一些常用操作:
#include <iostream>
#include <glm/glm.hpp>
#include <glm/gtx/string_cast.hpp> // 用于 to_string
int main() {
glm::vec3 vec(1.0f, 2.0f, 2.0f);
// 1. 向量长度
float length = glm::length(vec);
// 2. 向量归一化 (单位化)
glm::vec3 normalizedVec = glm::normalize(vec);
// 3. 使用 to_string 打印向量
std::cout << "Original vector: " << glm::to_string(vec) << std::endl;
std::cout << "Normalized vector: " << glm::to_string(normalizedVec) << std::endl;
return 0;
}
矩阵运算



矩阵是图形变换的核心。GLM提供了多种矩阵类型和操作。
#include <iostream>
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp> // 包含变换函数
#include <glm/gtx/string_cast.hpp>
int main() {
// 创建一个4x4单位矩阵
glm::mat4 identityMatrix = glm::mat4(1.0f);
std::cout << "Identity Matrix:\n" << glm::to_string(identityMatrix) << std::endl;
// 创建一个平移矩阵
glm::vec3 translation(1.0f, 2.0f, 3.0f);
glm::mat4 translateMatrix = glm::translate(glm::mat4(1.0f), translation);
// 创建一个绕Z轴旋转45度的矩阵
float angle = glm::radians(45.0f); // GLM使用弧度制
glm::mat4 rotationMatrix = glm::rotate(glm::mat4(1.0f), angle, glm::vec3(0.0f, 0.0f, 1.0f));
// 矩阵乘法:先旋转,后平移
glm::mat4 modelMatrix = translateMatrix * rotationMatrix;
return 0;
}
分量访问与Swizzling
GLM支持类似GLSL的分量访问和Swizzling操作,但这需要启用特定功能。

#define GLM_ENABLE_EXPERIMENTAL // 启用实验性功能(如Swizzle)
#include <iostream>
#include <glm/glm.hpp>
#include <glm/gtx/string_cast.hpp>
#include <glm/gtx/swizzle.hpp> // Swizzle操作

int main() {
glm::vec4 color(0.2f, 0.4f, 0.8f, 1.0f);
// 访问单个分量
float red = color.r;
float alpha = color.a;
// Swizzling: 创建新的向量,调整分量顺序
glm::vec3 rgb = color.rgb(); // 获取 (r, g, b)
glm::vec3 bgr = color.bgr(); // 获取 (b, g, r)
glm::vec2 texCoord = color.st(); // 获取 (s, t),等同于 (r, g)
std::cout << "RGB: " << glm::to_string(rgb) << std::endl;
std::cout << "BGR: " << glm::to_string(bgr) << std::endl;
return 0;
}
注意:Swizzling功能在较新版本的GLM中可能被视为实验性功能,需要定义 GLM_ENABLE_EXPERIMENTAL 宏并包含相应的头文件。
叉积运算
叉积用于计算垂直于两个向量的第三个向量,在计算法线或旋转时非常有用。


#include <iostream>
#include <glm/glm.hpp>
#include <glm/gtx/string_cast.hpp>

int main() {
glm::vec3 a(1.0f, 0.0f, 0.0f); // X轴方向
glm::vec3 b(0.0f, 1.0f, 0.0f); // Y轴方向
// 计算叉积,结果应为Z轴方向 (0,0,1) 或 (0,0,-1),取决于顺序
glm::vec3 c = glm::cross(a, b);
std::cout << "Cross product of a and b: " << glm::to_string(c) << std::endl;
// 输出应为 (0, 0, 1)
return 0;
}

总结
本节课中我们一起学习了GLM数学库的基础知识。我们首先完成了GLM库的下载和项目配置。然后,我们探索了它的核心功能,包括:
- 向量的创建、点积、归一化和长度计算。
- 矩阵的创建、基本变换(平移、旋转)以及矩阵乘法。
- 通过启用实验性功能使用分量访问和Swizzling操作。
- 计算两个向量的叉积。

GLM库因其与GLSL的相似性而成为OpenGL编程中的得力助手。掌握如何查阅其官方文档和头文件,将帮助你在未来更有效地使用它的全部功能。在接下来的课程中,我们将把这些数学工具应用到实际的OpenGL图形变换中。
019:向量、点积与叉积

在本节课中,我们将学习OpenGL编程中至关重要的数学基础概念。我们将从最基本的向量概念开始,逐步介绍向量的长度、单位向量、点积和叉积,并通过C++代码演示如何使用GLM库实现这些运算。理解这些数学工具是掌握3D图形渲染、光照计算和相机变换的关键。
向量与坐标系统
上一节我们配置了GLM数学库,本节中我们来看看向量的基本概念。
在计算机图形学中,我们经常使用向量来表示方向和大小。向量与单纯表示位置的点不同,它包含方向和大小两个属性。在代码中,我们使用 glm::vec3 数据类型来表示一个三维向量。
glm::vec3 a(3.0f, 4.0f, 0.0f); // 向量a
glm::vec3 b(0.0f, 7.0f, 0.0f); // 向量b
向量的长度(大小)
向量的长度,也称为其大小或模长,是一个标量值。计算向量长度遵循几何学中的勾股定理。
对于一个三维向量 v(x, y, z),其长度计算公式为:
长度 = √(x² + y² + z²)
在GLM库中,我们可以使用 glm::length() 函数轻松计算:
float lengthA = glm::length(a); // 计算向量a的长度
std::cout << "Length of vector a: " << lengthA << std::endl;
对于向量 a(3.0, 4.0, 0.0),其长度为5,因为 √(3² + 4²) = 5。
单位向量与归一化
在图形学中,我们经常使用单位向量。单位向量是指长度为1的向量,通常在其符号上方加一个“帽子”表示,如 â。
将任意向量转换为单位向量的过程称为归一化。归一化后的向量保留了原方向,但大小变为1。这在许多数学运算中能简化计算。
在GLM中,使用 glm::normalize() 函数进行归一化:
glm::vec3 aNormalized = glm::normalize(a); // 归一化向量a
std::cout << "Normalized vector a: " << glm::to_string(aNormalized) << std::endl;
// 验证归一化后的长度是否为1
std::cout << "Length of normalized a: " << glm::length(aNormalized) << std::endl;
归一化向量 a(3.0, 4.0, 0.0) 会得到 (0.6, 0.8, 0.0),其长度确实为1。
向量的点积
点积是两个向量之间的一种重要运算,其结果是一个标量。点积可以用来衡量两个向量的相似程度,进而计算它们之间的夹角。
两个三维向量 a 和 b 的点积计算公式为:
a · b = a.x * b.x + a.y * b.y + a.z * b.z
在GLM中,使用 glm::dot() 函数计算点积:
float dotProduct = glm::dot(a, b); // 计算向量a和b的点积
std::cout << "Dot product of a and b: " << dotProduct << std::endl;
点积结果的几何意义非常有用:
- 当两个向量方向完全相同时,点积结果为 1(假设向量已归一化)。
- 当两个向量互相垂直时,点积结果为 0。
- 当两个向量方向完全相反时,点积结果为 -1。
通过点积和反余弦函数 acos(),我们可以计算出两个向量之间的实际夹角(弧度制):
// 假设a和b已归一化
float dotResult = glm::dot(aNormalized, bNormalized);
float angleRadians = std::acos(dotResult); // 夹角(弧度)
float angleDegrees = angleRadians * (180.0f / M_PI); // 转换为角度
std::cout << "Angle between vectors: " << angleDegrees << " degrees" << std::endl;
点积在光照模型中至关重要,例如计算光线方向与表面法线的夹角,以确定表面的亮度。
向量的叉积
叉积是另一个关键的向量运算,它作用于两个向量,并产生一个新的向量。这个新向量与原来的两个向量都垂直。
两个三维向量 a 和 b 的叉积 c 计算公式为:
c.x = a.y * b.z - a.z * b.y
c.y = a.z * b.x - a.x * b.z
c.z = a.x * b.y - a.y * b.x
在GLM中,使用 glm::cross() 函数计算叉积:
glm::vec3 c = glm::cross(a, b); // 计算向量a和b的叉积,得到向量c
std::cout << "Cross product of a and b: " << glm::to_string(c) << std::endl;
叉积的顺序很重要:glm::cross(a, b) 和 glm::cross(b, a) 得到的结果向量方向相反。
叉积生成的向量长度,在几何上等于以原两个向量为边构成的平行四边形的面积。叉积在图形学中的一个主要用途是构建坐标系。例如,已知相机的观察方向和上方向,可以通过叉积计算出相机的右方向向量,从而建立一个完整的视图坐标系。
总结
本节课中我们一起学习了OpenGL数学基础的核心内容。
我们首先区分了点和向量,理解了向量具有方向和大小。接着,我们学习了如何计算向量的长度,以及如何通过归一化得到长度为1的单位向量。
然后,我们深入探讨了两种重要的向量运算:点积和叉积。点积是一个标量,用于衡量两个向量的相似性并计算其夹角,这是实现光照等效果的基础。叉积则生成一个新的向量,该向量垂直于参与运算的两个向量,常用于构建三维空间中的坐标系。

掌握这些向量运算的概念及其在GLM库中的实现,是进一步学习3D变换、相机控制和光照模型等高级图形学主题的基石。
020:矩阵变换(含GLM代码演示) 🧮

在本节课中,我们将继续学习OpenGL中的数学知识,重点讨论线性代数,并使用GLM(OpenGL Mathematics)库进行实际操作。我们将学习如何通过矩阵变换来移动、旋转和缩放三维空间中的点。
概述
在计算机图形学中,我们通过矩阵运算来变换三维空间中的顶点。本节课将介绍三种基本的变换:缩放、旋转和平移。我们将使用GLM库来创建和应用这些变换矩阵,并理解变换顺序的重要性。
顶点与齐次坐标
我们从一个顶点开始。在OpenGL中,一个顶点通常用一个四维向量表示,例如 glm::vec4(1.0f, 5.0f, 1.0f, 1.0f)。前三个分量是x、y、z坐标,第四个分量是w坐标。
w坐标的作用:
- w = 1:表示这是一个点(有位置)。
- w = 0:表示这是一个向量(有方向和大小,无位置)。
数学运算示例:
- 点 - 点 = 向量:
(1,5,1,1) - (1,5,-3,1) = (0,0,-4,0)。结果w=0,是一个沿z轴负方向、大小为4的向量。 - 点 + 点:没有明确的几何意义,通常不这样操作。
因此,我们使用四维向量(齐次坐标)来统一表示点和向量,并利用w分量来区分它们。
模型矩阵与变换
上一节我们介绍了向量的概念,本节中我们来看看如何用矩阵来变换这些顶点。我们的目标是将顶点从局部空间(物体自身的坐标系)变换到世界空间(场景的全局坐标系)。这个变换过程通过一个称为模型矩阵的4x4矩阵来完成。
初始时,我们从一个单位矩阵开始。单位矩阵就像数字1,任何矩阵或向量乘以它都不会改变。
单位矩阵公式:
[1, 0, 0, 0]
[0, 1, 0, 0]
[0, 0, 1, 0]
[0, 0, 0, 1]
在代码中,我们这样创建单位矩阵和顶点,并进行乘法运算:
glm::vec4 vertex_local(1.0f, 5.0f, 1.0f, 1.0f); // 局部空间顶点
glm::mat4 model = glm::mat4(1.0f); // 初始化为单位矩阵
glm::vec4 vertex_world = model * vertex_local; // 变换到世界空间
因为model是单位矩阵,所以vertex_world的结果仍然是(1.0, 5.0, 1.0, 1.0)。
基本变换矩阵
GLM库为我们提供了创建基本变换矩阵的便捷函数。以下是三种核心变换:
1. 缩放变换 🔍
缩放矩阵沿x、y、z轴拉伸或压缩物体。
缩放矩阵公式:
[Sx, 0, 0, 0]
[0, Sy, 0, 0]
[0, 0, Sz, 0]
[0, 0, 0, 1]
其中Sx, Sy, Sz是各轴的缩放因子。

GLM代码示例:
glm::mat4 S = glm::scale(glm::mat4(1.0f), glm::vec3(2.0f, 2.0f, 2.0f)); // 各轴放大2倍
vertex_world = S * vertex_local; // 应用缩放
// 结果: (2.0, 10.0, 2.0, 1.0)
2. 旋转变转 🔄
旋转变转矩阵使物体绕特定轴旋转一定角度。GLM使用弧度制,并需要指定旋转轴(一个单位向量)。

绕Y轴旋转180度的矩阵(示例):
[-1, 0, 0, 0]
[0, 1, 0, 0]
[0, 0, -1, 0]
[0, 0, 0, 1]
GLM代码示例:
float angle = glm::radians(180.0f); // 将角度转换为弧度
glm::vec3 axis(0.0f, 1.0f, 0.0f); // 绕Y轴旋转
glm::mat4 R = glm::rotate(glm::mat4(1.0f), angle, axis);
vertex_world = R * vertex_local;
// 结果: (-1.0, 5.0, -1.0, 1.0)
3. 平移变换 🚚
平移矩阵将物体在空间中移动。
平移矩阵公式:
[1, 0, 0, Tx]
[0, 1, 0, Ty]
[0, 0, 1, Tz]
[0, 0, 0, 1 ]
其中Tx, Ty, Tz是沿各轴的移动距离。
GLM代码示例:
glm::mat4 T = glm::translate(glm::mat4(1.0f), glm::vec3(0.0f, 0.0f, -2.0f)); // 沿Z轴负方向移动2个单位
vertex_world = T * vertex_local;
// 结果: (1.0, 5.0, -1.0, 1.0)
变换的组合与顺序
在实际应用中,我们通常需要组合多种变换。变换的顺序至关重要,因为矩阵乘法不满足交换律。
理解变换顺序的关键是:从右向左阅读和应用变换。
代码示例:
glm::mat4 S = glm::scale(..., glm::vec3(2.0f));
glm::mat4 R = glm::rotate(..., angle, axis);
glm::mat4 T = glm::translate(..., glm::vec3(0.0f, 0.0f, -2.0f));
// 顺序:先缩放(S),再旋转(R),最后平移(T)
glm::mat4 model = T * R * S; // 注意:书写顺序是 T * R * S,但应用顺序是从右向左:先S,再R,最后T
glm::vec4 vertex_world = model * vertex_local;
顺序不同的影响:
T * R * S:顶点先被缩放,然后绕自身中心旋转,最后被平移。S * R * T:顶点先被平移,然后旋转(此时旋转中心已改变),最后被缩放。这会得到完全不同的结果。
因此,在组合矩阵时,必须仔细规划你希望物体经历的变换序列。
调试技巧
在开发过程中,查看矩阵的值对于调试非常有帮助。以下是打印GLM 4x4矩阵各列的一个小技巧:
glm::mat4 myMatrix = ...;
for (int i = 0; i < 4; ++i) {
std::cout << glm::to_string(myMatrix[i]) << std::endl; // 打印每一列(在OpenGL的列主序中,`[i]`获取的是第i列)
}
总结
本节课中我们一起学习了OpenGL中矩阵变换的核心概念:
- 使用齐次坐标(四维向量,w=1为点,w=0为向量)来表示几何元素。
- 通过模型矩阵将顶点从局部空间变换到世界空间。
- 利用GLM库创建三种基本变换矩阵:缩放、旋转和平移。
- 理解了变换顺序的重要性:矩阵乘法按从右向左的顺序应用,不同的顺序会导致不同的最终位置。
- 掌握了组合多个变换矩阵来对物体进行复杂操作的方法。
这些矩阵运算最终会在着色器中由GPU高效执行,从而让场景中的所有物体动起来。理解这些数学基础是成为优秀图形程序员的关键。
021:整合所有组件 (SDL2+glad+glm)


在本节课中,我们将学习如何将GLM数学库整合到现有的OpenGL应用程序中。我们将回顾项目结构,理解SDL2、glad和GLM这三个核心组件如何协同工作,并配置编译环境以包含GLM库。
项目结构回顾
上一节我们介绍了基本的OpenGL绘图流程。本节中,我们来看看当前项目的整体结构。
我们的项目主要包含以下文件和文件夹:
shaders/:存放构建图形管线的顶点和片段着色器代码。main.cpp:应用程序的主要源代码文件。glad.c和glad.h:用于加载OpenGL函数的辅助工具。SDL2相关的动态链接库(例如Windows上的.dll文件)。
为了整合GLM,我们需要将其头文件包含到项目中。一种常见的做法是创建一个third_party或common文件夹来集中管理这些第三方库。
配置GLM库
GLM是一个仅包含头文件的数学库,这意味着我们不需要编译额外的.lib或.a文件,只需确保编译器能找到其头文件路径即可。
以下是配置GLM库的步骤:
- 获取GLM:从官方仓库下载GLM库。
- 组织目录:将GLM库文件夹(例如
glm-master)放置在一个方便引用的位置,例如项目根目录下的third_party文件夹内。 - 更新包含路径:在编译命令中,添加指向GLM头文件所在目录的
-I参数。



例如,在Linux/macOS的g++编译命令中,需要添加:
-I../third_party/glm
在main.cpp源文件中,则可以使用#include <glm/glm.hpp>来包含核心头文件。
核心组件协作解析
现在我们已经配置好了GLM,让我们深入理解SDL2、glad和GLM这三个组件在应用程序中扮演的角色及其协作方式。
- SDL2:负责创建和管理应用程序窗口,并建立OpenGL渲染上下文。它是一个独立的共享库,在链接阶段需要与我们的应用程序绑定。
- glad:作为一个头文件加载库,它根据我们指定的OpenGL版本(例如4.1),在运行时查找并加载显卡驱动中对应的函数指针。这使得我们可以调用现代OpenGL API。
- GLM:提供图形编程所需的数学工具,如向量、矩阵(例如
glm::mat4)、变换(平移、旋转、缩放)等计算功能。它是一个纯头文件库,代码在编译时直接嵌入到我们的程序中。
它们与我们的main.cpp应用程序的关系可以概括为:
- 编译时:通过
#include指令,将GLM和glad的头文件代码、以及SDL2的头文件声明导入main.cpp。 - 链接时:将我们编译好的
main.cpp目标文件与预编译好的SDL2共享库文件链接在一起,形成最终的可执行程序。
代码流程总览
为了更清晰地理解整个程序的执行脉络,我们来快速回顾一下main.cpp中的典型流程:
- 初始化:调用
SDL_Init和SDL窗口创建函数来设置SDL,然后使用glad加载OpenGL函数。 - 创建图形管线:编译链接着色器,创建顶点缓冲对象(VBO)和顶点数组对象(VAO),准备渲染数据。
- 主循环:在
while循环中处理事件(如退出事件),执行清除屏幕、绑定着色器程序、绑定VAO、发起绘制调用(如glDrawArrays)等渲染指令。 - 清理:退出循环后,删除OpenGL对象(如VAO, VBO, 着色器程序),并关闭SDL。
总结

本节课中我们一起学习了如何将GLM数学库整合到我们的OpenGL项目中。我们回顾了由SDL2处理窗口、glad加载OpenGL函数、GLM提供数学计算的核心架构,并理解了它们从源代码到最终可执行文件的协作过程。现在,我们的项目已经具备了进行复杂图形变换和渲染的所有基础组件。在接下来的课程中,我们将利用这些工具,让图形变得更加生动和有趣。
022:Uniform变量详解 🎮
在本节课中,我们将要学习OpenGL着色器编程中的一个核心概念:Uniform变量。Uniform是着色器中的一种全局变量,它允许我们将数据从CPU(我们的C++程序)传递到GPU(着色器程序)。这是继顶点缓冲对象之后,我们学习第二种向图形管线发送数据的重要机制。
上一节我们介绍了如何使用顶点缓冲对象来传递顶点属性数据。本节中,我们来看看如何通过Uniform变量传递那些不属于单个顶点、而是全局共享的数据。

Uniform变量是什么?
Uniform变量本质上是着色器中的一个全局变量。它具有以下关键特性:
- 它是一个在GPU上的全局变量。
- 它在整个图形管线中共享,这意味着顶点着色器、片段着色器以及其他着色器(如几何着色器)都可以访问同一个Uniform变量。
- 它在着色器内部是常量,意味着着色器代码不能修改它的值。其值只能由我们的CPU程序来设置和更新。
- 它的核心作用是实现从CPU到GPU的数据传递。
简而言之,Uniform的机制是:我们从CPU传递一个值到GPU。
实践:在着色器中声明Uniform


让我们通过代码来理解。首先,我们需要在着色器代码中声明一个Uniform变量。
以下是具体步骤,我们将在顶点着色器中添加一个Uniform:
- 打开顶点着色器文件。
- 在代码中声明一个Uniform变量。我们将其类型设为
float,并命名为u_offset。按照常见的命名风格,我们为Uniform变量添加u_前缀以便识别。// 在顶点着色器中声明一个uniform变量 uniform float u_offset; - 现在,我们可以使用这个变量。例如,用它来偏移顶点的Y坐标:
gl_Position = vec4(position.x, position.y + u_offset, position.z, 1.0); - 编译并运行程序。此时矩形可能没有移动,因为
u_offset的默认值可能是0。接下来,我们需要从C++代码中设置它的值。



在C++程序中设置Uniform值

为了动态地更新Uniform变量,我们需要在C++程序中进行以下操作:
-
查询Uniform的位置:在GPU的着色器程序中,每个Uniform变量都有一个特定的“位置”(一个整数句柄)。我们需要先找到它。
// 查询名为“u_offset”的uniform变量的位置 GLint location = glGetUniformLocation(graphics_pipeline_shader_program, "u_offset"); // 建议进行错误检查 if(location >= 0) { std::cout << "找到 u_offset,位置是: " << location << std::endl; } else { std::cout << "错误:未找到 u_offset,请检查拼写!" << std::endl; }glGetUniformLocation函数会在指定的着色器程序中查找Uniform变量,并返回其内存位置。这个位置是后续设置其值的依据。 -
设置Uniform的值:一旦获得了位置,我们就可以使用
glUniform*系列函数来设置它的值。对于我们的float类型变量,使用glUniform1f。// 假设我们有一个CPU端的变量来存储偏移值 float g_u_offset = 0.0f; // 在渲染循环中(例如pre-draw阶段),将CPU的值传递给GPU的uniform glUseProgram(graphics_pipeline_shader_program); // 首先启用着色器程序 glUniform1f(location, g_u_offset); // 设置uniform的值 -
实现交互:为了让矩形动起来,我们可以通过键盘输入来改变
g_u_offset的值,然后在每一帧将其传递给Uniform。// 在输入处理函数中 if(key_up_pressed) { g_u_offset += 0.01f; } if(key_down_pressed) { g_u_offset -= 0.01f; } // 之后在渲染循环中,glUniform1f会将最新的g_u_offset值传递给着色器


完成以上步骤后,运行程序,按下键盘的上下键,你应该能看到矩形随之上下移动。
Uniform的共享特性
一个重要的概念是Uniform的共享性。我们也可以在片段着色器中声明并使用同一个 u_offset 变量。


以下是具体操作:
- 在片段着色器中同样声明
uniform float u_offset;。 - 在片段着色器代码中使用它,例如影响颜色输出:
// 用u_offset来影响红色通道 colorOut = vec4(color.r - u_offset, color.g, color.b, 1.0); - 无需在C++端做任何额外工作。因为我们在C++中设置的
u_offset值会自动同步到顶点和片段着色器中。


现在,当你再次用键盘改变偏移值时,不仅矩形的位置会变化,其颜色也会随之改变。这证明了Uniform变量在多个着色器阶段是真正全局共享的。


核心概念与最佳实践总结
本节课中我们一起学习了Uniform变量的完整使用流程。我们来总结一下核心要点和几个优化建议:
- 核心机制:Uniform实现了 CPU → GPU 的单向数据传递,用于设置着色器中的全局常量。
- 工作流程:
- 在着色器中声明
uniform。 - 在C++中用
glGetUniformLocation查询其位置。 - 在C++中用
glUniform*函数设置其值。
- 在着色器中声明
- 性能提示:
glGetUniformLocation调用涉及字符串查找,不应在每帧都进行。最佳实践是在初始化时查询一次位置并缓存它,然后在渲染循环中直接使用缓存的位置。 - 调试技巧:可以利用
glGetUniformLocation的返回值进行错误检查(返回-1表示未找到),这有助于发现变量名拼写错误等问题。

Uniform变量是控制着色器行为(如变换、颜色、光照参数等)的强大工具。掌握它是迈向更复杂图形编程的关键一步。
023:从局部空间到世界空间(模型矩阵变换)🚀

在本节课中,我们将学习如何使用模型变换矩阵,将物体从其局部坐标空间移动到世界坐标空间。我们将通过平移、旋转、缩放等操作来移动物体。
概述
上一节我们介绍了如何使用Uniform变量向着色器传递简单的偏移值。本节中,我们将探讨一个更强大、更通用的概念:模型矩阵。通过模型矩阵,我们可以对每个物体进行独立的变换,从而将它们放置在虚拟世界的不同位置。
回顾与起点
首先,让我们回顾一下上节课结束时的程序状态。我们有一个图形管线,负责初始化程序、指定顶点数据、创建包含顶点和片段着色器的管线,并运行主循环。主循环处理输入、执行绘制前的准备工作(如上节课学习的Uniform设置)以及清理工作。
在本课中,我们首先关注顶点数据的指定部分,即设置几何体或顶点数据的地方。编译并运行当前程序,会得到一个矩形(实际上是一个被拉伸的正方形,原因将在下节课讨论)。按上/下键,可以看到我们通过Uniform变量在着色器中偏移了所有顶点的Y坐标。
局部坐标与世界空间
让我们先定义一些术语。目前,我们的顶点数据定义在一个特定的坐标范围内。在OpenGL的标准化设备坐标中,X和Y轴的范围通常是从-1到1。我们绘制的正方形顶点大约在-0.5的位置,这被称为物体的局部坐标空间。
然而,我们通常希望能在更大的“世界”中移动这个物体。这就需要将每个局部坐标乘以一个4x4的变换矩阵,从而将其转换到世界空间。我们可以为每个物体设置一个特殊的矩阵,称为模型矩阵,通过它来操纵物体(如移动、旋转、缩放)。
实现模型变换
我们将不再使用简单的Y轴偏移Uniform,而是创建一个完整的变换矩阵传递给着色器。


1. 修改C++代码


首先,在C++代码中,我们需要包含GLM库的矩阵变换头文件,并创建一个平移矩阵。

#include <glm/gtc/matrix_transform.hpp> // 引入矩阵变换
// 在绘制前例程中(例如第400行附近)
glm::mat4 modelMatrix = glm::translate(glm::mat4(1.0f), glm::vec3(0.0f, g_u_offset, 0.0f));


这里,glm::mat4(1.0f)创建了一个单位矩阵,glm::vec3(0.0f, g_u_offset, 0.0f)指定了沿Y轴的平移量(g_u_offset是我们保留的全局变量,用于按键控制)。
接着,我们需要获取着色器中模型矩阵Uniform的位置,并将这个4x4矩阵传递进去。
GLint u_model_matrix_location = glGetUniformLocation(shader_program, “u_model_matrix”);
if (u_model_matrix_location >= 0) {
glUniformMatrix4fv(u_model_matrix_location, 1, GL_FALSE, glm::value_ptr(modelMatrix));
} else {
// 处理错误:未找到Uniform
std::cerr << “Could not find u_model_matrix, did you spell it correctly?” << std::endl;
exit(EXIT_FAILURE); // 学习阶段,遇到错误直接退出
}
2. 修改顶点着色器
在顶点着色器中,我们需要声明对应的Uniform,并使用它来变换顶点位置。
#version 410 core
uniform mat4 u_model_matrix; // 声明模型矩阵Uniform
layout(location=0) in vec3 position; // 输入的顶点位置(vec3)
void main() {
// 将vec3的位置转换为vec4(w分量设为1.0),然后乘以模型矩阵
vec4 new_position = u_model_matrix * vec4(position, 1.0);
gl_Position = new_position; // 输出最终位置
}
关键点在于:不能直接用4x4矩阵乘以vec3。需要先将vec3的顶点位置转换为齐次坐标vec4(即添加一个w分量,通常设为1.0),然后再进行矩阵乘法。
核心概念总结
让我们通过一个简单的图示和公式来总结这个过程:
- 局部坐标:物体原始的顶点数据,定义在其自身的坐标系中。
- 例如:
vec3 local_position = (-0.5, -0.5, 0.0);
- 例如:
- 模型矩阵:一个4x4变换矩阵(
mat4),用于对物体进行平移、旋转、缩放。- 例如平移矩阵:
mat4 model_matrix = translate(identity_matrix, vec3(0.0, offset, 0.0));
- 例如平移矩阵:
- 世界空间变换:将局部坐标转换到世界空间的过程。
- 公式:
vec4 world_position = model_matrix * vec4(local_position, 1.0);
- 公式:
- 逆变换:模型矩阵的逆矩阵(
inverse(model_matrix))可以将世界坐标转换回该物体的局部坐标,这在某些计算中非常有用。
注意事项与调试
- 着色器优化:如果声明了Uniform但在着色器代码中未实际使用,GLSL编译器可能会将其优化掉,导致
glGetUniformLocation返回-1。确保你确实在计算中使用了该Uniform。 - 矩阵乘法顺序:在GLSL中,矩阵乘法是右乘,即
matrix * vector。确保顺序正确。 - 错误处理:在开发阶段,像上面代码那样,当找不到关键Uniform时使程序失败,可以帮助你快速定位拼写错误或逻辑错误。
展望
现在,我们已经成功将物体通过模型矩阵放置到了世界空间中。你可能会注意到屏幕上的“正方形”看起来有点被拉长成矩形。这是因为我们还没有进行视图和投影变换。在接下来的课程中,我们将引入相机(视图)矩阵和投影矩阵,来处理观察视角和3D到2D的投影,这将解决形状失真的问题,并让我们能够构建真正的3D场景。


总结
本节课中,我们一起学习了:
- 局部空间与世界空间的概念区别。
- 如何使用模型矩阵对物体进行变换(以平移为例)。
- 如何在C++程序中利用GLM库创建变换矩阵,并通过
glUniformMatrix4fv函数将其作为Uniform传递给着色器。 - 如何在顶点着色器中正确地使用4x4矩阵来变换顶点坐标(需要将
vec3转换为vec4)。
通过模型矩阵,我们获得了对物体位置、姿态和大小进行独立且灵活控制的能力,这是构建复杂3D场景的基石。希望你对OpenGL的学习感到越来越有趣!
024:投影矩阵与glm::perspective

概述
在本节课中,我们将要学习投影矩阵,特别是透视投影。我们将探讨如何解决当前渲染中正方形显示为矩形的问题,并理解透视投影的基本原理及其在OpenGL中的实现。

问题分析:为何正方形显示为矩形?
上一节我们介绍了模型矩阵,本节中我们来看看当前渲染存在的问题。仔细观察我们之前课程中运行的程序,其渲染结果是一个矩形。然而,如果我们查看顶点数据规范,会发现我们设置的是一个正方形。
问题的根源在于我们尚未应用透视投影。透视投影模拟了人眼观察物体时“近大远小”的视觉效果。
理解投影类型:透视与正交
以下是两种常见的投影类型:

- 透视投影:模拟人眼或相机观察世界的方式。物体距离观察者越远,看起来越小,最终会汇聚到一个点。例如,观察铁轨时,两条轨道会在远处交汇。
- 正交投影:保持物体的实际尺寸比例,无论距离远近。这种投影常用于工程制图或建筑设计,需要精确测量尺寸的场景。
在本教程中,我们将重点学习在实时图形应用中最常用的透视投影。
透视投影矩阵原理
透视投影的核心数学直觉是进行“透视除法”。对于一个点的坐标 (x, y, z, w),在裁剪空间之后,GPU会自动执行 (x/w, y/w, z/w) 的操作。
在透视投影矩阵中,一个关键作用是将原始的 z 值存储到变换后的 w 分量中。这样,在后续的透视除法步骤中,x 和 y 坐标就会被 z 值(即距离)所除,从而实现“近大远小”的效果。


透视投影矩阵 P 的结构大致如下(以列主序为例):
P = [
[scale_x, 0, 0, 0],
[0, scale_y, 0, 0],
[0, 0, -(far+near)/(far-near), -1],
[0, 0, -(2*far*near)/(far-near), 0]
]
其中,scale_x 和 scale_y 与视场角和宽高比相关。可以看到,该矩阵的最后一行的第三列是 -1,这确保了原始的 z 值被复制到了结果的 w 分量中。
实践:使用GLM库创建透视矩阵
好消息是,我们无需手动构建这个复杂的矩阵。GLM(OpenGL Mathematics)库提供了 glm::perspective 函数来帮助我们生成透视投影矩阵。
以下是创建透视投影矩阵的步骤:
-
在顶点着色器中添加投影矩阵uniform变量:
uniform mat4 u_projection; void main() { gl_Position = u_projection * u_model * vec4(a_position, 1.0); }注意矩阵乘法的顺序:通常是
投影矩阵 * 视图矩阵 * 模型矩阵 * 顶点位置。目前我们暂时没有视图矩阵。 -
在C++代码中生成并传递投影矩阵:
#include <glm/gtc/matrix_transform.hpp> // 定义投影参数 float fieldOfView = glm::radians(45.0f); // 视场角,转换为弧度 float aspectRatio = screenWidth / (float)screenHeight; // 宽高比 float nearPlane = 0.1f; // 近裁剪平面 float farPlane = 10.0f; // 远裁剪平面 // 创建透视投影矩阵 glm::mat4 projectionMatrix = glm::perspective(fieldOfView, aspectRatio, nearPlane, farPlane); // 获取着色器中uniform的位置并传递矩阵 GLint projLocation = glGetUniformLocation(shaderProgram, "u_projection"); glUniformMatrix4fv(projLocation, 1, GL_FALSE, glm::value_ptr(projectionMatrix));


参数解释:
fieldOfView (fov):垂直视场角,单位是弧度。值越大,能看到更广的范围(类似鱼眼镜头),但边缘可能产生畸变;值越小,视角越窄(类似望远镜)。45度是一个常用值。aspectRatio:渲染窗口的宽高比(宽度/高度)。这个值必须与窗口实际比例匹配,否则物体会被拉伸。nearPlane:近裁剪平面距离。比这个距离更近的物体不会被渲染。farPlane:远裁剪平面距离。比这个距离更远的物体不会被渲染。near和far的差值不宜过大,以免引起深度缓冲的精度问题(Z-fighting)。
坐标变换流程回顾
现在,让我们将透视投影整合到整个坐标变换流程中:
- 局部空间:物体的原始顶点坐标。
- 世界空间:通过模型矩阵 (
u_model) 将物体放置在世界中的特定位置、旋转和缩放。 - 观察空间:通过视图矩阵(下一节将介绍)模拟相机的位置和朝向。目前我们的代码中暂时省略了这一步。
- 裁剪空间:通过投影矩阵 (
u_projection) 将视锥体(一个平头截体)内的坐标变换到一个标准化立方体内。这个矩阵引入了透视效果。 - 屏幕空间:由GPU进行透视除法和视口变换,最终得到屏幕上的像素坐标。
可以将视图矩阵理解为相机本身(位置和角度),而投影矩阵则理解为相机的镜头(焦距、视野范围)。
常见问题与调试
在实现过程中,你可能会遇到物体没有随深度变化而改变大小的问题。请检查以下两点:
- 矩阵乘法顺序:确保在着色器中的乘法顺序是正确的。对于从局部坐标到裁剪空间的完整变换,典型顺序是:
投影矩阵 * 视图矩阵 * 模型矩阵 * 顶点位置。 - 顶点着色器输出:确保
gl_Position是一个vec4,并且其w分量不是固定的1.0。它应该来自前面矩阵变换的结果,因为w分量承载了深度信息用于透视除法。直接使用vec4(a_position, 1.0)会丢失透视效果。
总结
本节课中我们一起学习了:
- 透视投影的概念:它模拟了现实世界中物体“近大远小”的视觉效果,是游戏和模拟应用中最常用的投影方式。
- 透视投影矩阵的作用:其核心功能之一是将顶点深度信息(z值)传递到齐次坐标的
w分量,为后续的透视除法做准备。 - 使用GLM库:我们利用
glm::perspective函数方便地生成了透视投影矩阵,并了解了其参数(视场角、宽高比、近/远裁剪面)的含义。 - 整合到渲染管线:我们在顶点着色器中添加了投影矩阵uniform,并将其与模型矩阵一起作用于顶点,完成了从局部空间到裁剪空间的变换。

现在,你的程序应该能够正确显示深度变化带来的透视效果了。在下一节课中,我们将引入视图矩阵,学习如何控制“相机”在3D世界中的移动和观察。
025:OpenGL与ChatGPT 🧠
在本节课中,我们将探讨一个有趣的插曲:使用ChatGPT来辅助OpenGL编程。我们将看到AI工具如何生成代码片段,并讨论其在学习过程中的潜在作用与局限性。

上一节我们介绍了现代OpenGL的核心概念,本节中我们来看看AI工具如何介入编程学习过程。

不妨尝试一下。


是的。


好的。如果有人站出来批评我,我会用这个来回应。

虽然结果令人印象深刻,但我们最好还是继续我们的C++现代OpenGL系列教程。

以下是使用ChatGPT等AI工具时需要注意的几个要点:
- 辅助而非替代:AI可以生成代码片段,但理解其原理和调试仍需人工完成。
- 验证代码正确性:生成的代码可能存在错误或非最佳实践,必须进行测试和审查。
- 学习核心概念:依赖AI生成代码可能阻碍对OpenGL底层机制的理解。

总的来说,AI工具如ChatGPT可以作为编程的有趣辅助,快速生成代码框架或提供思路。然而,扎实掌握OpenGL的着色器(Shader)、缓冲区对象(VBO/VAO) 等核心概念,以及C++的编程能力,才是学习图形学的根本。
本节课中我们一起学习了如何辩证地看待AI在编程学习中的角色。工具虽强大,但深入理解与亲手实践更为重要。

期待在下一课中与你再见。
026:旋转矩阵(使用GLM)
在本节课中,我们将学习如何在OpenGL中使用旋转矩阵。我们将回顾图形渲染管线的基本概念,然后重点介绍如何利用GLM库实现绕Y轴的旋转,并最终让我们的图形对象动起来。
概述
上一节我们介绍了平移变换,本节中我们来看看旋转变换。旋转变换是计算机图形学中用于让物体绕特定轴旋转的核心操作。我们将学习其背后的矩阵原理,并动手实现一个可交互的旋转四边形。
图形渲染管线回顾

首先,让我们快速回顾一下图形渲染管线的基本流程。这对于理解变换发生的位置至关重要。
- 顶点规格化:我们首先在局部坐标系中定义一系列顶点。
- 顶点着色器:每个顶点被送入顶点着色器,在这里进行从局部坐标到世界坐标的变换。
- 世界空间:经过变换后,顶点被放置在世界坐标系中。

我们之前已经通过平移矩阵学习了如何移动顶点。其核心思想是创建一个矩阵,并与顶点向量进行矩阵乘法运算。
代码示例:平移矩阵
glm::mat4 translate = glm::translate(glm::mat4(1.0f), glm::vec3(offsetX, offsetY, offsetZ));
旋转变换原理
现在,让我们聚焦于旋转变换。旋转是通过特定的旋转矩阵来实现的,该矩阵定义了物体绕某个坐标轴(如X、Y、Z轴)旋转的角度。

例如,绕Y轴旋转θ角度的矩阵如下:
公式:绕Y轴旋转矩阵
[ cosθ, 0, sinθ, 0]
[ 0, 1, 0, 0]
[-sinθ, 0, cosθ, 0]
[ 0, 0, 0, 1]
理解这个矩阵的关键在于:当绕Y轴旋转时,物体上各点的Y坐标值保持不变,而X和Z坐标会根据三角函数关系发生变化。想象一个物体绕垂直的Y轴旋转,它只会水平转动,而不会上下移动。

幸运的是,我们无需手动记忆或构造这些矩阵。GLM库提供了现成的函数来生成旋转矩阵。
使用GLM实现旋转
GLM库中的glm::rotate函数可以方便地创建旋转矩阵。该函数需要三个参数:一个输入矩阵(通常是单位矩阵)、旋转角度(以弧度为单位)以及旋转轴(一个三维向量)。
代码示例:创建绕Y轴旋转45度的矩阵
glm::mat4 rotate = glm::rotate(glm::mat4(1.0f), glm::radians(45.0f), glm::vec3(0.0f, 1.0f, 0.0f));
其中,glm::vec3(0.0f, 1.0f, 0.0f) 表示Y轴。
代码集成与实践
接下来,我们将旋转功能集成到现有的OpenGL程序中。我们的程序结构主要包括初始化、顶点规格化、着色器创建和主渲染循环。
以下是实现旋转的关键步骤:
- 创建模型矩阵:我们将组合平移和旋转等变换到一个“模型矩阵”中。
- 更新变换顺序:在预绘制阶段,先应用平移,再应用旋转。注意矩阵乘法的顺序(从右向左应用)。
- 传递矩阵到着色器:将最终计算出的模型矩阵和投影矩阵一起传递给顶点着色器。
顶点着色器核心代码
gl_Position = projection * model * vec4(position, 1.0);
为了让旋转动起来,我们可以通过键盘输入来动态改变旋转角度。
以下是实现交互式旋转的逻辑:
- 定义一个全局变量(如
u_rotate)来存储当前旋转角度。 - 在输入处理函数中,监听左右方向键,按下时增加或减少
u_rotate的值。 - 在主循环中,使用更新的
u_rotate值重新计算旋转矩阵,并更新模型矩阵。
代码示例:键盘控制旋转
// 全局变量
float u_rotate = 0.0f;
// 输入处理中
if (key == SDL_SCANCODE_LEFT) {
u_rotate -= 1.0f; // 每帧减少1度
}
if (key == SDL_SCANCODE_RIGHT) {
u_rotate += 1.0f; // 每帧增加1度
}
// 预绘制阶段
glm::mat4 model = glm::translate(glm::mat4(1.0f), glm::vec3(0.0f, 0.0f, -5.0f));
model = glm::rotate(model, glm::radians(u_rotate), glm::vec3(0.0f, 1.0f, 0.0f));
运行程序后,你将看到一个四边形。通过左右方向键,可以控制它绕Y轴平滑地旋转。
总结
本节课中我们一起学习了OpenGL中的旋转变换。我们回顾了渲染管线中变换的应用点,理解了绕Y轴旋转矩阵的构成,并利用GLM库的 glm::rotate 函数轻松实现了旋转。最后,我们通过集成键盘输入,创建了一个可交互的旋转动画。

记住变换顺序的重要性,并时刻注意你的坐标系。尝试修改代码,让物体绕X轴或Z轴旋转,或者组合多种变换,观察不同的效果。
027:缩放矩阵 (glm::scale)
在本节课中,我们将要学习如何在OpenGL中使用缩放矩阵来改变物体的大小。我们将通过GLM库的glm::scale函数来实现这一操作。


上一节我们介绍了旋转矩阵,本节中我们来看看如何对物体进行缩放。
缩放矩阵基础
缩放是我们在顶点着色器中对每个顶点执行的特殊变换之一。我们通过一个矩阵乘法来改变每个顶点的坐标,从而放大或缩小整个物体。
缩放矩阵的结构如下:
| Sx 0 0 0 |
| 0 Sy 0 0 |
| 0 0 Sz 0 |
| 0 0 0 1 |
其中:
- Sx 是沿X轴的缩放因子。
- Sy 是沿Y轴的缩放因子。
- Sz 是沿Z轴的缩放因子。
当这些因子为1时,矩阵是单位矩阵,物体大小不变。当因子大于1时,物体会放大;当因子在0到1之间时,物体会缩小。
在代码中实现缩放

缩放操作通常在我们的主渲染循环中,更新模型矩阵时进行。我们将使用GLM库提供的glm::scale函数。
以下是实现缩放的关键步骤:
-
包含头文件:确保包含了GLM的头文件。
#include <glm/glm.hpp> #include <glm/gtc/matrix_transform.hpp> -
定义缩放变量:创建一个变量来控制缩放比例。
float g_uScale = 0.5f; // 缩放因子,0.5表示缩小一半 -
应用缩放变换:在更新模型矩阵时,在平移和旋转之后应用缩放。
// 初始化模型矩阵为单位矩阵 glm::mat4 model = glm::mat4(1.0f); // 先平移物体 model = glm::translate(model, glm::vec3(0.0f, 0.0f, -2.0f)); // 然后旋转物体 model = glm::rotate(model, angle, glm::vec3(0.0f, 1.0f, 0.0f)); // 最后缩放物体 model = glm::scale(model, glm::vec3(g_uScale, g_uScale, g_uScale)); -
传递矩阵到着色器:将最终计算出的模型矩阵通过uniform变量传递给顶点着色器。
glUniformMatrix4fv(u_model_matrix_location, 1, GL_FALSE, glm::value_ptr(model));
注意变换顺序:代码中先平移、再旋转、最后缩放的顺序很重要。这个顺序意味着缩放是在物体的局部坐标系中进行的。如果改变顺序,会得到不同的视觉效果。
运行效果
编译并运行程序后,你会看到物体的大小根据g_uScale的值发生了变化。例如,当g_uScale设置为0.5时,物体会缩小到原来的一半。

本节课中我们一起学习了OpenGL中的缩放变换。我们了解了缩放矩阵的构成,并通过GLM库的glm::scale函数在代码中实现了对物体的缩放操作。同时,我们再次强调了变换顺序的重要性。掌握了平移、旋转和缩放这三种基本变换,你就拥有了在3D空间中操纵物体的核心工具。
028:矩阵变换顺序的重要性

在本节课中,我们将要学习OpenGL中矩阵变换顺序的重要性。我们将通过一个简单的实验和代码演示来理解,为什么先旋转后平移与先平移后旋转会产生截然不同的结果。
概述
上一节我们介绍了基本的矩阵变换。本节中我们来看看这些变换的顺序如何影响最终结果。矩阵乘法不满足交换律,因此变换的顺序至关重要。
实验:顺序如何影响位置
为了直观理解,我们可以做一个简单的物理实验。以下是两个步骤相同但顺序不同的实验:
实验一
- 向前走5步。
- 向左转90度。
- 再向前走5步。
执行后,你的最终位置是 (-5, 5)。
实验二
- 向左转90度。
- 向前走5步。
- 再向前走5步。
执行后,你的最终位置是 (-10, 0)。

可以看到,尽管步骤相同,但顺序不同导致了完全不同的终点。这个原理同样适用于计算机图形学中的矩阵变换。
代码演示:变换顺序的影响

现在,让我们在OpenGL代码中观察这一现象。我们有一个基本的渲染管线,并在绘制前对模型应用一系列变换。
以下是初始的变换顺序(平移 -> 旋转 -> 缩放):
// 初始顺序:先平移,后旋转
glm::mat4 model = glm::mat4(1.0f); // 单位矩阵
model = glm::translate(model, glm::vec3(0.0f, 0.0f, -2.0f)); // 平移
model = glm::rotate(model, glm::radians(-65.0f), glm::vec3(0.0f, 1.0f, 0.0f)); // 旋转
model = glm::scale(model, glm::vec3(0.5f, 0.5f, 0.5f)); // 缩放
运行此代码,物体会以其自身轴心进行旋转。



接下来,我们交换平移和旋转的顺序:
// 改变顺序:先旋转,后平移
glm::mat4 model = glm::mat4(1.0f); // 单位矩阵
model = glm::rotate(model, glm::radians(-65.0f), glm::vec3(0.0f, 1.0f, 0.0f)); // 旋转
model = glm::translate(model, glm::vec3(0.0f, 0.0f, -2.0f)); // 平移
model = glm::scale(model, glm::vec3(0.5f, 0.5f, 0.5f)); // 缩放
运行修改后的代码,物体会围绕世界坐标系原点进行“公转”,就像一个手臂末端被甩动一样,而不是绕自身中心“自转”。这清晰地展示了变换顺序带来的巨大差异。
理解原理:矩阵串联
理解变换顺序的另一种方式是将其视为矩阵的串联(乘法)。每个变换操作都对应一个4x4矩阵。
当我们按顺序应用变换时,实际上是在进行矩阵乘法。例如:
- 顺序A(先平移T,后旋转R):最终变换矩阵是
M = R * T。 - 顺序B(先旋转R,后平移T):最终变换矩阵是
M = T * R。
由于矩阵乘法不满足交换律(A * B ≠ B * A),因此 R * T 与 T * R 的结果不同。你可以将变换顺序想象成一个字符串,如“TRS”或“RTS”,不同的字符串代表不同的最终效果。
核心概念总结
记住以下核心公式,它代表了模型变换的典型串联顺序:
最终模型矩阵 = 投影矩阵 * 视图矩阵 * 模型矩阵
而模型矩阵本身又是多个变换的串联:
模型矩阵 = 平移矩阵 * 旋转矩阵 * 缩放矩阵
(注意:这是常见的“TRS”顺序,但根据需求顺序可以变化)
在代码中,这体现为连续的矩阵乘法:
glm::mat4 modelMatrix = translationMatrix * rotationMatrix * scaleMatrix;
// 然后传入着色器:MVP = projection * view * modelMatrix;
实用技巧
对于初学者,以下方法有助于理解:
- 物理模拟:像开头的实验一样,用身体或手势(例如,拳头作为物体,手臂作为平移)来模拟变换。
- 记住常见模式:
平移(T) -> 旋转(R) -> 缩放(S)是让物体绕自身轴心变换的常见顺序。若想让它绕世界原点旋转(如行星),则使用旋转(R) -> 平移(T) -> 缩放(S)。 - 从单位矩阵开始:总是从一个单位矩阵开始构建你的模型矩阵。

总结

本节课中我们一起学习了OpenGL中矩阵变换顺序的核心重要性。我们通过一个行走实验直观理解了顺序不同导致结果不同,并在代码中演示了交换平移和旋转顺序如何让物体从“自转”变为“公转”。关键点在于矩阵乘法的不可交换性。理解变换顺序是掌握3D图形编程的基础,请务必动手实验以加深印象。
029:万向节锁理论


在本节课中,我们将继续探讨变换,但会关注一个在使用欧拉角时需要注意的重要问题——万向节锁。我们将了解它的现象、成因以及可能的解决方案。

上一节我们强调了矩阵乘法顺序的重要性。本节中,我们来看看旋转操作中一个特定的问题。
当同时围绕X、Y和Z三个轴进行旋转时,可能会失去一个旋转自由度,这种现象被称为万向节锁。

万向节锁的基本概念是,如果尝试同时绕X、Y和Z轴旋转,会失去一个自由度。可以通过观察一个称为万向节支架的工具来理解这一点。它用最外层的环和最内层的环代表每个可旋转的自由度轴。当这些轴对齐时,它们最终会以一个更少的自由度移动,因为轴实际上重合了。通过调整X轴和Y轴,可能实际上执行的是相同的旋转。这在3D图形中可能导致一些有趣的行为。
以下是另一个万向节支架的动画示例,用以说明此现象。
😊,这里有三个万向节在移动。你会注意到,当两个万向节对齐时,就会失去一个自由度。需要仔细观察或稍微放慢速度才能发现。在旋转接近尾声、它们对齐的地方,行为会变得有些特别。
在现实中对车辆等物体使用旋转时,这个问题曾导致一些著名的事故,你可以在维基百科页面上阅读到相关历史。
那么,如何解决万向节锁问题呢?在游戏开发中,有时我们并不担心它,因为可能只围绕一个特定的轴运动,这时可以继续使用欧拉角。
但人们也会采用其他方法,例如添加第四个万向节或第四个维度来旋转。这涉及到使用不同的数学方法,比如四元数。对于那些有游戏引擎使用经验、而非从零构建所有功能的人来说,四元数有助于避免此问题。如果两个轴对齐,你仍然会失去一个自由度,但你始终至少有三个轴可以绕其旋转。这就是基本思路。
本节课的目的,是让你意识到这个问题,并知道在3D图形中有方法可以缓解它。我们仍然可以绕X、Y和Z轴旋转。事实上,我鼓励你尝试这样的例子。但你应该了解“万向节锁”这个术语,它会在面试中出现,是你必须知道的知识点:有时我们使用的数学方法存在局限性,可能需要换用不同的系统。
因此,在本系列或另一个系列中,我最终会更多地讨论四元数,以便我们学习它们。但现在,请知道四元数是解决此问题的工具之一,并且你现在已经了解了这个术语。😊。
好了,各位。以上就是本次理论讲解视频的全部内容。希望它对你有用。再次说明,有很多优秀的资源可以演示这个现象。你也可以自己在例如Blender 3D中尝试同时绕所有三个轴旋转,来观察发生的问题。
好了,说到这里,我们很快就会回到编码实践。下节课再见。😊。
本节课中我们一起学习了万向节锁的概念。我们了解到,当使用欧拉角进行三维旋转时,特定情况下(两个旋转轴对齐)会导致失去一个旋转自由度。我们探讨了其现象,并通过万向节支架的比喻理解了其原理。最后,我们简要提及了使用四元数作为避免此问题的一种高级解决方案。理解万向节锁是掌握三维旋转和动画的重要一步。
030:快速修复与回顾 🎬

在本节课中,我们将回顾上一节关于矩阵变换的内容,并修复一个在代码中常见的小错误。我们将通过运行现有程序来理解其工作原理,并简要介绍即将添加的相机(视图矩阵)概念。
代码回顾与程序结构

上一节我们介绍了矩阵变换及其顺序的重要性。现在,让我们回顾一下当前项目的整体结构。
我们的项目包含一个着色器程序(由顶点着色器和片段着色器组成)、主程序文件 main.cpp,以及用于管理绘制状态的顶点数组对象(VAO)。
以下是程序初始化的核心步骤:
- 初始化SDL窗口库和OpenGL(通过GLAD)。
- 设置顶点数据,包括位置和颜色。
- 创建并绑定顶点数组对象(VAO)和顶点缓冲对象(VBO)。
- 设置顶点属性指针,告知OpenGL如何解析顶点数据。
修复一个常见的绑定错误
在绘制循环中,我们通常不需要在每次绘制时重新绑定顶点缓冲对象(VBO)。绑定VAO后,它会自动管理其关联的VBO状态。
以下是需要删除的冗余代码行:
// 在绘制函数中,以下这行是不必要的:
glBindBuffer(GL_ARRAY_BUFFER, gVbo);
删除此行后,程序功能保持不变,但代码更加简洁高效。
变换流程详解

现在,我们来详细看看物体是如何从本地坐标变换到世界坐标的。
这个过程通过模型矩阵(Model Matrix)实现。我们首先在本地空间定义一个四边形,然后通过模型矩阵对其进行平移、旋转或缩放,从而将其放置到世界空间中。


在代码中,我们这样应用模型矩阵:
// 1. 创建模型矩阵(例如,进行缩放)
model = glm::scale(model, glm::vec3(g_uScale));
// 2. 将矩阵传递给着色器中的uniform变量
glUniformMatrix4fv(gModelMatrixLocation, 1, GL_FALSE, glm::value_ptr(model));
着色器中的矩阵应用
这些变换最终在顶点着色器中执行。顶点着色器负责计算每个顶点的最终位置。
在顶点着色器中,我们这样使用模型矩阵:
// GLSL 顶点着色器代码
uniform mat4 model;
void main() {
gl_Position = model * vec4(aPos, 1.0);
}
重要提示:确保着色器中的uniform变量名称与C++代码中查询的名称完全一致,并且该变量确实在着色器中被使用。否则,GLSL编译器可能会将其优化掉,导致获取位置失败。
展望:相机与视图矩阵
目前,我们只应用了模型矩阵将物体置于世界空间。为了获得真实的3D透视效果,我们还需要两个关键矩阵:
- 视图矩阵(View Matrix):模拟相机的位置和观察方向。
- 投影矩阵(Projection Matrix):模拟相机的镜头,将3D坐标投影到2D屏幕上,并产生“近大远小”的透视效果。
在接下来的课程中,我们将重点介绍如何创建和集成视图矩阵,让我们的场景能够通过“相机”进行观察。

本节课中,我们一起回顾了OpenGL程序的绘制流程和矩阵变换基础,并修正了一个关于VBO绑定的小错误。我们明确了模型矩阵的作用,并预览了为场景添加相机(视图矩阵)的下一步方向。理解这些基础是构建复杂3D图形的关键。
031:视图矩阵理论 🎥
在本节课中,我们将学习OpenGL中一个核心概念——视图矩阵。我们将探讨为什么需要它,它在图形渲染管线中的作用,以及如何使用GLM库的lookAt函数来构建它。理解视图矩阵是实现一个可移动“相机”的关键,它能让我们在3D场景中自由穿梭,而不是仅仅移动物体。
当前程序的演示与问题
上一节我们介绍了模型和投影变换。本节中,我们来看看当前程序的一个现象,并引出视图矩阵的必要性。
这是我们的项目。左侧是源代码,主函数和图形管线着色器都在这里。
当我运行这个程序,并按下前进或后退键时,物体似乎在靠近或远离我们。但请记住,我们实际上是在移动物体本身。顶点着色器负责变换顶点,代码如下:
gl_Position = projection * model * vec4(aPos, 1.0);

我们通过模型矩阵(model)变换物体,再通过投影矩阵(projection)处理透视。当我旋转物体时,感觉就像在转动相机观察它。
然而,这并非真正的相机。我们只是在变换物体。如果我们希望像在游戏中一样,让观察者(相机)在场景中移动,观察静止的物体,就需要引入视图变换。
视图矩阵在管线中的位置
现在,让我们明确视图矩阵在整个渲染管线中的位置。
想象我们有一些顶点,它们最初位于局部空间。
我们应用模型矩阵,将其变换到世界空间。此时,物体可能已经过旋转和平移,不再位于世界原点。
接下来,我们将应用本节课的核心——视图矩阵。这相当于在场景中放置一个相机,并从相机的视角来观察世界。应用视图矩阵后,坐标就从世界空间转换到了视图空间(或称相机空间)。
管线流程总结:
局部空间 -> (模型矩阵) -> 世界空间 -> (视图矩阵) -> 视图空间 -> (投影矩阵) -> 裁剪空间
理解相机:位置、朝向与上方向
那么,如何定义这个“相机”呢?我们可以从现实世界的摄影来类比。
一个相机由三个关键属性定义:
- 位置:相机在三维世界中的坐标点。
- 观察方向:相机镜头对准的焦点或方向。
- 上方向:相机顶部指向的方向。这通常被定义为世界空间中的“向上”向量(例如,正Y轴),但它会随着相机的倾斜而改变。
在航空领域,这类似于俯仰、偏航和翻滚。为了简化,GLM库提供了一个强大的工具来根据这三个属性构建视图矩阵。
GLM的lookAt函数
以下是构建视图矩阵的核心工具。GLM库中的glm::lookAt函数可以为我们完成所有复杂的数学计算。
该函数需要三个参数来定义相机:
eye:相机在世界空间中的位置。center:相机所观察的目标点。观察方向就是从eye指向center的向量。up:世界空间中的“向上”向量(通常是(0, 1, 0))。
函数原型如下:
glm::mat4 viewMatrix = glm::lookAt(glm::vec3(eye),
glm::vec3(center),
glm::vec3(up));
这个函数会返回一个4x4的视图矩阵。当我们将这个矩阵乘以世界空间中的顶点坐标时,就能得到从相机视角看到的坐标。
上方向向量的重要性

为什么需要明确指定“上”方向?这确保了相机的正确朝向。
想象你的手机相机。当你水平握持时,“上”方向是屏幕的顶部。如果你把手机侧过来(竖屏拍摄),“上”方向就变成了手机的侧边。在3D图形中,即使相机倾斜了(例如飞机爬升时),我们仍然需要知道“哪个方向是上”,以便正确计算围绕自身轴(如偏航)的旋转。
lookAt函数内部会利用up向量,结合eye和center计算出的前向向量,通过叉乘运算生成一个相互垂直的右向量和上向量,从而构建出完整的、正交的相机坐标系矩阵。
总结与练习
本节课中我们一起学习了视图矩阵的理论基础。我们了解到,视图矩阵将顶点从世界空间转换到以相机为原点的视图空间,是实现3D相机控制的核心。GLM库的lookAt函数通过指定相机位置、观察点和上方向,简化了这一矩阵的构建过程。
作为练习,我强烈建议你尝试在不依赖glm::lookAt的情况下,手动推导并编写自己的视图矩阵生成函数。思考如何通过eye、center和up这三个向量,计算出相机的前向、右向和实际上向向量,并将它们组合成一个4x4变换矩阵。这个过程能极大地加深你对3D变换和坐标系的理解。
在下一节中,我们将把理论付诸实践,在代码中集成视图矩阵,实现一个真正的可移动相机。
032:使用glm::lookat构建视图矩阵(并移动摄像机)
概述
在本节课中,我们将继续学习现代OpenGL系列。我们将从上一节课结束的地方开始,着手在代码中构建一个实际的摄像机。我们将利用GLM库中的 glm::lookat 函数来构建摄像机矩阵,并开始对代码进行一些抽象和重构,以便更好地管理场景中的组件。

回顾:视图矩阵与摄像机
在上一节中,我们介绍了视图矩阵的概念,它负责将世界坐标系中的顶点转换到摄像机坐标系。本节中,我们将看看如何具体实现一个摄像机类来生成这个矩阵。
视图矩阵的核心是确定三个向量:摄像机位置(eye)、观察目标(center)和世界空间的上方向(up)。glm::lookat 函数正是利用这三个参数来构建矩阵的。其基本形式如下:
glm::mat4 viewMatrix = glm::lookAt(eye, center, up);
创建摄像机类
为了封装摄像机功能,我们需要创建一个独立的类。以下是创建摄像机类的步骤。

首先,我们创建头文件 camera.hpp,定义摄像机类的基本结构。
// camera.hpp
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp> // 包含 lookAt 函数
class Camera {
public:
// 构造函数,设置默认值
Camera();
// 获取视图矩阵
glm::mat4 getViewMatrix() const;
// 移动摄像机的基本函数
void moveForward(float speed);
void moveBackward(float speed);
void moveLeft(float speed);
void moveRight(float speed);
private:
glm::vec3 m_eye; // 摄像机位置
glm::vec3 m_viewDirection; // 观察方向(指向目标)
glm::vec3 m_upVector; // 世界空间的上方向
};


接下来,我们实现源文件 camera.cpp,为这些函数提供具体实现。
// camera.cpp
#include "camera.hpp"
// 默认构造函数
Camera::Camera() {
m_eye = glm::vec3(0.0f, 0.0f, 0.0f); // 初始位置在原点
m_upVector = glm::vec3(0.0f, 1.0f, 0.0f); // 假设Y轴向上
m_viewDirection = glm::vec3(0.0f, 0.0f, -1.0f); // 初始看向负Z轴
}
// 获取视图矩阵
glm::mat4 Camera::getViewMatrix() const {
// 计算观察目标:当前位置 + 观察方向
glm::vec3 center = m_eye + m_viewDirection;
return glm::lookAt(m_eye, center, m_upVector);
}
// 向前移动(沿观察方向)
void Camera::moveForward(float speed) {
m_eye += m_viewDirection * speed;
}
// 向后移动
void Camera::moveBackward(float speed) {
m_eye -= m_viewDirection * speed;
}
// 向左移动(需要计算右向量)
void Camera::moveLeft(float speed) {
glm::vec3 right = glm::normalize(glm::cross(m_viewDirection, m_upVector));
m_eye -= right * speed;
}
// 向右移动
void Camera::moveRight(float speed) {
glm::vec3 right = glm::normalize(glm::cross(m_viewDirection, m_upVector));
m_eye += right * speed;
}
在场景中使用摄像机
创建好摄像机类后,我们需要将其集成到主渲染循环中。这涉及更新着色器uniform变量和处理用户输入。
首先,在主程序中包含摄像机头文件并创建一个全局摄像机实例。
// main.cpp
#include "camera.hpp"
// ... 其他包含
Camera g_camera; // 全局摄像机实例
接着,在绘制前(preDraw 函数中),我们需要获取摄像机的视图矩阵,并将其传递给着色器。
// 在绘制前更新uniform
void preDraw() {
// ... 其他矩阵计算(如模型、投影矩阵)
// 获取摄像机的视图矩阵
glm::mat4 viewMatrix = g_camera.getViewMatrix();
// 找到着色器中视图矩阵uniform的位置并设置其值
GLint viewMatrixLocation = glGetUniformLocation(shaderProgram, "viewMatrix");
if (viewMatrixLocation >= 0) {
glUniformMatrix4fv(viewMatrixLocation, 1, GL_FALSE, &viewMatrix[0][0]);
} else {
std::cerr << "Could not find uniform: viewMatrix" << std::endl;
}
}
然后,我们需要更新顶点着色器,将视图矩阵纳入顶点变换管线。顶点变换的顺序是:局部坐标 -> 模型矩阵 -> 世界坐标 -> 视图矩阵 -> 摄像机坐标 -> 投影矩阵 -> 裁剪坐标。
// vertex_shader.glsl
#version 330 core

layout (location = 0) in vec3 position;
uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projectionMatrix;
void main() {
// 正确的乘法顺序:投影 * 视图 * 模型 * 顶点
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
}
处理用户输入以移动摄像机
最后,我们需要将键盘输入映射到摄像机的移动函数上。以下是处理箭头键输入的基本逻辑。
// 在主循环或事件处理函数中
void handleInput() {
// 假设有方法获取按键状态,这里使用伪代码
float moveSpeed = 0.1f;
if (isKeyPressed(GLFW_KEY_UP)) {
g_camera.moveForward(moveSpeed);
}
if (isKeyPressed(GLFW_KEY_DOWN)) {
g_camera.moveBackward(moveSpeed);
}
if (isKeyPressed(GLFW_KEY_LEFT)) {
g_camera.moveLeft(moveSpeed);
}
if (isKeyPressed(GLFW_KEY_RIGHT)) {
g_camera.moveRight(moveSpeed);
}
}
当前实现的局限性
通过以上步骤,我们实现了一个可以前后左右移动的基础摄像机。然而,这个实现存在一些局限性。

- 移动方向固定:目前的
moveForward和moveBackward是沿着初始的观察方向(负Z轴)移动,而不是根据摄像机当前的朝向。这更像是一个2D的“缩放”效果,而非第一人称的移动。 - 缺少旋转:摄像机无法左右看(偏航,yaw)或上下看(俯仰,pitch)。要实现第一人称视角,需要根据鼠标或键盘输入来更新
m_viewDirection向量。 - 未使用增量时间:移动速度
moveSpeed是固定的,帧率变化会导致移动速度不一致。最佳实践是乘以帧间时间差(delta time)。
总结
本节课中我们一起学习了如何使用 glm::lookat 函数构建视图矩阵,并创建了一个基础的摄像机类。我们实现了摄像机的前后左右移动,并将其集成到了渲染管线中。虽然当前的摄像机还比较简单,但它为场景提供了动态的观察视角,是构建交互式3D应用的重要一步。

在下一节课中,我们将探讨如何完善这个摄像机,实现基于鼠标的第一人称视角旋转,让移动方向与摄像机朝向真正关联起来。
033:第一人称鼠标视角相机 🎮
在本节课中,我们将继续完善我们的相机系统,实现一个第一人称视角的鼠标观察功能。我们将学习如何通过鼠标移动来旋转相机的观察方向,并修正上一节中相机移动的逻辑,使其能根据观察方向正确前进后退。
课程回顾
上一节我们介绍了相机的基本抽象,并实现了通过键盘按键(如上下箭头或WASD)来前后左右移动相机位置。我们创建了一个Camera类,它使用glm::lookAt函数来生成视图矩阵,该矩阵定义了从“眼睛”位置观察场景的视角。
然而,之前的移动逻辑仅简单地更新了相机位置的Z坐标,这并不符合第一人称相机的直觉。当我们转动视角后,前进的方向应该是我们“看”的方向,而不仅仅是世界坐标的Z轴负方向。本节我们将解决这个问题。
实现鼠标观察功能
为了实现用鼠标控制视角旋转,我们需要做以下几件事:
- 捕获SDL中的鼠标移动事件。
- 计算鼠标在相邻两帧之间的移动差值(Delta)。
- 根据这个差值来旋转相机的
viewDirection向量。


以下是具体步骤:
1. 在Camera类中添加鼠标观察函数
首先,我们需要在Camera类的头文件和源文件中声明并定义MouseLook函数。
代码示例:在 Camera.h 中添加
class Camera {
public:
// ... 其他现有函数 ...
void MouseLook(int mouseX, int mouseY);
private:
// ... 其他现有成员变量 ...
glm::vec2 mOldMousePosition; // 用于存储上一帧的鼠标位置
bool mFirstMouseLook = true; // 首次调用的标志
};
代码示例:在 Camera.cpp 中实现
#include <glm/gtx/rotate_vector.hpp> // 用于旋转向量
void Camera::MouseLook(int mouseX, int mouseY) {
// 将当前鼠标位置转换为vec2
glm::vec2 newMousePosition(mouseX, mouseY);
// 如果是第一次调用,只记录位置,不进行旋转
if(mFirstMouseLook) {
mOldMousePosition = newMousePosition;
mFirstMouseLook = false;
return;
}
// 计算鼠标位置的差值(Delta)
glm::vec2 mouseDelta = newMousePosition - mOldMousePosition;
// 根据X方向的差值,绕Y轴(上向量)旋转观察方向
// 将像素差值转换为弧度。这里的0.01f是一个灵敏度系数,可以调整。
float rotateAngle = mouseDelta.x * 0.01f;
// 使用GLM的rotate函数旋转viewDirection向量
// 参数:待旋转的向量,旋转弧度,旋转所绕的轴(这里是世界空间的上向量Y轴)
mViewDirection = glm::rotate(mViewDirection, rotateAngle, glm::vec3(0.0f, 1.0f, 0.0f));
// 更新旧鼠标位置,为下一帧做准备
mOldMousePosition = newMousePosition;
}
2. 在SDL中捕获并处理鼠标事件

接下来,我们需要在主循环的输入处理部分捕获鼠标移动事件,并调用相机的MouseLook函数。
代码示例:在主程序输入处理部分
// 在程序初始化部分(主循环之前),将鼠标锁定到窗口中心并隐藏
SDL_WarpMouseInWindow(gGraphicsApplicationWindow, SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2);
SDL_SetRelativeMouseMode(SDL_TRUE);

// 在主循环的事件处理部分
while(SDL_PollEvent(&event)) {
if(event.type == SDL_MOUSEMOTION) {
// 获取鼠标的相对移动量
int mouseX = event.motion.xrel;
int mouseY = event.motion.yrel;
// 调用相机的鼠标观察函数
gCamera.MouseLook(mouseX, mouseY);
}
// ... 处理键盘等其他事件 ...
}
3. 修正基于观察方向的移动逻辑
上一节中,MoveForward和MoveBackward函数只是简单地修改了相机位置的Z分量。现在我们需要根据动态变化的mViewDirection来移动。
代码示例:修正后的相机移动函数
void Camera::MoveForward(float speed) {
// 沿着观察方向向前移动
mEyePosition += mViewDirection * speed;
}
void Camera::MoveBackward(float speed) {
// 沿着观察方向的反方向向后移动
mEyePosition -= mViewDirection * speed;
}
同时,我们需要更新GetViewMatrix函数中的lookAt调用,确保“观察目标点”是“眼睛位置”加上“观察方向”。
代码示例:更新视图矩阵计算
glm::mat4 Camera::GetViewMatrix() const {
// 观察目标点是眼睛位置加上观察方向
return glm::lookAt(mEyePosition, mEyePosition + mViewDirection, mUpVector);
}
核心概念与公式
本节涉及的核心变换是向量旋转。我们使用以下公式(由GLM库实现)来根据鼠标输入旋转观察方向向量:
向量旋转公式(绕任意轴)
对于一个向量 v,绕单位轴 k 旋转角度 θ,其旋转后的向量 v' 可以通过罗德里格斯旋转公式计算:
v' = v cosθ + (k × v) sinθ + k (k · v)(1 - cosθ)
在我们的代码中,这被简化为一次函数调用:
mViewDirection = glm::rotate(mViewDirection, angleInRadians, axisOfRotation);


总结与挑战

本节课中,我们一起学习了如何构建一个第一人称鼠标观察相机。我们实现了:
- 鼠标视角控制:通过捕获鼠标移动差值,旋转相机的观察方向。
- 正确的方向性移动:使相机的前后移动基于当前的观察方向,而非固定的坐标轴。
- 视图矩阵的持续更新:确保
lookAt矩阵使用最新的眼睛位置和观察方向。
你现在拥有了一个可以环顾四周并朝看的方向移动的基础第一人称相机。
给你的挑战:
- 实现左右平移(Strafe):尝试实现
MoveLeft和MoveRight函数。提示:你需要计算与观察方向和世界向上向量都垂直的“右向量”(Right Vector),可以使用叉积glm::cross(mViewDirection, mUpVector)来获得。 - 实现上下俯仰(Pitch):修改
MouseLook函数,使其也能根据鼠标Y移动来绕“右向量”旋转观察方向,实现抬头和低头。注意需要限制俯仰角度,避免万向节死锁或视角翻转。 - 改进鼠标控制:尝试不同的鼠标灵敏度设置,或者实现鼠标光标隐藏与锁定,以获得更流畅的体验。

通过完成这些挑战,你将能完全掌控你的3D相机,为制作漫步场景或第一人称游戏打下坚实基础。继续实验,享受编程的乐趣!
034:第一人称摄像机的左右移动 🎮
在本节课中,我们将完成第一人称摄像机的实现,重点学习如何实现摄像机的左右平移。我们将运用之前讨论过的数学概念,如叉乘,来计算正确的移动方向向量。这个构建新矩阵(例如通过 lookAt 函数构建视图矩阵)的思想,在计算机图形学的其他领域也非常有用。
上一节我们实现了通过鼠标控制视角旋转以及前后移动。本节中,我们来看看如何实现摄像机的左右平移。
如果只是简单地更新摄像机位置(eye position)的X坐标,会导致移动方向不符合当前的视角方向。例如,当你面朝前方时按右键,你希望向右平移;但当你旋转视角后,再按右键,你仍然希望沿着你“右侧”的方向移动,而不是世界坐标的X轴正方向。
因此,我们需要计算一个“右向量”(right vector),它始终指向摄像机自身的右侧。然后,沿着这个向量的方向更新摄像机位置,即可实现正确的左右平移。
那么,如何计算这个“右向量”呢?我们已知两个向量:摄像机的“前向向量”(view direction)和世界的“上向量”(up vector,通常是 (0, 1, 0))。根据右手坐标系规则,对这两个向量进行叉乘运算,就可以得到垂直于它们的新向量,即“右向量”。
计算公式如下(使用GLM数学库):
glm::vec3 right = glm::cross(viewDirection, upVector);
注意叉乘的顺序:glm::cross(a, b) 的结果向量垂直于a和b构成的平面,方向遵循右手定则(四指从a弯向b,拇指方向即为结果方向)。为了得到正确的“右向量”,我们通常使用 glm::cross(viewDirection, upVector)。
以下是实现左右移动的关键步骤:
- 计算右向量:在每一帧,根据当前视角方向(
viewDirection)和世界向上向量(up)计算右向量。 - 更新摄像机位置:
- 按下“右移”键时,摄像机位置
eye加上rightVector * movementSpeed。 - 按下“左移”键时,摄像机位置
eye减去rightVector * movementSpeed。
- 按下“右移”键时,摄像机位置
- 重建视图矩阵:更新
eye位置后,使用glm::lookAt(eye, eye + viewDirection, up)重新计算视图矩阵。
通过这种方式,无论摄像机朝向何方,左右移动键都能让摄像机沿着其自身的左右方向平滑平移,从而实现了完整的第一人称移动控制(前、后、左、右、视角旋转)。
本节课中我们一起学习了如何为第一人称摄像机实现左右平移功能。核心在于利用叉乘计算基于当前视角的“右向量”,并沿此向量移动摄像机。至此,我们已构建了一个具备基础自由移动和视角控制功能的摄像机系统。


你可以尝试进一步的挑战,例如:
- 将移动键映射为常见的WASD控制模式。
- 增加摄像机的升降功能(例如使用空格键上升)。
- 实现视角的上下倾斜(俯仰角),完成全自由度的视角控制。

希望你能从中获得乐趣,并继续探索计算机图形的精彩世界。我们下节课再见!
035:GL_FLOAT枚举与GLfloat类型错误修复



在本节课中,我们将对代码进行一些修正和改进,主要涉及修复一个关于OpenGL数据类型使用的细微错误,并添加一个通过键盘退出程序的功能。这些改动旨在提升代码质量,为后续添加更多功能做好准备。
上一节我们介绍了OpenGL渲染管线的基本流程,本节中我们来看看如何修正代码中的一些潜在问题。
修正数据类型错误

在查看顶点规范设置代码时,我发现了一个细微的错误。一些OpenGL函数调用中,我错误地使用了GL_FLOAT枚举,而实际上应该使用GLfloat类型。
以下是需要修正的核心代码部分:

// 错误示例:使用了GL_FLOAT枚举(一个整数值)来指定数据类型大小
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices.data(), GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GL_FLOAT), (void*)0);
GL_FLOAT是一个枚举常量,其值通常为整数(例如0x1406),它用于在函数参数中指定数据类型。而GLfloat是一个类型别名,它保证了在目标平台上是一个32位的浮点数(通常是4字节)。在需要传递类型大小或进行指针运算时,应使用GLfloat。





为了更清晰地理解,以下是OpenGL中类型与枚举的区分:


- 枚举(Enum):通常全大写(如
GL_FLOAT,GL_STATIC_DRAW),用于指定函数参数选项。 - 类型(Type):混合大小写(如
GLfloat,GLsizei),用于变量声明、sizeof运算和指针偏移计算。
因此,正确的用法应该是:




// 正确示例:使用GLfloat类型来计算大小和偏移
glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(GLfloat), vertices.data(), GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (void*)0);
虽然在某些架构上GL_FLOAT的整数值可能恰好等于4(sizeof(float)),但这并非保证。使用GLfloat可以确保代码在不同平台上的可移植性。


查找并修正其他类似错误



我们需要在整个代码库中搜索并修正类似的错误。以下是需要检查的其他常见函数:
glUniformMatrix4fv:其transpose参数应使用GLboolean类型(或直接使用GL_FALSE枚举),但确保类型匹配。- 指针偏移计算:在任何需要计算字节偏移的地方,都应使用
sizeof(GLfloat)、sizeof(GLuint)等类型大小,而不是枚举值。
通过仔细审查代码并参考OpenGL官方文档,我们可以确保所有数据类型的使用都是准确和一致的。


添加程序退出功能

除了修正数据类型,我们还将添加一个实用的功能:通过按下ESC键来退出程序。这提升了程序的交互性。
以下是实现该功能的步骤:

- 在程序全局状态中,定义一个控制主循环的布尔变量(例如
gQuit)。 - 在主循环中,使用SDL库的函数(如
SDL_GetKeyboardState)检测键盘状态。 - 检查
ESC键(扫描码SDL_SCANCODE_ESCAPE)是否被按下。 - 如果按下,则将
gQuit变量设置为true,从而退出主渲染循环。
核心代码逻辑如下:


// 全局变量
bool gQuit = false;
// 在主循环中
while (!gQuit) {
// ... 处理其他事件 ...
// 检查键盘状态
const Uint8* state = SDL_GetKeyboardState(NULL);
if (state[SDL_SCANCODE_ESCAPE]) {
gQuit = true; // 按下ESC,设置退出标志
}
// ... 渲染代码 ...
}
添加此功能后,用户可以通过按下键盘上的ESC键来优雅地关闭应用程序窗口。


测试修正结果
完成上述修正和添加功能后,编译并运行程序。你应该看到与之前相同的渲染输出(旋转的几何图形)。现在,尝试按下ESC键,程序应该会立即退出。这验证了我们的代码修正没有引入新的错误,并且新功能工作正常。



本节课中我们一起学习了两个重要的内容:一是如何正确区分和使用OpenGL中的数据类型(GLfloat)与枚举常量(GL_FLOAT),以确保代码的精确性和跨平台兼容性;二是如何通过SDL库实现一个简单的键盘交互功能来退出程序。这些看似微小的改进,对于构建健壮、可维护的图形应用程序至关重要。
036:应用启动与网格抽象重构 🎮

在本节课中,我们将学习如何重构我们的OpenGL应用程序代码,使其更加模块化和可扩展。核心目标是抽象出应用状态和网格数据,为后续添加多个3D对象到场景中打下基础。我们将通过将全局变量封装到结构体中,并创建独立的网格数据结构来实现这一目标。

概述 📋
目前,我们的代码将所有状态(如窗口尺寸、着色器程序、网格数据)都存储为全局变量。虽然这很简单,但随着程序变得复杂,管理这些变量会变得困难。本节课,我们将把这些变量组织到两个主要的结构中:一个用于管理应用程序的整体状态(App),另一个用于管理单个3D网格的数据(Mesh3D)。这样做的目的是为了能够轻松地创建和管理多个对象。
代码重构:创建应用与网格结构 🏗️
上一节我们介绍了重构的目标,本节中我们来看看具体的实现步骤。我们将创建两个主要的结构体来封装数据。
应用程序结构体 (App)
App 结构体将包含所有与应用程序全局状态相关的变量,例如窗口尺寸、主循环标志、着色器程序和相机。
struct App {
// 窗口与上下文
SDL_Window* graphicsApplicationWindow;
SDL_GLContext openglContext;
int screenWidth;
int screenHeight;
bool quit;
// 图形管线
GLuint graphicsPipelineShaderProgram;
// 相机
Camera camera;
};
网格结构体 (Mesh3D)
Mesh3D 结构体将封装单个3D对象的所有数据,包括其OpenGL缓冲区对象、变换数据(位移、旋转、缩放)以及顶点数据。
struct Mesh3D {
// OpenGL 对象
GLuint vertexArrayObject;
GLuint vertexBufferObject;
GLuint indexBufferObject;
// 变换
glm::vec3 offset;
glm::vec3 rotate;
glm::vec3 scale;
// 顶点数据 (示例)
// 在实际应用中,这些数据可能来自文件或生成算法
// GLfloat vertexData[...];
// GLuint indexData[...];
};
重构初始化与绘制逻辑 🔄
现在我们已经定义了数据结构,接下来需要更新我们的函数,让它们使用这些新的结构体,而不是直接操作全局变量。
初始化程序 (InitializeProgram)
这个函数负责设置SDL窗口和OpenGL上下文。现在它接收一个指向 App 结构体的指针,并初始化其成员。
以下是更新后的函数签名和核心逻辑:
void InitializeProgram(App* app) {
// 初始化SDL
SDL_Init(SDL_INIT_VIDEO);
app->screenWidth = 800;
app->screenHeight = 600;
// ... 设置SDL窗口属性 ...
app->graphicsApplicationWindow = SDL_CreateWindow(...);
app->openglContext = SDL_GL_CreateContext(...);
// 初始化GLAD
if (!gladLoadGLLoader((GLADloadproc)SDL_GL_GetProcAddress)) {
// 错误处理
}
}
顶点规范 (VertexSpecification)
这个函数负责设置网格的几何数据(顶点和索引)。现在它接收一个指向 Mesh3D 结构体的指针,并填充其OpenGL缓冲区对象。
以下是该函数的核心步骤:
void VertexSpecification(Mesh3D* mesh) {
// 1. 生成并绑定顶点数组对象 (VAO)
glGenVertexArrays(1, &(mesh->vertexArrayObject));
glBindVertexArray(mesh->vertexArrayObject);
// 2. 生成并绑定顶点缓冲区对象 (VBO),上传顶点数据
glGenBuffers(1, &(mesh->vertexBufferObject));
glBindBuffer(GL_ARRAY_BUFFER, mesh->vertexBufferObject);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertexData), vertexData, GL_STATIC_DRAW);
// 3. 设置顶点属性指针 (例如位置、颜色)
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (void*)0);
glEnableVertexAttribArray(0);
// ... 设置其他属性 ...
// 4. 生成并绑定索引缓冲区对象 (EBO),上传索引数据
glGenBuffers(1, &(mesh->indexBufferObject));
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, mesh->indexBufferObject);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indexData), indexData, GL_STATIC_DRAW);
// 5. 解绑VAO(安全做法)
glBindVertexArray(0);
}
预绘制与绘制 (PreDraw 和 Draw)
在 PreDraw 函数中,我们设置全局的OpenGL状态,如视口和清除颜色。它现在从 App 结构体中获取屏幕尺寸。
void PreDraw(App* app) {
glViewport(0, 0, app->screenWidth, app->screenHeight);
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT);
glUseProgram(app->graphicsPipelineShaderProgram);
}
Draw 函数负责渲染单个网格。它接收 App 和 Mesh3D 的指针,计算模型矩阵(结合网格的位移、旋转、缩放),并将其与相机的视图投影矩阵一起传递给着色器。
void Draw(App* app, Mesh3D* mesh) {
// 计算模型矩阵
glm::mat4 modelMatrix = glm::translate(glm::mat4(1.0f), mesh->offset);
modelMatrix = glm::rotate(modelMatrix, glm::radians(mesh->rotate.x), glm::vec3(1.0f, 0.0f, 0.0f));
modelMatrix = glm::rotate(modelMatrix, glm::radians(mesh->rotate.y), glm::vec3(0.0f, 1.0f, 0.0f));
modelMatrix = glm::scale(modelMatrix, mesh->scale);
// 从相机获取视图和投影矩阵
glm::mat4 viewMatrix = app->camera.GetViewMatrix();
glm::mat4 projectionMatrix = glm::perspective(glm::radians(45.0f), (float)app->screenWidth / (float)app->screenHeight, 0.1f, 100.0f);
// 计算最终的MVP矩阵并传递给着色器
glm::mat4 mvpMatrix = projectionMatrix * viewMatrix * modelMatrix;
GLint mvpLocation = glGetUniformLocation(app->graphicsPipelineShaderProgram, "u_MVP");
glUniformMatrix4fv(mvpLocation, 1, GL_FALSE, glm::value_ptr(mvpMatrix));
// 绑定网格的VAO并绘制
glBindVertexArray(mesh->vertexArrayObject);
glDrawElements(GL_TRIANGLES, /*索引数量*/ , GL_UNSIGNED_INT, 0);
}
输入处理与主循环 🎮
输入处理函数(如处理键盘和鼠标)现在需要更新 App 结构体中的相机状态和退出标志。
主循环的结构基本保持不变,但内部调用的函数现在都接收 App 和 Mesh3D 的指针。
// 全局变量(重构后数量大大减少)
App gApp;
Mesh3D gMesh1;
// 未来可以添加: Mesh3D gMesh2;
while (!gApp.quit) {
ProcessInput(&gApp); // 处理输入,可能更新 gApp.camera 和 gApp.quit
PreDraw(&gApp);
Draw(&gApp, &gMesh1);
// 未来可以添加: Draw(&gApp, &gMesh2);
SDL_GL_SwapWindow(gApp.graphicsApplicationWindow);
}
清理工作 🧹
最后,我们需要在程序退出时正确地清理资源。我们为每个结构体创建了对应的清理函数。
以下是应用程序和网格的清理逻辑:
void CleanUp(App* app) {
// 删除OpenGL上下文和窗口
SDL_GL_DeleteContext(app->openglContext);
SDL_DestroyWindow(app->graphicsApplicationWindow);
// 删除着色器程序
glDeleteProgram(app->graphicsPipelineShaderProgram);
SDL_Quit();
}
void CleanUpMesh(Mesh3D* mesh) {
// 删除OpenGL缓冲区对象
glDeleteBuffers(1, &(mesh->vertexBufferObject));
glDeleteBuffers(1, &(mesh->indexBufferObject));
glDeleteVertexArrays(1, &(mesh->vertexArrayObject));
}
在 main 函数的最后,调用这些清理函数:
CleanUpMesh(&gMesh1);
CleanUp(&gApp);
总结 🎯
本节课中我们一起学习了如何对OpenGL应用程序进行初步的抽象和重构。我们主要完成了以下工作:
- 创建了数据结构:将散落的全局变量封装到
App(应用状态)和Mesh3D(单个网格数据)两个结构体中。 - 重构了函数:更新了初始化、顶点规范、绘制和清理函数,使其操作这些结构体,提高了代码的模块化。
- 奠定了扩展基础:通过将网格数据独立出来,我们现在可以轻松地创建多个
Mesh3D实例(例如gMesh1,gMesh2),并在主循环中分别绘制它们,为创建更复杂的3D场景做好了准备。

这次重构是迈向更复杂、更可维护的图形应用程序的关键一步。在接下来的课程中,我们将利用这个新的结构添加多个对象,并深入探讨深度测试、纹理和光照等主题。
037:网格抽象重构续篇——绘制两个四边形
在本节课中,我们将继续重构我们的图形代码或小型图形框架。我们的最终目标是理解如何渲染不止一个,而是两个网格对象。
上一节我们介绍了网格抽象的基本结构,本节中我们来看看如何扩展它以支持多个对象的渲染。
项目结构与代码回顾


首先,让我们回顾一下当前项目的结构。我们保持代码相对简洁,目前将许多内容放在主文件中,这便于我们整体概览。
以下是当前项目中的核心结构:
- 应用程序状态类 (
App):这是一个结构体,用于存放全局数据,例如相机、着色器管线、窗口状态等。虽然可以创建其实例,但目前我们将其视为应用程序的全局状态容器。 - 三维网格类 (
Mesh3D):这个类封装了顶点数组对象、顶点缓冲对象和索引缓冲对象。它旨在消除全局变量,并将网格数据与变换状态(如位移、旋转、缩放)绑定在一起。 - 主程序流程:程序初始化、顶点规范设置、图形管线创建,最后进入包含输入处理、更新、绘制和缓冲区交换的主循环。
重构绘制函数
在分析代码时,我们发现 draw 函数是重构的一个关键点。目前它直接操作全局网格数据,我们需要将其通用化,以便能绘制任意网格。
以下是重构 draw 函数的具体步骤:
- 创建通用绘制函数:我们将创建一个名为
MeshDraw的新函数,它接收一个Mesh3D指针作为参数。 - 绑定网格状态:在函数内部,绑定该网格的顶点数组对象,然后调用
glDrawElements进行绘制。 - 考虑管线绑定:绘制时还需要指定使用哪个图形管线(着色器程序)。我们可以选择在每次绘制网格时都绑定一次管线,但这在性能上并非最优。为了学习阶段的灵活性和清晰度,我们暂时采用这种方式,并添加备注说明。
// 注意:为每个网格切换图形管线状态通常效率不高。
// 此处为了学习阶段的灵活性和代码清晰度而采用此方式。
void MeshDraw(Mesh3D* mesh) {
if (mesh == nullptr) return;
glBindVertexArray(mesh->m_vertexArrayObject);
glUseProgram(mesh->m_pipeline); // 绑定该网格使用的着色器程序
glDrawElements(...); // 根据网格索引数据绘制
}
分离网格创建与状态设置
接下来,我们需要改进网格的创建和初始化流程。最初,我们在一个函数中同时设置了网格的几何数据和着色器管线。更好的设计是遵循单一职责原则。
以下是优化后的API设计:
MeshCreate函数:只负责创建和配置网格的几何数据(VAO, VBO, IBO)。MeshSetPipeline函数:一个独立的函数,用于为已创建的网格指定要使用的着色器程序。MeshUpdate函数(原PreDraw):用于在绘制前更新网格的变换状态(如模型矩阵),并将这些值传递给着色器uniform变量。MeshDelete函数:负责清理网格占用的OpenGL资源。
这种分离使得代码逻辑更清晰,也更容易管理多个网格。
实现多网格渲染
完成基础重构后,我们现在可以实现渲染两个网格的目标。
以下是创建和渲染两个四边形的步骤:
- 声明两个网格对象:我们创建两个全局的
Mesh3D实例,例如g_mesh1和g_mesh2。 - 分别创建网格:调用
MeshCreate为两个网格初始化几何数据(这里都是四边形)。 - 设置共享管线:调用
MeshSetPipeline为两个网格设置同一个编译好的着色器程序。 - 设置不同变换:为两个网格的
transform成员设置不同的位置值,使它们在场景中分开。 - 在主循环中更新和绘制:在主循环中,依次调用每个网格的
MeshUpdate和MeshDraw函数。
// 初始化阶段
Mesh3D g_mesh1, g_mesh2;
MeshCreate(&g_mesh1);
MeshCreate(&g_mesh2);
GLuint shaderProgram = CreateGraphicsPipeline(...); // 创建着色器程序
MeshSetPipeline(&g_mesh1, shaderProgram);
MeshSetPipeline(&g_mesh2, shaderProgram);
g_mesh1.transform.x = -2.0f; // 设置网格1的位置
g_mesh2.transform.x = 2.0f; // 设置网格2的位置
// 主循环中
while (running) {
// ... 处理输入 ...
MeshUpdate(&g_mesh1); // 更新网格1的uniform
MeshUpdate(&g_mesh2); // 更新网格2的uniform
// ... 清除屏幕 ...
MeshDraw(&g_mesh1); // 绘制网格1
MeshDraw(&g_mesh2); // 绘制网格2
// ... 交换缓冲区 ...
}
调试与注意事项
在实现过程中,我们遇到了一些典型的调试问题:
- 对象不显示:这通常是由于着色器管线未正确绑定或uniform变量未更新导致的。确保
MeshSetPipeline在MeshDraw之前被调用,且MeshUpdate正确计算并上传了模型矩阵。 - 状态管理:注意OpenGL是一个状态机。我们重构后的
MeshUpdate函数(负责设置uniform)和MeshDraw函数(负责绑定VAO和绘制)需要按正确顺序调用,且要确保在绘制每个对象前,其对应的状态已设置好。 - 深度测试:当两个网格在三维空间中重叠时,如果没有启用深度测试,它们可能会产生奇怪的叠加效果。确保在初始化时启用
GL_DEPTH_TEST。
总结与展望
本节课中我们一起学习了如何通过重构网格抽象来渲染多个OpenGL对象。我们主要完成了以下工作:
- 将通用的绘制逻辑封装进
MeshDraw函数。 - 将网格的创建、管线设置、状态更新和资源删除分离成独立的函数,遵循更好的API设计。
- 成功创建并渲染了两个具有独立位置的四边形成功。
通过这次重构,我们的代码向一个更模块化、更易扩展的小型图形框架迈进了一步。虽然目前我们仍使用全局变量和简单的函数式管理,但这为后续引入更高级的特性(如变换类、资源管理、场景图等)奠定了基础。在代码行数继续增长后,我们可以考虑将不同功能的代码拆分到独立的文件中,以保持项目的可维护性。



本节课中我们一起学习了:如何重构OpenGL代码以支持多网格渲染,包括创建通用的网格绘制函数、分离关注点以改进API设计,以及实现两个独立对象的创建、变换和绘制流程。
038:重构MeshUpdate与查找Uniform

概述



在本节课中,我们将继续重构我们的OpenGL抽象层。我们将重点关注mesh类,特别是mesh_update函数,并创建一个辅助函数来简化Uniform变量的查找和错误处理。通过这次重构,我们的代码将变得更清晰、更易于维护。
上一节我们介绍了mesh类的基本结构和初步重构。本节中,我们来看看如何进一步优化mesh的绘制流程,并抽象出Uniform查找的逻辑。
代码现状与目标


我们的main.c文件已经变得相当庞大。虽然我们很想立即将代码拆分到不同文件中,但在此之前,我们需要先理清一些抽象概念和整体系统设计。
目前,我们有一个Mesh3D结构体,它包含以下核心成员:
GLuint vao:顶点数组对象GLuint vbo:顶点缓冲对象GLuint ibo:索引缓冲对象(用于索引绘制)GLuint pipeline:用于渲染该网格的图形管线(着色器程序)Transform transform:变换类,用于存储模型矩阵等信息
以下是Mesh3D结构体的代码表示:
typedef struct {
GLuint vao;
GLuint vbo;
GLuint ibo;
GLuint pipeline;
Transform transform;
} Mesh3D;
我们目前创建了两个网格实例,程序运行时会显示两个矩形。由于深度测试被禁用,我们会看到一些重叠的奇怪现象,这将在后续完成抽象后讨论。


重构Mesh绘制流程


当前,我们有一个mesh_update函数,它负责在绘制网格前设置Uniform值。这个函数本质上是绘制前的准备工作。
我们可以考虑将这个更新逻辑直接移到mesh_draw函数中。因为在绘制时,我们需要绑定管线,然后设置Uniform值。对于当前的简单管线来说,这是合理的做法。
以下是重构步骤,我们将mesh_update中的代码移入mesh_draw:
- 将Uniform查找和设置的代码从
mesh_update剪切。 - 将其粘贴到
mesh_draw函数中,放在绑定着色器程序之后、实际绘制调用之前。 - 重新编译并运行程序,确保功能与之前一致。
完成这一步后,mesh_update函数就变得多余了。
创建Uniform查找辅助函数
在清理mesh_draw函数中的Uniform设置代码时,我们发现其中有重复的错误处理逻辑。遵循“不要重复自己”(DRY)的原则,我们将其抽象成一个辅助函数。


这个函数的目标是:根据Uniform变量名,在指定的着色器程序中查找其位置,并进行统一的错误处理。

以下是find_uniform_location函数的实现:
/**
* 根据名称返回着色器程序中Uniform变量的位置。
* @param program 着色器程序ID
* @param name Uniform变量的名称
* @return Uniform的位置。如果返回值小于0,则表示查找失败,程序会报错退出。
*/
GLint find_uniform_location(GLuint program, const char* name) {
GLint location = glGetUniformLocation(program, name);
if (location < 0) {
fprintf(stderr, "错误:在程序中未找到Uniform变量 '%s'\n", name);
exit(EXIT_FAILURE);
}
return location;
}
使用这个辅助函数,我们可以大幅简化mesh_draw函数中的代码。以下是重构前后的对比示例:
重构前:
GLint modelMatrixLocation = glGetUniformLocation(mesh->pipeline, "u_model_matrix");
if (modelMatrixLocation < 0) {
fprintf(stderr, "错误:未找到Uniform变量 'u_model_matrix'\n");
exit(EXIT_FAILURE);
}
glUniformMatrix4fv(modelMatrixLocation, 1, GL_FALSE, glm::value_ptr(mesh->transform.modelMatrix));
重构后:
GLint modelMatrixLocation = find_uniform_location(mesh->pipeline, "u_model_matrix");
glUniformMatrix4fv(modelMatrixLocation, 1, GL_FALSE, glm::value_ptr(mesh->transform.modelMatrix));
我们对视图矩阵和投影矩阵的Uniform设置也进行同样的重构。
代码清理与洞察
完成重构后,我们的代码变得更加简洁清晰。一个有趣的额外收获是,代码结构清晰后,我们更容易发现潜在的优化点。
例如,我们注意到目前是分别传递模型矩阵(u_model_matrix)和视图矩阵(u_view_matrix)到着色器,然后在顶点着色器中进行相乘。这会导致每个顶点都执行一次矩阵乘法。
一个常见的优化是:在CPU端将模型矩阵和视图矩阵相乘,得到一个“模型-视图”矩阵(u_model_view_matrix),然后只传递这一个Uniform到着色器。这样,每个顶点就只需与一个矩阵相乘,减少了计算量。
公式:model_view_matrix = view_matrix * model_matrix


虽然有时出于某些操作需要将矩阵分开,但在大多数绘制调用中,合并它们是更高效的做法。
最终调整与总结
我们将新创建的find_uniform_location函数移到了文件顶部,并归类在一个“OpenGL工具函数”的注释块下,以便管理。
最后,由于mesh_update函数已经没有任何实际作用,我们选择直接删除它,而不是保留一个空函数。这遵循了“最好的代码就是被删除的代码”这一理念。
本节课中我们一起学习了如何通过重构来简化OpenGL代码。我们主要完成了两件事:
- 将网格的Uniform更新逻辑整合到绘制函数中,使流程更直接。
- 创建了一个
find_uniform_location辅助函数,封装了Uniform查找和错误处理,消除了代码重复,并使主逻辑更清晰。

这次重构不仅减少了代码量,还让我们对数据流有了更清晰的认识,甚至发现了合并模型/视图矩阵的优化机会。在接下来的课程中,我们将继续进行重构,为添加纹理等图形功能打下坚实的基础。
039:重构Mesh绘制与相机



在本节课中,我们将继续重构代码库,目标是构建一个简洁的游戏框架。今天的重点是处理 mesh3D 类,我们将清理抽象层,使其更加清晰。


概述
首先,回顾一下当前的项目结构。在 main.cpp 中,我们有一个 Mesh 类,它负责设置几何体。这对应着图形渲染管线中的顶点规范阶段,即在CPU上设置缓冲区等操作。


我们为网格提供了一个默认的变换位置,以便移动它们。目前,我们依次创建了两个网格,这是有意为之,因为我们尚未讨论深度测试。在完成一些重构后,我们会处理这个问题。
接着,我们创建图形管线。管线设置完成后,就可以设置网格。你可能会想,我们其实可以在一步中创建网格并设置管线。我们可能会在后续进行这样的重组,例如在程序启动前编译所有图形管线,然后将管线与网格关联。但目前,这是一个两步过程,每个函数只做一件事,这是一种合理的软件设计方法。
然后,我们进入主循环。在主循环中,我们基本上就是绘制网格,当然也处理输入(例如移动按键)。
重构Mesh绘制函数
现在,让我们看看 mesh3D 类。今天的目标是继续清理这个类。我们需要更多地思考之前提到的 transform 结构体。在上节课中,我们开始处理获取统一变量位置,因为需要在 meshDraw 函数中进行清理。
meshDraw 函数目前看起来有些杂乱,我们将所有统一变量的设置集中在一起。但还有一些问题需要处理,例如如何处理我们为每个网格不断创建的透视相机。这个功能应该属于相机类本身。
因此,是时候移除这部分代码,将其改为两步操作。我们希望实现类似 Gf.mcaa.git projection matrix 这样的调用。这里称之为“投影矩阵”,因为我们不确定它将是透视投影还是正交投影。
为相机类添加投影矩阵
让我们打开 Camera.cpp 文件。就像我们有一个 getViewMatrix 函数一样,让我们创建一个 getProjectionMatrix 函数。它应该是一个常量函数。
我们可以在相机类中存储一个投影矩阵。在构造相机时,也许可以用一个默认的投影矩阵来初始化它。但这里隐藏了一些细节,我们可能想要改变它。
实际上,我们更希望有一个单独的函数来设置投影矩阵,而不是在构造函数中隐藏算法。因此,让我们创建一个 setProjectionMatrix 函数。


最好的方法是参考GLM的透视矩阵函数。GLM是一个高度模板化的库,如果你不习惯阅读C++代码,可能会有些挑战。




glm::perspective 函数需要以下参数:视野(FOV,浮点数)、宽高比(浮点数)、近平面(浮点数)和远平面(浮点数)。它返回一个4x4矩阵。
因此,我们的 setProjectionMatrix 函数将简单地调用 glm::perspective 并设置成员变量 m_projectionMatrix。
以下是该函数的核心代码:
void Camera::setProjectionMatrix(float fovY, float aspect, float near, float far) {
m_projectionMatrix = glm::perspective(fovY, aspect, near, far);
}
在主程序中使用新的相机功能
现在,回到 main.cpp 文件。在初始化程序并设置相机之后,我们可以调用 GApp.m_camera.setProjectionMatrix(...) 来设置投影矩阵。我们需要传入视野、宽高比、近平面和远平面的值。
然后,在 meshDraw 函数中,我们不再直接计算透视矩阵,而是从相机中获取投影矩阵。我们可以这样调用:
glm::mat4 projection = GApp.m_camera.getProjectionMatrix();
这样,我们就将投影矩阵的计算和管理抽象到了相机类中。
测试与总结
编译并运行程序,应该能看到两个四边形正常显示,与重构前一样。这次重构减少了出错的可能性,使代码更加清晰,并进一步抽象了功能。
然而,仍然有一些小问题需要处理。例如,变换操作仍然在 draw 函数中处理,这负担过重。我们希望能够控制网格的平移、旋转和缩放。这将是下一节课的重点。
一旦我们有了可以操作的网格和基础框架,我们将深入介绍更多图形学入门知识。

本节课中,我们一起学习了如何将投影矩阵的计算从网格绘制函数中抽离,并将其封装到相机类中,使代码结构更加清晰和模块化。
040:为Mesh3D类添加平移、旋转和缩放操作 🎮
在本节课中,我们将学习如何为我们的Mesh3D类抽象出平移、旋转和缩放操作。我们将创建mesh_translate、mesh_rotate和mesh_scale函数,以简化模型变换的代码,使主渲染循环更加清晰。
概述
目前,我们的代码在mesh_draw函数中直接处理模型的旋转和平移,这与网格绘制本身的核心逻辑混杂在一起。为了建立更清晰的抽象,我们将把这些变换操作提取出来,作为Mesh3D类的独立功能。这样,mesh_draw函数可以专注于设置管线、传递uniform变量和实际绘制网格。
上一节我们介绍了Mesh3D类的基本结构和绘制流程,本节中我们来看看如何将变换操作从绘制函数中分离出来。
代码回顾与目标
首先,让我们简要回顾当前的代码结构。在我们的main函数和mesh_draw函数中,我们直接使用GLM库进行矩阵变换计算。例如,我们这样设置模型矩阵:
glm::mat4 model = glm::mat4(1.0f);
model = glm::translate(model, glm::vec3(x, y, z));
model = glm::rotate(model, glm::radians(angle), glm::vec3(0.0f, 1.0f, 0.0f));
model = glm::scale(model, glm::vec3(scaleX, scaleY, scaleZ));
我们的目标是创建类似mesh_create、mesh_delete和mesh_set_pipeline的函数,来专门处理这些变换。我们将创建mesh_translate、mesh_rotate和mesh_scale函数。

重构Mesh3D结构体

为了实现变换的抽象,我们首先需要修改Mesh3D结构体,为其添加一个模型矩阵成员,用于存储该网格的当前变换状态。
typedef struct Mesh3D {
// ... 其他现有成员(如VBO、IBO、管线等)
glm::mat4 model_matrix; // 新增:存储模型变换矩阵
} Mesh3D;
在创建网格时,我们需要将model_matrix初始化为单位矩阵。
Mesh3D mesh_create(...) {
Mesh3D mesh;
// ... 初始化其他成员
mesh.model_matrix = glm::mat4(1.0f); // 初始化为单位矩阵
return mesh;
}
创建变换函数
现在,我们可以开始编写处理变换的独立函数了。这些函数将直接操作Mesh3D结构体中的model_matrix。
平移函数 (mesh_translate)
mesh_translate函数将网格平移到指定的(x, y, z)位置。它通过将平移矩阵与网格当前的模型矩阵相乘来实现。
平移矩阵的公式如下:
[ 1, 0, 0, Tx ]
[ 0, 1, 0, Ty ]
[ 0, 0, 1, Tz ]
[ 0, 0, 0, 1 ]
其中Tx, Ty, Tz是平移量。
以下是该函数的实现:
/**
* 平移一个网格,更新其模型矩阵。
* @param mesh 指向目标Mesh3D结构体的指针。
* @param x 沿X轴的平移量。
* @param y 沿Y轴的平移量。
* @param z 沿Z轴的平移量。
*/
void mesh_translate(Mesh3D* mesh, float x, float y, float z) {
mesh->model_matrix = glm::translate(mesh->model_matrix, glm::vec3(x, y, z));
}
旋转函数 (mesh_rotate)
mesh_rotate函数使网格绕任意轴旋转指定角度(弧度制)。我们提供一个绕Y轴旋转的简化版本mesh_rotate_y。
旋转矩阵(绕Y轴)的公式较为复杂,GLM库为我们处理了这些计算。


以下是该函数的实现:
/**
* 绕任意轴旋转一个网格。
* @param mesh 指向目标Mesh3D结构体的指针。
* @param angle_radians 旋转角度(弧度)。
* @param axis 旋转轴向量(例如 glm::vec3(0.0f, 1.0f, 0.0f) 代表Y轴)。
*/
void mesh_rotate(Mesh3D* mesh, float angle_radians, glm::vec3 axis) {
mesh->model_matrix = glm::rotate(mesh->model_matrix, angle_radians, axis);
}
/**
* 绕Y轴旋转一个网格(便捷函数)。
* @param mesh 指向目标Mesh3D结构体的指针。
* @param angle_radians 绕Y轴的旋转角度(弧度)。
*/
void mesh_rotate_y(Mesh3D* mesh, float angle_radians) {
mesh_rotate(mesh, angle_radians, glm::vec3(0.0f, 1.0f, 0.0f));
}
缩放函数 (mesh_scale)
mesh_scale函数对网格进行缩放。缩放可以是均匀的(所有轴比例相同)或非均匀的(各轴比例不同)。
缩放矩阵的公式如下:
[ Sx, 0, 0, 0 ]
[ 0, Sy, 0, 0 ]
[ 0, 0, Sz, 0 ]
[ 0, 0, 0, 1 ]
其中Sx, Sy, Sz是缩放因子。
以下是该函数的实现:
/**
* 非均匀缩放一个网格。
* @param mesh 指向目标Mesh3D结构体的指针。
* @param scale 包含X、Y、Z轴缩放因子的向量。
*/
void mesh_scale(Mesh3D* mesh, glm::vec3 scale) {
mesh->model_matrix = glm::scale(mesh->model_matrix, scale);
}
/**
* 均匀缩放一个网格(便捷函数)。
* @param mesh 指向目标Mesh3D结构体的指针。
* @param uniform_scale 所有轴的统一缩放因子。
*/
void mesh_scale_uniform(Mesh3D* mesh, float uniform_scale) {
mesh_scale(mesh, glm::vec3(uniform_scale));
}
更新绘制函数
创建了变换函数后,我们需要更新mesh_draw函数。现在,它不再需要自己计算模型矩阵,而是直接使用Mesh3D结构体中存储的model_matrix。
更新后的mesh_draw函数核心部分如下:
void mesh_draw(Mesh3D* mesh) {
if (!mesh_is_valid(mesh)) return;
mesh_set_pipeline(mesh);
// 获取模型矩阵并传递给着色器
GLint model_loc = glGetUniformLocation(mesh->pipeline, "model");
glUniformMatrix4fv(model_loc, 1, GL_FALSE, glm::value_ptr(mesh->model_matrix));
// ... 绑定VBO、IBO并执行绘制调用(glDrawElements)
}
在主循环中使用
现在,我们可以在主渲染循环中以更清晰的方式使用这些函数。以下是更新后的主循环示例:
// 初始化网格
Mesh3D g_mesh1 = mesh_create(...);
Mesh3D g_mesh2 = mesh_create(...);
// 初始变换
mesh_translate(&g_mesh1, 0.0f, 0.0f, 0.0f);
mesh_translate(&g_mesh2, 0.0f, 0.0f, -4.0f);
mesh_scale(&g_mesh2, glm::vec3(1.0f, 2.0f, 1.0f)); // 让第二个网格在Y轴上更高
float rotation_angle = 0.0f;
while (!glfwWindowShouldClose(window)) {
// ... 处理输入、清屏等
// 更新旋转角度
rotation_angle += 0.05f;
// 应用变换
mesh_rotate_y(&g_mesh1, rotation_angle);
mesh_rotate_y(&g_mesh2, -rotation_angle); // 反向旋转
// 绘制网格
mesh_draw(&g_mesh1);
mesh_draw(&g_mesh2);
// ... 交换缓冲区、检查事件
}

总结
本节课中我们一起学习了如何为Mesh3D类抽象出平移、旋转和缩放操作。通过创建mesh_translate、mesh_rotate和mesh_scale函数,我们将变换逻辑从绘制函数中分离出来,使代码结构更加模块化和清晰。
我们主要完成了以下工作:
- 在
Mesh3D结构体中添加了model_matrix成员来存储变换状态。 - 实现了独立的变换函数,这些函数直接操作模型矩阵。
- 更新了
mesh_draw函数,使其直接使用网格自身的模型矩阵。 - 在主循环中演示了如何使用新的API来轻松控制多个网格的变换。


现在,我们拥有了一个更健壮的小型图形框架基础,可以方便地创建、变换和渲染多个网格对象。这为我们后续加载复杂几何体、添加颜色和光照等功能奠定了良好的基础。

浙公网安备 33010602011771号