图形学系列文章目录

  • 序章 初探图形编程
  • 第1章 你的第一个三角形
  • 第2章 变换
  • 顶点变换
  • 视图矩阵 & 帧速率
  • 第3章 纹理映射
  • 第4章 透明度和深度
  • 第5章 裁剪区域和模板缓冲区
  • 第6章 场景图
  • 第7章 场景管理
  • 第8章 索引缓冲区
  • 第9章 骨骼动画
  • 第10章 后处理
  • 第11章 实时光照(一)
  • 第12章 实时光照(二)
  • 第13章 立方体贴图
  • 第14章 阴影贴图
  • 第15章 延迟渲染
  • 第16章 高级缓冲区


文章目录

  • 图形学系列文章目录
  • 前言
  • 顶点数据
  • 着色器
  • 连接数据
  • 示例程序
  • 网格类
  • 着色器类
  • 教程任务
  • 顶点着色器
  • 片段着色器
  • 渲染器类
  • 主文件
  • 总结
  • 课后作业



图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 你的第一个三角形_c++

前言

一个三角形——听起来很无聊,是吧?嗯,确实很无聊!但是在屏幕上绘制一个三角形将教给你 OpenGL 的基础知识,并让你在进行更高级的图形渲染的道路上顺利前行。为了使用 OpenGL 编写图形程序,你需要知道如何处理三种不同类型的数据——顶点数据、纹理数据和着色器。我们将在后面的教程中处理纹理数据;现在我们将集中精力于如何处理顶点数据和着色器。OpenGL 提供了用于复制顶点数据和编译着色器的函数,但在以“面向对象”的方式处理此类数据方面提供的帮助很少——没有类结构来整洁地封装功能。本模块为你提供的代码库包含一些类,用于封装使用顶点数据和着色器的 OpenGL 功能,所以本教程的一部分将致力于帮助你理解这些类是如何工作的。本教程系列的其余部分将使用你在这里学到的功能,所以尽管屏幕上的一个三角形可能听起来不是很有趣,但这是一个重要的教程!

顶点数据

顶点是空间中的点,它们构成了几何形状,代表着现代视频游戏中的一切——从屏幕上的敌人到游戏中的平视显示器(HUD)——它们都是由顶点组成的!几何网格中的每个顶点都将有许多属性,例如它的位置、颜色和纹理坐标。为了在屏幕上绘制一个网格,必须首先通过将此顶点数据复制到图形内存中进行缓冲。在一个 OpenGL 应用程序中,每个网格对于每种类型的顶点属性都将有一个这样的顶点缓冲对象(Vertex Buffer Object,VBO),它们被组合到一个单一的顶点数组对象(Vertex Array Object,VAO)中。这个数组对象还存储了关于顶点着色器应如何解释每个顶点缓冲区内的数据的信息,例如它的数据格式是什么(即它是整数、浮点数等),以及每个顶点元素占用多少字节。一旦所有的顶点数据都被缓冲到图形卡的随机存取存储器(RAM)中,并创建了顶点数组对象,OpenGL 就可以使用它来绘制一个网格。值得注意的是,VBO 和 VAO 都没有定义顶点数据代表什么形状——它可以是一组三角形、一组线条、凸多边形,甚至只是一团点。

着色器

着色器是在图形卡上运行的短程序,它们将你的顶点数据转化为屏幕上的最终图像。一个单独的着色器可执行文件由不同的组件组成——顶点程序、片段程序,甚至可能还有几何程序。顶点程序接收你的网格顶点缓冲对象(VBO)的顶点属性,并对它们进行操作,然后将结果数据传递给片段着色器。这些操作通常包括通过几个变换矩阵对顶点位置数据进行变换——你将在下一个教程中看到如何进行此操作。片段着色器接收这个过程的结果,并将它们转化为片段——将被写入当前 OpenGL 颜色缓冲区的颜色数据。从纹理中采样或进行光照计算等操作是片段着色器的典型领域。一些着色器可执行文件也有几何着色器。这些着色器位于顶点着色器和片段着色器之间,从顶点着色器接收原始数据(代表线条或三角形的顶点数组),并从它们生成新的几何形状——所以每个输入的三角形可以被转化为许多新的三角形,所有这些都被发送到片段着色器进行着色。

