VictorGordan-OpenGL-笔记-全-
VictorGordan OpenGL 笔记(全)
001:环境安装与配置 🛠️
在本节课中,我们将学习如何为OpenGL开发配置Visual Studio环境。这包括安装必要的工具、下载核心库以及正确设置项目结构。整个过程是后续所有OpenGL学习的基础。
本教程内容基于learnopengl.com,所有功劳归于Joey de Vries。
检查与安装必要软件
首先,你需要确保你的显卡驱动程序是最新版本,并且安装了最新版的Visual Studio。
接下来,请下载适用于Windows 64位系统的CMake安装程序,并完成安装。
下载核心库文件
现在,你需要下载GLFW的源代码包。

最后,你需要下载GLAD。请确保选择C/C++版本3.3核心(Core),忽略其他选项,然后点击生成(Generate)按钮。
点击生成的ZIP文件链接以下载它。

创建Visual Studio项目
完成CMake和Visual Studio的安装后,打开Visual Studio,点击“创建新项目”(Create a new project),为C++选择“空项目”(Empty Project)。
点击“下一步”(Next),为你的项目输入一个名称,确保复选框被勾选,并记住你将项目放置在哪个文件夹中。
打开你的项目文件夹,创建一个名为 libraries 的新文件夹。
组织项目库目录
现在,我们将在 libraries 文件夹内再创建两个文件夹,分别命名为 lib 和 include。
这里将是我们稍后导入所有库文件的位置。

使用CMake配置GLFW
我们需要解压GLFW的ZIP文件。打开CMake,在CMake中选择我们刚刚解压的文件夹作为源文件夹(source folder)。

然后在源文件夹内创建一个名为 Build 的新文件夹,并将其选为构建文件夹(build folder)。
点击“配置”(Configure),在确保你的设置与我相同后,点击“完成”(Finish)。

再次确认你的配置与我相同,然后再次点击“配置”(Configure)。接着点击“生成”(Generate),一切完成后,退出CMake。

构建GLFW库

现在我们需要构建GLFW。打开你解压的文件夹,进入 build 文件夹,然后用Visual Studio打开 GLFW.sln 文件。

在解决方案资源管理器中右键点击解决方案,选择“生成解决方案”(Build Solution)。如果出现任何错误,很可能意味着你在构建过程中出现了问题,因此我们必须使用CMake重新生成GLFW,并再次执行此步骤。
一旦构建成功完成,退出Visual Studio。


导入库文件到项目
现在是时候将我们的库导入项目了。打开解压后的GLFW文件夹,进入 build -> src -> Debug,将 glfw3.lib 文件剪切并粘贴到你项目的 libraries/lib 文件夹中。
回到解压的GLFW文件夹,进入 include 目录。将 GLFW 文件夹剪切并粘贴到你项目的 libraries/include 文件夹中。
假设你正确遵循了上述步骤,现在可以删除解压的GLFW文件夹了。
打开GLAD的ZIP文件。打开其中的 include 文件夹,将里面的两个文件夹解压到你项目的 libraries/include 文件夹中。
然后返回上一级目录,打开 src 文件夹,将 glad.c 文件解压到你项目的主文件夹中。

现在我们将进行配置。

本节课中,我们一起学习了OpenGL开发环境的完整搭建流程。我们安装了CMake和Visual Studio,下载并配置了GLFW和GLAD这两个核心库,并建立了清晰的项目目录结构。正确的环境配置是开始编写OpenGL代码的第一步。
002:创建窗口 🪟

在本节课中,我们将学习如何使用GLFW库创建一个OpenGL窗口。上一节我们介绍了如何在Visual Studio中配置OpenGL环境,本节中我们来看看如何初始化GLFW并创建一个可以交互的窗口。

概述


我们将通过以下步骤创建一个基本的OpenGL窗口:
- 初始化GLFW库。
- 配置OpenGL版本和核心模式。
- 创建窗口对象。
- 将窗口设置为当前上下文。
- 创建一个渲染循环,让窗口保持打开状态。
初始化与终止GLFW


使用GLFW的第一步是初始化它,以便我们可以调用其功能函数。初始化成功后,在程序结束前也必须正确地终止它,以释放资源。
以下是初始化GLFW的代码:


glfwInit();
在main函数结束时,我们需要添加终止GLFW的代码:

glfwTerminate();



配置OpenGL版本
GLFW默认不知道我们使用哪个版本的OpenGL,因此需要通过“提示”来告知它。我们使用glfwWindowHint函数来设置这些提示,它接受一个提示类型和一个值作为参数。

例如,我们需要设置主版本号和次版本号。因为我们使用的是OpenGL 3.3,所以主版本号为3,次版本号也为3。


glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);

选择OpenGL配置文件


OpenGL配置文件可以理解为函数集合。主要有两种:
- 核心模式:只包含现代函数。
- 兼容模式:包含现代函数和已废弃的旧函数。
为了使用现代OpenGL功能,我们选择核心模式。

glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);



创建窗口对象

配置完成后,我们可以创建窗口了。GLFWwindow是GLFW中表示窗口对象的数据类型。

我们使用glfwCreateWindow函数来创建窗口,它需要五个参数:窗口宽度、窗口高度、窗口标题、是否全屏(这里设为NULL表示否),以及最后一个不重要的参数(通常也为NULL)。
以下是创建窗口的代码示例:


GLFWwindow* window = glfwCreateWindow(800, 600, “My OpenGL Window”, NULL, NULL);
if (window == NULL) {
std::cout << “Failed to create GLFW window” << std::endl;
glfwTerminate();
return -1;
}


为了程序的健壮性,我们添加了简单的错误检查,如果窗口创建失败,会打印错误信息并终止GLFW。
设置当前上下文


创建窗口后,我们需要告诉GLFW将这个窗口设置为当前OpenGL渲染的上下文。上下文是一个抽象的概念,它持有OpenGL的状态和所有数据。
使用以下函数将我们创建的窗口设为当前上下文:


glfwMakeContextCurrent(window);

渲染循环

如果现在运行程序,窗口会一闪而过立即关闭。这是因为main函数执行完毕,程序就退出了。为了让窗口保持打开,我们需要一个循环,在用户主动关闭窗口前不断处理事件和刷新画面。

这个循环被称为“渲染循环”或“游戏循环”。我们使用glfwWindowShouldClose函数来检查窗口是否被要求关闭。


while (!glfwWindowShouldClose(window)) {
glfwSwapBuffers(window);
glfwPollEvents();
}
glfwSwapBuffers(window):交换前后缓冲区,将渲染好的图像显示到窗口上。glfwPollEvents():处理例如键盘输入、鼠标移动等事件。
最后,在循环结束、退出main函数之前,别忘了删除窗口对象。


glfwDestroyWindow(window);

总结

本节课中我们一起学习了创建一个基本OpenGL窗口的完整流程。我们初始化并配置了GLFW,设置了OpenGL 3.3核心模式,创建了窗口对象并将其设为当前上下文,最后通过一个渲染循环让窗口能够持续显示并响应用户操作。这是所有OpenGL应用程序的基础框架。在下一节中,我们将学习如何在窗口中绘制第一个三角形。
OpenGL教程:P3:绘制三角形
概述
在本节课中,我们将学习如何在OpenGL窗口中绘制一个三角形。我们将从理解图形渲染管线开始,然后逐步编写代码,定义三角形的顶点数据,并最终在屏幕上看到它。
图形渲染管线简介
上一节我们创建了一个窗口,本节中我们来看看如何将图形(一个三角形)绘制到窗口中。首先,需要理解一个核心概念:图形渲染管线。
图形渲染管线本质上是一系列函数,它在开始时接收一些数据,并在管线的末端输出一帧图像。
管线的输入称为顶点数据,这只是一个顶点数组。这里的顶点不仅仅是数学上的点,因为除了位置信息,每个顶点还可以包含其他数据,如颜色或纹理坐标。
顶点着色器
图形渲染管线的第一阶段是顶点着色器。顶点着色器接收所有顶点的位置并进行变换。你也可以选择保持它们不变。
图元装配
当所有变换完成后,图元装配阶段会根据一个图元类型来连接这些位置。图元是一种基本形状,例如三角形、点或线。每种图元对数据的解释方式不同:
- 对于三角形,它会取三个点并在它们之间绘制一个三角形。
- 对于线,它会每次取两个点并在它们之间绘制线条。
几何着色器
接下来是几何着色器,它可以从已有的图元中添加顶点并创建新的图元。这个阶段更复杂,我们将在很久以后才会涉及。
光栅化
然后是光栅化阶段,所有完美的数学形状在这里被转换为实际的像素。因此,之前那个完美的数学三角形现在变成了一堆像素(本质上是一堆方块)。
片段着色器
但这些像素本身并没有颜色。这时片段着色器就登场了,它是最重要的着色器之一。片段着色器为像素添加颜色。颜色取决于许多因素,例如光照、纹理或阴影。
测试与混合
此时,由于多个对象重叠,一个像素可能对应多种颜色。这个问题会在最后一个阶段被修正,同时也会处理透明物体的混合,以得到最终的颜色。
编写代码:定义三角形
现在,让我们开始实际的编码。遗憾的是,OpenGL没有为我们提供顶点和片段着色器的默认实现,所以我们必须自己编写。由于本教程的重点不在于着色器本身,而在于使用着色器的整体流程,我将直接复制粘贴着色器代码。不过别担心,我们会在未来的教程中详细讲解它们。
首先,让我们指定三角形的顶点坐标。目前我们将在2D空间工作,因此忽略Z轴。
对于X和Y轴,它们的原点位于窗口中心,X轴指向右方,Y轴指向上方。这个坐标系是归一化的,这意味着:
- 对于X轴,窗口最左侧是-1.0,最右侧是+1.0。
- 对于Y轴,窗口最底部是-1.0,最顶部是+1.0。

以下是定义三角形三个顶点的代码:
// 三角形的顶点坐标数组
GLfloat vertices[] = {
-0.5f, -0.5f * float(sqrt(3)) / 3, 0.0f, // 左下角顶点
0.5f, -0.5f * float(sqrt(3)) / 3, 0.0f, // 右下角顶点
0.0f, 0.5f * float(sqrt(3)) * 2 / 3, 0.0f // 顶部顶点
};
这段代码创建了一个等边三角形。每个顶点由三个浮点数(X, Y, Z)定义,Z坐标暂时设为0.0。
总结

本节课中,我们一起学习了OpenGL图形渲染管线的基本流程,从顶点数据输入,经过顶点着色器、图元装配、光栅化、片段着色器,最终到测试与混合输出像素。我们还编写了代码,定义了一个等边三角形的顶点数据,为在屏幕上绘制它做好了准备。下一节,我们将学习如何将这些顶点数据传递给GPU并配置着色器。
005:代码组织 🧹

概述
在本节课中,我们将学习如何组织OpenGL项目代码。我们将把着色器代码从主程序中分离到独立的文件中,并创建可重用的Shader类和VBO类,使代码结构更清晰、更易于维护。


从上一节到本节
上一节我们介绍了如何使用索引缓冲区。本节中,我们来看看如何整理项目中日益增多的代码。
目前,主函数中包含了许多内容。我们首先将着色器代码移动到独立的文本文件中。

以下是创建着色器文件的具体步骤:
- 打开解决方案资源管理器,在“资源文件”文件夹中创建一个名为“Shaders”的新文件夹。
- 添加一个新项,选择“实用工具” -> “文本文件”,并将其命名为
default.vert。 - 打开
default.vert文件,将顶点着色器的源代码复制粘贴进去。请确保删除所有引号和反斜杠。 - 对片段着色器执行完全相同的操作,但将其命名为
default.frag。

创建Shader类
接下来,我们创建自己的Shader类来封装OpenGL的着色器程序。
首先,在头文件中添加新项,创建一个名为 ShaderClass.h 的头文件。在此文件中,我们将声明类及其相关函数。
为了防止头文件被重复包含导致变量冲突,我们使用预处理指令进行保护:
#ifndef SHADERCLASS_H
#define SHADERCLASS_H
// ... 类声明 ...
#endif

我们需要声明一个读取着色器文本文件的函数。这个函数本身与OpenGL无关,它只是将文本文件的内容作为字符串输出。
现在,声明Shader类。这个类将是对OpenGL着色器程序的一个封装。
- 给它一个公共的ID成员变量。
- 声明一个构造函数,用于接收着色器源代码路径。
- 声明
Activate和Delete函数。

完成头文件后,转到源文件并创建一个名为 ShaderClass.cpp 的CPP文件。
- 首先包含
ShaderClass.h头文件。 - 将文件读取函数复制粘贴进去。
- 现在编写Shader类的构造函数:
- 将文本文件的内容读入字符串变量。
- 将字符串转换为字符数组。
- 复制主函数中所有与着色器相关的代码,稍作修改:将
shaderProgram替换为ID,将vertexShaderSource替换为vertexSource,将fragmentShaderSource替换为fragmentSource。
- 同样,通过从主函数复制代码来编写
Activate和Delete函数。
很好,我们已经完成了Shader类的创建。


创建VBO类
接下来,让我们创建一个顶点缓冲区对象类。

创建一个头文件 VBO.h,并包含OpenGL函数所需的 glad.h。

现在创建一个VBO类:
- 给它一个公共的ID变量。
- 声明一个构造函数,该构造函数接收顶点数据及其大小(以字节为单位)。顶点数据的大小使用
GLsizeiptr数据类型,因为这是OpenGL用于表示字节大小的类型。 - 添加
Bind、Unbind和Delete函数的声明。

总结
本节课中,我们一起学习了如何组织OpenGL项目代码。我们将着色器代码分离到独立文件中,并创建了可重用的Shader类和VBO类。这样做使代码结构更模块化、更清晰,为后续添加更多功能打下了良好的基础。
OpenGL教程:P6:着色器 🎨
在本节课中,我们将深入学习着色器。我们将了解着色器如何作为在GPU上运行的特殊函数,学习GLSL语言的基本结构,并实践如何为每个顶点添加颜色属性,实现从顶点着色器到片段着色器的数据传递。
在上一节中,我们为着色器程序、VAO、VBO和EBO创建了一些自定义类。本节中,我们来看看着色器本身。你可以将着色器理解为在GPU上运行的函数。由于它们类似于函数,因此可以接收输入并产生输出。
让我们通过查看默认的顶点着色器来开始学习。初看之下,你可能会认为这是C语言代码,但它实际上是OpenGL的着色语言,即GLSL,其语法与C语言相似。
第一行指定了我们使用的GLSL版本。由于我们使用的是OpenGL 3.3,因此需要对应使用GLSL 330。第二行使用 layout (location = 0) 声明了一个输入变量 aPos。layout 帮助OpenGL解读接收到的顶点数据。在这里,我们声明在第0个布局位置有一个用于存储位置信息的向量数据类型。
对于主函数,我们简单地将 gl_Position 赋值为一个四维向量,其中包含我们的位置坐标,以及一个目前可以忽略的第四维任意值。OpenGL能识别关键字 gl_Position,并知道将其用作顶点的位置。你可以将此着色器理解为输出了 gl_Position,尽管它并未显式地这样做。
另一方面,片段着色器在第二行显式声明它输出一个四维向量 FragColor。在主函数中,我们简单地赋予它一个RGBA格式的颜色,用于所有顶点。
但是,与其让所有点共享同一种颜色,不如让我们为每个顶点赋予其专属颜色。
以下是实现此目标的具体步骤:
首先,在顶点数组 vertices 中,在每个位置坐标后写入RGB值。
接着,在顶点着色器中添加第二个布局,使用 layout (location = 1) 来接收一个名为 aColor 的向量。
由于片段着色器是负责处理颜色的,我们需要将颜色从顶点着色器输出到片段着色器。为此,我在顶点着色器中输出一个名为 color 的向量,并在主函数中使其等于从顶点数组导入的 aColor。
现在,在片段着色器中,我输入一个完全同名的向量 color。为输入和输出赋予相同的名称至关重要,否则OpenGL将无法在它们之间建立链接。
此着色器的最后一步是使 FragColor 等于 color,因为这就是我们要输出的内容。
接下来,我们需要配置顶点属性指针。但在开始之前,必须先修改VAO类中的一个函数。
让我们将 linkVBO 函数改为 linkAttrib,并添加四个变量:numComponents(组件数量)、type(数据类型)、stride(步长)和 offset(偏移量)。
在VAO的CPP文件中进行同样的更改,并将这些新变量作为参数添加到 glVertexAttribPointer 函数调用中,具体操作如下方代码所示:
void VAO::linkAttrib(VBO& VBO, GLuint layout, GLuint numComponents, GLenum type, GLsizeiptr stride, void* offset) {
VBO.Bind();
glVertexAttribPointer(layout, numComponents, type, GL_FALSE, stride, offset);
glEnableVertexAttribArray(layout);
VBO.Unbind();
}
我们向着色器发送的是一个包含大量字节的数组。为了让OpenGL知道如何正确解析这些数据,我们必须通过 glVertexAttribPointer 函数明确告知它数据的组织方式,包括每个属性有多少个分量、数据类型是什么、以及数据在数组中的间隔(步长)和起始位置(偏移量)。


