计算机图形学基础-01 引言

《Fundamentals of Computer Graphics》5th(计算机图形学基础/虎书),中文翻译。

第 1 章 Introduction 引言

计算机图形学 (computer graphics) 一词描述了使用计算机创建和操作图像的任何情形。本书介绍了可用于创建各种图像的算法和数学工具 —— 逼真的视觉效果、信息性技术插图或美丽的计算机动画。图形可以是二维或三维的;图像可以完全由合成产生,也可以通过处理照片生成。本书讨论了图形学相关的基本算法和数学,特别是用于生成三维对象和场景的合成图像所使用的算法和数学。

在实际中,学习计算机图形学不可避免地需要了解特定硬件、文件格式以及通常一个或两个图形 API(见第 1.3 节)。计算机图形学是一个快速发展的领域,因此这方面的知识迭代也很快。因此,在本书中,我们尽力避免依赖于任何特定的硬件或 API。对于实际操作中涉及的软件和硬件环境,可能每个读者不完全相同,大家可自行阅读相关的文档。幸运的是,计算机图形学已经有了足够的标准术语和概念,使得本书中的讨论可以很好地适应大多数环境。

API:应用程序接口。

本章定义了一些基本术语并提供了一些历史背景以及与计算机图形学相关的信息来源。

1.1 图形学领域

对任何领域都强行分类是危险的,但大多数图形学从业者普遍认为计算机图形学包含了以下几个主要领域:

  • 建模 (Modeling):处理如何以代码的方式对物体的形状和外观属性进行数学规范。例如,一个咖啡杯可以描述为一组有序的三维点,以及一些插值规则来如何连接这些点和描述光线如何与杯子相互作用的反射模型等。
  • 渲染 (Rendering):根据计算机制作的三维模型来创建带有阴影的图像,这是从艺术 (art) 中借用的术语。
  • 动画 (Animation):是通过图像序列化的形式创建运动幻觉的一门技术。动画也会使用建模和渲染,但增加了关键的时间运动问题,这个问题通常不在基本建模和渲染中处理。

还有许多涉及计算机图形学的其他领域,它们是否属于图形学核心领域目前存在争议。这些领域都会在本书中或多或少的被提及。此类相关领域包括:

  • 用户交互 (User interaction):涉及输入设备(如鼠标和平板电脑)、应用程序、图像反馈以及其他感官反馈之间的接口。历史上,这个领域与图形学联系紧密,因为现在广泛使用的输入/输出设备最早时候只有图形学研究者才能接触到。
  • 虚拟现实 (Virtual reality):试图将用户沉浸到三维虚拟世界中。这通常需要至少立体图形以及对头部运动的响应。对于真正的虚拟现实,还应提供声音和力反馈。由于这个领域需要先进的三维图形和先进显示技术,因此通常与图形学密切相关。
  • 可视化 (Visualization):试图通过视觉展示帮助用户了解复杂的信息。通常,在可视化问题中需要解决一些图形问题。
  • 图像处理 (Image processing):处理 2D 图像的操作,它在图形学和视觉领域都被使用。
  • 三维扫描 (Three-dimensional scanning):使用测距技术创建测量的 3D 模型。这样的模型对于创建丰富的视觉图像非常有用,处理这样的模型通常需要使用图形学算法。
  • 计算摄影 (Computational photography):是利用计算机图形学、计算机视觉和图像处理方法来实现以新的方式拍摄物体、场景和环境的技术。

1.2 主要应用领域

