《Fundamentals of Computer Graphics》5th(计算机图形学基础/虎书),中文翻译。
第 17 章 Using Graphics Hardware 使用图形硬件
在本书的大部分内容中,重点是计算机图形学的基础知识,而不是与算法实现相关的 API 或硬件的具体问题。本章采用了稍微不同的方式,将使用图形硬件的详细信息与编程该硬件时涉及到的一些实际问题相结合。这一章被设计为一个介绍性指南,可作为研究图形硬件的一组每周实验的基础。
图形硬件描述了将 3D 对象快速渲染为计算机屏幕上的像素所需的硬件组件,使用了专门的光栅化(有时也使用光线跟踪)硬件架构。使用术语“图形硬件”旨在引起对执行多种图形计算所需的物理组件的感觉。换句话说,硬件是当前视频卡上找到的芯片组、晶体管、总线、处理器和计算内核的集合。正如您将在本章中学习并最终亲身体验的那样,当前的图形硬件非常擅长处理 3D 对象的描述,并将这些表示转换为填充您监视器的彩色像素。
实时图形:通常情况下,实时图形是指图形相关计算的执行速度足够快,以便可以立即查看结果。能够以 60Hz 或更高的速度进行操作被认为是实时的。一旦刷新显示的时间(帧率)降至 15Hz 以下,则速度被视为更加交互性而不是实时性,但这种区别并不关键。由于计算需要很快,用于渲染图形的方程通常是对可用更多时间时可行的方案的近似。
图形硬件在过去十年中发生了翻天覆地的变化。新的图形硬件提供了更多的并行处理能力,并且更好地支持专业渲染。其中一个解释是视频游戏行业及其经济推动力。基本上,这意味着每个新的图形卡都提供更好的性能和处理能力。因此,视频游戏显得更加视觉逼真。图形硬件上的处理器通常被称为 GPU 或图形处理器单元,具有高度的并行性,可以同时执行数千个线程。硬件设计用于吞吐量,可以在较短时间内处理更多像素和顶点。所有这些并行性对于图形算法来说都是有益的,但其他工作也受益于并行硬件。除了视频游戏外,GPU 还用于加速物理计算、开发实时光线跟踪代码、求解与 Navier-Stokes 相关的流体流模拟方程以及开发更快的代码来了解气候(Purcell、Buck、Mark 和 Hanrahan,2002;S.G.Parker 等人,2010 年;Harris,2004 年)。已经开发了几个 API 和 SDK,可以提供更直接的通用计算,例如 OpenCL 和 NVIDIA 的 CUDA。也存在硬件加速的光线跟踪 API,以加速光线-物体交点计算(S.G.Parker 等人,2010 年)。类似地,用于编程视频游戏的图形组件的标准 API,如 OpenGL 和 DirectX,也允许利用图形硬件的并行能力。随着开发更复杂的计算的新硬件的支持而开发出来的许多这些 API 会有所变化。
片段:片段是指在图形管道的最终阶段之前与像素相关联的信息。此定义包括可用于计算像素颜色的大量数据,例如像素的场景深度、纹理坐标或模板信息。
图形硬件是可编程的。作为开发人员,您可以控制与处理几何、顶点和最终成为像素的片段相关的大部分计算。最近的硬件更改以及对 API(如 OpenGL 或 DirectX)的持续更新支持完全可编程的管道。这些变化使开发人员可以创造性地利用 GPU 上可用的计算。在此之前,固定功能的光栅化管道将计算强制转换为特定样式的顶点变换、照明和片段处理。管道的固定功能确保基本的着色、照明和纹理映射可以非常快速地进行。无论是可编程接口还是固定功能计算,光栅化管道的基本计算都是相似的,并遵循图 17.1 中的说明。在光栅化管道中,将顶点从本地空间转换为全局空间,然后通过查看和投影变换矩阵进行屏幕坐标转换。与几何形状的顶点相关联的屏幕坐标集被光栅化为片段。管道的最终阶段将片段处理为像素,并可以应用每个片段的纹理查找、照明和任何必要的混合。通常情况下,管道适合并行执行,GPU 内核可用于同时处理顶点和片段。有关光栅化管道的其他详细信息,请参见第 8 章。
图 17.1。基本图形硬件管道由多个阶段组成,将 3D 数据转换为 2D 屏幕对象,准备进行光栅化和像素处理。
主机:在图形硬件程序中,主机指应用程序的 CPU 组件。 设备:图形应用的 GPU 部分,包括存储和执行在 GPU 上的数据和计算。
在使用图形硬件时,将 CPU 和 GPU 视为独立的计算实体是很方便的。在这种情况下,术语主机用于指代包括可用线程和内存的 CPU。设备一词用于指 GPU 或图形处理单元以及与其相关联的线程和内存。这是有意义的,因为大多数图形硬件由通过 PCI 总线连接到计算机的外部硬件组成。硬件也可以作为独立芯片组焊接到计算机上。从这个意义上讲,图形硬件表示一种专用的协处理器,因为 CPU(及其核心)和 GPU 及其核心都可以被编程。所有利用图形硬件的程序都必须首先建立 CPU 和 GPU 内存之间的映射。这是一个相当低级的细节,必要的是因为驻留在操作系统中的图形硬件驱动程序必须在硬件和操作系统以及窗口系统软件之间进行接口。请记住,由于主机(CPU)和设备(GPU)是分离的,因此必须在两个系统之间传递数据。更正式地说,操作系统、硬件驱动程序、硬件和窗口系统之间的这种映射称为图形上下文。通常通过调用窗口系统的 API 来建立上下文。关于建立上下文的详细信息超出了本章的范围,但许多窗口系统开发库具有根据需求查询图形硬件的各种能力并建立图形上下文的方法。由于设置上下文是与窗口系统相关的,这也意味着这样的代码不太可能是跨平台代码。然而,在实践中,或者至少在开始时,极少需要这种低级别的上下文设置代码,因为存在许多更高级别的 API 可帮助人们开发便携式交互应用程序。
许多交互式应用程序开发框架都支持查询输入设备,例如键盘或鼠标。一些框架提供对网络、音频系统和其他更高级别的系统资源的访问。在这方面,许多这些 API 是开发图形甚至游戏应用程序的首选方式。
OpenGL API 通常用于跨平台硬件加速。OpenGL 是一种开放的行业标准图形 API,支持许多类型的图形硬件上的硬件加速。OpenGL 代表着编程图形硬件的最常见的 API 之一,以及 DirectX 等 API。虽然 OpenGL 在许多操作系统和硬件架构上都可用,但 DirectX 专用于基于 Microsoft 的系统。为了本章的目的,将使用 OpenGL 来呈现硬件编程概念和示例。
使用 OpenGL API 进行编程时,您正在为至少两个处理器编写代码:CPU 和 GPU。OpenGL 以 C 风格的 API 实现,并且所有函数都以“gl”为前缀,表示它们包含在 OpenGL 中。OpenGL 函数调用更改图形硬件的状态,并可用于声明和定义几何形状、加载顶点和片段着色器以及确定数据经过硬件时计算的方式。
本章介绍的 OpenGL 版本是 OpenGL 3.3 Core Profile 版本。虽然不是最新的 OpenGL 版本,但 OpenGL 3.3 版本符合 OpenGL 编程的未来方向。这些版本专注于提高效率,同时完全将管道的编程交给开发人员。许多早期版本的 OpenGL 中存在的函数调用在这些新的 API 中不存在。例如,即时模式渲染已被弃用。即时模式渲染用于根据需要每帧将数据从 CPU 存储器发送到图形卡存储器,并且通常非常低效,特别是对于较大的模型和复杂场景。当前的 API 侧重于在需要之前在图形卡上存储数据并在呈现时间进行实例化。作为另一个示例,OpenGL 的矩阵堆栈也已被弃用,使开发人员使用第三方矩阵库(例如 GLM)或自己的类来创建必要的视图、投影和转换矩阵,如第 7 章所述。因此,OpenGL 着色器语言(GLSL)也承担了更大的角色,执行必要的矩阵变换以及光照和阴影处理。由于不再存在执行每个顶点变换和照明的固定功能管道,程序员必须自己开发所有着色器。本章中呈现的着色示例将利用 GLSL 3.3 Core Profile 版本的着色器规范。本章后续读者可以查看当前的 OpenGL 和 OpenGL 着色器语言规范,以获取有关这些 API 和语言支持的详细信息。
三个概念有助于理解当代图形硬件编程。第一个是数据缓冲区的概念,它简单地说就是在设备上的线性内存分配,可以存储各种 GPU 将要操作的数据。第二个是图形卡维护的计算状态(computational state),它确定与场景数据和着色器相关的计算在图形硬件上如何进行。此外,状态可以从主机传输到设备,甚至在设备内部在着色器之间传输。着色器表示计算发生的机制,在 GPU 上与每个顶点或每个片段处理相关。本章重点介绍顶点和片段着色器,但专用的几何着色器和计算着色器也存在于当前版本的 OpenGL 中。着色器在现代图形硬件功能中扮演非常重要的角色。
缓冲区是在图形硬件上存储数据的主要结构。它们代表与几何形状、纹理和图像平面数据等一切相关的图形硬件内部存储器。就第 8 章描述的光栅化管道而言,与硬件加速光栅化相关的计算会在 GPU 上读取和写入各种缓冲区。从编程的角度来看,应用程序必须在 GPU 上初始化需要应用程序的缓冲区。这相当于一种从主机到设备的复制操作。在执行的各个阶段结束时,也可以执行从设备到主机的复制操作,以将数据从 GPU 拉到 CPU 内存。此外,OpenGL API 中还存在机制,允许将设备内存映射到主机内存,使应用程序可以直接写入图形卡上的缓冲区。
在图形管道中,最终的像素颜色可以链接到显示器,或者它们可以被写入磁盘作为 PNG 图像。与这些像素相关的数据通常是一组 2D 颜色值数组。数据本质上是 2D 的,但在 GPU 上以 1D 线性内存数组的形式高效表示。该数组实现了显示缓冲区,最终将映射到窗口上。渲染图像涉及通过图形 API 向图形硬件通信修改显示缓冲区。在光栅化管道结束时,片段处理和混合阶段将数据写入输出显示缓冲区存储器。同时,窗口系统读取显示缓冲区的内容,以在监视器窗口上生成光栅图像。
大多数应用程序偏好双缓冲显示状态。这意味着与图形窗口相关联的有两个缓冲区:前缓冲区和后缓冲区。双缓冲系统的目的是,当使用前缓冲区内存驱动窗口上的像素颜色时,应用程序可以将更改传递给后缓冲区(因此,将更改写入该缓冲区)。
在渲染循环结束时,通过指针交换来交换缓冲区。前缓冲区指针指向后缓冲区,然后将后缓冲区指针分配给上一个前缓冲区。通过这种方式,窗口系统将使用最新的缓冲区刷新窗口内容。如果缓冲区指针交换与窗口系统的整个显示刷新同步,则呈现将看起来是无缝的。否则,用户可能会在实际显示上观察到几何形状的撕裂,因为场景的几何形状和片段的更改(因此被写入显示缓冲区)比屏幕刷新速度更快。
当将显示器视为内存缓冲区时,显示器上最简单的操作之一本质上是内存设置(或复制)操作,将内存清零,或清除到默认状态。对于图形程序,这可能意味着将窗口的背景颜色清除为特定的颜色。要在 OpenGL 应用程序中清除背景颜色(为黑色),可以使用以下代码:
cglClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glClearColor
函数的前三个参数表示红色、绿色和蓝色的颜色分量,范围为 。第四个参数表示不透明度或 alpha 值,范围为 表示完全透明到 表示完全不透明。alpha 值用于在管道的最终阶段通过各种片段混合操作确定透明度。
该操作仅清除颜色缓冲区。除了在此情况下指定的 GL_COLOR_BUFFER_BIT
被清除为黑色的颜色缓冲区之外,图形硬件还使用深度缓冲区表示碎片相对于相机的距离(您可能会回想起第 8 章中 z-buffer 算法的讨论)。清除深度缓冲区是必要的,以确保 z-buffer 算法的运行,并允许正确的隐藏表面去除发生。可以通过按位或两个位字段值来清除深度缓冲区,如下所示:
cglClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
在基本的交互式图形应用程序中,清除操作通常是在处理任何几何形状或片段之前执行的第一个操作。
通过说明清除显示颜色和深度缓冲区的操作,也引入了图形硬件状态的概念。glClearColor
函数设置在调用 glClear
时写入颜色缓冲区中所有像素的默认颜色值。clear 调用初始化显示缓冲区的颜色分量,并可以重置深度缓冲区的值。如果清除颜色在应用程序内不会改变,则清除颜色只需要设置一次,通常在 OpenGL 程序的初始化中完成。每次调用 glClear
时,它都使用先前设置的清除颜色状态。
请注意,z-buffer 算法状态可以根据需要启用和禁用。在 OpenGL 中,z-buffer 算法也称为深度测试。通过启用它,片段的深度值将与当前存储在深度缓冲区中的深度值进行比较,然后才写入任何片段颜色到颜色缓冲区。有时,深度测试是不必要的,可能会减慢应用程序的速度。禁用深度测试将防止 z-buffer 计算并更改可执行文件的行为。在 OpenGL 中启用 z-buffer 测试的方法如下:
cglEnable(GL_DEPTH_TEST); glDepthFunc(GL_LESS);
glEnable
调用打开深度测试,而 glDepthFunc
调用设置深度比较的机制。在此情况下,将深度函数设置为其默认值 GL_LESS,以显示其他状态变量的存在并且可以进行修改。glEnable
调用的相反操作是 glDisable
调用。
OpenGL 中状态的概念模仿了面向对象类中静态变量的使用方式。根据需要,程序员启用、禁用和/或设置驻留在图形卡上的 OpenGL 变量的状态。然后,这些状态会影响硬件上任何后续的计算。一般来说,高效的 OpenGL 程序试图最小化状态更改,启用所需的状态,同时禁用不需要用于渲染的状态。
一个简单的基本 OpenGL 应用程序有一个以显示循环为中心,该循环要么尽可能快地被调用,要么以监视器或显示设备的刷新率为周期。以下示例循环使用支持跨多个平台的 OpenGL 编码的 GLfW 库。
cwhile (!glfwWindowShouldClose(window)) {
// OpenGL code is called here,
// each time this loop is executed.
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// Swap front and back buffers
glfwSwapBuffers(window);
// Poll for events
glfwPollEvents();
if (glfwGetKey( window, GLFW_KEY_ESCAPE ) == GLFW_PRESS)
glfwSetWindowShouldClose(window, 1);
}
该循环严格限制在窗口打开时才运行。此示例循环基于先前设置(或默认值)重置图形硬件存储器中的颜色缓冲区和深度缓冲区深度值。输入设备,例如键盘、鼠标、网络或其他交互机制,在循环结束时进行处理,以更改与程序关联的数据结构的状态。glfwSwapBuffers
调用将图形上下文与显示刷新同步,执行前后缓冲区之间的指针交换,以便在用户屏幕上显示更新的图形状态。交换缓冲区的调用发生在所有图形调用已发出之后。
尽管概念上分离,但深度缓冲区和颜色缓冲区通常被合称为帧缓冲区。通过清除帧缓冲区的内容,应用程序可以继续进行其他 OpenGL 调用,以将几何形状和片段推送到图形管道中。帧缓冲区直接与打开包含图形上下文的窗口的大小相关。OpenGL 需要窗口或视口尺寸来构建硬件中的 Mvp 矩阵(第 7 章),通过以下代码实现,再次使用 GLfW 工具包提供查询请求的窗口(或帧缓冲区)尺寸的函数:
cint nx, ny;
glfwGetFramebufferSize(window, &nx, &ny);
glViewport(0, 0, nx, ny);
在此示例中,glViewport
使用 nx
和 ny
设置窗口维度的 OpenGL 状态,并指定从原点开始的视口的尺寸。
从技术上讲,OpenGL 通过光栅化几何形状并处理片段的操作将写入帧缓冲器内存。这些写入发生在像素显示在用户监视器之前。
与显示缓冲区的概念类似,几何形状也使用数组来存储顶点数据和其他顶点属性,例如用于着色的顶点颜色、法线或纹理坐标。缓冲区的概念将用于在图形硬件上分配存储空间,并将数据从主机传输到设备。
图形硬件编程的一个挑战是管理三维数据及其在图形硬件内存中的传输。大多数图形硬件使用特定的几何基元进行工作。不同的基元类型利用基元复杂性来提高图形硬件的处理速度。简单的基元有时可以非常快速地处理。但需要让基元类型具有通用性,以便对各种从非常简单到非常复杂的几何形状建模。在典型的图形硬件上,基元类型限于以下一种或多种:
基元:三个基元(点、线、三角形和四边形)是真正可用的基元!即使创建基于样条线的表面(如 NURBS),图形硬件也会将表面分割为三角形基元。
点渲染:点和线基元可能最初看起来用途有限,但研究人员已经使用点来渲染非常复杂的几何形状(Rusinkiewicz&Levoy,2000; Dachsbacher,Vogelgsang&Stamminger,2003)。
这三种基元类型构成了大多数可定义的几何形状的基本构建块。图 17.2 显示了一个使用 OpenGL 渲染的三角形网格的示例。
图 17.2。几何形状的组织方式将影响应用程序的性能。这个 Little Cottonwood Canyon 地形数据集的线框表示显示了数以万计的三角形,以三角形网格的形式运行实时速率。该图像使用 VTerrain Project 地形系统呈现,由 Ben Discoe 提供。
OpenGL 的现代版本要求使用着色器来处理顶点和片段。因此,没有至少一个顶点着色器来处理传入的基元顶点和另一个着色器来处理光栅化的片段就不能呈现任何基元。OpenGL 中存在高级着色器类型:几何着色器和计算着色器。几何着色器旨在处理基元,可能创建其他基元,并支持几何实例操作。计算着色器旨在在 GPU 上执行通用计算,并可以链接到特定应用程序所需的一组着色器中。有关几何和计算着色器的更多信息,请参阅 OpenGL 规范文件和其他资源。
顶点着色器提供对顶点进行转换的控制,并通常帮助准备数据供片段着色器使用。除了标准变换和潜在的每个顶点照明操作之外,顶点着色器还可以用于在 GPU 上执行通用计算。例如,如果顶点表示粒子,而粒子运动可以在顶点着色器计算中(简单)建模,则 CPU 可以大部分地从执行这些计算中移除。将计算应用于已存储在图形硬件内存中的顶点的能力是一种潜在的性能提升。虽然这种方法在某些情况下很有用,但高级通用计算可能更适合使用计算着色器编码。
在第 7 章中介绍了视口矩阵 Mvp。它将规范化视图体坐标转换为屏幕坐标。在规范化视图体内,坐标存在于 的范围内。超出此范围的任何内容都会被裁剪。如果我们做出一个初始假设,即几何形状存在于此范围内且 z 值被忽略,则可以创建一个非常简单的顶点着色器。该顶点着色器将顶点位置传递到光栅化阶段,最终的视口变换将在那里发生。请注意,由于这种简化,在传入顶点上不会应用投影、视图或模型转换。这对于创建除非常简单的场景之外的任何内容都是初始繁琐的,但将有助于介绍着色器的概念,并允许您将一个初始三角形渲染到屏幕上。以下是顶点着色器:
c# version 330 core
layout(location=0) in vec3 in_Position;
void main(void)
{
gl_Position = vec4(in_Position, 1.0);
}
这个顶点着色器只做了一件事,将传入的顶点位置作为 OpenGL 用于光栅化片段的 gl_Position
输出。请注意,gl_Position
是一个内置的保留变量,表示顶点着色器所需的关键输出之一。还请注意第一行中的版本字符串。在这种情况下,该字符串指示 GLSL 编译器使用 GLSL Core profile 的 3.3 版本来编译着色语言。
顶点和片段着色器都是 SIMD 操作,分别对管道中正在处理的所有顶点或碎片进行操作。可以使用输入、输出或统一变量将附加数据从主机通信到在设备上执行的着色器中。传递到着色器中的数据以 in 关键字为前缀。在着色器中直接指定其与特定顶点属性或片段输出索引相关的位置。因此,
clayout(location=0) in vec3 in_Position;
指定 in_Position
是一种类型为 vec3
的输入变量。该数据的来源是与几何体相关联的属性索引 0。此变量的名称由程序员确定,并且在设置设备上的顶点数据时,传入几何体和着色器之间的链接发生。GLSL 包含许多有用于图形程序的类型,包括 vec2
、vec3
、vec4
、mat2
、mat3
和 mat4
等。也存在标准类型,例如 int 或 float。在着色器编程中,向量(例如 vec4)包含 4 个分量,对应于齐次坐标的 、、 和 分量,或 RGBA 元组的 、、 和 分量。这些类型的标签可以根据需要进行交换(甚至重复),称为 swizzling(例如 in_Position.zyxa
)。此外,组件标签是重载的,并且可以适当地使用以提供上下文。
所有着色器都必须具有一个主函数,该函数在所有输入上执行主计算。在此示例中,主函数简单地将输入的顶点位置(in_Position
)复制到内置顶点着色器输出变量(gl_Position
),其类型为 vec4
。请注意,许多内置类型具有有用于转换的构造函数,例如在此处呈现的将传入顶点位置的 vec3
类型转换为 gl_Position
的 vec4
类型。OpenGL 中使用齐次坐标,因此指定 1.0 作为第四个坐标,以指示矢量是一个位置。
如果最简单的顶点着色器只是通过裁剪坐标,那么最简单的片段着色器将片段的颜色设置为一个常量值。
c# version 330 core
layout(location=0) out vec4 out_FragmentColor;
void main(void)
{
out_FragmentColor = vec4(0.49, 0.87, 0.59, 1.0);
}
在这个例子中,所有碎片都将被设置为一种浅绿色。一个关键的区别是使用了 out 关键字。通常,在着色器程序中,in 和 out 关键字表示进入和退出着色器的数据流。虽然顶点着色器接收传入的顶点并将其输出到内置变量,但片段着色器声明其要写入到颜色缓冲区的输出值:
clayout(location=0) out vec4 out_FragmentColor;
输出变量 out_FragmentColor
由用户定义。输出的位置是颜色缓冲区索引 0。片段着色器可以输出到多个缓冲区,但这是留给读者的高级主题,如果调查 OpenGL 的帧缓冲对象,则需要该主题。使用 layout
和 location
关键字使应用程序的几何数据与顶点着色器中的输出颜色缓冲区之间建立了显式的连接。
着色器程序以字符串的形式传输到图形硬件上。然后必须将它们编译和链接起来。此外,着色器被耦合在一起成为着色器程序,以便顶点和片段处理以一致的方式进行。开发人员可以根据需要激活已成功编译和链接到着色器程序中的着色器,同时在不需要时停用着色器。虽然本章没有提供创建、加载、编译和链接着色器程序的详细过程,但以下 OpenGL 函数将有助于创建着色器:
glCreateShader
在硬件上创建一个着色器句柄。glShaderSource
将字符字符串加载到图形硬件内存中。glCompileShader
在硬件内部执行着色器的实际编译。上述函数需要为每个着色器调用。因此,对于简单的传递着色器,每个函数都将针对提供的顶点着色器代码和片段着色器代码进行调用。在编译阶段结束时,可以使用其他 OpenGL 命令查询编译状态和任何错误。
在加载和编译两个着色器代码之后,它们可以链接成一个着色器程序。着色器程序是影响几何体渲染的内容。
glCreateProgram
创建一个包含先前编译的着色器的程序对象。glAttachShader
将着色器附加到着色器程序对象上。在简单示例中,此函数将为已编译的顶点着色器和片段着色器对象都进行调用。glLinkProgram
在所有着色器都已附加到程序对象之后,内部链接着色器。glUseProgram
将着色器程序绑定以便在图形硬件上使用。需要着色器时,使用该函数绑定程序句柄。不需要着色器时,可以使用程序句柄 0 作为参数使用该函数取消绑定着色器。顶点存储在图形硬件上,使用缓冲区(称为顶点缓冲对象)。除了顶点之外,任何其他顶点属性,例如颜色、法向量或纹理坐标,也将使用顶点缓冲对象指定。
首先,让我们专注于指定几何原始本身。这始于在应用程序的主机内存中分配与原始相关联的顶点。最常规的方法是在主机上定义一个数组,以包含原始所需的顶点。例如,完全包含在规范化体积中的单个三角形可以在主机上静态定义如下:
cGLfloat vertices[] = {-0.5f, -0.5f, 0.0f, 0.5f, -0.5f, 0.0f, 0.0f, 0.5f, 0.0f};
如果对这个三角形使用简单的传递着色器,那么所有顶点都将被渲染。虽然三角形被放置在 平面上,但是在此示例中 坐标实际上并不重要,因为它们在最终转换为屏幕坐标时基本上被丢弃了。还要注意这些示例中使用的 GLfloat 类型。就像 GLSL 语言具有专门类型一样,OpenGL 也具有相关类型,这些类型通常可以与标准类型(如 float)混合使用。为了精确性,在必要时将使用 OpenGL 类型。
OpenGL 坐标系统:OpenGL 使用的坐标系统与本书介绍的坐标系统相同。它是一个右手坐标系, 向右, 向上, 远离屏幕(或窗口)。因此, 指向显示器内部。
在处理顶点之前,首先在设备上创建顶点缓冲区以存储顶点。然后将主机上的顶点传输到设备。在此之后,需要引用顶点缓冲区以绘制存储在缓冲区中的顶点数组。此外,在初始传输顶点数据之后,不需要通过主机到设备总线复制数据,特别是如果几何体在渲染循环更新期间保持静态。如果主机内存是动态分配的,则可以删除任何主机内存。
顶点缓冲对象,通常称为 VBO,是在现代 OpenGL 中用于存储顶点和顶点属性的主要机制。出于效率目的,大多数与顶点相关的数据的初始设置和传输都发生在进入显示循环之前。例如,要为此三角形创建一个 VBO,可以使用以下代码:
cGLuint triangleVBO[1];
glGenBuffers(1, triangleVBO);
glBindBuffer(GL_ARRAY_BUFFER, triangleVBO[0]);
glBufferData(GL_ARRAY_BUFFER, 9 * sizeof(GLfloat), vertices, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
创建和分配顶点缓冲对象需要三个 OpenGL 调用。首先,glGenBuffers
创建一个句柄,该句柄可用于在设备上存储 VBO 时引用它。可以在单个 glGenBuffers
调用中创建多个 VBO 的句柄(存储在数组中),如本例所示但未使用。请注意,当生成缓冲区对象时,设备上的空间分配尚未执行。
在 OpenGL 中,对象(如顶点缓冲对象)是计算和处理的主要目标。在使用对象时必须将其绑定到已知的 OpenGL 状态,并在不使用时取消绑定。 OpenGL 使用对象的示例包括顶点缓冲对象、帧缓冲对象、纹理对象和着色器程序,仅举几个。在当前示例中,将 GL_ARRAY_BUFFER
状态绑定到先前生成的三角形 VBO 句柄。这基本上使三角形 VBO 成为活动顶点缓冲对象。任何影响顶点缓冲区的操作都将使用 VBO 中的三角形数据,无论是读取数据还是向其写入数据,只要跟随 glBindBuffer(GL_ARRAY_BUFFER,triangleVBO [0])
命令。
使用 glBufferData(GL_ARRAY_BUFFER,9 * sizeof(GLfloat),vertices,GL_STATIC_DRAW)
从主机(顶点数组)复制顶点数据到设备(当前绑定的 GL_ARRAY_BUFFER)。参数表示目标类型、要复制的缓冲区的大小(以字节为单位)、指向主机缓冲区的指针和指示缓冲区将如何使用的枚举类型。在当前示例中,目标是 GL_ARRAY_BUFFER
,数据大小为 9 * sizeof(GLfloat)
,最后一个参数为 GL_STATIC_DRAW
,指示 OpenGL 顶点不会在渲染过程中更改。最后,在不需要 VBO 作为读取或写入的活动目标时,使用 glBindBuffer(GL_ARRAY_BUFFER,0)
解除绑定。通常,将任何 OpenGL 对象或缓冲区绑定到句柄 0,取消绑定或禁用该缓冲区以影响随后的功能。
顶点数组对象是将顶点缓冲区捆绑到一起以形成一致的顶点状态并与图形硬件中的着色器通信和链接的 OpenGL 机制,而顶点缓冲区对象则是存储顶点(和顶点属性)的容器。请注意,过去的固定功能管线已不存在,因此,诸如法线甚至顶点颜色之类的每个顶点状态必须存储在硬件缓冲区中,然后使用输入变量(例如 in)在着色器中引用。
与顶点缓冲区对象一样,顶点数组对象或 VAOs 在绑定顶点数组对象时必须创建和分配,并设置任何必要的状态。例如,以下代码显示了如何创建包含先前定义的三角形 VBO 的 VAO:
cGLuint VAO;
glGenVertexArrays(1,&VAO);
glBindVertexArray(VAO);
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER,triangleVBO [0]);
glVertexAttribPointer(0,3,GL_FLOAT,GL_FALSE,3 * sizeof(GLfloat),0);
glBindVertexArray(0);
在定义顶点数组对象时,可以将特定的顶点缓冲区对象绑定到着色器代码中的特定顶点属性(或输入)。回想一下在传递顶点着色器中使用 layout(location = 0) in vec3 in_Position
语法来表示着色器变量将从绑定的顶点数组对象中的属性索引 0 接收其数据。在主机代码中,使用以下调用创建映射:
cglEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER,triangleVBO [0]);
glVertexAttribPointer(0,3,GL_FLOAT,GL_FALSE,3 * sizeof(GLfloat),0);
第一个调用启用顶点属性索引(在本例中为 0)。接下来的两个调用将保存顶点的先前定义的顶点缓冲区对象连接到顶点属性本身。由于 glVertexAttribPointer
利用当前绑定的 VBO,因此在分配顶点属性指针之前发出 glBindBuffer
很重要。这些函数调用创建了一种映射,将顶点缓冲区中的顶点绑定到着色器中的 in_Position
变量。glVertexAttribPointer
调用看起来很复杂,但基本上将属性索引 0 设置为包含三个 GLfloats
(第 2 和第 3 个参数)的组件(例如 ,,),它们未被归一化(第四个参数)。第五个参数指示 OpenGL 每组顶点集的开始处有三个浮点值。换句话说,这些顶点在内存中紧密排列,一个接一个。最后一个参数是数据的指针,但是因为在调用此函数之前已绑定了顶点缓冲区,所以数据将与顶点缓冲区相关联。
在进入显示循环之前,应执行初始化和构造顶点数组对象、顶点缓冲区对象和着色器的先前步骤。所有来自顶点缓冲区的内存都将传输到 GPU,并且顶点数组对象将建立数据与着色器输入变量索引之间的连接。在显示循环中,以下调用将触发顶点数组对象的处理:
cglBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
glBindVertexArray(0);
需要再次注意,绑定调用使顶点数组对象处于活动状态。glDrawArrays
调用启动此几何体的管道,描述应将几何体解释为从偏移量 0 开始的一系列三角形基元,并且仅渲染三个索引。在此示例中,数组中仅有三个元素,且基元是三角形,因此将渲染单个三角形。
将所有这些步骤组合起来,三角形的组装代码类似于以下内容,假设着色器和顶点数据加载包含在外部函数中:
c// Set the viewport once
int nx,ny;
glfwGetFramebufferSize(window,&nx,&ny);
glViewport(0,0,nx,ny);
// Set clear color state
glClearColor(0.0f,0.0f,0.0f,1.0f);
// Create the Shader programs, VBO, and VAO
GLuint shaderID = loadPassthroughShader();
GLuint VAO = loadVertexData();
while(!glfwWindowShouldClose(window)){
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glUseProgram(shaderID);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES,0,3);
glBindVertexArray(0);
glUseProgram(0);
// Swap front and back buffers
glfwSwapBuffers(window);
// Poll for events
glfwPollEvents();
if(glfwGetKey(window,GLFW_KEY_ESCAPE)== GLFW_PRESS)
glfwSetWindowShouldClose(window,1);
}
图 17.3. 使用简单的顶点和片段着色器呈现的标准三角形。
当前版本的 OpenGL 已经移除了过去用于引用投影矩阵和模型视图矩阵的矩阵堆栈。因为这些矩阵堆栈不再存在,程序员必须编写可以传输到顶点着色器中进行变换的矩阵代码。起初可能会感到很具有挑战性。然而,已经开发出了几个库和工具包来帮助跨平台开发 OpenGL 代码。其中一个库是 GLM(OpenGL Mathematics),它已经被开发成可以密切跟踪 OpenGL 和 GLSL 规范,以便 GLM 和硬件之间的互操作能够无缝工作。
GLM 提供了几种对计算机图形学非常有用的基本数学类型。为了我们的目的,我们将专注于一些类型和一些函数,这些函数在着色器内使用矩阵变换很容易。将使用的一些类型包括以下内容:
glm::vec3
: 由 3 个浮点数组成的紧凑数组,可以使用与着色器中找到的分量访问相同的方式进行访问;glm::vec4
: 由 4 个浮点数组成的紧凑数组,可以使用与着色器中找到的分量访问相同的方式进行访问;glm::mat4
: 表示为 16 个浮点数的 4×4 矩阵存储。矩阵以列优先的格式存储。同样,GLM 提供了一些函数来创建投影矩阵 Morth 和 Mp,以及生成视图矩阵 Mcam:
glm::ortho
创建一个 4×4 正交投影矩阵。glm::perspective
创建 4×4 透视矩阵。glm::lookAt
创建 4×4 齐次变换,用于平移和定向相机。前面的例子可以简单地扩展为将三角形顶点放入更灵活的坐标系中,并使用正交投影来渲染场景。前面例子中的顶点可以变为:
cGLfloat vertices[] = {-3.0f,-3.0f,0.0f,3.0f,-3.0f,0.0f,0.0f,3.0f,0.0f};
使用 GLM,可以在主机上轻松创建一个正交投影。例如,
cglm::mat4 projMatrix = glm::ortho(-5.0f, 5.0f, -5.0, 5.0, -10.0f, 10.0f);
然后可以将投影矩阵应用于每个顶点,将其转换为裁剪坐标。修改顶点着色器以执行此操作:
cvcanon = Morthv。
这个计算将在修改后的顶点着色器中发生,该着色器使用统一变量将数据从主机通信到设备。统一变量表示整个着色器程序执行期间不变的静态数据。对所有元素而言,数据都是相同且静态的。但是,应用程序可以在着色器执行之间修改统一变量。这是主机应用程序中的数据可以与着色器计算进行通信的主要机制。统一数据通常表示与应用程序相关的图形状态。例如,投影,视图或模型矩阵可以通过统一变量设置和访问。还可以通过统一变量获取场景中光源的信息。
修改顶点着色器需要添加一个用于保存投影矩阵的统一变量。我们可以使用 GLSL 的 mat4
类型来存储这些数据。然后自然地使用投影矩阵将传入的顶点转换为规范化坐标系:
c#version 330 core
layout(location=0) in vec3 in_Position;
uniform mat4 projMatrix;
void main(void)
{
gl_Position = projMatrix * vec4(in_Position, 1.0);
}
应用程序代码只需将统一变量从主机内存(GLM mat4)传输到设备的着色器程序(GLSL mat4)。这很容易,但需要在着色器程序链接完成后,主机端应用程序获得对统一变量的句柄。例如,要获取 projMatrix 变量的句柄,只需发出以下调用一次,链接着色器程序完成后:
cGLint pMatID = glGetUniformLocation(shaderProgram, "projMatrix");
第一个参数是着色器程序对象句柄,第二个参数是着色器中变量名称的字符字符串。然后可以将 id 与各种 OpenGL glUniform
函数调用一起使用,将主机上的内存传输到设备中。但是,在设置与统一变量相关的值之前,必须首先绑定着色器程序。此外,因为 GLM 用于在主机上存储投影矩阵,所以将使用 GLM 辅助函数来获取指向底层矩阵的指针,并允许复制进行。
cglUseProgram(shaderID);
glUniformMatrix4fv(pMatID, 1, GL_FALSE, glm::value_ptr(projMatrix));
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
glBindVertexArray(0);
glUseProgram(0);
注意 uniform
的形式。函数名以帮助定义其如何使用的字符结尾。在这种情况下,一个由单个 4×4 浮点矩阵组成的数组被传输到统一变量中。v
表示一个数组包含数据,而不是通过值传递。第三个参数让 OpenGL 知道是否应该转置矩阵(可能是一个方便的功能),最后一个参数是指向矩阵所在内存的指针。
通过本章节,您应该对着色器和顶点数据在使用 OpenGL 渲染对象时所扮演的角色有了一个概念。特别是在现代 OpenGL 中,着色器发挥着非常重要的作用。接下来的章节将进一步探讨着色器在渲染场景中所扮演的角色,试图建立在本书中介绍的其他渲染风格所扮演的角色之上。
前面的示例指定了一个没有其他数据的三角形。可以将顶点属性(例如法向量、纹理坐标或甚至颜色)与顶点数据交错存储在一个顶点缓冲区中。内存布局很简单。下面,每个顶点的颜色在数组中的每个顶点之后被设置。使用三个分量表示红、绿和蓝通道。除了数组大小现在是 18 个 GLfloats 而不是 9 之外,分配顶点缓冲区是相同的。
cGLfloat vertexData[] = {0.0f, 3.0f, 0.0f, 1.0f, 1.0f, 0.0f, -3.0f, -3.0f, 0.0f, 0.0f, 1.0f, 1.0f, 3.0f, -3.0f, 0.0f, 1.0f, 0.0f, 1.0f};
顶点数组对象规范不同。因为颜色数据在顶点之间交错存储,所以顶点属性指针必须适当地跨越数据。第二个顶点属性索引也必须启用。在上述示例的基础上,我们构建新的 VAO 如下:
cglBindBuffer(GL_ARRAY_BUFFER, m_triangleVBO[0]);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat),
0);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat),
(const GLvoid *)12);
使用单个 VBO 并在设置属性之前绑定,因为顶点和颜色数据都包含在 VBO 中。第一个顶点属性在索引 0 处启用,它将代表着色器中的顶点。请注意,跨度(第五个参数)不同,因为顶点由六个浮点数(例如,顶点的 、、,后跟颜色的 、、)分隔。第二个顶点属性索引被启用,并将代表着色器中位置 1 处的顶点颜色属性。它具有相同的跨度,但现在的最后一个参数表示第一个颜色值开始的指针偏移量。虽然上面的例子中使用了 12,但这与声明 3 * sizeof(GLfloat) 相同。换句话说,我们需要跳过表示顶点 、、 值的三个浮点数,以查找数组中的第一个颜色属性。
此示例的着色器仅略微修改。顶点着色器的主要差异是(1)第二个属性颜色位于位置 1,(2)vColor
是在顶点着色器的主体中设置的输出变量,如下所示:
c# version 330 core
layout(location=0) in vec3 in_Position;
layout(location=1) in vec3 in_Color;
out vec3 vColor;
uniform mat4 projMatrix;
void main(void)
{
vColor = in_Color;
gl_Position = projMatrix * vec4(in_Position, 1.0);
}
请回想一下,关键字 in
和 out
指的是着色器之间的数据流。从顶点着色器流出的数据成为连接的片段着色器中的输入数据,前提是变量名称匹配。此外,传递到片段着色器的 out
变量会使用重心插值在片段之间进行插值。一些插值方式可以通过附加关键字进行修改,但这个细节将留给读者去了解。在这个例子中,指定了三个顶点,每个顶点有一个特定的颜色值。在片段着色器内部,颜色将在三角形面上进行插值。
片段着色器的更改很简单。现在设置并从顶点着色器传递出来的 vColor
变量变成了 in
变量。在处理片段时,vColor vec3
将根据三角形内片段的位置包含正确的插值值。
c#version 330 core
layout(location=0) out vec4 fragmentColor;
in vec3 vColor;
void main(void)
{
fragmentColor = vec4(vColor, 1.0);
}
使用三角形数据运行此着色器的结果如图 17.4 所示。
图 17.4。在顶点着色器中设置每个顶点的颜色并将数据传递到片段着色器会导致颜色的重心插值。
前面的例子说明了如何在数组中交错存储数据。顶点缓冲区可以以多种方式使用,包括为不同的模型属性使用单独的顶点缓冲区。交错存储数据具有优点,因为与顶点关联的属性在内存中靠近顶点,并且在着色器中操作时可能利用内存局部性。虽然使用这些交错数组很简单,但以这种方式管理大型模型可能变得麻烦,特别是当使用数据结构来构建用于图形的强大(和可持续的)软件基础设施时(请参见第 12 章)。将顶点数据存储为包含顶点和任何相关属性的结构体向量非常简单。这样做时,只需要将结构映射到顶点缓冲区。例如,以下结构包含顶点位置和顶点颜色,使用 GLM 的 vec3
类型:
cstruct vertexData
{
glm::vec3 pos;
glm::vec3 color;
};
std::vector< vertexData > modelData;
STL vector
将保存与模型中所有三角形相关的所有顶点。我们将继续使用与以前示例中相同的三角形布局,即基本的三角形条带。每三个顶点表示列表中的一个三角形。有其他可以用于 OpenGL 的数据组织方式,第 12 章介绍了其他组织数据更有效的选项。
一旦数据加载到向量中,与之前使用的相同调用将数据加载到顶点缓冲对象中:
cint numBytes = modelData.size() * sizeof(vertexData);
glBufferData(GL_ARRAY_BUFFER, numBytes, modelData.data(), GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
STL 向量以连续方式存储数据。上面使用的 vertexData
结构由平坦的内存布局表示(它不包含指向其他数据元素的指针)且是连续的。但是,STL 向量是一个抽象,引用底层内存的指针必须使用 data()
成员进行查询。该指针提供给 glBufferData
的调用。在顶点数组对象中的属性分配与之前完全相同,因为顶点属性的局部性保持不变。
图形管道章节(第 8 章)和表面着色章节(第 10 章)描述和说明了每顶点和每片段着色与光栅化和着色的一般关系。使用现代图形硬件,在片段处理器中应用着色算法会产生更好的视觉效果,并更准确地近似照明。基于每个顶点计算的着色通常会受到与底层几何细分相关的视觉伪影的影响。特别是,基于每个顶点的着色通常无法在三角形的整个面上逼近适当的强度,因为仅在每个顶点处计算照明。例如,当距离光源的距离很小时,与所着色面的大小相比,面上的照明将是不正确的。图 17.5 说明了这种情况。尽管非常靠近光源,但三角形中心不会被亮光照亮,因为远离光源的顶点的照明被用来插值整个面的着色。当然,增加几何体的细分可以改善视觉效果。但是,这种解决方案在实时图形中的实际用途有限,因为需要更准确的照明所需的额外几何体可能导致渲染速度变慢。
图 17.5。光源到三角形的大小相对较小。
片段着色器在顶点变换和裁剪后从光栅化产生的片段上操作。一般来说,片段着色器必须输出一个写入帧缓冲区的值。通常情况下,这是像素的颜色。如果启用深度测试,则片段的深度值将用于控制颜色及其深度是否写入帧缓存存储器中。片段着色器用于计算的数据来自各种来源:
内置的 OpenGL 变量。这些变量由系统提供。片段着色器变量的示例包括 gl_FragCoord
或 gl_FrontFacing
。这些变量可能会根据 OpenGL 和 GLSL 的修订而发生变化,因此建议您检查针对的 OpenGL 和 GLSL 版本的规范。
统一变量。统一变量从主机传输到设备,并且根据用户输入或应用程序中的模拟状态更改而随时更改。这些变量由程序员声明和定义,用于在顶点和片段着色器中使用。前面的顶点着色器示例中的投影矩阵是通过统一变量传递给着色器的。如果需要,在顶点和片段着色器中可以使用相同的统一变量名称。
输入变量。输入变量在片段着色器中使用带有前缀关键字 in 来指定。请回想一下,数据可以在着色器之间流动。顶点着色器可以使用 out 关键字向下一个着色器阶段输出数据(例如,在先前的示例中使用的 out vec3 vColor
)。当下一个阶段使用 in 关键字后跟相同类型和名称限定符(例如,在先前示例的相应片段着色器中使用的 in vec3 vColor
)时,输出将链接到输入。
通过 in-out
链接机制传递到片段着色器的任何数据都将使用重心插值在每个片段上变化。插值在着色器外由图形硬件计算。在这种基础设施内,片段着色器可用于执行针对三角形表面上特定方程的每片段着色算法。顶点着色器提供支持计算,转换顶点并分配中间每顶点的值以进行片段代码的插值。
以下着色器程序代码实现了每个片段的 Blinn-Phong 着色。它将本章迄今为止介绍的大部分内容结合到了从第 4 章开始的着色器描述中。交错的顶点缓冲区用于包含顶点位置和法向量。这些值在顶点着色器中显示为索引 0 和索引 1 的顶点数组属性。片段着色器代码中发生的着色计算是在相机坐标(有时称为眼空间)中执行的。
我们程序的顶点着色器阶段用于使用模型和相机矩阵将传入的顶点转换为相机坐标。它还使用法线矩阵(M–1)T,以适当地转换传入的法向量属性。顶点着色器向片段阶段输出三个变量:
在对三角形中的三个顶点应用重心插值之后,每个变量都可用于片段计算。
此着色器程序使用单个点光源。光的位置和强度使用统一变量向顶点和片段着色器传输。使用 GLSL 的结构限定符声明了光数据,该限定符允许将变量以有意义的方式组合在一起。虽然此处未呈现,但 GLSL 支持数组和 for 循环控制结构,因此可以轻松地向此示例添加其他光源。
所有矩阵也使用统一变量提供给顶点着色器。现在,我们将想象模型(或本地变换)矩阵将设置为单位矩阵。在下一节中,将提供更多详细信息,以扩展如何使用 GLM 在主机上指定模型矩阵。
c#version 330 core
//
// Blinn-Phong Vertex Shader
//
layout(location=0) in vec3 in_Position;
layout(location=1) in vec3 in_Normal;
out vec4 normal;
out vec3 half;
out vec3 lightdir;
struct LightData {
vec3 position;
vec3 intensity;
};
uniform LightData light;
uniform mat4 projMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;
uniform mat4 normalMatrix;
void main(void)
{
// Calculate lighting in eye space: transform the local
// position to world and then camera coordinates.
vec4 pos = viewMatrix * modelMatrix * vec4(in_Position, 1.0);
vec4 lightPos = viewMatrix * vec4(light.position, 1.0);
normal = normalMatrix * vec4(in_Normal, 0.0);
vec3 v = normalize( -pos.xyz );
lightdir = normalize( lightPos.xyz - pos.xyz );
half = normalize( v + lightdir );
gl_Position = projMatrix * pos;
}
顶点着色器的主要函数首先使用 vec4
类型将位置和光源位置转换为相机坐标,以与 GLSL 的 mat4
的 4×4 矩阵对应。然后,我们转换法向量并将其存储在输出变量 out vec4 normal
中。然后计算视图(或眼)向量和光方向向量,这导致计算 Blinn-Phong 着色所需的半向量。最终计算通过应用投影矩阵来完成计算
cvcanon = MprojMcamMmodelv
它然后将顶点的规范化坐标设置为内置的 GLSL 顶点着色器输出变量 gl_Position。之后,该顶点处于剪辑坐标系中,并准备好进行光栅化。
片段着色器计算 Blinn-Phong 着色模型。它接收顶点处理阶段的重心插值值,用于顶点法向量、半向量和光方向。请注意,这些变量使用 in 关键字指定,因为它们从顶点处理阶段输入。光数据也使用与顶点着色器中使用的相同统一变量规范与片段着色器共享。矩阵不是必需的,因此不声明统一矩阵变量。通过统一变量将几何模型的材料属性通信,以指定 、、、 和 。这些数据一起允许片段着色器在每个片段上计算式 4.3:
c#version 330 core
//
// Blinn-Phong Fragment Shader
//
in vec4 normal;
in vec3 half;
in vec3 lightdir;
layout(location=0) out vec4 fragmentColor;
struct LightData {
vec3 position;
vec3 intensity;
};
uniform LightData light;
uniform vec3 Ia;
uniform vec3 ka, kd, ks;
uniform float phongExp;
void main(void)
{
vec3 n = normalize(normal.xyz);
vec3 h = normalize(half);
vec3 l = normalize(lightdir);
vec3 intensity = ka * Ia
+ kd * light.intensity * max( 0.0, dot(n, l) )
+ ks * light.intensity
* pow( max( 0.0, dot(n, h) ), phongExp );
fragmentColor = vec4( intensity, 1.0 );
}
片段着色器将计算出的强度写入片段颜色输出缓冲区。图 17.6 说明了几个示例,展示了在几何模型上不同程度的细分中执行每片段着色的效果。此片段着色器介绍了使用结构来保存统一变量。应注意它们是用户定义的结构,在本示例中,LightData 类型仅保存光位置及其强度。在主机代码中,当请求统一变量句柄时,使用完全限定的变量名称引用结构中的统一变量,例如:
clightPosID = shader.createUniform("light.position");
lightIntensityID = shader.createUniform("light.intensity");
图 17.6 在一个细分球体上增加细分程度的情况下应用每片段着色。随着细分程度的降低,镜面高光非常明显。
一旦您拥有了一个可工作的着色器程序,例如在此处介绍的 Blinn-Phong 着色器,就很容易扩展您的想法并开发新的着色器。开发一组非常特定的着色器进行调试也可能会很有帮助。其中一个这样的着色器是法向量着色器程序。通常,法向量着色对于了解传入几何是否正确组织或计算是否正确很有帮助。在本例中,顶点着色器保持不变。只有片段着色器发生变化:
c#version 330 core
in vec4 normal;
layout(location=0) out vec4 fragmentColor;
void main(void)
{
// Notice the use of swizzling here to access
// only the xyz values to convert the normal vec4
// into a vec3 type!
vec3 intensity = normalize(normal.xyz) * 0.5 + 0.5;
fragmentColor = vec4( intensity, 1.0 );
}
无论您要构建哪种着色器,请确保对它们进行注释!GLSL 规范允许在着色器代码中包含注释,因此请留下一些细节,以便以后指导自己。
一旦基本的着色器可以工作,就可以开始创建更复杂的场景。一些 3D 模型文件很容易加载,而其他一些则需要更多的工作。OBJ 格式是一种简单的 3D 对象文件表示形式。OBJ 是一种广泛使用的格式,有多种代码可用于加载这些类型的文件。早期介绍的结构体数组机制非常适合在主机上包含 OBJ 数据。然后可以轻松将其转换为 VBO 和顶点数组对象。
许多 3D 模型定义在其自己的本地坐标系中,并且需要各种变换来将它们与 OpenGL 坐标系对齐。例如,当 Stanford Dragon 的 OBJ 文件加载到 OpenGL 坐标系中时,它出现在原点处的一侧。使用 GLM,我们可以创建模型变换以将对象放置在我们的场景中。对于龙模型,这意味着绕 旋转 度,然后向 上移动。有效的模型变换为
并且龙直立并在地面以上,如图 17.7 所示。为此,我们利用了 GLM 中的几个功能来生成本地模型变换:
glm::translate
创建一个平移矩阵。glm::rotate
创建一个旋转矩阵,指定绕特定轴度数或弧度旋转。glm::scale
创建一个缩放矩阵。图 17.7。图像从左到右描述。龙的默认本地方向,侧卧着。绕 旋转 度后,龙直立但仍围绕原点居中。最后,在 中应用 1.0 的平移后,龙就准备好进行实例化了。
我们可以使用这些函数创建模型变换,并使用统一变量将模型矩阵传递给着色器。Blinn-Phong 顶点着色器包含将本地变换应用于传入顶点的指令。以下代码显示了如何呈现龙模型:
cglUseProgram( BlinnPhongShaderID );
// Describe the Local Transform Matrix
glm::mat4 modelMatrix = glm::mat4(1.0); // Identity Matrix
modelMatrix = glm::translate(modelMatrix, glm::vec3(0.0f, 1.0f, ↩ 0.0f));
float rot = (-90.0f / 180.0f) * M_PI;
modelMatrix = glm::rotate(modelMatrix, rot, glm::vec3(1, 0, 0));
// Set the Normal Matrix
glm::mat4 normalMatrix = glm::transpose( glm::inverse( viewMatrix↩ * modelMatrix ) );
// Pass the matrices to the GPU memory
glUniformMatrix4fv(nMatID, 1, GL_FALSE, glm::value_ptr(↩normalMatrix));
glUniformMatrix4fv(pMatID, 1, GL_FALSE, glm::value_ptr(projMatrix↩));
glUniformMatrix4fv(vMatID, 1, GL_FALSE, glm::value_ptr(viewMatrix↩));
glUniformMatrix4fv(mMatID, 1, GL_FALSE, glm::value_ptr(↩modelMatrix));
// Set material for this object
glm::vec3 kd( 0.2, 0.2, 1.0 );
glm::vec3 ka = kd * 0.15f;
glm::vec3 ks( 1.0, 1.0, 1.0 );
float phongExp = 32.0;
glUniform3fv(kaID, 1, glm::value_ptr(ka));
glUniform3fv(kdID, 1, glm::value_ptr(kd));
glUniform3fv(ksID, 1, glm::value_ptr(ks));
glUniform1f(phongExpID, phongExp);
// Process the object and note that modelData.size() holds
// the number of vertices, not the number of triangles!
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, modelData.size());
glBindVertexArray(0);
glUseProgram( 0 );
使用 OpenGL 进行实例化的实现方式与使用光线追踪器进行实例化不同。在光线追踪器中,射线通过模型变换矩阵逆转换到对象的本地空间中。在 OpenGL 中,实例化是通过将对象的单个副本作为顶点数组对象(带有关联的顶点缓冲区对象)加载,并根据需要重用几何形状来执行的。像光线追踪器一样,只有一个对象被加载到内存中,但可以呈现许多对象。
现代 OpenGL 很好地支持这种实例化方式,因为顶点着色器可以(并且必须)计算必要的变换以将顶点转换为剪辑坐标。通过编写嵌入这些变换的通用着色器,例如 Blinn-Phong 顶点着色器所示,可以使用相同的底层本地几何重新渲染模型。可以从更高级别的类结构中查询不同的材料类型和变换以填充每帧从主机传递到设备的统一变量。动画和交互式控制也很容易创建,因为模型变换可以随着显示循环迭代而随时间改变。图 17.8 和图 17.9 使用一个龙的内存占用空间,但在屏幕上呈现了三个不同的龙模型。
图 17.8 在使用统一变量指定材料属性和变换的情况下运行 Blinn-Phong 着色器程序对三个龙进行渲染的结果。
图 17.9 在 Blinn-Phong 着色器程序中设置统一变量 可以产生 Lambertian 着色。
纹理是使用 OpenGL 着色器操纵视觉效果的有效手段。它们在许多基于硬件的图形算法中广泛使用,并且 OpenGL 原生支持它们,使用纹理对象。与以前的 OpenGL 概念类似,必须通过将数据从主机复制到 GPU 内存并设置 OpenGL 状态来分配和初始化纹理对象。纹理坐标通常集成到顶点缓冲区对象中,并作为顶点属性传递给着色器程序。片段着色器通常使用从顶点着色器传递的插值纹理坐标执行纹理查找函数。
如果您已经有了工作的着色器和顶点数组对象,那么向代码中添加纹理会非常简单。使用纹理时,常规的 OpenGL 创建硬件对象技术都可以使用。但是,必须首先确定纹理数据的来源。数据可以从文件加载(例如,PNG,JPG,EXR 或 HDR 图像文件格式),也可以在主机上(甚至在 GPU 上)进行程序化生成。将数据加载到主机内存后,将数据复制到 GPU 内存,并可选择设置与纹理相关的 OpenGL 状态。 OpenGL 纹理数据被加载为一段线性的内存缓冲区,其中包含用于纹理的数据。硬件上的纹理查找可以是 1D,2D 或 3D 查询。无论纹理维度查询如何,数据都以相同的方式加载到内存中,在主机上使用线性分配的内存。在下面的示例中,从图像文件(或程序化生成)加载数据的过程留给读者自己决定,但提供了与加载图像时可能存在的变量名称相匹配的名称(例如 imgData
,imgWidth
,imgHeight
)。
cfloat *imgData = new float[ imgHeight * imgWidth * 3 ];
...
GLuint texID;
glGenTextures(1, &texID);
glBindTexture(GL_TEXTURE_2D, texID);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, imgWidth, imgHeight, 0,
GL_RGB, GL_fLOAT, imgData);
glBindTexture(GL_TEXTURE_2D, 0);
delete [] imgData;
以上示例介绍了如何使用着色器程序设置和使用基本的 2D OpenGL 纹理。创建 OpenGL 对象的过程现在应该已经非常熟悉了。必须在设备上生成一个句柄(或 ID)来引用纹理对象(例如,在此示例中为 texID)。然后,将 ID 绑定以允许任何后续的纹理状态操作影响纹理状态。存在相当广泛的 OpenGL 纹理状态和参数,可以影响纹理坐标解释和纹理查找滤波。不同的纹理目标在图形硬件上存在。在这种情况下,纹理目标被指定为 GL_TEXTURE_2D
,并且将出现在与纹理相关的函数的第一个参数中。对于 OpenGL,这个特定的纹理目标意味着纹理坐标将以设备归一化方式指定(即在 范围内)。此外,纹理数据必须分配,使得宽度和高度维数是 2 的幂次方(例如 , 等)。当前绑定的纹理的纹理参数可通过调用 glTexParameter
设置。这个函数的签名根据所设置的数据类型有多种形式。在本例中,硬件将把纹理坐标夹紧到明确的范围 。当进行纹理查找时,OpenGL 纹理对象的缩小和放大过滤器自动设置为使用线性过滤(而不是最近邻 - GL_NEAREST
)。第 11 章提供了有关纹理的详细信息,包括有关纹理查找中可能发生的过滤的详细信息。图形硬件可以通过设置相关的纹理状态来自动执行许多这些操作。
最后,调用 glTexImage2D
对纹理进行主机到设备复制。该函数有几个参数,但总体操作是在显卡上分配空间(例如,imageWidth X imgHeight
)的三个浮点数(第 7 个和第 8 个参数:GL_RGB
和 GL_FLOAT
),并将线性纹理数据复制到硬件中(例如,imgData
指针)。其余参数涉及设置 mipmap 级别细节(第 2 个参数),指定内部格式(例如,第 3 个参数的 GL_RGB
)以及纹理是否具有边框(第 6 个参数)。学习 OpenGL 纹理时,将这些保持为此处列出的默认值是安全的。但是,读者应该学习有关 mipmaps 和纹理的潜在内部格式,以便需要更高级别的图形处理时使用。
纹理对象的分配和初始化在上面的代码中完成。还必须对顶点缓冲区和顶点数组对象进行其他修改,以将正确的纹理坐标与几何描述链接起来。按照先前的示例,纹理坐标的存储是对顶点数据结构的直接修改:
cstruct vertexData
{
glm::vec3 pos;
glm::vec3 normal;
glm::vec2 texCoord;
};
因此,顶点缓冲区对象的大小将增加,并且纹理坐标的交错将需要更改顶点数组对象中顶点属性规范的步幅。图 17.10 说明了在顶点缓冲区中添加纹理坐标后的基本数据交错布局。
图 17.10。在顶点缓冲区中添加纹理坐标后的数据布局。每个块表示一个 GLfloat
,它是 4 字节。位置编码为白色块,法线编码为紫色,纹理坐标编码为橙色。
cglBindBuffer(GL_ARRAY_BUFFER, m_triangleVBO[0]);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(GLfloat), 0);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(GLfloat), (const GLvoid *)12);
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(GLfloat), (const GLvoid *)24);
glBindVertexArray(0);
在上面的代码片段中,纹理坐标被放置在顶点属性位置 2。请注意纹理坐标大小的更改(例如,glVertexAttribPointer
的第二个参数是 2,以与结构中的 vec2
类型相一致)。此时,纹理对象的所有初始化都已完成。
在使用着色器渲染顶点数组对象之前,必须启用(或绑定)纹理对象。一般来说,图形硬件允许在执行着色器程序时使用多个纹理对象。通过这种方式,着色器程序可以应用复杂的纹理和视觉效果。因此,要将纹理绑定到用于着色器的纹理单元,它必须与可能存在的许多纹理单元之一相关联。纹理单元表示着色器可以使用多个纹理的机制。在下面的示例中,仅使用一个纹理,因此将激活纹理单元 0,并将其绑定到我们的纹理。
激活纹理单元的函数是 glActiveTexture
。它的唯一参数是要激活的纹理单元。它在下面设置为 GL_TEXTURE0
,但如果需要多个纹理,则可以是 GL_TEXTURE1
或 GL_TEXTURE2
等。一旦激活了纹理单元,就可以使用 glBindTexture
调用将纹理对象绑定到它上。
cglUseProgram(shaderID);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texID);
glUniform1i(texUnitID, 0);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
glBindVertexArray(0);
glBindTexture(GL_TEXTURE_2D, 0);
glUseProgram(0);
上面的大部分代码应该是你迄今为止开发的逻辑扩展。请注意在渲染顶点数组对象之前调用 glUniform
。在现代图形硬件编程中,着色器执行纹理查找和混合的工作,因此必须具有有关哪个纹理单元保存在着色器中使用的纹理的数据。活动纹理单元使用 uniform
变量提供给着色器。在本例中,0 被设置为指示纹理查找将来自纹理单元 0。这将在以下部分进行扩展。
着色器程序执行查找和可能需要的任何混合。大部分计算通常进入片段着色器,但顶点着色器通常通过将纹理坐标传递到片段着色器来分阶段片段计算。通过这种方式,纹理坐标将被插值,并提供每个片段对纹理数据的查找。
在着色器程序中使用纹理数据需要进行简单的更改。使用先前提供的 Blinn-Phong 顶点着色器,仅需要三个更改:
1、 纹理坐标是存储在顶点数组对象中的每个顶点属性。它们与顶点属性索引 2(或位置 2)相关联。
clayout(location=2) in vec2 in_TexCoord;
2、 片段着色器将执行纹理查找,并需要一个插值的纹理坐标。将添加此变量作为输出变量,传递给片段着色器。
cout vec2 tCoord;
3、 在 main 函数中将传入的顶点属性复制到输出变量中:
c// Pass the texture coordinate to the fragment shader
tCoord = in_TexCoord;
片段着色器也需要进行简单的更改。首先,必须声明从顶点着色器传递的插值纹理坐标。还要记住,uniform 变量应存储绑定纹理的纹理单元。这必须作为 sampler
类型传递给着色器。Sampler 是一种着色语言类型,允许从单个纹理对象中查找数据。在本例中,只需要一个 sampler
,但在使用多个纹理查找的着色器中,将使用多个 sampler
变量。还有多种 sampler
类型,具体取决于纹理对象的类型。在这里介绍的示例中,使用 GL_TEXTURE_2D 类型创建了纹理状态。片段着色器中的相关 sampler
的类型为 sampler2D
。必须添加以下两个变量声明到片段着色器:
最终修改将进入片段着色器代码的 main 函数中。使用 GLSL 纹理查找函数对纹理进行采样,并(在本例中)替换几何体的漫反射系数。texture
的第一个参数是保存纹理单元的 sampler
类型。第二个参数是纹理坐标。该函数返回一个 vec4
类型。在下面的代码片段中,没有使用 alpha 值进行最终计算,因此所得到的纹理查找值是仅选择 RGB 分量的分量方式。从纹理查找中得到的漫反射系数设置为用于光照方程的 vec3
类型。
cvec3 kdTexel = texture(textureUnit, tCoord).rgb;
vec3 intensity = ka * Ia + kdTexel * light.intensity
* max( 0.0, dot(n, l) ) + ks * light.intensity
* pow( max( 0.0, dot(n, h) ), phongExp );
图 17.11 说明了使用这些着色器修改的结果。图中最右边的图像通过启用 OpenGL 状态的纹理平铺扩展了示例代码。请注意,这些更改仅在主机代码中完成,着色器不会更改。要启用此平铺,它允许超出设备归一化范围的纹理坐标,必须更改 GL_TEXTURE_WRAP_S
和 GL_TEXTURE_WRAP_T
的纹理参数,从 GL_CLAMP
更改为 GL_REPEAT
。此外,设置纹理坐标的主机代码现在范围为 。
图 17.11。最左边的图像显示纹理,一个 像素的图像。中间的图像显示应用纹理的场景,使用范围为 的纹理坐标,以便只有一个图像铺在地面上。最右边的图像修改纹理参数,因此 GL_REPEAT
用于 GL_TEXTURE_WRAP_S
和 GL_TEXTURE_WRAP_T
,纹理坐标范围从 。结果是在两个纹理维度中重复平铺五次。
顺便说一下,对于各种应用程序可能有用的另一个纹理目标是 GL_TEXTURE_RECTANGLE
。纹理矩形是独特的纹理对象,不受 2 的幂宽度和高度图像要求的限制,并使用非归一化的纹理坐标。此外,它们不允许重复平铺。如果使用纹理矩形,则必须使用特殊的 sampler
类型引用它们:sampler2DRect
。
随着您对 OpenGL 的熟悉程度的提高,将本章中描述的大部分内容封装到类结构中,以便包含模型特定数据并允许在场景中渲染各种对象变得明智。例如,在图 17.12 中,一个球体被实例化六次,以创建三个椭球体和三个球体。每个模型使用相同的底层几何,但具有不同的材质属性和模型变换。如果您跟随本书并按照第 4 章中详细说明的方式实现了射线追踪器,则很可能您的实现基于一个坚实的面向对象设计。该设计可以利用起来,使使用 OpenGL 开发图形硬件程序变得更容易。典型的射线追踪器软件架构将包括多个类,直接映射到图形硬件以及软件光栅化应用程序。在射线追踪器中表示表面、材质、光源、着色器和摄像机的抽象基类可以适应于初始化图形硬件状态、更新该状态,并在必要时将类数据渲染到帧缓冲区的操作。这些虚拟函数的接口可能需要适应您的特定实现,但是扩展表面类设计的第一步可能类似于以下内容:
cclass surface
virtual bool initializeOpenGL()
virtual bool renderOpenGL( glm::mat4& Mp, glm::mat4& Mcam)
图 17.12。左边是一个单个细分球体,使用不同的模型变换六次实例化来创建此场景,使用片段着色器程序进行渲染。右边的图像是使用基本的 Whitted 射线追踪器渲染的。请注意,阴影对场景感知的影响。每个片段的着色允许在两种渲染样式中类似地呈现高光反射。
将投影和视图矩阵传递给渲染函数可以为管理这些矩阵提供间接性。这些矩阵将来自于摄像机类,可以通过解释键盘、鼠标或游戏手柄输入来操作。初始化函数(至少对于表面导数)将包含顶点缓冲对象和顶点数组对象分配和初始化代码。除了启动任何顶点数组对象的绘制数组之外,渲染函数还需要激活着色器程序,并将所需的矩阵传递到着色器中,如先前在龙模型示例中所示。当您努力将图像顺序和对象顺序(硬件和软件)算法集成到相同的基础数据框架中时,一些软件设计挑战会出现,大多与数据访问和组织有关。但是,这是成为精通图形编程软件工程并最终获得混合渲染算法实战经验的高度有用的练习。
本章旨在提供一个基本的图形硬件编程入门,受 OpenGL API 的影响。您可以选择多种方向进行进一步学习。许多主题,例如帧缓冲对象、纹理渲染、环境映射、几何着色器、计算着色器和高级照明着色器未被覆盖。这些领域代表了学习图形硬件的下一个阶段,但即使在涵盖的领域中,人们也可以通过许多方向来开发更强大的图形硬件理解。图形硬件编程将继续发展和变化。感兴趣的读者应该期望这些变化,并查看 OpenGL 和 OpenGL Shading Language 的规范文档,以获取有关 OpenGL 的功能和硬件与这些计算之间关系的更多详细信息。
如何调试着色器程序?
在大多数平台上,调试顶点着色器和片段着色器并不简单。不过,越来越多的支持通过各种驱动程序、操作系统扩展和 IDE 向开发人员提供相关信息。它仍然可能很具有挑战性,因此使用着色器进行代码的视觉调试。如果屏幕上没有任何内容,请尝试渲染法线向量、半向量或任何可以给您一个感觉错误可能在哪里(或不存在)的东西。图 17.13 演示了法线着色器的操作。如果在窗口上出现图像,请确保它们与您的预期相符(参见图 17.14)!
图 17.13。将法线着色器应用于复杂模型以进行调试。
图 17.14。视觉调试非常重要!您能从图像中确定错误或从哪里开始调试吗?当将不正确的步幅应用于顶点数组对象时,渲染会出错。
有许多良好的资源可用于了解更多有关编程图形硬件所涉及的技术细节。一个好的起点可能是 OpenGL 和 GLSL 规范文档。它们可以在 opengl.org 网站上免费在线获取。这些文档将为所有不同版本和新版本的 OpenGL 提供完整的详细信息。
本章的各个部分大致组织为引导学生创建现代 OpenGL 应用程序的流程。然而,要理解与设置窗口和 OpenGL 上下文相关的细节需要额外的努力。但是,通过一组每周一个小时实验室的章节,应该可以完成以下操作:
这个列表仅供参考。在我的计算机图形学课程实验室中,学生会提供材料,让他们开始思考本周的想法。他们完成实验室后,可以为他们的代码添加自己的想法或创造性探索。随着学生熟悉图形硬件编程,他们可以探索更多感兴趣的领域,例如纹理、渲染到纹理或更高级的着色器和图形算法。
本文作者:青波
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!