本节课中,我们一起学习了着色器作为GPU函数的基本概念,探索了GLSL语言的结构,并实践了为顶点添加颜色属性以及通过同名变量在着色器阶段间传递数据的方法。我们还修改了VAO类以支持更灵活的顶点属性链接。理解这些数据如何被组织和传递,是掌握现代OpenGL渲染流程的关键一步。
OpenGL教程:P7:纹理
在本节课中,我们将学习如何在OpenGL中使用纹理。上一节我们介绍了着色器的基础知识,本节中我们来看看如何将图像作为纹理应用到几何图形上,使渲染结果更加生动。
纹理可以是一维、二维或三维的,但本教程我们只关注最常见的二维纹理。
导入图像
首先,我们需要将一张图像导入到程序中,以便将其转换为纹理并显示。为此,我们将使用一个流行的开源库:stb_image。
以下是安装步骤:
- 进入你的项目文件夹,然后进入
libraries目录下的include目录。 - 创建一个名为
stb的文件夹。 - 在
stb文件夹内,创建一个名为stb_image.txt的文本文件。 - 访问描述中提供的链接,按
Ctrl+A全选所有内容,然后复制粘贴到刚创建的stb_image.txt文件中。 - 保存文件,并将其重命名为
stb_image.h。 - 在你的项目源文件中创建一个名为
stb.cpp的CPP文件,并写入以下代码,以确保我们只使用该库中需要的部分:
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
- 右键点击
stb.cpp文件并选择“编译”。确保只编译这一个文件。
安装完成后,只需在想使用该库的文件中包含其头文件即可。我将在 main.cpp 文件中进行包含。
准备几何图形
在开始处理纹理之前,我们先确保有一个正方形的坐标,以便更好地观察纹理显示效果。同时,别忘了修改索引数组和 glDrawElements 函数的调用参数。
运行你的程序,确认确实得到了一个正方形。如果一切正常,我们就可以导入图像了。
请注意,尺寸为2的幂次方(例如1024x1024像素或2048x2048像素)的正方形纹理比具有随机像素数的纹理优化得更好。因此,请尽量使纹理符合此格式。

我将使用一张512x512像素的图片,并将其放入资源文件的 textures 文件夹中。别忘了也将图片复制到项目的主文件夹中。

加载图像数据
首先,创建三个整型变量来存储图像的宽度、高度(以像素为单位)和通道数。
int width, height, nrChannels;
然后,使用 stbi_load 函数将图像本身加载到一个名为 data 的无符号字符数组中。需要提供图像的位置和名称,以及我们创建的变量的指针。
unsigned char *data = stbi_load("textures/pop_cat.png", &width, &height, &nrChannels, 0);
图像导入就是这么简单。
创建纹理对象
现在,让我们创建纹理对象本身。就像任何OpenGL对象一样,我们首先创建一个 GLuint 类型的引用变量并命名为 texture。
GLuint texture;
接着,使用 glGenTextures 函数生成纹理对象。需要提供想要生成的纹理数量(本例中为1)以及指向引用变量的指针。
glGenTextures(1, &texture);
创建纹理后,我们也需要在 main 函数结束时删除它。
glDeleteTextures(1, &texture);
绑定纹理单元
现在我们需要将纹理分配到一个纹理单元。你可以将纹理单元想象成一组捆绑在一起的纹理槽。通常一组包含大约16个纹理,允许片段着色器同时处理所有16个纹理。
要将我们的纹理插入纹理单元的槽中,我们只需激活想要使用的纹理单元。

glActiveTexture(GL_TEXTURE0);

然后,将我们的纹理对象绑定到该活动纹理单元的目标上(对于2D纹理是 GL_TEXTURE_2D)。
glBindTexture(GL_TEXTURE_2D, texture);
设置纹理参数
绑定纹理后,我们需要设置一些参数来控制纹理在几何图形上的采样方式。以下是需要设置的主要参数:
- 纹理环绕方式:定义当纹理坐标超出[0, 1]范围时如何处理。
GL_TEXTURE_WRAP_S:水平方向(U轴)的环绕。GL_TEXTURE_WRAP_T:垂直方向(V轴)的环绕。- 常用选项:
GL_REPEAT(重复)、GL_MIRRORED_REPEAT(镜像重复)、GL_CLAMP_TO_EDGE(拉伸边缘像素)、GL_CLAMP_TO_BORDER(使用指定边框颜色)。
- 纹理过滤:定义当纹理被拉伸或缩小时如何采样纹理像素(纹素)。
GL_TEXTURE_MIN_FILTER:纹理缩小时的过滤方式。GL_TEXTURE_MAG_FILTER:纹理放大时的过滤方式。- 常用选项:
GL_NEAREST(最近邻,像素化)、GL_LINEAR(线性,平滑)。
使用 glTexParameteri 函数来设置这些参数。例如,设置水平和垂直方向都重复,并使用线性过滤:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
生成纹理
最后,使用 glTexImage2D 函数将我们加载的图像数据上传到GPU,从而生成最终的纹理。
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
参数说明:
- 目标(
GL_TEXTURE_2D)。 - 多级渐远纹理级别(0表示基本级别)。
- 纹理在OpenGL内部的存储格式(
GL_RGB表示红绿蓝三通道)。 - 纹理的宽度。
- 纹理的高度。
- 历史遗留参数,必须为0。
- 源图像的格式(
GL_RGB)。 - 源图像的数据类型(
GL_UNSIGNED_BYTE表示无符号字节)。 - 指向图像数据的指针。
生成纹理后,可以释放图像数据,因为数据已经上传到GPU。
stbi_image_free(data);
在着色器中使用纹理
要在着色器中使用纹理,我们需要:
- 在顶点着色器中,将纹理坐标作为顶点属性传入。
- 在片段着色器中,声明一个
uniform sampler2D变量来代表纹理,并使用texture函数根据纹理坐标进行采样。 - 在主程序中,通过
glUniform1i将纹理单元(例如GL_TEXTURE0对应数字0)赋值给着色器中的采样器。

总结

本节课中我们一起学习了OpenGL纹理的基础知识。我们介绍了如何安装和使用 stb_image 库加载图像,创建和配置纹理对象,绑定纹理单元,设置纹理参数,以及将图像数据上传到GPU生成纹理。最后,我们还简要提及了在着色器中采样纹理的方法。掌握这些步骤后,你就能为你的3D模型贴上丰富多彩的纹理了。
008:进入三维世界 🚀
概述
在本节课中,我们将告别二维平面,正式进入三维图形世界。我们将学习如何使用矩阵变换将三维物体坐标转换为最终在屏幕上显示的二维图像。核心内容包括理解不同的坐标空间、学习三种关键矩阵(模型、视图、投影矩阵)的作用,以及使用GLM库来简化矩阵运算。
修正先前代码
上一节我们介绍了如何为场景添加纹理。现在,在进入三维世界之前,需要对之前关于顶点数组对象和纹理类的代码进行一个小修正。
我忘记将顶点缓冲对象和着色器输入设置为引用类型。只需像下面这样添加一个“&”符号即可。
// 示例:将VBO和着色器输入设置为引用
void someFunction(VertexBufferObject& vbo, Shader& shader) {
// ... 函数体
}
引入GLM库
上一节我们介绍了纹理,本节中我们来看看如何为三维坐标变换做准备。OpenGL将我们的坐标限制在标准化坐标范围内。为了绕过此限制并为三维坐标提供更广泛的范围,我们可以使用矩阵来缩放不同的坐标。
如果你对矩阵了解不多,仍然可以大致跟上课程,但我强烈建议你先观看3Blue1Brown的线性代数系列视频,这对后续学习会有帮助。
我们将使用一个名为GLM的优秀库来处理矩阵。请按照以下步骤操作:
- 访问描述中提供的网站并点击下载。
- 进入你的库文件夹,然后进入“include”目录。
- 打开下载的ZIP文件,进入“GLM”文件夹。
- 将名为“GLM”的文件夹解压到你的“include”文件夹中。
完成以上步骤后,在你的项目中包含该库的以下部分:
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
现在,我们就可以轻松地使用矩阵了。



坐标系统理论 🧠
为了获得具有透视效果的漂亮三维图像,我们需要将不同的矩阵应用于不同的坐标。让我们来看看这些坐标类型。
以下是几种关键的坐标空间:
-
局部坐标
- 这些坐标的原点与物体自身的原点相同。
- 它们通常位于物体的中心,但并非总是如此。
-
世界坐标
- 这些坐标的原点位于世界的中心。
- 这些坐标通常包含其他物体的位置信息。
-
观察坐标
- 这些坐标的原点与相机或视点的位置相同。
- 请注意,这些坐标尚未考虑透视效果。
-
裁剪坐标
- 这些坐标本质上与观察坐标相同,但它们会进行“裁剪”,即删除标准化范围之外的任何顶点,并且可以处理透视效果。
-
屏幕坐标
- 这是最终的坐标空间,所有内容都被展平,以便在屏幕上显示。
为了从一个坐标系统转换到下一个系统,我们需要使用矩阵。😊
核心变换矩阵
上一节我们介绍了不同的坐标空间,本节中我们来看看连接这些空间的核心工具——变换矩阵。
我们将主要使用三种矩阵:
-
模型矩阵
- 作用:将局部坐标转换为世界坐标。
- 公式:
World Coordinates = Model Matrix * Local Coordinates
-
视图矩阵
- 作用:将世界坐标转换为观察坐标。
- 公式:
View Coordinates = View Matrix * World Coordinates
-
投影矩阵
- 作用:将观察坐标转换为裁剪坐标。
- 公式:
Clip Coordinates = Projection Matrix * View Coordinates
从裁剪坐标到屏幕坐标的最终转换由OpenGL自动完成。我们应用的所有这些矩阵都是4维的,但我们不必自己编写它们,因为GLM库可以处理这些工作。
创建模型矩阵
让我们开始创建模型矩阵。我们首先创建一个名为model、类型为mat4的变量,并将其初始化为单位矩阵。
glm::mat4 model = glm::mat4(1.0f);
这被称为初始化,必须执行此操作,否则矩阵可能包含未定义的值。

总结
本节课中我们一起学习了从二维迈向三维的关键步骤。我们理解了从局部坐标到屏幕坐标的完整变换管线,认识了模型、视图和投影矩阵各自的作用,并成功引入了GLM库来辅助矩阵运算。下一节,我们将利用这些知识,实际创建一个具有三维透视效果的场景。
009:摄像机类 🎥
在本节课中,我们将学习如何创建一个摄像机类,以简化OpenGL中的3D场景观察。我们将封装视图和投影矩阵的生成,并实现键盘和鼠标控制,使摄像机能够在场景中自由移动和观察。
概述
上一节我们成功地将场景从2D转换到了3D。本节中,我们将创建一个摄像机类来管理视图和投影矩阵,并实现基本的移动和视角控制功能,使代码结构更清晰,操作更直观。
创建摄像机类头文件
首先,创建一个名为 camera.h 的头文件。为了防止C++重复包含,我们使用预处理器指令。摄像机类将包含位置、朝向、上方向等核心向量,以及屏幕尺寸、移动速度和视角灵敏度等参数。

以下是 camera.h 文件的核心内容:
#ifndef CAMERA_H
#define CAMERA_H
#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
#include <glm/gtx/rotate_vector.hpp>
#include <glm/gtx/vector_angle.hpp>
class Camera {
public:
// 摄像机位置
glm::vec3 Position;
// 摄像机朝向(方向)
glm::vec3 Orientation = glm::vec3(0.0f, 0.0f, -1.0f);
// 上方向向量
glm::vec3 Up = glm::vec3(0.0f, 1.0f, 1.0f);
// 屏幕宽度和高度
int width;
int height;
// 移动速度
float speed = 0.1f;
// 鼠标灵敏度
float sensitivity = 100.0f;
// 构造函数
Camera(int width, int height, glm::vec3 position);
// 生成并传递矩阵到着色器
void Matrix(float FOVdeg, float nearPlane, float farPlane, GLuint shaderID, const char* uniform);
// 处理输入(键盘和鼠标)
void Inputs(GLFWwindow* window);
};
#endif
实现摄像机类
接下来,创建 camera.cpp 文件来实现摄像机类的功能。构造函数用于初始化基本参数,Matrix 函数负责生成视图和投影矩阵,并将其传递给着色器。
以下是 camera.cpp 中构造函数和矩阵函数的核心实现:
#include "camera.h"
// 构造函数
Camera::Camera(int width, int height, glm::vec3 position) {
this->width = width;
this->height = height;
this->Position = position;
}
// 矩阵函数
void Camera::Matrix(float FOVdeg, float nearPlane, float farPlane, GLuint shaderID, const char* uniform) {
// 初始化矩阵
glm::mat4 view = glm::mat4(1.0f);
glm::mat4 projection = glm::mat4(1.0f);
// 创建视图矩阵
// glm::lookAt(摄像机位置, 目标位置, 上方向向量)
view = glm::lookAt(Position, Position + Orientation, Up);
// 创建投影矩阵
projection = glm::perspective(glm::radians(FOVdeg), (float)(width / height), nearPlane, farPlane);
// 将矩阵导出到着色器
glUniformMatrix4fv(glGetUniformLocation(shaderID, uniform), 1, GL_FALSE, glm::value_ptr(projection * view));
}
集成摄像机到主程序
现在,我们需要在主程序中使用新创建的摄像机类。首先,在 main.cpp 中包含摄像机头文件,并创建一个摄像机对象。然后,在渲染循环中调用摄像机的 Matrix 函数来更新视图。
以下是集成步骤:
- 包含头文件并创建摄像机对象。
- 在渲染循环中删除旧的矩阵生成代码,改用摄像机的
Matrix函数。 - 在顶点着色器中,将旧的矩阵统一变量替换为新的摄像机矩阵。
完成这些步骤后,运行程序,你将看到与上一节相同的3D场景,但代码结构更加清晰。
实现键盘输入控制