几乎任何行业都会用到计算机图形学,但主要应用在以下行业:

  • 电子游戏 (Video games) 越来越多地使用复杂的 3D 模型和渲染算法。
  • 卡通 (Cartoons) 通常直接从 3D 模型渲染。许多传统的 2D 卡通使用从 3D 模型渲染的背景,这允许连续移动视点而不需要大量艺术家的时间。
  • 视觉效果 (Visual effects) 几乎使用所有类型的计算机图形学技术。几乎每部现代电影都使用数字合成将分别拍摄的前景和背景叠加在一起。许多电影还使用 3D 建模和动画创建合成环境、物体甚至角色,大多数观众都不会怀疑它们不是真实的。
  • 动画电影 (Animated films) 使用许多与视觉效果相同的技术,但不一定旨在创造看起来真实的图像。
  • CAD/CAM 代表计算机辅助设计和计算机辅助制造。这些领域利用计算机技术在计算机上设计零件和产品,然后使用这些虚拟设计指导制造过程。例如,许多机械零件是在 3D 计算机建模软件中设计,然后通过计算机控制的铣床自动生产出来。
  • 仿真 (Simulation) 可以认为是电子游戏的一个分类。例如,飞行模拟器使用复杂的 3D 图形来模拟驾驶飞机的经验。这样的仿真对于安全关键领域的初步培训(如驾驶)和经验丰富的用户(如某些昂贵或危险的特定灭火情况)的场景培训非常有用。
  • 医学成像 (Medical imaging) 创建扫描患者数据的有意义的图像。例如,计算机断层扫描(CT)数据集由大量的三维密度值数组组成。计算机图形学用于创建阴影图像,以帮助医生从这些数据中提取最重要的信息。
  • 信息可视化 (Information visualization) 创建数据的图像,这些数据不一定具有“自然”的视觉描述。例如,十种不同股票价格的时间趋势没有明显的视觉描述,但巧妙的图形技术可以帮助人们看到这样的数据中的模式。

1.3 图形 API

使用图形库的关键部分是处理图形 API。应用程序接口 (API) 是一组标准函数的集合,用来是计算机执行一组相关操作;图形 API 是一组函数,用于在屏幕上的窗口中绘制图像和 3D 模型等基本操作。

每个图形程序都需要能够使用两个相关的 API:用于可视输出的图形 API 和用于从用户获取输入的用户界面 API。目前有两种主流的图形和用户界面 API 范例。第一种是集成方法,例如 Java,其中图形和用户界面工具包是集成和可移植的软件包,作为语言的一部分完全标准化和支持。第二种是由 Direct3D 和 OpenGL 代表的方法,其中绘图命令是软件库的一部分,与诸如 C++之类的语言绑定,而用户界面软件则是独立的实体,可能因系统而异。在后一种方法中,编写可移植代码可能存在兼容性问题,尽管对于简单的程序,可能可以使用可移植库层来封装系统特定的用户界面代码。

无论你选择哪种 API,基本的图形调用过程都大致相同,本书所列举的概念也都适用。

1.4 图形管线 (graphics pipeline)

现在每台计算机都有一个功能强大的 3D 图形管线。这是一个特殊的软件/硬件子系统,可以有效地以透视方式绘制 3D 基元 (primitives)。通常,这些系统被优化为处理具有共享顶点的 3D 三角形。管线中的基本操作将 3D 顶点位置映射到 2D 屏幕位置并对三角形进行着色,使其既看起来真实又呈现适当的前后顺序。

对于图像的深度问题,曾经人们会按照从后到前的顺序依次绘制三角形,但现在最常用的则是使用 z-buffer 方式,z-buffer 使用特殊的内存缓冲区以暴力计算的方式解决图像深度问题。

事实证明,在图形管线中使用的几何操作几乎可以完全在一个 4D 坐标空间(由三个传统几何坐标和第四个齐次坐标组成)中完成。这些 4D 坐标可以使用 4×44×4 矩阵和四维向量来进行操纵。因此,图形管线包含了许多用于高效处理和组合这些矩阵和向量的机制。这个 4D 坐标系统是计算机科学中使用的最微妙和最美丽的构造之一,它也是学习计算机图形学时的最大智力障碍,几乎每本图形相关书籍的第一部分都会涉及这方面的知识。

生成图像的速度取决于正在绘制的三角形数量。由于交互性在许多应用程序中比视觉质量更重要,因此人们更倾向于将用于表示模型的三角形数量最小化。此外,如果从远处查看模型,则需要较少的三角形(只需要展示大概轮廓),而从更近的距离查看模型则需要更多的三角形,这种按照级别展示不同模型的技术在图形学中叫做 LOD。

1.5 数值问题

许多图形程序实际上只是 3D 数值代码 (numerical codes)。在这些程序中,数值问题通常非常关键。在“旧时代”,要以健壮且可移植的方式处理此类问题非常困难,因为机器对数字具有不同的内部表示,并且更糟糕的是,以不同且不兼容的方式处理异常。幸运的是,几乎所有现代计算机都符合 IEEE 浮点标准 (IEEE Standards Association, 1985)。这使得程序员可以对某些数字条件将如何处理做出许多方便的假设。

