这个系列是阅读《Fundamentals of Computer Graphics》(第五版)时所作的笔记,该书由 Steve Marschner 和 Peter Shirley 所作,笔者会对书中重要的部分进行翻译。
关于封面
封面的老虎源于 1998 年 Alain Fournier(1943-2000)在康奈尔大学一场研讨会上的讲话,他将自己的观点总结为:
“尽管过去 35 年间,计算机图形学在建模和渲染方面已经有了长足的进步,但我们仍无法‘自动地’、精细地刻画出猛虎过江这一壮观场面。这里,‘自动地’意指不需要艺术家仔细地手动调整。
坏消息是我们仍有很长的路要走。
好消息是我们仍有很长的路要走。”
本章导语
任何使用计算机生成、操纵图像的应用场景均属于计算机图形学(computer graphics)的范畴。本书介绍了生成图像所用到的算法和数学工具。图形学可以是二维的,也可以是三维的;图像可以完全是合成的,也可以是操纵图片生成的。
实际上,做计算机图形学必不可免地需要知道一些硬件、文件格式、图形学 API 的知识。伴随着计算机图形学这一领域的快速发展,这些知识也随之改变。因此,本书尽可能地避免依赖任何硬件或 API。所幸计算机图形学圈子里已经形成了足够标准的术语和概念,使得本书中的讨论适用于大多数场景。
图形学研究领域及应用场景
大多数相关从业人员都会认同下面这些计算机图形学领域:
- 建模(Modeling):以计算机可以存储的方式对形状和外观进行数学描述。
- 渲染(Rendering):根据 3D 计算机模型生成染色后的图像。
- 动画(Animation):通过图像序列来产生运动的幻觉,动画是在建模和渲染的基础上添加了随时间运动。
此外,还有一些领域也涉及到计算机图形学,如:用户交互、虚拟现实、可视化、图像处理、3D 扫描、计算摄影等。
计算机图形学的主要应用场景有:游戏、卡通、视觉效果、动画电影、CAD/CAM、模拟、医学成像、信息可视化等。
图形学 APIs
图形学 API 就是一组函数,用来执行一些基本操作,如:将图像和 3D 曲面绘制到屏幕上。
每个图形学程序都需要使用两类 API:一类是图形学 API,用于视觉输出;另一类是用户交互 API,用于获取用户输入。关于这两类 API,目前有两种主流范式:一种是将图形学 API 和用户交互 API 集成起来,如:Java;另一种是将图形学 API 作为软件库的一部分绑定到某种语言,如:C++,而用户交互 API 则依赖于系统。
不管是哪种 API,基础的图形学调用大多相同,本书的概念也都适用。
图形流水线(Graphics Pipeline)
如今的每台计算机都拥有一条强大的 3D 图形流水线。它是一个特殊的硬软件子系统,用于高效地绘制 3D 基础图元。为了处理具有公共顶点(vertice)的 3D 三角形,这些系统通常已经做了优化。流水线上的基本操作是把 3D 顶点映射到 2D 屏幕上并给三角形染色,使得它们既看起来真实,又能呈现正确的前后顺序。
尽管以正确的前后顺序绘制三角形曾是计算机图形学中最重要的研究课题,但现在几乎总是使用 z-buffer 来解决这一问题。
事实证明,图形流水线中使用的几何操作基本都可以在一个 4D 空间中完成,该空间由三个常规的几何坐标和第四个用于透视的齐次坐标(homogeneous coordinate)构成。这些 4D 坐标使用 4 阶方阵和 4 维矢量来处理。因此,图形流水线中包含很多装置,用于高效地处理这些矩阵和矢量。这个 4D 坐标系是计算机图形学中最精妙的构造之一,它可能也是学习计算机图形学时最大的障碍。每本图形学书籍的第一部分都有一大块内容讲解这些坐标。
生成图像的速度强依赖于需要绘制的三角形数目。由于在许多应用中交互性比视觉质量更重要,因此减少一个模型的三角形数目是有价值的。此外,一个模型位于远处时需要的三角形比位于近处时更少。这意味着,以可变的细节度(level of detail, LOD)来表示模型是有用的。
数值问题
许多图形学程序实际上就是 3D 数值计算代码。在这些程序中,数值问题变得至关重要。在过去,数字表示以及异常处理因设备而异。而现今,几乎所有的计算机都遵从 IEEE 浮点数标准。下面只介绍该标准中对图形学比较重要的部分。
实数的三个特殊值
- 无穷大():合法数字,大于其它所有数字。
- 负无穷大():合法数字,小于其它所有数字。
- 非数字(
NaN):非法数字,结果不确定的运算将返回NaN,如:。
特殊值相关规则
除以无穷大:
其中, 为任意正实数。其它有关无穷大的操作均与常识保持一致,例如:
涉及无穷大的布尔表达式:
- 所有合法数字均小于
- 所有合法数字均大于
- 小于
涉及 NaN 的表达式:
- 任何涉及
NaN的算数表达式,结果均为NaN - 任何涉及
NaN的布尔表达式,结果均为false
除以 :
注意:运算过程中可能产生 ,此时需仔细处理。
IEEE 规则的优势
合理利用 IEEE 规则可以减少对特殊值的检查。
利用 IEEE 规则对无穷大的处理,无需检查下式中的 、 是否接近于 :
利用 IEEE 规则对 NaN 的处理,无需检查下式中 a 是否合法:
a = f(x)
if (a > 0) then
// do something
只需在函数 f 内部合理地决定返回值,就可以省略特殊值检查,这让程序变得更小、更健壮、更高效。
性能
为了提高程序的性能,在可预见的将来,程序员都应该更加注意内存访问的模式,而不是操作的次数。这与 20 年前截然相反。这一转变是因为内存的速度追不上处理器的速度,而这一趋势仍在继续。
执行以下步骤是一个让程序变快的合理方法:
- 尽可能以最直接的方式写代码,实时地按需计算中间结果而不是存储它们;
- 使用优化模式编译代码;
- 使用已有的分析工具(profiling tools)定位性能瓶颈;
- 检查数据结构以寻找改善局部性(locality)的方法;
- 如果分析工具揭示出性能瓶颈源于数值计算,那就检查汇编代码并重写源码。
其中,第一步是最重要的。大部分优化只会让程序更难以读懂,而没有提升性能。此外,事先优化代码所花的时间更应该用在修 bug 或开发新需求上面。再者,当心老教科书上的建议,现代 CPU 做浮点数运算和做整数运算基本上一样快。最后,不管什么时候,都需要分析工具来确定优化所带来的好处。
图形程序设计
类设计
为几何实体(如:向量、矩阵)和图形实体(如:RGB 颜色、图像)定义类(class)在每个图形程序中都很重要。一个常见的设计问题是:位置和位移是否需要两个类?使用两个类可以增加程序的可读性,也可以让计算机捕获一些 bug;另外,即使考虑到 KISS 准则,两个类也没有增加过多的复杂性。但作为示例,我们暂不区分两者。
KISS 准则:keep it simple, stupid(保持简单、直接)
基础类包括:
- vector2:一个存储 x、y 分量的 2D 向量类。以长度为 2 的数组存储这些分量可以支持索引操作。此外还需要支持的操作有:向量加法、向量减法、点乘、叉乘、标量乘除(数乘)。
- vector3:一个类似于 vector2 的 3D 向量类。
- hvector:带有第四个分量的齐次向量。
- rgb:一个存有三个分量的 RGB 颜色。需要支持的操作有:RGB 加法、RGB 减法、RGB 乘法、标量乘除。
- transform:一个用作变换的 4 阶方阵。需要支持的操作有:矩阵乘法以及应用于位置、方向、表面法向量的成员函数。
- image:一个由 RGB 像素构成的、带有输出操作的 2D 数组。
此外,可能还需要为区间(intervals)、正交基(orthonormal bases)、坐标系(coordinate frames)添加相应的类。
单精度与双精度
根据现代计算机架构,降低内存使用和保持一致的内存访问是提高性能的关键。这表明,应该使用单精度浮点数。然而,使用双精度浮点数可以避免数值问题。两者间的权衡取决于程序本身。不管如何选择,最好在类的定义中给一个默认精度。
P.S. 建议几何计算使用双精度,颜色计算使用单精度。对于占据很多内存的数据,如三角网格,建议以单精度存储,成员函数访问时再转为双精度。
S.M. 提倡使用单精度做所有计算,直到发现代码中的某些部分必须使用双精度为止。
调试
科学的方法
生成一个图像,观察哪里出了问题。然后提出假设并进行验证。
这种方法可以奏效的原因在于,我们从来都不需要关注错误的值或是直接地寻找概念错误,而是通过实验对我们概念上的错误缩小范围。通常,只需要一些试验就可以找出问题所在,而这种调试方式也令人愉悦。
将调试结果编码为图像
获取一个图形程序调试信息的最简单的渠道就是输出图像本身。对于那些需要跑遍每个像素的计算,如果想知道计算过程中变量的值,可以直接跳过后续计算,将中间值复制到输出图像上。例如,将表面法向量转化为颜色,用亮红色像素表示溢出值,等等。
其它的技巧还有:用一种显眼的颜色画出曲面的背面,将对象的 ID 数值转化为颜色,将计算所花的工作量转化为颜色。
使用调试器
由于图形程序通常涉及相同代码的多次执行,因此在调试器中从头到尾逐步调试是一种完全不切实际的做法。
一个有用的方法是“捕获” bug。首先,确保程序是确定性的——在单个线程上运行并且所有随机数都是根据一个固定的种子计算出来的。然后,找出有问题的那个像素(或三角形),并在可能有 bug 的代码前加上一行只对该像素(或三角形)执行的语句。接着可以为该语句打断点进行调试。有些调试器的条件断点功能可以实现同样的需求而不用修改代码。
如果程序崩了,可以用传统调试器定位崩的位置。然后在程序中回溯(backtracking),使用断言(asserts)和重编译(recompiles)找出程序从什么地方开始出错的。这些断言应该留在程序中防止将来可能添加 bug。
数据可视化
画一张图来表示程序中的数据可以帮助理解数据的含义。
通过代码对程序内部状态可视化,也帮助我们更好地理解程序的行为,这对后续代码优化有帮助。