为了允许用户通过键盘控制摄像机移动,我们需要在 Camera 类中添加一个 Inputs 函数。该函数将处理WASD键(前后左右移动)、空格键(上升)、Ctrl键(下降)以及Shift键(加速)的输入。
以下是 Inputs 函数中处理键盘控制的核心逻辑:
void Camera::Inputs(GLFWwindow* window) {
// 处理WASD移动
if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS) {
Position += speed * Orientation;
}
if (glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS) {
Position += speed * -glm::normalize(glm::cross(Orientation, Up));
}
if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS) {
Position += speed * -Orientation;
}
if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS) {
Position += speed * glm::normalize(glm::cross(Orientation, Up));
}
// 处理上升和下降
if (glfwGetKey(window, GLFW_KEY_SPACE) == GLFW_PRESS) {
Position += speed * Up;
}
if (glfwGetKey(window, GLFW_KEY_LEFT_CONTROL) == GLFW_PRESS) {
Position += speed * -Up;
}
// 处理加速
if (glfwGetKey(window, GLFW_KEY_LEFT_SHIFT) == GLFW_PRESS) {
speed = 0.4f;
} else {
speed = 0.1f;
}
}
在主程序的渲染循环中调用 camera.Inputs(window) 后,你就可以使用键盘在场景中自由移动了。
实现鼠标输入控制
目前摄像机还无法通过鼠标观察四周。为了实现鼠标控制,我们需要在 Inputs 函数中添加处理鼠标移动的代码。其核心思想是:隐藏鼠标光标,根据鼠标移动的偏移量来调整摄像机的朝向(Orientation)向量。
以下是处理鼠标控制的核心步骤:
- 隐藏鼠标光标并将其锁定在窗口中心。
- 计算当前帧与上一帧之间鼠标位置的偏移量。
- 根据偏移量和灵敏度,调整摄像机在水平和垂直方向上的旋转角度。
- 使用旋转后的角度更新摄像机的朝向向量。
将这部分代码添加到 Inputs 函数中,并在主循环中调用后,你就可以通过移动鼠标来环顾3D场景了。
总结


本节课中,我们一起学习了如何创建一个功能完整的OpenGL摄像机类。我们定义了摄像机的位置、朝向和上方向等属性,并实现了生成视图和投影矩阵的 Matrix 函数。通过集成键盘的WASD控制和鼠标的视角控制,我们使得摄像机能够在3D场景中自由移动和观察。这个摄像机类极大地简化了主程序的代码,并为后续更复杂的交互功能打下了基础。
010:光照
在本节课中,我们将学习如何为OpenGL场景添加基础光照效果。我们将从修改相机类开始,然后引入一个光源立方体,并最终通过计算光线颜色和强度来模拟真实的光照效果。
修改相机类
上一节我们介绍了如何创建相机。本节中,我们首先对相机类进行微调,以便能高效地在多个物体上使用视图矩阵,并确保新的相机功能在主函数中正常工作。
添加光源立方体

接下来,我们将添加一个立方体作为场景中的光源。如果你不清楚如何操作,特别是对光源位置向量和光源模型矩阵的作用有疑问,建议回顾我之前关于坐标系的教程。
以下是创建光源立方体的核心步骤:
- 定义光源立方体的顶点数据。
- 为其创建独立的VAO、VBO和着色器程序。
- 在主渲染循环中,使用光源的模型矩阵(通常只包含平移变换)和视图矩阵绘制它。
启动程序后,你应该能看到你的主物体和一个完全白色的小立方体。
模拟光线颜色

正如你在现实生活中可能注意到的,光线可以拥有多种颜色。通常光线是白色的,因此能显示出物体的真实颜色。但如果光源是红色的,那么所有物体看起来都会偏红。
我们可以通过将物体的颜色与光源的颜色相乘来模拟这一现象。两者都是RGB值。例如,如果光源的RGB值为 (1.0, 0.0, 0.0)(红色),当它与一个橙色物体(例如 (1.0, 0.5, 0.0))的颜色相乘时,绿色和蓝色分量将变为0,而红色分量保持不变。
// 在物体片段着色器中
vec3 objectColor = vec3(1.0, 0.5, 0.0); // 橙色
vec3 lightColor = vec3(1.0, 0.0, 0.0); // 红色光源
vec3 result = objectColor * lightColor; // 结果为 (1.0, 0.0, 0.0),即红色
这模拟了现实世界中只有红色光被反射回来的情况,即使物体本身是橙色的。
现在,让我们创建一个名为 lightColor 的 vec4 变量,并将其设为纯白色。我们需要将这个变量传递给两个着色器:光源的片段着色器和我们主物体的片段着色器。在光源的着色器中,我们直接将其用作立方体的颜色。在主物体的着色器中,我们将用它乘以当前片段的颜色 fColor。
计算光照强度
接下来,我们需要模拟光照的强度。你可能已经注意到,表面与光源之间的夹角越大,该表面的颜色强度就越低。这在一个球体上可以清晰地看到强度沿曲面产生的渐变效果。
为了获取这个夹角并计算强度,我们需要光源的位置(我们已经有了)以及了解表面斜率的方法。传统的做法是用法向量来表示表面的斜率。
理解与添加法向量
到目前为止,我们的顶点数据中包含了坐标、颜色和纹理坐标。现在,我们还需要添加法向量。
法向量是单位向量(即长度为1的向量),它帮助我们计算光线应如何作用于特定物体。法向量可以垂直于单个三角形的表面(称为面法线),也可以以其他方式排列,例如垂直于所有相邻顶点构成的平面(称为顶点法线)。
以下是两种法线类型的区别:
- 面法线:选择这种方式会得到所谓的平面着色,所有三角形清晰可见。
- 顶点法线:选择这种方式,物体看起来会平滑得多,效果更佳。
选择哪一种取决于你的网格模型和艺术风格。由于我们有一个金字塔模型,我们将采用平面着色,因为在立方体或金字塔等棱角分明的几何形状上,平滑着色看起来会很奇怪。
因为金字塔每个面的法线都不同,我们需要为每个面指定正确的法线数据。
总结

本节课中,我们一起学习了OpenGL基础光照的核心步骤。我们首先优化了相机以便复用,然后创建了一个可视化的光源。接着,我们通过将物体颜色与光源颜色相乘来模拟有色光的效果。最后,我们引入了法向量的概念,它是计算光照强度(如漫反射)的基础。下一节,我们将利用这些法向量来计算具体的漫反射光照模型。
012:光源类型 💡
在本节课中,我们将学习OpenGL中三种主要的光源类型:点光源、定向光和聚光灯。我们将了解它们的基本概念、实现原理,并学习如何通过代码来控制它们的光照效果。
概述
上一节我们改进了镜面高光效果。本节中,我们来看看不同类型的光源。主要有三种类型的光源:点光源、定向光和聚光灯。点光源向所有方向照亮环境,但其光照强度会随着距离增加而衰减。这是我们目前一直在使用的光源类型,只是之前没有加入强度衰减。现在让我们快速实现它。
点光源
首先,我们创建一个名为 pointLight 的向量函数,将主函数中的所有相关代码复制粘贴进去,并让 fragColor 等于这个函数的输出。


在现实生活中,光照强度与距离成反平方关系。但在计算机图形学中,我们使用一个更复杂的方程来更好地控制光源的属性。我们不是使用 1 / 距离²,而是使用一个关于距离的二次方程。
这个二次方程有两个常数:A(二次项)和 B(一次项)。我们可以修改这些常数来改变强度衰减的速度以及光线到达的距离。对于这些常数,没有通用的完美数值,你需要根据场景进行调整。需要注意的是,如果你希望光线能照射到较远的地方,这些数值通常小于1。
为了实现这个效果,我们首先需要计算到光源的距离。距离就是光源位置减去当前位置所得向量的长度。由于我们在漫反射光照中已经使用了这个向量,我将创建一个名为 lightVec 的变量,这样我们就不需要计算两次了。
现在,我们可以简单地使用 length 函数来获取距离。最后,我们写出方程,使用变量进行加权,并将其应用到镜面高光和漫反射光照上。
以下是我使用不同常数得到的结果示例。
定向光
第二种光源是定向光。这种光通常被认为距离场景非常遥远,以至于它发出的光线基本上是平行的,就像太阳光一样。它没有衰减,实际上是三种光源中最容易实现的。
我们只需要从点光源代码中复制粘贴部分代码,然后不再基于位置计算光线方向,而是直接给它一个归一化的 vec3 常量作为光线方向。需要注意的是,由于我在这里编写代码的方式,它应该指向与你希望光照效果相反的方向。所以,如果你希望光线从上方照射,它应该指向上方,而不是下方。如果你运行程序,你会看到一切都按预期被照亮。
聚光灯
最后一种光源是聚光灯。聚光灯只照亮一个锥形区域,就像手电筒或舞台聚光灯一样。为此,我们将再次复制粘贴点光源的代码,并首先添加两个浮点数,它们将代表两个角度的余弦值。
第一个角度是内锥与光线方向之间的夹角,第二个角度是外锥与光线方向之间的夹角。我们利用这两个锥体来在黑暗区域和明亮区域之间形成平滑的渐变。因为如果我们只使用一个锥体,光线到黑暗的过渡会非常生硬。
为了节省计算资源,我们直接使用余弦值,而不是用角度来表示内锥和外锥,因为那样需要额外的计算,会降低程序速度。
现在我们需要计算当前片段位置与光线方向之间的夹角的余弦值。如果这个余弦值大于内锥的余弦值,那么片段就在内锥内,应该被完全照亮。如果它小于外锥的余弦值,那么片段就在聚光灯范围之外,应该完全黑暗。如果它介于两者之间,我们就使用一个平滑的插值函数来计算光照强度。
总结

本节课中,我们一起学习了OpenGL中的三种主要光源类型。我们实现了点光源的距离衰减,了解了定向光的平行光线特性,并构建了具有平滑过渡边缘的聚光灯。通过调整各种参数,你可以为你的场景创造出丰富多样的光照效果。
013:网格类 🧱
在本节课中,我们将整合之前创建的所有类以及大部分核心功能,构建一个网格类。这个类将作为未来教程中导入3D模型的基础。
概述
网格是3D图形中的基本单元。它通常包含顶点数据、索引数据,有时还包含纹理数据。本节课我们将学习如何创建一个封装这些数据的Mesh类,并实现其绘制功能。
网格类的定义
首先,我们为网格类创建一个头文件。网格类需要管理三类数据:顶点、索引和纹理。由于我们无法预先知道这些数据的大小,因此使用C++的std::vector容器来存储它们,以保证灵活性。
以下是网格类的基本结构:
#include <string>
#include <vector>
#include "VBO.h"
#include "EBO.h"
#include "Camera.h"
#include "Texture.h"
class Mesh {
public:
std::vector<Vertex> vertices;
std::vector<GLuint> indices;
std::vector<Texture> textures;
VAO VAO;
Mesh(std::vector<Vertex>& vertices, std::vector<GLuint>& indices, std::vector<Texture>& textures);
void Draw(Shader& shader, Camera& camera);
};
注意,这里的vertices向量存储的是Vertex结构体,而不是原始的浮点数数组。这使数据组织更清晰。


顶点结构体


上一节我们定义了网格类的框架,但其中用到了一个尚未定义的Vertex结构体。本节我们来创建它。

一个顶点通常包含以下信息:
- 位置
- 法线
- 颜色
- 纹理坐标
将这些数据打包到一个结构体中,比使用一个扁平的浮点数数组更易于管理。结构体定义如下:
#include <glm/glm.hpp>
struct Vertex {
glm::vec3 position;
glm::vec3 normal;
glm::vec3 color;
glm::vec2 texUV;
};
修改VBO和EBO类
定义了顶点结构体后,我们需要更新VBO(顶点缓冲对象)和EBO(元素缓冲对象)类,使其能够接受std::vector<Vertex>作为输入,而不是原始的GLfloat数组。
这样修改的好处是,我们不再需要手动计算数据大小,std::vector可以帮我们管理。
以下是VBO构造函数修改后的核心代码:
VBO::VBO(std::vector<Vertex>& vertices) {
glGenBuffers(1, &ID);
glBindBuffer(GL_ARRAY_BUFFER, ID);
glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), vertices.data(), GL_STATIC_DRAW);
}
对EBO类进行类似的修改,使其接受std::vector<GLuint>作为索引数据。
实现网格类构造函数


现在,让我们在.cpp文件中实现网格类的具体功能。首先从构造函数开始。

构造函数接收顶点、索引和纹理向量,并将它们存储到类的成员变量中。接着,它需要初始化VAO(顶点数组对象),并设置顶点属性指针。


以下是构造函数的核心步骤:

- 将传入的数据赋值给成员变量。
- 生成并绑定VAO、VBO和EBO。
- 将顶点数据复制到VBO中。
- 将索引数据复制到EBO中。
- 设置顶点属性指针,告诉OpenGL如何解析顶点数据。
我们按照位置 -> 法线 -> 颜色 -> 纹理坐标的顺序来布局顶点属性。虽然顺序不是强制的,但保持一致性会让代码更清晰。
Mesh::Mesh(std::vector<Vertex>& vertices, std::vector<GLuint>& indices, std::vector<Texture>& textures) {
this->vertices = vertices;
this->indices = indices;
this->textures = textures;
VAO.Bind();
VBO VBO(vertices);
EBO EBO(indices);
// 位置属性 (第0个属性,vec3)
VAO.LinkAttrib(VBO, 0, 3, GL_FLOAT, sizeof(Vertex), (void*)0);
// 法线属性 (第1个属性,vec3)
VAO.LinkAttrib(VBO, 1, 3, GL_FLOAT, sizeof(Vertex), (void*)(3 * sizeof(float)));
// 颜色属性 (第2个属性,vec3)
VAO.LinkAttrib(VBO, 2, 3, GL_FLOAT, sizeof(Vertex), (void*)(6 * sizeof(float)));
// 纹理坐标属性 (第3个属性,vec2)
VAO.LinkAttrib(VBO, 3, 2, GL_FLOAT, sizeof(Vertex), (void*)(9 * sizeof(float)));
VAO.Unbind();
VBO.Unbind();
EBO.Unbind();
}
实现绘制函数
最后,我们实现网格类的Draw函数。这个函数负责在每一帧中渲染网格。
它的主要任务是:
- 激活并绑定着色器程序。
- 将相机矩阵(视图和投影矩阵)传递给着色器。
- 绑定网格的VAO。
- 如果网格有纹理,则激活并绑定它们。
- 调用
glDrawElements进行绘制。
void Mesh::Draw(Shader& shader, Camera& camera) {
shader.Activate();
VAO.Bind();
// 传递相机矩阵
glUniformMatrix4fv(glGetUniformLocation(shader.ID, "view"), 1, GL_FALSE, glm::value_ptr(camera.viewMatrix));
glUniformMatrix4fv(glGetUniformLocation(shader.ID, "projection"), 1, GL_FALSE, glm::value_ptr(camera.projectionMatrix));
// 绑定纹理(如果有的话)
for (unsigned int i = 0; i < textures.size(); i++) {
textures[i].Bind();
// 可以将纹理单元位置传递给着色器,例如:
// glUniform1i(glGetUniformLocation(shader.ID, "tex0"), i);
}
// 绘制网格
glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0);
VAO.Unbind();
}
更新着色器
由于我们改变了顶点属性的布局顺序,需要相应地更新顶点着色器和片段着色器,以正确接收这些属性。
在顶点着色器中,按照位置、法线、颜色、纹理坐标的顺序声明输入变量:
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec3 aColor;
layout (location = 3) in vec2 aTex;
// ... 其余代码
在片段着色器中,可以相应地使用这些传递过来的变量。
总结
本节课中,我们一起学习了如何创建一个功能完整的网格类。我们定义了Vertex结构体来整洁地组织顶点数据,修改了VBO和EBO类以支持向量输入,并实现了网格的初始化和绘制逻辑。