虽然 IEEE 浮点在编码数字算法时具有许多有价值的功能,但在图形学中遇到的大多数情况下,只有一些功能是至关重要的。首先,也是最重要的一点,就是要了解在 IEEE 浮点中有三个“特殊”的实数值:

  • 无穷大 (\infin):这是一个有效的数字,比所有其他有效数字都大。
  • 负无穷大 (-\infin):这是一个有效的数字,比所有其他有效数字都小。
  • 非数字 (NaNNaN):这是一个无效的数字,会在进行无效的数学操作时产生,例如零除以零。

IEEE 浮点的设计者做出了一些极其方便的决策,涉及上述三个特殊值,用于处理诸如除以零等异常情况。在这些情况下,会记录异常,但在许多情况下,程序员可以忽略它。具体而言,对于任何正实数 a,以下涉及除以无限值的规则适用:
+a/(+)=+0+a/(+\infin) = +0
a/(+)=0-a/(+\infin) = -0
+a/()=0+a/(-\infin) = -0
a/()=+0-a/(-\infin) = +0

涉及无限值的其他操作的行为符合预期。再次以正 a 为例,行为如下:

  • +=+\infin+\infin = +\infin
  • =NaN\infin-\infin = NaN
  • ×=\infin×\infin = \infin
  • /=NaN\infin/\infin = NaN
  • /a=\infin/a = \infin
  • /0=\infin/0 = \infin
  • 0/0=NaN0/0 = NaN

涉及无限值的布尔表达式的规则与预期相符:

  • 所有有限有效数字均小于 ++\infin
  • 所有有限有效数字均大于 -\infin
  • -\infin 小于 ++\infin

包含 NaN 值的表达式的规则很简单:

  • 包括 NaN 的任何算术表达式结果为 NaN。
  • 包括 NaN 的任何布尔表达式为 false。

IEEE 浮点最有用的一点可能是如何处理除以零;对于任何正实数 a,以下涉及除以零值的规则适用:

  • +a/+0=++a/+0 = +\infin
  • a/+0=-a/+0 = \infin

如果可能出现负零 (0-0),则必须小心处理。

如果程序员利用 IEEE 规则,许多数值计算将变得更加简单。例如,考虑表达式:

a=11b+1ca=\frac{1}{\frac1b + \frac1c}

这样的表达式与电阻器和透镜相关。如果除以零导致程序崩溃(在许多 IEEE 浮点之前的系统中是真的),则需要两个 if 语句来检查 b 或 c 的小值或零值。相反,使用 IEEE 浮点,如果 b 或 c 为零,则我们将得到所需的零值 a。另一种避免特殊检查的常见技术是利用 NaN\text{NaN} 的布尔属性。考虑以下代码段:

1
2
3
a = f(x)
if (a > 0) then
do something

在这里,函数 ff 可能返回诸如 \infinNaN\text{NaN} 之类的“丑陋”值,但是 if 条件仍然是明确定义的:对于 a=NaNa = \text{NaN}a=a = -\infin,它为 false,并且对于 a=+a = +\infin,它为 true。通过仔细决定返回哪些值,通常 if 可以做出正确的选择,而不需要特殊检查。这使得程序更小、更健壮和更高效。

1.6 效率

世界上没有平白无故就能使代码更高效的魔法,计算机程序的效率是通过微妙的权衡方式实现的,而这些权衡方式对于不同的架构采取的方式也不同。然而,在可预见的未来,一个好的实践是,相比于操作计数,程序员应该更关注内存访问模式。这与 20 年前的最佳实践相反。这种转变是因为内存速度的发展没有跟上处理器速度的步伐。由于这种趋势持续存在,优化有限且一致的内存访问的重要性只会越来越重要。

使代码快速的合理方法是按以下顺序进行,仅采取所需的步骤:

  1. 以最直接的方式编写代码。根据需要实时计算中间结果而不是存储它们。
  2. 在优化模式下进行编译。
  3. 使用任何存在的分析工具查找关键瓶颈。
  4. 检查数据结构以寻找改进本地性的方法。如果可能,将数据单元大小与目标架构上的缓存/页大小匹配。
  5. 如果分析显示数字计算中存在瓶颈,请检查编译器生成的汇编代码是否存在效率问题。重写源代码以解决任何问题。

