图形编程在刚开始时可能是一项艰巨的任务。渲染管道涉及大量步骤,每个步骤都涉及各种数学运算。它有运行实际程序来计算下一个结果的阶段。掌握这个管道,能够用它作为实现视觉效果的工具,是成为图形程序员的本质。
本书的这一部分将介绍 3D 图形所需的基本数学知识。它将介绍 OpenGL 定义的渲染管道。它将演示数据如何流经图形管道。
介绍
与本书的大多数章节不同,本节没有源代码或项目。在这里,我们将讨论矢量数学、图形渲染理论和 OpenGL。这是本书其余部分的入门知识。
矢量数学
本书假设您熟悉代数和几何,但不一定熟悉矢量数学。后面的内容将带您快速了解更复杂的主题,但本书将介绍矢量数学的基础知识。
向量可以有很多含义,这取决于我们是在几何上还是在数字上谈论。无论哪种情况,向量都有维数;这表示向量的维数。二维向量仅限于单个平面,而三维向量可以指向任何物理空间。向量可以具有更高的维度,但通常我们只处理 2 到 4 之间的维度 。
从技术角度来说,向量只能有一个维度。这样的向量称为 标量。
从几何学角度来说,向量可以表示两个概念之一:特定空间中的位置或方向。向量 位置 表示空间中的特定位置。例如,在此图上,我们有一个向量位置 A:
图 1. 位置向量
向量也可以表示 方向。方向向量没有原点;它们只是指定空间中的方向。这些都是方向向量,但向量 B 和 D 是相同的,即使它们绘制在不同的位置:
图 2. 方向向量
这对于几何学来说很好,但向量也可以用数字来描述。在这种情况下,向量是一系列数字,每个维度一个数字。因此,二维向量有两个数字;三维向量有三个数字。等等。从数字上讲,标量只是一个数字。
向量中的每个数字称为一个 分量。每个分量通常都有一个名称。就我们的目的而言,向量的第一个分量是 X 分量。第二个分量是 Y 分量,第三个分量是 Z 分量,如果有第四个分量,则称为 W。
在文本中写矢量时,它们用括号括起来。因此,3D 矢量可以是 (0, 2, 4);X 分量为 0,Y 分量为 2,Z 分量为 4。当将它们写为等式的一部分时,它们写法如下:
在数学方程式中,向量变量要么用粗体表示,要么用箭头表示。
在用图形绘制矢量时,人们会区分位置矢量和方向矢量。然而,从数字上看,这两者 没有 区别。唯一的区别在于你如何使用它们,而不是你如何用数字表示它们。因此,你可以将位置视为一个方向,然后对它们应用一些矢量运算,然后再次将结果视为一个位置。
尽管向量具有单独的数值分量,但作为一个整体,向量可以应用许多数学运算。我们将展示其中的一些运算,包括它们的几何和数字表示。
向量加法。 你可以取两个向量并将它们相加。图形上,其工作原理如下:
图 3. 向量加法
请记住,向量方向可以改变而不会改变其值。因此,如果将两个向量首尾相对,向量和就是从第一个向量尾部到最后一个向量头部的方向。
图 4. 向量首尾相加
从数值上来说,两个向量的和只是相应分量的和:
公式 1. 带数字的向量加法
对向量的每个分量执行操作的任何操作都称为 分量运算。向量加法是分量加法。对两个向量执行的任何分量运算都要求这两个向量具有相同的维数。
向量求反和减法。 你可以对向量求反。这会反转其方向:
图 5. 向量求反
从数字上来说,这意味着对向量的每个分量取负数。
等式 2. 向量求反
与标量数学一样,向量减法与第二个向量取反的加法相同。
图 6. 矢量减法
向量乘法。 向量乘法是少数没有实际几何等效的向量运算之一。将一个方向乘以另一个方向,或将一个位置乘以另一个位置,实际上没有任何意义。但这并不意味着数值等效没有用处。
两个向量的数值相乘仅仅是分量相乘,很类似于向量加法。
等式 3. 向量乘法
向量/标量运算。 向量可以通过标量值进行运算。回想一下,标量只是单个数字。向量可以与标量相乘。这会根据标量值放大或缩小向量的长度。
图 7. 矢量缩放
从数值上讲,这是一个逐分量乘法,其中向量的每个分量与标量的每个分量相乘。
等式 4. 矢量标量乘法
标量也可以添加到向量中。这与向量到向量的乘法一样,没有几何表示。它是标量与向量的每个分量逐个相加。
等式 5. 矢量标量加法
向量代数。 了解这些向量运算之间的关系很有用。
向量加法和乘法遵循许多与标量加法和乘法相同的规则。它们是交换律、结合律和分配律。
等式 6. 向量代数
矢量/标量运算具有类似的属性。
长度。 向量具有长度。向量方向的长度是从起点到终点的距离。
从数值上讲,计算距离需要以下方程:
等式 7. 向量长度
这使用勾股定理来计算向量的长度。这适用于任意维度的向量,而不仅仅是二维或三维。
单位向量和归一化。 长度恰好为 1 的向量称为 单位向量。 这表示具有标准单位长度的纯方向。数学方程中的单位向量变量在变量名称上用 ^ 表示。
_ 通过对向量进行归一化,_ 可以将向量转换为单位向量。方法是将向量除以其长度。或者更确切地说,乘以长度的倒数。
等式 8. 向量归一化
这并不是我们将在本教程中使用的所有矢量数学。新的矢量数学运算将在首次使用时根据需要进行介绍和解释。与此处介绍的数学运算不同,它们中的大多数不是按分量进行的运算。
范围符号。 本书将频繁使用标准符号来指定某个值必须在一定范围内。
如果某个值被限制在 0 到 1 之间,而它实际上可能具有值 0 和 1,则称该值“在范围 [0, 1] 内”。方括号表示该范围包括其旁边的值。
如果一个值被限制在 0 和 1 之间,但它实际上可能不具有 0 这个值,那么它就被说成在范围 (0, 1) 内。括号表示范围不包括该值。
如果将值限制为 0 或任何大于零的数字,则将使用无穷大符号。此范围由 [0, ∞) 表示。请注意,无穷大永远无法达到,因此它始终是独有的。对任何小于零但不包括零的数字的限制都在范围 (-∞, 0) 内。
图形和渲染
这是渲染过程的概述。如果您不能立即理解所有内容,请不要担心;后面的教程将详细介绍每个步骤。
您在计算机屏幕上看到的一切,甚至您现在正在阅读的文本(假设您是在电子显示设备上阅读,而不是打印输出)都只是二维像素阵列。如果您截取屏幕上的某些内容并将其放大,它会看起来非常块状。
图 8. 图像
每个块都是一个 像素 。“像素 (pixel)”一词 源于“图片元素 (Picture Element) ”一词。屏幕上的每个像素都有特定的颜色。二维像素阵列称为 图像。
因此,任何类型的图形的目的都是确定将哪种颜色放入哪个像素中。这种确定性使得文本看起来像文本,窗口看起来像窗口,等等。
既然所有图形都只是二维像素阵列,那么 3D 是如何工作的呢?因此,3D 图形是一种为像素生成颜色的系统,它让您相信您正在查看的场景是一个 3D 世界,而不是 2D 图像。将 3D 世界转换为该世界的 2D 图像的过程称为 _渲染。
渲染 3D 世界的方法有很多种。实时图形硬件(例如计算机中的硬件)所使用的处理过程涉及大量伪造。此过程称为光栅化,使用光栅化的渲染系统称为 光栅化器。
在光栅化器中,您看到的所有对象都是空壳。有些技术可用于切开这些空壳,但这只是用另一个壳替换壳的一部分,以显示内部的样子。一切都是壳。
所有这些外壳都是由三角形构成的。如果你仔细观察,即使是看起来是圆形的表面也只是三角形。有些技术可以为看起来更近或更大的物体生成更多三角形,这样观察者几乎看不到物体的多面轮廓。但它们始终都是由三角形构成的。
Note
一些光栅化器使用平面四边形:四边形物体,其中所有点都位于同一平面上。基于硬件的光栅化器始终使用三角形的原因之一是三角形的所有线都保证位于同一平面上。了解这一点会使光栅化过程变得不那么复杂。
物体由一系列相邻的三角形组成,这些三角形定义了物体的外表面。这些三角形系列通常称为 几何体 (geometry)、模型 (model) 或 网格 (mesh)。这些术语可互换使用。
光栅化过程有几个阶段。这些阶段按顺序排列在管道中,三角形从顶部进入,二维图像在底部填充。这就是光栅化如此适合硬件加速的原因之一:它按特定顺序一次对每个三角形进行操作。三角形可以输入到管道顶部,而先前发送的三角形仍处于光栅化的某个阶段。
三角形和各种网格提交给光栅化器的顺序会影响其输出。请始终记住,无论您如何提交三角形网格数据,光栅化器都会按照特定顺序处理每个三角形,只有当前一个三角形绘制完成后才会绘制下一个三角形。
OpenGL 是用于访问基于硬件的光栅化器的 API。因此,它符合基于光栅化的 3D 渲染器的模型。光栅化器从用户那里接收一系列三角形,对它们执行操作,并根据此三角形数据写入像素。这是对 OpenGL 中光栅化工作方式的简化,但对于我们的目的来说很有用。
三角形和顶点。 三角形由 3 个顶点组成。顶点 是任意数据的集合。为了简单起见(我们稍后会对此进行扩展),我们假设此数据必须包含三维空间中的一个点。它可能包含其他数据,但至少必须包含此数据。任何不在同一条线上的 3 个点都会形成一个三角形,因此三角形的最小信息由 3 个三维点组成。
三维空间中的一个点由 3 个数字或坐标定义。X 坐标、Y 坐标和 Z 坐标。这些通常用括号表示,例如 (X, Y, Z)。
光栅化概述
光栅化管道,尤其是对于现代硬件而言,非常复杂。这是对该管道的一个非常简化的概述。在了解使用 OpenGL 渲染事物的细节之前,有必要对管道有一个简单的了解。如果没有高层次的概述,这些细节可能会让人不知所措。
裁剪空间变换。 光栅化的第一阶段是将每个三角形的顶点变换到某个空间区域。该体积内的所有内容都将被渲染到输出图像中,而该区域之外的所有内容则不会被渲染。该区域对应于用户想要渲染的世界视图。
用 OpenGL 的说法,三角形变换到的体积称为 裁剪空间。三角形顶点在裁剪空间中的位置称为 裁剪坐标。
裁剪坐标与常规位置略有不同。三维空间中的位置有 3 个坐标。裁剪空间中的位置有 四个 坐标。前三个是通常的 X、Y、Z 位置;第四个称为 W。最后一个坐标实际上定义了此顶点的裁剪空间范围。
三角形内不同顶点的裁剪空间实际上可能有所不同。它是 X、Y 和 Z 方向上范围为 [-W, W] 的 3D 空间区域。因此,具有不同 W 坐标的顶点与其他顶点位于不同的裁剪空间立方体中。由于每个顶点可以具有独立的 W 分量,因此三角形的每个顶点都存在于其自己的裁剪空间中。
在裁剪空间中,正 X 方向向右,正 Y 方向向上,正 Z 方向远离观察者。
将顶点位置转换到裁剪空间的过程相当随意。OpenGL 在此步骤中提供了很大的灵活性。我们将在整个教程中详细介绍此步骤。
由于裁剪空间是世界的可见变换版本,因此任何超出此区域的三角形都将被丢弃。部分超出此区域的三角形将经历一个称为 裁剪的过程。 这会将三角形分解为多个较小的三角形,使得较小的三角形完全在裁剪空间内。因此得名“裁剪空间” 。
规范化坐标。 裁剪空间很有趣,但不太方便。这个空间的范围对于每个顶点都是不同的,这使得三角形的可视化变得相当困难。因此,裁剪空间被转换为更合理的坐标空间: 规范化设备坐标。
这个过程很简单。将每个顶点位置的 X、Y 和 Z 除以 W 即可得到规范化的设备坐标。就这样。
规范化设备坐标的空间本质上就是裁剪空间,只是 X、Y 和 Z 的范围是 [-1, 1]。方向都相同。除以 W 是将 3D 三角形投影到 2D 图像上的重要部分;我们将在以后的教程中介绍这一点。
图 9. 规范化设备坐标空间
立方体表示规范化设备坐标空间的边界。
窗口变换。 光栅化的下一个阶段是再次变换每个三角形的顶点。这一次,它们从规范化设备坐标转换为 窗口坐标。顾名思义,窗口坐标是相对于 OpenGL 在其中运行的窗口的。
尽管它们指的是窗口,但它们仍然是三维坐标。X 向右,Y 向上,Z 向后,就像剪辑空间一样。唯一的区别是这些坐标的边界取决于可视窗口。还应注意,虽然这些是窗口坐标,但精度不会丢失。这些不是整数坐标;它们仍然是浮点值,因此它们的精度超过了单个像素的精度。
Z 的边界为 [0, 1],0 表示最近,1 表示最远。此范围之外的顶点位置不可见。
请注意,窗口坐标以左下角位置作为 (0, 0) 原点。这与用户习惯的窗口坐标相反,即以左上角位置作为原点。如果需要,您可以使用一些变换技巧,以便在左上角坐标空间中工作。
随着教程的进展,我们将详细讨论这一过程的全部细节。
扫描转换。 将三角形的坐标转换为窗口坐标后,三角形将经历一个称为 扫描转换的过程。 此过程获取三角形,并根据三角形覆盖的输出图像上的窗口像素排列将其分解。
图 10. 扫描转换三角形
中心图像显示输出像素的数字网格;圆圈代表每个像素的中心。每个像素的中心代表一个 样本:像素区域内的离散位置。在扫描转换期间,三角形将为三角形二维区域内的每个像素样本生成一个 片段 。
右图显示了三角形扫描转换生成的碎片。这大致近似了三角形的一般形状。
渲染共享边的三角形的情况非常常见。OpenGL 保证,只要共享边的顶点位置相同,扫描转换期间就不会出现采样间隙。
图 11. 共享边扫描转换
为了方便使用,OpenGL 还提供了保证,即如果您将相同的输入顶点数据通过相同的顶点处理器,您将获得相同的输出;这称为 不变性保证。因此,用户有责任使用相同的输入顶点,以确保无间隙扫描转换。
扫描转换本质上是 2D 操作。此过程仅使用三角形在窗口坐标中的 X 和 Y 位置来确定要生成哪些片段。Z 值不会被忽略,但它不是扫描转换三角形的实际过程的直接组成部分。
扫描转换三角形的结果是覆盖三角形形状的一系列片段。每个片段都具有与其关联的某些数据。此数据包含片段在窗口坐标中的 2D 位置,以及片段的 Z 位置。此 Z 值称为片段的深度。片段中可能还包含其他信息,我们将在后续教程中对此进行扩展。
片段处理。 此阶段从扫描转换的三角形中获取片段,并将其转换为一个或多个颜色值和一个深度值。单个三角形的片段处理顺序无关紧要;由于单个三角形位于单个平面中,因此从其生成的片段不可能重叠。但是,另一个三角形的片段可能会重叠。由于顺序在光栅化器中很重要,因此必须先处理一个三角形的片段,然后再处理另一个三角形的片段。
此阶段相当随意。OpenGL 用户有很多选择来决定如何为片段分配什么颜色。我们将在整个教程中详细介绍此步骤。
Direct3D 注意事项
Direct3D 更喜欢将此阶段称为“像素处理”或 “像素着色”。出于多种原因,这是一个错误的名称。首先,像素的最终颜色可以由单个像素内的多个 样本 生成的多个片段的结果组成。这是一种去除三角形锯齿状边缘的常用技术。此外,片段数据尚未写入图像,因此它还不是像素。实际上,片段处理步骤可以根据任意计算有条件地阻止片段的渲染。因此, D3D 术语中的“像素”可能根本不会真正成为像素。
片段写入。 生成一个或多个颜色和一个深度值后,片段被写入目标图像。此步骤不仅仅涉及写入目标图像。将颜色和深度与图像中当前的颜色相结合可能涉及大量计算。这些将在各种教程中详细介绍。
颜色
以前,像素被认为是二维图像中具有特定颜色的元素。颜色可以用多种方式来描述。
在计算机图形学中,颜色通常被描述为一系列在 [0, 1] 范围内的数字。每个数字都对应一个特定参考颜色的强度;因此,该系列数字所表示的最终颜色是这些参考颜色的混合。
这组参考颜色称为 颜色空间。屏幕最常见的颜色空间是 RGB,其中参考颜色是红色、绿色和蓝色。印刷作品往往使用 CMYK(青色、洋红色、黄色、黑色)。由于我们要处理屏幕渲染,并且 OpenGL 需要它,因此我们将使用 RGB 颜色空间。
Note
您可以使用编程着色器玩一些奇特的游戏(见下文),这些着色器允许您在不同的颜色空间中工作。因此从技术上讲,我们只需输出到线性 RGB 颜色空间。
因此,OpenGL 中的一个像素被定义为范围在 [0, 1] 内的 3 个值,它们表示线性 RGB 颜色空间中的颜色。通过组合这 3 种颜色的不同强度,我们可以生成数百万种不同的色调。稍后我们将处理透明度问题,这将略有扩展。
着色器
着色器 是一种设计用于在渲染器上作为渲染操作的一部分运行的程序。无论使用哪种渲染系统,着色器都只能在渲染过程的某些点执行。这些 着色器阶段 代表钩子,用户可以在其中添加任意算法来创建特定的视觉效果。
就光栅化而言,如上所述,有多个着色器阶段,其中任意处理既节省性能又为用户提供高实用性。例如,将传入的顶点转换为剪辑空间对于用户定义代码来说是一个有用的钩子,将片段处理为最终颜色和深度也是如此。
OpenGL 的着色器在实际渲染硬件上运行。这通常可以释放宝贵的 CPU 时间来执行其他任务,或者简单地执行那些在没有执行任意代码的灵活性的情况下很难甚至不可能完成的操作。这样做的缺点是它们必须在某些限制之内,而 CPU 代码则不必这样做。
各种 API 都提供多种着色语言。本教程中使用的是 OpenGL 的主要着色语言。它被毫无想象力地称为 OpenGL 着色语言,简称为 GLSL。它看起来很像 C,但实际上并不是C。
什么是 OpenGL
在开始编写 OpenGL 应用程序之前,我们必须首先知道我们要编写的是什么。OpenGL 到底是什么?
OpenGL 作为 API
OpenGL 通常被认为是应用程序编程接口 ( API )。OpenGL API 已暴露于多种语言。但它们最终在最低级别使用的都是 C API。
OpenGL API 被定义为状态机。几乎所有 OpenGL 函数都会设置或检索 OpenGL 中的某些状态。唯一不会改变状态的函数是使用当前设置的状态来引发渲染的函数。
你可以将状态机想象成一个包含大量不同字段的非常大的结构体。这个结构体被称为OpenGL 上下文,上下文中的每个字段都代表渲染所需的一些信息。
在 C 语言中,API 由许多 typedef、#defined 枚举值和函数定义。typedef 定义基本 GL 类型,如 GLint、 GLfloat 等。这些类型被定义为具有特定的位深度。
OpenGL 中永远不会直接暴露结构等复杂聚合。任何此类结构都隐藏在 API 后面。这使得向非 C 语言暴露 OpenGL API 变得更加容易,而无需复杂的转换层。
在 C++ 中,如果您想要一个包含整数、浮点数和字符串的对象,您可以像这样创建并访问它:
struct Object
{
int count;
float opacity;
char *name;
};
//Create the storage for the object.
Object newObject;
//Put data into the object.
newObject.count = 5;
newObject.opacity = 0.4f;
newObject.name = "Some String";在 OpenGL 中,你可以使用类似这样的 API:
//Create the storage for the object
GLuint objectName;
glGenObject(1, &objectName);
//Put data into the object.
glBindObject(GL_MODIFY, objectName);
glObjectParameteri(GL_MODIFY, GL_OBJECT_COUNT, 5);
glObjectParameterf(GL_MODIFY, GL_OBJECT_OPACITY, 0.4f);
glObjectParameters(GL_MODIFY, GL_OBJECT_NAME, "Some String");当然,这些都不是真正的 OpenGL 命令。这只是此类对象接口的示例。
OpenGL 拥有所有 OpenGL 对象的存储空间。因此,用户只能通过引用访问对象。几乎所有 OpenGL 对象都由无符号整数 (GLuint ) 引用。对象由形式为 glGen* 的函数创建,其中 * 是对象的类型。第一个参数是要创建的对象数,第二个参数是 接收新创建的对象名称的 GLuint* 数组。
要修改大多数对象,我们必须先将它们绑定到上下文。许多对象可以绑定到上下文中的不同位置;这允许以不同的方式使用同一对象。这些不同的位置称为 目标;所有对象都有一个有效目标列表,有些只有一个。在上面的例子中,虚构的目标 “ GL_MODIFY ”是绑定 objectName 的位置。
将目标视为全局指针。因此,在我们的例子中,目标 GL_MODIFY 只是一个可以存储此类型对象的全局指针。调用 glBindObject(GL_MODIFY, objectName) 类似于执行以下操作:
Object *GL_MODIFY = NULL;
//glBindObject:
GL_MODIFY = ptr(objectName); //Convert object into pointer.修改这些对象的 OpenGL 函数仅修改绑定到上下文的对象。这些函数将要修改的对象的目标作为其第一个参数。这引用了绑定的对象之一。
枚举器 GL_OBJECT_* 都命名了对象中可以设置的字段。glObjectParameter 函数系列设置了与给定目标绑定的对象内的参数。请注意,由于 OpenGL 是 C API,因此它必须 glObjectParameteri 对每个不同类型的变体进行不同的命名。因此,整数参数、 glObjectParameterf 浮点参数等等都有相应的名称。就代码而言,您可以将这些函数视为以下形式:
//glObjectParameteri(GL_MODIFY, GL_OBJECT_COUNT, 5);
GL_MODIFY->count = 5;
//glObjectParameterf(GL_MODIFY, GL_OBJECT_OPACITY, 0.4f);
GL_MODIFY->opacity = 0.4f;
...将对象 0 绑定到上下文中的 a 目标相当于将该目标的全局指针设置为 NULL。它会解除当前绑定到该目标的任何对象。
请注意,并非所有 OpenGL 对象都像此示例一样简单,并且更改对象状态的函数并不总是遵循这些命名约定。我们将在遇到例外情况时进行讨论。
OpenGL 规范
从技术角度来说,OpenGL 不是一个 API,而是一个规范。一份文档。C API 只是实现该规范的一种方式。该规范定义了初始 OpenGL 状态、每个函数如何更改或检索该状态,以及调用渲染函数时应该发生什么。
该规范由 OpenGL 架构审查委员会( ARB ) 编写,该委员会由 Apple、NVIDIA 和 AMD(ATI 部分)等公司的代表组成。ARB 是 Khronos 集团 的一部分。
规范是一份非常复杂且技术性很强的文档。但是,它的某些部分还是相当易读的,尽管您通常需要至少对应该进行的操作有所了解才能理解它。如果您尝试阅读它,那么最重要的一点是要理解这一点:它描述的是 结果,而不是实现。规范说 X 会发生并不意味着它真的会发生。这意味着用户不应该能够分辨出差异。如果硬件可以以不同的方式提供相同的行为,那么规范允许这样做,只要用户永远无法分辨出差异。
OpenGL 实现。 虽然 OpenGL ARB 确实控制着规范,但它并不控制 OpenGL 的代码。OpenGL 不是从集中位置下载的东西。对于任何特定的硬件,由该硬件的开发人员为该硬件编写 OpenGL实现。顾名思义,实现就是实现 OpenGL 规范,公开规范中定义的 OpenGL API。
对于不同的操作系统,OpenGL 实现的控制者是不同的。在 Windows 上,OpenGL 实现几乎完全由硬件制造商自己控制。在 Mac OSX 上,OpenGL 实现由 Apple 控制;他们决定公开哪个版本的 OpenGL 以及可以为用户提供哪些附加功能。Apple 在 Mac OSX 上编写了大部分 OpenGL 实现,硬件开发人员则根据 Apple 创建的内部驱动程序 API 进行编写。在 Linux 上,事情就… 复杂了。
简而言之,如果您编写的程序似乎表现出不符合规范的行为,那么这是 OpenGL 实现制造商的错误(假设它不是代码中的错误)。在 Windows 上,各种图形硬件制造商将其 OpenGL 实现放在其常规驱动程序中。因此,如果您怀疑其实现中存在错误,您应该做的第一件事就是确保您的图形驱动程序是最新的;自上次更新驱动程序以来,错误可能已得到纠正。
OpenGL 版本。OpenGL 规范有许多版本。OpenGL 版本与大多数 Direct3D 版本不同,后者通常会更改大部分 API。适用于某个 OpenGL 版本的代码几乎总是适用于更高版本的 OpenGL。
相对于之前的版本,唯一的例外是 OpenGL 3.0 及以上版本。v3.0 弃用了许多旧函数,而 v3.1 从 API 1(https://paroj.github.io/gltut/Basics/Intro%20What%20is%20OpenGL.html#ftn.idp1131) 中删除了大部分这些函数。这也将规范分为两个变体(称为配置文件):核心和兼容性。兼容性配置文件保留了 3.1 中删除的所有函数,而核心配置文件则没有。理论上,OpenGL 实现可以只实现核心配置文件;这将导致依赖兼容性配置文件的软件在该实现上无法运行。
从实际角度来说,这些都不重要。没有 OpenGL 驱动程序开发人员会发布仅实现核心配置文件的驱动程序。因此,实际上,这毫无意义;所有 OpenGL 版本实际上都是向后兼容的。
1(https://paroj.github.io/gltut/Basics/Intro%20What%20is%20OpenGL.html#idp1131) 弃用仅意味着将这些函数标记为将在后续函数中删除。它们在 3.0 中仍可使用。
词汇表
向量
由其他值的有序序列组成的值。向量中存储的值的数量是其维数。可以对向量作为一个整体执行数学运算。
标量
单个非向量值。一维向量可视为标量。
矢量位置
表示位置的向量。
矢量方向
表示方向的矢量。
矢量分量
向量中的一个值。
组件操作
对向量进行的操作,将某些操作应用于向量的每个分量。分量操作的结果是一个与操作输入相同维度的向量。许多向量操作都是分量操作。
单位向量
长度恰好为 1 的向量。这些表示纯方向向量。
向量规范化
将矢量转换为指向与原始矢量相同方向的单位矢量的过程。
像素
数字图像的最小部分。像素在特定色彩空间中具有特定颜色。
图像
二维像素数组。
渲染
将源 3D 世界转换为 2D 图像的过程,该图像代表从特定角度看到的该世界的视图。
光栅化
一种特殊的渲染方法,用于将一系列 3D 三角形转换为 2D 图像。
几何、模型、网格
三维空间中由三角形构成的单个物体。
顶点
构成三角形的 3 个元素之一。顶点可以包含任意数据,但这些数据中有一个三维位置,表示顶点在 3D 空间中的位置。
剪辑空间,剪辑坐标
顶点位置被变换到三维空间中的区域。这些顶点位置是 4 维量。裁剪坐标的第四个分量 (W) 表示该顶点在裁剪空间中的可见范围。因此,裁剪坐标的 X、Y 和 Z 分量必须介于 [-W, W] 之间,才能成为世界的可见部分。
在裁剪空间中,正 X 向右,正 Y 向上,正 Z 向外。
剪辑空间顶点由渲染管道的顶点处理阶段输出。
剪裁
在裁剪坐标中获取三角形的过程,如果其一个或多个顶点位于裁剪空间之外,则将其分割。
规范化设备坐标
这些是已除以第四个分量的裁剪坐标。这使得该空间范围对于所有分量都相同。位置在 [-1, 1] 范围内的顶点可见,其他顶点不可见。
窗口空间、窗口坐标
规范化设备坐标映射到的三维空间区域。此空间中顶点的 X 和 Y 位置相对于目标图像。原点位于左下角,X 正值向右,Y 正值向上。Z 值是 [0, 1] 范围内的数字,其中 0 表示最近值,1 表示最远值。此范围之外的顶点位置不可见。
扫描转换
在窗口空间中取一个三角形,并根据将其投影到输出图像的像素上,将其转换为多个片段的过程。
样本
像素边界内的离散位置,用于确定是否通过扫描转换三角形生成片段。单个像素的区域可以有多个样本,从而可以生成多个片段。
分段
扫描转换三角形的单个元素。片段可以包含任意数据,但这些数据中有一个三维位置,用于标识此片段在窗口空间中三角形上的起源位置。
不变性保证
OpenGL 提供的一种保证,如果您向顶点处理提供二进制相同的输入,而所有其他状态保持完全相同,那么将输出剪辑空间中完全相同的顶点。
色彩空间
一组参考颜色,定义计算机图形中颜色的表示方式,以及这些参考颜色与实际颜色之间的函数映射。所有颜色都是相对于特定颜色空间定义的。
着色器
设计用于由渲染器执行的程序,用于执行某些用户定义的操作。
着色器阶段
渲染管道中的特定位置,在此可以执行着色器以进行计算。此计算的结果将输入到渲染管道中的下一阶段。
OpenGL
定义基于光栅化的渲染系统的有效行为的规范。
OpenGL 上下文
用于渲染的一组特定状态。OpenGL 上下文就像一个大型 C 风格结构,其中包含大量可访问的字段。如果您要创建多个窗口进行渲染,则每个窗口都会有自己的 OpenGL 上下文。
对象绑定
对象可以绑定到 OpenGL 上下文中的特定位置。当发生这种情况时,对象内的状态将替换上下文中的一组特定状态。大多数对象都有多个绑定点,每种对象都可以绑定到某些绑定点。对象绑定到哪个绑定点决定了对象覆盖哪种状态。
建筑审查委员会
管理 OpenGL 规范的 Khronos 集团机构。
OpenGL 实现
为特定系统实现 OpenGL 规范的软件。## 词汇表
向量
由其他值的有序序列组成的值。向量中存储的值的数量是其维数。可以对向量作为一个整体执行数学运算。
标量
单个非向量值。一维向量可视为标量。
矢量位置
表示位置的向量。
矢量方向
表示方向的矢量。
矢量分量
向量中的一个值。
组件操作
对向量进行的操作,将某些操作应用于向量的每个分量。分量操作的结果是一个与操作输入相同维度的向量。许多向量操作都是分量操作。
单位向量
长度恰好为 1 的向量。这些表示纯方向向量。
向量规范化
将矢量转换为指向与原始矢量相同方向的单位矢量的过程。
像素
数字图像的最小部分。像素在特定色彩空间中具有特定颜色。
图像
二维像素数组。
渲染
将源 3D 世界转换为 2D 图像的过程,该图像代表从特定角度看到的该世界的视图。
光栅化
一种特殊的渲染方法,用于将一系列 3D 三角形转换为 2D 图像。
几何、模型、网格
三维空间中由三角形构成的单个物体。
顶点
构成三角形的 3 个元素之一。顶点可以包含任意数据,但这些数据中有一个三维位置,表示顶点在 3D 空间中的位置。
剪辑空间,剪辑坐标
顶点位置被变换到三维空间中的区域。这些顶点位置是 4 维量。裁剪坐标的第四个分量 (W) 表示该顶点在裁剪空间中的可见范围。因此,裁剪坐标的 X、Y 和 Z 分量必须介于 [-W, W] 之间,才能成为世界的可见部分。
在裁剪空间中,正 X 向右,正 Y 向上,正 Z 向外。
剪辑空间顶点由渲染管道的顶点处理阶段输出。
剪裁
在裁剪坐标中获取三角形的过程,如果其一个或多个顶点位于裁剪空间之外,则将其分割。
规范化设备坐标
这些是已除以第四个分量的裁剪坐标。这使得该空间范围对于所有分量都相同。位置在 [-1, 1] 范围内的顶点可见,其他顶点不可见。
窗口空间、窗口坐标
规范化设备坐标映射到的三维空间区域。此空间中顶点的 X 和 Y 位置相对于目标图像。原点位于左下角,X 正值向右,Y 正值向上。Z 值是 [0, 1] 范围内的数字,其中 0 表示最近值,1 表示最远值。此范围之外的顶点位置不可见。
扫描转换
在窗口空间中取一个三角形,并根据将其投影到输出图像的像素上,将其转换为多个片段的过程。
样本
像素边界内的离散位置,用于确定是否通过扫描转换三角形生成片段。单个像素的区域可以有多个样本,从而可以生成多个片段。
分段
扫描转换三角形的单个元素。片段可以包含任意数据,但这些数据中有一个三维位置,用于标识此片段在窗口空间中三角形上的起源位置。
不变性保证
OpenGL 提供的一种保证,如果您向顶点处理提供二进制相同的输入,而所有其他状态保持完全相同,那么将输出剪辑空间中完全相同的顶点。
色彩空间
一组参考颜色,定义计算机图形中颜色的表示方式,以及这些参考颜色与实际颜色之间的函数映射。所有颜色都是相对于特定颜色空间定义的。
着色器
设计用于由渲染器执行的程序,用于执行某些用户定义的操作。
着色器阶段
渲染管道中的特定位置,在此可以执行着色器以进行计算。此计算的结果将输入到渲染管道中的下一阶段。
OpenGL
定义基于光栅化的渲染系统的有效行为的规范。
OpenGL 上下文
用于渲染的一组特定状态。OpenGL 上下文就像一个大型 C 风格结构,其中包含大量可访问的字段。如果您要创建多个窗口进行渲染,则每个窗口都会有自己的 OpenGL 上下文。
对象绑定
对象可以绑定到 OpenGL 上下文中的特定位置。当发生这种情况时,对象内的状态将替换上下文中的一组特定状态。大多数对象都有多个绑定点,每种对象都可以绑定到某些绑定点。对象绑定到哪个绑定点决定了对象覆盖哪种状态。
建筑审查委员会
管理 OpenGL 规范的 Khronos 集团机构。
OpenGL 实现
为特定系统实现 OpenGL 规范的软件。
第 1 章 你好,三角形!
传统上,编程语言的教程和入门书籍都以一个名为“ Hello, World! ”的程序开始。该程序是打印文本“ Hello, World! ”所需的最简单的代码。它可以作为一项很好的测试,以查看构建系统是否正常运行以及是否可以编译和执行代码。
使用 OpenGL 编写实际文本相当复杂。我们的第一个教程将代替文本在屏幕上绘制一个三角形。
框架和 FreeGLUT
本教程的源代码位于 Tut1 Hello Triangle/Tut1.cpp,相当简单。构建最终可执行文件的项目文件实际上使用了两个源文件:教程文件和 通用框架文件 framework/framework.cpp。框架文件是 FreeGLUT 实际初始化的地方;也是 main 所在的位置。此文件仅使用主教程文件中定义的函数。
FreeGLUT 是一个相当简单的 OpenGL 初始化系统。它创建并管理一个窗口;所有 OpenGL 命令都引用此窗口。由于各种 GUI 系统中的窗口需要进行某些记录,因此用户与此交互的方式受到严格控制。
该 display 函数是最重要的工作发生的地方。当 FreeGLUT 检测到需要渲染屏幕时,它将调用此函数。
剖析 display
该 display 函数表面上看起来相当简单。然而,它的功能相当复杂,并且与 init 函数中的初始化交织在一起。
例 1.1.display 函数
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(theProgram);
glBindBuffer(GL_ARRAY_BUFFER, positionBufferObject);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, 0);
glDrawArrays(GL_TRIANGLES, 0, 3);
glDisableVertexAttribArray(0);
glUseProgram(0);让我们详细检查一下这段代码。
前两行清除屏幕。glClearColor 是状态设置函数之一;它设置清除屏幕时将使用的颜色。它将清除颜色设置为黑色。glClear 不设置 OpenGL 状态;它导致屏幕被清除。 GL_COLOR_BUFFER_BIT 参数意味着清除调用将影响颜色缓冲区,导致它被清除为我们在上一个函数中设置的当前清除颜色。
下一行设置当前着色器程序以供所有后续渲染命令使用。我们稍后会详细介绍其工作原理。
接下来的三个命令都设置了状态。这些命令设置了要渲染的三角形的坐标。它们告诉 OpenGL 三角形的位置在内存中的位置。这些命令的具体工作原理将在后面详细介绍。
glDrawArrays 顾名思义,该函数是一个渲染函数。它使用当前状态生成将形成三角形的顶点流。
接下来的两行只是清理工作,撤消了为渲染目的所做的一些设置。
最后一行,glutSwapBuffers,是 FreeGLUT 命令,而不是 OpenGL 命令。正如我们在 中设置的 framework.cpp,OpenGL 帧缓冲区是双缓冲的。这意味着当前显示给用户的图像与我们正在渲染的图像 不同 。因此,我们的所有渲染都隐藏在视图之外,直到显示给用户。这样,用户就永远不会看到半渲染的图像。 glutSwapBuffers 是导致我们正在渲染的图像显示给用户的函数。
跟踪数据
在 基础背景部分,我们描述了 OpenGL 管道的功能。现在我们将在教程 1 中的代码上下文中重新审视此管道。这将使我们了解 OpenGL 如何渲染数据的具体细节。
顶点传输
光栅化管道的第一阶段是将顶点转换到裁剪空间。然而,在 OpenGL 执行此操作之前,它必须收到一个顶点列表。因此,管道的第一阶段是将三角形数据发送到 OpenGL。
这是我们希望传输的数据:
const float vertexPositions[] = {
0.75f , 0.75f , 0.0f , 1.0f ,
0.75f , -0.75f , 0.0f , 1.0f ,
-0.75f , -0.75f , 0.0f , 1.0f ,
};每行 4 个值代表一个顶点的 4D 位置。这些是四维的,因为您可能还记得,裁剪空间也是 4D 的。这些顶点位置已经在裁剪空间中。我们希望 OpenGL 做的是根据此顶点数据渲染三角形。由于每 4 个浮点数代表一个顶点的位置,因此我们有 3 个顶点:三角形的最小数量。
即使我们有这些数据,OpenGL 也不能直接使用它。OpenGL 对它可以读取的内存有一些限制。你可以自己分配任何你想要的顶点数据;OpenGL 不能直接看到你的任何内存。因此,第一步是分配一些 OpenGL 可以看到的内存,并用我们的数据填充该内存。这是通过一个称为缓冲对象的东西来完成的 。
缓冲区对象是内存的线性数组,由 OpenGL 根据用户的要求进行管理和分配。此内存的内容由用户控制,但用户只能间接控制它。将缓冲区对象视为 GPU 内存的数组。GPU 可以快速读取此内存,因此在其中存储数据具有性能优势。
本教程中的缓冲区对象是在初始化期间创建的。以下是负责创建缓冲区对象的代码:
例 1.2. 缓冲区对象初始化
void InitializeVertexBuffer()
{
glGenBuffers(1, &positionBufferObject);
glBindBuffer(GL_ARRAY_BUFFER, positionBufferObject);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertexPositions), vertexPositions, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
}第一行创建缓冲区对象,并将对象的句柄存储在全局变量 positionBufferObject 中。虽然该对象现在存在,但它还不拥有任何内存。这是因为我们还没有为该对象分配任何内存。
该 glBindBuffer 函数将新创建的缓冲区对象绑定到 GL_ARRAY_BUFFER 绑定目标。如 简介 中所述,OpenGL 中的对象通常必须绑定到上下文才能执行任何操作,缓冲区对象也不例外。影响绑定到的缓冲区的后续命令 GL_ARRAY_BUFFER 将影响此特定对象,直到新的缓冲区对象绑定到 GL_ARRAY_BUFFER。
该 glBufferData 函数执行两个操作。它为当前绑定到的缓冲区分配内存 GL_ARRAY_BUFFER,该缓冲区是我们刚刚创建并绑定的缓冲区。我们已经有一些顶点数据;问题是它在我们的内存中,而不是 OpenGL 的内存中。sizeof(vertexPositions) 使用 C++ 编译器来确定数组的字节大小 vertexPositions。然后我们将此大小传递给 glBufferData 作为为此缓冲区对象分配的内存大小。因此,我们分配了足够的 GPU 内存来存储我们的顶点数据。
执行的另一个操作 glBufferData 是将数据从内存数组复制到缓冲区对象中。第三个参数控制这一点。如果此值不为 NULL(如本例所示),glBufferData 则会将指针引用的数据复制到缓冲区对象中。调用此函数后,缓冲区对象将准确存储所 vertexPositions 存储的内容。
第四个参数是我们在未来的教程中会讨论的。
第二个绑定缓冲区调用只是清理。通过将缓冲区对象 0 绑定到 GL_ARRAY_BUFFER,我们使先前绑定到该目标的缓冲区对象与其解除绑定。在这种情况下,零的作用很像 NULL 指针。这不是绝对必要的,因为任何稍后绑定到此目标的操作都只会解除已存在的绑定。但除非您对渲染有非常严格的控制,否则解除绑定的对象通常是一个好主意。
这一切都只是为了获取 GPU 内存中的顶点数据。但缓冲区对象没有格式化;就 OpenGL 而言,我们所做的只是分配一个缓冲区对象并用随机二进制数据填充它。现在我们需要做一些事情来告诉 OpenGL 这个缓冲区对象中有顶点数据以及顶点数据采用什么形式。
我们在渲染代码中执行此操作。这就是这些行的目的:
glBindBuffer(GL_ARRAY_BUFFER, positionBufferObject);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, 0);第一个函数我们之前见过。它只是表示我们将使用这个缓冲区对象。
第二个功能,glEnableVertexAttribArray 我们将在下一节中解释。如果没有这个功能,下一个功能就不重要了。
第三个函数才是真正的关键。尽管里面有“指针”glVertexAttribPointer 这个词,但它并不处理指针。相反,它处理缓冲区对象。
在渲染时,OpenGL 从存储在缓冲区对象中的数组中提取顶点数据。我们需要告诉 OpenGL 的是缓冲区对象中的顶点数组数据是以什么格式存储的。也就是说,我们需要告诉 OpenGL 如何解释存储在缓冲区中的数据数组。
在我们的例子中,我们的数据格式如下:
-
我们的位置数据使用 C/C++ 类型 float 以 32 位浮点值存储。
-
每个位置由 4 个这样的值组成。
-
每组 4 个值之间没有空格。值在数组中紧密排列。
-
我们的数据数组中的第一个值位于缓冲区对象的开头。
该 glVertexAttribPointer 函数将这些全部告诉 OpenGL。第三个参数指定值的基本类型。在本例中,它是 GL_FLOAT,对应于 32 位浮点值。第二个参数指定这些值中有多少个表示单个数据。在本例中,它是 4。第五个参数指定每组值之间的间距。在我们的例子中,值之间没有空格,所以这个值为 0。第六个参数指定缓冲区对象中值的字节偏移量在最前面,即距离缓冲区对象开头 0 个字节。
第四个参数我们将在后面的教程中讨论。第一个参数我们将在下一节中讨论。
似乎缺少的一件事是指定此数据来自哪个缓冲区对象。这是一种隐式关联,而不是显式关联。 glVertexAttribPointer 总是指在调用此函数时绑定到的任何缓冲区 GL_ARRAY_BUFFER。因此它不采用缓冲区对象句柄;它只是使用我们之前绑定的句柄。
在后面的教程中我们会更详细地讲解这个函数。
一旦 OpenGL 知道从哪里获取顶点数据,它现在就可以使用该顶点数据进行渲染。
glDrawArrays(GL_TRIANGLES, 0, 3);这个函数表面上看起来很简单,但功能却很强大。第二个和第三个参数表示从我们的顶点数据中读取的起始索引和索引数。 glVertexAttribPointer 将处理顶点数组(用 定义)的第 0 个索引,然后处理第 1 个和第 2 个索引。也就是说,它从第 0 个索引开始,并从数组中读取 3 个顶点。
第一个参数 glDrawArrays 告诉 OpenGL,它将每 3 个顶点视为一个独立三角形。因此,它将只读取 3 个顶点并将它们连接起来形成一个三角形。
再次,我们将在另一个教程中详细介绍。
顶点处理和着色器
现在我们可以告诉 OpenGL 顶点数据是什么了,接下来就是管道的下一个阶段:顶点处理。这是本教程中我们将介绍的两个可编程阶段之一,因此这涉及到 着色器的使用。
着色器只不过是在 GPU 上运行的程序。管道中有多个可能的着色器阶段,每个阶段都有自己的输入和输出。着色器的目的是获取其输入以及可能的各种其他数据,并将它们转换为一组输出。
每个着色器都针对一组输入执行。需要注意的是,任何阶段的着色器都 完全独立 于该阶段的任何其他着色器运行。着色器的单独执行之间不会发生串扰。每组输入的执行从着色器的开头开始,一直持续到结尾。着色器定义了其输入和输出,并且着色器在未写入其所有输出的情况下完成是非法的(在大多数情况下)。
顶点着色器,顾名思义,对顶点进行操作。具体来说,每次调用顶点着色器都对 单个 顶点进行操作。这些着色器必须在任何其他用户定义的输出中输出该顶点的裁剪空间位置。如何计算此裁剪空间位置完全取决于着色器。
OpenGL 中的着色器是用 OpenGL 着色语言 ( GLSL ) 编写的。这种语言看起来很像 C,但它与 C 完全不同。它有太多限制,不能算作 C(例如,禁止递归)。我们的简单顶点着色器如下所示:
例 1.3. 顶点着色器
#version 330
layout(location = 0) in vec4 position;
void main()
{
gl_Position = position;
}
这看起来相当简单。第一行声明此着色器使用的 GLSL 版本是 3.30。所有 GLSL 着色器都需要版本声明。
下一行定义了顶点着色器的输入。输入是一个名为 的变量 position,类型为 vec4:一个 4 维浮点值向量。它的布局位置也是 0;我们稍后会解释这一点。
与 C 一样,着色器的执行从 main 函数开始。此着色器非常简单,将输入复制 position 到名为 的变量中。这是着色器中 未定义 gl_Position 的变量 ;这是因为它是每个顶点着色器中定义的标准变量。如果您在 GLSL 着色器中看到以“ gl_ ”开头的标识符,那么它一定是内置标识符。您不能创建以“ gl_ ”开头的标识符;您只能使用已经存在的标识符。
gl_Position 定义为:
out vec4 gl_Position;
回想一下,顶点着色器必须做的最少的事情是为顶点生成一个裁剪空间位置。这就是 gl_Position:顶点的裁剪空间位置。由于我们的输入位置数据已经是裁剪空间位置,因此此着色器只是将其直接复制到输出中。
顶点属性。 着色器有输入和输出。把它们想象成函数参数和函数返回值。如果着色器是一个函数,那么它会被输入值调用,并且预计会返回多个输出值。
着色器阶段的输入和输出来自某个地方,并到达某个地方。因此,position 顶点着色器的输入必须在某处填充数据。那么这些数据来自哪里呢?顶点着色器的输入称为 顶点属性。
您可能会认出类似于术语“顶点属性”的东西。 例如,“ glEnable VertexAttrib Array ”或 “ gl VertexAttrib Pointer ” 。
这就是数据在 OpenGL 中沿管道流动的方式。渲染开始时,将根据 glVertexAttribPointer 所做的设置工作读取缓冲区对象中的顶点数据 。此函数描述属性的数据来自何处。对 glVertexAttribPointer 的特定调用与顶点着色器输入值的字符串名称之间的联系 有些复杂。
顶点着色器的每个输入都有一个称为 属性索引的索引位置。此着色器中的输入使用以下语句定义:
layout(location = 0) in vec4 position;
布局位置部分将属性索引 0 分配给 position。属性索引必须大于或等于零,并且任何时候可使用的属性索引数量存在基于硬件的限制 2(https://paroj.github.io/gltut/Basics/Tut01%20Following%20the%20Data.html#ftn.idp1428)。
在代码中,引用属性时,它们 总是 通过属性索引来引用。函数 glEnableVertexAttribArray、 glDisableVertexAttribArray 和 glVertexAttribPointer 都将属性索引作为其第一个参数。我们 position 在顶点着色器中将属性的属性索引赋值为 0,因此调用 会 glEnableVertexAttribArray(0) 为属性启用属性索引 position。
以下是顶点着色器的数据流图:
图 1.1. 到顶点着色器的数据流
如果不调用 glEnableVertexAttribArray,则调用 glVertexAttribPointer 该属性索引意义不大。启用调用不必在顶点属性指针调用之前调用,但必须在渲染之前调用。如果未启用该属性,则在渲染期间不会使用它。
光栅化
到目前为止,发生的事情就是将 3 个顶点提供给 OpenGL,并使用顶点着色器将它们转换为裁剪空间中的 3 个位置。接下来,通过将位置的 3 个 XYZ 分量除以 W 分量,将顶点位置转换为规范化设备坐标。在我们的例子中,3 个位置的 W 为 1.0,因此这些位置实际上已经位于规范化设备坐标空间中。
此后,顶点位置被转换为窗口坐标。这是通过 视口变换 完成的。之所以这样命名,是因为用于设置它的函数 glViewport。每次窗口大小发生变化时,本教程都会调用此函数。请记住,只要窗口大小发生变化,框架就会调用 reshape。因此,本教程对 reshape 的实现如下:
例 1.4. 重塑窗口
void reshape (int w, int h)
{
glViewport(0, 0, (GLsizei) w, (GLsizei) h);
}这告诉 OpenGL 我们要渲染到哪个可用区域。在本例中,我们将其更改为与整个可用区域匹配。如果没有此函数调用,调整窗口大小将不会对渲染产生任何影响。另外,请注意,我们没有努力保持纵横比不变;在某个方向上缩小或拉伸窗口将导致三角形缩小和拉伸以匹配。
回想一下,窗口坐标位于左下角坐标系中。因此点 (0, 0) 是窗口的左下角。此函数将左下角位置作为前两个坐标,将视口矩形的宽度和高度作为其他两个坐标。
一旦进入窗口坐标系,OpenGL 就可以获取这 3 个顶点并将其扫描转换为一系列片段。然而,为了做到这一点,OpenGL 必须确定顶点列表代表什么。
OpenGL 可以用多种不同的方式解释顶点列表。OpenGL 解释顶点列表的方式由 draw 命令给出:
glDrawArrays(GL_TRIANGLES, 0, 3);枚举 GL_TRIANGLES 告诉 OpenGL,列表中的每 3 个顶点都应用于构建一个三角形。由于我们只传递了 3 个顶点,因此我们得到 1 个三角形。
图 1.2. 光栅化器的数据流
如果我们渲染 6 个顶点,那么我们将得到 2 个三角形。
片段处理
片段着色器用于计算片段的输出颜色。片段着色器的输入包括片段的窗口空间 XYZ 位置。它还可以包括用户定义的数据,但我们将在后面的教程中介绍这一点。
我们的片段着色器如下所示:
例 1.5. 片段着色器
#version 330
out vec4 outputColor;
void main()
{
outputColor = vec4(1.0f, 1.0f, 1.0f, 1.0f);
}
与顶点着色器一样,第一行表明该着色器使用 GLSL 版本 3.30。
下一行指定了片段着色器的输出。输出变量的类型为 vec4。
主函数只是将输出颜色设置为 4 维向量,所有分量均为 1.0f。这会将颜色的红色、绿色和蓝色分量设置为全强度,即 1.0;这会创建三角形的白色。我们将在后面的教程中看到第四个分量。
虽然所有片段着色器都提供了片段的窗口空间位置,但这个片段着色器不需要它。所以它根本不使用它。
片段着色器执行后,片段输出颜色被写入输出图像。
Note
在顶点着色器部分,我们必须使用 layout(location = #) 语法来提供顶点着色器输入和顶点属性索引之间的连接。这是为了让用户将顶点数组连接到顶点着色器输入所必需的。所以你可能想知道片段着色器输出和屏幕之间的连接从何而来。
OpenGL 认识到,在很多渲染中,片段着色器输出只有一个逻辑位置:当前渲染的图像(在我们的例子中是屏幕)。因此,如果您只定义一个片段着色器输出,则此输出值将自动写入当前目标图像。可以有多个片段着色器输出,它们输出到多个不同的目标图像;这会增加一些复杂性,类似于属性索引。但那是另一回事了。
2(https://paroj.github.io/gltut/Basics/Tut01%20Following%20the%20Data.html#idp1428) 自从商业可编程硬件诞生以来,对于几乎所有硬件来说,这个限制都正好是 16。不多也不少。
制作着色器
我们略过了这些称为着色器的文本字符串实际上是如何发送到 OpenGL 的。我们现在将对此进行详细介绍。
Note
如果您熟悉 Direct3D 等其他 API 中着色器的工作原理,那么这对您没有帮助。OpenGL 着色器的工作方式与其他 API 中的工作方式截然不同。
着色器是用类似 C 的语言编写的。因此,OpenGL 使用非常类似 C 的编译模型。在 C 中,每个单独的 .c 文件都会被编译成一个对象文件。然后,一个或多个对象文件会链接在一起形成一个程序(或静态/共享库)。OpenGL 做的事情非常类似。
着色器字符串被编译成 着色器对象;这类似于对象文件。一个或多个着色器对象被链接到 程序对象 中。
OpenGL 中的程序对象包含用于渲染的 所有 着色器的代码。在本教程中,我们有一个顶点着色器和一个片段着色器;这两个着色器链接在一起形成一个程序对象。构建该程序对象是此代码的责任:
例 1.6. 程序初始化
void InitializeProgram()
{
std::vector<GLuint> shaderList;
shaderList.push_back(CreateShader(GL_VERTEX_SHADER, strVertexShader));
shaderList.push_back(CreateShader(GL_FRAGMENT_SHADER, strFragmentShader));
theProgram = CreateProgram(shaderList);
std::for_each(shaderList.begin(), shaderList.end(), glDeleteShader);
}第一条语句只是创建了我们打算链接在一起的着色器对象的列表。接下来的两个语句编译我们的两个着色器字符串。该 CreateShader 函数是本教程定义的编译着色器的函数。
将着色器编译为着色器对象非常类似于编译源代码。最重要的是,它涉及错误检查。这是以下实现 CreateShader:
例 1.7. 着色器创建
GLuint CreateShader(GLenum eShaderType, const std::string &strShaderFile)
{
GLuint shader = glCreateShader(eShaderType);
const char *strFileData = strShaderFile.c_str();
glShaderSource(shader, 1, &strFileData, NULL);
glCompileShader(shader);
GLint status;
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (status == GL_FALSE)
{
GLint infoLogLength;
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &infoLogLength);
GLchar *strInfoLog = new GLchar[infoLogLength + 1];
glGetShaderInfoLog(shader, infoLogLength, NULL, strInfoLog);
const char *strShaderType = NULL;
switch(eShaderType)
{
case GL_VERTEX_SHADER: strShaderType = "vertex"; break;
case GL_GEOMETRY_SHADER: strShaderType = "geometry"; break;
case GL_FRAGMENT_SHADER: strShaderType = "fragment"; break;
}
fprintf(stderr, "Compile failure in %s shader:\n%s\n", strShaderType, strInfoLog);
delete[] strInfoLog;
}
return shader;
}OpenGL 着色器对象,顾名思义,就是一个对象。因此,第一步是使用 创建对象 glCreateShader。此函数创建特定类型(顶点或片段)的着色器,因此它需要一个参数来告知它创建的对象类型。由于每个着色器阶段都有特定的语法规则和预定义的变量和常量(因此使不同的着色器阶段成为 GLSL 的不同方言),因此必须告知编译器正在编译哪个着色器阶段。
Note
着色器和程序对象是 OpenGL 中的对象。但它们的工作方式与其他类型的 OpenGL 对象截然不同。例如,创建缓冲区对象(如上所示)使用形式为“ glGen* ”的函数,其中 * 是 “ Buffer ”。它需要创建多个对象和一个列表来放置这些对象句柄。 着色器/程序对象与其他类型的 OpenGL 对象之间存在许多其他差异。
下一步是将文本着色器实际编译到对象中。从 C++std::string 对象中检索 C 样式字符串,并使用 glShaderSource 函数将其输入到着色器对象中。第一个参数是要将字符串放入的着色器对象。下一个参数是要放入着色器的字符串数量。将多个字符串编译成单个着色器对象的工作方式类似于在 C 文件中编译头文件。当然,.c 文件明确列出了它包含的文件,而您必须手动添加它们 glShaderSource。
下一个参数是 const char* 字符串数组。最后一个参数通常是字符串长度的数组。我们传入 NULL,它告诉 OpenGL 假设字符串以空字符结尾。一般来说,除非需要在字符串中使用空字符,否则无需使用最后一个参数。
一旦字符串进入对象,它们就会被编译 glCompileShader,其操作正如它所说的那样。
编译后,我们需要查看编译是否成功。我们通过调用 glGetShaderiv 来检索 GL_COMPILE_STATUS。如果是 GL_FALSE,则着色器编译失败;否则编译成功。
如果编译失败,我们会进行一些错误报告。它会向 stderr 打印一条消息,解释编译失败的原因。它还会打印来自 OpenGL 的信息日志来描述错误;可以将此日志视为常规 C 编译的编译器输出。
创建两个着色器对象后,我们将它们传递给 CreateProgram 函数:
例 1.8. 程序创建
GLuint CreateProgram(const std::vector<GLuint> &shaderList)
{
GLuint program = glCreateProgram();
for(size_t iLoop = 0; iLoop < shaderList.size(); iLoop++)
glAttachShader(program, shaderList[iLoop]);
glLinkProgram(program);
GLint status;
glGetProgramiv (program, GL_LINK_STATUS, &status);
if (status == GL_FALSE)
{
GLint infoLogLength;
glGetProgramiv(program, GL_INFO_LOG_LENGTH, &infoLogLength);
GLchar *strInfoLog = new GLchar[infoLogLength + 1];
glGetProgramInfoLog(program, infoLogLength, NULL, strInfoLog);
fprintf(stderr, "Linker failure: %s\n", strInfoLog);
delete[] strInfoLog;
}
for(size_t iLoop = 0; iLoop < shaderList.size(); iLoop++)
glDetachShader(program, shaderList[iLoop]);
return program;
}这个函数相当简单。它首先用创建一个空的程序对象 。这个函数不接受任何参数;请记住,程序对象是 所有 glCreateProgram 着色器阶段的组合。
glAttachShader 接下来,它通过在着色器对象上循环 调用该函数,将每个先前创建的着色器对象附加到程序 std::vector。程序不需要被告知每个着色器对象用于哪个阶段;着色器对象本身会记住这一点。
一旦所有着色器对象都附加完毕,代码就会将程序与 链接起来 。与之前类似,我们必须通过调用 glLinkProgram 来获取链接状态。如果是 GL_FALSE,则链接失败,我们会打印链接日志。否则,我们将返回创建的程序。glGetProgramiv``GL_LINK_STATUS
Note
在上面的着色器中,顶点着色器输入的属性索引 position 直接在着色器本身中分配。除了 之外,还有其他方法可以为属性分配属性索引 layout(location = #)。如果您不使用任何一种方法,OpenGL 甚至会分配属性索引。因此,您可能不知道属性的属性索引。如果您需要查询属性索引,您可以 glGetAttribLocation 使用程序对象和包含属性名称的字符串进行调用。
一旦程序成功链接,着色器对象就会从程序中删除 glDetachShader。程序的链接状态和功能不会因删除着色器而受到影响。它所做的只是告诉 OpenGL 这些对象不再与程序关联。
在程序成功链接并从程序中删除着色器对象后,使用 C++ 算法删除着色器对象 std::for_each. 此行循环遍历列表中的每个着色器并调用 glDeleteShader 它们。
使用程序。 要告诉 OpenGL 渲染命令应使用特定程序对象, glUseProgram 将调用该函数。在本教程中,该函数被调用两次 display。它使用全局调用 theProgram,这告诉 OpenGL 我们要使用该程序进行渲染,直到另行通知。稍后使用 0 调用,这告诉 OpenGL 不会使用任何程序进行渲染。
Note
就这些教程而言,使用程序对象并非 可选。OpenGL 在其兼容性配置文件中确实具有默认渲染状态,当程序未使用时,该状态将接管。我们不会使用它,也鼓励您避免使用它。
清理
本教程分配了大量 OpenGL 资源。它分配了一个缓冲区对象,该对象代表 GPU 上的内存。它创建了两个着色器对象和一个程序对象,它们都存储在 OpenGL 拥有的内存中。但它只删除了着色器对象;没有其他内容。
部分原因是 FreeGLUT 的性质,它不提供清理函数的钩子。但部分原因也是 OpenGL 本身的性质。在这样一个简单示例中,无需删除任何内容。OpenGL 在关闭窗口停用时会清理自己的资源。
在关闭 OpenGL 之前删除您创建的对象通常是一种好习惯。如果您将对象封装在 C++ 对象中,您当然应该这样做,这样析构函数就会删除 OpenGL 对象。但这并不是绝对必要的。
回顾
在本教程中,您学习了以下内容:
-
缓冲区对象是 OpenGL 分配的线性内存数组。它们可用于存储顶点数据。
-
GLSL 着色器被编译成着色器对象,这些对象表示单个着色器阶段要执行的代码。这些着色器对象可以链接在一起以生成程序对象,该程序对象表示渲染期间要执行的所有着色器代码。
-
该
glDrawArrays函数可用于绘制三角形,使用特定的缓冲对象作为顶点数据源和当前绑定的程序对象。
进一步研究
即使有像这样的简单教程,也有很多东西需要尝试和研究。
-
将片段着色器设置的颜色值更改为不同的值。使用 [0, 1] 范围内的值,然后查看超出该范围时会发生什么。
-
更改顶点数据的位置。将位置值保持在 [-1, 1] 范围内,然后查看当三角形超出此范围时会发生什么。注意当您更改位置的 Z 值时会发生什么(注意:当它们在范围内时不会发生任何事情)。现在将 W 值保持在 1.0。
-
使用 [0, 1] 范围内的值更改清除颜色。注意这与上方视口的变化如何交互。
-
向列表中添加另外 3 个顶点,并将调用中发送的顶点数
glDrawArrays从 3 更改为 6。添加更多并使用它们。 -
reshape改变的 值glViewport。使它们大于或小于窗口,看看会发生什么。将它们移动到窗口内的不同象限。 -
更改
reshape函数,使更改窗口大小不会拉伸三角形。这意味着渲染到的视口区域可能小于窗口区域。此外,请尝试使其始终将区域置于窗口的中心。
值得注意的 OpenGL 函数
glClearColor,glClear
这些功能清除屏幕当前可视区域。 glClearColor 设置要清除的颜色,而 glClear 使用 GL_COLOR_BUFFER_BIT 值则会导致图像被该颜色清除。
glGenBuffers、glBindBuffer、glBufferData
这些函数用于创建和操作缓冲区对象。 glGenBuffers 创建一个或多个缓冲区, glBindBuffer 将其附加到上下文中的位置,并 glBufferData 分配内存并使用来自用户的数据填充到缓冲区对象中。
glEnableVertexAttribArray、glDisableVertexAttribArray、glVertexAttribPointer
这些函数控制顶点属性数组。 glEnableVertexAttribArray 激活给定的属性索引,glDisableVertexAttribArray 停用给定的属性索引,并 glVertexAttribPointer 定义顶点数据的格式和源位置(缓冲区对象)。
绘制数组
此函数使用当前活动的顶点属性和当前程序对象(以及其他状态)启动渲染。它导致按顺序从属性数组中提取多个顶点。
glViewport
此函数定义当前视口变换。它定义为窗口的一个区域,由左下角位置和宽度/高度指定。
glCreateShader、glShaderSource、glCompileShader、glDeleteShader
这些函数创建一个工作的着色器对象。 glCreateShader 只需创建特定着色器阶段的空着色器对象。glShaderSource 将字符串设置到该对象中;多次调用此函数只会覆盖先前设置的字符串。 glCompileShader 导致使用先前设置的字符串编译着色器对象。 glDeleteShader 导致删除着色器对象。
glCreateProgram、glAttachShader、glLinkProgram、glDetachShader
这些函数创建一个工作程序对象。 glCreateProgram 创建一个空的程序对象。glAttachShader 将着色器对象附加到该程序。多次调用将附加多个着色器对象。 glLinkProgram 将所有先前附加的着色器链接到一个完整的程序中。 glDetachShader 用于从程序对象中删除着色器对象;这不会影响程序的行为。
glUseProgram
此函数使给定的程序成为当前程序。此调用后发生的所有渲染都将使用此程序进行各个着色器阶段。如果给定程序 0,则没有程序是当前的。
glGetAttribLocation
此函数检索命名属性的属性索引。它使用程序来查找属性,以及用户要查找属性索引的顶点着色器的输入变量的名称。
词汇表
缓冲对象
表示线性内存数组的 OpenGL 对象,包含任意数据。缓冲区的内容由用户定义,但内存由 OpenGL 分配。缓冲区对象中的数据可用于多种用途,包括存储渲染时要使用的顶点数据。
输入变量
着色器变量,在全局范围内声明。输入变量从 OpenGL 渲染管道中的早期阶段接收其值。
输出变量
使用关键字在全局范围内声明的着色器变量 out 。着色器写入的输出变量将传递到 OpenGL 渲染管道中的后续阶段进行处理。
顶点属性
顶点着色器的输入变量称为顶点属性。每个顶点属性都是一个长度最多为 4 个元素的向量。顶点属性是从缓冲区对象中提取的;缓冲区对象数据和顶点输入之间的连接由 glVertexAttribPointer 和 glEnableVertexAttribArray 函数建立。特定程序对象中的每个顶点属性都有一个索引;可以使用查询此索引 glGetAttribLocation。其他各种顶点属性函数使用该索引来引用该特定属性。
属性索引
顶点着色器中的每个输入变量都必须分配一个索引号。此数字在代码中用于引用特定属性。此数字是属性索引。
视口变换
将顶点数据从规范化的设备坐标空间转换到窗口空间的过程。它指定窗口的可视区域。
着色器对象
OpenGL API 中的一个对象,用于编译着色器并表示已编译着色器的信息。每个着色器对象都根据其包含数据的着色器阶段进行分类。
程序对象
OpenGL API 中的一个对象,表示渲染时要使用的所有着色器处理的完整序列。可以查询程序对象以获取属性位置和有关程序的各种其他信息。它们还包含一些将在后续教程中看到的状态。
第 2 章 玩转色彩
本教程将展示如何为上一教程中的三角形提供一些颜色。我们不只是为三角形提供纯色,而是使用两种方法为其表面提供不同的颜色。第一种方法将使用片段的位置来计算颜色,而第二种方法将使用每个顶点的数据来计算颜色。
片段位置显示
正如我们在概述中所述,片段数据的一部分包括片段在屏幕上的位置。因此,如果我们想改变三角形表面的颜色,我们可以在片段着色器中访问此数据并使用它来计算该片段的最终颜色。这是在片段位置教程中完成的,其主文件为 FragPosition.cpp。
在本教程以及所有后续教程中,着色器都将从文件加载,而不是从 .cpp 文件中的硬编码字符串加载。为了支持这一点,框架具有 Framework::LoadShader 和 Framework::CreateProgram 函数。它们的工作原理与上一个教程的 CreateShader 和 类似 CreateProgram,只是 LoadShader 接受的是文件名而不是着色器文件。
FragPosition 教程加载了两个着色器,即顶点着色器 data/FragPosition.vert 和片段着色器 data/FragPosition.frag。顶点着色器与上一个教程中的相同。但是片段着色器非常新:
例 2.1. FragPosition 的片段着色器
#version 330
out vec4 outputColor;
void main()
{
float lerpValue = gl_FragCoord.y / 500.0f;
outputColor = mix(vec4(1.0f, 1.0f, 1.0f, 1.0f),
vec4(0.2f, 0.2f, 0.2f, 1.0f), lerpValue);
}
gl_FragCoord 是一个内置变量,仅在片段着色器中可用。它是一个 vec3,因此它具有 X、Y 和 Z 分量。X 和 Y 值位于 窗口 坐标中,因此这些数字的绝对值将根据窗口的分辨率而变化。回想一下,窗口坐标将原点放在左下角。因此,三角形底部的片段的 Y 值将低于顶部的片段。
此着色器的理念是,片段的颜色将基于其窗口位置的 Y 值。500.0f 是窗口的高度(除非您调整窗口大小)。函数第一行中的除法只是将 Y 位置转换为 [0, 1] 范围,其中 1 位于窗口顶部,0 位于底部。
第二行使用这个 [0, 1] 值在两种颜色之间执行线性插值。该函数是 OpenGL 着色语言提供的众多标准函数之一。其中许多函数(如 mix 都是 矢量化的。也就是说,它们的一些参数可以是矢量,当它们是矢量时,它们将同时对矢量的每个分量执行操作。在这种情况下,前两个参数的维数必须匹配。mix
该 mix 函数执行线性插值。如果第三个参数为 0,它将准确返回第一个参数;如果第三个参数为 1,它将准确返回第二个参数。如果第三个参数介于 0 和 1 之间,它将根据第三个参数返回其他两个参数之间的值。
Note
第三个参数
mix必须在 [0, 1] 范围内。但是,GLSL 不会检查这一点或为您执行限制。如果不在此范围内,则函数的结果mix将未定义。 “未定义”是 OpenGL 的简写,表示“我不知道,但这可能不是您想要的。 ”
我们得到如下图像:
图 2.1. 片段位置

在这种情况下,三角形的底部(最接近 Y 为 0 的部分)将是最白的。而三角形的顶部(最接近 Y 为 500 的部分)将具有最暗的颜色。
除片段着色器外,代码没有太大变化。
顶点属性
在片段着色器中使用片段位置非常有用,但它远非控制三角形颜色的最佳工具。一个更有用的工具是明确地为每个顶点赋予颜色。顶点颜色 教程实现了这一点;本教程的主要文件是 VertexColors.cpp。
我们希望影响通过系统传输的数据。我们希望发生的事件顺序如下。
-
对于传递给顶点着色器的每个位置,我们都希望传递相应的颜色值。
-
对于顶点着色器中的每个输出位置,我们还希望输出一个与顶点着色器接收的输入颜色值相同的颜色值。
-
在片段着色器中,我们希望从顶点着色器接收输入颜色,并将其用作该片段的输出颜色。
您很可能对这一事件顺序有一些严重的疑问,尤其是步骤 2 和步骤 3 是如何工作的。我们会讲到这一点。我们将跟踪经过修改的数据流通过 OpenGL 管道。
多个顶点数组和属性
为了完成第一步,我们需要更改顶点数组数据。该数据现在如下所示:
例 2.2. 新的顶点数组数据
const float vertexData[] = {
0.0f, 0.5f, 0.0f, 1.0f,
0.5f, -0.366f, 0.0f, 1.0f,
-0.5f, -0.366f, 0.0f, 1.0f,
1.0f, 0.0f, 0.0f, 1.0f,
0.0f, 1.0f, 0.0f, 1.0f,
0.0f, 0.0f, 1.0f, 1.0f,
};首先,我们需要了解数据数组在最底层是什么样子的。单个字节是 C/C++ 中最小的可寻址数据。一个字节代表 8 位(一位可以是 0 或 1),它是 [0, 255] 范围内的数字。 浮点类型的值需要 4 个字节的存储空间。任何浮点 值都存储在 4 个连续的内存字节中。
一个由 4 个浮点数组成的序列,按照 GLSL 的说法是 vec4,就是:一个由四个浮点值组成的序列。因此, avec4 占用 16 个字节,是 4 个 float 乘以 float 的大小。
该 vertexData 变量是一个大型浮点数组。然而,我们想要使用它的方式是将其用作两个数组。每 4 个浮点数是一个 vec4,前三个 vec4 代表位置。接下来的 3 个是相应顶点的颜色。
在内存中,该 vertexData 数组如下所示:
图 2.2. 顶点数组内存映射
上面两个图是基本数据类型的布局,每个框是一个字节。下面这个图是整个数组的布局,每个框是一个 浮点数。框的左半部分代表位置,右半部分代表颜色。
前 3 组值是三角形的三个位置,后 3 组值是这些顶点的三种颜色。我们实际上有两个恰好在内存中相邻的数组。一个从内存地址 开始 &vertexData[0],另一个从内存地址 开始 &vertexData[12]
与所有顶点数据一样,这被放入缓冲区对象中。我们之前见过这样的代码:
例 2.3. 缓冲区对象初始化
void InitializeVertexBuffer()
{
glGenBuffers(1, &vertexBufferObject);
glBindBuffer(GL_ARRAY_BUFFER, vertexBufferObject);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertexData), vertexData, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
}代码没有改变,因为数组的大小是由编译器使用 sizeof 指令计算的。由于我们向缓冲区添加了一些元素,因此计算出的大小自然会变大。
此外,您可能会注意到位置与之前的教程不同。原始三角形(即 Fragment Position 代码中使用的三角形)是等腰三角形(三条边中有两条边的长度相同)的直角三角形(三角形的一个角为 90 度)。这个新三角形是一个以原点为中心的等边三角形(三条边的长度都相同)。
回想一下,我们为每个顶点发送两部分数据:一个位置和一个颜色。我们有两个数组,每个数据一个。它们可能恰好在内存中相邻,但这不会改变什么;有两个数据数组。我们需要告诉 OpenGL 如何获取这些数据。
具体操作如下:
例 2.4. 渲染场景
void display()
{
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(theProgram);
glBindBuffer(GL_ARRAY_BUFFER, vertexBufferObject);
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, 0);
glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 0, (void*)48);
glDrawArrays(GL_TRIANGLES, 0, 3);
glDisableVertexAttribArray(0);
glDisableVertexAttribArray(1);
glUseProgram(0);
glutSwapBuffers();
glutPostRedisplay();
}由于我们有两部分数据,因此我们有两个顶点属性。对于每个属性,我们必须调用 glEnableVertexAttribArray 以启用该特定属性。第一个参数是顶点着色器中该属性的字段 layout(location) 设置的属性位置 。
然后,我们调用 glVertexAttribPointer 每个要使用的属性数组。两次调用的唯一区别在于将数据发送到哪个属性位置以及最后一个参数。最后一个参数是此属性数据在缓冲区中的起始字节偏移量。在本例中,此偏移量为 4(浮点数的大小) * 4( vec4 中的浮点数)* 3(位置数据中的 vec4 数 )。
Note
如果您想知道为什么是 (void*)48 而不是 ,那是因为一些遗留的 API 问题。函数名称为 glVertexAttrib “ Pointer ”48 的原因是因为最后一个参数在技术上是指向客户端内存的指针。或者至少,在过去可能是。因此,我们必须将整数值 48 明确转换为指针类型。
之后,我们使用 glDrawArrays 渲染,然后使用禁用数组 glDisableVertexAttribArray.
详细绘制
在上一篇教程中,我们略过了具体的作用 glDrawArrays。现在让我们仔细看看。
各种属性数组函数设置了数组,供 OpenGL 在渲染时读取。在我们的例子中,我们有两个数组。每个数组都有一个缓冲区对象和一个数组开始的缓冲区偏移量,但数组没有明确的大小。如果我们将所有内容视为 C++ 伪代码,则我们得到的是:
例 2.5. 顶点数组
GLbyte *bufferObject = (void*){0.0f, 0.5f, 0.0f, 1.0f, 0.5f, -0.366f, ...};
float *positionAttribArray[4] = (float *[4])(&(bufferObject + 0));
float *colorAttribArray[4] = (float *[4])(&(bufferObject + 48));每个元素 positionAttribArray 包含 4 个分量,即顶点着色器输入的大小 (vec4)。之所以如此,是因为的第二个参数 glVertexAttribPointer 是 4。每个分量都是浮点数;同样,因为第三个参数是 GL_FLOAT。数组从 中获取数据, bufferObject 因为这是调用 时绑定的缓冲区对象 glVertexAttribPointer。并且从缓冲区对象开头的偏移量为 0,因为这是 的最后一个参数 glVertexAttribPointer。
也是一样 colorAttribArray,只是偏移量值不同,它是 48 字节。
使用上面的顶点数组数据的伪代码表示, glDrawArrays 将实现如下:
例 2.6. 绘制数组实现
void glDrawArrays(GLenum type, GLint start, GLint count)
{
for(GLint element = start; element < start + count; element++)
{
VertexShader(positionAttribArray[element], colorAttribArray[element]);
}
}这意味着顶点着色器将被执行 count 次,并且将从第 start 个元素开始并循环 count 次继续为元素提供数据。因此,顶点着色器第一次运行时,它从中获取位置属性 ,从中获取颜色属性。第二次从中获取位置,从中获取颜色。依此类推。bufferObject[0 + (0 * 4 * sizeof(float))]bufferObject[48 + (0 * 4 * sizeof(float))]bufferObject[0 + (1 * 4 * sizeof(float))]“bufferObject[48 + (1 * 4 * sizeof(float))]`
从缓冲区对象到顶点着色器的数据流现在如下所示:
图 2.3. 多个顶点属性
与前面一样,每处理 3 个顶点就会变换为一个三角形。
顶点着色器
我们的新顶点着色器如下所示:
例 2.7. 多输入顶点着色器
#version 330
layout (location = 0) in vec4 position;
layout (location = 1) in vec4 color;
smooth out vec4 theColor;
void main()
{
gl_Position = position;
theColor = color;
}
这里有三行新内容。让我们逐一讨论。
全局变量的声明 color 为顶点着色器定义了一个新的输入。因此,此着色器除了接受名为的输入外, position 还接受名为的第二个输入 color。与输入一样 position,本教程将每个属性分配给一个属性索引。position 分配了属性索引 0,而 color 分配了 1。
以上操作仅将数据传入顶点着色器。我们希望将这些数据传出顶点着色器。为此,我们必须定义一个 _ 输出变量 _。这可以使用关键字完成 out。在本例中,输出变量被称为 vec4theColor 类型 。
关键字 smooth 是 _ 插值限定符 _。稍后我们将看到它的含义。
当然,这只是定义输出变量。在 main 中 ,我们实际上写入它,将 color 作为顶点属性给出的值分配给它。这是着色器代码,我们可以使用其他启发式或任意算法来计算颜色。但就本教程而言,它只是将传递给顶点着色器的属性值传递。
从技术上讲,内置变量 gl_Position 定义为 out vec4 gl_Position。因此它也是一个输出变量。它是一个特殊的输出,因为这个值直接由系统使用,而不是仅由着色器使用。像 theColor 上面一样,用户定义的输出对系统没有内在意义。它们只有在其他着色器阶段使用它们时才会产生影响,正如我们接下来将看到的。
片段程序
新的片段着色器如下所示:
例 2.8. 带输入的片段着色器
#version 330
smooth in vec4 theColor;
out vec4 outputColor;
void main()
{
outputColor = theColor;
}
此片段着色器定义一个输入变量。此输入变量的名称和类型与顶点着色器的输出变量相同,这并非巧合。我们试图将信息从顶点着色器提供给片段着色器。为此,OpenGL 要求上一阶段的输出与下一阶段的输入具有相同的名称和类型。它还必须使用相同的插值限定符;如果顶点着色器使用了 smooth,片段着色器也必须这样做。
这就是为什么 OpenGL 需要将顶点和片段着色器链接在一起的原因;如果名称、类型或插值限定符不匹配,则 OpenGL 会在程序链接时引发错误。
因此片段着色器接收来自顶点着色器的值输出。着色器只是获取此值并将其复制到输出。因此,每个片段的颜色将是顶点着色器传递的任何颜色。
片段插值
现在我们要谈论一个显而易见的问题。这是一个基本的沟通问题。
瞧,我们的顶点着色器只运行了 3 次。此执行产生 3 个输出位置 ( gl_Position) 和 3 个输出颜色 ( theColor)。这 3 个位置用于构造和光栅化三角形,从而产生多个片段。
片段着色器不会运行 3 次。它只会针对光栅化器为此三角形生成的每个片段运行一次。三角形生成的片段数量取决于查看分辨率以及三角形覆盖的屏幕面积。边长为 1 的等边三角形面积约为 0.433。总屏幕面积(X 和 Y 范围为 [-1, 1])为 4,因此我们的三角形覆盖了屏幕的大约十分之一。窗口的自然分辨率为 500x500 像素。500*500 为 250,000 像素;其中十分之一为 25,000。因此我们的片段着色器执行了大约 25,000 次。
这里有一个小小的差异。如果顶点着色器直接与片段着色器通信,并且顶点着色器仅输出 3 个总颜色值,那么其他 24,997 个值来自哪里?
答案是 片段插值 。
通过在定义顶点输出和片段输入时使用插值限定符 smooth,我们告诉 OpenGL 用这个值做一些特殊的事情。每个片段不再从单个顶点接收一个值,而是每个片段获得的是三角形表面上三个输出值的 混合。片段离一个顶点越近,该顶点的输出对片段程序接收的值的贡献就越大。
因为这种插值是目前顶点着色器和片段着色器之间通信的最常见模式,所以如果您不提供插值关键字,smooth 将默认使用。还有另外两种替代方案:noperspective 和 flat。
如果您修改教程并将 更改 smooth 为 noperspective,则不会看到任何变化。这并不意味着没有发生变化;我们的示例太简单了,实际上不会发生变化。smooth 和 noperspective 之间的区别 有些微妙,只有在我们开始使用更具体的示例时才重要。我们稍后将讨论这种差异。
插值 flat 实际上关闭了插值。它本质上说,三角形的每个片段将只获取三个顶点着色器输出变量中的第一个,而不是在三个值之间进行插值。片段着色器会获取三角形表面上的平面值,因此有术语“ ” flat。
每个光栅化三角形都有自己的一组 3 个输出,这些输出会进行插值以计算该三角形创建的片段的值。因此,如果您渲染 2 个三角形,则一个三角形的插值不会直接影响另一个三角形的插值。因此,每个三角形都可以独立于其他三角形获取。
在许多情况下,从共享顶点和顶点数据构建多个三角形是可能的,而且非常可取。但我们稍后会讨论这个问题。
最终图像
当您运行本教程时,您将得到以下图像。
图 2.4. 插值顶点颜色

三角形每个端点的颜色都是纯红色、纯绿色和纯蓝色。它们向三角形中心混合在一起。
回顾
在本教程中,您学习了以下内容:
-
数据通过缓冲区对象和属性数组传递到顶点着色器。这些数据被处理成三角形。
-
内置的 GLSL 变量
gl_FragCoord可以在片段着色器中使用来获取当前片段的窗口空间坐标。 -
可以使用顶点着色器中的输出变量和片段着色器中相应的输入变量将数据从顶点着色器传递到片段着色器。
-
从顶点着色器传递到片段着色器的数据在三角形表面上进行插值,基于分别用于顶点和片段着色器中的输出和输入变量的插值限定符。
进一步研究
这里有一些可以尝试的想法。
-
在 FragPosition 教程中更改视口。将视口放在显示屏的上半部分,然后放在下半部分。看看这会如何影响三角形上的着色。
-
将 FragPosition 教程与 Vertex Color 教程结合起来。使用来自顶点着色器的插值颜色,并将其与基于片段的屏幕空间位置计算的值相乘。
值得注意的 GLSL 函数
vec **mix**( | vec initial, |
| vec final, | |
float alpha); |
根据 在 、 之间 initial 执行 线性插值。 值为 0 表示 返回值,而值为 1 表示返回值。vec 类型表示参数可以是向量或 浮点数。在特定函数调用中,所有出现的 vec 必须相同,但是,so 和 必须具有相同的类型。finalalphaalphainitalalphafinalinitial__final
该 alpha 值可以是标量,也可以是长度与 initial 和相同的向量 final。如果是标量,则两个值的所有分量都由该标量插值。如果是向量,则 initial 和的分量 final 由 的相应分量插值 alpha。
如果 alpha 或 的任何分量 alpha 超出范围 [0, 1],则该函数的返回值未定义。
词汇表
片段插值
这是获取 3 个相应的顶点着色器输出并在三角形表面上对其进行插值的过程。对于生成的每个片段,还会为每个顶点着色器的输出生成一个插值(某些内置输出除外,例如 gl_Position)。插值处理方式取决于顶点输出和片段输入上的 _ 插值限定符。_
插值限定符
分配给顶点着色器输出和片段着色器相应输入的 GLSL 关键字。它决定了三角形的三个值如何在该三角形的表面上进行插值。顶点着色器输出上使用的限定符必须与同名片段着色器输入上使用的限定符匹配。
有效的插值限定符是 smooth、 flat 和 noperspective。