这个Mesh类成功地将顶点缓冲、索引缓冲、纹理和绘制命令封装在一起,为后续加载复杂的3D模型打下了坚实的基础。现在,你可以使用这个类来轻松创建和渲染任何由三角形构成的3D物体了。
014:模型加载 🎮
在本教程中,我们将学习如何通过构建一个基础的导入器,将3D模型导入到你的OpenGL应用程序中。本教程会比往常更长,并且只有在最后才能看到可见的结果,因此请保持耐心并注意我的操作步骤,否则你可能会在最后遇到一连串的错误。
首先需要说明一点。你可能知道,在存储图像方面,行业标准是使用PNG和JPEG等格式。但对于3D模型,存在数十种甚至更多的文件格式,其中许多是专有的。这种文件格式的多样性使得在不同程序之间导出和导入模型变得更加困难。本着标准化的精神,本教程将使用GLTF文件格式,因为它是由创建OpenGL的Khronos集团开发的,并且看起来是一个有前途、未来可能成为标准的格式。我在描述中留下了一个由Godot引擎开发者撰写的关于此格式的优秀文章链接。
现在进入实际教程。GLTF使用JSON文件结构,因此我们需要做的第一件事是从GitHub安装Neil Slaughterman的JSON库,以便能够解析JSON文件。库的准备工作就绪后,我们就可以为模型类创建一个头文件,模型即一组网格。然后,我们将创建一个构造函数,它接收一个文件名;以及一个绘制函数,它接收一个着色器和一个相机。在私有部分,我们将存储文件名、一个包含模型所有数据的顶点向量,以及一个稍后会详细解释的JSON对象。
现在,在模型的CPP文件中,让我们在构造函数中使用与读取着色器相同的函数来读取GLTF文件。然后我们解析文本并将其存储在JSON变量中。JSON文件的工作原理类似于字典中的字典。字典有键和与这些键关联的值。如果你给字典一个键,它会指向某个值。这个JSON对象将GLTF文件抽象成这样的结构。所以,我们只需存储文件,然后将我们的数据变量赋值给一个名为getData的函数。
我们希望这个函数从一个外部的二进制数据文件中获取一个顶点向量。因此,我们创建一个字符串变量rawText来保存原始文本。为了获取文件的位置,我们可以查看buffer键,它将指向一个数组,我们想查看第一个元素,这又是一个字典。所以我们再次使用一个键,这次是uri键。这个uri给我们一个包含二进制数据的.bin文件的名称。然后我们获取文本,将其放入向量中并返回。


现在你已经初步了解了文件结构,让我们更仔细地看一下。该文件包含字典,这些字典又包含其他字典,使其成为一种树状结构,但这棵树并不规整。因为许多分支会包含索引,这些索引将指向其他分支,所以事情会变得相当复杂。
为了避免在这种情况下的混淆,我喜欢从叶子节点开始,然后逐步向下处理到树的根部。请记住,我将在GLTF文件中采取一些捷径,否则这个导入器会变得过于复杂。抛开这些,下图是树的一个主要分支的简化视图,也是我们最关心的部分。在顶部,我们有存储在缓冲区中的数据,但要知道应该读取哪些部分,我们需要查看访问器和缓冲区视图。

上一节我们介绍了GLTF文件的基本结构和数据获取方法。本节中,我们来看看如何通过访问器和缓冲区视图来解析模型的具体几何数据。
访问器定义了如何解释缓冲区视图中的数据。它包含诸如数据类型、偏移量、步长和计数等信息。缓冲区视图则指向缓冲区中的特定数据块,并指定其长度和偏移量。
以下是解析网格数据的关键步骤:
- 遍历网格:GLTF文件中的
meshes数组包含了所有网格。我们需要遍历这个数组。 - 获取图元:每个网格包含一个或多个图元。图元定义了网格的几何形状(如三角形)和所使用的属性(如位置、法线、纹理坐标)。
- 解析属性:对于每个图元,我们需要解析其属性。这涉及到查找对应的访问器,然后通过访问器找到缓冲区视图,最终从二进制数据中提取出顶点数据。
- 处理索引:如果图元使用了索引绘制,我们还需要解析索引数据。索引数据也通过一个访问器来定义。

为了简化,我们的基础导入器将专注于加载顶点位置、法线和纹理坐标。我们将创建一个Mesh类来存储这些数据,并在Model类中管理多个Mesh实例。
现在,让我们看看如何从JSON结构中提取一个网格的顶点位置数据。假设我们已经有了一个指向网格图元的JSON对象primitive:
// 伪代码,展示思路
int positionAccessorIndex = primitive["attributes"]["POSITION"];
json positionAccessor = accessors[positionAccessorIndex];
int bufferViewIndex = positionAccessor["bufferView"];
json bufferView = bufferViews[bufferViewIndex];
int bufferIndex = bufferView["buffer"];
int byteOffset = bufferView["byteOffset"]; // 在缓冲区中的偏移
int byteLength = bufferView["byteLength"]; // 数据长度
int target = bufferView["target"]; // 目标(如 ARRAY_BUFFER)
// 根据访问器中的类型(如"VEC3")和组件类型(如5126代表GL_FLOAT)
// 从之前获取的二进制数据向量中,从 byteOffset 处开始,读取 byteLength 长度的数据,
// 并将其解释为一系列浮点数,填充到顶点向量中。
通过类似的方式,我们可以提取法线、纹理坐标和索引数据。将所有必要的数据提取并组织到Mesh对象的顶点数组和索引数组中后,我们就可以像之前渲染自定义几何体一样,为每个网格设置VAO、VBO和EBO。
最后,在Model的Draw函数中,我们遍历所有存储的Mesh对象,并调用它们各自的绘制函数,同时传入着色器和相机所需的变换矩阵(如模型矩阵)。

本节课中我们一起学习了如何加载GLTF格式的3D模型。我们从安装JSON库开始,解析了GLTF的JSON结构,理解了缓冲区、缓冲区视图和访问器之间的关系,并逐步提取了顶点位置、法线、纹理坐标和索引数据来构建网格。虽然这是一个基础导入器,但它揭示了模型加载的核心原理。通过扩展此导入器,你可以支持更多属性、动画和材质,从而在OpenGL应用中渲染复杂的3D场景。
015:深度缓冲区 🧊
在本节课中,我们将学习OpenGL中的深度缓冲区,并了解如何利用它实现一个简单的图形效果。深度缓冲区是处理3D场景中物体前后遮挡关系的关键组件。
概述
你可能还记得,在之前的“进入3D世界”教程中,我们已经使用深度缓冲区解决了一个奇怪的渲染问题。深度缓冲区默认是关闭的,因此我们需要确保启用它,并像清空颜色缓冲区一样,在每一帧清空深度缓冲区。
深度缓冲区原理
深度缓冲区的基本作用是存储深度值。这些深度值表示特定片段距离投影矩阵近裁剪平面的远近程度。深度值0表示片段正好在近裁剪平面上,而深度值1表示片段在远裁剪平面上。
利用这些深度信息,我们可以判断哪个物体应该显示在另一个物体的前面。
深度测试函数
我们可以通过使用glDepthFunc函数并传入以下参数之一来实现深度测试。默认情况下,OpenGL选择GL_LESS。这意味着如果一个物体的深度值小于当前深度缓冲区中的值,那么前者的片段将替换后者的片段。
在大多数情况下,你应该使用GL_LESS。但如果你想让你的游戏或应用产生迷幻效果,也可以选择其他函数。
以下是可用的深度测试函数:
GL_ALWAYS:总是通过测试。GL_NEVER:永远不通过测试。GL_LESS:片段深度值小于缓冲区值时通过(默认)。GL_EQUAL:片段深度值等于缓冲区值时通过。GL_LEQUAL:片段深度值小于或等于缓冲区值时通过。GL_GREATER:片段深度值大于缓冲区值时通过。GL_NOTEQUAL:片段深度值不等于缓冲区值时通过。GL_GEQUAL:片段深度值大于或等于缓冲区值时通过。
可视化深度缓冲区
现在来看一个有趣的部分:可视化深度缓冲区。我们可以轻松地在片段着色器中,将gl_FragCoord.z作为颜色值输出来实现。
void main()
{
FragColor = vec4(vec3(gl_FragCoord.z), 1.0);
}
但问题是,一旦运行程序,你会发现屏幕几乎是纯白色的。只有非常靠近物体时,才能看到一点暗色。
非线性深度
这是因为OpenGL中的深度是非线性的。如果深度是线性的,那么我们在近距离和远距离将拥有相同的深度精度。
由于我们几乎总是关注靠近我们的物体,因此我们希望近处的精度非常高,而远处的精度较低。这是通过以下公式实现的:
公式:
depth = (1/z - 1/near) / (1/far - 1/near)

不用担心,我们不需要手动实现它,因为OpenGL会自动处理。
线性化深度值
不过,有时你可能想使用另一个公式。为此,我们必须首先通过线性化深度函数来获取Z值。这可以使用以下函数完成:
float LinearizeDepth(float depth, float near, float far)
{
float z = depth * 2.0 - 1.0; // 从[0,1]映射回NDC[-1,1]
return (2.0 * near * far) / (far + near - z * (far - near));
}
使用这个函数,我们得到了Z值。请记住,这个值没有归一化,它只是到近裁剪平面的距离。
现在,让我们清除之前设置的近平面和远平面常量,并将线性深度除以远平面距离来快速归一化它,看看结果如何。
float linearDepth = LinearizeDepth(gl_FragCoord.z, near_plane, far_plane);
linearDepth /= far_plane; // 为了演示进行归一化
FragColor = vec4(vec3(linearDepth), 1.0);

Z冲突问题
接下来,让我们看一个使用深度缓冲区时可能遇到的常见问题,然后我将展示一个利用深度缓冲区实现的酷炫效果。
深度缓冲区引发的主要问题称为Z冲突。当两个或多个三角形具有非常接近甚至相同的深度值时,深度测试函数无法决定哪一个更近,从而导致它们在渲染时不断闪烁切换。
一个简单的修复方法通常是确保没有三角形彼此过于接近且平行。如果Z冲突出现在远距离,那么你也可以考虑调整深度缓冲区的函数,以在该距离上获得更高的精度。最后一个技巧是使用更大位数的整数作为深度缓冲区,默认通常使用24位。
利用深度缓冲区的效果
现在,展示一个利用深度缓冲区实现的酷炫效果。我们可以将线性化的深度值用于后处理效果,例如创建雾效或边缘检测。
一个简单的雾效实现如下:
void main()
{
// ... 计算颜色和深度 ...
float fogAmount = smoothstep(fogNear, fogFar, linearDepth);
FragColor = mix(objectColor, fogColor, fogAmount);
}

总结


本节课中,我们一起学习了OpenGL深度缓冲区的核心概念。我们了解了它的工作原理、如何通过glDepthFunc控制深度测试、深度值的非线性特性及其线性化方法。我们还探讨了Z冲突问题及其解决方案。最后,我们看到了深度值如何被用于实现诸如雾效等后处理图形效果。掌握深度缓冲区是渲染正确3D场景的基础。
OpenGL教程:P16:模板缓冲与模型描边
在本节课中,我们将学习模板缓冲的基本概念,并探讨如何利用它来实现一个实用的视觉效果:为模型添加轮廓描边。
模板缓冲与深度缓冲类似,它为屏幕上的每个像素存储一个值,通常用于图像遮罩。与深度缓冲每个像素存储2到4字节数据不同,模板缓冲的每个像素只存储1字节数据。这意味着其取值范围是0到255,但在实际应用中,主要只使用0和1这两个值。
上一节我们介绍了模板缓冲的基本概念,本节中我们来看看如何操作这个缓冲。
首先,我们使用函数 glStencilMask 来选择允许修改模板缓冲的哪些部分。该函数对遮罩像素和模板缓冲中对应的像素进行按位与(bitwise AND)比较。每个像素有1字节(8位)数据。按位与操作会比较两个数的每一位,只有当对应位都为1时,结果的该位才为1。
以下是 glStencilMask 的两种典型用法:
- 如果传入
0x00(即8位全为0),所有比较都会失败,模板缓冲将完全不会被修改。 - 如果传入
0xFF(即8位全为1),则可以修改模板缓冲的任何部分。
接下来,我们介绍另外两个关键函数:glStencilFunc 和 glStencilOp。
glStencilFunc 函数用于控制模板测试如何通过或失败。它接受三个参数:一个比较函数、一个参考值和一个遮罩。默认的比较函数是 GL_ALWAYS,表示测试总是通过。参考值是我们用于比较的基准值。在比较模板值与参考值之前,会先使用遮罩对两者进行按位与操作。因此,若想进行准确的数值比较,通常应将遮罩设置为 0xFF。
glStencilOp 函数则用于指定在三种情况下对模板缓冲执行什么操作。它同样接受三个参数,分别对应:
- 模板测试失败时(
sfail) - 模板测试通过但深度测试失败时(
dpfail) - 模板测试和深度测试都通过时(
dppass)
对于这些情况,你可以从多个选项中选择操作,默认值都是 GL_KEEP,即保持模板缓冲值不变。
关于这些函数的更多细节,建议查阅官方文档以获取最全面的信息。
模板缓冲可以用于实现多种效果,例如传送门、镜子等。一个相对容易实现的功能是为模型添加轮廓。下面我们来看看具体步骤。
首先,像往常一样渲染你的模型对象,并同时更新模板缓冲:在模型片段覆盖的每个像素位置,将模板值设为1;在其他位置,将模板值设为0。这本质上会创建一个模型的“剪影”。




接着,我们需要禁用对深度缓冲和颜色缓冲的写入,并稍微放大模型进行第二次渲染。这次,我们设置模板测试,使其仅在模板值不等于1的地方通过(即只渲染原始模型轮廓之外的区域)。这样,一个放大的、单色的模型轮廓就会绘制在原始模型周围,从而实现描边效果。




本节课中我们一起学习了模板缓冲的工作原理,掌握了 glStencilMask、glStencilFunc 和 glStencilOp 等核心函数的使用方法,并最终实现了一个为3D模型添加轮廓描边的实用效果。
OpenGL教程:P17:面剔除与FPS计数器

在本节课中,我们将学习OpenGL中的面剔除技术,了解它如何提升渲染性能。同时,我们将通过制作一个FPS计数器来量化这一性能变化。
面剔除是图形渲染管线中的一个步骤,它决定一个三角形是否继续传递到片段着色器,即是否被绘制。OpenGL通过判断三角形的哪一面朝向摄像机来做出决定。在大多数3D图形程序中,三角形的前面会被发送到片段着色器,而背面则被丢弃。
OpenGL通过索引顺序约定来判断哪一面是前面。这个约定可以是顺时针或逆时针。在逆时针约定下,如果一个三角形的索引顺序在面向我们时是逆时针的,那么我们看到的这一面就是前面。反之,如果索引顺序是顺时针的,我们看到的就是背面。对于顺时针约定,情况则完全相反。大多数图形程序使用逆时针标准,但并非所有程序都如此。
上一节我们介绍了面剔除的基本概念,本节中我们来看看如何将其转化为代码。
以下是启用面剔除的步骤:
- 使用
glEnable(GL_CULL_FACE)启用面剔除功能。 - 使用
glCullFace指定要保留的面。在99%的情况下,这会是GL_FRONT(前面)。 - 使用
glFrontFace指定使用的标准。建议使用GL_CCW(逆时针),因为它更为常见。
运行程序后,你会注意到当进入一个物体内部时,将无法看到其内部结构。这是因为构成物体内部的三角形背面已被剔除,我们只能看到背景。
为了衡量面剔除带来的性能差异,我们需要一个FPS计数器,并将其显示在窗口标题栏。让我们从创建几个变量开始。
以下是创建FPS计数器所需的变量:
previousTime:记录上一帧的时间。currentTime:记录当前帧的时间。timeDifference:记录两帧之间的时间差。frameCounter:一个无符号整数,用于统计特定时间段内的帧数。
FPS即每秒帧数。要计算FPS,我们可以统计在一秒内获得的帧数(一帧即主循环的一次迭代)。但这意味着FPS每秒才更新一次。我们可以改为每1/30秒更新一次。
以下是计算FPS和每帧耗时的逻辑:
- 使用
glfwGetTime()获取当前时间(秒)。 - 计算与上一帧的时间差,并递增帧计数器。
- 如果时间差大于或等于1/30秒,则进行测量:
- FPS计算公式:
FPS = frameCounter / timeDifference - 每帧耗时(毫秒)计算公式:
frameTime = (timeDifference / frameCounter) * 1000
- FPS计算公式:
- 将新的标题(包含FPS和帧耗时)通过
glfwSetWindowTitle设置给窗口。 - 将
previousTime更新为currentTime,并将frameCounter重置为0,为下一个测量周期做准备。
启动程序后,你就能在窗口标题栏看到实时的帧率信息。