在 OpenGL 中,着色器通常用 GLSL 编写。这是一种类似 C 的语言,具有特定于图形渲染的数据类型和函数。它们可以有函数、if 语句、for 循环等等,就像你一直在编写的 C++程序一样,但它们不能有类。着色器专门用于对浮点向量(具有多个数据元素的结构)执行操作。例如,着色器可以非常快速地对四分量向量执行点积和叉积,以及将它们相乘——这对于混合顶点颜色和变换顶点位置很方便!出于这个原因,GLSL 有额外的内置数据类型来表示向量,除了你习惯的浮点数和整数之外。GLSL 的数据类型 vec2、vec3 和 vec4 分别表示有 2、3 和 4 个浮点分量的向量。可以像访问 C 结构体或类的成员变量一样使用标识符 x、y、z 和 w 来访问它们的浮点数——注意,w 是 vec4 的第四个分量,而不是第一个!随着本教程系列的进展,你将看到如何对这些向量数据类型执行操作,包括通过 mat4 数据类型将向量与矩阵相乘。

要在 OpenGL 应用程序中使用着色器,必须加载它们的源代码然后进行编译,就像你的 C 程序在运行之前必须编译一样。这个编译过程接受顶点、片段或几何着色器源代码,并创建一个着色器对象。然后,这些着色器对象被附加到一个着色器程序上——一种用于管理着色器对象的容器。接着,着色器对象被链接在一起形成最终的着色器可执行文件——这个链接过程将一个着色器对象的输出与下一个的输入连接起来,创建一个用于图形渲染的最终的内聚程序。

图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 你的第一个三角形_游戏引擎_02

连接数据

缓冲区中包含的顶点数据在某个时刻必须连接到顶点着色器,这样图形处理器(GPU)才能处理这些缓冲区并将它们在屏幕上转换为几何图形。这个过程包括从一个或多个顶点缓冲区中的特定位置获取数据,并将它们连接到顶点着色器中的一个或多个变量。在 OpenGL 中,几乎所有东西都是通过一个整数 ID 来访问的,顶点着色器的输入变量也不例外。一个顶点着色器会有一些整数“插槽”,可以连接到着色器中的特定变量。只有这样,顶点缓冲区中包含的数据才有特定的意义——我们知道它包含顶点位置,是因为我们将那种类型的数据上传到了一个缓冲区,但只有当我们将那个缓冲区连接到一个名为“位置”的变量时,顶点着色器才知道这个数据可能是什么。

顶点缓冲区包含我们决定放入其中的任何数据——它们甚至可能在其中包含多个顶点属性的数据。因此,在连接顶点缓冲区和顶点着色器时,需要有一种方法来告诉 OpenGL 哪些数据应该放在哪里。

通过例子可能最容易理解这一点。所以这里有一个图表,展示了三个顶点缓冲区,包含足够三个顶点的顶点属性信息(分别以红色、绿色和蓝色显示)。

图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 你的第一个三角形_数据_03


缓冲区 1 很简单——它是一个紧密排列的浮点数块,足以构成 3 个三维坐标。缓冲区 2 稍微复杂一些——它包含两个顶点属性的数据,一个顶点属性的所有数据(这里假设是二维向量)在另一个属性(四维向量)的所有数据之前。最后一个缓冲区更加棘手,因为它也有两个顶点属性,但是它们是交错的。所以我们先有顶点 0 的两个属性,然后是顶点 1 的两个属性,最后是顶点 2 的两个属性。这些都是完全有效的顶点缓冲区,但需要一些稍微不同的逻辑才能提取任何特定顶点的顶点属性数据。为了正确地告诉 OpenGL 一个顶点属性在缓冲区中的起始位置,我们需要给它一个偏移值,并且为了告诉它如何从一个顶点的数据获取到另一个顶点的数据,我们需要一个步长值。

图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 你的第一个三角形_游戏引擎_04


这就是前面提到的顶点数组对象(VAO)发挥作用的地方。一个顶点数组对象存储了每个顶点属性要使用的偏移量和步长,以及它要从哪个缓冲区获取数据,还有它应该有多少个元素(对于一个三维向量是 3,等等)。

图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 你的第一个三角形_游戏引擎_05

示例程序

在本教程中,你将看到创建一个由三个顶点组成的简单网格以及一个基本的顶点着色器和片段着色器以在屏幕上显示它的过程。作为这个过程的一部分,你需要进行一些编程,但这段代码将依赖于几个重要的类——网格类(Mesh class)和着色器类(Shader class)。为了让你开始了解 OpenGL 编程的一些有趣元素,我们将首先研究这些类所包含的一些方法,在本教程系列的其余部分中你将使用这些方法。

网格类

网格由一系列顶点组成,OpenGL 将这些顶点存储在一个或多个顶点缓冲对象(VBO)中,任何一个网格所需的这些 VBO 集合通过一个顶点数组对象(VAO)组合在一起。如果你打开网格类(Mesh class)的头文件,你应该在protected:部分看到一系列看起来像是新数据类型的东西,即 GLuint:

//Mesh.h
GLuint arrayObject;

GLuint bufferObject[MAX_BUFFER];

GLuint numVertices;
GLuint numIndices;

GLuint type;