其中最重要的步骤是第一步。大多数“优化”只会使代码更难阅读而不是提高运行速度。此外,花费时间优化代码通常比修正错误或添加功能更好。此外,要小心旧文本中的建议;一些经典技巧,例如使用整数代替实数,可能不再产生速度优势,因为现代 CPU 通常可以像执行整数操作一样快速执行浮点数操作。在所有情况下,都需要进行分析以确保特定机器和编译器的任何可优化的点。

1.7 设计和编写图形程序

在图形编程中,通常有一些常见的好用的方法。在本节中,我们提供一些建议,希望您在实现本书中学到的方法时会发现它们有用。

我强烈坚信 KISS(“保持简单,愚蠢”)原则,在这种情况下,使用两个类的论点不足以证明增加的复杂性。—— P.S.

1.7.1 类设计 (Class Design)

任何图形程序的关键部分是拥有良好的几何实体类或例程,例如向量和矩阵,以及图形实体类,例如 RGB 颜色和图像。这些例程应尽可能清洁和高效。一个通用的设计问题是位置和位移是否应该作为单独的类,因为它们具有不同的操作;例如,将位置乘以一半在几何意义上没有意义,而位移的一半则有 (Goldman,1985; DeRose,1989)。对于这个问题,大家意见不一,这可能会在图形实践者中引发数小时的激烈辩论,但为了举例说明,让我们假设我们不会进行区分。

我喜欢将点和向量分开,因为这使得代码更易读,并且可以让编译器捕获一些错误。—— S.M.

这意味着要编写一些基本的类,包括

  • vector2. 一个二维向量类,存储 xx-yy- 分量。应该将这些分量存储在长度为 2 的数组中,以便支持索引运算符。类中还应包括向量加法、向量减法、点积、叉积、标量乘法和标量除法的操作。
  • vector3. 与 vector2 类似的 3D 向量类。
  • hvector. 具有四个分量的齐次向量(参见第 8 章)。
  • rgb. 存储三个分量的 RGB 颜色。您还应包括 RGB 加法、RGB 减法、RGB 乘法、标量乘法和标量除法的操作。
  • transform. 用于转换的 4×44×4 矩阵。您应包括矩阵乘法和应用于位置、方向和表面法向量的成员函数。如第 7 章所示,它们都是不同的。
  • image. 具有输出操作的 RGB 像素的 2D 数组。

您还可以考虑为单位长度向量添加特殊的类,虽然我发现它们比价值更痛苦。-P.S.

此外,您可能需要添加间隔、正交基和坐标框架的类,也可能不需要。

1.7.2 浮点数 vs. 双精度浮点数

现代架构建议将内存使用保持在较低水平并维护一致的内存访问是提高效率的关键。这表明使用单精度数据。然而,如果为了避免数值问题,则建议使用双精度算术。如何权衡取决于程序,但在类定义中最好具有默认值。

我建议在几何计算中使用双精度,而在颜色计算中使用单精度。对于占用大量内存的数据,例如三角形网格,我建议存储浮点数据,但在通过成员函数访问数据时转换为双精度。—— P.S.

1.7.3 调试图形程序

如果你问周围的人,可能会发现随着程序员经验的增加,他们使用传统调试器的频率越来越少。其中一个原因是,对于复杂程序而言,使用此类调试器比简单程序更麻烦。另一个原因是最困难的错误是概念性的,即正在实现错误的内容,很容易花费大量时间调试变量值而未检测到此类情况。我们发现在图形中特别有用的几种调试方法。

我主张在发现代码的特定部分需要双精度之前,使用 float 执行所有计算。-S.M.

比较科学的方法