本节课中我们一起学习了面剔除的原理与实现,它通过丢弃不可见的三角形背面来提升渲染效率。同时,我们构建了一个FPS计数器来监测性能变化,掌握了计算帧率和每帧耗时的方法。
OpenGL教程:P18:透明度与混合
在本节课中,我们将学习如何在OpenGL中快速启用透明度效果,并利用混合功能实现半透明物体的渲染。
概述
到目前为止,我们使用的所有图片都包含四个分量:红、绿、蓝和Alpha。前三个分量决定了场景的颜色,而最后一个分量Alpha则控制着物体的透明度。本节课将首先介绍如何通过片段着色器实现基础的透明度效果,然后深入讲解OpenGL的混合机制,并解决渲染顺序带来的问题。
启用基础透明度
上一节我们介绍了纹理的基础知识。本节中,我们来看看如何让一个物体变得透明。以导入的草地模型为例,默认情况下它是不透明的。
为了启用透明度,我们需要创建一个新的片段着色器。这个着色器与我们常规的片段着色器几乎相同,但会添加一个关键步骤:检查Alpha值。如果Alpha值低于某个阈值,我们就丢弃该片段。
以下是实现此逻辑的着色器代码示例:
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;


uniform sampler2D texture1;


void main()
{
vec4 texColor = texture(texture1, TexCoords);
if(texColor.a < 0.1) // 如果Alpha值小于0.1,则丢弃该片段
discard;
FragColor = texColor;
}
不要忘记为这个新的片段着色器创建一个新的着色器程序。运行程序后,草地应该会按预期显示为透明。

接着,我添加了一组随机放置的透明窗户。用于渲染窗户的着色器非常简单,仅显示纹理,不包含任何光照计算。
现在,虽然窗户已经显示出来,但即使纹理本身是透明的,窗户看起来仍然不透明。为了实现“透视”效果,我们需要使用混合功能。
理解混合理论
混合是将源颜色(当前片段着色器输出的颜色)与目标颜色(当前颜色缓冲区中已有的颜色)结合的过程。OpenGL使用以下公式进行混合:
最终颜色 = (源颜色因子 × 源颜色) + (目标颜色因子 × 目标颜色)
其中:
- 源颜色:当前片段着色器输出的颜色。
- 目标颜色:颜色缓冲区中已存在的颜色。
- Alpha值:0表示完全透明,1表示完全不透明。
最常见的配置是让源颜色因子等于源颜色的Alpha值,而目标颜色因子等于 1 - 源颜色的Alpha值。这样,最终颜色就是源颜色和目标颜色的加权混合。


配置OpenGL混合


现在,让我们告诉OpenGL使用上述混合配置。
首先,使用 glBlendFunc 函数指定源因子和目标因子:
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
以下是其他一些可能用到的混合因子选项:
GL_ZERO:因子为0GL_ONE:因子为1GL_SRC_COLOR:因子等于源颜色向量GL_ONE_MINUS_SRC_COLOR:因子等于1 - 源颜色向量GL_DST_COLOR:因子等于目标颜色向量GL_ONE_MINUS_DST_COLOR:因子等于1 - 目标颜色向量GL_SRC_ALPHA:因子等于源颜色的Alpha值GL_ONE_MINUS_SRC_ALPHA:因子等于1 - 源颜色的Alpha值
其次,你还可以使用 glBlendEquation 来指定颜色组合方式,默认是 GL_FUNC_ADD(将源和目标相加):
glBlendEquation(GL_FUNC_ADD);
其他选项包括 GL_FUNC_SUBTRACT(相减)和 GL_FUNC_REVERSE_SUBTRACT(反向相减)。
此外,glBlendFuncSeparate 函数允许为RGB通道和Alpha通道分别设置混合因子,提供了更精细的控制。
最后,在渲染透明物体(如窗户)之前,启用混合;渲染完成后,立即禁用它,以免影响其他不透明物体的渲染。
glEnable(GL_BLEND);
// ... 渲染所有透明物体 ...
glDisable(GL_BLEND);
对于透明物体,应始终遵循此模式。
解决深度测试问题
编译并运行程序后,窗户现在应该是透明的了,但你可能发现混合效果看起来不正确,显得混乱。

这个问题源于我们的老朋友——深度缓冲区。由于窗户是随机绘制的,后绘制的窗户可能会覆盖先绘制的窗户,即使它在现实中应该位于其后。深度测试仅根据深度值决定是否写入片段,而不考虑透明度,这导致了错误的遮挡关系。


解决方案是:先绘制所有不透明物体,然后禁用深度写入(但保持深度测试启用),最后按照从远到近的顺序绘制所有透明物体。

具体步骤如下:
- 绘制所有不透明物体。
- 使用
glDepthMask(GL_FALSE)禁用深度缓冲区的写入操作。 - 对透明物体进行排序,按照从后(距离摄像机远)到前(距离摄像机近)的顺序进行绘制。
- 绘制所有排序后的透明物体。
- 使用
glDepthMask(GL_TRUE)重新启用深度写入。
这样,远处的透明片段会先与颜色缓冲区混合,然后近处的片段再覆盖上去,从而得到正确的视觉效果。
总结
本节课中我们一起学习了OpenGL中的透明度与混合。
- 我们首先通过片段着色器丢弃低Alpha片段来实现基础的透明效果。
- 接着,深入理解了OpenGL的混合公式,并学会了如何使用
glBlendFunc和glBlendEquation来配置混合。 - 最后,我们识别并解决了由深度测试引起的透明物体渲染顺序问题,关键步骤是先绘制不透明物体,然后按从远到近的顺序绘制透明物体,并在绘制透明物体时禁用深度写入。


掌握这些技术后,你就能在场景中正确地渲染窗户、玻璃、水等半透明物体了。
019:帧缓冲与后期处理 🖼️
在本节课中,我们将学习如何在OpenGL应用程序中实现自定义帧缓冲,并利用帧缓冲实现后期处理效果。
概述
帧缓冲是多个缓冲区的集合,它最终生成你在屏幕上看到的图像。它包含颜色缓冲、深度缓冲和模板缓冲。通过创建自定义帧缓冲,我们可以将渲染结果绘制到一个覆盖整个屏幕的矩形上,然后使用着色器修改矩形上的像素,从而实现各种效果。这种方法被称为“后期处理”,因为所有渲染完成后才处理像素。
帧缓冲的实现
上一节我们介绍了帧缓冲的基本概念,本节中我们来看看如何具体实现一个帧缓冲。
与任何OpenGL对象类似,我们首先创建一个无符号整数来标识帧缓冲对象,然后生成并绑定它。
unsigned int framebuffer;
glGenFramebuffers(1, &framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
至此,帧缓冲对象创建完成。但要使它有用,我们还需要为其附加颜色附件。
颜色附件
以下是创建并附加颜色纹理附件的步骤。
我们创建一个纹理,就像在纹理教程中一样。需要确保将纹理的环绕方式设置为“夹取到边缘”,否则某些效果会因纹理默认的重复行为而从屏幕一侧“溢出”到另一侧。
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

然后,我们使用glFramebufferTexture2D将纹理附加到帧缓冲上。我们将颜色存储在纹理中,这样我们就可以在着色器中访问它,这对于后期处理是必需的。


glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
深度与模板附件
对于深度缓冲,在本教程中我们并不需要在着色器中读取它。因此,我们可以使用渲染缓冲对象,它比纹理附件更快,但缺点是无法直接在着色器中读取。
以下是创建并附加渲染缓冲对象的步骤。
我们使用glGenRenderbuffers创建渲染缓冲对象,然后使用glRenderbufferStorage配置其存储。这里我们使用GL_DEPTH24_STENCIL8内部格式,以便同时存储深度和模板数据。
unsigned int rbo;
glGenRenderbuffers(1, &rbo);
glBindRenderbuffer(GL_RENDERBUFFER, rbo);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, width, height);
然后,我们将其附加到帧缓冲的深度和模板附件点上。
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);
非常重要的一点是,帧缓冲的所有附件(颜色纹理和渲染缓冲)必须具有相同的宽度和高度,否则可能会出错。
为了进行错误检查,可以添加以下代码。遗憾的是,OpenGL的错误信息通常不具体,只提供一个错误码。
if(glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
std::cout << "ERROR::FRAMEBUFFER:: Framebuffer is not complete!" << std::endl;
以下是可能遇到的错误码及其含义:
GL_FRAMEBUFFER_UNDEFINED:指定的帧缓冲是默认帧缓冲,但它不存在。GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT:至少一个附件点不完整。GL_FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT:帧缓冲没有附加任何图像。GL_FRAMEBUFFER_INCOMPLETE_DRAW_BUFFER:glDrawBuffers调用中指定的颜色附件点没有图像附着。GL_FRAMEBUFFER_INCOMPLETE_READ_BUFFER:GL_READ_BUFFER指定的附件点没有图像附着。GL_FRAMEBUFFER_UNSUPPORTED:附着的图像内部格式组合不受支持。GL_FRAMEBUFFER_INCOMPLETE_MULTISAMPLE:所有图像的采样数不同。GL_FRAMEBUFFER_INCOMPLETE_LAYER_TARGETS:某些图像不是分层纹理,或者附加到了多个层。

屏幕矩形与着色器

现在我们已经有了帧缓冲对象,接下来需要创建一个覆盖整个屏幕的矩形。这个矩形不需要任何变换(如模型、视图、投影矩阵),因为它直接对应屏幕空间。
然后,我们编写两个非常基础的着色器来从帧缓冲纹理中读取颜色,并将它们链接成一个着色器程序。在着色器中,我们只需要采样我们附加的颜色纹理。
顶点着色器示例:
#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec2 aTexCoords;
out vec2 TexCoords;
void main()
{
gl_Position = vec4(aPos.x, aPos.y, 0.0, 1.0);
TexCoords = aTexCoords;
}
片段着色器示例:
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D screenTexture;
void main()
{
FragColor = texture(screenTexture, TexCoords);
}
在程序中,我们需要将纹理单元0(GL_TEXTURE0)传递给着色器,因为这是此着色器中唯一的纹理。
shader.use();
shader.setInt("screenTexture", 0);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture); // 绑定我们之前创建的帧缓冲纹理
渲染流程

现在让我们来处理绘制部分。以下是使用自定义帧缓冲进行渲染的步骤。

首先,在绘制任何物体(包括背景)之前,确保绑定我们自定义的帧缓冲。同时,确保清空缓冲并启用深度测试。
// 1. 绑定到自定义帧缓冲并绘制场景
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
glEnable(GL_DEPTH_TEST); // 启用深度测试
glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// ... 绘制你的3D场景 ...
在场景中的所有物体绘制完毕后,我们切换回默认的帧缓冲(通过绑定ID为0的帧缓冲),然后绘制那个覆盖全屏的矩形,它将显示我们刚刚解绑的自定义帧缓冲中的内容。记得在绘制矩形前禁用深度测试,否则矩形可能因为深度测试而被遮挡。
// 2. 绑定回默认帧缓冲并绘制屏幕四边形
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDisable(GL_DEPTH_TEST); // 禁用深度测试
glClearColor(1.0f, 1.0f, 1.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
screenShader.use();
glBindVertexArray(quadVAO);
glBindTexture(GL_TEXTURE_2D, texture); // 绑定帧缓冲的颜色纹理
glDrawArrays(GL_TRIANGLES, 0, 6);
现在如果你运行程序,应该看到和之前完全一样的画面。如果你的屏幕显示为纯色,请首先检查控制台是否有任何OpenGL错误(例如不完整的帧缓冲)。如果控制台窗口没有错误信息,请确保你的视口(glViewport)设置正确,覆盖了整个窗口。
总结

本节课中我们一起学习了OpenGL帧缓冲的核心机制。我们了解了帧缓冲是颜色、深度和模板附件的集合。我们实现了创建自定义帧缓冲、附加纹理作为颜色附件、附加渲染缓冲作为深度和模板附件的完整流程。最后,我们掌握了使用双通道渲染(先渲染到自定义帧缓冲,再渲染到默认帧缓冲的屏幕矩形)来实现后期处理的基础框架。有了这个框架,你就可以在屏幕矩形的片段着色器中添加各种图像处理算法(如反相、灰度、模糊、核效果等),轻松实现丰富的后期处理效果。
020:Cubemaps与Skyboxes 🎨
在本教程中,我们将学习OpenGL中的Cubemaps(立方体贴图)是什么,以及如何使用它们来创建Skyboxes(天空盒)。
概述
立方体贴图是一种特殊的纹理类型,它包含六个2D纹理,分别对应一个立方体的六个面。采样立方体贴图时,我们使用一个3D向量而非2D纹理坐标。这使得我们能够轻松地在立方体的所有六个面之间进行采样。由于立方体的坐标与采样向量相对应,因此不需要UV坐标。立方体贴图最常见的用途是环境贴图和天空盒。
创建立方体贴图
上一节我们介绍了立方体贴图的基本概念,本节中我们来看看如何创建它。
首先,我们需要定义立方体的顶点和索引数据。
以下是立方体的顶点坐标和索引数据:
float skyboxVertices[] = {
// 位置坐标
-1.0f, 1.0f, -1.0f,
-1.0f, -1.0f, -1.0f,
1.0f, -1.0f, -1.0f,
1.0f, 1.0f, -1.0f,
// ... 其他顶点
};
unsigned int skyboxIndices[] = {
0, 1, 2,
2, 3, 0,
// ... 其他索引
};
然后,我们需要创建VAO、VBO和EBO,就像在最初的教程中一样。
unsigned int skyboxVAO, skyboxVBO, skyboxEBO;
glGenVertexArrays(1, &skyboxVAO);
glGenBuffers(1, &skyboxVBO);
glGenBuffers(1, &skyboxEBO);
// ... 绑定和配置缓冲区
加载天空盒纹理


接下来,我们需要加载天空盒的六个面纹理。
首先,创建一个包含六个图像路径的字符串数组。
std::vector<std::string> faces = {
"right.jpg",
"left.jpg",
"top.jpg",
"bottom.jpg",
"front.jpg",
"back.jpg"
};
然后,创建立方体贴图纹理本身。这与创建其他纹理类似,但有一个关键区别:我们使用GL_TEXTURE_CUBE_MAP而不是GL_TEXTURE_2D。
unsigned int cubemapTexture;
glGenTextures(1, &cubemapTexture);
glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture);
确保在所有三个方向上对纹理进行钳制。由于纹理是一个立方体(因此是3D的),这种钳制可以防止任何接缝出现。
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
现在,我们将遍历所有六个纹理,使用STB库读取它们,并将它们放入立方体贴图中。
int width, height, nrChannels;
for (unsigned int i = 0; i < faces.size(); i++) {
unsigned char *data = stbi_load(faces[i].c_str(), &width, &height, &nrChannels, 0);
if (data) {
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
stbi_image_free(data);
}
}
请注意,我禁用了垂直翻转。这是因为与OpenGL中的大多数纹理不同,立方体贴图期望从左上角开始,而不是左下角。
另外,请注意我如何将i添加到GL_TEXTURE_CUBE_MAP_POSITIVE_X。这代表了我当前正在分配纹理的立方体面,我通过添加i来循环遍历所有面。
以下是OpenGL文档中面的顺序:


GL_TEXTURE_CUBE_MAP_POSITIVE_X
GL_TEXTURE_CUBE_MAP_NEGATIVE_X
GL_TEXTURE_CUBE_MAP_POSITIVE_Y
GL_TEXTURE_CUBE_MAP_NEGATIVE_Y
GL_TEXTURE_CUBE_MAP_POSITIVE_Z
GL_TEXTURE_CUBE_MAP_NEGATIVE_Z