GLuint 实际上是 C++中无符号整型(unsigned int)数据类型的类型定义;你会看到代码库有时会这样做,以便能够快速更改对象的数据类型大小(而不是遍历可能数千行的代码,只需更改一个类型定义即可)。arrayObject 和 bufferObject 变量是我们的顶点数组对象(VAO)和顶点缓冲对象(VBO)。当 OpenGL 生成一个 VAO 或 VBO 时,你会得到一个对象名称——该对象的数字标识符。你可以把它想象成有点像一个指针,因为它是一个代表在其他地方持有的数据的变量,只是在这种情况下,那个“其他地方”是在 OpenGL 驱动程序内部。还有一些成员变量用于保存网格拥有的顶点数量以及它的绘制类型——这可以是三角形、点、线或 OpenGL 支持的任何其他绘制图元。

bufferObject 数组使用一个枚举来定义它的大小,这可能看起来不寻常。下面是这个枚举的样子:

//Mesh.h
enum MeshBuffer {
	VERTEX_BUFFER,
	COLOUR_BUFFER,
	TEXTURE_BUFFER,
	NORMAL_BUFFER,
	TANGENT_BUFFER,
	WEIGHTVALUE_BUFFER,
	WEIGHTINDEX_BUFFER,
	INDEX_BUFFER,
	MAX_BUFFER
};

默认情况下,枚举是命名的整数,从初始值 0 开始递增——所以在这种情况下,MAX_BUFFER 枚举编译后的值为 8;足以用作数组的大小,在它之前的枚举等同于该数组的有效索引。这在 C++中是一个常见的技巧,在这种情况下,它被用来确保我们有一个 GLuint 用于表示单个网格数据的每个顶点缓冲区,每个都有一个容易识别的标识符。

回到网格类。你应该在protected:部分看到它也有一些指针变量:

//Mesh.h
Vector3 * vertices;
Vector4 * colours;
Vector2 * textureCoords;
Vector3 * normals;
Vector4 * tangents;

Vector4 * weights;
int * weightIndices;

这些中的每一个都将是我们顶点属性在 CPU 端的副本。我们有时可能需要在 C++算法中处理一些顶点属性(在教程进行的过程中我们会看到一些这样的情况!),所以值得在 CPU 可以访问的地方保留一份副本——在 GPU 和 CPU 之间复制是很慢的,而且大多数网格不会占用那么多数据,所以一个 CPU 副本是可以的。

在整个教程系列中,我们将在网格类中创建新的函数,并创建它的一个子类。这些将以各种不同的方式在 CPU 上创建顶点数据。不过它们的共同点是需要将那个 CPU 数据复制到 GPU 上。这需要使用一些 OpenGL 命令,并且已经为你提供了一个示例方法,叫做 BufferData。这个方法本身将把所有顶点属性上传到 GPU,但这几乎是一遍又一遍地做同样的事情,所以我们将看一下这个方法的简化版本,以了解 OpenGL API 函数本身是如何工作的:

//Mesh.cpp
void Mesh::BufferData () {
	glGenVertexArrays (1 , & arrayObject );
	glBindVertexArray ( arrayObject );

	glGenBuffers (1 , & bufferObject [ VERTEX_BUFFER ]);
	glBindBuffer ( GL_ARRAY_BUFFER , bufferObject [ VERTEX_BUFFER ]);
	glBufferData ( GL_ARRAY_BUFFER , numVertices * sizeof ( Vector3 ) ,vertices , GL_STATIC_DRAW );
	glVertexAttribPointer ( VERTEX_BUFFER , 3 , GL_FLOAT , GL_FALSE , 0 , 0);
	glEnableVertexAttribArray ( VERTEX_BUFFER );

	if ( colours ) { // Just in case the data has no colour attribute ...
		glGenBuffers (1 , & bufferObject [ COLOUR_BUFFER ]);
		glBindBuffer ( GL_ARRAY_BUFFER , bufferObject [ COLOUR_BUFFER ]);
		glBufferData ( GL_ARRAY_BUFFER , numVertices * sizeof ( Vector4 ) ,colours , GL_STATIC_DRAW );
		glVertexAttribPointer ( COLOUR_BUFFER , 4 , GL_FLOAT , GL_FALSE ,0 ,0);
		glEnableVertexAttribArray ( COLOUR_BUFFER );
	}
	glBindVertexArray (0);
	glBindBuffer ( GL_ARRAY_BUFFER , 0);
}

这个方法首先做的事情是向 OpenGL 驱动程序请求一个新的对象,以代表我们的顶点数组对象。glGenVertexArrays 函数接收两个参数——第一个是我们想要创建多少个对象,第二个是对一些 GLuint 类型变量的引用,用于保存新生成的对象。这相当于在 C++中调用“new”,因为在 OpenGL 驱动程序的某个地方,会分配一些空间来保存顶点数组对象的内部工作机制。

