图形学系列文章目录
- 序章 初探图形编程
- 第1章 你的第一个三角形
- 第2章 变换
- 顶点变换
- 视图矩阵 & 帧速率
- 第3章 纹理映射
- 第4章 透明度和深度
- 第5章 裁剪区域和模板缓冲区
- 第6章 场景图
- 第7章 场景管理
- 第8章 索引缓冲区
- 第9章 骨骼动画
- 第10章 后处理
- 第11章 实时光照(一)
- 第12章 实时光照(二)
- 第13章 立方体贴图
- 第14章 阴影贴图
- 第15章 延迟渲染
- 第16章 高级缓冲区
文章目录
- 图形学系列文章目录
- 前言
- 渲染技术
- 光线追踪
- 矢量图形
- 光栅化
- 图形数据
- 顶点
- 纹理
- 着色器程序
- 图元
- 空间
- APIs
- DirectX
- OpenGL
- 选哪个API?
- OpenGL 渲染管线
- 裁剪
- 插值
- 缓冲
- 垂直同步
前言
本教程将向你介绍现代图形编程背后的概念,包括顶点数据是如何变成最终的像素化图像的。你将了解到可用于渲染图形的应用程序接口(API)、渲染过程中使用的数据类型,以及图形硬件是如何被编程的。不同于其他晦涩难懂的英文翻译产物,本教程将用通俗易懂的语言来带你入门图形学,并且每一章都有配套的练习代码,让你牢牢掌握学过的知识。
渲染技术
光线追踪
光线追踪是一种流行的渲染技术,它通过模拟光线的工作方式可以生成非常逼真的图像。正如其名称所示,光线追踪的工作原理是将光线投射到场景中,然后查看它与哪些物体相交;根据物体的材质属性,可能会创建更多的光线来找到可能在一个物体上产生反射的物体。随着光线在场景中传播,它们“拾取”所反弹的物体的颜色。与预期相反,光线追踪通常是为屏幕的每个像素向场景中投射一条光线,而不是从场景中的每个光源投射光线。虽然这种技术可以创建逼真的图像,但它非常缓慢,因为每一帧都需要进行大量的光线计算。尽管如此,光线追踪经常在电影行业中用于渲染计算机生成图像(CGI)场景,因为视觉保真度比速度更重要,使得每 7 小时渲染一帧(如在《玩具总动员 3》中)这样的渲染时间成为一个值得的权衡。
矢量图形
矢量图形由许多数学函数组成,这些函数定义了线条、点和曲线,从而创建出最终的图像。这种数学特性使得矢量图像在放大或变更大小时具有“无限”的分辨率,这与位图图像不同,位图图像在像素占据更多屏幕空间时会变得块状化。字体就是矢量图形的一个经典例子——一组函数定义了一种字体,它可以缩放到任何字号而不会损失细节。Macromedia Flash 是另一种流行的技术,它可以利用矢量来制作文本和动画图形。
光栅化
一般来说,光栅化是获取图像数据并将其显示在屏幕上的过程;它之所以被这样称呼,是因为旧的阴极射线管显示器的扫描模式,其中电子束以类似网格的图案在屏幕上“扫过”(拉丁语:rastrus)。但更常见的是,光栅化指的是将一系列几何形状(通常是三角形)投影到屏幕上的过程。与光线追踪不同,光栅化不是通过物体相交来计算像素的光影,并且通常不像矢量那样使用曲线计算,这使得光栅化非常快。不过,被确定为被几何形状覆盖的像素可以进行着色——这意味着它的颜色可以根据与覆盖形状相关的信息(如纹理和朝向)来计算。由于这种速度和适应性,光栅化已成为计算机游戏中渲染 3D 场景最流行的方法,并且已经开发了几种光栅化应用程序接口(API)来辅助使用光栅化进行图形渲染。
图形数据
在现代图形渲染中,有三种基本形式的图形数据,即顶点、纹理和着色器程序。在使用时,这些数据通常包含在图形硬件的板载随机存取存储器(RAM)中,但如果需要可以按需加载,或者如果板载图形内存不足,可以缓存在系统内存中。
顶点
一个顶点代表一个形状中的一个角。在游戏渲染中使用的所有形状都是由许多平面组成的,顶点作为边 —— 甚至像球体这样的 “圆形” 形状也是如此!顶点通过边连接在一起,形成图元,然后可以相应地进行着色或纹理映射。顶点至少有一个位置,通常在三维笛卡尔空间中定义,但可能有许多其他属性:颜色、纹理坐标、法线以及高级渲染技术所需的其他信息。
纹理
纹理是应用于你想要在屏幕上渲染的几何数据的图像。它们通常是二维的,从像 PNG 或 JPG 这样的图像文件格式中加载;三维纹理有时用于某些类型的高级可视化,比如医学核磁共振扫描的结果。一维纹理也是可能的,把它们想象成一条像素带——或者更确切地说,纹素(texels),这是纹理每个组成部分的正确名称。这些组成部分中的每一个都有许多值——通常是红色、绿色和蓝色信息,有时还有一个额外的透明度通道来表示透明度。一些硬件应用程序接口(OpenGL 2 支持,OpenGL 3 不支持)支持“调色板化”纹理,其中纹理中的纹素是指向调色板的索引——这在 8 位和 16 位游戏机时代是一种流行的定义图形信息的方法,但现在很少使用了。
着色器程序
着色器是直接在你的图形硬件上运行的简短程序,它们执行将你的图形数据转换为所需的最终图像的操作。就像你一直在编写的 C 程序一样,着色器是用高级语言编写的,然后编译成一个二进制着色器可执行文件,适合在你的图形硬件上运行。着色器程序可以分为三种不同类型:顶点着色器、几何着色器和片段着色器。每个着色器程序有不同的作用范围,并且输入和输出不同类型的数据——这些不同的着色器程序可以组合起来形成一个单一的着色器可执行文件。
顶点着色器程序,正如其名称所暗示的那样,在顶点范围内运行——每次执行顶点着色器都可以“看到”一个单独的顶点,它将该顶点转换为屏幕上所需的位置,并计算后续着色器阶段可能需要的任何额外数据。顶点着色器输出经过变换的顶点。
几何着色器在图元阶段运行——它们“看到”一个完整的图元(比如一条线或一个三角形),并输出零个或多个图元。几何着色器通常用于将输入的几何图形“放大”到更高的数据级别,可能基于与相机的距离,这个过程被称为细节层次。输入和输出的图元不需要是相同类型——几何着色器可以将线转换为三角形,反之亦然。
最后一种着色器类型是片段着色器。当裁剪后的三角形被光栅化时,可以确定它们在屏幕上覆盖的潜在像素或片段,并将其发送给片段着色器。片段着色器的作用范围是单个片段,它接收插值后的顶点数据,并输出一个值——片段的颜色,它可以是纹理数据和顶点属性的任意组合。
现代图形硬件有许多核心,数量可达数百个,在多芯片显卡的情况下甚至超过一千个——这些核心中的每一个在任何时候都可以运行一个像素着色器、顶点着色器或片段着色器,有时甚至可以同时运行来自多个可执行文件的着色器。通过这种方式,图形硬件可以在每个时钟周期处理多个顶点,并输出多个片段。
图元
你将在屏幕上渲染的所有对象,无论是一个简单的正方形,还是一整个地牢的走廊和大厅,都是由许多渲染图元组成的。每个图元都是由一系列顶点组成的,这些顶点以各种方式连接在一起——除了最简单的图元类型,它只是一个由不相连的点组成的“云”。这些图元用于形成许多物体的面,有时也被称为表面或小平面。
支持的图元的确切数量是由所选择的渲染应用程序接口(API)决定的——OpenGL 2.0 有下面列出的 9 种图元(以及很少使用的四边形条带),而从 OpenGL 3.0 开始实际上删除了渲染四边形和四边形条带的能力,因为四边形可以很容易地进一步分解为三角形。
首先,让我们看一下线图元。最简单的形式是在每对顶点之间画一条线,而条带形式是在列表中的当前顶点和前一个顶点之间画一条线。循环形式的工作方式与条带类似,但还会在最后一个顶点和第一个顶点之间额外画一条线。
最常见的图元形式是三角形——图形硬件的设计是围绕着尽可能快地渲染三角形。三角形图元只是为每三个顶点绘制一个三角形,而条带形式会为每增加一个顶点绘制一个额外的三角形,使用前两个已定义的顶点作为另一个三角形的角。扇形是条带形式的一种变体,在顶点列表中的第一个顶点总是被用作一个角,当前顶点和前一个顶点完成三角形的绘制。
最后,我们有点、四边形和凸多边形。最后一种类型仅限于凸多边形(即那些没有内角大于 180 度的多边形),因为通过 API 的操作,它们可以很容易地转换为多个三角形,只需在中间添加一个额外的顶点,将其连接到每个点及其相邻点,就像扇形图元那样。
空间
在将几何图形渲染到屏幕的过程中,它会被转换到许多不同的空间中,并且最终通常通过一个变换矩阵从三维坐标“投影”(或展平)为二维屏幕位置。除非另有说明,这些空间是在中的笛卡尔坐标系中定义的,坐标轴从负无穷到正无穷。
局部空间:每当定义几何图形时,无论是在数组或顶点数据中,还是从网格文件中加载,它都是在局部空间中定义的。可以把这想象成一个围绕其定义网格的局部原点——例如,你的“局部原点”就是你站立的位置。
世界空间:为了创建有大量几何图形和动态的场景,我们需要知道物体之间的相对位置。这是通过使用一个“全局”原点来实现的,物体围绕这个原点放置。进一步扩展前面的例子,你的世界空间位置可以是你的纬度、经度和海拔高度。顶点位置通过世界变换从局部空间转换到世界空间。
相机空间:为了能在屏幕上实际绘制顶点,我们需要知道它们相对于我们用来观察场景的虚拟“相机”的位置。这个相机在世界空间中也有一个位置,所以你可以把相机空间想象成以相机为原点。顶点位置可以通过相机变换从世界空间转换到相机空间。最后再举一个关于你的例子:如果出于某种原因,你在一部电影中,并且被告知站在相机前 10 米处,那么你就是在将自己转换为相对于相机的位置。
裁剪空间:一旦顶点位置处于相机空间,我们就可以应用透视扭曲。我们不希望我们的相机有“管状视野”——我们希望有一个视野范围,从镜头以一定角度向外延伸。我们的渲染相机还能做一些“真实相机”做不到的事情——它可以通过近平面和远平面忽略太近或太远的几何图形。一旦顶点处于相机空间,我们可以通过对顶点位置执行另一个变换(即投影变换)来实现这些。你可以把这个阶段想象成调整图形管线的虚拟相机的镜头属性。这会将顶点放置到裁剪空间中——之所以这么叫,是因为在这个阶段之后,可以确定哪些多边形在屏幕上,以及是否需要裁剪以去除不在屏幕上的部分。
透视除法:远处的物体看起来比近处的物体小,但到目前为止,我们的变换还没有考虑到这一点。这是通过被称为透视除法的操作来实现的——你会在教程 2 中明白为什么。现在,只需要知道这会应用使远处物体变小的缩短效果。
归一化设备坐标:一旦执行了这些步骤,顶点数据就处于归一化设备坐标中——图形硬件可以对其进行操作,将你的顶点数据实际光栅化为屏幕上的图像。
屏幕空间:最后,根据视口变换,你的几何图形会出现在屏幕上——这包括渲染窗口在屏幕上的位置及其大小。与其他空间不同,这是一个二维空间,原点在屏幕的一个角上——具体是哪个角取决于图形接口,因为 OpenGL 使用左下角作为原点,而 DirectX 使用左上角作为原点。
APIs
在光栅化图形方面,有两个“大”的应用程序接口——微软的 DirectX 和 OpenGL。OpenGL 最初是由硅图公司开发的,现在由 Khronos 集团管理。它们本质上做的是同一件事,即使用矩阵和着色器在屏幕上光栅化顶点数据。
DirectX
DirectX 于 1995 年末首次发布,此后经历了几个里程碑式的版本——如今最流行的 DirectX 版本是 2002 年发布的 DirectX 9 和 2006 年发布的 DirectX 10。严格来说,DirectX 是一系列应用程序接口,涵盖声音(DirectSound)、网络(DirectPlay)、输入(DirectInput)和图形(Direct2D 和 Direct3D),但“DirectX”这个术语早已成为图形渲染的代名词。一般来说,DirectX 的新版本发布与图形硬件的新功能集相吻合——DirectX 8 是第一个带来着色器功能的版本,DirectX 9 带来了高精度纹理和更完整的着色器语言,而 DirectX 10 则带来了几何着色器。和 DirectX 一样,随着图形硬件的改进,它也在不断发展。
OpenGL
OpenGL 的历史可以追溯到 90 年代初期,当时硅图公司将他们的 IRIS GL 产品的一个子集作为开放标准发布。由于这种开放性,OpenGL 在许多平台上得到了广泛采用,包括像Mesa3D这样的全软件渲染器。与 DirectX 严格定义的功能集不同,OpenGL 支持被称为扩展的特定于供应商的 API 调用,允许在不等待新版本的 OpenGL 发布的情况下公开新的硬件功能。
OpenGL 1.0 定义了基础级别的 OpenGL API 结构,使得顶点能够很容易地被发送到图形硬件进行处理。而 OpenGL 2.0 通过类似 C 语言的 GLSL 语言和 ARB 汇编语言为 OpenGL 标准添加了对着色器的支持。随着 OpenGL 3.0 的形成,GLSL 最终被更新为能够运行几何着色器。OpenGL 3 的另一个重要特性是移除了许多旧的 OpenGL 1 和 2 的功能,并转向完全可编程的图形管线。这是通过定义两个配置文件来实现的——核心配置文件去除了可以在着色器中执行或者不太可能被硬件加速的旧功能,而兼容性配置文件则为遗留应用程序保留了旧 API 的部分功能。
OpenGL 4.0 API 的引入使 OpenGL 与 DirectX 11 在功能上达到同等水平,通过细分控制着色器实现了硬件细分的使用。它还包括诸如每个像素计数器和数据字节打包以提高带宽等功能。
选哪个API?
如今,OpenGL 和 DirectX 在功能集方面非常相似,然而只有 OpenGL 是真正的跨平台的。Windows、Linux 和 PlayStation 3 都使用(或可以使用)某种类型的 OpenGL,而 DirectX 仅限于 Windows 和 Xbox。大多数移动设备以及最近的网络浏览器也通过 OpenGL 的一个子集(称为 OpenGL ES)支持硬件加速图形渲染,这使得 OpenGL 成为有抱负的图形程序员的一个明显起点。在本教程系列中,将通过 OpenGL—— 更具体地说是通过其核心配置文件的 OpenGL 3 来介绍图形编程。这允许通过着色器进行高度的图形编程,消除了对许多 “遗留” OpenGL API 调用的需求 —— 并确保你学习图形编程的理论,而不是严格意义上的 OpenGL 编程。但是为什么我们在这个教程系列中不使用 OpenGL 4 呢?快速查看一下 Steam 硬件调查会发现,只有 5.56% 的用户拥有兼容 OpenGL 4 的显卡 。
OpenGL 渲染管线
OpenGL 通过将图形渲染命令流一个接一个地按照你调用 API 函数的顺序发送到你的图形硬件来工作。下面是一个经典的 OpenGL 1.x 编程示例。
glColor3f (1.0 ,0.0 ,0.0) // 将绘制颜色设置为红色
glBegin ( GL_TRIANGLES ) // 让我们画一个三角形图元!
glVertex2f (0.0 , 0.0) // 左
glVertex2f (1.0 , 0.0) // 右
glVertex2f (0.5 , 1.0) // 上
glEnd () // 现在已经有足够的三角形了...... ...
glBindTexture (GL_TEXTURE_2D , 1) // 使用纹理贴图进行绘制
glBegin (GL_TRIANGLES) // 让我们画一个三角形图元…… ...
glVertex2f (10.0 , 0.0)
glVertex2f (11.0 , 0.0)
glVertex2f (10.5 , 1.0)
glEnd ()
这将告诉 OpenGL 以红色绘制东西(第 1 行),然后开始绘制三角形,传递 3 个顶点以在屏幕上渲染。然后,绑定一个纹理,并绘制另一个三角形。需要注意的是,在 OpenGL 中,API 调用不是“阻塞”的,所以 glEnd API 调用不会在三角形绘制完成之前“等待”并返回。这些命令只是被“推”到一个先进先出(FIFO)队列中,图形卡驱动程序会一个接一个地“弹出”这些命令并将其发送到硬件——绘图和状态更改调用保证以正确的顺序发送到图形硬件。OpenGL 作为一个巨大的“状态机”工作——诸如颜色设置等状态更改会一直“设置”着,直到另一个命令更改相同的状态。以上面的代码示例为例——由于之前的状态更改调用 glColor3f,第二个三角形实际上将以红色绘制。如果绘制第三个三角形,它也将是红色的,并且会应用一个纹理,除非颜色或纹理状态被更改。
但是 OpenGL 和你的图形硬件如何解释这些输入命令呢?它们是如何变成最终图像的呢?嗯,与在屏幕上绘制顶点直接相关的输入命令会被推送到图形管线中,这是一组根据它们接收到的输入命令对图形数据进行处理的阶段。在较旧版本的 OpenGL 中,这个图形管线对每个图形操作都有一个阶段——有一个阶段用于丢弃低于特定透明度值的片段,添加雾值等等。包括本教程系列中使用的 OpenGL 3.2 在内的较新版本摒弃了这种“固定功能”管线,大部分功能设置在可编程着色器中。OpenGL 3+图形管线有以下阶段:
用于渲染当前对象的顶点先由当前设置的顶点着色器进行处理,然后被组合成图元组。这些图元随后可以由几何着色器进行增强或修改,如果它们在屏幕外则被剔除,如果部分在屏幕上则被裁剪。接着,实际的光栅化过程发生,确定一个图元在屏幕上占据哪些像素,并运行片段着色器来确定它在屏幕上的最终颜色。
片段着色器完成后,进行着色器后处理,例如模板测试(将绘制限制在某些像素上)、深度测试(仅当当前图元在屏幕上已有的内容前面时才绘制到当前片段)和 alpha 混合(混合透明物体的颜色),并将最终值(如果有)写入屏幕缓冲区。
值得注意的是一个可选阶段,即反馈缓冲区(feedback buffer)。这允许将顶点的变换后状态保存到数据缓冲区中。这很有用,既可以用于调试目的,也可以在静态场景中通过在多个帧中不使用复杂的顶点着色器来节省处理时间。
裁剪
前面提到过,三角形可能需要进行裁剪以去除不可见的部分。这是为了降低将三角形光栅化到屏幕上最终图像的成本。想象一个三角形,每条边都有一百英里长,但只有一个小角在屏幕上可见——尽管在屏幕上永远看不到,但会处理大量的三角形表面积。为了缓解这个问题,那些顶点位于屏幕边缘之外但在屏幕上仍然可见的三角形会被切割成完全适合屏幕的较小三角形。有许多不同的三角形裁剪算法,其中许多可以处理任意的裁剪区域和多边形,但最常见的是被称为 Sutherland-Hodgeman 裁剪的算法。这个方法依次处理屏幕的每条边,并“切割”要裁剪的多边形的边,根据四个简单的情况规则将顶点添加到一个列表中。
情况 A:如果当前多边形边的两个顶点都在当前边的外部,那么这两个顶点都不会被添加到输出列表中。
情况 B:如果两个顶点都在当前裁剪边的内部,将顶点 b 添加到输出列表中。
情况 C:如果顶点 a 在裁剪边外部,顶点 b 在裁剪边内部,计算出交点顶点 i,然后将顶点 i 和顶点 b 添加到输出列表中。
情况 D:如果顶点 a 在裁剪边内部,顶点 b 在裁剪边外部,计算出交点顶点 i,并将其添加到输出列表中。
输出列表中剩下的顶点接着针对下一条屏幕边进行裁剪,如此进行下去,直到只剩下在屏幕上可见的已裁剪边。
这里有一个示例来展示 Sutherland-Hodgeman 如何将一个三角形裁剪到屏幕边缘:
首先,屏幕的上边裁剪三角形的三条边,得到一个梯形。这个形状接着被屏幕的右边裁剪为一个更简单的四边形形状,这个四边形完全在屏幕的底边和左边之内。这个形状可以很容易地被三角化为两个三角形。
插值
管线的片段着色器阶段使用顶点数据作为其输入——但是为了生成正确的图像,这个数据在导致特定片段的顶点之间进行插值。特定顶点的所有顶点数据属性都进行插值——位置、颜色以及任何其他顶点数据都进行插值以生成最终图像。例如,想象一条由两个顶点组成的简单线条——一个是红色的,一个是绿色的。当这条线被光栅化时,这条线会像这样创建片段:
随着光栅化的片段越来越接近线的末端,它们受绿色顶点的影响逐渐增大,而受红色顶点的影响逐渐减小。沿着一条线对顶点属性值进行插值是很容易计算的。例如,想象我们有两个顶点 和 ,并且我们想要找到点 的位置, 正好在 和 之间的中点。
我们可以这样找到 的 和 值:
其中(希腊字母 Mu)是一个介于 0 和 1 之间的值——在这种情况下,我们想要找到 v0 和 v1 之间正好中点的位置,所以使用,得到 v2 的位置为(15, 2.5)。这是重心坐标的一个例子,它定义了一系列加权点(在这种情况下是顶点位置),由系数加权,这些系数总和为 1(在这种情况下是和)。
插值也可以在其他图元数据上进行。例如,这里有一个三角形,有红色、绿色和蓝色顶点:
从这些顶点出发,我们可以再次使用重心坐标来算出三角形内任意一点的确切插值颜色。这一次,我们不用 μ,而是可以使用重心权重 α、β 和 γ(分别称为 Alpha、Beta 和 Gamma)来为点 vx 找到一个插值的值。
但是 α、β 和 γ 的值是多少呢?对于任何一个三角形 t 以及一个点 vx(我们想要为其找到插值的值的位置),我们可以把三角形分成 3 个“子三角形”t0、t1 和 t2。从这些子三角形中,α、β 和 γ 可以通过它们的面积与原始三角形 t 的面积的比例来算出。
缓冲
在进行渲染时,你并不是直接访问屏幕;而是将内容渲染到一个缓冲区中——一块足够大的内存区域,能够为每个像素存储足够的信息。根据渲染技术的不同,你可能有一个缓冲区,也可能有多个缓冲区——通常是两个。在单缓冲中,缓冲区的内容会不断地发送到屏幕上(这被称为位块传输)。这会带来一个问题——当你渲染新的对象时,你发送到屏幕上的数据会不断地被更新。这样的副作用是你会看到屏幕闪烁,因为随着新对象被渲染或者屏幕被清空,缓冲区的颜色数据会发生变化。
解决这个问题的方法是使用多个缓冲区。当使用多个缓冲区时,一个缓冲区用于绘制,而另一个缓冲区被位块传输到屏幕上。然后,当一帧的绘制完成后,这些缓冲区会被翻转,一次性将整个渲染好的场景呈现给位块传输器,并提供另一个缓冲区用于绘制。这样做会将屏幕的更新延迟一帧——你总是会看到上一帧的渲染结果。在这样的多缓冲技术中,正在屏幕上被渲染的缓冲区被称为前台缓冲区,而正在被绘制的缓冲区被称为后台缓冲区。
双缓冲的一个例子——使用两个缓冲区的多缓冲。在帧 A 中,蓝色缓冲区用于绘制,而红色缓冲区被位块传输到屏幕上。在下一帧中,缓冲区被翻转,使得蓝色缓冲区呈现在屏幕上,而红色缓冲区用于绘制。在接下来的一帧中,缓冲区会再次被翻转,以此类推。
垂直同步
当一帧的渲染完成后,缓冲区被交换,屏幕从新的前台缓冲区更新。然而,如果缓冲区的翻转速度比显示器的刷新率快会怎么样呢?计算机显示器每秒只能刷新图像一定的次数,通常在每秒 60 到 120 次之间。如果一个显示器每秒刷新图像 60 次,而渲染更新的翻转速率与此不同步(即它的渲染速度比 60 帧每秒快或慢),就会出现屏幕撕裂现象。如果物体在屏幕上以这种不同步的更新方式移动,它们将看起来像是“撕裂”了,出现在屏幕上的多个位置。解决这个问题的方法是使用“垂直同步”,在这种情况下,缓冲区之间的翻转会被延迟,直到前一个缓冲区的所有内容都完成。这将帧率限制为屏幕刷新率的倍数,但可以防止撕裂,所以许多应用程序编程接口(API)默认启用这种垂直同步功能。