这是我们写入路径的顺序。注意到有什么奇怪的地方吗?通常,在OpenGL中,前方是负Z方向,但对于立方体贴图,前方是正Z方向。这意味着立方体贴图在左手坐标系中工作,而OpenGL的大部分内容在右手坐标系中工作。这可能会非常令人困惑,老实说,我不知道他们为什么选择这样做。但请记住,如果不小心,你可能会因此遇到一些小错误。在我的情况下,我的右侧纹理由于某种原因一直倒置显示。为了解决这个问题,我只需在图像编辑器中翻转纹理。所以请准备好处理这类问题,因为据我所知,这在天空盒中经常发生。
创建天空盒着色器
现在我们需要为天空盒创建两个着色器。
顶点着色器将接收坐标、输出纹理坐标,并接收用于矩阵变换的uniform变量。
#version 330 core
layout (location = 0) in vec3 aPos;
out vec3 TexCoords;
uniform mat4 projection;
uniform mat4 view;
void main() {
TexCoords = aPos;
vec4 pos = projection * view * vec4(aPos, 1.0);
gl_Position = pos.xyww;
}
在main函数中,创建一个pos变量,用于保存最终变换后的坐标。由于这些坐标现在在屏幕空间中,我们将做一些有点奇怪的事情:我们不给gl_Position分配坐标,而是分配pos的x、y、w和w分量。这将导致在透视除法之后,Z分量始终为1。由于深度缓冲区将Z分量作为深度值,天空盒将始终具有深度值1。
片段着色器将接收纹理坐标,并使用立方体贴图纹理进行采样。
#version 330 core
out vec4 FragColor;

in vec3 TexCoords;
uniform samplerCube skybox;
void main() {
FragColor = texture(skybox, TexCoords);
}
渲染天空盒
最后,我们需要在渲染循环中绘制天空盒。确保在绘制其他物体之后绘制天空盒,并禁用深度写入,以便天空盒始终在背景中。
glDepthMask(GL_FALSE);
glBindVertexArray(skyboxVAO);
glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture);
glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_INT, 0);
glDepthMask(GL_TRUE);

总结

在本节课中,我们一起学习了OpenGL中的立方体贴图(Cubemaps)以及如何使用它们创建天空盒(Skybox)。我们首先了解了立方体贴图的基本概念,然后逐步实现了顶点和索引数据的定义、纹理的加载、着色器的编写以及最终的渲染过程。通过本教程,你应该能够理解并实现一个基本的天空盒效果,为你的3D场景增添背景环境。
021:几何着色器 🧩
在本教程中,我们将学习什么是几何着色器,以及如何使用它来创建诸如可见法线等效果。
概述
到目前为止,我们只使用了顶点着色器和片段着色器。在大多数情况下,这两个着色器已经足够。但有时,在顶点着色器和片段着色器之间,你可能需要一个额外的步骤来修改网格的几何形状。虽然看起来可以在顶点着色器中完成,但你只能对单个顶点进行操作。如果你想修改整个三角形(即一组顶点),那么就需要使用几何着色器。几何着色器的第二个优点是它可以在不同类型的图元之间切换,从而创建或删除顶点。
将几何着色器添加到着色器类
首先,我们需要将几何着色器添加到我们的着色器类中。请注意,我执行的操作与其他两个着色器完全相同,只是我使用了 GL_GEOMETRY_SHADER。
// 创建几何着色器
unsigned int geometryShader = glCreateShader(GL_GEOMETRY_SHADER);
glShaderSource(geometryShader, 1, &geometryShaderSource, NULL);
glCompileShader(geometryShader);
// ... 检查编译错误并链接到着色器程序
创建一个基础的几何着色器
现在我们已经支持自定义几何着色器,让我们创建一个什么都不做的几何着色器。与任何其他着色器一样,我们首先声明版本。

#version 330 core


然后,我们需要两个布局声明,写法如下。第一个布局表示我们接收的图元类型,可以是以下之一:
pointslineslines_adjacencytrianglestriangles_adjacency

第二个布局表示我们输出的图元类型,可以是以下之一:
pointsline_striptriangle_strip
在本例中,我们希望接收一个三角形并输出一个三角形。
layout (triangles) in;
layout (triangle_strip, max_vertices = 3) out;

接着,我们定义输出到片段着色器的变量。请记住,数据应该从顶点着色器传递到几何着色器,然后再传递到片段着色器。

out vec3 Normal;
out vec3 Color;
out vec2 TexCoord;
将数据导入几何着色器
现在,为了将数据导入几何着色器,我们需要做一些稍微不同的事情。我们不是简单地使用一个 in 变量,而是定义一个类似C语言的结构体,写法如下:
in VS_OUT {
vec3 Normal;
vec3 Color;
vec2 TexCoord;
} gs_in[];
请注意,我们不需要在这个结构体中包含位置信息,因为它已经内置在一个名为 gl_in[] 的默认结构中。
修改顶点着色器
我们需要回到顶点着色器,将所有输出数据替换为完全相同的结构体,只是最后一部分用 out 代替 in。确保结构体中的所有其他内容都与几何着色器中的对应部分相同。
out VS_OUT {
vec3 Normal;
vec3 Color;
vec2 TexCoord;
} vs_out;
另外,我们只包含了模型视图矩阵,而没有包含投影矩阵。这是因为我们希望在修改几何形状之后才应用投影矩阵。

现在,要为这些输出值分配数据,我们只需写下我们给它们起的名字,加上一个点,再加上我们想要分配数据的变量名,这非常类似于C或C++的结构体。


vs_out.Normal = mat3(transpose(inverse(modelView))) * aNormal;
vs_out.Color = aColor;
vs_out.TexCoord = aTexCoord;
gl_Position = modelView * vec4(aPos, 1.0);
在几何着色器中组装数据
在几何着色器中,我们已经拥有了所有需要的数据,剩下的就是将这些数据组装起来。为此,我们只需为位置、法线、颜色和纹理坐标分配数据。
请注意,在这里,除了要访问的结构体部分的名称外,我还有一个索引。这是因为我们在几何着色器中,本质上拥有一个这样的结构体数组,每个数组元素对应一个特定顶点的不同值。
gl_Position = gl_in[0].gl_Position;
Normal = gs_in[0].Normal;
Color = gs_in[0].Color;
TexCoord = gs_in[0].TexCoord;
EmitVertex();
一旦我们完成了一个顶点的值分配,我们必须使用 EmitVertex() 来声明我们已经完成了这个顶点。
我们可以对其他两个顶点做同样的事情:
gl_Position = gl_in[1].gl_Position;
Normal = gs_in[1].Normal;
Color = gs_in[1].Color;
TexCoord = gs_in[1].TexCoord;
EmitVertex();
gl_Position = gl_in[2].gl_Position;
Normal = gs_in[2].Normal;
Color = gs_in[2].Color;
TexCoord = gs_in[2].TexCoord;
EmitVertex();
一旦我们完成了三角形所需的所有三个顶点,我们就需要使用 EndPrimitive() 来声明我们的图元已经完成。

EndPrimitive();

总结
在本节课中,我们一起学习了几何着色器的基础知识。我们了解了它的作用——在顶点和片段着色器之间处理图元(如三角形),并能够创建或修改顶点。我们逐步实现了将几何着色器集成到现有着色器程序中,包括:
- 在着色器类中添加对几何着色器的支持。
- 编写一个基础的几何着色器,定义了输入和输出的图元类型。
- 使用自定义结构体在顶点着色器和几何着色器之间传递数据。
- 在几何着色器中,通过索引访问每个顶点的数据,并使用
EmitVertex()和EndPrimitive()函数来组装并输出最终的图元。

这个基础的几何着色器目前没有改变几何形状,但它为后续实现更复杂的功能(如生成可见法线)搭建了必要的框架。
022:实例化 🚀
在本教程中,我们将学习什么是实例化,以及如何利用它来大幅提升OpenGL项目的性能和视觉效果。
实例化是一项功能,它允许你在一次绘制调用中多次绘制同一个网格。为什么需要这个功能呢?考虑以下场景:左边场景中有一堆小行星,它们都源自同一个小行星网格,并通过顶点着色器进行变形以产生多样性。我使用一个循环来逐个绘制每个小行星,这意味着每个小行星都需要一次单独的绘制调用。
而在右边的场景中,我一次性绘制所有小行星,这意味着只需要一次绘制调用。观察性能差异,你会发现提升是巨大的。现在,让我们开始实现它。
我将从为第一个场景(即逐个绘制)已经写好的代码开始,因为这对本系列教程来说并不新鲜。
启用实例化
为了启用实例化,我们需要做的只是将 glDrawElements 替换为 glDrawElementsInstanced,并在其末尾添加我们想要绘制的网格实例数量。
glDrawElementsInstanced(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0, instanceCount);



唯一的问题是,这会将所有网格绘制在完全相同的位置上,因此目前是无效的。

为每个实例设置不同位置
有多种方法可以将每个网格移动到唯一的位置。例如,你可以在顶点着色器中编写代码来实现这一点。
使用 gl_InstanceID,你可以获取当前正在绘制的实例的索引,从而可以将其用于可控的随机数生成。
int instanceIndex = gl_InstanceID;
或者,你可以使用一个包含所有变换矩阵的uniform变量,并通过 gl_InstanceID 来检索特定实例的正确变换矩阵。
uniform mat4 instanceTransforms[100];
mat4 transform = instanceTransforms[gl_InstanceID];
但这种方法的问题是,uniform变量无法存储大量数据。
因此,要在拥有大量变换矩阵的同时,又不将生成逻辑完全放在顶点着色器内,最佳方法是将变换矩阵存储在附加到网格VAO的顶点缓冲区中。


实现实例化变换
让我们从创建一个接受 glm::mat4 向量(即变换矩阵列表)的VBO构造函数开始。
在网格类中,我们需要添加一个公共的无符号整数变量,来表示我们期望的实例数量。当然,我们也需要将其添加到构造函数中以便轻松修改。
同样在构造函数中,我们应该添加用于实例的变换矩阵向量,以便我们可以将其传入顶点缓冲区。
在网格类的实现文件(.cpp)中,我们想要为实例创建一个VBO,然后将其属性链接到VAO。注意: 仅当我们绘制多个实例时才需要这样做。
确保将矩阵链接为四个不同的 vec4 属性,否则程序将无法工作。最后,使用 glVertexAttribDivisor 函数,为每个 vec4 属性的布局位置传入参数 1。
glVertexAttribDivisor(layoutLocation, 1);
这里的 1 意味着这个 vec4 属性将用于整个实例。如果它是 0,则该 vec4 将用于每个顶点,然后下一个 vec4 用于下一个顶点,这显然不是我们想要的。为了更清晰,如果它是 2,则意味着每两个实例才会切换到下一个 vec4 值。
现在,我们也需要限制 glDrawElementsInstanced 仅在有多个实例时才被使用。
对于模型类,我们需要做与网格类完全相同的事情:在构造函数中添加实例变换矩阵作为变量,并添加实例化数量作为变量。
总结

本节课中,我们一起学习了OpenGL中的实例化技术。我们了解了实例化的概念及其在提升性能方面的巨大优势。通过将 glDrawElements 替换为 glDrawElementsInstanced,并配合使用 gl_InstanceID 和存储在VBO中的变换矩阵,我们实现了在单次绘制调用中渲染大量相似但位置不同的对象。关键在于正确设置顶点属性指针和使用 glVertexAttribDivisor 来指定属性数据更新的频率。掌握实例化对于渲染粒子系统、植被、大量重复建筑等场景至关重要。
023:抗锯齿(MSAA)
在本教程中,我们将学习什么是抗锯齿,以及如何在你的OpenGL项目中实现它。具体来说,我们将重点介绍多重采样抗锯齿(MSAA) 的原理和实现步骤。
概述:什么是锯齿与抗锯齿?
你可能已经注意到,在渲染图形时,水平和垂直的边缘看起来非常清晰,而对角线边缘却常常呈现出类似楼梯的锯齿状。这种现象被称为锯齿(Aliasing)。
其根本原因在于我们的显示器是由无数微小的正方形(即像素)组成的。在绘制对角线时,无法完美地呈现一条平滑的线条。抗锯齿技术通过将边缘的颜色“渗透”到相邻的像素中来模拟平滑效果,从而改善视觉体验。
有多种抗锯齿技术,各有优劣。本教程将专注于多重采样抗锯齿(MSAA)。
核心原理:MSAA如何工作?
上一节我们介绍了锯齿产生的原因,本节中我们来看看MSAA的核心工作原理。
在图形渲染管线的光栅化(Rasterization)阶段,系统需要决定哪些像素应该被着色。传统方法是检查每个像素中心的采样点(Sample Point)是否位于图元(如三角形)内部。如果采样点在三角形外,该像素就不会被着色,即使从视觉上看它应该被部分着色。


MSAA通过在每个像素内设置多个采样点来解决这个问题。系统会检查所有采样点与图元的重叠情况,并根据重叠比例来计算该像素的最终颜色。


例如,如果一个像素有4个采样点,其中2个位于三角形内部,那么该像素的颜色将是背景色和三角形颜色的混合。这有效地平滑了边缘。


核心公式/概念:
- 传统采样:
if (center_sample_inside_primitive) { color_pixel(); } - MSAA采样:
final_color = (num_samples_inside / total_samples) * primitive_color + (num_samples_outside / total_samples) * background_color

实现步骤:在OpenGL中启用MSAA
理解了原理后,现在让我们进入实践环节,看看如何在代码中实现MSAA。实现方式取决于你是否使用了自定义的帧缓冲。
情况一:不使用自定义帧缓冲
如果你的项目直接渲染到默认的窗口帧缓冲,启用MSAA非常简单。
以下是需要执行的步骤:
- 在创建窗口前,使用GLFW提示指定所需的多重采样级别。
- 启用OpenGL的多重采样功能。
对应的核心代码如下:
// 1. 指定采样数(例如4倍抗锯齿)
glfwWindowHint(GLFW_SAMPLES, 4);
// ... 创建窗口和OpenGL上下文 ...
// 2. 启用多重采样
glEnable(GL_MULTISAMPLE);
在这种情况下,启用上述设置后,你的渲染就会自动应用MSAA。
情况二:使用自定义帧缓冲(FBO)
如果你的项目使用了离屏渲染的自定义帧缓冲,步骤会稍复杂一些。我们需要创建一个支持多重采样的帧缓冲。

以下是创建多重采样帧缓冲的步骤:
- 创建纹理附件时,使用
GL_TEXTURE_2D_MULTISAMPLE类型代替普通的GL_TEXTURE_2D。 - 使用
glTexImage2DMultisample函数来分配存储空间,并指定采样数量。 - 创建渲染缓冲对象(RBO)附件时,使用
glRenderbufferStorageMultisample函数。
核心代码对比如下:
// 普通帧缓冲纹理附件
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL);
// 多重采样帧缓冲纹理附件
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, texture);
glTexImage2DMultisample(GL_TEXTURE_2D_MULTISAMPLE, 4, GL_RGB, width, height, GL_TRUE); // 4个样本
// 普通渲染缓冲对象
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, width, height);
// 多重采样渲染缓冲对象
glRenderbufferStorageMultisample(GL_RENDERBUFFER, 4, GL_DEPTH24_STENCIL8, width, height); // 4个样本
高级处理:结合后期效果
直接使用多重采样帧缓冲会带来一个问题:我们无法再对其内容进行后期处理(Post-Processing),因为多采样纹理与普通的着色器采样器不兼容。
为了解决这个问题,我们需要一个额外的渲染管线。思路是:
- 将所有场景渲染到一个多重采样帧缓冲(MSAA FBO) 中。
- 将这个多重采样帧缓冲的内容解析(Resolve) 到一个普通的中间帧缓冲(Intermediate FBO) 中。
- 对中间帧缓冲中的图像进行后期处理。
- 将处理后的结果绘制到屏幕。
以下是渲染循环中的核心步骤:
// 1. 绑定多重采样FBO,渲染场景
glBindFramebuffer(GL_FRAMEBUFFER, msaaFBO);
glClearColor(...);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glEnable(GL_DEPTH_TEST);
// ... 绘制所有场景对象 ...
// 2. 绑定中间FBO,将多重采样FBO的内容解析(复制)过来
glBindFramebuffer(GL_READ_FRAMEBUFFER, msaaFBO);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, intermediateFBO);
glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_NEAREST);