一旦创建完成,接下来要做的就是将我们的网格的顶点数组对象绑定到 OpenGL 状态机上。这是 OpenGL 对对象进行操作的主要方式——大多数 OpenGL 函数不会直接接收对象名称,它们只是使用绑定到 OpenGL 状态机相关部分的对象名称来执行其功能——所以纹理函数将在当前绑定的纹理上执行,或者就像在这种情况下,顶点数组功能将在新绑定的顶点数组对象上执行。一开始这有点让人困惑,但随着你对这个 API 有更多经验,你会很快习惯绑定和解除绑定对象。当顶点数组对象被绑定时,与顶点属性相关的 OpenGL 任何部分的状态更改都将被“缓存”在顶点数组对象中,这使得我们可以在以后通过一个函数调用快速恢复这些状态。

然后,从第 6 行和第 12 行开始,我们经历将一些网格数据缓冲到 GPU 内存中的过程,包括一些顶点位置和一些顶点颜色。在每种情况下,我们生成一个新的顶点缓冲对象(VBO),并将其名称存储在 bufferObject 数组的适当索引中。请注意,头文件中的枚举被广泛使用,以便更容易看出任何特定的 OpenGL 函数调用所指的是哪个属性。然后我们绑定它,这有双重目的——这意味着所有的顶点缓冲函数都在新创建的 VBO 上执行,并且也使我们的顶点数组对象“记住”这是任何顶点属性函数调用应该引用的 VBO。

glBufferData函数实际上是将数据复制到图形内存中的函数。在它的四个参数中,第一个参数不是很有趣,但其他三个参数需要稍微解释一下。就像你在 C++教程中可能使用过的memcpy函数一样,glBufferData需要知道我们正在复制的数据有多大(每个位置是一个由三个浮点数组成的向量,而颜色是一个由四个浮点数组成的向量),所以第二个参数告诉 OpenGL 要从第三个参数(代表我们实际顶点属性数据的 C++数组)复制多少数据。在大多数情况下,顶点属性数据一旦上传就不会改变,所以我们可以用最后一个参数通知 OpenGL 这种可能性——在 OpenGL 术语中,这个“提示”可以让驱动程序在数据上传方式上进行优化,但不会对数据的使用方式施加任何限制。

一个缓冲区只是数据,除了我们上传了一些可能包含坐标的浮点数数据之外,没有什么特别的东西将它与几何图形的渲染联系起来。为了告诉 OpenGL 实际使用这个数据作为顶点属性的来源,我们接下来要调用另外两个函数。

为了用这个新创建的缓冲区的信息填充顶点数组对象(VAO),我们需要调用glVertexAttribPointer函数。第 9 行的调用告诉 OpenGL,顶点属性插槽 0(查看枚举以了解为什么是 0)每个顶点有三个浮点数分量。如果我们的缓冲区包含多个属性,最后两个参数将被设置为适当的步长和指针值;不过,为了简单起见,这个代码库为每个属性都有一个单独的缓冲区,所以它们都可以是零。

用相同的插槽 ID 调用glEnableVertexAttribArray“启用”顶点属性,这意味着 OpenGL 将尝试从在调用glEnableVertexAttribArray函数时绑定的缓冲区中以之前由glVertexAttribPointer设置的格式为顶点着色器提供数据。OpenGL 就像一个黑盒子,这使得这个过程相当令人困惑,但好消息是,由于我们绑定了一个 VAO,OpenGL 将把所有这些信息存储在 VAO 中,所以我们不必一直调用这些顶点属性函数。

并非所有的网格都将使用相同的顶点属性。虽然这个代码库假设每个顶点总是有位置,但它们可能没有其他属性——在第 12 行,我们有一个选择性地决定上传一些顶点颜色数据的例子。请注意,它进入一个不同的顶点属性“索引”,并且有四个浮点数而不是三个;正确设置这些数字很重要,因为这是 OpenGL 知道如何访问存储在缓冲区中的数据的唯一方法。

数据上传后,BufferData函数的最后一个任务是解除绑定 VAO 和顶点缓冲区。解除绑定是可选的,但建议这样做——它确保在 OpenGL 中的其他任何地方调用的其他函数不会意外地修改这些对象的任何状态部分!

在我们程序的某个时刻,我们可能想要绘制我们的网格。由于 VAO 存储了绘制所需的所有顶点状态,所以绘制实际上是一个比上传缓冲区和设置属性更加紧凑的过程。这里是网格类中处理这个问题的方法的一个简化示例:

//Mesh.cpp
void Mesh::Draw () {
	glBindVertexArray (arrayObject);
	glDrawArrays (type , 0 , numVertices);
	glBindVertexArray (0);
}