在图形程序中,有一种传统调试的替代方法通常非常有用。它的缺点是它与计算机程序员早期职业生涯所学的内容非常相似,因此如果您采用这种方法,您可能会感到“不道德”:我们创建一个图像并观察其中的问题。然后,我们提出导致问题的假设并对其进行测试。例如,在射线跟踪程序中,我们可能会有许多看起来有些随意的黑色像素。这是大多数人编写射线跟踪器时遇到的经典“阴影暗疮”问题。传统的调试在这里没有帮助;相反,我们必须意识到阴影射线正在击中正在被着色的表面。我们可能会注意到,暗点的颜色是环境颜色,因此缺少直接照明。直接照明可以在阴影中关闭,因此您可能会假设这些点被错误地标记为在阴影中当它们不是。为了测试这个假设,我们可以关闭阴影检查并重新编译。这将表明这些是虚假的阴影测试,我们可以继续我们的侦探工作。这种方法有时成为良好实践的关键原因在于我们从未必须发现错误值或真正确定我们的概念错误。相反,我们只是通过实验逐渐缩小了我们的概念误差。通常,只需要进行少量尝试即可追踪问题,并且这种类型的调试非常有趣。

将图像作为编码调试输出

在许多情况下,从图形程序中获取调试信息最简单的通道就是输出图像本身。如果要知道某个变量的值在运行每个像素的计算部分时,请暂时修改程序,直接将该值复制到输出图像中,并跳过通常要执行的其余计算。例如,如果怀疑表面法向量存在问题导致着色出现问题,则可以直接将法向量复制到图像中(x 转为红色,y 转为绿色,z 转为蓝色),从而得到彩色编码的向量实际上在计算中使用。或者,如果怀疑特定值有时超出其有效范围,请让程序在发生这种情况时写入鲜红色像素。其他常见技巧包括以明显颜色绘制表面的背面(当它们不应该可见时),按对象的 ID 号码对图像进行着色,或按计算所需的工作量对像素进行着色。

使用调试器

仍然存在一些情况,特别是当科学方法似乎导致矛盾时,没有替代观察确切发生了什么的方法。问题在于图形程序通常涉及同一段代码的许多次执行(例如,每个像素一次或每个三角形一次),这使得从开始处在调试器中逐步执行完全不可行。而且最困难的 Bug 通常只会在复杂输入的情况下出现。

一种有用的方法是为 Bug “设置一个陷阱”。首先,确保您的程序是确定性的——在一个线程中运行它,并确保所有的随机数都是从固定的种子计算出来的。然后找出哪个像素或三角形出现了问题,并在您怀疑不正确的代码之前添加一个语句,该语句仅在怀疑的情况下执行。例如,如果您发现像素 (126,247)(126,247) 出现了问题,则添加

1
2
if x = 126 and y = 247 then
print "blarg!"

使用固定的随机数种子的特殊调试模式非常有用。

如果在 print 语句上设置断点,则可以在计算您感兴趣的像素之前进入调试器。一些调试器具有“条件断点”功能,可以在不修改代码的情况下实现相同的功能。

对于程序崩溃的情况,传统的调试器有助于确定崩溃位置。然后,您应该通过使用 assert 和重新编译向程序回溯,找出程序出错的位置。这些 asserts 应该留在程序中,以便未来可能出现的 Bug。这再次意味着避免了传统的逐步过程,因为那样将不会为您的程序添加有价值的 asserts。

数据可视化调试

通常,很难理解您的程序在做什么,因为它在最终出错之前计算了许多中间结果。情况类似于测量大量数据的科学实验,并且解决方案是相同的:为自己制作良好的图表和插图,以理解数据的含义。例如,在射线跟踪器中,您可能会编写代码来可视化射线树,以便您可以看到哪些路径对像素做出了贡献,或者在图像重采样例程中,您可能会制作显示从输入中取样点的所有点的图。编写用于可视化程序内部状态的代码所花费的时间也将在优化程序时更好地理解其行为时得到回报。

我喜欢格式化调试打印语句,使输出恰好成为一个 MATLAB®或 Gnu-plot 脚本,从而生成有用的图表。——S.M.

注释

软件工程的讨论受到《Effective C++》系列(Meyers,1995、1997)、《Extreme Programming》(Beck 和 Andres,2004)和《The Practice of Programming》(Kernighan 和 Pike,1999)的影响。实验调试的讨论基于与 Steve Parker 的讨论。

与计算机图形相关的一些年度会议包括 ACM SIGGRAPH 和 SIGGRAPH Asia、Graphics Interface、游戏开发者大会(GDC)、Eurographics、Pacific Graphics、High Performance Graphics、Eurographics 渲染研讨会和 IEEE VisWeek 等。这些会议可以通过网络搜索它们的名称轻松找到。