// 3. 绑定中间FBO,进行后期处理(例如应用灰度、模糊等着色器)
glBindFramebuffer(GL_FRAMEBUFFER, intermediateFBO);
// ... 应用后期处理着色器,绘制一个覆盖屏幕的四边形 ...
// 4. 绑定回默认帧缓冲(屏幕),将后期处理结果绘制出来
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// ... 将中间FBO的纹理绘制到全屏四边形上 ...
总结
本节课中我们一起学习了抗锯齿技术,特别是多重采样抗锯齿(MSAA)。
我们首先了解了锯齿(Aliasing) 现象的产生原因。接着,深入探讨了MSAA的核心原理:通过为每个像素设置多个采样点,并根据采样点被图元覆盖的比例来混合颜色,从而平滑边缘。
在实现部分,我们分两种情况讨论:
- 对于不使用自定义帧缓冲的简单场景,只需设置GLFW提示并启用
GL_MULTISAMPLE即可。 - 对于使用自定义帧缓冲的复杂场景,需要创建特殊的多重采样纹理和渲染缓冲,并通常需要结合一个中间帧缓冲来实现后期处理流程。


通过本教程,你应该已经掌握了在OpenGL项目中应用MSAA来提升图形渲染质量的基本方法。
025:Gamma校正 🎨
在本节课中,我们将要学习Gamma校正的概念,理解为什么显示器显示的颜色与我们代码中定义的颜色存在差异,并学习如何在OpenGL项目中实现Gamma校正,以获得更真实、更线性的光照效果。
概述
在计算机图形学中,Gamma校正是一个至关重要的概念。它描述了显示器对输入颜色信号的响应曲线。由于历史原因,显示器并非线性地显示颜色,这导致我们在代码中设定的颜色(例如,完美的灰色)在屏幕上会显得更暗。为了模拟现实中线性的光照,我们需要在渲染管线中应用Gamma校正。
上一节我们介绍了帧缓冲和后处理的基础知识,本节中我们来看看如何通过Gamma校正来改善最终图像的色彩表现。
什么是Gamma?
Gamma本质上是显示器对不同颜色深浅的敏感度。
请看下图,其中X轴代表我们在代码中输入的色彩值,Y轴代表最终在屏幕上显示的色彩值。如果曲线是一条直线,那么输入将等于输出,这是理想情况,因为我们知道代码中写入的颜色就是显示器上实际看到的颜色。



然而,现实中至少对于显示器而言并非如此。由于历史原因,显示器自动带有一条如下图所示的Gamma曲线。这意味着,如果我们输入一个0.5的颜色值(即介于黑色和白色之间的完美灰色),我们在屏幕上实际得到的颜色值将是0.218,这是一个暗得多的灰色。

为什么需要Gamma校正?
我们希望以线性的方式表现光线,因为现实中的光是线性的。为了实现这一点,我们需要将所有颜色转换为Gamma函数的反函数。这样,当Gamma函数被应用时,它们会相互抵消,从而得到一个线性函数。这个反函数就叫做Gamma校正函数。
如果上述解释不够清晰,建议查阅网络上更详细的资料。
如何在OpenGL中实现Gamma校正?
现在进入实际的编码部分。启用Gamma校正的一个非常简单的方法是调用:
glEnable(GL_FRAMEBUFFER_SRGB);
这种方法的问题在于,它不允许我们控制Gamma的幂值。一般来说,Gamma幂值为2.2(默认值)最适合大多数显示器,但我们可能希望有能力控制它。
为了实现自定义控制,我们可以简单地将Gamma校正函数应用到后处理帧缓冲的片段着色器中。以下是核心公式:
fragmentColor.rgb = pow(fragmentColor.rgb, vec3(1.0 / gamma));
其中 gamma 通常取值2.2。

运行程序后,你会发现一切都变得更亮了,但背景色和模型网格会显得过亮且颜色失真。
遇到的问题与解决方案
为什么会出现这种情况?Gamma校正不是应该让颜色看起来更好、更真实吗?难道生活只是一团糟吗?并非如此。
当你选择背景颜色时,你很可能是在看着显示器的情况下进行的。这意味着,你选择的颜色已经通过显示器的Gamma校正被“调整”过了。现在我们在着色器中再次应用校正,等于校正了两次。模型纹理也存在同样的问题,因为它们也是由人类艺术家在看着显示器的情况下制作的。
以下是解决此问题的步骤:
1. 修正背景色
对于背景色,我们可以简单地将颜色的每个分量提升到我们的Gamma幂值。
backgroundColor = pow(backgroundColor, vec3(gamma));
2. 修正纹理
在加载纹理时,如果它们是GL_RGB格式,我们可以加载为GL_SRGB;如果它们是GL_RGBA格式,我们可以加载为GL_SRGB_ALPHA。这样,OpenGL会自动对所有纹理应用默认的Gamma值(2.2)。
// 例如,加载为SRGB格式
glTexImage2D(..., GL_SRGB, ...);
如果你想应用自定义的Gamma值,则必须在每个使用纹理的片段着色器中进行手动校正。
精度问题与最终效果
现在运行应用程序,你会发现颜色看起来好多了,也更真实。但如果你仔细观察图像的某些部分,可能仍会注意到一些阶梯状的渐变。
这是由于我们在不同Gamma级别之间反复转换颜色时产生的精度错误,因为浮点数并非无限精确。幸运的是,这通常不是一个严重问题,并且可以通过使用更高精度的帧缓冲(例如16位或32位浮点纹理)来缓解。
最终,应用Gamma校正后的图像对比度更自然,暗部细节更丰富,更接近人眼在真实世界中看到的效果。

总结

本节课中我们一起学习了Gamma校正。我们了解到显示器显示颜色的非线性特性,并学会了通过应用Gamma校正函数来抵消这种影响,从而在渲染中获得线性的光照计算。我们探讨了两种实现方法:使用OpenGL内置的sRGB帧缓冲,以及在着色器中手动进行幂运算校正。同时,我们也解决了对背景色和纹理进行重复校正的问题。正确应用Gamma校正是实现逼真渲染的重要一步。
026:阴影贴图(定向光)🕶️
在本教程中,我们将学习如何使用阴影贴图技术,为场景中的定向光添加阴影效果。
概述
阴影本质上是光线被遮挡的区域。目前我们实现的光照模型,光线不会从一个表面“弯曲”到另一个表面,它只会照射到其直接路径上的表面。这意味着,从光源的视角观察,我们看到的图像包含了所有被光线直接照射的表面。因此,在片段着色器中,我们可以切换到光源的视角,检查当前处理的片段是否处于光照之中。
核心原理
实现阴影贴图的核心思想是:从光源的视角渲染场景,生成一张深度图(即阴影贴图)。这张图记录了从光源视角看,每个像素点距离光源最近的深度值。

随后,在正常的渲染流程中,对于每个片段,我们将其转换到光源的裁剪空间,得到其深度值。我们将这个深度值与阴影贴图中对应位置的深度值进行比较。
- 如果片段深度 > 阴影贴图深度:意味着当前片段位于某个被光源直接照射的物体之后,因此它处于阴影中。
- 否则:意味着当前片段是距离光源最近的表面,因此它被照亮。
用伪代码表示这个判断逻辑:
float shadow = currentFragmentDepth > depthFromShadowMap ? 1.0 : 0.0;
其中,shadow为1.0表示处于阴影,0.0表示被照亮。
实现步骤
上一节我们介绍了阴影贴图的基本原理,本节中我们来看看具体的实现步骤。整个过程可以分为两个主要阶段:生成阴影贴图和利用阴影贴图渲染。
第一阶段:生成阴影贴图
首先,我们需要创建一个帧缓冲对象(Framebuffer Object, FBO)来专门用于渲染阴影贴图。