就像在BufferData中一样,我们必须通过调用glBindVertexArray来绑定 VAO——一次是打开它(第 3 行),一次是在我们完成后再次关闭它(第 5 行)。这就是我们需要做的一切——在 OpenGL 中启用 VAO 就足以恢复所有与顶点属性相关的状态信息!要发出绘制调用,我们可以只调用glDrawArrays,给它一个图元类型、一个起始顶点(这样如果我们愿意的话可以跳过网格的一部分)以及要尝试处理的顶点数量。这通常是所有顶点,所以默认情况下这个函数将使用numVertices变量。

着色器类

为了让绘制的网格实际在屏幕上显示,我们需要一个着色器可执行文件。代码库用一个专用的着色器类来表示这个,这个类可以用许多单独的着色器对象来表示可执行文件。它已经为你完成得足够多,以至于它可以加载着色器文件并生成程序和可执行文件,但是通过在这里查看代码的一些更重要的部分,希望你能更深入地理解 OpenGL 是如何运作的。

与网格不同,网格可以有许多可以用许多不同方式处理的顶点缓冲区,着色器要简单得多。它可以有一个或多个着色器程序,代表图形管线的不同阶段——就我们加载着色器而言,这些不同的阶段只是一个枚举:

//Shader.h
enum ShaderStage {
	SHADER_VERTEX ,
	SHADER_FRAGMENT ,
	SHADER_GEOMETRY ,
	SHADER_DOMAIN ,
	SHADER_HULL ,
	SHADER_MAX
};

我们将使用与在网格类中相同的技巧,使用枚举值来确定一些数组的大小,正如在着色器类的受保护区域中看到的那样。

//Shader.h
protected :
	GLuint programID ;
	GLuint objectIDs [ SHADER_MAX ];
	GLint programValid ;
	GLint shaderValid [ SHADER_MAX ];

	string shaderFiles [ SHADER_MAX ];

就像顶点缓冲区一样,OpenGL 将一个着色器程序或一个着色器对象表示为一个 GLuint。一个着色器可能有多个对象,所以我们有一个这样的对象数组(第 4 行),但它们最终都组合成一个单一的程序(第 3 行)。出于调试目的,了解每个对象的编译过程是否成功也很有好处,所以这个状态也会被存储起来,同时还会存储每个使用的着色器文件的文件名。当一个着色器类被实例化时,会传入一系列文件名,然后这些文件名会被处理和编译,希望最终能得到一个可用于网格渲染的编译后的程序。

从文件加载着色器涉及读取一系列文本行,并将结果输入到 GLSL 编译器中。为此,着色器类中有一个专用的文本加载函数:

//Shader.cpp
bool Shader::LoadShaderFile(const string& filename, string &into){
	ifstream	file(SHADERDIR + filename);
	string		textLine;

	cout << "Loading shader text from " << filename << "\n\n";

	if(!file.is_open()){
		cout << "ERROR ERROR ERROR ERROR: File does not exist!\n";
		return false;
	}
	int lineNum = 1; 
	while(!file.eof()){
		getline(file,textLine);
		textLine += "\n";
		into += textLine;
		cout << "(" << lineNum << ") :" << textLine;
		++lineNum;
	}
	cout << "\nLoaded shader text!\n\n";
	return true;
}

这不是一次性加载整个文本文件的唯一方法,甚至也不是最快的方法,但逐行获取数据可以让我们将着色器的行号打印到控制台,当着色器编译失败并且编译器给出行号时,这会很方便。请注意,代码总是从 SHADERDIR 目录加载着色器,该目录设置为代码库下载的“…/Shaders/”文件夹。

文本文件加载只是故事的一部分,真正的操作发生在我们使用生成的文本编译着色器时,这由 GenerateShaderObject 方法处理。这个方法首先使用上面的 LoadShaderFile 方法读取一个着色器文件,将生成的 GLSL 代码放入 shaderText 变量中:

//Shader.cpp
void Shader::GenerateShaderObject(unsigned int i)	{
	cout << "Compiling Shader...\n";

	string shaderText;
	if(!LoadShaderFile(shaderFiles[i],shaderText)) {
		cout << "Loading failed!\n";
		shaderValid[i] = false;
		return;
	}

创建一个着色器对象就像调用一个 OpenGL 函数 glCreateShader 一样简单。这个函数接收一个特殊的 OpenGL 定义的值,这个值告诉它将创建的是什么类型的着色器。为了方便地遍历不同的着色器类型,代码库使用一个枚举来保存这些值:

//shaderTypes enum
GLuint shaderTypes[SHADER_MAX] = {
	GL_VERTEX_SHADER,
	GL_FRAGMENT_SHADER,
	GL_GEOMETRY_SHADER,
	GL_TESS_CONTROL_SHADER,
	GL_TESS_EVALUATION_SHADER
};

任何时候你看到一个变量以“GL ”开头,你通常可以假设它是在 OpenGL 头文件的某个地方定义的,并且有一个对 OpenGL 驱动程序有意义的值。

由于 OpenGL 是一个 C 库,它接收字符数组而不是 std::string,所以在第 33 行和第 34 行,我们从字符串中取出这些内容并将它们放入变量中。在第 35 行调用的 glShaderSource 函数实际上可以一次读取多组字符串(由它的第二个参数控制),所以它接收一个指向字符指针数组的指针以及它们的长度。glShaderSource 只是将文本文件“附加”到内部的 OpenGL 对象上;只有在第 36 行调用 glCompileShader 函数时,编译器才会运行将文本转换为你的图形卡可以理解的代码。

文本文件中的代码可能包含错误,所以我们使用 glGetShaderiv 询问 OpenGL 它是否成功编译,glGetShaderiv 是一个通用函数,它接收一个表示我们想要提取什么信息的值(在这种情况下是编译状态)以及一个我们希望它放置答案的引用。知道一个着色器是否工作肯定很方便,所以这个信息被输出到控制台。

//Shader.cpp
	objectIDs[i] = glCreateShader(shaderTypes[i]);

	const char *chars	= shaderText.c_str();
	int textLength		= (int)shaderText.length();
	glShaderSource(objectIDs[i], 1, &chars, &textLength);
	glCompileShader(objectIDs[i]);

	glGetShaderiv(objectIDs[i], GL_COMPILE_STATUS, &shaderValid[i]);

	if (!shaderValid[i]) {
		cout << "Compiling failed!\n";
		PrintCompileLog(objectIDs[i]);
	}
	else {
		cout << "Compiling success!\n\n";
	}

	glObjectLabel(GL_SHADER, objectIDs[i], -1, shaderFiles[i].c_str());
	glAttachShader(programID, objectIDs[i]);
}

如果成功,我们可以使用 glAttachShader 将对象连接到整个程序中——大多数时候,这将是一个顶点着色器和一个片段着色器,我们很快就会看到它们的例子。

一旦所有的着色器对象都被附加,程序就可以被链接,就像你的 C++程序在每个编译单元都被编译后进行链接一样。简短的 LinkProgram 方法处理这个问题,并获取链接尝试的结果,与上面的编译类似。

void Shader::LinkProgram()	{
	glLinkProgram(programID);
	glGetProgramiv(programID, GL_LINK_STATUS, &programValid);
}

前面我们看到顶点着色器有一系列的插槽,这些插槽与着色器变量连接,以便顶点数组对象(VAO)的缓冲插槽可以发送到正确的变量。这些插槽可以在顶点着色器中显式地定义,但 OpenGL 也允许我们在 C++代码中使用 glBindAttribLocation 来做到这一点。这个函数接收一个着色器程序、一个插槽 ID 和一个要连接到该插槽的变量名。这里是一个简化的示例,展示了着色器类如何通过 SetDefaultAttributes 方法自动为我们完成这个操作:

void Shader::SetDefaultAttributes()	{
	glBindAttribLocation(programID, VERTEX_BUFFER,  "position");
	glBindAttribLocation(programID, COLOUR_BUFFER,  "colour");
	//还有其他属性...
}

教程任务

在 OpenGL 中,网格和着色器是非常重要的概念,了解它们如何工作的最好方法是自己制作一些!在本教程中,我们在“…/Shaders/”文件夹中需要两个新的着色器,分别叫做“BasicVertex.glsl”和“ColourFragment.glsl”。为了了解这些着色器如何影响我们看到的几何图形,我们还将为它们制作一个自定义的网格进行渲染。要做到这一点,请转到网格类的头文件,并在公共部分添加一个新的静态方法声明:

//Mesh.h
public:
	static Mesh* GenerateTriangle();

在Mesh.cpp文件中,创建如下方法定义:

//Mesh.cpp
Mesh* Mesh::GenerateTriangle()	{
	Mesh*m = new Mesh();
	m->numVertices = 3;

	m->vertices = new Vector3[m->numVertices];
	m->vertices[0] = Vector3(0.0f,	0.5f,	0.0f);
	m->vertices[1] = Vector3(0.5f,  -0.5f,	0.0f);
	m->vertices[2] = Vector3(-0.5f, -0.5f,	0.0f);

	m->colours = new Vector4[m->numVertices];
	m->colours[0] = Vector4(1.0f, 0.0f, 0.0f,1.0f);
	m->colours[1] = Vector4(0.0f, 1.0f, 0.0f,1.0f);
	m->colours[2] = Vector4(0.0f, 0.0f, 1.0f,1.0f);
	
	m->BufferData();
	return m;
}