-
创建帧缓冲和深度纹理:
- 创建一个帧缓冲对象(
glGenFramebuffers)。 - 创建一张纹理来存储深度信息(
glGenTextures)。注意,纹理的内部格式应设置为GL_DEPTH_COMPONENT,因为我们只需要深度值,而非颜色。 - 将这张深度纹理附加到帧缓冲的深度附件上(
glFramebufferTexture2D)。 - 由于我们不需要向这个帧缓冲写入颜色,需要显式告知OpenGL:
glDrawBuffer(GL_NONE); glReadBuffer(GL_NONE);
- 创建一个帧缓冲对象(
-
配置纹理参数:
- 将纹理的环绕模式设置为
GL_CLAMP_TO_BORDER,并将边界颜色设置为白色(即深度值1.0)。这是因为阴影贴图只能覆盖有限区域,区域外的部分我们希望其深度值为最大(1.0),这样在深度比较时,区域外的所有片段都会被视为“被照亮”,从而避免产生错误的阴影。glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER); float borderColor[] = { 1.0f, 1.0f, 1.0f, 1.0f }; glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
- 将纹理的环绕模式设置为
-
计算光源变换矩阵:
- 对于定向光,其光线是平行的,因此我们使用正交投影矩阵(
glm::ortho)而非透视投影矩阵。透视投影会使平行线交汇,不符合定向光的特性。 - 正交投影的范围(
left,right,bottom,top,near,far)需要根据场景调整。一个经验法则是:从一个较大的范围开始,逐步缩小以紧密包裹你的场景。范围过大虽然仍能产生阴影,但会因纹理分辨率有限而导致阴影质量下降(称为“透视锯齿”)。范围与场景匹配得越好,阴影质量越高。 - 由于定向光被认为在无限远处,我们需要为其在光线方向上选取一个合适的位置来构建观察矩阵(
glm::lookAt)。这个位置应能确保整个需要阴影的场景都位于其视锥体内。通常可以沿着光的方向,取一个远离场景原点的点。 - 最终的光源变换矩阵是:正交投影矩阵 × 光源观察矩阵。
glm::mat4 lightProjection = glm::ortho(-10.0f, 10.0f, -10.0f, 10.0f, 1.0f, 7.5f); glm::mat4 lightView = glm::lookAt(lightPos, glm::vec3(0.0f), glm::vec3(0.0, 1.0, 0.0)); glm::mat4 lightSpaceMatrix = lightProjection * lightView;
- 对于定向光,其光线是平行的,因此我们使用正交投影矩阵(
-
创建渲染阴影贴图的着色器:
- 顶点着色器:非常简单,只需将顶点位置通过模型矩阵和上一步计算出的
lightSpaceMatrix变换到光源的裁剪空间。#version 330 core layout (location = 0) in vec3 aPos; uniform mat4 lightSpaceMatrix; uniform mat4 model; void main() { gl_Position = lightSpaceMatrix * model * vec4(aPos, 1.0); } - 片段着色器:留空即可。因为我们绑定了深度纹理到帧缓冲,且禁用了颜色绘制,OpenGL会自动将深度值写入深度附件(即我们的阴影贴图纹理)。
#version 330 core void main() { // gl_FragDepth is automatically written }
- 顶点着色器:非常简单,只需将顶点位置通过模型矩阵和上一步计算出的
-
渲染到阴影贴图:
- 绑定为阴影贴图准备的帧缓冲。
- 使用上面创建的简单着色器程序。
- 将视口大小设置为阴影贴图纹理的分辨率(例如1024x1024)。
- 清除深度缓冲。
- 渲染场景中的所有物体(通常只渲染会投射阴影的物体)。此时,深度信息会被记录到阴影贴图纹理中。
第二阶段:使用阴影贴图进行渲染


现在我们已经有了阴影贴图,接下来在正常渲染场景时使用它。
-
修改主着色器:
- 在顶点着色器中,除了常规变换,还需要额外输出一个变量,表示当前顶点在光源裁剪空间中的坐标(即
lightSpaceMatrix * model * vec4(aPos, 1.0)的结果),并传递给片段着色器。 - 在片段着色器中:
a. 接收从顶点着色器传来的光源空间位置。
b. 将其执行透视除法(xyz /= w),将坐标从裁剪空间转换到标准设备坐标(NDC,范围[-1,1])。
c. 再将NDC坐标转换到[0,1]范围,以便作为纹理坐标采样阴影贴图。
d. 使用转换后的xy坐标采样阴影贴图,得到最近表面的深度值。
e. 将当前片段在光源空间中的深度值(z分量)与采样得到的深度值进行比较。
f. 根据比较结果计算阴影因子,并将其应用到最终的光照计算中。
核心片段着色器代码示例:
uniform sampler2D shadowMap; in vec4 FragPosLightSpace; // 从顶点着色器传来 float ShadowCalculation(vec4 fragPosLightSpace) { // 执行透视除法,得到NDC坐标 vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w; // 转换到[0,1]范围 projCoords = projCoords * 0.5 + 0.5; // 获取当前片段在光源视角下的深度 float currentDepth = projCoords.z; // 从阴影贴图中获取最近深度 float closestDepth = texture(shadowMap, projCoords.xy).r; // 检查当前片段是否在阴影中 float shadow = currentDepth > closestDepth ? 1.0 : 0.0; return shadow; } void main() { // ... 其他光照计算 ... float shadow = ShadowCalculation(FragPosLightSpace); vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * objectColor; FragColor = vec4(lighting, 1.0); } - 在顶点着色器中,除了常规变换,还需要额外输出一个变量,表示当前顶点在光源裁剪空间中的坐标(即
总结

本节课中我们一起学习了OpenGL中阴影贴图的基本原理与实现方法。关键点在于:从光源视角渲染场景生成深度图(阴影贴图),然后在正常渲染时,将片段转换到同一空间进行深度比较,以决定其是否处于阴影中。对于定向光,需使用正交投影。实现时需注意阴影贴图纹理的配置、光源变换矩阵的计算以及着色器间的坐标传递与转换。这是实时渲染中生成动态阴影的基础且高效的技术。
027:聚光灯与点光源的阴影映射 🔦
在本教程中,我们将学习如何在OpenGL中为聚光灯和点光源实现阴影映射技术。我们将从相对简单的聚光灯阴影开始,然后深入探讨更复杂的点光源阴影实现,后者会利用立方体贴图和几何着色器。
如果你还不了解如何为定向光源实现阴影映射,强烈建议你先观看我之前的教程,否则本教程的内容可能难以理解。
聚光灯阴影映射
上一节我们介绍了定向光源的阴影映射。本节中,我们来看看如何为聚光灯实现阴影映射。聚光灯的阴影实现与定向光源非常相似,主要区别在于投影矩阵。
以下是实现聚光灯阴影映射的核心步骤:
- 替换投影矩阵:将定向光源使用的正交投影矩阵替换为透视投影矩阵。
- 应用阴影映射代码:将阴影映射的渲染和采样逻辑应用到聚光灯光源上。
- 调整深度偏移:由于聚光灯有明确的位置,通常可以获得更高质量的阴影贴图。因此,你可能需要调整深度偏移的精度以获得正确的阴影效果,这与定向光源中可能需要调整以适应大范围场景不同。
点光源阴影映射
现在,我们来看看更复杂的点光源阴影映射。点光源向所有方向发射光线,因此其阴影计算需要覆盖整个球面。
理论上,你可以为立方体的六个面分别使用六个聚光灯来计算阴影,但这需要六次渲染过程。


为了避免这种低效的做法,我们可以使用立方体贴图配合几何着色器,在单次渲染过程中绘制出所有六个面的深度信息。
创建立方体贴图帧缓冲
首先,我们需要创建一个帧缓冲对象和一个立方体贴图纹理。
// 创建立方体贴图
glGenTextures(1, &depthCubemap);
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
for (unsigned int i = 0; i < 6; ++i)
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_DEPTH_COMPONENT, SHADOW_WIDTH, SHADOW_HEIGHT, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
// ... 设置纹理参数
// 创建帧缓冲并附加立方体贴图
glGenFramebuffers(1, &depthMapFBO);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthCubemap, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
请记住,我们需要告知OpenGL这个帧缓冲不会绘制到颜色缓冲。
计算变换矩阵
接下来,我们需要为立方体贴图的六个面创建透视变换矩阵。
float aspect = (float)SHADOW_WIDTH/(float)SHADOW_HEIGHT;
float near = 1.0f;
float far = 25.0f;
glm::mat4 shadowProj = glm::perspective(glm::radians(90.0f), aspect, near, far);
std::vector<glm::mat4> shadowTransforms;
shadowTransforms.push_back(shadowProj * glm::lookAt(lightPos, lightPos + glm::vec3( 1.0, 0.0, 0.0), glm::vec3(0.0,-1.0, 0.0)));
shadowTransforms.push_back(shadowProj * glm::lookAt(lightPos, lightPos + glm::vec3(-1.0, 0.0, 0.0), glm::vec3(0.0,-1.0, 0.0)));
// ... 为 +Y, -Y, +Z, -Z 方向添加其余矩阵
确保视野为90度以精确覆盖立方体的一个面,并注意上向量不要与你观察的方向平行。
编写着色器
我们需要三个着色器来生成阴影贴图。
- 顶点着色器:简单地输出顶点位置。
#version 330 core layout (location = 0) in vec3 aPos; uniform mat4 model; void main() { gl_Position = model * vec4(aPos, 1.0); } - 几何着色器:这是关键所在。它接收一个三角形,并同时向六个面(即六个图层)输出这个三角形。
#version 330 core layout (triangles) in; layout (triangle_strip, max_vertices=18) out; uniform mat4 shadowMatrices[6]; out vec4 FragPos; // 输出到片段着色器 void main() { for(int face = 0; face < 6; ++face) { gl_Layer = face; // 指定输出到立方体贴图的哪个面 for(int i = 0; i < 3; ++i) { // 对三角形的三个顶点 FragPos = gl_in[i].gl_Position; gl_Position = shadowMatrices[face] * FragPos; EmitVertex(); } EndPrimitive(); } } - 片段着色器:计算并输出线性化的深度值。
#version 330 core in vec4 FragPos; uniform vec3 lightPos; uniform float far_plane; void main() { // 计算当前片段到光源的距离(即深度) float lightDistance = length(FragPos.xyz - lightPos); // 将深度值映射到 [0,1] 范围并线性化 lightDistance = lightDistance / far_plane; gl_FragDepth = lightDistance; }
然后,创建一个着色器程序,链接着三个着色器,并设置它们所需的uniform变量。
渲染与采样阴影
在正常渲染场景之前,先渲染深度立方体贴图。
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
// ... 激活阴影着色器程序,传递uniform,渲染场景
glBindFramebuffer(GL_FRAMEBUFFER, 0);
在正常渲染的片段着色器中采样阴影时,注意使用 samplerCube 而不是 sampler2D。
uniform samplerCube shadowMap;
阴影计算算法与之前非常相似:
- 初始化阴影值
shadow = 0.0。 - 计算片段到光源的向量,取其长度作为当前深度
currentDepth。 - 添加一个深度偏移
bias。点光源的偏移量可能更接近定向光源而非聚光灯。 - 为了获得柔和的软阴影,可以遍历立方体贴图中当前采样点附近的纹素,检查它们是否在阴影中。这称为百分比渐进过滤。
float shadow = 0.0; float bias = 0.15; int samples = 20; vec3 sampleOffsetDirections[20] = vec3[](...); // 一些随机偏移方向 float viewDistance = length(viewPos - fragPos); float diskRadius = (1.0 + (viewDistance / far_plane)) / 25.0; for(int i = 0; i < samples; ++i) { float closestDepth = texture(shadowMap, fragToLight + sampleOffsetDirections[i] * diskRadius).r; closestDepth *= far_plane; // 将 [0,1] 映射回实际距离 if(currentDepth - bias > closestDepth) shadow += 1.0; } shadow /= float(samples); - 最后,将计算出的
shadow因子应用到最终的光照结果中。
如果你现在运行程序,应该能看到由点光源生成的柔和阴影。


总结
本节课中我们一起学习了:
- 聚光灯阴影映射:通过将正交投影替换为透视投影,并沿用定向光源的阴影映射框架来实现。
- 点光源阴影映射:利用立方体贴图和几何着色器,在单次渲染过程中生成覆盖所有方向的深度图,并通过在片段着色器中采样立方体贴图并应用PCF来生成软阴影。


这两种技术极大地增强了场景中局部光源的真实感。别忘了查看我的Discord频道和Patreon页面以获取更多资源,源代码也一如既往地可供参考。
028:法线贴图 🗺️
在本教程中,我们将学习什么是法线贴图,以及如何利用它们为网格模型增添大量细节。
概述
我们将探讨法线贴图的基本概念、工作原理及其在OpenGL中的实现方法。通过使用法线贴图,可以在不增加模型顶点数量的前提下,显著提升模型表面的光照细节和视觉真实感。
什么是法线贴图?
假设我们有一个由三角形构成的表面,我们希望为其增添更多细节。
一个增加细节的有效方法是添加更多的法线,这样光线就能更好地与表面交互。
但我们只能在有顶点的位置拥有法线。因此,要增加法线数量,就必须增加三角形的数量,这可能会严重影响程序性能。
这正是法线贴图的精妙之处。我们可以保持原有的三角形数量,而只需将一张法线贴图包裹在模型表面。
现在,法线贴图上的每个像素都将代表模型表面的一个法线方向。这样,我们就能在几乎不影响程序性能的情况下,极大地改善模型的外观。
法线贴图详解


让我们更仔细地观察一张法线贴图。通常,它们看起来与漫反射贴图非常相似,但整体呈紫罗兰色调。
这是因为法线贴图的每个像素都代表一个三维空间中的向量。

换句话说,每个像素的RGB值分别代表该法线向量的X、Y、Z坐标。
Z轴(即蓝色分量)垂直于表面向外。由于法线通常垂直于其所在的表面,因此法线贴图主要由这种紫罗兰色主导。
如果某个区域偏红色,则表示该处的法线指向右侧(正X方向)。如果偏绿色,则表示法线指向上方(正Y方向)。
需要注意的最后一点是,RGB值的范围是[0, 1],但我们希望法线的范围是[-1, 1],以便它们也能指向负的X、Y、Z轴方向。
因此,在从贴图中读取法线时,需要进行一个简单的转换。
在OpenGL中实现
现在让我们开始编码。我们将从一个面向正Z轴的平面开始。

我们需要稍微修改纹理类,使其能够加载法线贴图。关于法线贴图以及除漫反射贴图之外的任何纹理,关键一点是我们不希望对其应用伽马校正。


因此,我们需要以RGB格式(而非sRGB格式)加载法线贴图。



不要忘记加载纹理并将其传递到片段着色器。
接下来需要做的,就是从法线贴图中读取法线数据,将其转换到[-1, 1]的范围,然后我们就完成了。
你的平面上现在应该显示法线贴图的效果了。这看起来不错,但你会发现,如果我移动平面的位置,法线贴图的效果会突然出错。
这是因为法线贴图始终指向正Z方向,而在这个例子中,我们的平面平放在地面上,指向正Y方向。
这种不一致导致了我们看到的错误。我们希望法线贴图和光照变量处于同一个空间。
为了实现这一点,我们需要一个所谓的TBN矩阵,它由表面的切线(Tangent)、副切线(Bitangent)和法线(Normal)构成。
要计算这个矩阵,可以使用以下公式:

如果你对这个公式的推导感兴趣,我在描述中留下了一些相关文章的链接。
由于公式需要三角形的所有三个顶点信息,你可以在CPU上计算TBN矩阵,也可以在几何着色器中计算。我选择在几何着色器中完成。


在默认的几何着色器中,我们首先需要根据公式计算两条边向量以及纹理坐标的差值。


然后计算一个除数因子,最后得出切线和副切线。


我们需要将模型矩阵传入几何着色器,以便用它来变换切线和副切线向量。

至于法线,我们希望它垂直于三角形表面,通常可以通过两个边向量的叉积来计算。

总结
在本节课中,我们一起学习了法线贴图的核心概念。我们了解到,法线贴图是一种利用RGB颜色存储表面法线方向信息的技术,它能在不增加模型几何复杂度的前提下,通过改变光照计算来模拟高精度表面的凹凸细节。
我们探讨了法线贴图的工作原理,即每个像素的颜色值对应一个三维法线向量。接着,我们实现了在OpenGL中加载和使用法线贴图的基本步骤,并解决了因模型空间与贴图空间不一致而导致光照错误的问题,关键是通过构建TBN矩阵将法线从切线空间转换到世界空间。

通过本教程,你现在应该能够理解法线贴图的价值,并掌握在渲染管线中集成法线贴图来显著提升模型视觉细节的方法。
029:视差遮蔽映射 🧱
在本节课中,我们将学习什么是视差映射,以及如何将其与法线贴图结合使用,从而在物体表面获得非常出色的细节效果。
概述
想象一下,我们想要一个具有真实几何形状的漂亮砖墙,就像这样。为了实现它,我们可能需要一个包含大量三角形的高度图平面。
这种方法的问题是,仅这一个平面就可能需要成千上万个三角形。但我们不能真的这样做,因为这会严重拖累性能。
那么,如果我告诉你,你现在看到的这面砖墙只由两个三角形构成呢?这很酷,对吧?下面我们来讲解视差映射的工作原理。
视差映射原理


首先,我们仍然需要一个高度图。区别在于,我们不会实际置换几何体,而是在片段着色器中模拟这种效果。


你最初可能会想到在平面“上方”模拟高度。但问题是,这个平面只会在其自身表面上显示内容。因此,像图中这样的区域会被切掉,从而破坏视觉错觉。
所以,我们将改为反转高度图,并在平面“后方”模拟深度。
实现方法
在深度为0的位置,我们有我们的平面。在它下方,是我们理论上的高度图(意味着你实际上看不到它,它只存在于材质数据中)。我们当前正在对点A进行采样。
但我们真正应该采样的是点B,因为那才是我们应该看到的高度。问题在于,我们无法直接通过公式计算出B点。我们需要设法获得一个近似值。
为此,我们可以简单地取当前点的高度,乘以观察向量的倒数。我们称这个向量为S。由于大多数高度图在相邻点之间的高度变化并不极端,这使我们能接近目标点。
这也意味着,如果我们确实有一个陡峭的位移,我们的猜测就会相差甚远,导致画面看起来很奇怪。
技术局限与改进
这项技术的问题是,其结果会根据高度图而不一致,尤其是当你从一个小角度观察时。因为此时点A离点B非常远,假设它们高度相近就行不通了。
因此,我们实际上需要对此进行一些修改。如果我们不是获取点A的高度并乘以B的倒数,而是检查多个高度层级呢?本质上,我们将从小长度到大长度逐步缩放S向量,直到它位于表面下方,此时我们将采样该像素以获取颜色等信息。
我们可以通过平均表面下方的S向量和紧挨着它之前的S向量的高度,来获得更精确的结果,从而进一步改进这个方法。
此外,在加载纹理时使用线性过滤而非最近邻过滤,可以获得更平滑的结果。
实践步骤
上一节我们介绍了法线贴图,本节我们将在此基础上继续,因为我们需要在纹理空间中使用TBN矩阵。

以下是实现视差遮蔽映射的主要步骤:
- 添加位移纹理:首先,我们需要在纹理类中添加位移贴图类型。
- 加载并绑定贴图:然后,加载位移贴图,绑定它,并将其发送到片段着色器。
- 在片段着色器中实现:最后,在片段着色器中实现我们讨论过的算法。
总结

本节课中,我们一起学习了视差遮蔽映射技术。我们了解到,它通过使用高度图在片段着色器中模拟深度,从而用极少的几何体(如两个三角形)创造出复杂的表面细节。其核心思想是沿着观察方向进行多次采样,以找到表面下方的正确纹理坐标。这种方法在性能和视觉效果之间取得了很好的平衡。
031:Bloom效果实现指南 🌟
在本节课中,我们将学习什么是Bloom效果,以及如何通过一系列步骤将其添加到渲染中,使明亮光源周围产生光晕,从而提升画面的视觉表现力。
Bloom效果是明亮光源周围类似光晕的色彩,它使光源看起来比实际更亮。实现此效果的基本步骤相当直接。
概述与原理
上一节我们介绍了Bloom的基本概念。本节中,我们来看看其核心实现流程。
实现Bloom效果的步骤如下:
- 正常渲染场景图像。
- 将场景中的高亮部分提取到另一个纹理中。
- 对提取出的高亮纹理进行模糊处理。
- 将模糊后的纹理叠加到原始场景图像上。

以下是实现第一步“提取高亮”的具体操作。



提取高亮区域
首先,需要为后处理缓冲区创建第二个纹理。确保使用 GL_COLOR_ATTACHMENT1 将其附加到帧缓冲的第二个颜色附件位置。
接着,需要告知OpenGL我们将渲染到多个纹理。这通过使用 glDrawBuffers 函数并传入所使用的附件数组来实现。
然后,在片段着色器中指定输出到两个纹理,并按如下方式分配片段颜色:
// 增强红色通道,使熔岩线条更突出
FragColor.r *= 2.0;
// 计算片段的亮度(灰度值)
float brightness = dot(FragColor.rgb, vec3(0.2126, 0.7152, 0.0722));
// 根据亮度阈值决定是否输出到第二个纹理(高亮纹理)
if(brightness > 1.0)
BrightColor = vec4(FragColor.rgb, 1.0);
else
BrightColor = vec4(0.0, 0.0, 0.0, 1.0);
此时,如果绑定第二个纹理进行查看,其内容应类似于仅包含场景中高亮部分的图像。
应用高斯模糊
上一节我们成功提取了高亮区域。接下来,需要对这个图像进行模糊处理。本教程将使用高斯模糊,因为它常用于实现Bloom效果。
首先,创建一个用于模糊的着色器程序,并将我们希望修改的纹理传递给它。


以下是模糊着色器的核心思路:为了提高性能,我们采用两步法。首先在一次渲染中计算所有像素的水平方向模糊,然后在另一次渲染中计算垂直方向模糊。若需更详细的解释,可访问 learnopengl.com 或搜索“两步法高斯模糊”。
准备好着色器后,需要创建两个帧缓冲,每个附带一个纹理。这两个帧缓冲将用于运行上述两个模糊通道,它们被称为“乒乓”帧缓冲,因为数据会在两者之间来回传递。
在主循环中,我们需要在它们之间传递数据。纹理在两者之间“反弹”的次数取决于你想要的模糊程度。注意,在第一次“反弹”时,我使用了之前生成的高亮图像作为输入。此后,所有的传递都在乒乓缓冲的纹理之间进行。
不要忘记通过一个布尔值(uniform bool horizontal)告知着色器当前正在进行的是水平还是垂直模糊通道。
经过数次数据传递后,你应该能得到一个类似下图的模糊图像。
最终合成

经过之前的步骤,我们得到了原始场景纹理和模糊后的高亮纹理。最后一步是将它们合成。

现在,只需绑定原始颜色纹理和模糊后的纹理,将它们传递到后处理的片段着色器中,并将两者相加混合。
作为最后一步,别忘了启用HDR颜色,以便在Bloom效果中获得更丰富的色彩变化。
总结

本节课中,我们一起学习了Bloom效果的原理与完整实现流程。我们首先提取了场景中的高亮区域到独立纹理,然后使用乒乓帧缓冲和两步高斯模糊算法对该纹理进行模糊处理,最后将模糊结果叠加回原始图像,从而创造出光源周围的光晕效果,显著提升了渲染画面的视觉吸引力。

浙公网安备 33010602011771号