静态函数GenerateTriangle将返回一个指向Mesh的指针,其顶点数组对象(VAO)和顶点缓冲对象(VBO)中包含的数据将绘制一个彩色三角形。它通过在堆上初始化一个新的Mesh(在第 3 行)来实现这一点,将其顶点数量设置为 3,然后用数据初始化其顶点指针和颜色指针。你应该能够看到颜色如何分别对应红色、绿色和蓝色,以及三角形的顶点如何按照上、右下、左下的顺序排列。现在几何图形在系统内存中,但要在 OpenGL 中使用它,必须将其复制到 VBO 中。这是通过调用BufferData实现的。数据传输完成后,可以返回一个指向新三角形的指针。

顶点着色器

你的第一个顶点着色器不会做很多事情——它将依次接收每个顶点的位置和颜色,然后简单地输出它们。我们从一个 GLSL 预处理器定义开始,将 GLSL 着色器输出设置为与 OpenGL 3 兼容的着色器可执行文件。然后,我们定义两个输入变量——这些是我们的顶点属性,你应该注意到它们与前面着色器类的SetDefaultAttributes方法中引用的变量具有相同的名称。然后,我们定义一个输出接口块,它由一个单一的四分量向量组成,将保存从顶点着色器发送到片段着色器的数据。你可以把接口块想象成 C++结构体,它们将输入和输出数据保存在一起。

GLSL 着色器必须有一个主函数,就像 C++程序一样——而且它们的定义方式完全相同!我们的顶点着色器的主函数只有两行;第一行将 GLSL 输出顶点位置设置为传入的顶点位置,并扩展为 GLSL 期望的顶点位置的四分量向量格式(你将在后续教程中看到为什么)。第二行将顶点着色器的输出设置为正在处理的顶点的颜色。这就是全部内容!

//basicVertex.glsl
# version 330 core
in vec3 position;
in vec4 colour;

out Vertex {
	vec4 colour;
} OUT;

void main (void) {
	 gl_Position = vec4 (position , 1.0);
	 OUT . colour = colour;
}

片段着色器

你的第一个片段着色器甚至会更短——它所要做的就是输出一个片段,颜色是从顶点着色器发送给它的。和我们的顶点着色器一样,我们将从设置版本预处理器定义和接口块的定义开始。这个块包含与我们顶点着色器的输出接口块相同的值,甚至有相同的名称——为了让链接过程正确地匹配这些块,所有的东西都必须完全匹配。然后,我们有我们的输出值——OpenGL 不允许片段输出使用接口块,所以它只是一个单一的vec4——这是有道理的,因为片段着色器所能输出的只是片段颜色。最后,我们创建片段着色器所需的主函数。正如你所看到的,它所做的只是将片段颜色设置为传入的颜色,然后返回。

//colourFragment.glsl
#version 330 core

in Vertex {
	vec4 colour;
} IN;

out vec4 fragColour;
void main (void) {
	fragColour = IN.colour;
}

渲染器类

在本教程系列中,每个项目都将由一个渲染器类控制,每个渲染器类都将从OGLRenderer基类派生,该基类为你处理初始化 OpenGL 上下文的过程。我们在本教程中创建的渲染器类将非常简单——将其作为新类添加到 Tutorial1 项目中,以OGLRenderer作为其父类——OGLRenderer构造函数为你创建一个 OpenGL 上下文,甚至已经包含了你的网格和着色器头文件!

以下是你应该添加到刚刚创建的头文件中的内容:

//renderer.h
#pragma once
#include "OGLRenderer.h"
class Renderer : public OGLRenderer
{
public:
	Renderer(Window& parent);
	virtual ~Renderer(void);
	virtual void RenderScene();

protected:
	Mesh* triangle;
	Shader* basicShader;
};

很直接,但是你可以看到它将使用我们之前看到的网格(Mesh)和着色器(Shader)类。

渲染器类的构造函数首先使用我们之前编写的GenerateTriangle函数来初始化成员变量triangle。然后,我们通过将我们刚刚创建的顶点和片段着色器源文件的文件名作为参数来初始化成员变量basicShader。然后,我们将OGLRenderer成员变量init设置为true——我们的主函数将检查这个值,以确定构造函数中的所有内容是否正常工作。析构函数非常简短——它所要做的就是删除我们刚刚创建的内容!

//renderer.cpp
#include "Renderer.h"

Renderer::Renderer(Window& parent) : OGLRenderer(parent) {
	triangle = Mesh::GenerateTriangle();
	basicShader = new Shader("basicVertex.glsl", "colourFragment.glsl");

	if (!basicShader->LoadSuccess())
	{
		return;
	}
	init = true;
}
Renderer ::~Renderer(void) {
	delete triangle;
	delete basicShader;
}

我们教程中的渲染器类的最后一个函数是最重要的一个!RenderScene启用我们的着色器并绘制我们的三角形。在绘制三角形之前,它还通过清除上一帧来设置图像,因为它从操作系统接收的用于渲染的缓冲区中会有上一帧的数据(或者在最初的几帧中有未初始化的数据)。

glClearColor告诉 OpenGL 要将屏幕清除为什么颜色——在这种情况下是深灰色。对于调试目的来说,这比默认的黑色更好,因为如果 OpenGL 在某个地方处于损坏状态,它倾向于将对象绘制为黑色。OGLRenderer基类已经包含了这个函数,但在这里明确说明它的目的是值得的。glClear是实际将屏幕清除为所选颜色的函数,使用GL_COLOR_BUFFER_BIT符号常量。在后面的教程中,你将看到如何通过将几个符号常量进行或运算来同时清除其他缓冲区。

//renderer.cpp
void Renderer::RenderScene() {
	glClearColor(0.2f, 0.2f, 0.2f, 1.0f);
	glClear(GL_COLOR_BUFFER_BIT);

	BindShader(basicShader);
	triangle->Draw();
}

主文件

以下代码是所有教程的主函数都将使用的基本模板,只有一些小的变化。在主函数中,我们首先声明一个Window类的实例。它接收四个参数——一个在窗口顶部显示的字符串、一个窗口宽度和高度(以像素为单位)以及一个布尔值来确定是否全屏。如果由于某种原因我们的窗口初始化失败,我们通过第 8 行的 if 语句使程序退出。

然后,我们创建一个我们的渲染器类的实例,将我们的Window类传递给它,以便它知道要渲染到什么地方。与Window类一样,有一个函数用于检查是否成功初始化。

#include "../NCLGL/window.h"
#include "Renderer.h"

int main()	{
	Window w("My First OpenGL Triangle!", 800, 600, false);

	if(!w.HasInitialised()) {
		return -1;
	}
	
	Renderer renderer(w);
	if(!renderer.HasInitialised()) {
		return -1;
	}

	while(w.UpdateWindow()  && !Window::GetKeyboard()->KeyDown(KEYBOARD_ESCAPE)){
		renderer.RenderScene();
		renderer.SwapBuffers();
	}
	return 0;
}

完成初始化后,我们可以进入主循环。你可能以前用过类似的东西——这是一个 while 循环,不断更新我们的场景,直到窗口关闭(while 循环中断条件的前半部分),或者用户按下 Esc 键(后半部分)。

在我们的循环中,我们将反复调用渲染器类的RenderScene函数,然后告诉 OpenGL 交换缓冲区。一旦最终跳出 while 循环,函数返回 0,结束程序执行。


总结

如果一顺利,当你运行程序时,你会看到类似于开头的图片。如果是这样,那么做得好!你刚刚编写了你的第一个 OpenGL 程序和你的第一个着色器。一个三角形可能不是太令人印象深刻,但你已经学到了很多 OpenGL 渲染的基本概念。现在你应该知道什么是顶点数组对象,什么是顶点缓冲对象,如何创建它们,如何将几何图形发送到图形卡,最后如何使用顶点和片段着色器在屏幕上实际绘制东西。你还编写了两个重要的类,以封装着色器和网格绘制的功能。在接下来的教程中,你将在此基础上进行扩展,看看如何使用模型矩阵移动、缩放和旋转三角形,并使用投影矩阵为场景添加透视效果。

课后作业

  1. 尝试用三角形制作更多的形状——方格纸在这里可能会很有用!对于一个由 10 个三角形组成的网格,在调用glDrawArrays时你会使用什么值?glBufferData呢?你如何绘制一个四边形而不是三角形?
  2. 三角形顶点缓冲对象中定义的颜色是逐像素自动插值的,以产生“彩虹”效果。研究 GLSL 中的smoothflat插值限定符。
  3. 一旦顶点缓冲数据被复制到图形硬件中,它可以通过映射到系统地址空间来进行修改。研究 OpenGL 命令glMapBufferglUnmapBuffer——尝试使用它们在每一帧为三角形生成随机颜色。
  4. 在本教程中,我们使用了GL_TRIANGLES符号常量与glDrawArrays。研究GL_LINESGL_TRIANGLE_STRIPGL_POINTS。OpenGL 函数GLPointSize在这里可能会很有用。GL_LINES的行为是否如你所期望的那样?GL_TRIANGLE_STRIP在绘制四边形时如何提供帮助?
  5. 当我们将数据缓冲到顶点缓冲对象时,我们使用了符号常量GL_STATIC_DRAW。研究GL_DYNAMIC_DRAWGL_STREAM_DRAW符号常量。你会使用什么符号常量来创建可变形物体?

欢迎大家踊跃尝试,期待同学们的留言!!