软件工作原理-全-
软件工作原理(全)
原文:
zh.annas-archive.org/md5/2614aa32964b64203cb9b99e2db44189译者:飞龙
前言

科幻作家阿瑟·C·克拉克曾说:“任何足够先进的技术都与魔法 indistinguishable。”如果我们不知道某样东西是如何运作的,那它就像是由超自然力量来解释的。按这个标准,我们生活在一个魔法的时代。
软件已经融入了我们的生活,体现在日常事物中,比如在线交易、电影中的特效以及视频流媒体。我们已经忘记了曾经生活在一个问题的答案不只是谷歌搜索的世界,或者找到一条车程路线需要从打开笨重的地图开始的时代。
但很少有人知道这些软件是如何运作的。与过去许多创新不同,你不能拆开软件来看它在做什么。一切都发生在一个计算机芯片上,无论设备是在执行一个惊人的任务,还是根本没有打开。知道一个程序是如何工作的,似乎需要花费多年的学习才能成为程序员。所以我们很多人都认为软件是超出我们理解的,是一群技术精英知道的秘密。但这是错的。
本书适合谁
任何人都可以学习软件是如何工作的。你需要的只是好奇心。无论你是技术的普通爱好者、正在成为程序员的人,还是两者之间的人,这本书都适合你。
本书涵盖了软件中最常用的过程,并且没有一行编程代码。你不需要先前了解计算机是如何运作的。为了实现这一点,我简化了一些过程并省略了一些细节,但这并不意味着这些只是高层次的概述;你将获得真正的核心内容,足够的细节让你真正理解这些程序是如何做到它们所做的事情的。
覆盖的主题
计算机在现代世界中无处不在,我可以涵盖的主题几乎没有尽头。我选择了那些与我们日常生活最相关且最有趣的主题进行讲解。
• 第一章:加密 使我们能够将数据加密,从而只有我们自己能够访问。当你锁定手机或为一个.zip文件设置密码时,你就在使用加密技术。我们将看到现代加密软件如何结合不同的加密技术。
• 第二章:密码 是我们用来锁定数据和识别自己与远程系统的方式。你将看到密码如何在加密中使用,并了解保护密码免受攻击者侵害所需采取的惊人步骤。
• 第三章:网络安全 是我们在线购买商品或访问账户时需要的保障。数据传输时的加密需要一种不同的加密方法,称为公钥加密。你将发现一个安全的网页会话需要前面三章中提到的所有技术。
• 第四章:电影 CGI 是纯粹的软件魔法,通过数学描述创造出整个世界。你将会发现,软件如何接管传统的动画制作,然后学习如何用软件打造完整电影场景的关键概念。
• 第五章:游戏图形 不仅在视觉效果上令人印象深刻,还因为它们是如何在短短的几毫秒内制作完成的。我们将探索游戏中使用的一系列巧妙技巧,当它们没有时间使用上一章讨论的技术时,如何制作出惊人的图像。
• 第六章:数据压缩 通过压缩数据,让我们能够更高效地利用存储空间和带宽限制。我们将探索压缩数据的最佳方法,并进一步了解它们如何被组合起来,以压缩蓝光光盘和网页流中的高清电影。
• 第七章:搜索 是关于如何即时找到数据的,无论是搜索我们自己电脑上的文件,还是在整个网络上进行搜索。我们将探讨如何组织数据以便快速搜索,搜索是如何精准定位到请求的数据的,以及网页搜索如何返回最有用的结果。
• 第八章:并发 让多个程序能够共享数据。如果没有并发,网络游戏就不可能存在,网上银行系统也只能允许一个客户同时进行操作。我们将讨论让不同处理器能够在不干扰彼此的情况下访问相同数据的方法。
• 第九章:地图路线 是我们从地图网站和车载导航系统中获得的即时方向指引。你将会发现软件眼中的地图是怎样的,以及用来寻找最佳路线的专业搜索技巧。
幕后魔法
我认为分享这些知识很重要。我们不应生活在一个自己无法理解的世界里,而如今若不理解软件,理解现代世界几乎变得不可能。克拉克的讯息可以看作是一个警告:那些懂技术的人可以欺骗不懂的人。例如,一家公司可能声称其登录数据的盗窃对客户几乎没有危险。这可能是真的吗?如果是,为什么呢?读完本书后,你将知道如何回答类似的问题。
更重要的是,学习软件如何运作的秘密还有一个更好的理由:因为这些秘密真的很酷。我认为,了解魔术是如何实现的之后,最棒的魔术会变得更加神奇。继续阅读,你就会明白我什么意思。
第一章:1
加密

我们每天都依赖软件来保护我们的数据,但大多数人对这些保护是如何工作的知之甚少。为什么浏览器角落里的“锁”图标意味着可以安全地输入你的信用卡号码?为你的手机创建密码到底是如何保护里面的数据的?到底是什么防止别人登录你的在线账户?
计算机安全是保护数据的科学。从某种程度上来说,计算机安全代表了技术解决技术所带来的问题。不久之前,大多数数据并没有以数字形式存储。我们有办公室里的文件柜和床下的照片鞋盒。当然,那时你不能轻松地与世界各地的朋友分享照片,也不能通过手机查看银行余额,但当时没有人能够在不亲自拿走数据的情况下窃取你的私人信息。今天,不仅可以在远距离被抢劫,而且你可能甚至不知道自己已经被抢劫——直到银行打电话问你为什么要买价值数千美元的礼品卡。
在这前三章中,我们将讨论计算机安全背后的最重要概念。在本章中,我们谈论加密。加密本身为我们提供了将数据锁定的能力,使得只有我们才能解锁它。为了提供我们依赖的完整安全套件,接下来两章中将讨论其他技术,但加密是计算机安全的核心。
加密的目标
想象一下你电脑上的一个文件:它可能包含文本、照片、电子表格、音频或视频。你想访问这个文件,但又想让其他人无法看到。 这是计算机安全的基本问题。为了保持文件的秘密性,你可以使用加密将其转化为一种新的格式,直到通过解密将文件恢复到原始形式之前,它是无法读取的。原始文件是明文(即使文件不是文本),而加密后的文件是密文。
攻击者是试图在未授权的情况下解密密文的人。加密的目标是创建一个对授权用户来说容易解密的密文,同时对攻击者来说几乎不可能解密。“几乎”是安全研究人员头疼的源泉。正如没有任何锁是绝对不可破解的,没有任何加密能够绝对不被解密。只要有足够的时间和计算能力,任何加密方案理论上都可以被破解。计算机安全的目标是让攻击者的工作变得如此困难,以至于成功的攻击在实践中是不可能的,需要超出攻击者能力的计算资源。
我不会直接跳入基于软件的加密复杂性中,而是从一些简单的例子开始,这些例子来自于加密和间谍活动的前软件时代。虽然多年来加密的强度有了巨大的提升,但这些经典技术仍然是所有加密的基础。稍后你将看到这些思想是如何在现代数字加密方案中结合使用的。
转置:相同的数据,不同的顺序
加密数据的最简单方法之一被称为转置,它的意思就是“改变位置”。转置是我和朋友们在小学时用来传递纸条时采用的加密方法。因为这些纸条会通过不可信的手传递,所以确保纸条对我们之外的人是无法理解的至关重要。
为了保持信息的机密性,我们使用一个简单、易于反转的方案重新排列字母的顺序。假设我需要传达一个重要信息:“CATHY LIKES KEITH”(名字已更改以保护无辜)。为了加密该信息,我复制了明文中的每第三个字母(忽略空格)。在第一次操作中,我复制了五个字母,如图 1-1 所示。

图 1-1:样本信息转置的第一次操作
到达信息末尾后,我从头开始,继续选择每第三个剩余字母。第二次操作后,我得到了如图 1-2 所示的结果。

图 1-2:第二次转置操作
在最后一次操作中,我复制了剩余的字母,如图 1-3 所示。

图 1-3:最终的转置操作
得到的密文是 CHISIAYKKTTLEEH。我的朋友们可以通过反向转置过程读取这个信息。第一步如图 1-4 所示。将所有字母恢复到原位后,就能还原出明文。

图 1-4:解密时转置操作的第一次回退
这种基本的转置方法使用起来很有趣,但它的加密强度非常弱。最大的担忧是泄密——我的一个朋友把加密方法透露给了圈外的人。一旦发生这种情况,发送加密信息就不再安全,反而会变得更麻烦。泄密是不可避免的——不仅仅是在学童之间。每种加密方法都会面临泄密的风险,而且使用某种方法的人越多,泄密的可能性就越大。
正因如此,所有好的加密系统都遵循荷兰早期密码学家奥古斯特·凯尔科夫(Auguste Kerckhoffs)提出的一个规则,称为凯尔科夫原则:数据的安全性不应依赖于加密方法本身的保密性。
密码密钥
这引出了一个显而易见的问题。如果加密方法并不是秘密的,那我们如何才能安全地加密数据呢?答案在于遵循一种通用的、公开披露的加密方法,但使用密码钥匙(或简称钥匙)来变化每条消息的加密方式。为了理解什么是钥匙,我们可以先看一个更通用的置换方法。
在这种方法中,发送者和接收者在发送任何消息之前会共享一个秘密数字。假设我和我的朋友们约定使用 374。我们将使用这个数字来改变我们密文中的置换模式。此模式在图 1-5 中展示了消息“CATHY LIKES KEITH”的加密过程。我们秘密数字的每一位都指示应该从明文中复制哪个字母到密文中。因为第一个数字是 3,所以明文中的第三个字母T变成密文的第一个字母。下一个数字是 7,因此下一个字母是从T往后数七个字母,也就是S。接下来我们选择从S开始的第四个字母。密文的前三个字母是TST。
图 1-6 展示了接下来的两个字母如何被复制到密文中。从我们上次停止的地方开始(图中由圆圈标出的 1),我们数三个位置,当到达末尾时返回到明文的开始处,选择A作为密文的第四个字母。接下来选择的字母是从A开始往后数七个位置,跳过已经复制的字母:K。这个过程继续进行,直到所有明文的字母都被置换完成。

图 1-5:使用密钥 374 进行置换的第一步

图 1-6:使用密钥 374 进行置换的第二步
因此,秘密数字 374 就是我们的密码钥匙。即便有人截获了这条消息,如果没有密钥,他们也无法解密,即使他们知道我们使用的是置换加密方法。密码可以定期更换,以防止泄密者或叛徒泄露加密信息。
攻击加密
即使没有密钥,攻击者仍然可以尝试通过其他手段恢复明文。加密数据可以通过暴力破解进行攻击,尝试所有可能的加密方法来处理密文。对于使用置换加密的消息,暴力破解攻击会检查密文的所有排列组合。因为暴力破解几乎总是可行的,所以攻击者需要进行的试验次数是衡量加密强度的一个良好基准。在我们的示例中,消息“CATHY LIKES KEITH”大约有 400 亿种排列方式。
这是一个庞大的数字,因此聪明的攻击者会采用一些常识来加速恢复明文的过程,而不是依赖暴力破解。如果攻击者能假设明文是英文,那么在测试之前,大部分排列组合都可以被排除。例如,攻击者可以假设明文不会以HT开头,因为没有英文单词以这两个字母开头。这样,攻击者就不需要检查十亿种排列组合了。
了解消息中某些单词的攻击者可以更聪明地推断出明文。在我们的例子中,攻击者可能猜测消息中包含某个同学的名字。他们可以查看密文字母中可以组成哪些名字,然后通过剩余字母推测出可能的单词。
关于明文内容的猜测被称为字典攻击。最强的字典攻击形式是已知明文攻击。为了实施这种攻击,攻击者必须能够访问一个明文 A,其对应的密文 A,以及一个使用与密文 A 相同的密钥加密的密文 B。虽然这种情况听起来不太可能,但确实会发生。人们经常在文件不再被视为机密时,将其置于无保护状态,而没有意识到这可能会帮助攻击其他文件。已知明文攻击是非常强大的;当你面前同时有明文和密文时,破解置换模式非常容易。
对抗已知明文攻击的最佳防御措施是良好的安全实践,例如定期更改密码。然而,即使采取了最好的安全实践,攻击者几乎总是能大致了解明文的内容(这也是他们为何如此感兴趣于读取它的原因)。在许多情况下,他们会知道大部分明文内容,并且可能拥有已知明文-密文对。一个好的加密系统应该使得明文和已知的密文对对攻击者无效。
替代:数据替换
另一种基本的加密技术对“字典攻击”更具抵抗力。与其移动数据,不如替代方法有系统地替换数据的各个部分。对于文本消息,最简单的替代形式是将每个字母替换为另一个字母。例如,将每个A替换为D,每个B替换为H,依此类推。该类型加密的密钥如表 1-1 所示。
表 1-1: 一种替代密码密钥
| 原始 | A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | P | Q | R | S | T | U | V | W | X | Y | Z |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 替换 | M | N | B | V | C | X | Z | L | K | F | H | G | J | D | S | A | P | O | I | U | Y | T | R | E | W | Q |
尽管这种方法被称为简单替代,它比置换加密有所改进,但它也有问题:可能的替代方式是有限的,因此攻击者有时可以通过暴力破解来解密密文。
简单的替代加密同样容易受到频率分析的攻击,攻击者通过应用对特定语言中字母或字母组合出现频率的了解来进行攻击。广义而言,了解数据项在明文中出现的频率会给攻击者带来优势。例如,字母E是英语写作中最常见的字母,而TH是最常见的字母组合。因此,在较长的密文中,最常出现的字母很可能代表明文中的E,而最常出现的字母组合则很可能代表明文中的TH。
频率分析的威力意味着,随着文本长度的增加,替代加密变得更加脆弱。当已知一组密文是使用相同密钥加密时,攻击也会变得更加容易;因此,避免密钥重用是一个重要的安全实践。
变化的替代模式
为了加强对频率分析的加密防护,我们可以在加密过程中改变替代模式,因此明文中的第一个E可能会被替换成A,而第二个E则替换成T。这种技术被称为多字母替代。多字母替代的一种方法使用了一个字母表网格,称为塔布拉·雷克塔,如图 1-7 所示。在这个表格中,每一行和每一列都用开始该行或列的字母标记。网格中的每个位置由两个字母定位,例如行 D,列 H,该位置包含字母K。

图 1-7:塔布拉·雷克塔——阴影部分的第一列和第一行是标签。
使用塔布拉·雷克塔时,密钥是文本形式的——使用字母来变化加密,而不是像我们在置换示例中使用的数字。明文的字母选择塔布拉·雷克塔中的行,密钥的字母选择列。例如,假设我们的明文信息是单词SECRET,而我们的加密密钥是单词TOUGH。因为明文的第一个字母是S,而密钥的第一个字母是T,所以密文的第一个字母位于塔布拉·雷克塔的行 S 列 T 的位置:字母L。接着我们使用表格中的 O 列来加密第二个明文字母E(得到S),依此类推,如图 1-8 所示。由于明文比密钥长,我们必须重复使用密钥的第一个字母。

图 1-8:使用塔布拉·雷克塔和密码密钥 TOUGH
解密过程是逆向操作,如图 1-9 所示。密钥中的字母表示列,扫描列以找到对应的密文字母。找到密文字母所在的行即为明文字母。在我们的示例中,密钥的第一个字母是T,密文的第一个字母是L。我们扫描塔布拉矩阵的 T 列找到L;因为L出现在 S 行,所以明文字母是S。这个过程对每个密文字母都重复进行。

图 1-9:使用塔布拉矩阵和密码键的解密 TOUGH
多表替代法比简单替代法更有效,因为它在整个消息中变化替代模式。在我们的示例中,明文中两个E的出现会变成不同的密文字母,而密文中两个L的出现代表了两个不同的明文字母。
密钥扩展
尽管多表替代法相对于简单替代法有了很大改进,但只有当密钥不被过于频繁地重复时,它才有效;否则,它就会和简单替代法一样存在问题。例如,当密钥长度为五时,每个明文字母只能由五个不同的密文字母表示,这使得长密文容易受到频率分析和潜文本的攻击。攻击者需要更加努力地工作,但只要有足够的密文,攻击者仍然能够破解加密。
为了最大程度地提高效果,我们需要与明文等长的加密密钥,这种技术被称为一次性密码本。但对于大多数情况来说,这并不是一个实用的解决方案。相反,一种叫做密钥扩展的方法可以让短密钥完成长密钥的工作。这个思路的一种实现常出现在间谍小说中。两位需要交换信息的间谍并不共享超长的密钥,而是约定使用一个密码书,该密码书作为长密钥的存储库。为了避免引起怀疑,密码书是一本普通的文学作品,比如莎士比亚戏剧的某一版。
假设将发送一个 50 个字母的消息,使用此方案。除了密文,消息发送者还附加上未扩展的密钥。以莎士比亚的作品作为密码书,未扩展的密钥可能是 2.2.4.9。第一个 2 表示莎士比亚的第二部戏剧(按字母顺序排列,如你所愿)。第二个 2 表示第二幕。4 表示该幕的第四场。9 表示该场的第九句话:“当我在家时,我在一个更好的地方,但旅行者必须知足。” 这句话的字母数超过了明文的字母数,可以像之前一样使用塔布拉矩阵进行加密和解密。通过这种方式,一个相对较短的密钥可以扩展到适应特定的消息。
请注意,这种方案不符合一次性密钥的要求,因为密码本是有限的,因此句子密钥最终必须被重复使用。但这意味着我们的间谍在加密消息时,只需记住较短的密码密钥,同时使用更长的密钥来更安全地加密消息。正如你将看到的,密钥扩展概念在计算机加密中非常重要,因为所需的密码密钥非常庞大,但必须以较小的形式存储。
高级加密标准
现在我们已经看到转置、替换和密钥扩展是如何单独工作的,让我们看看如何通过巧妙地结合这三种技术来实现安全的数字加密。
高级加密标准(AES) 是一种开放标准,这意味着任何人都可以在不支付许可费的情况下实现其规范。无论你是否意识到,你的大部分数据都由 AES 保护。如果你在家里或办公室有一个安全的无线网络,如果你曾经在.zip压缩文件中设置过密码,或者如果你在商店使用信用卡或从 ATM 机取款,你可能至少在某种程度上依赖于 AES。
二进制基础
到目前为止,我一直使用文本加密示例来保持示例简单。然而,计算机加密的数据是以二进制数的形式表示的。如果你以前没有处理过这些数字,这里有一个介绍。
十进制与二进制
我们从小就使用的数字系统叫做十进制系统,deci意为“十”,因为该系统使用 10 个数字,从 0 到 9。数字中的每一位表示比右侧数字大 10 倍的单位数量。十进制数字 23,065 的单位和数量如图 1-10 所示。左起第五位的 2 表示我们有 2 个“万”,例如,6 表示 6 个“十”。

图 1-10:十进制数字 23,065 中的每一位代表不同的单位数量。
在二进制数字系统中,只有两个可能的数字,0 或 1,它们被称为位,即binary digits。二进制数字中的每一位代表一个单位,其大小是右边位数的两倍。二进制数字 110101 的单位和数量如图 1-11 所示。如图所示,我们有以下每个单位:32、16、4 和 1。因此,二进制数字 110101 表示这四个单位值的总和,即十进制数字 53。

图 1-11:二进制数字 110101 中的每一位代表不同的单位数量。
二进制数字通常以固定数量的位表示。二进制数最常见的长度是八位,称为字节。虽然十进制数字 53 可以写作 110101 的二进制形式,但将 53 写成字节需要八位,因此前导 0 位填充其他位置,形成 00110101。最小的字节值 00000000 表示十进制 0;最大的字节值 11111111 表示十进制 255。
按位操作
除了常见的数学运算,如加法和乘法,软件还使用一些独特于二进制数字的操作。这些操作被称为按位操作,因为它们是单独应用于每一位,而不是整个二进制数。
按位操作中称为异或(XOR)的操作在加密中很常见。当两个二进制数字进行 XOR 运算时,第二个数字中的 1 位会反转第一个数字中相应的位,如图 1-12 所示。

图 1-12:异或(XOR)操作。第二个字节中的 1 位表示在第一个字节中被“反转”的位,如阴影列所示。
记住,密码学必须是可逆的。XOR 操作通过改变位模式,使得在不知道参与的二进制数字的情况下,难以预测其结果,但它是可以轻松逆转的。将结果与第二个数字进行 XOR 运算,可以将相同的位反转回原始状态,如图 1-13 所示。

图 1-13:如果我们将一个字节与相同的字节进行两次 XOR 运算,结果将回到我们开始的地方。
将数据转换为二进制形式
计算机使用二进制数字表示各种数据。一个纯文本文件可以是文本消息、电子表格、图像、音频文件或其他任何东西——但最终,每个文件都是字节的序列。大多数计算机数据已经是数字形式,因此可以直接转换为二进制数。但在某些情况下,需要一个特殊的编码系统来将非数字数据转换为二进制形式。
例如,要查看一条文本消息是如何转换成一系列字节的,可以考虑以下消息:
Send more money!
该消息包含 16 个字符,计算时包括字母、空格和感叹号。我们可以使用像美国信息交换标准代码(ASCII,发音为“as-key”)这样的系统,将每个字符转换为一个字节。在 ASCII 中,大写字母A表示数字 65,B表示 66,依此类推,到Z的 90。表 1-2 展示了来自 ASCII 表的一些选定条目。
表 1-2:ASCII 表中的部分条目
| 字符 | 十进制数 | 二进制字节 |
|---|---|---|
| (space) | 32 | 00100000 |
| ! | 33 | 00100001 |
| , | 44 | 00101100 |
| . | 46 | 00101110 |
| A | 65 | 01000001 |
| B | 66 | 01000010 |
| C | 67 | 01000011 |
| D | 68 | 01000100 |
| E | 69 | 01000101 |
| a | 97 | 01100001 |
| b | 98 | 01100010 |
| c | 99 | 01100011 |
| d | 100 | 01100100 |
| e | 101 | 01100101 |
AES 加密:大致过程
在我们详细查看 AES 加密之前,这里是该过程的概述。
AES 中的密码密钥是二进制数字。密钥的大小可以变化,但我们将讨论最简单版本的 AES,使用的是 128 位密钥。通过数学密钥扩展,AES 将原始的 128 位密钥转换为 11 个 128 位的密钥。
AES 将明文数据分为 16 字节的块,并以 4×4 网格的形式呈现;示例消息 发送更多钱! 的网格如 图 1-14 所示。重线分隔 16 字节,轻线则分隔字节内部的位。

图 1-14:示例消息 发送更多钱!转化为字节网格,准备使用 AES 进行加密
明文数据被分割为尽可能多的 16 字节块。如果最后一个块不满,剩余部分会使用随机二进制数进行填充。
接着,AES 将每个 16 字节的明文数据块进行 10 轮 加密。在每一轮中,字节会在块内进行置换,并使用表格进行替换。然后,使用 XOR 操作,块内的字节与其他字节及 128 位密钥之一进行组合。
这就是 AES 的基本概念;现在让我们更详细地看看这些步骤。
AES 中的密钥扩展
数字加密系统中的密钥扩展与我们之前讨论的“密码本”概念有所不同。AES 不仅仅是查找书中的一个更长密钥,而是利用它稍后用于加密的相同工具进行密钥扩展:二进制 XOR 操作、置换和简单替换。
图 1-15 展示了密钥扩展过程的前几个阶段。图中的每个块为 32 位,图中的一行代表一个 128 位的密钥。原始的 128 位密钥构成了前四个块,这些块在图中被着色。每个其他块是通过对两个前一个块进行 XOR 运算得到的;XOR 操作用圆圈中的加号表示。例如,块 6 是通过块 2 和块 5 的 XOR 运算得到的。

图 1-15:AES 密钥扩展过程
正如图右侧所示,每四个块中就有一个通过一个标记为“额外扰乱”的盒子。这一过程包括对块内的字节进行置换,并根据一个叫做 S-box 的表进行字节替换。
S-box 表被精心设计,用于加密过程中放大明文之间的差异。也就是说,两个相似的明文字节将趋向于拥有完全不同的 S-box 替代值。表中的前八个条目展示在 表 1-3 中。
表 1-3:S-Box 表中的摘录
| 原始位模式 | 替换位模式 |
|---|---|
| 00000000 | 01100011 |
| 00000001 | 01111100 |
| 00000010 | 01110111 |
| 00000011 | 01111011 |
| 00000100 | 11110010 |
| 00000101 | 01101011 |
| 00000110 | 01101111 |
| 00000111 | 11000101 |
| 00001000 | 00110000 |
| 00001001 | 00000001 |
AES 加密轮次
一旦 AES 获得了所有所需的密钥,真正的加密过程就可以开始了。回想一下,二进制明文存储在一个 16 字节或 128 位的网格中,这与原始密钥的大小相同。这并非巧合。实际加密的第一步是将 128 位的数据网格与原始的 128 位密钥进行异或操作。现在,工作开始真正展开,因为数据网格将经过 10 轮的数值处理。每一轮有四个步骤。
1. 替代。
网格中的每个 16 字节都使用与密钥扩展过程中相同的 S-盒表进行替换。
2. 行置换。
接下来,字节将被移动到网格中它们所在行的不同位置。
3. 列组合。
接下来,对于网格中的每个字节,通过结合该列中的所有四个字节计算出一个新字节。这个计算再次涉及到异或操作,但也涉及到一种二进制形式的置换。为了让你理解这个过程,图 1-16 展示了最左边列的最低行左侧字节的计算方法。最左列的四个字节会先进行异或运算,但列中的最上面和最下面的字节会先进行位置换。这种置换被称为按位旋转;字节的位会向左滑动一个位置,最左边的位会移动到右侧。
新网格中的每个字节都通过类似的方式计算,方法是将列中的字节通过异或运算结合起来;唯一的变化是哪些字节在异或之前先进行位旋转。

图 1-16:AES 一轮中列置换步骤的一个部分
4. 与密文密钥进行异或。
最后,将前一步得到的网格与该轮的密钥进行异或运算。这就是为何需要密钥扩展的原因,以便每一轮都与不同的密钥进行异或。
AES 解密过程执行与加密过程相同的步骤,只不过是反向操作。由于加密过程中的操作只有异或、简单的 S-盒替换以及位和字节的置换,若已知密钥,所有操作都是可逆的。
块链链接
AES 加密可以单独应用于文件中的每个 16 字节块,但这会在密文中留下漏洞。正如我们之前讨论过的,密钥使用的次数越多,攻击者发现并利用模式的可能性就越大。计算机文件通常非常庞大,使用相同的密钥加密数百万个块,这是一种大规模的密钥重用形式,会使密文暴露于频率分析及相关技术。
由于这个原因,像 AES 这样的基于块的加密系统经过修改,使得明文中相同的块生成不同的密文块。其中一种修改方法称为块链式加密。
在块链式加密中,明文的第一个块在加密前会与一个随机的 128 位数进行异或(XOR)运算。这个随机数称为起始变量,并与密文一起存储。由于每次加密都会分配一个随机的起始变量,因此即使两个文件以相同的数据开始,在使用相同密钥加密时,它们的密文也会不同。
每个后续的明文块在加密前都会与前一个密文块进行异或(XOR)运算,从而实现加密的“链式”作用,如图 1-17 所示。链式加密确保了明文中的重复块会生成不同的密文块。这意味着任何长度的文件都可以在不担心频率分析的情况下进行加密。

图 1-17:使用块链式加密的 AES 加密过程
为什么 AES 是安全的
如你所见,尽管 AES 包含许多步骤,但每个步骤都只是置换或简单的替代。为什么 AES 被认为足够强大,能够保护世界的数据?记住,攻击者通常会使用暴力破解、已知明文或利用密文中的模式。AES 在防范这些攻击方法方面表现得非常出色。
对于 AES,暴力破解意味着通过所有可能的密钥将密文传递到解密过程,直到明文被恢复。在 AES 中,密钥长度有 128、192 或 256 位。即使是最小的密钥长度,也有大约 300,000,000,000,000,000,000,000,000,000,000,000,000 个可能的密钥,暴力破解攻击需要尝试其中一半的密钥才能找到正确的密钥。假设攻击者的计算机每秒可以尝试百万个密钥,那么在一天内,攻击者可以尝试 1,000,000 密钥 × 60 秒 × 60 分钟 × 24 小时 = 86,400,000,000 个密钥。在一年内,攻击者可以尝试 31,536,000,000,000 个密钥。虽然这个数字很大,但它甚至不到可能组合数的十亿分之一。即使攻击者获取了更多的计算能力,尝试这么多的密钥仍然不现实——而这仅仅是针对 128 位版本。
AES 还使得使用已知明文或寻找可利用的模式变得困难。在每一轮加密过程中,AES 会旋转每行的字节并合并每列的字节。经过多轮操作后,字节被彻底混合,因此密文网格中任意一个字节的最终值都依赖于网格中所有字节的初始明文值。这种加密特性被称为扩散。
此外,经过 S 盒的多轮处理,扩散效果会得到放大,而块链式加密将每个块的扩散效应传递到下一个块。所有这些操作共同赋予了 AES雪崩效应,即明文中的微小变化会导致密文中的大范围变化。
无论攻击者对明文的大致布局了解多少,AES 都能抵挡住攻击。例如,一家公司可能基于一个共同的模板向客户发送电子邮件,唯一的变量是客户的账户号码和未结余额。通过扩散、雪崩和块链技术,这些电子邮件的密文将会非常不同。扩散和雪崩还会减少可能通过频率分析被利用的模式。即使是一个包含重复相同 16 字节块的巨大明文文件,通过 AES 加密和块链处理后,也会得到看起来完全随机的比特串。
可能的 AES 攻击
AES 看似能有效抵御传统的加密攻击,但是否存在一些隐藏的弱点能提供破解密码的捷径?答案尚不明了,因为证明一个否定命题是很困难的。说没有已知的捷径或破解方法是其中一回事,而证明它们不可能存在又是另一回事。密码学是一门科学,而科学总是在不断扩展边界。我们对密码学及其基础数学的理解还不到可以断言什么是不可能的程度。
分析像 AES 这样的开放标准的漏洞的困难部分在于,程序员在实现该标准时可能无意中引入安全漏洞。例如,一些 AES 实现容易受到时间攻击的影响,攻击者通过测量加密所需的时间来获取关于加密数据的信息。然而,攻击者必须能够访问执行加密的特定计算机,因此这实际上并不是底层加密的缺陷,但如果安全性受到威胁,这也并不能让人安心。
AES 最为人知的漏洞是相关密钥攻击。当两个密钥以特定方式数学相关时,攻击者有时可以利用通过一个密钥加密的消息中收集到的信息,恢复使用另一个密钥加密的消息。研究人员发现了一种方法,可以在比暴力破解攻击更短的时间内恢复特定密文的 AES 加密密钥,但该方法要求使用与原始密钥在非常特定的方式上相关联的密钥加密的同一明文的密文。
尽管这一捷径算作一个破解方法,但对攻击者来说可能并没有实际价值。首先,尽管它大大减少了恢复原始密钥所需的工作量,但对现有计算机或计算机网络而言,这可能是不可行的。其次,获取已用相关密钥加密的其他密文并不容易;这需要加密方法或使用方式出现故障。因此,这一破解方法目前被认为是理论性的,而非系统的实际弱点。
也许这种破解方式最令人担忧的一点是,它被认为只对所谓更强的 256 位密钥版本的 AES 有效,而不适用于本章描述的更简单的 128 位密钥版本。这可能展示了现代加密技术的最大弱点:它们的复杂性。即便有专家审查,缺陷可能依然在多年后未被发现;设计中的小变化也可能对安全性产生重大影响;而本应增强安全性的特性,可能会产生相反的效果。
私钥加密的局限性
然而,像 AES 这样的加密方法的真正局限性与潜在的隐藏漏洞无关。
本章中的所有加密方法,包括 AES,都被称为对称密钥方法——这意味着加密消息或文件的密钥与解密它所使用的密钥是相同的。如果你想用 AES 加密桌面硬盘上的文件或手机中的联系人列表,这没有问题;只有你自己在加锁和解锁数据。但是,当你需要保护数据传输时,比如在零售网站上输入信用卡号码时会发生什么呢?你可以用 AES 加密数据并发送到网站,但网站上的软件在没有密钥的情况下无法解密密文。
这是共享密钥问题,也是密码学的核心问题之一。没有一种安全的方式来共享密钥,对称密钥加密仅对锁定自己的私人数据有用。为了进行数据传输加密,必须采取不同的方法,使用不同的密钥进行加密和解密——你将在第三章中看到这是如何实现的。
但我们首先需要解决另一个问题。AES 需要一个巨大的二进制数作为密钥,但用户不可能记住 128 位的字符串。相反,我们记住的是密码。事实证明,密码的安全存储和使用本身就会带来一些难题。这些将是下章的内容。
第二章:2
密码

软件最重要的任务之一就是保护密码。这可能让人吃惊。毕竟,密码不就是用来提供保护的吗?难道密码不保护我们的银行账户、网上零售商和在线游戏账户吗?
事实上,尽管密码是计算机安全的基石,但它们也可能成为攻击的目标。如果远程计算机根据密码接受你的身份验证,这个过程被称为身份验证,它必须拥有一个用户密码列表来进行比对。这个密码列表成为攻击者诱人的目标。近年来,出现了许多大规模的客户账户数据盗窃事件。到底是怎么发生的,怎样才能减少此类漏洞发生的可能性?这就是本章所探讨的内容。
然而,在你了解密码是如何被保护的之前,你将看到它们是如何被转换为二进制数字的,这个过程对于密码存储和加密都有重要影响。
将密码转换为数字
在第一章中,你看到如何将一个单独的字符替换为来自 ASCII 表的数字。在这里,你将看到如何将一串字符替换为一个大数字,例如我们在 AES 中需要的 128 位密钥。在计算机中,将某物转换为一个指定范围内的数字被称为哈希,而生成的数字称为哈希值、哈希码,或者简称哈希。
在这里,哈希一词意味着将某物切碎后再将这些碎片重新组合在一起,就像做哈希土豆一样。一个特定的哈希方法被称为哈希函数。哈希密码总是从将密码中的每个字符转换为数字开始,使用如 ASCII 这样的编码系统。哈希函数在如何组合这些数字上有所不同;用于加密和身份验证系统的哈希函数必须经过精心设计,否则安全性可能会受到威胁。
优秀哈希函数的特性
开发一个好的哈希函数并非易事。为了理解哈希函数面临的挑战,可以考虑一个简单的密码dog。这个单词包含 3 个 ASCII 字节,或者仅仅 24 位数据,而 AES 密钥至少有 128 位。因此,一个好的哈希函数必须能够将这 24 位数据转换成一个 128 位的哈希码,并具有以下特性。
充分利用所有位
像 AES 这样的基于计算机的加密系统的一个主要优点是密钥长度,即攻击者面对的可能密钥的数量。然而,如果所有可能的密钥实际上并未被使用,这一优点就会消失。一个好的哈希函数必须在所有可能的哈希码范围内产生结果。即使是我们的短密码dog,生成的 128 位哈希码的所有位也必须受到原始 24 位密码的影响。
不可逆性
在第一章中,你学到了一种加密方法必须是可逆的。相比之下,一个好的哈希函数不应是可逆的。我将在本章稍后讨论为什么这一点很重要。现在,要知道,对于给定的哈希值,不应该有直接的方式恢复生成它的密码。我说是一个密码而不是这个密码,因为多个密码可能会生成相同的哈希值,这种现象称为哈希碰撞。由于可能的密码比哈希值更多,因此碰撞是不可避免的。一个好的哈希函数应该使得攻击者很难找到生成特定哈希值的任何密码。
雪崩效应
对加密至关重要的雪崩效应在哈希中同样重要。密码的微小变化应该导致哈希值发生较大的变化——尤其是因为许多人在需要选择新密码时,通常会选择他们旧密码的轻微变体。对于dog生成的哈希值,应该与类似密码如doge、Dog或odg生成的哈希值有很大不同。
MD5 哈希函数
满足所有这些标准并不容易。好的哈希函数通过巧妙的方法解决了这个问题。它们从一堆杂乱的位开始,并利用密码的位模式进一步修改这些杂乱的位。这就是广泛使用的哈希函数MD5的工作原理——它是消息摘要哈希函数的第五个版本。
密码编码
为了开始,MD5 将密码转换为 512 位的块;我将其称为编码密码。这个编码的第一部分由密码中每个字符的 ASCII 码组成。例如,如果密码是BigFunTime,第一个字符是B,它的 ASCII 字节是 01000010,因此编码密码的前 8 位是 01000010;接下来的 8 位是i的字节,01101001;以此类推。因此,我们示例中的 10 个字母BigFunTime密码将占用 512 中的 80 位。
现在其余的位需要填充。接下来的一个位被设置为 1,直到最后 64 个比特之前的所有位都设置为 0。最后 64 个比特存储的是原始密码的长度(二进制表示),以比特为单位。在这种情况下,密码长度为 10 个字符,或者 80 位。80 的 64 位二进制表示是:
00000000 00000000 00000000 00000000 00000000 00000000 00000000 01010000
显然,我们不需要 64 位来存储密码的长度。使用 64 位来表示长度,使 MD5 能够对任意长度的输入进行哈希——这个好处我们将在稍后看到。
图 2-1 显示了示例密码的编码,将其组织成 16 行,每行 32 位。

图 2-1:密码 BigFunTime 转换为用于 MD5 哈希函数输入的 512 位
这个编码后的密码充满了零,因此不满足一个好的函数“充分利用所有位”的特性,但这没关系,因为这不是哈希值;它只是起始点。
位运算
MD5 哈希函数使用了一些我之前没有讨论过的操作。我们来简单了解一下这些操作。
二进制加法
第一个新操作是二进制加法。二进制加法与你已经知道的十进制加法类似,只不过是使用二进制数。例如,数字 5 的 32 位表示是:
00000000 00000000 00000000 00000101
46 的 32 位表示是:
00000000 00000000 00000000 00101110
如果我们将 5 和 46 相加,结果是 51。同样,这两个二进制表示相加的结果也是 51 的二进制表示:
00000000 00000000 00000000 00110011
然而,与普通加法不同,在普通加法中有时结果的位数会超过操作数的位数,而在二进制加法中,位数是固定的。如果两个 32 位二进制数相加的结果超过了 32 位,我们会忽略结果左侧的“进位”,只保留右侧的 32 位。这就像使用一台便宜的计算器,只有两位数显示屏,因此当你加 75 和 49 时,它不会显示 124,而只会显示最后两位数字 24。
位运算 NOT
接下来的新操作称为“not”,通常写作全大写的NOT。如图 2-2 所示,NOT 操作“翻转”所有位,将每个 1 替换为 0,每个 0 替换为 1。

图 2-2:位运算 NOT。所有位都被翻转,1 位用高亮显示以便清晰显示。
位或运算(OR)
接下来的操作是OR,有时称为包含性 OR,用来区分它与第一章中看到的异或(XOR)。OR 操作将两个具有相同位数的二进制数对齐。在结果二进制数的每个位置,如果第一个数或第二个数中有 1,则结果为 1;否则,结果为 0, 如图 2-3 所示。

图 2-3:位或运算(OR)。如果两个输入中的位之一为 1,结果位为 1。
注意,与异或(XOR)不同,你不能对 OR 操作应用两次以恢复原始字节。这是一个单向过程。
位与运算(AND)
最后一个新操作是AND。将两个二进制数对齐,在每个位置上,如果两个数的该位置位都是 1,结果就是 1;否则,结果为 0。因此,结果中某个位置为 1,意味着该位置在第一个数和第二个数中都是 1,如图 2-4 所示。与 OR 类似,AND 操作是不可逆的。

图 2-4:位与运算(AND)。如果两个输入中的位都是 1,结果位为 1。
MD5 哈希轮次
现在我们准备进行哈希操作了。加密密码的部分数据在 MD5 过程中的出现只是短暂的,但这些出现决定了整个过程的不同。MD5 过程始终以相同的 128 位开始,概念上分为四个 32 位部分,标记为 A 至 D,如图 2-5 所示。

图 2-5:MD5 哈希码的 128 位初始配置
从这里开始,整个过程就是不断地移动这些位并对它们进行翻转,这一过程会重复整整 64 次。在这方面,这个过程很像 AES,但轮数更多。图 2-6 是其中一个 64 轮的宽泛示意图。

图 2-6:MD5 哈希函数的一个轮次。在结果中,三个部分被交换,而所有四个部分被组合成一个新的部分。
如图所示,B、C 和 D 部分只是被交换,使得一轮的 D 部分成为下一轮的 A 部分。MD5 的主要操作发生在每轮的“额外混乱”中,这一过程利用前一轮所有四个部分的位生成一个新部分。额外混乱使用不可逆操作 AND、OR 和 NOT,将所有四个部分的位与编码密码的一行结合起来。在不同的轮次中使用不同的编码密码行,因此最终所有的编码密码行都会被多次使用。由于有交换操作,这个过程只需四轮就能将四个原始部分中的每一个替换为额外混乱的结果。经过完整的 64 轮过程后,原始的各个部分位将与编码密码彻底交织在一起。
满足良好哈希函数的标准
由于 MD5 从一组位开始,然后反复改变这些位,逐步加入编码密码的片段,我们可以确保所有的位在过程中都被影响,从而生成真正的 128 位哈希码。大量不可逆的操作——记住,这些操作会重复进行 64 次——意味着整个哈希函数是不可逆的。每一轮中“额外混乱”的位移和改变,再加上各个部分本身的旋转,分散了位和字节,最终创建了预期的雪崩效应。
MD5 满足一个良好哈希函数的所有基本要求。然而,它确实有一些微妙的弱点,正如你很快会看到的那样。
数字签名
哈希函数除了从密码生成密钥外,还在安全性中起着其他作用。其中最重要的之一是创建文件的 签名。如前所述,MD5 可以处理任何大小的输入。如果输入大于 512 位,首先会将其分割成多个 512 位的块。然后,每个块都会应用一次 MD5 处理。第一个块从初始的 128 位开始,后续每个块都从前一个块生成的哈希码开始。通过这种方式,我们可以将本书的整个文本、一个音频文件、一个视频文件或任何其他数字文件都通过该函数,得到一个单一的 128 位哈希码作为返回值。这个哈希码将成为该文件的签名。
为什么一个文件需要签名呢?假设你决定下载 FreeWrite(一款虚构的免费文字处理软件)。不过,你心存疑虑,因为曾经有过一次糟糕的经历,你下载了一款免费的程序,结果它是假的,充满了恶意软件。为了避免这种情况,你希望确保下载的 FreeWrite 文件和开发者上传的文件是一样的。开发者可以用 MD5 对文件进行哈希处理,并将生成的哈希码——即文件签名——发布在他们的网站freewrite.com上。这样,你就可以使用 MD5 哈希程序对文件进行处理,并将结果与开发者网站上的代码进行比对。如果结果不匹配,那么文件、签名或两者之一发生了变化。
身份问题
不幸的是,匹配发布的哈希码只能证明 FreeWrite 文件是合法的,前提是该哈希码确实是开发者发布的。但如果攻击者复制了开发者的freewrite.com网站,改用一个类似的域名,如free-write.com,然后发布了一个被篡改的文件以及该文件的哈希码呢?数字签名的可信度取决于其提供者。我们将在第三章中进一步探讨这个问题。
碰撞攻击
即便来自合法来源的哈希码匹配,一个文件仍然可能存在问题。许多不同的文件会产生相同的哈希码,这意味着一个攻击者如果试图修改文件以进行恶意操作,只要修改后的文件产生相同的哈希码,就能避免被检测到。
生成两个具有相同哈希码的文件并不难,这被称为碰撞攻击:只需随机生成文件,直到两个哈希码匹配。找到第二个与另一个文件的特定哈希码匹配的文件要难得多。为了对攻击者有实际用途,具有匹配哈希码的文件不能只是一些随机字节,它必须是一个能够为攻击者执行恶意操作的程序。
不幸的是,有方法可以生成第二个文件,它与第一个文件非常相似且具有相同的 MD5 哈希码。MD5 哈希函数中发现的这一缺陷促使研究人员建议使用其他哈希函数来进行签名。这些更先进的哈希函数通常拥有更长的哈希码(最多 512 位),更多的哈希轮次,并且在每轮过程中进行更复杂的二进制运算。然而,就像加密一样,不能保证更复杂的哈希函数不会被发现存在缺陷。数字签名的正确使用意味着始终保持在已知设计缺陷之前,因为攻击者会无情地利用这些缺陷。数字安全就像一场猫捉老鼠的游戏,正义的一方是老鼠,尽力避免被吃掉,永远无法打败猫,只能希望活得稍微久一点。
身份验证系统中的密码
这种猫捉老鼠的游戏在身份验证系统中尤为明显。每一个需要输入密码的地方都必须有一个密码列表用于对比,而妥善保护这个列表需要极大的小心。
密码表的危险
让我们来看一下最直接的密码存储方式。在这个例子中,东北钱银银行(NEMB)存储了每个客户的用户名和密码,以及账户号码和当前余额。密码表的一部分如表 2-1 所示。
表 2-1: 设计不当的密码表
| 用户名 | 密码 | 账户号码 | 余额 |
|---|---|---|---|
| richguy22 | ilikemoney | 21647365 | $27.21 |
| mrgutman | falcon | 32846519 | $10,000.00 |
| squire | yes90125 | 70023193 | $145,398.44 |
| burgomeister78 | taco999 | 74766333 | $732.23 |
正如凯尔科夫原理所说,我们不能依赖加密方法保持秘密,我们也不应当依赖密码列表保持秘密。NEMB 信息技术部门的一名不满的员工可能轻易获取包含此列表的文件,或者外部的攻击者可能通过公司的防御渗透进去。
这就是所谓的单点防御,意味着一旦有人看到这个表格,游戏就结束了。首先,这个表格展示了所有客户的账户号码和余额,至少这就造成了隐私的重大损失。更糟糕的是,每个密码都以用户输入的原始形式存储。访问这个密码列表将允许攻击者以任何客户的身份登录——这是一场灾难的前兆。
幸运的是,这种存储系统的问题可以很容易地得到修正。知道了这些问题,也了解系统的危险性,你可能会认为这种方式永远不会被使用。可惜,你错了。现实中的一些公司就这样存储用户的密码。有些非常大的公司,可能花费了大量资金来建设其网站,但却仍然在使用这种做法。
哈希密码
如果表 2-1 展示了错误的做法,那么正确的做法是什么呢?一种改进方法是将密码从表格中移除,而是存储密码的哈希值,如表 2-2 所示。(在接下来的示例中,为了便于管理,我将哈希值以十进制数字的形式展示。)
表 2-2: 存储哈希密码的密码表
| 用户名 | 密码哈希值 | 账户号码 | 余额 |
|---|---|---|---|
| richguy22 | 330,711,060,038,684,200,901,827,278,633,002,791,087 | 21647365 | $27.21 |
| mrgutman | 332,375,033,828,033,552,423,319,316,163,101,084,850 | 32846519 | $10,000.00 |
| squire | 295,149,488,455,763,164,542,524,060,437,757,020,453 | 70023193 | $145,398.44 |
| burgomeister78 | 133,039,589,388,270,767,475,032,770,360,311,206,892 | 74766333 | $732.23 |
当用户尝试登录时,提交的密码会被哈希处理,并将结果与存储的哈希码进行比对。如果匹配,用户即可登录。由于哈希函数是不可逆的,因此获取表格并不等于获取密码。攻击者无法凭借哈希码登录账户。
然而,账号和余额仍然以明文形式存储,最好对它们进行加密,制作一个只包含哈希码和密文的表格。问题是,如果我们使用密码的哈希值作为加密密钥,那么加密数据就无法提供额外的保护,因为任何获取到此表格的人都能解密密文。
解决这个问题有几种方法。一个解决方案是使用一个哈希函数对密码进行认证处理,另一个哈希函数将密码转换为加密密钥,用来加密账号和余额。只要这些哈希函数是不可逆的,即便攻击者获得了表格,依然可以保护账户数据的安全。
字典攻击
对密码进行哈希处理是防御攻击者的一种有效手段,但仅此还不够。认证系统仍然容易受到字典攻击的威胁。
在基本的字典攻击中,攻击者无法访问密码表,只能猜测密码。攻击者可以尝试随机字符组合,但使用字典会更加成功,在软件领域中,字典就是一个单词列表。在这个案例中,字典包含了最常见的密码,类似如下:
• password
• 123456
• football
• mypassword
• abcdef
为了防止基本的字典攻击,大多数网站会统计失败登录次数,在达到一定次数(可能是三次)后,暂时阻止来自特定计算机的进一步登录尝试。这通过增加找到正确密码所需的时间,使得攻击变得不切实际。
一种不同形式的字典攻击是攻击者获取了哈希并加密的密码表副本。在这种情况下,攻击者会对字典中的每个密码进行哈希处理,并将其与窃取到的表格中的每个哈希码进行比对。当发现匹配时,攻击者就知道了生成该用户哈希码的密码。为了节省时间,攻击者可以先将字典中的所有密码通过选定的哈希函数处理一次,并将结果存储在一个字典中,就像表 2-3 那样。
表 2-3: 带有哈希码的字典
| 密码 | MD5 哈希码 |
|---|---|
| password | 126,680,608,771,750,945,340,162,210,354,335,764,377 |
| 123456 | 299,132,688,689,127,175,738,334,524,183,350,839,358 |
| football | 74,046,754,153,250,065,911,729,167,268,259,247,040 |
| mypassword | 69,792,856,232,803,413,714,004,936,714,872,372,804 |
| abcdef | 308,439,634,705,511,765,949,277,356,614,095,247,246 |
字典展示了为什么用户选择不明显的密码如此重要。密码越晦涩,它出现在攻击者字典中的可能性就越小。
哈希表
不幸的是,攻击者可以完全放弃字典,构建一个随机生成密码及其对应哈希值的表格,我称之为预计算哈希表。当然,潜在密码的数量是巨大的,因此如果攻击者想要有一个合理的匹配机会,哈希表需要非常庞大。构建预计算哈希表需要大量的计算能力和时间,但它只需要构建一次,之后可以反复使用。
这个表格的一个弱点是其庞大的体积会使得查找匹配项变得非常缓慢。当你考虑到文字处理软件在大文档中查找特定单词的速度时,这似乎有些令人吃惊,但这些预计算的表格远大于你计算机上的任何文件。假设攻击者拥有一个包含 10 个或更少的大写字母、小写字母和数字的所有密码的表格。即使有这些限制,潜在密码的数量是 62¹⁰,也就是 839,299,365,868,340,224。预计算哈希表不需要包含每一个潜在密码的条目,但它需要有相当一部分。尽管如此,这个表格也会如此庞大,以至于它无法装进计算机的内存中。它甚至无法放进硬盘——为了直说,它可能需要分布在一百万个硬盘上。而这仅仅是存储问题。如果没有谷歌那样的分布式计算能力,查找如此庞大的表格是不切实际的。(即使是谷歌,搜索海量数据也并不容易;我们将在第七章详细探讨搜索问题。)
哈希链
由于预计算哈希表太大,无法存储和搜索,攻击者使用了一种巧妙的技术,称为哈希链,可以显著减少表格中的条目数,同时不降低其有效性。该技术使用一种称为还原函数的不同类型的函数,它执行与哈希函数类似的数学运算,但目的是相反的。它不是从密码创建哈希值,而是从哈希值创建密码——不是生成哈希值的密码,而是生成一个具有有效密码形式的字符序列。
这是哈希链的一个示例。当glopp26taz使用 MD5 进行哈希时,它产生了这个哈希值:
22,964,925,579,257,552,835,515,378,304,344,866,835
还原函数将哈希码转化为另一个有效的密码,比如7HGupp2tss。然后,将其通过哈希函数处理,生成另一个哈希码,再将其通过还原函数生成下一个密码,以此类推。这样交替生成的密码和哈希码序列,如图 2-7 所示,就是一个哈希链。

图 2-7:在哈希链中,哈希函数(H)与还原函数(R)交替使用,后者从哈希码生成任意密码。
攻击者不使用密码和哈希码的表格,而是生成一系列哈希链,每个链条长度相同,只存储每个链条的第一个和最后一个链接。图 2-7 中的链条在表 2-4 中作为第三项显示。这个表格有 5 项,但每项都是由 3 对密码/哈希码组成的链条,因此相当于一个包含 15 项的普通表格。
表 2-4: 哈希链表
| Start | End |
|---|---|
| sop3H4Yzai | 302,796,960,148,170,554,741,517,711,430,674,339,836 |
| 5jhfHTeu4y | 333,226,570,587,833,594,170,987,787,116,324,792,461 |
| glopp26taz | 33,218,269,111,507,728,124,938,049,521,416,301,013 |
| YYhs9j2a22 | 145,483,602,575,738,705,325,298,600,400,764,586,970 |
| Pr2u912mn1 | 737,08,819,301,203,417,973,443,363,267,460,459,460 |
图 2-8 展示了如何使用该表的一个示例。我们的攻击者正试图恢复目标哈希码 117,182,660,124,686,473, 413,705,332,853,526,309,255 的密码。攻击者必须确定表中的哪个链条(如果有的话)包含目标哈希码。首先,目标哈希码与表中“End”列的每个数字进行比较。在这个案例中,没有找到匹配项,因此攻击者将目标哈希码通过还原函数转换为一个新的密码,将该结果通过哈希函数,再将新的哈希码搜索表格的“End”列。这个过程将持续进行,直到找到匹配项,或者在运行了三次后(表格中链条的长度)。
在这种情况下,初始目标哈希值被转换为密码pRh7T63y,然后进行哈希处理,新的哈希值出现在表格的第三项中,与起始密码glopp26taz形成链条。这标识了目标密码可能出现的哈希链,但攻击者必须通过迭代这个链条来获取密码。链条中的起始密码被哈希处理;所得哈希值与初始哈希值不匹配,因此它被转化为新的密码7HGupp2tss,并再次进行哈希处理。这个哈希值匹配,这意味着7HGupp2tss就是密码。
哈希码链表显著缩小了表格的大小,同时仍然提供相同量的可搜索数据。例如,如果一个链表有 100 个密码和 100 个哈希码,那么匹配这些哈希码的密码可以通过该链表间接检索,尽管链表中只包含一个密码和一个哈希码。因此,具有如此长链表的表格,其功能相当于一个常规的预计算哈希表,大小是其 100 倍。
但是,也存在一些潜在的问题。首先,使用哈希链表进行搜索需要更多的计算工作。此外,由于碰撞—多个密码产生相同的哈希码—一个匹配的链表不一定包含所查找的哈希码及其对应的密码,这个问题被称为链表合并。然而,对于我们这些关心数据安全的人来说,这些问题仍然是一些安慰。虽然有方法可以减少链表合并的问题,但即使没有这些方法,显然对于特定的哈希函数,仍然可以制作有效的预计算表,从而使得使用这些哈希函数的密码变得脆弱。

图 2-8:使用哈希链表查找产生特定哈希值的密码。表中既不列出密码,也不列出哈希值。
迭代哈希
防止创建预计算哈希表的一种方法是多次应用哈希函数。由于哈希函数的输出本身也可以被哈希处理,原始密码可以经过相同的哈希函数任意次数。这个技术同样不太有帮助地被称为哈希链,但为了避免混淆,我将其称为迭代哈希。图 2-9 展示了对密码football进行五次迭代哈希处理的过程。

图 2-9:重复应用哈希函数
使用此技术时,密码在存储时和用户登录时都会被反复进行哈希处理。为了破解这一点,攻击者必须基于相同的思想,制作一张表格,运行所选的哈希函数相同次数。根据凯尔克霍夫原则,我们知道加密系统不应依赖于保密其方法。迭代哈希的目标不是掩盖密码被哈希的次数,而是尽可能地增加攻击者预计算哈希表的创建难度。在这个示例中,密码会经过五次哈希处理。这将使得攻击者创建哈希表所需的时间增加五倍。现实世界中,密码可以通过哈希函数运行数百次甚至数千次。这足够防止创建有用的预计算哈希表吗?也许吧。计算机每天都在变得更快。这大多数情况下是件好事,但计算能力不断增强的副作用是,它不断推动实际限制的边界,因此我们的信息安全在很大程度上依赖于这些实际限制。
设置基于迭代哈希的密码系统时,必须选择迭代次数。今天选择一个能提供良好安全性的数字相对容易。难的是预测一年的迭代次数,或者两年、十年后的情况。
你可能认为,最好的选择是某个极大的数字来防止未来计算机的攻击。问题是,现在的计算机在处理合法登录时会遇到很大困难。你愿意等五分钟才能访问你的某个在线账户吗?
密码加盐
认证系统需要一种方法来加强哈希,但又不会因哈希迭代次数过多而影响性能;也就是说,它们需要一种存储密码的方法,能够让攻击者投入不切实际的时间,而不会对合法访问造成同样不现实的时间负担。这个方法叫做盐。盐这个词非常恰当,谁想出这个概念,我真是佩服。用在烹饪中,一小撮盐就能显著改变菜肴的味道。在密码学中,撒在密码上的少量盐会极大地改变其哈希值。
它是如何工作的:当一个新用户注册账户并选择用户名和密码时,系统会自动为该账户生成盐。盐是一串字符,就像一个短的、随机的密码,它与用户的密码结合后再进行哈希。例如,用户mrgutman选择了falcon作为密码,系统生成了h38T2作为盐。
盐和密码可以通过不同的方式结合,但最简单的方式是将盐附加到密码的末尾,在这个例子中就是falconh38T2。然后,这个组合被哈希,哈希值与用户名和盐一起存储在认证表中,如表 2-5 所示。
表 2-5: 使用盐的密码表
| 用户名 | 盐 | 密码+盐的哈希值 |
|---|---|---|
| richguy22 | 7Pmnq | 106,736,954,704,360,738,602,545,963,558,770,944,412 |
| mrgutman | h38T2 | 142,858,562,082,404,032,402,440,010,972,328,251,653 |
| squire | 93ndy | 122,446,997,766,728,224,659,318,737,810,478,984,316 |
| burgomeister78 | HuOw2 | 64,383,697,378,169,783,622,186,691,431,070,835,777 |
每次用户请求访问时,盐值都会添加到输入的密码末尾,再进行哈希处理。攻击者如果获取到这个认证表的副本,无法从预计算的哈希表中得到太多有用的信息。虽然表中可能有一个密码哈希值与给定的代码匹配,但当该密码与盐值结合时,结果不会产生正确的代码。相反,攻击者需要为特定的盐值创建一个表。虽然这可以做到,但请记住,盐值是随机选择的。如果说,在一个被盗的认证表中有 10 万个用户,并且盐值足够多,以至于没有盐值在表中重复,那么攻击者就需要创建 10 万个表。到这时,我们甚至不能再称它们为预计算的表,因为攻击者需要为每次攻击创建这些表。
密码表安全吗?
盐值和迭代哈希通常是一起使用的,这给攻击者带来了真正的麻烦。迭代哈希增加了创建一个预计算哈希表所需的时间,而盐值则意味着攻击者需要创建多个表。但这种组合足够安全吗?
对这个问题没有明确的答案。密码学研究人员和安全专家们继续开发新的防御措施以防止未经授权的访问。然而,与此同时,攻击者也在不断找到新的方法突破防御。计算能力和编程理论的进步有利于率先利用它们的一方。
也许本次讨论最重要的教训就是,安全性往往超出了用户的控制范围。总是存在漏洞,但用户无法知道某个特定网站或服务是否采用了最佳的安全措施。例如,盐值技术只对使用它的系统有益,而并非所有系统都使用它。
密码存储服务
这就是密码在远程认证系统中的存储方式。那么在用户端呢?我们如何安全地存储我们的密码?
很久以前,我的密码少得可以完全依赖记忆来保管,但最终我意识到我必须把密码存储在脑袋以外的地方。然而,把密码写在纸上又是一种不同的安全隐患。有一段时间,我采用了一种复杂的自制方案,其中涉及一个用 AES 加密的.txt文件,存储在一个存放在金属盒中的内存卡上,而这个金属盒可能并非百分之百防火。这个方案有效,唯一的问题是每次需要查找密码时,我都得去取盒子,把内存卡取出来插入电脑,双击文件,输入密码(唯一需要记住的密码),然后在表格中找到所需的条目。
最终,我放弃了,注册了一个基于网页的密码存储服务。当我创建该服务的账户时,我选择了一个主密码。然后我将所有其他的密码和用户名存储在这个网站上。这些信息的存储方式使得即使有人获得了原始数据,对他们也几乎没有用处。因此,如果我在亚马逊的密码是chickenfat(实际上不是),那么chickenfat这个词就不会存储在密码存储服务器上。相反,这些密码在发送到密码存储站点之前,会通过我浏览器中的程序加密,使用我选择的主密码生成加密密钥。因此,即使服务器被攻破,攻击者也无法在没有主密码的情况下恢复我的各个密码。
主密码本身也不会存储在密码存储网站上。当需要加密或解密某个具体登录信息时,主密码会被加盐然后进行多次哈希处理,哈希的次数由我指定。
尽管使用密码存储服务把所有的“鸡蛋”都放在了一个篮子里,但这让我能够在个人登录中使用最佳实践。以前,我可能会创建一些由我认为能记住的单词和数字组合成的密码,而现在我的密码是长度随机的杂乱字符串。而且它们都不相同,因为我不再需要记住所有密码了。
最后的思考
在讨论认证系统时,我忽略了一个至关重要的细节。认证系统将存储的用户密码与登录时提供的密码进行比较,但远程计算机如何在第一次进行认证时获得用户选择的密码呢?安全传输需要加密,这意味着用户必须加密密码——但远程系统如何在没有密码的情况下解密这些加密密码呢?这又回到了共享密钥的问题——如果这个问题没有解决,我们在本章讨论的内容都无法正常工作。所以,接下来我们就来解决这个问题。
第三章:3
Web 安全

你可能之前没意识到,但我们所知的互联网如果没有解决共享密钥问题,根本无法存在。想象一个典型的情境:你在一个你以前没有购买过东西的在线零售商那里购物。某一时刻,你会被要求提供信用卡数据。浏览器告诉你,你的数据是安全的,可能通过在角落里显示一个“锁”图标来告知你。但要让浏览器通过 AES 保护你的卡号,必须确保你的系统和零售商使用相同的加密密钥。那两个系统如何在不事先交换密钥的情况下安全地传输数据呢?
解决这个共享密钥问题对于提供 Web 安全至关重要。在本章中,我们将探讨解决共享密钥问题的方法,它结合了我们在前两章中看到的所有技术,并添加了一个新的特殊元素:公钥密码学。
公钥密码学如何解决共享密钥问题
在物理安全的世界里,共享密钥问题有一个直接的解决方案,因为锁和钥匙是两个独立的物件。假设 A 需要将机密物理文件发送给 B。B 可以购买一个强箱和一把带钥匙的锁,然后将箱子和锁邮寄给 A,同时保留钥匙。然后 A 将文件放入箱子,用 B 的锁把箱子锁好,接着将箱子寄回给 B。由于 B 是唯一拥有锁钥匙的人,这是一种安全的递送方式。
这也是数字传输数据的理想情况。我们需要将数据的加锁和解锁方法分开,这样仅知道如何加密数据的人就无法解密结果的密文。
在第一章中,我们了解了 AES,它是一种对称密钥加密方法,意味着加密和解密使用相同的密钥。对于数据传输,我们需要一种非对称密钥加密方法,一种密钥用于加密,另一种用于解密。加密密钥被称为公钥,因为即使它落入攻击者手中,公开分发也不会带来不良影响;因此,非对称密钥加密也被称为公钥密码学。解密密钥只有接收者知道,因此被称为私钥。这些关系如图 3-1 所示。

图 3-1:非对称密钥加密,使用公钥加密,使用私钥解密。只有接收者拥有私钥。
公钥密码学的数学工具
那么,公钥密码学所需要的,是一种可逆的加密方法,但不是使用在加密中的密钥来反转。我们到目前为止所见的加密方法的基本工具,不适用于公钥密码学。例如,AES 中最常用的操作是异或(exclusive-or),之所以使用它,恰恰是因为当某个东西与相同的二进制数字异或两次时,结果会得到你开始时的那个数字。像异或这样的可逆操作不可避免地导致加密和解密使用相同的密钥。
因此,公钥加密需要一种新的技术。事实证明,公钥加密的秘密在于数字之间隐藏的关系。为了说明这些关系是什么以及它们如何用于密码学,我们需要了解一些数学术语。
可逆函数
广义地说,函数描述了任何一种情形,其中每个数值输入都会产生一个唯一的数值输出。例如,当前的摄氏温度是当前华氏温度的一个函数。对于任何特定的华氏温度,总会有一个对应的摄氏温度。
同样,一堆硬币的货币价值是每种类型硬币数量的函数。一个包含三枚 25 美分硬币、两枚 5 美分硬币、一枚 10 美分硬币和四枚 1 美分硬币的堆,货币总值为 99 美分。这个硬币堆不能值其他任何金额。
有时候,一个函数可以被反转来产生另一个函数。如果我们知道一个温度的华氏度数,我们也可以知道它的摄氏度数,反之亦然:如果我们知道一个温度的摄氏度数,我们也能算出它的华氏度数。从数学角度来说,我们可以说摄氏到华氏的转换函数是华氏到摄氏函数的反转,而原始函数是可逆的。然而,硬币的例子则是不可逆的。相同的总货币价值可以通过多种不同的硬币组合产生。如果我口袋里的硬币总值是 99 美分,我可能有三枚 25 美分硬币、两枚 5 美分硬币、一枚 10 美分硬币和四枚 1 美分硬币,或者我可能有九枚 10 美分硬币和九枚 1 美分硬币,或者其他的组合。
单向函数
对于一些可逆函数,从一个方向进行计算可能比从另一个方向计算要容易得多。例如,平方和平方根是互补的数学概念。假设你家里有一个正方形的房间,地板上铺着黑白相间的瓷砖,如图 3-2 所示。要计算地板的总面积,你可以将 12 乘以 12 得到 144。
我们说 144 是 12 的平方。反过来,我们说 12 是 144 的平方根。这两个都是函数;每个数字都有一个平方和一个平方根。然而,计算这两个函数的难度是截然不同的。算出一个数字的平方很简单:你只需将该数字乘以自身。而算出平方根则很难。除非你有一个数值表来帮助你,否则计算平方根实际上是一个试错过程。你先猜测平方根可能是什么,计算出这个猜测的平方,看它是否太高或太低,然后根据结果调整下一个猜测,重复这一过程直到找到准确的平方根,或者接近得足够可以停止。当一个函数是可逆的,但其逆函数计算起来要困难得多时,它被称为单向函数。

图 3-2:一间墙长 12 英尺的正方形房间总面积为 144 平方英尺。
陷门函数
非对称加密需要一个单向函数,使得加密密钥可以公开——加密过程简单,但解密将变得非常困难,几乎不可行。问题在于,我们不应让预定的接收者也无法解密。因此,任何普通的单向函数都不行。我们需要一种被称为陷门函数的单向函数,其中逆函数一般很难计算,但如果知道某个秘密值,逆函数就容易计算。
素数
我们将要讨论的特定“陷门”函数涉及素数。一个数字如果大于 1 且只能被自身和 1 整除(没有余数),则被称为素数。例如,5 是素数,因为它只能被自身和 1 整除,不能被 2、3 或 4 整除。然而,6 除了可以被 1 和自身整除外,还可以被 2 和 3 整除,因此它是一个非素数或合成数。能整除较大数字的小数字被称为较大数字的因数。每个数字都能被其自身和 1 整除,但我们称这些为平凡因数,并且在讨论因数时通常会忽略它们。一个素数只有平凡因数。
互质数
在一个相关概念中,如果两个数字只有 1 作为公因数,则称它们是互质的。无论每个数字本身是否为素数,它们都可以被认为是素数,至少从另一个数字的角度来看是如此。例如,合成数 9 和 4 是互质的,因为除了 1 外,没有其他数字可以同时整除它们。相反,6 与 9 或 4 都不是互质的,因为 6 与两者都有共同的因数。这些关系在表 3-1 中有所展示。
表 3-1: 显示 9 和 4 是互质的,但 6 与 9 或 4 都不是互质的
| 因数 | 来自 9 的余数 | 来自 6 的余数 | 来自 4 的余数 |
|---|---|---|---|
| 9 | (平凡的) | ||
| 8 | 1 | ||
| 7 | 2 | ||
| 6 | 3 | (平凡的) | |
| 5 | 4 | 1 | |
| 4 | 1 | 2 | (简单) |
| 3 | 0 | 0 | 1 |
| 2 | 1 | 0 | 0 |
| 1 | (简单) | (简单) | (简单) |
尽管 1 不是质数,但它被认为是与任何其他数字互质的。
质因数
现在我们即将接近使公钥加密能够工作的隐藏关系。如果我们将两个质数相乘,得到的积只有这两个质数作为因数(再次强调,不计算它自身和 1)。例如,5 和 3 是质数。3 和 5 的积是 15,且 15 只有 3 和 5 作为因数,如 表 3-2 所示。
表 3-2: 3 和 5 的积是 15,且 15 只有 3 和 5 作为因数
| 除以 15 | 结果 | 余数 |
|---|---|---|
| 15 | 0 | 0 (简单) |
| 14 | 1 | 1 |
| 13 | 1 | 2 |
| 12 | 1 | 3 |
| 11 | 1 | 4 |
| 10 | 1 | 5 |
| 9 | 1 | 6 |
| 8 | 1 | 7 |
| 7 | 2 | 1 |
| 6 | 2 | 3 |
| 5 | 3 | 0 |
| 4 | 3 | 3 |
| 3 | 5 | 0 |
| 2 | 7 | 1 |
| 1 | 15 | 0 (简单) |
这是一个单向函数。如果我给你两个质数,你可以轻松地将它们相乘,尽管如果数字很大,你可能需要使用计算器。这个函数的逆操作意味着从两个质数的积开始,找出原始的两个质数。这要困难得多。
让我们以 18,467 为例。这个数字确实是两个质数的积——但是 是哪 两个质数呢?要回答这个问题,你需要将 18,467 除以从 2 开始的每个质数。最终你会发现,18,467 除以 59 得到 313,这意味着 59 和 313 是这两个质因数。
如果只有积,找到质因数是非常困难的。然而,当你拥有积和其中一个因数时,找到另一个因数就很简单,因为你只需将第一个质数除以积。这使得它成为一种陷门函数——在一个方向上容易,在另一个方向上则很难,除非你有额外的信息。如果质数足够大,在没有陷门的情况下,找到因数几乎是不可能的。
RSA 加密方法
这种陷门函数是 RSA 公钥加密系统的核心,该系统以其发明者 Rivest、Shamir 和 Adleman 的首字母命名。在实际应用中,该系统使用非常大的数字来防止简单的暴力破解,但我会在一个简化的示例中使用小数字,以便更容易演示它是如何工作的。
假设兄妹 Zed 和 Abigail 共享一个银行账户,但他们住得分开。Zed 刚刚将账户的四位数字密码更改为 1482,并需要通过电子邮件将这个新号码发送给 Abigail。由于电子邮件传输过程中可能经过许多不安全的计算机,因此必须以某种方式加密密码,但 Zed 和 Abigail 之前并未共享任何可以使用像 AES 这样的加密方法的密码密钥。因此,Zed 将使用 RSA 安全地传输这个新密码。
创建密钥
尽管在这个示例中 Zed 拥有机密数据需要传输,但 RSA 过程从 Abigail 开始,她必须生成公钥,然后 Zed 才能加密 PIN 码。
步骤 1
Abigail 首先选择两个素数;假设她选择了 97 和 113。
步骤 2
Abigail 将这两个数字相乘得到 10,961。为了清楚起见,我将这个数字称为素数积。
步骤 3
接下来,Abigail 必须计算欧拉函数值(发音为TOE-shent,与quotient押韵)。对于一个数字N,欧拉函数值是小于N并且与N互质的数字的数量。例如,数字 15 与 1、2、4、7、8、11、13 或 14 互质,如图 3-3 所示。由于与 15 互质的数字有八个,因此 15 的欧拉函数值是 8。

图 3-3:这八个圈出的数字与 15 没有共同因子。因此,15 的欧拉函数值是 8。
计算一个数字的欧拉函数值通常需要检查所有小于该数字的数是否有共同因子,因此这是一个繁琐的过程——对于非常大的数字,计算欧拉函数几乎是不可能的。然而,如果该数字是两个素数的乘积,就有一个快捷方式:只需从这两个素数中各减去 1,然后将结果相乘。例如,15 是两个素数 3 和 5 的乘积。如果我们从这两个素数中各减去 1,得到 2 和 4;如果我们将 2 和 4 相乘,得到 8,这是 15 的欧拉函数值。
这个快捷方式极大地帮助了 Abigail,她的下一步是计算素数积 10,961 的欧拉函数值。由于 10,961 是 97 和 113 的乘积,因此 10,961 的欧拉函数值是 96 × 112,即 10,752。
步骤 4
现在 Abigail 选择一个符合以下标准的数字:
• 大于 1
• 小于欧拉函数值
• 与欧拉函数值互质
假设她选择了 5。这个选择是可以接受的,因为它大于 1,小于 10,752,并且除了 1,没有其他数可以同时整除 5 和 10,752。Abigail 将与 Zed 共享这个数字,所以我们称之为公钥。
步骤 5
选择的公钥决定了 Abigail 的私钥,这是她需要保密的数字。对于给定的公钥和欧拉函数值,只有一个数字可以作为私钥,我们可以通过测试欧拉函数值的连续倍数来找到它。对于每个倍数,我们加 1,看看结果是否能被公钥整除。当能整除时,除法的结果就是私钥。
过程在表 3-3 中进行了演示。10,752 的第一个倍数是 10,752 本身;Abigail 加 1 得到 10,753,然后用 5 除,得到商 2,150,余数为 3。她尝试第二个倍数 21,504,加 1 后除以 5,得到 4,301 且没有余数,因此她的私钥是 4,301。
表 3-3: 寻找私钥
| 倍数 | 乘以 10,752 | 加 1 | 除以 5 | 余数 |
|---|---|---|---|---|
| 1 | 10,752 | 10,753 | 2,150 | 3 |
| 2 | 21,504 | 21,505 | 4,301 | 0 |
当然,使用更大的数字可能需要更多的倍数来找到私钥,但总有一个数字会通过测试。测试的倍数总是小于公钥(在我们的例子中,Abigail 知道她最多四次尝试就能找到私钥)。无论如何,现在 Abigail 已经得到了她的私钥,实际的加密过程可以开始了。
使用 RSA 加密数据
Abigail 将她的素数积(10,961)和公钥(5)通过电子邮件发送给 Zed。因为这些数字无法让任何人解密生成的密文,所以在邮件到达 Zed 之前,其他人是否阅读邮件并不重要。
新的 PIN 加密实际只需要两步。
步骤 1
Zed 将 PIN 1,482 提升到公钥 5 的次方——也就是将 1,482 自己乘以五次:
1,482 × 1,482 × 1,482 × 1,482 × 1,482 = 7,148,929,565,430,432
步骤 2
第二步是找出步骤 1 的结果除以素数积的余数。在这种情况下,10,961 可以进入 7,148,929,565,430,432 大约 652 亿次,但 Zed 关心的只是那次除法的余数是 2,122。Zed 将这个余数发送给 Abigail。
步骤 3
在接收端,Abigail 执行两步类似的操作来解密密文。她首先将密文数字 2,122 提升到私钥 4,301 的次方。因为 2,122^(4,301) 非常庞大——超过 14,000 位——我这里不展示它。
步骤 4
Abigail 找到将步骤 3 中得到的巨大数字除以素数积的余数。这个余数正好是 1,482,揭示了 Zed 的 PIN。
RSA 的有效性
记住,RSA 的目标,像任何加密系统一样,是使加密简单,目标接收者的解密也简单,而其他人解密非常困难。我们 RSA 示例的总结见 图 3-4。
即使使用更大的素数,借助计算机,加密和授权解密也是简单的,回顾我们示例中的步骤就可以看出这一点。
-
Abigail 选择了两个素数并将它们相乘,得到了她的 素数积。相乘两个数字很容易。
-
Abigail 通过先从两个素数中每个减去 1 然后再相乘来计算 欧拉函数。减法和乘法很容易。
-
Abigail 选择了一个 公钥,这个数字与欧拉函数没有任何公因数。对于大数字来说,手工计算这个是不可行的,但对计算机来说,这是容易的。
-
Abigail 找到了合适的 私钥,它应该在与她的公钥相乘时,得到一个比欧拉函数的倍数大 1 的数字。这手动计算很麻烦,但对计算机来说,这也是容易的。
-
Abigail 发送了素数积和公钥给 Zed。
-
Zed 将 PIN 提升到公钥的幂。对计算机来说,这是相对简单的。
-
Zed 将上一步的结果除以素数积并取余。除法很简单。
-
Zed 将其余部分发送给了 Abigail。
-
Abigail 将 Zed 发送的数字提升到私钥的幂。很简单。
-
Abigail 将前一步的结果除以素数积并取余,从而得出了 Zed 的 PIN。很简单。

图 3-4:RSA 示例的总结。中间的框显示了 Zed 的责任;其余的是 Abigail 的。
对授权方来说,RSA 加密和解密对于计算机而言是轻松的工作,但未经授权的解密则极其困难。要解密,攻击者必须同时拥有素数积(Abigail 会公开给出)和私钥(Abigail 会自己保留)。那么攻击者如何计算私钥呢?找到这个数字意味着首先要找到素数积的欧拉函数,但请记住,Abigail 之所以能快速计算欧拉函数,是因为她知道生成素数积的两个质数。没有这两个质数,攻击者必须通过繁琐的方式来计算欧拉函数——检查所有小于素数积的数字,找到所有互质的数。
在我们的示例中,素数积很小,所以计算机以这种暴力方式找到欧拉函数是可行的。然而,实际上,素数积非常大,根本无法以这种方式找到其欧拉函数。事实上,攻击者更应该去寻找组成素数积的两个质数,利用捷径方法来计算欧拉函数。不过,这仍然需要检查所有小于素数积平方根的数字,所以对于大数字来说,这和通过繁琐方式找到欧拉函数一样不切实际。
因此,RSA 加密方法创造了我们期望的数字“保险箱”对应物。加密和解密不再共享相同的秘密,所以知道如何锁定数据并不能提供解锁它的能力。
RSA 在现实世界中的应用
我们的简化示例展示了 RSA 加密的基本原理,但在实际应用中,我们还需要考虑其他一些细节。
双向传输
示例中展示的系统允许 Zed 安全地传输给 Abigail,但反过来则不行。如果他们想要双向传输安全信息,Zed 必须像 Abigail 那样,完成所有的步骤,生成自己的素数积、欧拉函数、公共密钥和私有密钥,并将素数积和公钥发送给 Abigail。
密钥大小
在 RSA 中,加密或解密的最后一步是对素数乘积进行取余运算,这意味着明文数字必须小于素数乘积。在 Abigail 和 Zed 的示例中,最大的明文数字为 14,960。这对 Zed 和他的四位数 PIN 码来说不是问题,但对于一般用途来说,需要更大的范围。
同样重要的是,素数乘积的值越大,攻击者找到两个素因子的难度就越大。换句话说,素数乘积的大小直接影响加密的安全性。在当前的实践中,选择素数以产生至少 1024 位的素数乘积。如你所记得,在第一章中描述的高级加密标准(AES)仅使用 128 或 256 位的密钥。所以我们讨论的是一个真正巨大的数字——1024 位相当于一个超过 300 位的十进制数。
长明文与性能
一个 1024 位的密钥可以加密非常大的数字。但典型的文本、图像或音频文件是一长串小数字,而不是一个大数字。我们如何使用 RSA 传输一长串数字呢?对于 AES,长文件会被切分成必要的多个 128 位块。在理论上,我们也可以用 RSA 做到这一点,将文件切分成多个 1024 位块,并对每个块应用 RSA。问题在于 RSA 加密比 AES 慢得多。
AES 的步骤比 RSA 加密标准多,但即便如此,AES 的性能很高,因为这些步骤本身非常简单。最常见的操作是 XOR 和位移,这些操作本身非常简单。你可以通过在脑海中运算这些操作的结果来理解这一点,如图 3-5 所示。

图 3-5:计算 XOR 或旋转位到新位置是很简单的。
相比之下,RSA 过程只有几个步骤,但由于依赖指数运算,整体工作量较大。考虑一个相对较小的指数:17¹⁶。写出来是……
17 × 17 × 17 × 17 × 17 × 17 × 17 × 17 × 17 × 17 × 17 × 17 × 17 × 17 × 17 × 17
试着在脑海中计算这个,你就能看出问题所在。现在,想象一下涉及数百位数字的指数。尽管计算机能够处理这些运算,但显然,指数运算要比简单的 XOR 运算费时得多。由于指数运算需要大量时间,使用 RSA 处理大量数据并不实际。
结合系统
解决 RSA 性能问题的方法很简单:不要使用 RSA 传输大量数据。相反,使用 RSA 传输另一个更快方法的加密密钥,例如 AES。
返回到 Abigail 和 Zed,假设 Zed 需要向 Abigail 发送一份他已经用 ASCII 表将其转换为一系列数字的长文档。Zed 会更倾向于使用 AES 加密该文档,而不是采用 RSA 的复杂工作。但要使用 AES,Zed 和 Abigail 都需要共享一个 AES 加密密钥。RSA 提供了安全共享该密钥的方法。Zed 可以自己创建 AES 密钥,然后使用 Abigail 的公钥用 RSA 加密该密钥。然后 Zed 可以用 AES 加密长文档,Abigail 可以使用他们现在共享的密钥解密得到的密文。这个过程在图 3-6 中得到了说明。

图 3-6:结合 RSA 和 AES 以生成具有高性能的非对称公钥系统
在此图中,A 锁符号表示“用 AES 加密”,而 R 锁符号表示“用 RSA 加密”。通过发送 AES 加密的文档和用她的公钥 RSA 加密的 AES 密钥,Abigail 拥有解密文档所需的一切,但拦截传输的攻击者无法在没有 Abigail 私钥的情况下解密文档。
通过结合这两种加密方法,我们将它们的优点结合在一起,获得 AES 的高性能和 RSA 的共享密钥。公钥加密通常以这种方式使用,用来启动一个本来无法实现的对称密钥加密过程。
RSA 用于身份验证
公钥加密技术带来了身份验证问题。因为公钥本身就是公开的,任何人都可以向私钥持有者发送加密信息;因此,接收者无法确定发送者的身份。这一问题在对称密钥加密中不会出现,因为当一个密钥可以共享时,它的保密性不仅确保了信息的安全,还能确保信息来自于持有该密钥的另一方。幸运的是,公钥加密也可以用于身份验证。
使用 RSA 进行身份验证
在我们的 RSA 示例中,Abigail 拥有她的素因子积 10,961 和她的私钥 4,301,而 Zed 拥有素因子积和 Abigail 的公钥 5。这使得 Zed 能够向 Abigail 发送安全信息,同时也使 Abigail 能够向 Zed 发送经过身份验证的信息。
假设 Abigail 想要将相同的 PIN 码 1482 返回给 Zed,以确认已收到信息,并且以 Zed 能够确定该确认来自 Abigail 的方式发送。
Abigail 将 PIN 码 1,482 作为底数,提升到她的私钥的幂(而不是用于加密的公钥)。1,482^(4,301)是另一个巨大的数字——它有超过 13,000 位——所以我不打算在这里写出来,但当这个巨大的数字除以 10,961 的素数积时,余数是 8,742。Abigail 将这个余数通过电子邮件发送给 Zed。Zed 现在将 8,742 提升到 Abigail 的公钥 5 的幂,结果是 51,056,849,256,616,667,232。最后,Zed 将这个数字除以素数积,得到余数 1,482。Zed 认出这个数字是 PIN 码,并知道它一定是用 Abigail 的私钥进行转换的,从而证明这个数字来自 Abigail。RSA 安全性与认证之间的关系如图 3-7 所示。

图 3-7:RSA 过程提供加密或认证。
我们可以通过将此认证过程应用于像 AES 这样的系统的加密密钥,来认证整个文件,并将加密文件和认证过的密钥一起发送给接收者。
因此,RSA 过程可以生成经过认证的消息或安全的消息,具体取决于我们是使用私钥还是公钥进行加密。理想情况下,我们希望消息既能被认证又能保持安全。我们可以通过将该过程的两种变体应用于同一消息来实现这一目标。在我们的示例中,如图 3-8 所示,Abigail 可以用她的私钥加密她要传输的数字,然后用 Zed 的公钥对结果进行加密。收到后,Zed 将逆转这些步骤,首先使用他的私钥解密,然后再次使用 Abigail 的公钥解密。

图 3-8:使用发送者的私钥和接收者的公钥应用 RSA 提供认证和安全性。
身份验证机构
你可能已经注意到,认证引入了一种更微妙的共享密钥问题版本。Zed 知道电子邮件来自 Abigail,因为他认出了用 Abigail 的公钥转换该数字时产生的 PIN 码,这意味着发送者必须拥有匹配的私钥。但如果 Zed 担心有人冒充 Abigail,他如何知道公钥最初是由 Abigail 发送的,而不是一个黑客入侵了 Abigail 的邮箱账号的冒名顶替者?
这个问题的解决方案是一个权威机构,一个帮助验证身份的第三方。正如你将看到的,权威机构提供了数字等同于身份证的服务。当两台计算机通过交换公钥启动一个安全的、经过身份验证的传输时,它们会展示各自的身份证明,这样可以确保每台计算机知道对方的身份。当然,这假设每台计算机都信任提供身份验证的权威机构,所以最终,身份验证要求你对某个机构有隐性信任。你要么相信传输确实来自声称发送它的实体,要么相信某个第三方来识别发送者。身份认证权威是本章最终讨论主题——网络安全的一个至关重要的组成部分。
网络安全:HTTPS
网页是通过 HTTP 传输的,HTTP 代表超文本传输协议。当这些数据被安全传输时,它被称为 HTTPS,其中S代表安全。这就是为什么当你在浏览器的地址栏中看到https时,表明你正在传输敏感数据——或者我希望你能看到。网络安全是大多数人认为理所当然的事情,但要在两个可能刚刚建立联系的自动化方之间立即建立信任和安全,这需要所有你已经看到的技巧和技术,真是一个了不起的成就。
在本讨论中,假设你正在通过计算机或手机从零售网站购买商品。在这种情况下,你的计算机被称为客户端。为零售商运行网站的计算机被称为服务器。这是你第一次从这家零售商购买商品,所以你必须提供送货和账单信息,比如你的地址和信用卡号。这个情况迫切需要安全保障,但它也需要身份验证。
为了理解其原因,你必须记住,计算机并没有直接连接到服务器。你的数据会通过你的互联网服务提供商(ISP)管理的计算机、零售商的 ISP 管理的计算机,甚至可能通过既不由两者管理的中介系统传递。任何这些系统都可能被攻击者攻破,从而感染系统并拦截原本要传输给零售商的数据,伪装成零售商进行响应。如果发生这种情况,当你下订单时,你实际上是把数据交给了攻击者,而不是零售商。虽然数据是加密的,但它是使用被感染系统提供的密钥加密的,因此加密只确保没有人窃听你发送给攻击者的数据。这种冒充身份的攻击被称为中间人攻击,而它可以通过良好的身份验证来防止。
握手过程
数据的安全传输是在会话中进行的。会话是网络中相当于电话通话的过程:当你第一次加载网站页面时开始,且在你一定时间内没有与网站互动时结束。
在传输开始之前,客户端和服务器必须成功执行一个被称为握手的过程。这个名字意味着两台计算机仅仅是互相打个招呼,但实际上更像是一场犯罪剧中的紧张场面,一方不愿在另一方拿出公文包里的现金之前,展示面包车后面的“东西”。如果握手成功,客户端就能验证服务器身份,并生成将用于加密整个会话期间数据的密钥。与阿比盖尔和泽德的情况类似,公钥加密系统仅在足够的时间内用于交换所需的密钥,然后切换到性能更好的私钥加密系统。
步骤 1
客户端告知服务器其支持的加密方法。HTTPS 协议允许计算机从一套可接受的加密方法中选择,这意味着不同的安全网站可能使用不同的加密技术,从而提供不同级别的安全性。除了加密支持信息外,客户端还会提供一个随机生成的数字——你很快就会看到这个数字的作用。
步骤 2
服务器返回其支持的加密方法列表,并且还会返回其证书。该证书包含多个数据项,包括网站的域名(例如amazon.com)和证书颁发机构的名称(验证网站身份的权威机构)。证书中还包含服务器的公钥。HTTPS 可以使用多种不同的公钥加密系统,但 RSA 是常见的。服务器对每个与其交易的客户端使用相同的证书,因此每个证书只需创建一次公私钥对。虽然这意味着服务器对所有客户端使用相同的 RSA 密钥,但正如你将看到的,RSA 密钥只在握手阶段使用。
服务器证书还包含签名。正如在第二章中讨论的,数字签名是哈希码。在这种情况下,服务器对证书数据进行哈希处理,并使用服务器的私钥加密哈希码。
此外,服务器还会向客户端发送一个随机数字,就像客户端向服务器发送了一个随机数字一样。
步骤 3
客户端验证证书。验证过程有两个方面。首先,客户端使用服务器的公钥对证书中的哈希码进行解密,然后对证书本身进行哈希处理,并比较两个哈希码。如果哈希码匹配,则证明证书在内部有效,但这并不意味着这是该站点的实际证书。
现在客户端必须检查证书的颁发者,这个颁发者是与你的浏览器内建信任的认证机构。如果你深入浏览器的选项,你会发现一个名为“受信任的根证书颁发机构”的颁发者列表。颁发者提供站点证书的副本;当该证书与服务器提供的证书匹配时,客户端就能确认服务器的身份。
步骤 4
客户端生成另一个随机数,长度为 48 字节或 384 位,称为预主密钥。顾名思义,这个数字必须保持机密。然而,客户端需要将其发送给服务器,因此客户端使用服务器的公钥对其进行加密。
步骤 5
客户端和服务器通过对预主密钥和在前两个步骤中交换的两个随机数进行哈希运算,独立地生成 384 位的主密钥。一旦主密钥生成,预主密钥和其他两个随机数就会被丢弃。
请注意,主密钥并未在客户端和服务器之间交换。此时,客户端和服务器已经拥有创建主密钥所需的所有数字。它们独立地将这些数字通过相同的过程处理,以生成相同的结果。
握手过程的总结如图 3-9 所示。

图 3-9:HTTPS 握手过程
通过 HTTPS 传输数据
现在客户端和服务器可以开始传输实际的数据——服务器的网页和媒体,以及客户端的用户数据。384 位的主密钥被分成三个 128 位的部分,每部分提供不同的安全性。
数据加密
主密钥的第一部分被用作私钥加密系统(如 AES)的密钥。在安全会话期间,后续的每次数据传输都将使用此密码密钥进行加密。
区块链
由于网页具有标准的标题格式,这可能为攻击者提供线索,因此采用了诸如区块链(在第一章中讨论)等方法。正如你可能记得的那样,这种系统需要一个起始值来加密传输的第一个数据块;主密钥的中间 128 位部分被用作这个起始值。
消息认证码
主密钥的最后 128 位部分用于为每次传输创建消息认证码,或MAC。在这种情况下,我们并不是要认证发送者的身份——这一点已经在握手阶段处理过了。相反,MAC 确保数据在传输过程中没有被篡改。
在这个过程中,每个传输都会通过像 MD5 这样的函数进行哈希,但首先,传输数据会与主密钥剩余的 128 位部分组合。这被称为带密钥哈希,在这个上下文中,这 128 位部分被称为MAC 密钥。使用带密钥的哈希有助于防止中间人攻击。一个希望向接收方传递伪造传输的攻击者需要 MAC 密钥来生成一个哈希值,使接收方接受这个哈希值为真实的。
哈希发生在加密之前,这样原始消息和哈希值都会被加密。
共享密钥问题解决了吗?
这就是数据如何在 Web 上安全传输的方式。正如你所看到的,解决共享密钥问题几乎需要用到加密学工具包中的每一个技巧。公钥加密为初始通信创建了安全通道。私钥加密用于保护单独的数据传输。哈希算法验证了会话和单个消息的身份。如果网站使用密码来认证用户,那么第二章中的所有密码技术也会派上用场。
Web 安全是一个复杂的技术体系。正因如此,它也带来一个潜在的问题:计算机安全的复杂性可能掩盖了其中的弱点。就像一台部件更多的机器有更多部件可能出现故障一样,众多精细方法和技术的层叠也可能掩盖了尚未发现的漏洞。有时候,漏洞并非存在于某个单一部分,而是存在于各部分之间的连接方式。尽管像 RSA 和 AES 这样的算法目前被认为是安全的,但聪明的攻击者可能会找到方法,在不破坏底层加密方法的情况下突破安全。
例如,HTTPS 的早期版本曾易受到某种中间人攻击,这种攻击源于观察到大多数安全会话开始时,用户点击了一个链接。假设,例如,你收到了来自发行你信用卡的银行的一封邮件,里面有一个链接指向你最近的账户对账单。该链接是一个 HTTPS 地址,这意味着当你点击它时,你的浏览器会启动并请求与银行服务器建立安全连接。然而,这个请求本身并不安全。攻击者的程序可以拦截这个请求,并将其转发给银行服务器,作为一个普通的未加密 HTTP 连接请求,然后窃听所有随后的未加密流量。用户可能会通过地址栏中的前缀发现异常,但有多少用户会想到检查这一点呢?为了解决这个安全漏洞,Web 服务器现在可以告知浏览器,所有连接必须通过 HTTPS 进行——但这个解决方案并不能防止一个能够拦截公告的攻击者。最终的解决方案可能是要求所有 Web 通信都必须使用 HTTPS。
毫无疑问,未来会发现新的漏洞,这需要发明新的防御措施。计算机安全是一个不断变化的目标。我们永远无法宣称我们的数据完全安全,但依靠最佳实践可能使我们始终领先一步,超越攻击者。
第四章:4
电影 CGI

一些软件最令人印象深刻的工作可以在电影院中看到。那些在早期时代通过模型、底片画、复杂的服装和特技摄影制作的图像,现在都可以通过计算机来实现。计算机生成图像(CGI)不仅简化了电影制作过程,还创造出了以前无法实现的图像。对许多影迷来说,当他们看到《侏罗纪公园》时,电影的历史永远改变了。当斯蒂文·斯皮尔伯格在开发这部电影时,他本打算使用像自动化木偶和动画微型模型这样的传统特效来制作恐龙,但在看到一些计算机动画的测试镜头后,他决定使用 CGI 来制作许多恐龙镜头。结果让观众对图 4-1 中展示的全景画面感到震惊。为对比,用传统方式将恐龙放入电影的效果可以参见图 4-2。

图 4-1:CGI 恐龙在《侏罗纪公园》中造访水源地(环球影业/安布林娱乐,1993 年)。

图 4-2:《海底两万里》中的怪兽(杰克·迪茨制片公司,1953 年)在康尼岛大吃大喝。
尽管像《侏罗纪公园》这样的电影令人惊叹,但它们只是 CGI 革命的开始。现在,像《阿凡达》这样的电影利用 CGI 创造出完整的世界,以至于观众无法确定镜头中的哪些部分是物理上真实的。如果有的话。只要有足够的时间和资金,似乎电影制作人可以创作出任何他们能想象的东西。
然而,在计算机通过恐龙和郁郁葱葱的外星行星震撼我们之前,它们已经开始改变传统动画电影的世界。计算机不仅彻底改变了传统动画的制作过程,而且正如你将要发现的那样,所使用的概念和技术构成了计算机图形学几乎所有内容的基础。CGI 的故事从这里开始。
传统动画软件
电影是一系列静态图像或帧,以快速连续的方式呈现给观众,就像一个高速幻灯片。每一帧图像在消失后会在视网膜上停留片刻,实际上与下一帧图像融合在一起,从而产生连续运动的错觉——这一现象被称为视觉延续。传统上,电影以每秒 24 帧(fps)的速度播放。制作电影意味着每秒钟需要制作 24 张图像。
现实电影通过相机实时收集影像。不过,像《忠犬历险记》这样的传统动画电影则有些不同:电影的每一帧都是经过单独拍摄的、手工制作的艺术作品。
传统动画是一项庞大的工作,需要一个庞大的艺术团队。通常,每个动画电影中的角色都会指定一个首席动画师,但首席动画师并不会在每一帧中都画出该角色,因为对于一个人来说那样工作量太大了。相反,首席动画师只会绘制足够的关键帧来提示动作——也许只是每隔几帧才会绘制一次。这时,其他动画师会绘制中间帧以完成整个动画序列,这一过程称为tweening(过渡绘制)。在此阶段,动画仍然只是纸上的一系列铅笔画。这些画必须被转移到透明的纤维素片上,这也是这种风格的动画被称为cel 动画的原因。接下来是动画师所称的“墨水和上色”:淡淡的铅笔线会被黑色墨水描摹一遍,然后将纤维素片上色。最后,这些片段被放置在单独绘制的背景前并进行拍摄。
正如你所预料的那样,过渡绘制、描线和上色都是繁琐且耗时的工作。大约从 1990 年开始,计算机图像被用来模仿 cel 动画风格,而且所需的人工劳动要少得多。
数字图像是如何工作的
在传统动画电影中,每一帧都是一张物理艺术作品的照片,但计算机动画则使用数字图像——由数字数据定义的图像。
当你观看电视、智能手机屏幕或数字投影影院屏幕等视频显示时,进入你眼睛的图像是由不同颜色的点组成的,这些点被称为像素。图 4-3 展示了一个树木和蓝天的图像,这个图像是由像素网格组成的。这个 10×10 网格中的每个 100 个像素都有一个指定的颜色,颜色通过名称来指定。

图 4-3:由像素构成的树
尽管我们可以将每个像素看作是一个纯色,但实际情况稍有不同。例如,在家里,你可能会在一台普通的液晶显示器(LCD)电视上观看电影,在这种电视中,像素颜色由电控晶体决定。在 LCD 屏幕的背面有一个光源,要么是荧光灯,要么是一系列发光二极管(LED)。光源本身是白色的。在光源前面是一个半透明的面板,上面有三种原色条——红色、绿色和蓝色,如图 4-4 所示。

图 4-4:三种纯原色条形图组成一个 LCD 像素。
一层液晶位于光源和颜色面板之间,每个半透明条的后面都有一个单独控制的液晶晶体。你可以把这些晶体看作是电控门,每个晶体门的开度决定了光线透过的多少。通过调节红色、绿色或蓝色的量,每个像素可以产生数百万种颜色。这是加色混合,即增加颜色会使结果更亮。例如,如果我们希望某个像素呈现明亮的黄色,我们会将红色和绿色的级别设置得较高,而蓝色的级别设置得较低。如果我们想要深灰色,我们会将每种颜色条的强度设置为相同的低值。所有三种颜色在最大强度下会产生纯白色。本章后面我们将看到减色混合的例子,这可能是你从美术课上记得的内容,其中添加更多颜色会使结果更暗。
颜色是如何定义的
定义像素颜色最常用的方法是RGB系统,该系统通过三个数字表示像素中红色、绿色和蓝色的强度。这些数字通常在 0 到 255 之间,以匹配 8 位字节的范围。这意味着每个 RGB 像素由三个字节的数据指定。
就软件而言,数字图像(如图 4-3 所示)只是一组颜色数据的字节,每个像素占用三个字节。这组字节称为图像的位图。位图中的前三个字节是图像左上角像素的红色、绿色和蓝色级别,依此类推。图像或位图的宽度和高度(以像素为单位)被称为其分辨率;例如,图 4-3 的分辨率是 10×10。一个称为显示缓冲区的位图存储数字显示器(如液晶电视)每个像素的颜色;最终,计算机图形方法就是设置显示缓冲区中的数字。
位图中某个特定像素的位置通过两个坐标来指定,一个是水平位置的x-坐标,另一个是垂直位置的y-坐标。坐标(0,0),称为原点,可以位于角落或中心;它在不同的坐标系统中有所不同。在物理显示器上定位像素时,我们称坐标为屏幕坐标。屏幕坐标系统通常将原点设置在左上角像素,因此 1920×1080 屏幕上像素的位置如图 4-5 所示。在这里,y 轴向下移动时增大,x 轴向右移动时增大,中心位置是(960, 540)。

图 4-5:在 1920×1080 屏幕上定位像素
坐标系统是计算机图形学中无处不在的一部分,正如你将在本章和下一章看到的那样,制作图形的大部分工作涉及将坐标从一个系统转换到另一个系统。
软件如何制作原子动画
现在你已经了解了数字图像的构成,你准备好了解软件如何制作看起来像传统原子画的数字图像了。第一步是将艺术家的作品导入计算机。
将图形转化为模型
软件生成的原子动画的起始方式与传统动画相同:艺术家先画出角色。不过,艺术家不是在纸上绘制,而是使用鼠标或电子笔绘制,绘制过程由软件记录。为了最终生成位图图像,我们需要一个系统来数值化艺术家的笔触,生成一个模型。模型中的位置被称为局部坐标。图 4-6 展示了一个虫人图形,它被放置在一个定义局部坐标空间的框内。

图 4-6:在定义坐标限制的框内绘制的虫人图形
该模型中的每一条线和曲线都通过这些局部坐标来定义。像我们角色的天线和腿这样的直线段,可以通过线段两端的点的坐标来定义,如图 4-7 所示。请注意,这里的坐标包含小数部分,以提高精度。

图 4-7:通过端点坐标定义直线段
对于曲线,除了端点外,还需要控制点来定义曲线的方向和弯曲程度。可以想象控制点是附加在曲线上的,移动它就能控制弯曲程度,如图 4-8 中的简单曲线所示。如果你曾经使用过矢量图形软件,那么你很可能也接触过这种曲线。

图 4-8:由两个端点和一个控制点定义的曲线
简单的曲线可以仅通过两个端点和一个控制点来表示,但较长、较复杂的曲线则由一系列简单的曲线组成,如图 4-9 中虫人鞋子的示例所示。
线条和曲线仅定义了角色或其他图形的轮廓;轮廓内部的颜色则使用像 RGB 这样的系统来定义。因此,角色模型是所有线条、曲线和颜色数据的数值表示。

图 4-9:由简单曲线构成的复杂曲线
自动过渡
数值定义绘图允许自动过渡。动画师首先绘制角色动画序列中的一帧,然后通过移动前一帧曲线的控制点来创建后续的关键帧。动画软件接着可以通过插值生成其他帧。图 4-10 演示了这个概念。这里,中间点的坐标是其他点坐标的平均值。插值点的 x 坐标 20 介于 10 和 30 之间;y 坐标 120 介于 100 和 140 之间。在这个例子中,所有点都位于一条直线上,但插值路径也可以是曲线。

图 4-10:通过插值计算两个关键帧点之间的中间点
图 4-11 展示了插值如何创建新的动画帧。最左边的面是原始模型;第二个面显示了一些控制点;第三个面则通过将两个控制点向下移动,形成了一个大嘴巴。最右边的面通过线性插值创建,线性插值将每个控制点放置在两个关键帧位置的中间。动画软件可以根据需要创建任意数量的中间位置,以填补关键帧之间的空隙。

图 4-11:从左到右:一个模型,选定控制点的模型,移动了两个控制点的模型,以及通过插值生成的过渡模型
尽管基本的插值过渡可以节省大量时间,但调整大量小点的位置仍然是繁琐的。更先进的动画软件可以将角色绘图视为一个完整的、相互连接的身体,其中定义了刚性连接和关节。这意味着,动画师只需要为每个关键帧定位角色的脚,就能使虫人行走,软件会根据需要自动定位其他部位的腿部。软件甚至可能处理现实世界的物理效果,这样我们虫人跨越树干的动画序列就可以完全由软件自动生成。
定位与缩放
数值建模还允许将图形放置在框架中的任何位置,并可以调整其大小。改变模型的大小称为缩放,通过对每个点的坐标进行乘法或除法来完成。图 4-12 展示了图 4-6 中的虫人模型,通过将每个坐标除以二,将其缩小为原始面积的四分之一。模型上的一个天线点被突出显示,以便展示这一思想。
将模型放置在屏幕的特定位置称为平移,这一过程通过增加或减少坐标的固定值来实现。在图 4-13 中,来自图 4-12 的缩小版虫人被平移到屏幕中央,方法是将每个 x 坐标增加 700,y 坐标增加 200。

图 4-12:缩放模型意味着对每个坐标进行乘法或除法操作。

图 4-13:平移模型意味着在坐标上加减数值。
“数字图像的墨水与上色”
现在,模型上的点已映射到屏幕坐标,接下来是将每一帧转换成位图。这就像是传统手绘动画中的“墨水与上色”过程。为了简单起见,让我们看看当我们的虫人模型的右臂显示在纯白背景上时,如何将其转换为位图,或者说光栅化。图 4-14 展示了右臂覆盖在像素网格上,圆圈标记了像素的中心。
在数学上定义了模型后,软件可以将手臂放置在位图的任何位置,然后将指定的颜色——在此案例中为黑色——应用于适当的像素。然而,我们很快就会发现一个问题:手臂的轮廓与像素的边界并不匹配,那么我们该如何确定哪些像素需要上色呢?一个简单的规则是,当像素的中心被覆盖时就进行上色。图 4-15 展示了基于像素中心上色的结果。

图 4-14:虫人右臂叠加在像素网格上

图 4-15:基于像素中心对像素进行纯黑上色
然而,正如你所看到的,这个结果相当难看。由于像素是方形的,这个上色规则将模型优雅弯曲的边界替换为锯齿状的边缘,这也是这个问题被称为锯齿效应的原因。这个问题的根本在于,模型是平滑连续的,而位图是由方形的黑白像素组成的。位图仅仅是对模型的近似。连续模型与其位图近似之间的差异被称为别名效应,它是计算机图形中许多视觉异常的根源。
为了避免锯齿效应,我们需要使用抗锯齿技术来上色像素。在我们的例子中,我们将使用一系列灰度值,而不是单纯的黑白,来更好地近似模型。每个像素的颜色将根据其被手臂覆盖的程度来决定。
为了将这个想法付诸实践,我们不再只检查每个像素的中心,而是测试每个像素内的多个点,看看有多少个点位于模型内。在图 4-16 中,10 个散布在像素区域内的测试点中有 7 个被形状覆盖,意味着覆盖率为 70%。
每个像素被模型覆盖的百分比决定了其灰度级别。虫人手臂的结果显示在图 4-17 中。尽管这个例子看起来可能不算特别显眼,但如果你把页面拉远一点并眯起眼睛,边缘应当会平滑地融入白色背景,产生优雅曲线的错觉。

图 4-16:虫人手臂末端一个像素的特写,散布着 10 个点以估算模型覆盖的区域

图 4-17:使用灰度进行抗锯齿,展示了带像素网格和不带像素网格的效果。
与任何背景的融合
我们需要将刚才描述的技术进行概括,使其能够在其他背景上也能使用。考虑图 4-18。左侧是虫人模型,中间是他将出现的拍摄背景:日落时分的岩石地貌特写。右侧是将模型叠加在背景上的完整图像。

图 4-18:虫人模型、背景及模型叠加在背景上
本书是黑白印刷的,但在此图像中,太阳的颜色应为红橙色调,地面的颜色应为棕色调。和之前一样,模型边缘的像素会显得锯齿状,除非我们使用抗锯齿技术。然而,使用之前的技术将像素涂成灰色并不能帮助黑色边缘与红橙色和棕色像素的背景融合。
一种更为通用的抗锯齿技术会根据模型覆盖的像素百分比为每个像素计算一个alpha 水平。你可以将 alpha 水平看作是透明度的度量。像颜色级别一样,alpha 水平通常在 0–255 范围内定义。在图 4-19 中,一条黑色条形叠加在树木上,具有不同的 alpha 水平。在 alpha 水平为 255 时,条形完全不透明,而在 25 时条形几乎不可见。alpha 水平为 0 时,条形将完全不可见。
位图中所有像素的 alpha 值统称为alpha 通道。为模型制作 alpha 通道的过程类似于我们如何将黑色手臂与白色背景抗锯齿,只不过这次我们不再根据像素覆盖百分比来赋予灰度值,而是为每个像素分配一个 alpha 值。因此,每个模型从概念上来说被转化为一张位图,显示模型覆盖的每个像素的颜色,以及一个 alpha 通道,显示每个像素的不透明度。图 4-20 展示了虫人手臂的颜色位图(这里只有黑色像素)和 alpha 通道。

图 4-19:一棵树上覆盖了五条具有不同 alpha 水平的黑色条形

图 4-20:虫人模型的手臂及其对应的颜色位图和透明通道
现在,模型可以应用于任何背景。每个像素的最终颜色是背景颜色和模型颜色位图的混合,透明度水平决定了每种颜色在混合中所占的比例。在图 4-18 中的虫人场景中,如果一个黑色的虫人像素具有 30%的透明度,并覆盖在一个红橙色的日落背景像素上,结果将是一个更暗的红橙色,如图 4-21 所示。每个颜色成分的最终比例会在两种混合颜色之间,但由于黑色像素的透明度只有 30%,因此红橙色的背景颜色占主导地位。对于完全被模型覆盖的像素,透明度为 100%,最终图像中的颜色与模型的颜色位图相同。通过这种方式,带有透明通道的位图可以平滑地与任何背景混合。

图 4-21:三种颜色的红、绿、蓝组成部分:模型的黑色,背景像素的红橙色,以及这两种颜色混合后的结果,如果黑色的透明度为 30%
从卡通动画软件到渲染的 2D 图形
这些技术现在已经成为制作卡通风格动画的默认方式,而软件在动画工作室中的地位与早期世代中的画笔和纸张一样普及。虽然一些动画工作室使用自己开发的程序,但大多数直接面向视频或电视的动画以及一些电影是使用现成的软件制作的。其中一个这样的程序,Toon Boom,曾被用于《辛普森一家》和《费尼亚斯与费布》这样的电视节目,而吉卜力工作室的艺术家们则使用一款名为 Toonz 的程序制作了《千与千寻》等电影。
这些技术的应用并不限于电影制作。更广泛地说,用于模拟传统卡通风格动画的软件技术被称为二维图形,或2D 图形,因为模型的控制点是通过两个坐标 x 和 y 来定位的。将模型转换为最终图像的任务称为渲染,执行这一任务的软件被称为渲染器。渲染的 2D 图形在计算机中广泛应用。许多视频游戏,如愤怒的小鸟,都使用卡通动画风格。这些渲染技术也被用于显示浏览器和文字处理器等应用中的字体和图标。
尽管渲染的 2D 图形在计算机中无处不在,并且可以制作出很棒的卡通风格动画,但像阿凡达这样的电影中的震撼视觉效果,要求将这些理念扩展到三维。
3D CGI 软件
像《阿凡达》这样的电影中的震撼 CGI 使用了3D 图形。这里的“3D”并不指模拟的深度感知(像在 3D 电影中那样),而是指动画模型中每个控制点的三个坐标:x 和 y 坐标用于水平和垂直定位,z 坐标用于表示深度。图 4-22 显示了一个三维模型的盒子,其中一个高亮显示的点是通过 x、y 和 z 坐标定义的。

图 4-22:三维空间中的一个盒子
和 2D 图形一样,3D 图形的核心是将模型渲染成位图。产生最逼真效果的渲染方法通常需要最长的处理时间。电影 CGI 之所以令人印象深刻,主要是因为渲染器可以对每一帧进行非常长时间的处理,产生出高质量的效果,我称之为电影级渲染。本章将讨论电影级渲染的关键。然后,在第五章中,我们将讨论视频游戏的图形,并探讨当图像必须实时生成以响应用户交互时,许多这里展示的技术如何被修改、伪造或完全放弃。
如何描述 3D 场景
3D 模型和 2D 模型一样,是由线条和曲线构成的,但这些线条和曲线在三维空间中延伸,而不是二维空间。图 4-22 中的盒子是一个由八个点定义的非常简单的模型;而电影 CGI 中使用的模型通常非常复杂,由成百上千,甚至成万个点定义。与 2D 渲染一样,3D 渲染中的模型是通过局部坐标定义的。例如,位于图 4-22 中盒子角落的点,是相对于盒子底部的局部原点来定义的。
虽然 2D 渲染可以直接从局部坐标映射到屏幕坐标,3D 模型首先需要被放置到虚拟世界中的场景里,这个虚拟世界有自己称为世界坐标的坐标空间。设计一个 3D 场景就像是制作电影布景。我们可以在虚拟世界中放置任意数量的模型,任意大小和位置,渲染器可以计算出这些模型中所有位置的世界坐标。
引入另一种坐标系统可能看起来是多余的复杂性,但从长远来看,世界坐标实际上使得 3D 图形变得更加简单。例如,艺术家可以独立建模一个餐厅椅子,而不需要考虑它将被使用的场景中的其他模型。然后,艺术家可以复制这个单一的椅子模型,制作出餐厅场景所需的座位数量。此外,一个场景,像电影布景一样,不是为了产生单一的图像,而是为了创造一个将在多个角度的许多图像中展示的空间,正如我们在下一节中将看到的那样。
虚拟相机
在场景确定之后,接下来需要一个视点。在电影拍摄中,摄影师通过摆放摄像机和选择镜头来决定捕捉到的图像。在计算机生成图像(CGI)中,视点决定了如何将三维场景转换为二维渲染图像。
从三维到二维的转换被称为投影。为了更好地理解投影,考虑图 4-23,其中一个虚拟的金字塔从观看者的眼睛出发,观看者正在注视着桌子。一个半透明的网格位于观看者与场景之间的金字塔内。通过这个网格观看,观看者可以将三维桌面上每个可见的位置映射到二维网格上的一个特定方格。这就是投影,但不同的是,它不是由方格组成的网格,而是由位图中的像素组成的网格。

图 4-23:将三维场景投影到平面显示屏上就像通过一个半透明的网格观看现实世界的场景。
直接照明
投影有许多不同的方法,但电影质量的渲染中的投影方法是照明这一更大问题的一部分。虽然我们通常不会意识到,但我们对物体颜色的感知不仅仅取决于物体本身,还取决于我们观看物体时所处的照明条件。意识到这一点后,电影制作者会精心照亮他们的场景,以营造戏剧效果,但 CGI 中的照明问题更为根本。没有准确的场景照明模型,最终渲染的图像就无法显得真实。
为了理解这一点,让我们以一个简单的场景为例:一张黄色金属桌子放在绿色房间中,如图 4-24 所示。

图 4-24:一个三维场景
从这个视点来看,一些像素将是“桌子”像素,而其他的将是“墙”或“地板”像素。一个简单的渲染器可能会将每个桌子像素着色为相同的黄色,而将其他所有像素着色为相同的绿色。但由于这种着色忽略了照明的影响,最终的图像将是平面的、不真实的。(这种色块会让图像看起来像动画片中的画格——一个有趣的效果,但不真实。)一个电影级别的渲染器需要一个照明模型,使得我们场景中的颜色受虚拟光源的影响。
CGI 渲染器建模的基本现实世界照明效应包括距离效应、漫反射和镜面反射。
距离效应
为了理解距离效应,想象一盏纯白光的灯直接挂在桌子中央上方,正如图 4-25 所示。
光离桌子越近,桌面看起来就越亮。在物理世界中,这种效应是由光束随着远离光源而逐渐扩展造成的。光源越集中,光随距离的衰减越少——这也解释了为什么激光的高度集中光几乎不会衰减。

图 4-25:光离表面越近,表面看起来就越亮。
渲染器可以逼真地模拟距离效应,但它们也允许不现实的距离效应,以创造特定的外观或氛围。例如,在一个角色拿着火把穿过洞穴的场景中,灯光设计师会决定火把光是照得很远,还是几乎无法穿透黑暗。
我们将讨论的所有光照效应都允许这种类型的调整。虽然故意制造不现实的光照效果看起来可能很奇怪,特别是在光照模型的目的是为了创造一个现实的场景,但现实与观众对现实的预期之间有一个微妙而重要的区别。以不现实的方式使用光照是一种古老的电影技巧。例如,当一个角色在昏暗的卧室里打开台灯时,布景天花板上的舞台灯也会亮起,这样整个场景就被柔和地照亮了。如果没有额外的不现实光源,场景看起来就不对——它会显得太暗。以同样的方式,CGI 光照模型允许对其控制进行微调,产生略显不正确但感觉正确的效果。
漫反射效应
正对着表面照射的光看起来比从锐角照射的光要亮。在图 4-26 中,桌子中央看起来比角落更亮,或者说更偏黄。

图 4-26:漫反射光照取决于光线照射表面的角度。
这部分是由于距离效应——中心离台灯比角落更近——但主要是由于漫反射效应,这是由光的入射角变化引起的亮度变化。在图 4-27 中,实线表示入射光线,而虚线表示反射光线。正如你所看到的,光线在 B 点的入射角远大于 A 点,因此 B 点看起来比 A 点更亮。但请注意,观察角度或反射角对漫反射效应没有影响。因此,A 点对两个观察者来说看起来是一样的,B 点也是如此。

图 4-27:漫反射光照的亮度会根据光线照射表面的角度变化,但从所有视角来看是一样的。
镜面反射效应
由于金属桌面具有高度的反射性,它部分地起到了镜子的作用。与任何镜子一样,镜子中看到的内容取决于与观察角度相对的物体位置。图 4-28 显示了桌面上的一个亮点,那里悬挂的灯与我们的视角处于相对角度,大约位于桌子中心和最近边缘之间。由于这个亮点是白色灯泡的镜面反射,这个亮点将是白色的。

图 4-28:镜面光照依赖于光线击中表面的角度和观察角度。
这些闪亮的点被称为镜面反射,出现在光线的入射角与反射角匹配的地方。图 4-29 显示了两个不同视角下镜面反射的位置;注意每条光线的反射角与它击中桌面的角度相同。两位观察者都能看到桌面上的亮点,但他们看到的亮点位置不同。
在现实世界中,一些材料的反射方式与其他材料不同。像塑料这样光滑的材料具有较高的镜面反射,而像棉布这样哑光的材料则具有更多的漫反射。CGI 光照模型允许艺术家为模型上的每个表面设置不同的反射属性,以匹配真实世界材料的外观。

图 4-29:桌面上的镜面光照在不同视角下出现在不同位置。
全局光照
到目前为止,我们讨论的是直接光照,即光线直接从光源照射到表面。在现实中,物体的颜色受到周围每个物体颜色的影响。在白色墙壁的房间里,一张浅棕色沙发看起来与蓝色墙壁的房间里的沙发非常不同,因为沙发会从墙壁反射的光线中获得微妙的色调。这就是间接光照,为了使计算机生成的图像看起来真实,它必须考虑到这种效果。一个考虑到场景中所有光源,包括直接光和间接光的光照模型,被称为全局光照模型。
一个间接光照的例子见于图 4-30。假设灯泡发出纯白色光线。光束首先照射到一面涂成青色(浅蓝色)的墙上。墙面反射的光线同样是青色的,当反射的青色光线照射到黄色地毯上时,反射光的结果变成了绿色。因此,颜色的反射造成了黄色地毯上微妙的绿色调。这一系列颜色变化是由减色法引起的,在减色法中,混合颜色会导致更深的色调,就像彩色喷墨打印机通过混合青色、黄色和品红色墨水来制造不同的色调一样。减色法与我们在本章早些时候讨论的加色 RGB 系统相反,在加色法中,混合颜色会产生更亮的颜色。

图 4-30:光线在多个表面上反射影响其表观颜色。
光线如何被追踪
全局光照模型似乎要求追踪光束在场景中反弹的路径。那么,一个简单的渲染器可能会使用三维坐标数学来追踪每一束光线从每个光源出发,在表面之间反射的路径。然而,这样做会浪费大量的计算工作,因为它会计算场景中每个表面的颜色——包括那些观察者无法看到的表面,比如那些位于视野之外、被其他物体遮挡或背向观察点的表面。
为什么光线是逆向追踪的
渲染器通过从观察点逆向追踪光线进入场景来避免这种低效的做法,这一技术被称为光线追踪。在光线追踪中,一条假想的光线从观察点出发,穿过像素网格中每个方块的中心,如图 4-31 所示。场景中每个模型的几何形状与这条假想的光线进行比较,看它们是否相交。离观察点最近的交点表示该像素的可见表面,并决定该像素的颜色。请注意,这种投影方法与图 4-23 的解释非常相似。
接下来,从这个已知的可见点向外追踪更多的光线。目标是发现哪些光线最终到达光源,可能是直接到达,也可能是经过其他物体反射后到达。如图 4-31 所示,镜面反射只追踪每次撞击后的反弹,且反弹角度与入射角相同,而漫反射则会在随机方向上追踪多条光线。当漫反射光束撞击其他物体时,会产生更多的漫反射,这意味着随着过程的持续,追踪的路径数目会不断增加。渲染器会应用一个截止值来限制每条光线的反弹次数。

图 4-31:从观察点出发,沿着阴影像素的中心追踪光束,直到它到达场景中的模型。要确定镜面光照,光线按照与入射角相同的角度反弹;而对于漫反射光照,它会在多个随机角度反弹。
光线追踪如何模拟现实世界的效果
尽管光线追踪对于计算机网络来说是一项繁重的工作,但该方法能够准确地模拟许多现实世界的视觉效果。
其中一个效果是半透明效果。虽然可以通过为像素分配较低的 alpha 值使位图变得半透明,但对于像玻璃这样的透明材料,这并不是全部。例如,一只玻璃杯不仅仅允许光线通过它,还会扭曲其后面的物体,正如图 4-32 所示。

图 4-32:弯曲玻璃的光学畸变
光线追踪渲染器可以根据光学定律折射光束,当它们穿过半透明的材料时。这不仅可以让渲染器在计算机生成图像中模拟玻璃,还能帮助再现透明材料和液体(如水)的扭曲效果。
光线追踪还可以扩展到模拟相机镜头。通常,计算机生成的图像中的所有物体都是完美对焦的。然而,在电影镜头拍摄的图像中,只有与相机有一定距离的物体是清晰对焦的,离该距离越远的物体则变得越模糊。虽然有人可能认为将所有物体都对焦是计算机生成图像的优点,但熟练的电影摄影师会使用选择性对焦来帮助讲述故事。在图 4-33 中,吉米·斯图尔特和格蕾丝·凯利在前景中是对焦的,而背景中的公寓则模糊不清;观众的注意力被引导到演员身上,但远处的开阔背景则微妙地提醒着观众,这个庭院中的公寓彼此是多么显眼——这是电影中的一个重要细节。因为电影观众已经习惯了通过对焦来获取场景的深度信息,所以计算机生成的图像和电影通常必须模拟摄影镜头的使用,以匹配观众的预期。

图 4-33:《后窗》中的焦点深度(派拉蒙影业/帕特龙公司,1954 年)
阴影是现实感强的计算机生成图像的另一个关键组成部分。光线追踪自然地产生阴影,如图 4-34 所示。因为没有光束能够照射到阴影区域,所以从观察视点追溯回来的光束也无法到达光源,因此该区域将保持黑暗。

图 4-34:追踪光束自然地渲染阴影。
光线追踪还可以通过为材质设置非常高的镜面反射属性,来模拟高度反射的表面。例如,当你站在一个光线充足的房间里,而外面是黑暗的时候,房间的景象就会清晰地反射在窗户上。
因此,尽管光线追踪在计算上要求较高,但添加这些真实世界效果并不会增加太多额外工作,而且这些效果大大增强了最终图像的真实感。在下一章中,你将看到视频游戏如何在实时渲染中渲染反射表面和阴影效果,当光线追踪不可用时。一些效果,比如玻璃扭曲,通常在实时渲染中是无法实现的;时间根本不够。
全场景抗锯齿
尽管光线追踪渲染的图像可以非常震撼,但它们也可能遭遇我们在 2D 图形中看到的相同的别名问题。每当一个物体位于另一个物体的前面时,每条投射的光束要么会撞击前景物体,要么会错过并撞击位于物体后面的东西。图 4-35 展示了一把椅子和地毯的视图。这个视图中,椅子座位边缘附近投射的光束要么会撞击椅子,要么会撞击地毯,从而将相关像素的颜色赋予椅子或地毯的表面。这导致了我们在 2D 图像中看到的锯齿状边缘。
渲染器可以通过对整张图像应用抗锯齿来避免锯齿现象。有许多全屏抗锯齿方法,但在光线追踪中,直接的抗锯齿方式是从视点投射比必要更多的光束。例如,不仅仅在每个像素的中心发送一束光,渲染器还可能向像素中心之间的空隙发送光束。在确定每束光的颜色后,每个像素的最终颜色是通过将中心光束的颜色与邻近角落的光束颜色混合而得到的。图像中沿着边缘的像素因此会被分配中间色,避免了锯齿状的“阶梯”效应。

图 4-35:在高亮区域中,每条光束轨迹要么结束在椅子上,要么结束在地毯上,导致锯齿状的效果。
图 4-36 展示了这一思想。每个圆圈代表一条投射到场景中的光束。像素的颜色是基于每个像素中心和角落颜色的平均值,这导致了右侧显示的抗锯齿边缘。可以追踪更多的光束以获得更好的效果,但代价是更多的处理时间。

图 4-36:每个像素的最终颜色是从五条光束追踪到场景中的混合结果,中心一条和四个角落的光束。
结合真实与虚拟
在一部完全由计算机动画制作的电影中,渲染是制作每帧的最后一步,但当计算机生成图像(CGI)融入到真人电影中时,还有更多的工作需要完成。例如,假设有一个场景,其中一只计算机生成的暴龙在真实的草地上徘徊。
为了实现这一点,我们首先需要两组数字图像序列。一组显示草地场景,可能是用数字相机拍摄,或者是用传统胶片相机拍摄后再扫描成数字格式。无论哪种方式,摄像机的运动是由计算机控制的,这样可以确保摄像机的运动与另一组序列中虚拟摄像机的运动精确匹配,即计算机生成的恐龙动画。
接下来,两个序列逐帧合成,这个过程叫做数字合成。尽管恐龙序列是通过 3D 模型制作的,但此时两个序列都只是二维位图,并通过与在图 4-18 中将我们的“虫人”叠加到日落背景上的方法相同的方式进行合成。通过使用 alpha 混合技术,每一帧中恐龙的边缘与草地背景的边缘平滑融合。如果没有这种融合,恐龙的边缘就会像气象主播站在五天天气预报前一样,出现闪烁的边缘。
数字合成在现代电影制作中被广泛应用,即使没有涉及计算机生成图像的情况下,例如在溶解效果(一种场景平滑过渡到下一个场景的过渡效果)中。以前,溶解效果是通过一种叫做光学打印机的设备制作的,该设备将相机对准一个屏幕,多个投影仪的图像会被投射到这个屏幕上。相机会拍摄一部新影片,将投影出来的影像结合在一起。通过调节一个投影仪的光线亮度同时调节另一个投影仪的光线亮度,就可以实现溶解效果。虽然效果可接受,但你总能在电影中辨别出光学打印机的镜头,因为第二代影像会比影片其他部分模糊。现在,溶解效果、叠加标题以及各种你可能不会认为是“特效”的电影效果,都通过数字合成来完成。
电影级渲染的理想
当本章描述的所有高级渲染技术结合在一起时,效果可以是令人惊叹的现实主义、极具风格化的,或者介于两者之间。CGI 的唯一真正限制是时间,但这是一个巨大的限制。事实上,我所说的电影级渲染即使对于好莱坞来说,也可能是一个难以达到的理想。虽然电影制作可能需要几年时间,但每一帧的时间分配是有限的。以皮克斯的计算机动画电影WALL-E为例,这部时长 98 分钟的电影需要渲染超过 14 万张高分辨率的计算机图像。如果皮克斯想在两年内完成WALL-E的所有图像制作,那么它平均每 8 分钟就需要渲染一张图像。
即使在一个联网的“渲染农场”中,八分钟也不足以为每一张图像使用光线追踪、全局光照、玻璃折射以及其他所有高端技术。面对这些实际的限制,电影制作人会挑选和选择在每个镜头中使用哪些技术,以最大化视觉冲击力。当需要理想渲染时,时间就会花费,但当最佳效果不影响观感或者预算无法承受时,它们就不会被使用。皮克斯使用的渲染器——一款名为 RenderMan 的程序,最初由卢卡斯影业开发——可以放弃光线追踪及其庞大的计算量,但这意味着许多提升真实感的效果必须通过其他方式来实现。
那么,这是如何做到的呢?在没有光线追踪的情况下渲染图像需要哪些技巧——这些图像可能不完全真实,但依然令人惊叹?为了回答这个问题,我们将从好莱坞转向视频游戏的世界,在那里渲染面临着极端的时间限制。多么极端呢?如果八分钟都不足以产生理想渲染,试想在不到 20 毫秒的时间里渲染一张图像。下一章,我们将看到视频游戏是如何在短时间内制作出精彩图形的。
第五章:5
游戏图形

现代视频游戏就像现代电影一样——一项大型制作,涉及许多不同技术领域的专业知识。程序员团队开发音频、人工智能、网络连接等方面的代码。不过,您在玩视频游戏时首先注意到的还是图形。
早期的视频游戏系统,如雅达利 2600 和世嘉 Genesis,依赖于预制的位图图形;也就是说,没有渲染,甚至没有上一章中提到的 2D 渲染。相反,如果一款视频游戏需要展示游戏中的英雄走路,艺术家会绘制几个位图,并按顺序重复显示。背景也是手绘的。显示分辨率低,且仅提供少量像素颜色选择。
随着显示质量的提高,游戏开发者转向其他技术来生成位图。像真人快打这样的格斗游戏会扫描特技演员的服装照,或至少使用这些照片作为参考。在这一时期,一些游戏确实使用了渲染图形,但并非实时渲染;相反,它们会在更强大的系统上花费较长时间预渲染这些位图。我们今天所知道的 3D 游戏在当时是鲜为人知的,只在少数早期实验中存在。
这种情况在 1990 年代中期开始发生变化。像索尼 PlayStation 这样的游戏主机是围绕 3D 图形能力而非位图构建的。PC 玩家开始购买当时被称为图形加速器的插件硬件,帮助创建 3D 图形。与今天的游戏相比,那些早期的 3D 游戏在图形和其他方面都显得粗糙。此外,由于微软尚未构建 DirectX——游戏软件和图形硬件之间的标准化接口——这意味着游戏必须包含不同的代码,以适配每个制造商的图形加速器,因此很少有 3D 游戏会为 PC 制作。
尽管如此,玩家们对新兴的 3D 游戏着迷,每一代图形硬件的性能都远远超越了前一代。在过场动画——短小的预渲染视频,通常在游戏开始时设置场景,或者在游戏中的关键时刻推动情节发展——这一代际飞跃中,这种变化尤为明显。由于这些视频是在昂贵硬件上预渲染的,就像我们在第四章中讨论的电影 CGI 一样,早期的过场动画比实际游戏中的图形要令人印象深刻得多。然而,随着硬件的进步,游戏画面开始与甚至超越早期游戏的过场动画效果。
如今,很少有游戏使用预渲染的过场动画。虽然游戏仍然可能包含非互动的“电影”场景来设置或推动情节发展,但这些场景更可能是实时渲染的,就像游戏的其余部分一样。这是因为实时渲染效果已经非常好,游戏开发者不再认为做其他处理是值得的。
我认为,这就是为什么我觉得电子游戏图形如此令人惊叹的原因。它们看起来和我在早期视频游戏中看到的预渲染图形一样好,甚至更好,或者说像早期的 CGI 电影一样,而且它们是在实时生成的。这两个词——实时——看起来无害,但它们包含了游戏渲染器面临的巨大挑战。用数字来说:如果你的典型玩家希望获得 60 帧每秒的刷新率,那么每一帧图像必须在仅仅¹/[60]秒内渲染完成。
实时图形的硬件
实时图形质量的提升与图形硬件的进步密切相关。今天的图形硬件非常强大,并且针对 3D 图形渲染中的任务进行了优化。尽管本书是关于软件的,但简要讨论硬件是必要的,因为这有助于理解为什么游戏图形是以这种方式运作的。
计算机或视频游戏主机中的主要处理器是中央处理单元(CPU)。这些处理器可能有多个核心,即独立的处理子单元。可以把一个核心想象成一个办公室工作人员。CPU 内部的核心就像是快速且受过广泛训练的工人。它们擅长做几乎任何任务,并且非常迅速。然而,它们的成本太高,因此通常只能拥有少数几个核心,通常是八个或更少,尽管这个数字会继续增加。
相比之下,图形处理单元(GPU)会拥有数百个甚至上千个核心。这些核心比 CPU 内的核心更简单,每个核心的速度也更慢。可以把它们看作是只能做少数几项任务并且完成得不特别快的工人,但它们的成本非常低廉,以至于可以拥有大量的它们。这种硬件设计方法被采用,是因为单个核心的速度提升已经有限。尽管每一代核心的原始速度有所提升,但仍然远不足以缩小性能差距,无法支持高质量的实时渲染;唯一的解决方案就是更多的核心。
因此,CPU 擅长那些必须按特定顺序完成的任务,就像填报税表一样。而 GPU 则更适合那些可以轻松分配给许多工人的任务,就像粉刷房子的外墙一样。游戏渲染器的设计就是要尽可能让所有 GPU 核心保持忙碌。
为什么游戏不使用光线追踪
我们在前一章中看到,光线追踪可以产生惊人的图形。但游戏不会使用光线追踪,因为它对于实时渲染来说太慢了。其原因有很多。
其中一个原因是光线追踪与“工人军团”GPU 设计不太匹配。例如,光线追踪为每个像素发射一束光线,确定光线的撞击点,然后从撞击点发射更多的光线,继续确定它们的撞击点,依此类推。这个过程更适合由 CPU 来处理,因为渲染器必须在知道接下来要检查哪些光线之前,先确定每个撞击点的位置。
更广泛来说,实时渲染应该把计算资源集中在那些对观众有显著影响的地方。试想一个计算机生成的场景,你站在一个抛光木地板上的椅子前面。一种光线追踪技术会让光线在房间里反弹,从而间接确定椅子背面的每个点的颜色,因为这些数据对于地板的全局光照是必要的。然而,游戏渲染器不可能有这种奢侈,去为一个不会被直接看到的表面上色。
只有线条,没有曲线
为了理解视频游戏是如何在没有光线追踪的情况下进行渲染的,我们从游戏图形的基本构建块——三角形开始。在上一章中,我们了解了电影中的 CGI 模型是由线条和曲线构成的。在游戏渲染中,模型通常完全由线条构成。如果你记得高中代数中画抛物线的情景,你会意识到,描述曲线的数学比描述线条的数学复杂得多,而在游戏中根本没有足够的时间来处理曲线。这就是为什么游戏渲染器使用线条,而这意味着由控制点定义的表面是平坦的。最简单的平面表面是三角形,它由空间中的三个点定义。
三角形在游戏中无处不在。在一款游戏中,无论你以为自己看到的是什么,实际上你看到的都是成千上万的三角形,这些三角形通过角度连接在一起,形成表面和形状。渲染中使用的三角形通常被统称为多边形,尽管几乎所有的多边形都是简单的三角形。
游戏通过使用大量的三角形来模拟曲面。例如,一个圆形的玻璃杯可以被近似为一个由相互连接的三角形组成的环形,如图 5-1 所示。右侧显示了每个三角形的轮廓,以便更清晰地展示。

图 5-1:用三角形近似的圆形玻璃杯
没有光线追踪的投影
为了渲染场景模型中的三角形,渲染器必须将定义三角形的控制点投影到屏幕上。光线追踪通过沿着每个像素中心的虚拟光束进行投影,但在这种情况下,我们必须采取不同的方式。
好消息是,世界坐标和屏幕坐标之间存在直接的数学关系,这使得映射点变得相当简单。我们知道视点的位置信息——x、y 和 z 的世界坐标——以及我们想要投影的模型点的位置。我们还知道虚拟投影屏幕的位置。图 5-2 展示了我们如何利用这些位置信息来确定视线与模型点对准的线穿过投影屏幕时的确切 y 坐标。在这个例子中,投影屏幕的深度(即视点沿 z 坐标的距离)是视点到模型点的深度的四分之三,底部的大块区域就是这种比例的体现。知道这个比例后,我们就能计算出投影点的 x 和 y 坐标。投影点的 y 坐标是视点的 y 坐标与模型点的 y 坐标之间距离的四分之一,如投影屏幕上的阴影框所示。此外,尽管从图 5-2 的角度看不出来,投影点的 x 坐标将是视点和模型点的 x 坐标之间距离的四分之一。

图 5-2:将虚拟世界中的点投影到屏幕上
请注意,虚拟世界中假想的投影屏幕的位置会影响最终的投影效果。为了观察这种效果,可以用双手的食指和拇指做一个矩形框,然后看着它,双手靠近眼睛再逐渐远离。双手离眼睛越远,视野越狭窄。同理,游戏可以通过改变虚拟世界中视点和投影屏幕之间的距离来调整视野。例如,允许玩家通过望远镜或瞄准镜查看的游戏,通过将投影屏幕移得更深来实现缩放效果。
渲染三角形
当三角形的三个顶点都位于屏幕空间时,渲染三角形遵循我们在第四章中看到的相同光栅化过程,将 2D 模型转换为位图。在图 5-3 中,三角形边界内的像素中心被填充为灰色。
从阅读上一章,你可能会对这种简单的三角形渲染方法提出一些异议。首先,为什么我们仅仅把每个像素都填充相同的颜色——那所有的光照效果怎么办?其次,看看那些锯齿状的边缘——我们该如何去除它们?

图 5-3:通过定位三角形的顶点,可以渲染该三角形。
这些问题将会得到解答,但首先我们必须处理一个更基本的问题。仅仅确定每个三角形在屏幕上的位置并给它的像素上色是行不通的,因为屏幕上的每个像素可能会位于多个三角形之内。考虑图 5-4 所示的图像。花盆位于一个立方体后面,而立方体又位于一个高杯子后面。像素 A 位于四个不同的三角形内:杯子前面一个、杯子背面一个、立方体前面一个和立方体侧面一个。同样,四个三角形也包围着像素 B。在每种情况下,实际上只有一个三角形应该决定该像素的颜色。为了正确渲染图像,渲染器必须始终将每个像素映射到场景中距离视点最近的模型表面。光线追踪已经找到了光束与场景中模型之间最近的交点,因此这个问题无需额外努力就能处理。然而,如果没有光线追踪,渲染器应该怎么办呢?

图 5-4:场景中三个重叠的模型
画家算法
一种简单的解决方案被称为画家算法。首先,按照距离视点的远近顺序对场景中的所有三角形进行排序。然后,模型按从后到前的顺序“绘制”,就像 Bob Ross 在《绘画的乐趣》中绘制风景一样。这个算法对程序员来说很容易实现,但它也有几个问题。
首先,它非常低效:渲染器会一遍又一遍地给相同的像素上色,因为前景模型会覆盖在之前的背景模型之上,这浪费了大量的计算资源。
其次,它不容易进行细分以保持 GPU 上的工作单元忙碌。画家算法要求模型按特定顺序绘制,因此很难将工作有效地分配给不同的处理单元。
第三,通常没有简单的方法来确定两个三角形中哪个离视点更远。图 5-5 显示了两个三角形的透视图,数字表示每个顶点的深度。顶视图清楚地显示了哪个三角形位于前面,但由于一个三角形的顶点深度介于另一个三角形的顶点之间,通过直接比较顶点深度无法轻松确定哪个三角形更接近。

图 5-5:两个三角形的透视图和顶视图
深度缓冲
由于画家算法的种种不足,游戏中最常见的投影解决方案是称为深度缓冲的方法。如前一章所介绍,计算机图形需要一个位图,称为显示缓冲,用来存储显示中每个像素的颜色。此技术还使用一个相应的深度缓冲来跟踪每个像素的深度——即它离视点的远近。当然,屏幕是平的,所以像素实际上没有深度。深度缓冲实际存储的是用于确定该像素颜色的场景中某一点的深度。这使得渲染器可以按任何顺序处理场景中的物体。
这是深度缓冲如何在图 5-4 的示例场景中工作的。最初,每个像素的深度会被设置为一个大于场景中任何实际物体深度的最大值——假设为 100,000 虚拟英尺。如果杯子首先被绘制,则该像素在深度缓冲中的深度会设置为对应的视点距离。假设花盆接着被绘制,渲染器则设置其像素的深度。我们可以将深度缓冲想象为一幅灰度图像,其中像素离视点越近,颜色越深。此时的深度缓冲如图 5-6 所示。
深度缓冲解决了将正确的点投影到像素上的问题。在渲染一个像素之前,渲染器会检查该像素位置的深度缓冲值,以确定新像素是否在已经显示缓冲中的像素之前或之后。当新像素出现在显示缓冲中的该位置并且位于现有像素后面时,渲染器会跳过该像素并继续处理。继续我们的示例,当立方体被绘制时,立方体左侧与杯子重叠的像素不会被绘制,因为深度缓冲中的值显示杯子的像素在立方体前面。立方体会覆盖花盆的像素,因为花盆像素的深度大于立方体的深度。

图 5-6:一个绘制了两个物体的深度缓冲。颜色越深表示离视点越近。
深度缓冲是一种高效的投影解决方案,因为它减少了不必要的工作。模型可以大致按顺序预排序,使它们大致按照从前到后的顺序进行绘制,以最小化被覆盖的像素。此外,由于深度缓冲允许以任何顺序渲染模型,因此工作可以更容易地在图形处理器的各个核心之间分配。在我们的示例中,不同的核心可以同时处理杯子、立方体和花盆,而最终渲染的图像中每个像素都会正确地投影到相应的模型上。
实时光照
现在渲染器已经知道每个像素属于哪个三角形,接下来必须给像素上色。在实时渲染中,这个过程被称为像素着色。一旦某个像素通过深度缓冲测试,所有用于给像素上色的数据都会通过一种叫做像素着色器的算法进行处理。因为每个像素都可以独立上色,所以像素着色是保持 GPU 内部大量处理单元忙碌的一个好方法。
着色器所需的数据会根据光照模型的复杂度而有所不同,包括场景中光源的位置、方向和颜色。没有像光线追踪这样的技术,就无法实现完整的全局光照模型,其中近距离表面之间的反射会相互影响。然而,着色器可以包含距离、漫反射和镜面反射的基本效果。
在图 5-7 中,代表光线的实线箭头从三角形上反射。虚线箭头表示该位置的法线(或表面法线);法线只是一个指向远离表面的垂直线。在第四章中,我们学到了光线、表面和视点之间的角度如何影响漫反射和镜面反射。像素着色器使用法线进行这些计算;因此,例如,在图 5-7 中,如果深色箭头表示光线,那么由于光线与法线之间的角度较小,它将有较高的漫反射。

图 5-7:三角形的表面法线(虚线箭头)与三角形表面垂直,光线(实线箭头)照射到表面。
在图 5-7 中,法线方向垂直向上,意味着它与三角形平面垂直。每个表面点的法线都垂直向上的三角形是完全平坦的,这使得每个三角形在渲染时都清晰可见。例如,在图 5-8 中,带有垂直向上法线的酒杯看起来像一颗宝石一样棱角分明。
为了获得更圆滑的外观,法线如图 5-9 所示被弯曲。这里,角落处的法线向外弯曲,三角形内部任意位置的法线是角落法线的加权平均。由于光线照射点的法线不再垂直向上,光线的反射变得更加尖锐。如果这属于漫反射光照计算,最终的颜色将会更亮。

图 5-8:如果三角形上每个位置的法线方向相同,那么该模型将被渲染为一系列平坦的三角形。

图 5-9:光照影响点的法线会受到弯曲角法线的影响,从而改变反射角度。
弯曲法线允许平面三角形像图 5-10 中的弯曲三角形那样反射光线。

图 5-10:弯曲法线使得三角形在光照计算中呈现弯曲的形状。
然而,这种方法仅仅解决了一部分问题,因为底层形状没有改变。弯曲法线并不会影响哪些像素与哪个三角形匹配;它仅影响像素着色器中的光照计算。因此,幻觉会在模型的边缘处崩溃。在我们的杯子模型中,弯曲法线帮助杯子的侧面看起来平滑,但它并不影响杯子的轮廓,杯口依然是由一系列直线组成的。更平滑的模型渲染需要额外的技术,我们将在本章后面看到这些技术。
阴影
阴影在通过赋予模型重量和现实感来说服观众接受图像的真实性方面发挥着重要作用。产生阴影需要追踪光线束;毕竟,阴影是位于光源和表面之间的物体轮廓。游戏渲染器没有时间进行完整的光线追踪,因此它们使用巧妙的捷径来产生令人信服的阴影效果。
考虑图 5-11 中显示的场景轮廓。这个场景将在夜间环境中渲染,因此左侧的路灯将投下强烈的阴影。为了正确渲染阴影,渲染器必须确定从这个视角可见的哪些像素会被路灯照亮,哪些则只会被其他光源照亮。在这个例子中,渲染器必须确定标记为 Scene-A 的点不可见于路灯,但 Scene-B 是可见的。

图 5-11:路灯的光线应该在这个场景中投下阴影。
游戏中常见的解决方案是阴影贴图,这是一种从光源视角快速渲染的图像,仅计算深度缓冲区,而不计算显示缓冲区。图 5-12 是图 5-11 中路灯的阴影贴图,显示了路灯与场景中每个点之间的距离;与深度缓冲区一样,这以灰度显示,距离较近的像素颜色较暗。

图 5-12:来自路灯视角的深度缓冲区渲染图
阴影贴图会为每个光源在场景像素上色之前生成。当为像素上色时,像素着色器会检查每个光源的阴影贴图,以确定被渲染的点是否从该光源可见。考虑图 5-11 中的场景 A 和场景 B 的点。着色器计算每个点到路灯顶部的距离,并将这个距离与投影到阴影贴图上的同一位置的深度进行比较,在图 5-12 中分别标记为阴影 A 和阴影 B。在这个例子中,图 5-12 中阴影 A 的深度小于图 5-11 中场景 A 到路灯的距离,这意味着有东西挡住了光线,光线无法到达场景 A。相比之下,阴影 B 的深度与场景 B 到路灯的距离一致。因此,场景 A 处于阴影中,而场景 B 则没有。
我故意让图 5-12 中的阴影贴图呈现块状外观;为了提高性能,阴影贴图通常会以较低的分辨率生成,从而产生块状阴影。如果一款游戏提供了“阴影质量”设置,这个设置很可能控制阴影贴图的分辨率。
环境光与环境遮蔽
实时渲染中的简单光照模型通常会产生过于昏暗的图像。我们很容易忽视周围环境中间接光照的影响。例如,站在白天的户外,即使站在完全的阴影中,你也有足够的光线阅读,因为间接阳光会从附近的表面反射过来。
为了产生自然光照效果,游戏渲染器通常会应用一个简单的环境光模型。这种光照是无处不在的,它照亮每个模型的表面,而不考虑光束或入射角度,因此即使是被场景光照遗漏的表面也不会完全黑暗。环境光在游戏中广泛使用,即使是在室内场景中也是如此。这是一种小小的“伪造”能够产生更真实效果的情况。
环境光还可以用来调整场景的氛围。在像魔兽世界这样的开放世界游戏中,当你从金黄的秋日田野进入昏暗的森林时,一个重要的效果就是环境光从明亮的黄色变为昏暗的蓝色。
尽管简单的环境光模型使得渲染效果不会太暗,但这种方法并不产生阴影,损害了场景的真实感。环境遮蔽方法通过模拟阴影的形成位置来伪造来自环境光的阴影,基于这样的观察:这些阴影应该出现在缝隙、裂缝、洞口等地方。图 5-13 展示了这一关键思路。点 A 的遮蔽程度远低于点 B,因为光线能够照射到该点的角度更大,从而让更多的光线通过。因此,环境光应该对点 A 的影响大于点 B。
然而,为了让渲染器精确测量遮挡,它必须向每个方向发射光线,类似于漫反射光照中的光散射,但我们已经知道,对于实时渲染来说,追踪光线并不是一种可行的选择。相反,一种叫做屏幕空间环境遮挡(SSAO)的技术,在主要渲染完成后,利用渲染过程中已计算的数据近似每个像素的遮挡量。
在图 5-14 中,我们可以看到 SSAO 近似的实际应用。请注意,视点是直接向下看表面。虚线箭头表示表面某一点的法线。灰色区域是与该法线对齐的半球,在此二维表示中呈半圆形。着色器检查半球内散布的点。每个点都被投影到屏幕坐标中,就像图 5-2 中所示的模型点投影一样。然后,点的深度与该像素位置的深度缓冲进行比较,这告诉着色器该点是位于模型表面前面(白色)还是后面(黑色)。位于表面后面的点的百分比是环境遮挡量的一个良好近似。

图 5-13:在给定点测量遮挡情况

图 5-14:屏幕空间环境遮挡通过表面后方点的百分比近似遮挡程度
SSAO 对渲染器来说是一项繁重的工作,因为它需要投影和检查大量额外的点——每个像素至少需要 16 个点才能获得可接受的结果。然而,每个像素的计算是独立的,这使得这些工作能够轻松地分配给大量的工作核心。如果玩家的硬件足够强大,SSAO 可以产生可信的环境阴影效果。
纹理映射
在这些关于图形的讨论中,我们一直假设模型的表面是单一的颜色,但实际上这种情况在真实世界中很少见。老虎有条纹,地毯有图案,木材有纹理,等等。为了重现复杂颜色的表面,像素着色器使用纹理映射,它从概念上将一张平面图像包裹到模型的表面上,就像城市公交车侧面的广告覆盖一样。需要明确的是,纹理映射不仅仅用于游戏渲染;电影 CGI 也广泛使用它。但纹理映射对于游戏来说是一个特殊的问题,因为纹理必须在毫秒级别内应用。为了渲染一个帧所需的纹理和纹理操作的数量,构成了游戏渲染中的最大挑战之一。
图 5-15 展示了一幅纹理位图(一个锯齿形图案的图像)和一个应用了该图案的场景。用于纹理映射的位图图像称为纹理。在这个案例中,地毯矩形的表面覆盖了一个大纹理,尽管对于像地毯上这种规则图案的纹理,也可以使用较小的纹理并重复应用,铺满整个表面。
像素着色器负责使用相关纹理选择像素的基础颜色;这个基础颜色之后会被光照模型修改。由于纹理表面与视点之间的距离和方向是任意的,因此纹理中的像素与模型表面上的像素之间并没有一一对应关系。在纹理区域内根据应用的纹理选择像素颜色的过程被称为采样。

图 5-15:纹理映射。顶部的锯齿形纹理应用于椅子下的地毯对象。
为了说明采样过程中涉及的决策,让我们从图 5-16 中显示的戴帽子机器人的位图开始。纹理中的像素称为纹理元素(texels)。这个 20×20 的纹理包含 400 个纹理元素。
在这个例子中,这个纹理将作为墙上画框中的一幅画出现,见图 5-17。
假设框架内的区域填充了渲染图像中的一个 10×10 像素块。纹理将直接应用于图像上,而不进行透视调整,这意味着渲染器只需将 20×20 的纹理元素块缩小到适应最终图像中的 10×10 像素块。

图 5-16:一个戴帽子的机器人纹理

图 5-17:在这个场景中,图 5-16 中的纹理将应用于墙上画框内。
最近邻采样
由于需要 10×10 个像素来填充纹理区域,我们可以想象在纹理上覆盖了一张 100 个采样点的网格。图 5-18 展示了图 5-16 中原始机器人纹理的局部放大部分。这里,纹理元素的中心被显示为方块,而十字标记表示场景中像素的采样点。采样解决了纹理元素与像素之间的这种不匹配问题。
最简单的采样方法是选择最近的纹理元素(texel)的颜色,这种方法被称为最近邻采样。这种方法易于实现且计算速度快,但效果通常很差。在这个例子中,四个纹理元素到像素中心的距离相等,因此我任意选择了每个像素中心右下角的纹理元素。图 5-19 展示了这种采样方法选择的纹理元素,以及最终图像中将出现的 10×10 像素块。
正如你所看到的,结果看起来更像是一个骨瘦如柴的有氧操教练,而不是一个戴帽子的机器人。如果你曾经仔细观察过油画,你可能猜到为什么最近邻技术会产生如此不吸引人的结果。近距离观察时,油画展示了丰富的细节,成千上万的笔触。但稍微后退几步,这些笔触就消失了,颜色在眼中融合在一起。同样地,当纹理用更少的像素表示时,邻近纹理元素的颜色应该是混合的。然而,最近邻采样只是简单地选取一个纹理元素的颜色,没有任何混合;在我们的例子中,四个纹理元素中有三个对结果完全没有影响。

图 5-18: 图 5-16 纹理的特写部分。方框是纹理元素的中心;十字是采样点。

图 5-19:对图 5-16 进行 10×10 最近邻采样的结果。左侧是原始纹理的选择纹理元素,右侧是结果位图。
当纹理被扩展到填充更大区域时,结果同样是丑陋的。在这种情况下,一些纹理元素(texel)将简单地在纹理区域内重复,从而产生块状效果。为了看清这个问题,我们从一个三角形和它作为 16×16 反走样纹理的表示开始,如图 5-20 所示。

图 5-20:三角形及其作为 16×16 像素反走样纹理的表示。
现在假设这个纹理应用在一个 32×32 的区域上。理想情况下,它应该看起来比原来的小纹理更平滑;更高的分辨率提供了更精细边缘的机会。然而,正如图 5-21 所示,最近邻采样将每个纹理元素放置在四个采样点上,因此原始的 16×16 纹理中的每个纹理元素在放大后都变成了四个相同颜色的像素。

图 5-21:当用来放大纹理时,最近邻采样仅仅是重复像素。
双线性过滤
一种更好看的采样方法是双线性过滤。它不是简单地取最近纹理元素的颜色,而是每个纹理样本是四个最近纹理元素的按比例混合。该方法称为双线性,是因为它利用了采样点在由四个最近纹理元素形成的正方形内沿两个轴的位置。例如,在图 5-22 中,采样点位于底部并稍微偏左,产生了所示的混合百分比。该样本的最终颜色是根据给定百分比从纹理元素的颜色中计算得出的。
图 5-23 显示了经过双线性过滤缩小后的机器人纹理。缩小后的版本只有原始像素的四分之一,必然缺乏细节,但如果将原始纹理置于手臂长度处,并将缩小版置于近处进行对比,你会发现缩小效果非常好,比最近邻结果要好得多。

图 5-22:双线性过滤测量样本点在相邻纹素的正方形内的垂直和水平方向的位置,并利用这些位置来确定每个纹素对样本颜色的影响百分比。

图 5-23:通过双线性过滤缩小的机器人纹理
图 5-24 显示了通过双线性过滤放大的 32×32 区域,这是从 16×16 三角形纹理中获取的—相比块状的最近邻采样,这是一个明显的改进。

图 5-24:通过双线性过滤扩展的三角形纹理
Mipmap
前一节中的示例展示了双线性过滤的局限性。为了使双线性过滤效果良好,纹理的分辨率需要至少是纹理区域的一半,但不能超过两倍。如果纹理太小,双线性过滤仍然会产生块状效果。如果纹理太大,尽管每个采样使用了四个纹素,但有些纹素不会对任何样本产生影响。
避免这些问题需要为每个纹理准备一组不同大小的位图:一个用于近距离查看的全分辨率版本,以及在纹理区域较小时使用的小版本。这些逐渐变小的纹理集合称为 mipmap。一个示例见于 图 5-25。mipmap 中的每个纹理都是下一个较大纹理面积的四分之一。

图 5-25:mipmap 是一个纹理集合,每个纹理的大小是前一个的四分之一。
使用 mipmap,渲染器总是能够找到一个合适的纹理,通过双线性过滤产生良好的效果。例如,如果需要 110×110 的纹理,就将 128×128 的纹理缩小。如果需要 70×70 的纹理,就将 64×64 的纹理放大。
三线性过滤
虽然双线性过滤和各级贴图(mipmap)通常能很好地工作,但在从一个 mipmap 贴图过渡到另一个 mipmap 贴图时,它们会引入一种令人分心的视觉异常。假设在一款第一人称游戏中,你正在朝着一堵使用 mipmap 贴图的砖墙跑去。当你靠近墙壁时,较小的贴图会越来越被放大,直到你到达那个点,下一层较大贴图的缩小版本会出现在你面前。不幸的是,通过双线性过滤缩小的较大贴图并不完全与扩展的较小贴图匹配,因此在过渡的那一刻,贴图会“跳动”。这个问题也可能发生在没有任何运动的情况下,比如走廊中铺设的长地毯,地毯上使用了重复的贴图;因为不同距离处的地毯部分被 mipmap 中不同的贴图覆盖,所以在贴图接触的地方会明显看到接缝。
为了平滑贴图过渡,渲染器可以在贴图之间进行混合,除了混合贴图中的纹素外,还可以混合来自不同贴图的样本。假设需要贴图的区域是 70×70,这个尺寸介于 mipmap 中的 64×64 和 128×128 贴图之间。渲染器不仅仅对较近的 64×64 贴图进行双线性过滤,它可以对较大和较小的贴图同时进行双线性过滤,然后混合这两个结果样本。就像双线性过滤本身一样,这一步也是按比例进行的:在我们的例子中,颜色大部分由 64×64 贴图的结果决定,少量 128×128 贴图的结果混合其中。由于我们在每个贴图上都进行了二维过滤,然后再混合结果,这种技术被称为三线性过滤。它在图 5-26 中展示。
三线性过滤消除了 mipmap 贴图之间的跳动和接缝,但由于它需要两个双线性样本,然后进行最终的混合,它的工作量是双线性过滤的两倍多。

图 5-26:三线性过滤从 mipmap 中较大和较小的贴图中获取双线性样本,并混合这些结果。
反射
正如在第四章中讨论的,光线追踪自然能够捕捉到光从一个表面反射到另一个表面的所有效果。不幸的是,附近表面颜色的微妙影响几乎无法在没有光线追踪的情况下捕捉到,但游戏渲染器确实有办法伪造我所称之为清晰反射的效果:即在抛光的台面、窗户,当然还有镜子本身等表面上,明显的、镜面般的反射。
游戏限制了哪些表面能产生清晰的反射。仅有少数物体具有这种反射能保持场景的真实感,并大大降低计算成本。为了进一步减少工作量,渲染器使用环境映射,将光亮物体概念性地放置在立方体内部,并用事先渲染的物体周围环境图像进行纹理映射。
图 5-27 显示了一个示例场景:一辆光亮的跑车在展厅的旋转台上。为了计算清晰反射的效果,渲染器在概念上将汽车放置在一个立方体中;该立方体本身并不被渲染,只是用于映射反射。立方体内部使用展厅内景的图像进行纹理映射,如图 5-28 所示。由于反射图像会因车身表面而有所扭曲,观众不会注意到反射图像与渲染世界中的汽车位置不完全匹配。

图 5-27:为了逼真,光亮的汽车车身应该反射展厅的景象。

图 5-28:为了映射反射,汽车被视为处于一个立方体内,立方体内部覆盖着展厅的位图图像。
与其追踪光线在场景中四处反弹,映射反射变成了一种间接的纹理映射引用,这是一种相对简单的计算。当然,汽车表面可能也已经进行了纹理映射,这意味着增加反射至少是加倍了每个像素的计算工作量,但通常这种真实感的提升是值得额外工作量的。
当反射模型在移动时,工作变得更加困难,例如,如果我们的汽车在驾驶游戏中沿沙漠公路行驶。渲染器不能仅仅将一张静态的沙漠图像粘贴到立方体内部并期望能够欺骗观众。由于视点将随着汽车移动,当汽车沿公路行驶时,反射也必须随之移动——或者至少看起来是这样。
有一个古老的好莱坞技巧,用来传达与相机相关的横向移动假象。演员会站在跑步机上,这样他就可以走路而不离开原地。在他身后,一幅连续卷轴上的景物插图会与跑步机保持相同的速度滑过。只要观众没有注意到相同的树木不断出现,看起来就像演员正在横向移动。
同样的思路可以应用于包围光亮汽车的立方体内部。选择一部分宽广的连续图像,如图 5-29 所示。通过将选定的“窗口”滑动在宽图像上,以匹配汽车的运动,产生出汽车反射场景中干旱山脉的假象。

图 5-29:将一个窗口滑动到一幅宽广的连续图像上,会在映射的反射中创造出运动的效果。
伪造曲线
没有什么能比一个明显可以辨认出三角形的模型更快地破坏视频游戏的真实感了,尤其是当这个模型试图表现一个圆形物体时。早期的 3D 游戏中,汽车轮胎像八边形一样,人物角色看起来像是由玩具积木做成的。我们已经看到了解决这个问题的部分方法——弯曲三角形顶点的法线——但要生产光滑的模型,还需要一整套技巧。
远距离替代模型
解决平面三角形问题的一个显而易见的方案是将模型拆分为许多小三角形,以至于单独的面无法被识别。理论上是可行的,但即使三角形是简单的形状,渲染的数量仍然受到时间的限制。试图以最高的细节设计每个模型会使渲染速度变得极其缓慢。
然而,渲染器可以使用大量额外的三角形,仅仅平滑那些靠近视点的模型。这就是远距离替代模型的思路。在这种方式下,游戏中的每个物体都有两个模型——一个是高度详细的高三角形模型,另一个是简化的低三角形模型。这个简化的模型是原始模型的“替代品”,当模型超出视点的某个距离时,低质量的模型就会替代高质量的模型。
远距离替代模型有效利用了渲染时间,但由于这两种模型差异太大,如果玩家在靠近某个特定模型时进行观察,模型之间的过渡可能会显得视觉上很突兀。理想情况下,你希望让观众感觉到远处的物体在靠近时展现出更大的细节,但实际上这两种模型差异太大,以至于替换的效果看起来像是一个物体神奇地变成了另一个物体。
凸凹贴图
另一种平滑模型的技术是保持三角形数量不变,但在每个像素的光照计算中进行调整,以呈现不规则的表面外观。
要理解为什么这种bump mapping方法如此有效,可以想象一款游戏中有一座带有石膏墙的庄园。为了获得石膏的外观,渲染器可以将一个实际石膏墙的图像作为纹理,应用到庄园模型的墙面上。由于石膏墙是波浪形的,它的起伏应当在场景光照下可见。仅仅将纹理应用到平坦的墙面上并不能让人信服;那看起来就像是一个平坦的墙壁上贴了石膏的图片。
凸凹贴图使得平面表面能够像石膏墙那样呈现波浪状,像爆米花天花板那样颠簸,或者像皱褶、百叶窗等其他形态那样反应光照。该过程从一个与将要应用到模型表面的纹理相同大小的灰度位图开始。这个位图被称为高度图,因为每个像素的亮度表示表面的高度。
高度图允许像素着色器在每个像素位置近似表面法线。最容易理解的是在 2D 中。图 5-30 显示了一排 10 个像素。底部的数字表示每个像素的高度。10 个点按比例显示,并附有表面法线。我添加了灰色线条以显示如何计算第 4 和第 7 个点的法线。通过两侧的两个点画一条假想线,然后,选择的点的法线设置为垂直于这条线。

图 5-30:一排像素,通过凹凸贴图改变了光照计算。数字表示每个像素的人工高度。渲染器根据相邻像素的高度来确定每个像素的法线。
这些弯曲的法线会影响漫反射和镜面反射光照的计算,使得平面表面能够像粗糙或波动的表面一样响应光线。然而,正如之前涉及弯曲法线的技巧一样,带有凹凸贴图的表面仍然是平面表面。表面上的点实际上并没有被抬高或降低,它们仅仅是以不同的方向响应光线。当玩家在 3D 场景中经过一个带有凹凸贴图的模型时,表面的光照会以逼真的方式变化,但模型的边缘仍然是直的,这可能会暴露出游戏的痕迹。就像图 5-8 中杯缘暴露出模型上的直线一样,我们的凹凸贴图哈西恩达模型的外角将是完全直的,而应该是波浪形的,因为凹凸贴图并没有改变平面墙面的形状。
细分
假设你正在玩一个幻想游戏,所有注意力都集中在一只慢慢接近的巨大巨魔身上,手里拿着一把斧头。作为玩家,你希望这只巨魔看起来尽可能好,即使它接近到几乎填满整个屏幕,但你不希望它由太多三角形构成,以至于帧率太低,无法有效地与它战斗。
然而,如果渲染器使用远距离替代物(impostor),就会出现突兀的过渡,提醒你这只是一个游戏。如果渲染器对巨魔模型进行凹凸贴图处理,光照会从他的盔甲上的铆钉反射出来,但这种精细的光照效果掩盖不住模型由于三角形数量太少而无法近距离观察的事实。
一种称为细分的过程解决了这个问题。首先,哥布林模型中的每个三角形被细分为更多的三角形。这些新三角形的角被独立地向内或向外(即相对于原始三角形向上或向下)使用高度图进行操作。与凹凸贴图仅通过弯曲法线来欺骗光照模型不同,细分实际上生成了一个更复杂、细节更多的模型。图 5-31 展示了单个三角形的过程。
这种方法是掩盖三角形直线的好方法,并且在外观上明显优于凹凸贴图和远程替代模型技术。因为模型实际上被变形为一个新的、更复杂的形状,即使是模型的边缘也会受到适当影响,这不同于凹凸贴图的情况。此外,与远程替代技术不同,模型随着视点距离的减小而逐渐改善,避免了模型切换时的剧烈过渡。

图 5-31:一个三角形被细分,形成一个由更小的三角形组成的网格。这些新三角形的顶点随后通过高度图进行操作,产生底部的更复杂表面。
尽管你可能认为细分技术在游戏中被广泛使用,但事实并非如此,因为它带来的性能影响远远大于之前讨论的更简单的方法。实时创建更复杂的模型比访问几个预制的模型(如远程替代模型方法)或调整法线(如凹凸贴图)要费力得多。
因此,细分技术通常用于效果最为显著的地方。例如,在一个设定在户外的游戏中,角色脚下的地面可能延伸得非常远。若要对地面进行精细建模,将需要大量的三角形,这会成为性能瓶颈;但如果地面模型的三角形数量较少,离观众最近的地面将呈现出不真实的棱角感。细分技术能够平滑地面中最接近观众的部分。
实时抗锯齿
如果单个像素因锯齿而变得明显可见,渲染器的辛勤工作将毫无意义。与电影 CGI 相似,游戏需要某种形式的全屏抗锯齿来平滑模型和表面的边缘。对于光线追踪,抗锯齿在概念上是简单的:发射比像素更多的光束并融合结果。然而,游戏渲染器必须使用更高效的技术。
超采样
与发射多束光线的最直接近似方法被称为超采样抗锯齿(SSAA)。与其每个像素发射多束光线,超采样会渲染一个比目标最终图像大得多的中间图像。最终图像中每个像素的颜色是来自更大图像中多个像素样本的混合。
请看图 5-32 中被灰色三角形覆盖的两个白色三角形。注意,白色三角形的边缘在渲染图像中不可见,但这里为了清晰起见展示了它们。

图 5-32:三个三角形的排列
图 5-33 展示了这些三角形在 8×4 分辨率下的基本渲染。每个像素根据像素中心是否位于前景中的灰色三角形区域内,分别被着色为灰色或白色。

图 5-33:没有抗锯齿的像素着色
为了生成 8×4 的超采样图像,首先将三角形以 16×8 分辨率渲染,如图 5-34 所示。

图 5-34:对三个三角形进行超采样。在这里,最终位图中的每个像素由四个具有分散采样点的子像素表示。
如你所见,图 5-33 中的每个像素在图 5-34 中变成了四个更小的像素。这些更小的像素称为子像素。通过这种高分辨率的渲染,最终渲染中每个像素的颜色是其四个子像素颜色的按比例混合,如图 5-35 所示。

图 5-35:通过混合子像素来着色每个像素
超采样能很好地平滑锯齿,但正如你所预料的,使用更高的分辨率渲染图像会带来较大的性能开销。为了在最终图像中生成一个像素,需要对四个像素进行采样,这意味着像素着色器要处理四倍的工作量。在这个例子中,我通过给每个三角形分配一个平面颜色来简化问题,但在典型的游戏渲染中,每个子像素至少代表一个纹理映射样本,接着进行光照计算。尽管早期的电子游戏常常使用 SSAA,但如今这种方法已经很少见了。
多重采样
在前面的例子中,你可以看到,当所有四个子像素都在同一个三角形内时,超采样并没有起到任何作用。为了减少抗锯齿带来的性能损耗,可以将子像素的处理限制在三角形的边缘,这些边缘正是锯齿出现的地方,这就是一种叫做多重采样抗锯齿(MSAA)的技术。
图 5-36 展示了这一概念的一个版本。两个像素位于两个三角形的边缘上。使用超采样时,每个八个子像素都进行纹理采样,并根据场景光照单独着色。使用多重采样时,两个像素仍然有八个子像素,但不是八个样本。相反,渲染器首先确定每个子像素所属的三角形。每个位于同一三角形内的四个子像素被赋予相同的颜色,这个颜色是从子像素采样点之间的中间点采样得到的。因此,虽然超采样为八个子像素 A 到 H 着色,但多重采样只为四个子像素 A 到 D 着色,这意味着在纹理映射和光照方面的工作量大大减少。

图 5-36: 比较超采样和多重采样
当所有四个子像素都位于同一个三角形内部时,多重采样每个最终像素仅对一个子像素进行采样,从而引入很小的计算开销。多重采样将额外的计算资源集中在最需要的地方——减少边缘的锯齿——因此,它是渲染时间的高效利用。
后处理抗锯齿
通过将抗锯齿延迟到图像渲染完成后再进行处理,可以进一步提高性能,这种方法被称为后处理抗锯齿。也就是说,图像首先以所需的最终分辨率正常渲染,然后识别并平滑锯齿。实质上,后处理抗锯齿技术认为图像中的某些像素颜色是错误的,这一判断仅仅基于这些像素自身的颜色。
一种这样的技术叫做快速近似抗锯齿,或FXAA。(为什么它不是 FAAA,也许是一个我们不应该问的问题。)FXAA 的理念是找出可能位于重叠三角形边缘上的像素,然后混合相邻像素的颜色,以平滑突兀的过渡。
FXAA 会单独检查图像中的每个像素——我们将正在检查的像素称为当前像素。该过程首先计算当前像素及其四个直接邻居的感知亮度,类似于检查图像的黑白版本。选择邻域中最亮和最暗的像素,如图 5-37 所示,然后将它们的差异与设定的阈值进行比较。此测试确保抗锯齿仅应用于高对比度的像素邻域——即最亮和最暗像素之间差异较大的区域。

图 5-37: 检查像素邻域的对比度水平
这些高对比度区域很可能表示需要平滑的锯齿边缘,每个这样的区域会进一步检查,如图 5-38 所示。以当前像素为中心的 3×3 像素块,既被视为三列像素,也被视为三行像素,从而确定这是水平边缘还是垂直边缘。在这个例子中,因为列之间比较相似,而其中一行与其他两行有较强的对比度,因此这将被归类为水平边缘。

图 5-38:在像素邻域的列和行中寻找对比度
因为这是一个水平边缘,下一步是比较当前像素上下方的像素,找出哪个与当前像素的对比度最大。在这种情况下,位于上方的像素比当前像素亮得多,而下方的像素则相似。这意味着检测到的边缘位于当前像素与其上方邻居之间。为了抗锯齿处理这一边缘,当前像素将被替换为两个像素中心之间的双线性采样,显示为图 5-39 中的白色圆圈。FXAA 会检查边缘上其他像素,以确定边缘的锯齿程度,并通过将采样点远离当前像素中心来调整混合的程度。

图 5-39:为了平滑这个边缘,FXAA 会将中心像素的颜色替换为圆点处的双线性采样。
像 FXAA 这样的后处理抗锯齿方法比超采样甚至多重采样快得多,因为它完全不创建任何子像素。然而,FXAA 的结果并不总是像其他方法那样令人印象深刻。特别是,FXAA 有时会模糊那些实际上并没有锯齿的区域;与超采样不同,像 FXAA 这样的后处理方法只是猜测边缘的位置,因此纹理中的高对比度区域可能会欺骗算法。
渲染预算
不同抗锯齿技术的权衡意味着实时图形应用程序的开发人员必须在最佳质量和最佳性能之间做出选择。FXAA 对这种情况足够好吗?还是必须使用 MSAA?然而,这个选择并不是孤立做出的。从更广泛的角度来看,游戏开发人员必须审视所有实时渲染可用的技术——光照、阴影、抗锯齿,还有许多其他我们没有空间讨论的可能性,比如运动模糊和粒子系统——并选择一套最大化图像质量而不超出渲染时间限制的技术。在那 ¹/[60] 秒内,可以完成大量的工作,但并不能使用所有最漂亮的技术,因此必须在某些地方做出妥协。
在控制台或移动游戏中,这些选择通常由游戏设计师做出。在 PC 上,通常会提供一定的选择余地,用户可以调整纹理分辨率,选择纹理过滤方法,选择抗锯齿方法,开启或关闭阴影和反射,甚至可以在多种方式中微调渲染器。部分原因是,给予用户这些控制选项是为了让他们根据系统的性能调整渲染工作负载,因为这台 PC 可能是顶级的,也可能是一台老旧的电脑。
更进一步,详细的渲染选项体现了一个事实:美丽是主观的——某些视觉效果令一个观众印象深刻,而另一个观众可能毫无感觉。例如,有些玩家对锯齿状的边缘感到恐惧,通常会将抗锯齿调整到最大,而另一些玩家则不愿将宝贵的处理器周期浪费在去除锯齿上,而更愿意去优化更具现实感的阴影。某种程度上,视频游戏的核心就是让我们置身于可信的虚拟世界,而我们相信的内容,完全取决于我们自己。
游戏图形的未来
那么,游戏图形未来将会如何发展呢?我们可以预见,游戏程序员将继续面临显示技术进步带来的挑战。显示器的分辨率不断提高,逐渐削弱了每一代 GPU 的优势。虚拟现实(VR)头盔将带来一个特殊的挑战,它结合了头盔内置的显示屏和传感器,用于追踪玩家的头部动作。如果显示延迟了运动,VR 头盔可能会带来麻烦——我们的脑袋不喜欢冲突的信息,当我们的眼睛看到一个东西,而内耳感受到另一个时,很多人会感到恶心。在普通平面屏幕上玩游戏时,玩家希望能获得稳定的高帧率,但如果偶尔出现帧率下降,他们通常不会太过在意;而使用 VR 设备时,帧率的稳定性至关重要。
超越满足显示需求,确切预测游戏图形如何发展是困难的。在过去的十年里,每当我玩一个新的 AAA 游戏(业内对最高预算游戏的称呼),我都会认为图形不可能再做得更好了,认为下一代硬件带来的任何改进都将微不足道。而每次,我都被证明是错的。所以我相信,即使我无法确定这些进展会是什么,我仍然会继续为游戏图形的进步感到震撼。
原始硬件性能只是问题的一部分。购买一款核心数量是旧款 GPU 两倍的新 GPU 意味着硬件在相同的时间分配内可以处理两倍的三角形,但一旦三角形的数量足够多,简单地将数量加倍并不会显著改善最终的图像质量。事实上,在某些时候,模型的细节可能会变得非常复杂,三角形的数量如此之高,以至于每个三角形占据的屏幕面积不到一个像素。当这种情况发生时,整个将场景渲染为三角形的概念就会受到质疑。渲染器可能不再使用三角形来确定一个像素的颜色,而是用体积固定的单个点来代替三角形——可以想象用小巧的棉花糖来雕刻一座雕塑。
然而,推动游戏图形发展的最终动力并不是硬件,而是图形程序员的创造力。第四章中介绍的许多技术,都是关于如何精确地,或者至少合理地模拟现实世界中光线和视觉的工作原理。游戏图形的目标仅仅是产生看起来不错的效果。这给程序员提供了极大的发挥空间,去尝试新的方法,找出如何在有限的渲染预算中找到新花样,去为玩家脸上带来傻乎乎的笑容。我不确定游戏开发者们为下一代游戏准备了什么,但我相信他们一定会继续让我的 GPU 在令人激动和惊叹的方式下工作。
第六章:6
数据压缩

有时候,软件的辛勤工作对每个人都显而易见,就像电影中的计算机生成图像(CGI)和电子游戏图形一样。你不需要知道计算机是如何工作的,就能为《阿凡达》这样的电影和《孤岛危机》这样的游戏中的视觉效果所惊叹。然而,有时,软件在看似毫不费力的情况下,正完成着最令人惊叹的工作。
如今,通过光盘或互联网流媒体观看高清视频是我们大多数人理所当然的事情。这不就是存储和显示图像吗?为什么这需要特殊的技术?为了理解为什么我们应该对蓝光视频和 Netflix 流媒体感到惊叹,让我们先回顾一下在这些格式出现之前的视频是怎样的。
录像带,最早的家庭视频介质,通过磁带卷录制图像。这些是模拟录音——磁性转录的信号,与电视天线广播的信号相同。视频分辨率甚至低于我们现在称为“标清”的标准,而且与其他模拟录音设备如录音带和黑胶唱片一样,视频质量会随着时间的推移而退化。录像带的唯一优点是它的容量:一部更长的电影只需要一卷更长的磁带。
接着是激光影碟。它的大小约与黑胶唱片相当,这些光盘看起来像是今天的 DVD 和蓝光光盘的更大版本,但与录像带一样,它们仍然存储的是模拟广播格式的信号。然而,激光影碟记录了更高分辨率的图像,接近标准清晰度,并且允许你直接跳到视频中的特定位置,而不需要像使用录像带那样倒带或快进。曾一度,激光影碟看起来是视频的未来,但现在容量成了问题。与磁带卷几乎无限的容量不同,激光影碟每面最多只能存储 60 分钟的视频,因此观看电影意味着在中途翻转光盘,甚至更换光盘。
今天,容量问题变得更加严重。我们的蓝光光盘比激光影碟小得多,但我们的视频分辨率却高得多。让我用数字来说明这个问题。在高清晰度视频中,每一帧是一个 1920×1080 的位图,总共有 2,073,600 个像素。如果每个像素存储为三字节的 RGB 格式,那么一帧高清电影将需要 6,220,800 字节,约为 6.2 兆字节(兆意味着“百万”)。电影以每秒 24 或 30 帧的速度录制,每分钟 1,800 帧,每小时 108,000 帧,或者一部两小时的电影需要 216,000 帧。如果每帧需要 6,220,800 字节,那么 216,000 帧就是 1,343,693 兆字节,约为 1,345 千兆字节(千兆意味着“十亿”)。
那么所有这些数据如何能存储在一张蓝光光盘上呢?部分原因是“蓝光”本身,它使用的蓝激光比激光唱片或甚至传统 DVD 上使用的激光更窄,从而允许将更多数据压缩到更小的区域,就像更小的字体可以让一页上容纳更多的文字一样。即便如此,一张蓝光光盘最多也只能存储约 50GB 的数据,这还不到所需数据的 4%。
流媒体视频也面临同样的问题。如果一个视频帧是 6.2 兆字节(MB),并且视频以每秒 30 帧的速度播放,那么流媒体就需要每秒 186 兆字节(MBps)的互联网连接。而典型的家庭宽带连接大约只有 4MBps。更糟糕的是,由于网络拥堵和波动,你无法指望在长时间传输过程中保持满负荷带宽。实际上,流媒体视频应该最多使用几兆字节每秒。
那么我们如何将大量的视频数据存储进这些小容器中呢?答案是数据压缩——以比原始格式所需更少的字节来存储数据。压缩技术大致可以分为两类。无损压缩可以将压缩后的数据恢复到完全原始的状态。而有损压缩则接受恢复的数据可能与原始数据略有不同。视频流媒体和存储通常使用这两种压缩方式的结合。在本章中,我们将首先通过简单的例子研究一些通用的压缩技术。接着,我们将看到这些思想如何应用于视频,生成几乎与未压缩的原始图像一样清晰的高度压缩图像序列。
游程编码
我们大多数人都使用过某种形式的无损压缩,虽然我们可能没这么称呼它,因为许多无损压缩的技术其实是常识性的方法。一个这样的例子是游程编码。假设我给你展示一个 27 位的数字,让你看一分钟,看看你是否能在一个小时后记住它。听起来可能很难,但看看这个数字:
777,777,777,555,555,555,222,222,222
我怀疑你不会试图单独记住每个数字。相反,你会统计每个数字的出现次数,并把它记作“九个七,九个五,和九个二”。
这就是游程编码的作用。当相同的数据片段(在这个例子中是数字)重复出现时,这些重复的片段称为游程,当游程很常见时,我们可以通过记录游程的长度而不是整个数字来缩短数据。游程编码是无损压缩,因为如果我们记住了简写版本的数字,我们可以在需要时恢复出它的原始形式。
仅仅使用行程编码就可以为某些类型的图像提供极好的压缩效果,例如图标、标志、漫画风格的插图——任何具有大块单一颜色的图像。当像素与相邻像素颜色相同时,我们可以显著减少存储需求。举个例子,我将描述TGA图像文件格式使用的系统。TGA 是Truevision Graphics Adapter(真视图图形适配器)的缩写,是一款早期的视频编辑硬件。尽管适配器已经不再使用,这种文件格式在视频行业中仍然被广泛应用,它可能是最简单的图像行程编码示例。
TGA 文件中的图像数据是按行进行压缩的。在每一行中,所有两个或更多个完全相同颜色的像素连续块都会被识别出来。其余的像素被称为原始像素。考虑图 6-1 中选中的示例图像行。在这一行中,有多个短的像素连续块,以及几个与邻近像素不同的原始像素。

图 6-1:选中的行包含了连续块和原始像素的混合。
TGA 格式将连续的像素和原始像素组织成数据包。每个数据包以一个字节的头部开始。头部字节的最左侧位确定该数据包是连续块数据包还是原始数据包。其余七个位表示数据包的像素大小。因为最小的数据包包含一个像素,所以 TGA 将数据包的大小编码为比实际大小少 1;即,大小字段为 0000000 表示大小为 1,0000001 表示大小为 2,依此类推。头部后面跟着的是该连续块中所有像素的编码颜色,或者对于原始数据包,则是每个像素的单独颜色。使用 RGB 颜色格式时,图 6-1 中的像素行将按照表 6-1 中的方式进行编码。
表 6-1:TGA 像素行的编码
| 行程/原始 | 大小 | 红色 | 绿色 | 蓝色 | 描述 |
|---|---|---|---|---|---|
| 1 | 0000001 | 11111111 | 11111111 | 11111111 | 两个白色像素的连续块 |
| 1 | 0000010 | 11001100 | 11001100 | 00000000 | 三个黄色像素的连续块 |
| 0 | 0000001 | 11111111 | 11111111 | 11111111 | 两个像素的原始数据包;第一个是白色 |
| 00000000 | 10000000 | 00000000 | 原始数据包中的第二个像素;深绿色 | ||
| 1 | 0000001 | 00000000 | 00000000 | 11111111 | 两个蓝色像素的连续块 |
| 0 | 0000000 | 11111111 | 11111111 | 11111111 | 一个原始白色像素 |
该编码需要 23 个字节,而未压缩的大小为 30 个字节。这个压缩比为 30:23,约为 4:3,虽然不算非常高,但需要注意的是,仅 4 个字节就足以存储每个像素颜色相同的行,例如图 6-1 的顶行。这个 TGA 格式位图的整体压缩比为 300:114,约为 5:2,效果相当出色。
字典压缩
仅仅使用自身,行程编码能够压缩包含大块纯色的图像,但电影中的大多数图像并不是这样。对于照片和其他类型的数字图像,其中有大量颜色变化,软件必须更加努力地寻找可以通过压缩利用的模式。一个关键工具被称为词典压缩。
基本方法
稍后我们将看到词典压缩如何应用于图像,但最容易理解这一概念的是将其应用于文本文件,因此我们从这里开始。未压缩的文本文件是作为一系列字符代码存储的,例如 ASCII。
我们将压缩这个示例段落:
由计算机创建的图像称为计算机图形。当这些由计算机创建的图像按顺序查看时,这个顺序被称为动画。由动画组成的完整电影,即由计算机创建的一系列图像,称为计算机动画电影。
为了简化这个示例,我将忽略文本中的空格和标点,只关注字母。这个段落中有 234 个字母;如果以未压缩的 ASCII 文本存储,这些字母将需要 234 字节。要对这些文本进行词典压缩,我们首先需要一个词典,在这个上下文中,词典是一个编号列表,列出了文档中每个被压缩的单词。表 6-2 就是我们的单词列表,按十进制和二进制编号。请注意,大写字母和小写字母算作不同的条目:an 和 An 是不同的条目。
表 6-2: 词典压缩
| 位置 | 二进制编码位置 | 单词 |
|---|---|---|
| 1 | 00000 | 一个 |
| 2 | 00001 | 一个 |
| 3 | 00010 | 一个 |
| 4 | 00011 | 动画 |
| 5 | 00100 | 动画 |
| 6 | 00101 | 是 |
| 7 | 00110 | 由 |
| 8 | 00111 | 被称为 |
| 9 | 01000 | 计算机 |
| 10 | 01001 | 创建 |
| 11 | 01010 | 整个 |
| 12 | 01011 | 从 |
| 13 | 01100 | 图形 |
| 14 | 01101 | 在 |
| 15 | 01110 | 是 |
| 16 | 01111 | 电影 |
| 17 | 10000 | 的 |
| 18 | 10001 | 图像 |
| 19 | 10010 | 顺序 |
| 20 | 10011 | 那个 |
| 21 | 10100 | 这些 |
| 22 | 10101 | 那些 |
| 23 | 10110 | 查看 |
| 24 | 10111 | 当 |
如图所示,5 位足以表示所使用的位置范围。原文中的每个单词都被替换为该表中的位置。例如,代替每次出现的计算机使用八个 ASCII 码(64 位),我们改用 5 位的词典条目。
然而,字典本身需要占用空间,并且必须包含在压缩文档中,因此只有当一个单词出现超过一次时,我们才能节省空间。在这个例子中,字典中所有单词的总字母数为 116,需占用 116 字节。将样本文本中 48 个单词中的每一个替换为一个 5 位字典引用需要 235 位,约为 30 字节。总的压缩存储量是 146 字节,相比原始未压缩的 234 字节,压缩比大约是 8:5。对于较长的文档,节省的空间将会更明显,因为文本增长的速度远快于字典。例如,一本典型的小说大约有 80,000 个单词,但使用的词汇量只有几千个单词。
哈夫曼编码
在几乎所有的文本中,某些单词的使用频率远高于其他单词。一种名为哈夫曼编码的技术利用了这一事实,以改进基本的字典压缩。
为了创建哈夫曼编码,文档中的单词按频率排序。假设有一个儿童故事,词汇量为表 6-3 中显示的 10 个单词。与基本字典压缩类似,每个单词都会被分配一个二进制编码,但在这里,出现频率最高的单词将被分配更短的编码。
表 6-3: 儿童故事的哈夫曼编码
| 单词 | 频率 | 二进制编码 |
|---|---|---|
| the | 25% | 01 |
| a | 20% | 000 |
| 公主 | 12% | 100 |
| good | 11% | 110 |
| 女巫 | 10% | 111 |
| evil | 8% | 0010 |
| ate | 7% | 0011 |
| magic | 4% | 1010 |
| 蘑菇 | 2% | 10110 |
| forevermore | 1% | 10111 |
一旦表格建立,哈夫曼编码压缩与基本字典压缩相同:每个单词都被其对应的二进制编码替换。例如,the princess ate a magic toadstool的编码将从the的 01 开始,接着是princess的 100,依此类推。完整的编码是:
011000011000101010110
正如你可能已经注意到的,表 6-3 中的二进制编码跳过了一些可能的编码,例如 011 或 0110。跳过编码是必要的,以使其成为一个前缀编码,即没有任何二进制编码会出现在另一个编码的开头。例如,由于 01 是the的编码,其他以 01 开头的编码,如 011 或 0110,都是禁止的。因为各个编码的长度不同,所以需要使用前缀编码来确定每个编码的结束位置。在我们的示例中,位序列开始的 01 必须是the的编码,因为没有其他编码以 01 开头;分割整个序列的唯一方式是:
01 100 0011 000 1010 10110
如果我们允许一个违反前缀规则的编码,序列可能会变得模糊。假设forevermore被分配了编码 00。虽然这是一个较短的编码,但它意味着该示例序列也可能被分解为:
01 100 00 110 00 1010 10110
这将解码为短语the princess forevermore good forevermore magic toadstool。
通过为最常见的单词分配最短的编码,霍夫曼编码在数据能够以相对较小的编码集存储且某些编码比其他编码更常见时,比仅使用字典压缩能获得更大的压缩效果。
为了更好的压缩重组数据
不幸的是,我们在视频中看到的图像并不是霍夫曼编码的理想对象。与我们使用游程编码压缩的色块图像不同,视频图像中的像素颜色覆盖了所有可能的颜色范围。由于 RGB 颜色有 1600 多万种不同的可能性,视频图像很可能没有足够的重复性来使霍夫曼编码有效。然而,有时可以通过改变数据存储方式,在变化的数据中创造出重复性。
预测编码
以某种方法为例,考虑一个每小时记录一次温度的气象站,在一天的时间里,记录了以下数据:
51, 52, 53, 54, 55, 55, 56, 58, 60, 62, 65, 67, 68, 69, 71, 70, 68, 66, 63, 61, 59, 57, 54, 51
ZIP 文件中的压缩
字典压缩和霍夫曼编码是大多数通用压缩方案的核心。例如,.zip存档格式可以选择多种压缩方法,但通常使用一种叫做deflate的算法。与其用一个来自单词列表的参考编号替换重复数据,这个算法使用一种称为滑动窗口的字典压缩变体。
使用这种方法,重复的数据被用数字指示符替换,显示数据之前出现的位置。在图 6-2 中的文本示例中,有三个重复的字符序列。每一对数字的第一个数字是回溯的字符数,第二个数字是连续字符的长度。例如,数字对 5, 2 表示“回溯五个字符,并复制两个字符。”

图 6-2:滑动窗口压缩
这段文本的压缩版本可以象征性地写成“Then t[5,2] scar[5,5]ed[16,4]m。”不过,这些数字对并不是直接存储的,而是经过霍夫曼编码的,因此最常见的数字对会被分配更短的编码。deflate 方法是一种非常有效的通用压缩方案,能够将托尔斯泰《战争与和平》的原始文本版本中的 3,138,473 个字符压缩成大约 930,000 字节的.zip文件,压缩比大约为 10:3。
如果我们假设温度范围从 120 到-50,我们可以用一个 8 位字节存储每个温度,共使用 192 位。不过,这个列表中没有太多重复项,因此霍夫曼编码并不有效。如果我们使用预测编码重新编写这个列表,情况就有所改善。对于第一个温度之后的每一个温度,我们记录的不是温度本身,而是它与前一个温度的差值。现在,列表看起来是这样的:
(51): 1, 1, 1, 1, 0, 1, 2, 2, 2, 3, 2, 1, 1, 2, -1, -2, -2, -3, -2, -2, -2, -3, -3
原始数据重复较少,而预测编码后的数据重复性较强。现在我们可以应用霍夫曼编码,取得非常好的效果。
量化
另一种方法是,如果我们愿意接受一定的数据劣化,可以采用量化,即以较低的精度存储数据。假设前面例子中的气象站还记录了每日降水量,并在三周内进行了如下读数:
0.01, 1.23, 1.21, 0.02, 0.01, 0.87, 0.57, 0.60, 0.02, 0.00, 0.03, 0.03, 2.45,
2.41, 0.82, 0.53, 1.29, 0.02, 0.01, 0.01, 0.04
这些读数有两位小数,但我们可能实际上不需要这么高的精度。首先,0.05 以下的任何数值可能代表的是收集器上的冷凝水,而不是实际的降水;同样,1.23 和 1.21 这样的读数之间的差异也可能只是冷凝水的区别。因此,我们可以省略每个数字的小数点后最后一位:
0.0, 1.2, 1.2, 0.0, 0.0, 0.8, 0.5, 0.6, 0.0, 0.0, 0.0, 0.0, 2.4, 2.4, 0.8,
0.5, 1.2, 0.0, 0.0, 0.0, 0.0
这样一来,数据就被压缩了,因为存储小数点后一个位数所需的位数比存储两个位数所需的位数要少。此外,量化后的数据也有几个零的连续序列,可以通过行程长度编码进行压缩,还有一些重复的数据可以通过霍夫曼编码进行压缩。
这些技术指向了一种通用的多阶段压缩方法。首先,通过存储数字之间的小差异而不是原始数字本身,量化数据,或两者结合,来重新组织数据,从而增加行程和重复的出现。然后,使用行程长度编码和霍夫曼编码来压缩数据。
JPEG 图像
我们现在几乎拥有压缩视频所需的所有工具。压缩视频的逻辑第一步是压缩视频中的单个图像。然而,我们不能直接将预测编码和量化应用于具有大量细微色差的数字照片和其他图像;我们需要先将这些图片转换成另一种格式。
这就是JPEG的基本思想,JPEG 是一种专门为数字照片设计的常见压缩图像格式。(这个名字是由开发该格式的联合摄影专家组的缩写组成的。)该格式的压缩方法基于对摄影和人类感知的几个关键观察结果。
首先,尽管像素颜色在图像中可能会有很大的变化,但单个像素往往与其邻近像素相似。如果你拍摄一棵树的照片,背景是部分多云的天空,那么许多绿色的叶片像素将与其他绿色像素相邻,蓝色的天空像素将与蓝色天空像素相邻,灰色的云朵像素将与灰色云朵像素相邻。
其次,在相邻的像素之间,亮度的变化会比色调的变化更为显著。以我们的树木照片为例,每个无数的叶片像素都会反射不同数量的阳光,但每个像素的基础颜色大致相同。此外,虽然人类视觉机制尚未完全理解,但测试表明,我们对亮度差异的感知比对色差的感知更加明显。
数字照片的高压缩仅能通过有损压缩实现;我们必须接受图像的某些退化。然而,遵循这些关键观察结果,可以使 JPEG 格式丢弃那些最不容易被察觉的数据。在我们的树木照片中,最重要的区分是树叶和天空之间的广泛差异,或者天空和云朵之间的差异,而不是两颗相邻云朵像素之间的差异。之后,最重要的区分是像素的相对亮度,而非相对颜色。因此,JPEG 格式优先考虑广泛差异而非细微差异,优先考虑亮度而非颜色。
另一种存储颜色的方式
JPEG 压缩将图像划分为 8×8 像素块,并对每个块进行独立压缩。为了分别压缩亮度和颜色,每个像素的 R、G 和 B 值会被转换为三个其他数值 Y、Cb 和 Cr。这里,Y 表示像素的 亮度,即像素产生的光的强度。Cb 是 蓝色差值,Cr 是 红色差值。最简单的理解 YCbCr 系统的方法是想象一个黑绿色的视频屏幕,屏幕上有三个旋钮标记为 Y、Cb 和 Cr,初始时都设为零:提高 Y,屏幕变亮;提高 Cb,屏幕变得更蓝且绿色减少;提高 Cr,屏幕变得更红且绿色减少。表 6-4 列出了在两种系统中比较的几种命名颜色。(历史注:YCbCr 来源于电视广播中使用的颜色系统。在早期彩色电视的时代,剩下的黑白电视机通过只解释信号中的 Y 分量就能正确显示彩色图像。)
表 6-4: RGB 和 YCbCr 颜色系统中的选择颜色
| R | G | B | 颜色描述 | Y | Cb | Cr |
|---|---|---|---|---|---|---|
| 0 | 255 | 0 | 青柠绿 | 145 | 54 | 34 |
| 255 | 255 | 255 | 纯白色 | 235 | 128 | 128 |
| 0 | 255 | 255 | 水绿色 | 170 | 166 | 16 |
| 128 | 0 | 0 | 栗色 | 49 | 109 | 184 |
JPEG 分别压缩 Y、Cb 和 Cr 数据,因此我们可以将每个 8×8 像素块看作是由三个 8×8 的 Y、Cb 和 Cr 数据块组成。这种数据分离方式利用了亮度比颜色变化更大的特点。在 YCbCr 系统下,大多数像素之间的差异会集中在 Y 分量中。Cb 和 Cr 数据块的低方差使得它们更容易压缩,而且由于我们对亮度变化的敏感度高于对颜色变化的敏感度,Cb 和 Cr 数据块可以进行更高比例的压缩。
离散余弦变换
转换为 YCbCr 遵循亮度比颜色更重要的观察。为了利用宽范围变化相较于窄范围变化更重要的特点,我们需要再次转换每个 8×8 数据块。然而,离散余弦变换(DCT) 将绝对亮度和颜色数据转换为这些值如何在像素之间变化的相对度量。尽管这种变换应用于整个 8×8 的数字块,但我首先将通过一个亮度(Y)块中的单行八个数字来说明这个概念,这些数字在 图 6-3 中以灰度表示。

图 6-3:一行亮度级别
为了开始 DCT,我们从每个数字中减去 128,这样可以将 0-255 的范围移动到围绕 0 的新范围,因此最大亮度为 127,绝对黑色为 -128。该行的结果亮度级别在 图 6-4 中以折线图表示。

图 6-4:从每个亮度级别中减去 128,使得可能数字的范围围绕 0 居中。
离散余弦变换(DCT)生成了八个新数字,每个数字以不同的方式组合了八个亮度级别。图 6-5 显示了上一张图的 DCT。

图 6-5:数据在 图 6-4 中的离散余弦变换。
请注意,数字按“粗糙”到“精细”的范围标记。DCT 中最左边的数字是亮度级别的最简单组合:它们的总和。因此,第一个数字是像素的总体亮度,对于亮度较高的像素行,它是正数,而对于较暗的像素行,它是负数。第二个数字有效地比较了行左端和右端的亮度级别,在本例中为正,因为我们左边的亮度级别比右边亮。最右边的数字有效地比较了每个亮度值与其邻近的像素,它在这里接近 0,因为 图 6-4 中的数字变化较为平缓。
这些 DCT 数字是由一种叫做 矩阵乘法 的操作得出的系数。如果你感到困惑,别担心:这个操作仅仅涉及乘法和加法。我们通过将亮度值与不同的预定向量相乘来生成每个系数。在这个上下文中,向量 只是一个有序的数字列表。DCT 中使用的八个向量在 图 6-6 中有示意图。(每个向量中的数字与三角学中的余弦函数相关,这也是离散余弦变换得名的原因,但我们可以忽略这一点,专注于讨论其他内容。)

图 6-6:我们单行 DCT 所需的向量
为了计算亮度行的系数,我们将向量中每个数字与相同位置的亮度相乘。例如,表 6-5 显示了如何计算亮度行的向量 2 系数。每个亮度行中的数字都与向量 1 中相同位置的数字相乘,然后将这些乘积求和,得到 157.386。
表 6-5: 计算向量 2 的系数
| 位置 | 亮度(来自 图 6-4) | 向量 | 乘积 |
|---|---|---|---|
| 1 | 76 | 0.49 | 37.24 |
| 2 | 127 | 0.416 | 52.832 |
| 3 | 127 | 0.278 | 35.306 |
| 4 | 76 | 0.098 | 7.448 |
| 5 | 25 | –0.098 | –2.45 |
| 6 | –26 | –0.278 | 7.228 |
| 7 | –77 | –0.416 | 32.032 |
| 8 | 25 | –0.49 | –12.25 |
| 总计 | 157.386 |
查看 图 6-6 的向量,你可以看到每个向量如何以不同的方式组合亮度级别。由于向量 1 中的每个数值都是相同的正数,因此向量 1 的系数成为整体亮度的度量。由于向量 2 中的数值逐渐从高到低变化,当亮度从左到右在像素行中逐渐下降时,第二个系数为正,而当亮度趋向增加时,系数为负。向量 3 的系数衡量的是行的两端与中间的差异,依此类推。你已经在 图 6-5 中看到了这些结果系数的图示;表 6-6 则通过数字展示了结果。
表 6-6: 从样本亮度行的离散余弦变换系数
| 向量编号 | 系数 |
|---|---|
| 1 | 124.804 |
| 2 | 157.296 |
| 3 | –9.758 |
| 4 | –87.894 |
| 5 | 18.031 |
| 6 | –49.746 |
| 7 | 23.559 |
| 8 | –13.096 |
这个过程是可逆的:我们可以通过将八个系数与八个不同的向量相乘来恢复原始的亮度数值,这个过程称为 逆离散余弦变换(IDCT)。表 6-7 展示了如何从系数中提取第二个亮度值 127。
表 6-7: 从系数计算第二个亮度值
| 位置 | 系数 | 向量 | 乘积 |
|---|---|---|---|
| 1 | 124.804 | 0.354 | 44.125 |
| 2 | 157.296 | 0.416 | 65.393 |
| 3 | –9.758 | 0.191 | –1.867 |
| 4 | –87.894 | –0.098 | 8.574 |
| 5 | 18.031 | –0.354 | –6.375 |
| 6 | –49.746 | –0.49 | 24.395 |
| 7 | –23.559 | –0.462 | –10.833 |
| 8 | –13.096 | –0.278 | 3.638 |
| 总计 | 127 |
离散余弦变换(DCT)为我们提供了一种不同的方式来存储相同的数字:作为数据之间的关系,而不是数据本身。为什么这很有用?记住,像素之间的细微差别比大范围的差别不那么显眼。稍后你会看到,DCT 如何让 JPEG 格式在压缩细节时比压缩大范围内容更有效。
二维 DCT
JPEG 压缩不是在像素行上工作,而是在 8×8 像素块上工作,因此现在让我们来看一下二维 DCT 如何操作。一维 DCT 将八个向量与原始的八个数字相乘,产生八个系数。然而,二维 DCT 需要 64 个 矩阵,每个矩阵是一个 8×8 的数字表。像向量一样,每个矩阵将与 8×8 块中的所有 64 个数据相乘。
这些矩阵本身是我们之前看到的向量的二维组合。最容易理解的是通过图示。图 6-7 显示了水平向量 1 与垂直向量 1 的组合。由于向量 1 中的所有数字都是相同的,结果矩阵中的数字也是相同的。在这些矩阵示意图中,较浅的灰色表示较大的数字。

图 6-7:向量 1 与自身的矩阵组合
在图 6-8 中,水平向量 1 与垂直向量 2 组合。结果矩阵从上到下逐渐变化,因为向量 2 逐渐变化,但从左到右没有变化,因为向量 1 中的数字没有变化。

图 6-8:向量 1 和向量 2 的矩阵组合
图 6-9 显示了最后一个例子,向量 8 与向量 8 的组合。由于向量 8 在正负之间来回摆动,组合矩阵呈现棋盘格样式。

图 6-9:向量 8 与自身的矩阵组合
二维离散余弦变换(DCT)将 8×8 块中的每一个 64 个数字替换为一个矩阵系数。图 6-10 显示了用于几个位置的矩阵。与一维 DCT 相似,左上角的系数(在图 6-7 中显示的是相同的)将原始块中的所有数字平均求和。随着我们向下和向右移动,测量的差异变得越来越细。

图 6-10:用于二维离散余弦变换(DCT)的一些矩阵
为了演示二维 DCT,我将仅使用图 6-11 中显示的像素块的亮度值。

图 6-11:一个像素块及其关联的亮度(Y)块
图 6-12 显示了相同的亮度块,其中每个数字都减去 128,使其范围从 –127 到 128,中心为 0。

图 6-12:图 6-11 中的亮度块,值的范围围绕 0 进行居中
图 6-13 显示了 DCT 后的亮度块。每个数字是通过将图 6-12 中亮度值的矩阵与图 6-10 中的矩阵相乘得到的系数。记住,这些数字也是围绕 0 中心的。例如,左上角的 132 表示整个块的高亮度水平。注意,左上角的数字是最大的(远离 0),这表明广泛的亮度差异比这个像素块中的细微差异要大得多。这一结果是 JPEG 编码照片的典型特征。

图 6-13:在图 6-12 中的块的 DCT
压缩结果
现在真正的压缩可以开始,第一步是量化。图 6-14 显示了用于量化亮度块的 8×8 除数块。图 6-13 中系数块中的每个数字都被除以图 6-14 中相同位置的数字,结果四舍五入到最接近的整数。这通过量化误差降低了图像质量,但请注意,图 6-14 中的除数在左上角是最小的。因此,量化误差在测量最细微区别的系数中最为显著,而这些误差最不容易被察觉。除数的实际值根据压缩质量的不同而有所变化,用于量化 Cr 和 Cb 块的除数较大,但除数块始终遵循这一一般模式(左上角的值较低,右下角的值较高)。

图 6-14:用于量化亮度块的除数
我们示例块的量化结果显示在图 6-15 中。
你可以看到这些数字是如何适合于运行长度编码和霍夫曼编码的。大多数系数已经被量化为 0,剩余部分中有许多重复的系数。
量化后,非零结果倾向于聚集在矩阵的左上角,因此量化后的数字以图 6-16 中所示的锯齿形模式列出。

图 6-15:量化后的亮度块

图 6-16:以锯齿形顺序存储系数
这种锯齿形模式倾向于在结尾产生非常长的零值运行,正如我们示例中所示:
8 10 -7 -7 6 -4 0 -2 1 -2 -1 -1 1 -1 0 0 1 0 0 -1 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
为了编码零值的运行,我们用一对数字替换列表中的每个非零项:跳过的零的数量(可能没有)和系数本身。例如,列表中的第八个数字是一个 –2,前面有一个 0。它将变成数字对 1, –2。在这一阶段,我们的列表如下所示:
0, 8
0, 10
0, -7
0, -7
0, 6
0, -4
1, -2
0, 1
0, -2
0, -1
0, -1
0, 1
0, -1
1, -1
2, 1
2, -1
(all the rest are zero)
这些数字对中,例如 0,–1,在这些列表中出现的频率远高于其他对如 0,10。为了实现最大压缩,JPEG 标准为这些列表中的每个可能的数字对定义了霍夫曼编码。例如,常见的 0,–1 对被编码为短霍夫曼码 001,而不常见的 0,10 对则被编码为较长的码 10110010。还有一个特殊的编码 1010,用于表示列表中其余的系数都是 0。我们的列表的霍夫曼编码显示在表 6-8 中。
表 6-8: 图 6-15 中系数的霍夫曼编码
| 跳过零值 | 系数 | 霍夫曼编码 |
|---|---|---|
| 0 | 8 | 10110000 |
| 0 | 10 | 10110010 |
| 0 | –7 | 100111 |
| 0 | –7 | 100111 |
| 0 | 6 | 100010 |
| 0 | –4 | 100100 |
| 1 | –2 | 11100110 |
| 0 | 1 | 000 |
| 0 | –2 | 0110 |
| 0 | –1 | 001 |
| 0 | –1 | 001 |
| 0 | 1 | 000 |
| 0 | –1 | 001 |
| 1 | –1 | 11001 |
| 2 | 1 | 110110 |
| 2 | –1 | 110111 |
| (仅剩零值) | 1010 |
右侧列中的所有比特串联在一起,表示我们原始亮度块的压缩编码。原始块使用 64 字节或总共 512 位表示亮度级别。而表 6-8 中的编码仅使用 88 位。
两个颜色块 Cr 和 Cb 会表现出更高的压缩率,因为应用于颜色块的除数更大,这产生了更小的数值、较短的霍夫曼编码以及更多的零值进行行程长度编码。总体而言,JPEG 图像通常能够达到 10:1 的压缩比。通过使用比图 6-14 中显示的更小或更大的除数,可以增加或减少压缩量。这些除数通过图像编辑程序中的“质量”滑块进行调整。将滑块移至“低质量”会增加除数,从而减小文件大小,但会增加量化误差。
JPEG 图像质量
高压缩比只有在恢复的图像与原始图像无法区分时才有意义,或者几乎无法区分。通常 JPEG 压缩对图像的改变是难以察觉的。为了感受压缩带来的变化,我们可以将原始的亮度值块与压缩和解压缩后的结果进行比较,正如在图 6-17 中所示。

图 6-17:原始亮度块,以及压缩和解压缩后的结果
由于很难直观地比较这两个数字块,图 6-18 通过灰度矩阵显示了它们之间的差异。正如你所看到的,矩阵的大部分是中性色灰,表示这些数值与原始值非常接近。

图 6-19:每个位置的亮度块误差量
JPEG 图像质量的最佳证据见图 6-19。上方是未压缩的数字照片。由于这张照片是灰度图,我们不需要 RGB 像素颜色,只需要一个字节来表示灰度级别。在 975×731 的分辨率下,这张未压缩的照片需要不到 713 千字节的存储空间。中间是原始照片的压缩 JPEG 版本,仅需 75 千字节的存储空间,几乎无法与原图区分。底部的照片是低质量 JPEG,使用了较大的除数。虽然该照片仅占用大约 7 千字节,但压缩伪影非常明显。许多单独的 8×8 像素块已经变成了相同灰度级别的实心方块。一般来说,JPEG 可以在不牺牲视觉质量的情况下达到 10:1 的压缩比。
压缩高清视频
JPEG 格式在压缩图像方面表现非常出色,几乎没有质量损失,但对于高清视频,我们需要更多的压缩。记住,未压缩的高清视频大约需要 186MBps。单独将每个图像压缩为 JPEG 格式,可以将这一需求降低到约 18MBps——这是一个巨大的改进,但对于流媒体或光盘存储,我们需要将数据压缩到每秒仅几 MBps。

图 6-18:未压缩照片(上)、高质量 JPEG 压缩(中)、低质量 JPEG 压缩(下)
时间冗余
为了达到这个目标,视频压缩技术利用了图像序列中图像之间的相似性。图 6-20 展示了电影开场字幕的图像序列。

图 6-20:开场标题序列中的几帧
这些图像将显示几秒钟;这意味着序列中将包含许多连续的重复帧。此外,即使视频从一张图像过渡到另一张图像,大部分画面保持不变,只有中间区域发生变化。
现在,请参考图 6-21 所示的图像序列。尽管每一帧与下一帧不同,但每一帧中都有相同的元素,只是它们在屏幕上的位置不同。

图 6-21:带有移动物体的图像序列
这些示例展示了两种不同形式的时间冗余,即从一帧到下一帧的数据连续性。利用这种冗余的压缩称为时间压缩,正如我们将在下一节中看到的,它是实现视频流媒体和存储所需压缩比的关键。
MPEG-2 视频压缩
MPEG-2是一种常见的视频格式,支持蓝光光盘和数字广播电视,它采用了一种时间压缩方法。虽然更先进的技术已经存在,但它们是对这里展示的思想的扩展。
图像组
MPEG-2 视频被划分为大约 15 帧的序列,称为图像组(GOPs)。每个 GOP 中正好有一帧被选为基本的 JPEG 编码图像,称为内编码帧(I-Frame)。这帧是整个 GOP 的基石。其他所有帧都使用时间压缩,这意味着它们不是以图像中像素的绝对颜色存储,而是通过这些颜色与 GOP 中其他图像的颜色差异来存储,稍后我们将详细了解。
组内的其他帧被分配为两种类型之一,预测帧(P-Frames)和双向帧(B-Frames)。P 帧存储其像素与前一帧像素之间的差异,而 B 帧则存储其像素与前一帧和后一帧像素之间的差异。
如图 6-22 所示,一个 GOP(图像组)中包含箭头,表示时间压缩所引用的帧。如你所见,一切都依赖于 I 帧。在播放过程中,I 帧必须先于其他任何图像解码,之后,直接引用 I 帧的其他帧才能解码,以此类推。

图 6-22:一个 GOP,或图像组
以这种方式对图像进行分组简化了编码和解码过程,同时也限制了引用“链条”的长度。就像复印复印件一样,时间压缩的链条越长,图像就越模糊。I 帧的定期出现也是你能够在快进或倒带时看到图像的原因;视频播放器只需提取 I 帧,这些帧可以独立于其所在 GOP 的其他帧进行解码和显示。
MPEG 规范赋予编码软件在形成 GOP 时较大的自由度。I 帧的数量直接决定了 GOP 的大小,由编码器决定,B 帧的数量也由编码器决定,这些 B 帧位于其他帧类型之间。与 JPEG 量化中使用的除数类似,调整三种帧类型的相对数量提供了一种在质量与压缩之间的权衡。在压缩至关重要的应用中,例如视频会议,I 帧较少,而 B 帧较多;而在蓝光光盘中,编码器会尽可能多地使用 I 帧,同时确保所有视频数据能够装入光盘。
时间压缩
那么 P 帧和 B 帧的时间压缩是如何工作的呢?在这个例子中,我们通过引用 I 帧来压缩 P 帧。首先,P 帧中的像素被划分为 16×16 的宏块。对于每个宏块,在 I 帧中搜索与其颜色数据相匹配的像素块。然而,这个匹配的块可能不会出现在 I 帧的完全相同位置,因此它由偏移量表示:即 P 帧位置和 I 帧位置之间的差异,表示为屏幕坐标。例如,偏移量为-100, 50 表示该宏块在 I 帧中的位置比在 P 帧中的位置左移 100 像素、下移 50 像素,如图 6-23 所示。

图 6-23:P 帧中的一个宏块,引用了前一帧中匹配的像素块
在大多数情况下,无法找到完全匹配,因此除了存储最佳匹配的位置外,还必须存储两个宏块之间的差异。图 6-24 展示了 P 帧中的一个亮度块及其在 I 帧中的最佳匹配。(为了使示例更易于理解,我使用了 8×8 的块,而不是完整的 16×16 宏块。)

图 6-24:亮度块及其在前一帧中的最佳匹配
接下来,计算差异块:将 I 帧块中的每个数值从 P 帧块中相同位置的数值中减去。我们的示例结果如图 6-25 所示。

图 6-25:两个亮度块之间的差异,如图 6-24 所示
由于这些块非常接近匹配,因此这些值都很小。这是一种预测编码的形式,就像本章前面展示的温度列表一样。通过存储差异,我们大大缩小了数据的范围,因此更容易进行压缩。当我们应用离散余弦变换(DCT)并量化结果时,数字变得极其微小,如图 6-26 所示。

图 6-26:量化图 6-25 中块并应用离散余弦变换(DCT)的结果
该块对压缩的最后阶段非常敏感:即行程长度编码和哈夫曼编码的结合。如表 6-9 所示,原始的亮度块已被压缩至仅 39 个比特。
表 6-9: 图 6-26 中数字的哈夫曼编码
| 行程长度 | 系数 | 哈夫曼编码 |
|---|---|---|
| 4 | 1 | 1110110 |
| 1 | –1 | 11001 |
| 0 | 1 | 000 |
| 0 | 1 | 000 |
| 0 | –1 | 001 |
| 1 | 1 | 11000 |
| 7 | 1 | 111110100 |
| (只剩下零) | 1010 |
并非 P 帧中的每个宏块都以这种方式编码。在某些情况下,一个宏块可能与前一帧中的任何像素块相似度不足,无法通过存储差异来节省空间。这些宏块可以像 I 帧中的宏块一样直接记录。对于 B 帧,匹配的宏块可以在前一帧或后一帧中找到,这提高了匹配的几率。
带有时间压缩的视频质量
时间压缩依赖于时间冗余——即变化较少的帧序列。因此,某些视频比其他视频压缩得更好。像《科洛弗档案》或《女巫布莱尔计划》这样的电影,由于镜头运动频繁,压缩较为困难;而像《2001 太空漫游》这类长镜头、摄像机不动的电影则理想适合压缩。
最终,视频压缩既是一门艺术,也是一门科学。如前所述,不同的 MPEG-2 编码器对同一组图像序列可能会产生不同的结果。较短的 GOP(更多 I 帧、较少 B 帧)会产生更好看的视频,而较长的 GOP 则意味着更好的压缩效果。编码器可以根据时间冗余的程度来调整帧的组合,在高时间冗余时使用较长的 GOP,在冗余较低时使用较短的 GOP。此外,优秀的编码器会尽量使 GOP 边界与电影中的明显剪辑对齐;如果你曾看到过在场景切换时视频瞬间变得非常块状,那很可能是因为 GOP 跨越了这个剪辑。
还有一个性能问题,尤其是当视频实时压缩时,比如直播事件。可能没有足够的时间在另一帧中找到宏块的最佳匹配。
播放质量也可能有所不同。例如,由于帧被拆分成单独处理的宏块,可能会在宏块的边界出现接缝。为减少这种效果,解码器可能会应用去块滤镜。这通过对像素颜色进行平均来平滑块边界,就像前几章展示的抗锯齿方法一样。滤镜的强度可以根据边界干净的可能性进行调整。例如,在 B 帧中,如果一个宏块引用了前一帧,而相邻的宏块引用了后一帧,那么边界粗糙的可能性更大,这时需要更强的滤镜处理。
在其他情况下,视频的分辨率和显示分辨率可能不匹配。例如,当你在高清电视上播放老电视剧Adam-12(不仅仅是我对吧?)时,电视机或播放器必须将原始的 640×480 图像转换为填充 1920×1080 显示屏。这与我们在第五章中解决的纹理映射问题相同——将位图应用到更大的区域——视频设备也可以采用类似的技术。早期的高清播放器实际上使用了最近邻采样,这产生了较差的效果。较新的播放器采用了类似三线性过滤的技术。不同的是,它们不再在 mipmap 中的两个不同级别的双线性样本之间进行混合,而是在连续的帧之间进行混合。这在平滑运动物体方面尤其有效。
尽管播放一个时间压缩视频的计算量不如原始编码那么强烈,但仍然需要处理器进行大量的计算。此外,GOP 的结构要求解码帧的顺序与实际顺序不同。这也意味着需要对帧进行缓冲,在显示前将其保存在队列中。对于流媒体视频,使用了更大的缓冲区,以便网络中的小问题不会中断播放。
视频压缩的现状与未来
最新的视频压缩标准,H.264 或 MPEG-4,扩展了 MPEG-2 中使用的技术,但并没有根本性的不同。主要的区别在于改进了宏块匹配的质量。宏块不再仅与一帧或两帧进行匹配,而是可以与 32 帧进行匹配。此外,16×16 的宏块本身还可以分解为单独匹配的 8×8 块。
通过这样的改进,MPEG-4 通常能够在相同的质量下实现 MPEG-2 的两倍压缩比。因此,MPEG-4 已成为流媒体和存储领域的行业标准。大多数蓝光视频都使用它,YouTube 和 Netflix 也在使用它。它的主要竞争对手是名为 Theora 的格式,Theora 使用类似的压缩方法,但它是免费的许可,而不像专有的 MPEG-4。
今天的压缩格式在缩小视频数据方面表现出色,但它们的计算成本非常高。下次你在 YouTube 上观看一个视频片段时,可以想想 GOP,所有宏块如何从一帧到下一帧被复制和更新,以及执行 DCT 时进行的所有数字运算。为了展示一只猫从钢琴上掉下来,进行这些计算是一项令人头晕的工作。
未来将需要更多的计算能力。像彼得·杰克逊的《霍比特人》系列电影中所展示的超高清(UHD)格式,正在逐渐进入家庭视频领域。UHD 图像的分辨率为 3840×2160,是当前高清分辨率的四倍。帧率也将增加,从今天的 24 或 30 帧每秒提高到 48、60,甚至 120 帧每秒。UHD 视频可能会将比特需求从今天的 1,400Mbps 增加到超过 23,000,这将需要相应增加带宽和磁盘存储容量——除非有聪明的人能提出一种更好的方法,让软件能够压缩数据。
第七章:7
搜索

本章讨论的是一个或许比本书中其他任何主题都更为我们所忽视的话题:找到我们想要的数据,也就是搜索。搜索如此频繁且迅速,以至于我们常常忽视了其中的“魔法”。当文字处理软件为你刚输入的错别字加上下划线时,背后已经进行了快速的搜索。当你输入文件名的一部分并得到笔记本硬盘上匹配的文件列表时,那也是一次几乎瞬间完成的搜索。而还有终极搜索成就:万维网。万维网庞大到我们只能猜测它的真实规模,然而,网页搜索引擎可以在不到一秒的时间内找到相关的网页。
软件是如何如此快速地找到我们想要的东西的呢?
定义搜索问题
让我们从理清术语开始。一组数据被称为数据集。数据集中的每一项被称为记录。每条记录通过一个键(与密码学术语无关)来唯一标识。搜索会检索出与给定键匹配的记录。举个现实中的例子,当你使用字典时,你查找的单词就是键,而该单词的定义就是记录。
搜索的主要目标是找到正确的记录。但搜索的速度同样重要。如果搜索可以无限进行,搜索就会变得简单。但随着等待时间的增加,我们的沮丧感也会增加。我们愿意等待的搜索时间是有差异的,但通常不会很长,在许多情况下,搜索必须看起来几乎是瞬间完成的。
将数据排序
高效的搜索需要井井有条的数据。当你去书店时,比如说,按作者的姓氏排列书架上的书籍,找到某个特定作者的小说就很容易。首先,你知道从哪里开始寻找。你看到书架上的第一本书,看到它的作者名字按字母顺序离你要找的作者名字有多近,你就能大致知道接下来该去哪里找。
如果书店没有按照任何特定顺序排列书籍,那么找一本书就会变得非常困难。最好的办法是从书架的一端开始,逐本检查,这叫做顺序搜索。在最糟糕的情况下,你想要的书甚至不在书架上,但直到你查看完所有书籍之前,你都不会知道这一点。
因此,将数据集按特定顺序排列,也就是排序,对于高效的搜索至关重要。有许多不同的排序方法;为了描述不同的排序算法,已经出版了整本书。我们将在这里介绍两种方法。
选择排序
如果我让你将一个数字列表排序,你很可能会使用被称为选择排序的方法。首先,你会扫描列表找到最小的数字,然后将该数字删除并复制到新列表中。你会重复这个过程,直到所有数字都在新的已排序列表中。
九个数字的选择排序的前三步显示在图 7-1 中。在第一步中,最小的数字被复制到新列表的开头。在接下来的步骤中,剩余的最小数字被复制到新列表中。

图 7-1:九个数字的选择排序的前三步
快速排序
虽然选择排序很容易理解,但软件中很少使用它,因为它效率低下。每一步都需要处理未排序列表中的每个数字,而为了这些努力,我们仅仅得到一个数字排到了正确的位置。
一种更好的排序方法叫做快速排序,它在每次遍历时部分排序所有处理过的数据,从而减少了后续的工作量和时间。我们不需要扫描整个列表来找到最小的数字,而是选择列表中的一个数字作为基准。我们利用基准来分区列表,将列表围绕基准进行划分。小于基准的数字移到列表前面,大于基准的数字移到列表后面。
在这个例子中,我们将使用与选择排序相同的数字列表。图 7-2 显示了分区的第一步。快速排序的不同版本选择基准的方式不同;我们为了简便起见,选择列表中的第一个数字 47 作为基准。接下来的数字 93 由于大于 47,因此被复制到新列表的末尾。

图 7-2:数字 93 大于基准,因此它移到新列表的末尾。
在图 7-3 中,56 同样大于 47,因此它被复制到末尾的下一个位置。

图 7-3:数字 56 大于基准,因此它移到新列表的末尾。
在图 7-4 中,33 小于 47,因此它被复制到新列表的前面。

图 7-4:数字 33 小于基准,因此它移到新列表的前面。
图 7-5 合并了接下来的五个步骤。剩余的三个数字移到列表前面,两个数字移到列表后面。这留下了一个空位,等待填充一个数字。

图 7-5:列表中剩余的数字被分区。
在图 7-6 中,这个空位被 47 填充,即基准数字。这完成了初始的分区过程。

图 7-6:基准填补了新列表中的空位。
这个新列表尚未排序,但比之前的状态更好。枢轴已经放在了正确的排序位置,用阴影标识出来。列表中的前四个数字小于 47,而最后四个数字大于 47。一次分区不仅仅是将一个数字放到正确的位置,就像选择排序的一步;它还将剩余的数字划分成子列表,如图 7-7 所示。这些子列表可以独立排序。排序两个较短的列表比排序一个较长的列表需要的努力要少。如果你对此有所怀疑,考虑一个极端的情况:你宁愿排序 50 个包含 2 个数字的短列表,还是一个包含 100 个数字的长列表?

图 7-7:分区已经将列表转变为两个独立的小列表,可以独立排序。
现在,两个子列表已经独立分区。在图 7-8 中,子列表中的第一个数字 33 成为新的枢轴,子列表 1 中的四个数字被分区。这将 22 和 11 放在 33 的左侧,将 45 放在右侧。

图 7-8:对图 7-7 的子列表 1 进行分区
在图 7-9 中,子列表 2 使用 74 作为枢轴进行了分区。

图 7-9:对图 7-7 的子列表 2 进行分区
这些分区将它们的两个枢轴放在了正确的排序位置。这些分区还创建了四个新的子列表,如图 7-10 所示。

图 7-10:现在剩下四个子列表。单一数字的子列表是琐碎的。
子列表 4 和 6 包含单一数字,这意味着没有需要分区的内容。在图 7-11 中,子列表 3 和 5 已被分区。

图 7-11:只剩下两个琐碎的子列表,意味着整个列表已排序。
现在我们只剩下两个单一数字的子列表,这意味着排序已经完成。
在这个例子中,枢轴均匀地划分了它们的分区,但快速排序并不总是如此幸运。有时分裂是不均匀的,最糟糕的情况是枢轴可能是列表中最小或最大数字,这意味着分区的结果与选择排序的某一步相同。但大多数分区会大致均匀,这通常会导致更快的排序。
更一般来说,快速排序比选择排序扩展得更好。对于任何排序方法,排序时间会随着数据集合大小的增加而增加,但选择排序的速度下降比快速排序要快得多。假设一台特定的计算机可以用两种方法在大约一秒钟内排序 10,000 条记录。在同一台计算机上,选择排序 1,000,000 条记录大约需要 3 小时,而快速排序只需要约 11 分钟。
二分查找
当数据是有序时,软件可以轻松找到特定的记录。对于有序数据,一种简单的查找方法是 二分查找。在这里,二分一词并不是指二进制数字,而是指在两种选择之间做出决策。
图 7-12 展示了二分查找的实际操作。我们要查找的记录的键值为 48。最初,我们知道的是集合中的数据按照键值排序,因此记录可能出现在任何地方。在第一步中,我们检查集合中间的记录。如果这条记录的键值为 48,那我们就完成了查找,但这不太可能。然而,由于这条记录的键值为 62,超过了 48,我们知道目标记录必须出现在前七条记录中。因此,检查这一条记录不仅排除了它本身,也排除了集合中之后的七条记录。
在第二步中,我们检查第四条记录,它是剩余七条记录的中点。这条记录的键值为 23,低于 48。因此,目标记录必须出现在 23 和 62 之间的三条记录中。
在第三步中,我们检查这三条记录中的中间记录,它的键值为 47。这告诉我们目标记录一定是 47 和 62 之间的那一条。如果那条记录的键值不是 48,那就意味着集合中没有键值为 48 的记录。

图 7-12:二分查找通过四步在 15 条记录的集合中找到特定记录
每一步二分查找都会将一半的记录从考虑范围中排除,这意味着二分查找的扩展性非常好。对于顺序查找来说,数据集合大小翻倍意味着平均查找所需时间也会翻倍。而对于二分查找来说,记录数翻倍只需要多一步。例如,如果我们从 31 条记录开始,检查中间记录后,要么我们运气好找到了目标记录,要么我们知道目标记录是在前 15 条记录还是后 15 条记录。无论哪种情况,我们现在只需要继续查找剩下的 15 条记录,这就相当于回到了 图 7-12 所示的状态。对于庞大的数据集合,二分查找与顺序查找之间的差距是巨大的。顺序查找 1,000,000 条记录平均会检查 500,000 条记录,而二分查找 1,000,000 条记录最多只需检查 20 条。
索引
为了简化示例,我们到目前为止使用的仅仅是记录的键值。然而,实际上,记录的其余部分必须存储在某个地方,这可能会引发问题。为了理解原因,我们需要明白在为数据分配存储空间时,软件所面临的选择,无论是在主存、硬盘,还是其他地方。
固定大小存储分配为每个记录分配相同的空间,适用于大小始终相同或有小的最大大小的数据。例如,信用卡号码始终是 16 位数字。另一方面,信用卡持有人的名字长度不一,但卡片上只能容纳有限数量的字母。信用卡号和持卡人姓名都可以存储在固定字节数中。在图 7-13 中,姓氏的最大长度为 15 个字符,正好够 "Hammond-Hammond"。其他名字较短,导致浪费的字节如阴影方块所示。然而,由于存储名字所需的空间很小,这些浪费的空间并不构成大问题。

图 7-13:固定存储分配导致空间浪费
可变大小存储分配精确适应数据的大小。考虑一组 MP3 文件。粗略地说,歌曲越长,MP3 文件就越大。一首短小的流行歌曲可能是 3 或 4MB,而一首渐进摇滚史诗可能大到 20MB。我们不希望将歌曲数据存储在固定大小的空间中,因为这样会浪费过多空间用于较短的歌曲,同时也会限制歌曲的长度。相反,数据应该存储在正好满足需求的空间中。
可变大小存储分配高效利用空间,但软件需要固定大小存储分配来使用高效的查找方法。当集合中的所有记录大小相同时,软件可以快速找到某个特定位置的记录。
这是因为存储位置是通过数字 地址 来标识的。数字存储中的每一个字节——无论是在计算机的主内存中,还是在闪存驱动器或硬盘上——都可以通过其地址精确定位。例如,如果一台计算机有 8GB 的主内存,那么这些字节的编号从零开始,直到超过 8 万亿。固定大小记录的集合是连续存储的,这使得查找记录的地址变得简单。假设一个集合有 100 个记录,每个记录 20 字节,且该集合从地址 1,000 开始。这意味着第一个记录位于地址 1,000,第二个记录位于地址 1,020,第三个记录位于 1,040,以此类推。我们可以通过将记录的位置编号乘以 20 并将结果加到 1,000 来计算任何记录的地址。这样,软件就能快速定位任何固定大小记录集合中的记录。
快速查找记录对于像二分查找这样的算法至关重要。如果没有固定大小的记录,查找某个特定位置的记录唯一的方法是从数据集的开始处开始,并逐一计算记录。这就是顺序查找,完全违背了高效查找的初衷。
选择固定大小或可变大小的存储分配意味着在高效搜索和高效存储之间做出选择。然而,一种叫做索引的技术使我们能够同时拥有这两者。索引将键与其余记录分开,就像图书馆卡片目录使读者可以先在卡片上查找书籍,再从书架上取书一样。
索引是记录键和值地址的表。地址本身作为二进制数存储,具有固定的位数。例如,当微软发布“32 位”和“64 位”版本的 Windows 时,这些位数指的是主内存地址的大小。由于地址是固定大小的,我们可以将地址和键一起存储在一个固定大小的索引记录中,并使用类似二分查找的方法高效地进行搜索。每条记录的其余数据存储在一个可变大小的分配区域中。这种方式产生的数据集合在存储和搜索上都很高效。
图 7-14 展示了一个包含四首歌曲的索引数据集合。左侧是索引,包含每首歌的歌名和其余数据的地址,如艺术家名称和编码的音乐。右侧是一个从 1 到 400 编号的内存单元块。箭头指向每个地址。
如示例所示,这种分离的数据分配方式允许每条记录根据需要使用多大或多小的空间。它甚至允许索引和剩余数据存储在不同的存储设备上。例如,索引可能保存在计算机的快速主内存中,而编码的音乐数据则存储在相对较慢的硬盘上。由于只需要索引进行搜索,这种安排使得在使用最少主内存的情况下实现高效搜索。

图 7-14:数字音乐的索引数据集合
我们也可以为同一数据集合设置多个索引。图 7-14 中的排列方式允许通过歌名快速定位单独的歌曲,但对于根据艺术家名称或专辑标题搜索歌曲没有帮助。数据集合可以有多个索引以满足不同的搜索标准,并且因为主记录数据只是通过地址进行引用,拥有多个索引并不会显著增加数据集合的总存储需求。
哈希
虽然有序数据对高效搜索是必需的,但排序数据需要时间。到目前为止,我们讨论排序时,仿佛数据集合只需要排序一次。有时确实如此;例如,文字处理程序需要一个正确拼写单词的列表用于拼写检查,但该列表只需创建一次,并作为应用程序的一部分提供。拼写检查单词列表是一个 静态 数据集合,很少发生变化。然而,我们搜索的许多集合是 动态的——记录会频繁地添加或删除。由于高效的搜索需要有序数据,集合必须在每次添加或删除后重新排序。当插入和删除操作很频繁时,重新排序数据集合所花费的时间可能会抵消搜索速度的提升。在这种情况下,更好的做法是构建数据结构,以便频繁的变更操作。
一种简化记录添加和删除的数据结构涉及哈希函数,它们在第二章中有所介绍。以这个例子为例,我们可以想象一个哈希函数,它生成一个仅为 3 位的哈希,相当于 0 到 7 之间的十进制数。我们可以利用它在一个 哈希表 中存储记录,哈希表有 8 个槽位。槽位是一个可能存放记录的地方。
要将一条记录存储在哈希表中,我们对记录的键进行哈希,以确定使用哪个槽位。假设我们正在存储带有歌曲标题作为键的 MP3 文件。表 7-1 显示了四个标题及其相关的哈希码。
表 7-1: 示例歌曲标题的哈希码
| 歌曲标题 | 哈希码 |
|---|---|
| Life on Mars | 6 |
| Nite Flights | 4 |
| Surrender | 1 |
| The True Wheel | 4 |
图 7-15 展示了插入 表 7-1 中前三首歌曲后的哈希表。每条记录的第一列是一个比特,如果槽位在使用中则为 1,如果不在使用中则为 0。第二列是标题,第三列保存剩余数据的地址。

图 7-15:一个八槽哈希表
哈希表的优点是搜索实际上并不需要进行查找。我们只需通过哈希函数处理键,结果会告诉我们记录应该存放在哪里。如果该槽位没有记录,我们立即知道集合中没有该键的记录。更好的是,哈希表避免了排序的工作量。这使得哈希表成为一个非常适合频繁添加和删除记录的集合的选择。
然而,我们并没有插入列表中的第四首歌曲。歌曲标题“The True Wheel”哈希到 4,和“Nite Flights”相同。如你在第二章中所记得的,哈希函数并不能保证对每个输入产生不同的哈希值,事实上,一些匹配的哈希值,或者碰撞,是不可避免的。由于每个插槽只能放置一个记录,我们需要一个处理碰撞的规则。最简单的规则是使用碰撞点之后的第一个空插槽。因为插槽 4 已经被“Nite Flights”占用,所以我们会将“The True Wheel”放入下一个空插槽,即插槽 5,如图 7-16 所示。

图 7-16:解决碰撞问题。第二首哈希到 4 的歌曲被放入下一个空插槽,即插槽 5。
这解决了碰撞问题,但它使得哈希表的使用变得更加复杂。
有了这个碰撞规则,查找记录不再是一个一步的过程。每次搜索仍然从哈希码所指示的插槽开始,但随后会依次检查插槽,直到找到匹配的歌曲标题。如果搜索达到一个空插槽,则说明歌曲不在集合中。
碰撞还可能导致记录被存储在与哈希码所指示的位置相距较远的地方。例如,如果插入一个哈希码为 5 的标题到图 7-16 所示的表中,即使之前没有任何歌曲标题哈希到 5,插槽已经被“The True Wheel”占用,因此新歌曲会移动到插槽 7。随着哈希表的填充,这种情况变得更加常见,导致搜索性能下降;实际上,一些哈希表搜索变成了小型的顺序搜索。
碰撞还使得记录的删除变得复杂。假设“ Nite Flights”从图 7-16 中的哈希表中删除。删除记录的显而易见的方法是将插槽标记为“空”,但那样并不奏效。为了理解原因,请记住,歌曲标题“The True Wheel”曾哈希到 4,并且该歌曲被存储在插槽 5 中,只有当时插槽 4 被占用。搜索“The True Wheel”将从插槽 4 开始,如哈希码所示,找到插槽为空并结束搜索,无法找到该歌曲。该歌曲仍然在索引表中,但通过哈希搜索无法找到。
为了避免这个问题,我们可以删除歌曲数据,但保留插槽标记为已占用,如图 7-17 所示。
插槽 4 现在被称为墓碑。通过在删除数据时将插槽标记为已占用,我们确保搜索仍然有效。然而,墓碑会浪费空间。此外,由于表格实际上并没有释放任何记录插槽,因此拥塞的性能问题依然存在。
基于这些原因,哈希表会定期进行重新哈希。当表中的某个槽位被占满到一定比例时,便会创建一个新的、更大的表,并且原表中的每个键值都会使用新的哈希函数重新进行哈希,生成一个新的、稀疏的表,并且不再包含任何“墓碑”条目。

图 7-17:删除其数据后,保持槽 4 标记为占用
网页搜索
本章中展示的所有技术都需要高效地搜索大型数据集,而没有比网络更大的数据集了。像 Google 这样的搜索引擎依赖于庞大的索引,其中的关键字是搜索词,地址是 URL,网页是记录。Google 索引的大小估计约为 100 PB(拍字节),即 100,000,000 GB。要在如此庞大的索引中找到某个内容,必须使用所有最佳的搜索技术。尽管这些技术有助于说明如何搜索如此庞大的索引,但它们并没有告诉我们这个索引最初是如何创建的。
搜索引擎使用机器人,即无需直接人工干预的程序,来建立它们的索引。这些机器人会遍布整个网络。从某个特定的网页开始,它们会列出该页面上的所有链接。然后,这些链接的页面会被处理,以寻找其他页面的链接,以此类推。最终,机器人会获取到网络上大部分的内容链接。
然而,有些内容更难以定位。有些页面无法从网站的主页访问,而是通过网站自己的搜索引擎找到。例如,新闻网站可能不会链接到较老的文章,但提供了本地搜索功能来查找其存档。这些未链接但有价值的内容被称为深网。将深网内容纳入搜索引擎索引通常需要网站的帮助。网站管理员可以通过几种方式为网页爬虫提供“目录”,例如一个叫做Sitemap的文档。这个文档得名于一些网站提供给用户快速找到所需内容的网站地图页面,但它有一个特定的格式,便于机器人处理。网站地图帮助搜索引擎及时更新内容变化,尤其对那些拥有大量深层内容的网站特别有用,这些内容否则可能会被排除在搜索引擎索引之外。
排名结果
当机器人收集页面时,搜索引擎会挖掘页面中的关键词,并统计每个关键词在每个页面上出现的频率。早期的搜索引擎只是简单地列出关键词及其页面计数。如果你搜索蛋糕,出现蛋糕关键词频率最高的页面将排在返回结果的最前面。这看起来很合逻辑,但仅仅根据字数统计并不能提供我们现在认为的优秀搜索结果。
第一个问题是,系统太容易被某些人利用来谋取个人利益。假设某个售卖假冒药品的网站运营者想要获取大量流量,而不在乎如何实现这一目标。当运营者发现大批人正在搜索煎蛋卷食谱时,他可能会尽可能多地将这些词语放在首页上,甚至将这些词语隐藏在后台的格式化代码中。结果,网站可能会出现在煎蛋卷食谱的搜索结果的前几名,尽管网站上根本没有这类食谱。词频并不保证搜索词和内容之间的匹配。
另一个网站运营者可能会建立一个真正与煎蛋卷相关的网站,但网站内容充满了从维基百科偷来的内容,以便通过广告获得零胆固醇蛋替代品的收入。在这种情况下,词频确实将搜索词与匹配内容联系起来,但内容质量很差。
根本的问题在于,网站自我报告其内容的性质和质量。缺失的是一个无偏的观众的意见。理想情况下,搜索引擎可以雇用一支审查员队伍来确定页面的主题以及它们覆盖话题的质量。然而,由于网络庞大且不断变化,这几乎是不可能的。
相反,搜索引擎依赖其他网站的意见。这些意见通过外部链接的形式获取。指向某个页面的链接数量是衡量该页面在在线社区中被看作多么有价值的一个良好指标。在图 7-18 中,C 页有四个外链,D 页没有外链,其他页面每个都有一个。仅凭这一点,C 页看起来是最有价值的资源,而 A、B 和 E 则显得同样有用。

图 7-18:指向页面的链接数量是搜索引擎用于确定排名的一个因素。
然而,故事还不止于此。具有大量外链的页面会为它所链接的页面加分。在前面的图中,三页只有一个外链,但每个链接的质量不同。E 页是从 C 页链接过来的,而 C 页有很多外链;而 A 页和 B 页只是相互链接。将每个链接的质量考虑到链接计数中,有助于打破链接农场,即大量无意义的网站通过免费的托管服务创建,目的是增加目标网站的外链数。
实际上,这将网络转变为一系列自组织的专家社区。当多个受人尊敬的烹饪网站开始链接到一个新的煎蛋卷网站,而该网站又反向链接到已建立网站中的煎蛋卷相关内容时,新网站便被纳入在线烹饪社区。此后,新网站的链接与老牌网站的链接同样重要。
有效使用索引
虽然建立索引是搜索引擎工作的主要部分,但索引在搜索过程中如何使用同样重要。良好的搜索结果需要关注细节。
首先,搜索引擎不能仅仅将提供的搜索词作为关键词。考虑到词形的差异,你可能会在搜索框中输入frozen rain,但大多数相关信息的页面使用的是frozen rain的变体形式freezing rain。通过将不同形式的关键词在其索引中关联起来,搜索引擎能够最大化搜索结果的有效性。这个思路同样适用于同义词。因为insomnia和sleeplessness的意思相同,搜索任何一个词都会产生相似的结果,尽管有些页面主要使用其中一个词。例如,关于失眠的维基百科条目在这两个搜索词的前几个结果中都会出现,尽管在撰写本文时,sleeplessness一词仅出现在文章中两次,而insomnia一词出现了 200 多次。
然而,这些搜索词的结果并不完全相同。搜索insomnia时还会出现 2002 年电影《Insomnia》的链接,但搜索sleeplessness时这些链接却不会出现。这一结果是合理的——可以推测,搜索电影的人不会使用电影标题的同义词——但是,搜索引擎怎么知道这两个词在某些情况下相关,而在另一些情况下则无关呢?
跟踪搜索词的组合可以提供宝贵的线索。如果搜索者经常在insomnia一词后加上movie或film等词语,那么仅搜索insomnia可能表明搜索者对电影感兴趣,而非对这一医学问题的关注。
此外,搜索结果页面上的链接实际上并不是直接链接到列出的页面。相反,它们是跳转链接。例如,如果你在谷歌搜索insomnia,然后点击维基百科条目的链接,你首先会被带到 google.com 服务器,然后再被重定向到wikipedia.org。谷歌会追踪你选择了哪个结果,且这些数据是从无数用户身上收集的,能够帮助谷歌不断优化搜索结果,将用户真正觉得有用的链接排在前面。
搜索引擎还可以利用搜索者的位置。例如,当你在某个城市搜索smiley’s pizza时,搜索引擎会将该城市的名称附加到搜索中,从而使结果本地化,而不是返回全球范围内最受欢迎的同名披萨店网站。
下一步:网络搜索的未来
尽管当前的网络搜索能力令人印象深刻,但仍有提升空间。
例如,图像为搜索引擎提供了独特的挑战。目前,图像文件是根据附带的文本进行索引的。搜索引擎可能会根据图像文件名收集线索,或者根据页面上图像周围的文本进行推测。
我们很快就能看到计算机视觉技术在网页索引中的应用。这种软件技术将图像转化为对图像的描述。从某种程度上来说,这与第四章和第五章中描述的图形技术正好相反,后者是将数学模型转化为图像。而计算机视觉则是将图像简化为数学描述,然后按模式进行分类。这样的软件目前已在自我管理的机器人中使用,以便它们能够识别出被指派去获取的物体。未来的搜索引擎可能会使用这些技术处理网页上的图像,识别出图像中的一般主题(如“晴空”,“小猫”)和特定主题(如“埃菲尔铁塔”,“亚伯拉罕·林肯”)。
索引的更新速度也将加快。目前,网络索引只有在网络爬虫机器人经过时才会更新。未来,索引可能会接近实时更新,这样社交媒体上快速发展的对话可以在发生的同时被索引。最终,实时搜索可能与人工智能结合,自动生成来自社交媒体的基础新闻报道,以应对像自然灾害这样的突发事件。
但这些都是明天的奇迹。今天的奇迹是网络及其搜索引擎,它们是一个信息的强大源泉,几乎是几十年前无法想象的。
第八章:8
并发

通常,我们能够察觉软件在做一些有趣的事情,即使我们不知道它是怎么做到的。我们知道计算机会生成图形、加密我们的传输并播放视频。但我们忽视的是,这些任务通常涉及多个程序、多个处理器,甚至是多个通过网络连接的计算机,同时访问相同的数据。
这种数据的重叠访问,被称为并发,是现代技术的关键部分。像图形处理和共享资源(如网站)这样的高性能任务,如果没有并发,根本无法实现。但如果并发没有得到妥善管理,就会带来很大的问题。在这一章中,我们将看到当多个处理器访问相同数据时,结果是如何变得混乱的。接着,我们将看看巧妙的软件(和硬件)技术,如何确保处理器不会相互干扰。
为什么需要并发
需要并发的情况可以分为三类:性能、多用户环境和多任务处理。
性能
当有更多的工作要做,而单个处理器无法处理时,就需要并发。直到最近,处理器每秒可以执行的指令数量还在稳步增加,但现在这种进步的速度已经放缓。为了在相同的时间内执行更多的指令,处理器必须运行得更快。运行速度越快,电力通过处理器的流动就越多,温度也会越高,最终可能会损坏组件。
为了缓解这个问题,处理器中的组件尺寸不断减小,以便它们消耗更少的电流并保持相对较低的温度。但是,使处理器组件进一步缩小变得越来越困难,这也使得它们的运行速度难以进一步提升。当单个处理器无法完成任务时,唯一的解决方案是使用多个处理核心。我们在第五章中已经看到过视频游戏图形的情况,但不仅仅是高端游戏图形需要多个处理器,即便是今天的一些基本图形任务也可能需要多个处理器核心。
多用户环境
并发还使得网络化计算机系统能够协同工作。假设你正在玩一款在线游戏,例如魔兽世界。游戏会跟踪每个玩家的动作以及计算机控制的怪物的动作。游戏的服务器会统计每个法术和斧头挥动的次数,并计算造成的伤害、击败的怪物数量以及掉落的战利品。
这里需要并发,因为每个玩家计算机中的处理器必须共享附近玩家和计算机控制的生物的数据。
多任务处理
即使只有一个处理器,仍然可以发生并发。现代计算机多任务处理,这意味着即使我们认为自己一次只在做一件事情,计算机也在不断地在不同程序之间切换。例如,多任务处理使得你的电子邮件客户端能够在你浏览网页时接收新邮件。在这些情况下,无论计算机是否有多个处理器核心,它都肯定在运行多个进程——不同程序的执行是重叠的。
打印是另一个典型的例子。当你从网站打印一个食谱时,管理打印机的软件(称为驱动程序)会按照顺序将打印数据排队,然后按需将其传递给打印机。这就是打印缓存。如果没有打印缓存,浏览器只能以打印机处理数据的速度发送数据,这意味着你必须等打印任务完成后,才能继续使用浏览器做其他事情。
打印缓存如果没有并发就无法工作。你可以把打印缓存想象成那种短时餐厅中,位于前台和厨房之间的旋转装置,就像图 8-1 所示的那样。前台的人将新订单放到旋转装置上,后台的人根据完成情况拿下订单。旋转装置的共享数据存储使得点单员和厨师能够独立工作。

图 8-1:订单票旋转装置
这种安排被称为共享缓冲区,并且在软件的幕后经常使用。例如,假设你正在输入一封邮件,但你的计算机暂时变慢,屏幕上没有显示你输入的内容。然后,系统赶上来了,所有你输入的内容都出现在邮件中。这是因为键盘并不直接与电子邮件程序通信,而是通过操作系统作为中介。操作系统将按键存储在共享缓冲区中,电子邮件程序可以在准备好时访问这些按键。
多任务处理还允许程序在后台运行,并在发生重要事件时打断你。当你在使用文字处理软件时,桌面屏幕角落弹出一封新邮件的提醒,或者你在玩游戏时,手机提醒收到新短信,这就是多任务处理在工作中的体现。
除了多个处理器和分布式处理的性能优势外,多任务处理的重要性意味着必须有某种形式的并发,以提供我们每天依赖的基本计算功能。
并发如何失败
尽管并发是日常计算中的重要部分,但它给软件带来了巨大的麻烦,如果没有适当的保障措施来防止问题的发生,可能会导致严重的故障。
根本问题在于数据在计算中是如何被复制的。本质上,计算机处理器所做的就是从存储中提取数字,然后用它们进行数学运算或比较。然而,要完成这些任务,处理器必须将数字从存储位置复制到处理器内部的某个位置。存储的数据不会直接被修改。相反,计算机会从主存储器、硬盘或通过网络提取值,并将其传输到处理器的最内层。处理器对这个内部副本进行计算,然后将更新后的值送回存储,以替换原始数据。
假设你正在玩一款第一人称射击游戏。当你跑过一个弹药包时,你的备用子弹数量是 300 发,捡起了 20 发子弹。图 8-2 展示了涉及的步骤。为了更新你的子弹数,处理器首先从存储位置获取当前的子弹数量和弹药包中的子弹数,如步骤 1 所示。这些值被输入到处理器中“加法器”电路的输入端,如步骤 2 所示,进行实际的数学运算。然后,结果被送回主存储器,替换掉子弹数存储位置中的旧值,如步骤 3 所示。

图 8-2:将数字从 300 更新到 320 的三步骤
这个更新顺序会在多个进程尝试对同一存储位置进行修改时引发问题。例如,考虑一个大型多人在线游戏(MMO)。Trina Orcslayer 和 Skylar Rockguardian 是两名玩家。他们都是同一个“公会”的成员,而这个游戏允许公会在多个游戏服务器之间共享银行账户。周五早上,公会账户的余额正好是 10,000 金币,Skylar 和 Trina 各自的个人账户中有 500 金币。在当天的某个时刻,Skylar 从公会账户提取了 300 金币,而 Trina 向公会账户存入了 200 金币。如果只有这两笔交易发生,那么最终的余额应为:公会账户中 9,900 金币(10,000 – 300 + 200),Skylar 账户中 800 金币(500 + 300),Trina 账户中 300 金币(500 – 200)。
如果这些交易被分开处理,那就会发生上述情况。假设 Skylar 在早上进行取款,而 Trina 在下午进行存款。我们不涉及编程部分,但可以考虑游戏软件在执行这些交易时将采取的步骤。让我们从 Skylar 的取款开始:
-
获取公会账户的余额,称之为Skylar 的副本。
-
从Skylar 的副本中减去 300 金币。
-
向 Skylar 的个人账户增加 300 金币。
-
更新公会银行余额为Skylar 的副本。
假设 Trina 在下午进行存款。她的交易步骤如下:
-
获取公会账户的余额,称之为Trina 的副本。
-
从 Trina 的个人账户中减去 200 金币。
-
向Trina 的副本增加 200 金币。
-
将公会银行余额更新为Trina 的副本。
在这个例子中,一切正常。但是如果 Skylar 和 Trina 同时进行交易,会发生什么呢?在这种情况下,公会账户的最终余额可能会不正确。如果在两个进程完成交易之前,两个进程都从银行提取了原始公会余额 10,000 金币进行计算,就会发生这种情况。
查看表格 8-1 中显示的详细信息。当 Trina 和 Skylar 同时发起交易时,同样的 10,000 金币余额会被提取到他们各自的余额副本中。Trina 的副本增加到 10,200,而 Skylar 的副本减少到 9,700。然后这两个更新的数字会覆盖公会账户余额。在表格中显示的例子中,Skylar 的更新数字最后到达,这意味着 9,700 成为了新的账户余额,而 200 金币就这样消失了。
结果本可以不同——Trina 的副本本可以在 Skylar 的副本之后到达,从而增加公会的金币余额,但当然无论哪种结果都不正确。唯一正确的最终余额是 9,900 金币,这个余额对应的是两个交易分别发生的情况。
类似于这个例子的情况可能发生在两个或更多进程同时使用相同数据时。这种情况的通用术语是竞态条件,因为所有涉及的进程都在竞速,争先完成任务。在这种情况下,最后完成的进程“获胜”,因为它决定了数据的最终值。
虽然这个例子涉及的是两个不同的处理器,Trina 的和 Skylar 的,但需要注意的是,即使只有一个处理器,竞态条件也可能发生。因为多任务处理涉及每秒多次切换处理器到不同的程序,所以多个进程操作相同数据时,可能会交替执行,导致竞态条件。
表格 8-1: 重叠银行交易的危险
| 步骤 | 描述 | Skylar 的副本 | Trina 的副本 | 公会余额 |
|---|---|---|---|---|
| Trina 1 | 从银行提取公会余额。 | 10,000 | 10,000 | |
| Skylar 1 | 从银行提取公会余额。 | 10,000 | 10,000 | |
| Trina 2 | 从 Trina 的储备中减去 200 金币。 | 10,000 | 10,000 | |
| Trina 3 | 向 Trina 的公会余额副本中添加 200 金币。 | 10,200 | 10,000 | |
| Skylar 2 | 从 Skylar 的公会余额副本中减去 300 金币。 | 9,700 | 10,000 | |
| Skylar 3 | 向 Skylar 的储备中添加 300 金币。 | 9,700 | 10,000 | |
| Trina 4 | 将 Trina 的公会余额副本发送到银行。 | 10,200 | 10,200 | |
| Skylar 4 | 将 Skylar 的公会余额副本发送到银行。 | 9,700 | 9,700 |
确保并发安全
为了使并发有意义,我们需要防止竞争条件的发生。这要求对进程如何访问数据进行规则的约束。限制越严格,越容易防止问题发生,但这些限制可能会对性能产生不利影响。
只读数据
一种可能的限制是允许进程同时检索数据,但禁止它们修改数据;这被称为只读数据。这可以消除竞争条件的可能性,但代价极大。大多数需要共享数据访问的应用程序无法在没有修改数据能力的情况下正常工作。因此,这种方法很少被考虑。然而,正如我们稍后看到的,将想要更改数据的进程与仅仅想读取数据的进程区分开来,可以提高并发性能。
基于交易的处理
另一种直接而全面的解决方案完全消除了同时数据访问的问题。示例中的竞争条件发生是因为 Skylar 和 Trina 的交易重叠了。如果我们能防止交易重叠会怎样呢?为了执行这一规则,一旦任何银行交易开始,我们需要等到它发出完成信号之后,才能开始其他交易。例如,Skylar 的流程中的步骤现在可能如下所示:
向银行服务器发出开始交易信号。
检索公会账户余额,将其称为Skylar 的副本。
从Skylar 的副本中扣除 300 金币。
向 Skylar 的个人储备中添加 300 金币。
将公会银行余额更新为Skylar 的副本。
向银行服务器发出结束交易信号。
Trina 的流程中的步骤也将按类似方式被括起来:
向银行服务器发出开始交易信号。
检索公会账户余额,将其称为Trina 的副本。
从 Trina 的个人储备中扣除 200 金币。
向Trina 的副本中添加 200 金币。
将公会银行余额更新为Trina 的副本。
向银行服务器发出结束交易信号。
银行服务器流程强制执行交易规则。当没有交易进行时,新的交易开始信号会立即被接受。所以,如果 Trina 的交易在空闲期间开始,它会继续进行。然而,如果 Skylar 的开始交易信号在 Trina 的交易正在处理时到达,Skylar 的交易就必须等到 Trina 的交易完成。如果在此期间有其他交易到达,银行服务器会将它们排入队列,按到达顺序处理。
这个规则将公会银行转变成相当于一个只有一个出纳员的大堂。如果客户到达且出纳员空闲,客户将立即得到服务;否则,客户必须等到出纳员空闲。这可以防止竞争条件,但剥夺了系统通过多个处理器带来的性能优势。就像在一个忙碌的银行只有一个出纳员意味着每个客户都要长时间等待一样,每次只允许一个交易通过银行服务器,也意味着每个交易都要相对等待较长时间。
这个规则过于严格。在任何给定时间,银行可能正在处理大量交易,而且其中很少(甚至没有)涉及相同的账户。这个规则通过阻止所有重叠交易来防止竞争条件,即使这些重叠是无害的。
信号量
另一个思路利用了大多数交易并不操作相同数据这一事实。如果交易规则像是一个只有一个出纳员的银行,那么更好的规则应该像是一个每个账户都有自己个人出纳员的银行。两个或更多客户同时尝试访问同一个账户时会排队,但访问不同账户的客户不会互相影响。
这种技术背后的秘密成分是称为信号量的特殊数据类型。在航海语言中,信号量是船只举起的旗帜,用来向其他船只发信号;在软件中,信号量是旗帜的数字等价物,用于指示是否存在逻辑连接的数据正在使用。最简单类型的信号量只有两个可能的值,0 或 1,称为二进制信号量。
信号量如何防止竞争条件
返回到我们的公会银行账户,我们可以通过在银行服务器上为每个账户余额创建信号量来避免竞争条件。每个信号量的初始值为 1。
在请求账户余额之前,进程必须先获取与该账户相关的信号量。此获取操作将检查信号量的值。如果信号量为 1,表示没有其他进程正在使用相关余额;此时,信号量将变为 0,进程将被允许继续执行。
然而,如果信号量已经为 0,意味着另一个进程正在访问相关的余额。在这种情况下,软件将必须等待。
当一个进程完成交易后,它会释放信号量,立即将其值恢复为 1。这允许其中一个等待信号量的进程继续执行。
使用信号量时,Skylar 的进程将如下所示:
-
获取公会账户的信号量。
-
获取公会账户的余额。称之为Skylar 的副本。
从Skylar 的副本中减去 300 金币。
-
向 Skylar 的个人存储添加 300 金币。
-
更新公会银行余额为Skylar 的副本。
-
释放公会账户的信号量。
以及 Trina 的:
-
获取公会账户的信号量。
-
获取公会账户的余额。将其称为Trina 的副本。
从 Trina 的个人储备中减去 200 金币。
-
向Trina 的副本中添加 200 金币。
-
更新公会银行余额为Trina 的副本。
-
释放公会账户的信号量。
通过这种方式,Skylar 和 Trina 被防止同时访问公会余额,从而避免了竞态条件。此外,任何不涉及该账户的其他事务也不会受到影响。
信号量是如何创建的
现在让我们来看看信号量是如何实际创建的。如果信号量实现不小心,它们可能会导致它们本应防止的竞态条件。尽管获取操作对于 Skylar 和 Trina 的进程来说只是一步,但实际上,它本身需要多个步骤:
-
获取信号量的值。
-
如果值是 0,返回第 1 步并重试。
-
将信号量设置为 0。
现在考虑一下如果 Skylar 和 Trina 的进程同时尝试获取公会账户信号量会发生什么。如果信号量的值是 1,两者都能在第 1 步获取到这个初始值,然后才有机会检查这个值并将其设置为 0。在这种情况下,两个进程都会认为自己是唯一获取信号量的进程,因此可以随意操作随附的银行余额。我们又回到了最初的状态。
要创建信号量,软件需要硬件的帮助。银行服务器上的处理器必须能够以一种方式实现获取和释放操作,使得没有任何东西能打断它们。这被称为使操作成为原子操作,在这个意义上,原子操作指的是不可分割的。
现代处理器实现了一种硬件操作,称为测试和设置。这个操作会将主存中的一个字节设置为特定值,同时检索先前的值进行检查。测试和设置使信号量成为可能。在信号量的步骤列表中,问题出在第 1 步和第 3 步之间的潜在中断。如果两个不同的进程在任何一个进程到达第 3 步之前执行第 1 步,它们都能修改信号量本应保护的数据。然而,通过使用原子测试和设置操作,信号量获取操作可以这样实现:
-
使用测试和设置,将信号量设置为 0 并获取旧值。
-
如果旧值为 0,返回第 1 步并重试。
现在竞态条件不会发生。如果两个进程同时尝试获取相同的信号量,它们都会执行第 1 步中的测试和设置操作。两个操作都会将信号量值设置为 0,但只有第一个执行测试和设置的进程会获取到 1,而另一个进程会获取到 0。一个进程会立即继续,另一个进程则必须等待。
无限等待的问题
使用这种两步计划获取信号量的进程——不断检查信号量的值,直到它恢复为 1——被称为自旋锁。这是等待信号量变得可用的最简单方法,但它有两个主要问题。首先,它浪费了处理器时间。处于自旋锁中的进程不断执行代码,但这些代码并没有做任何有用的事情。其次,自旋锁可能是不公平的。在某些情况下,一些进程的信号量检查速度可能比其他进程慢。也许进程运行在较慢的处理器上,或者进程正在通过较慢的网络与服务器通信。无论原因如何,如果一个信号量的资源如此受欢迎,以至于总有多个进程在等待,检查信号量较慢的进程可能永远无法获取到信号量。这就是饥饿现象;可以想象在一个繁忙餐厅里,最不积极的人面对唯一的服务员,你就能理解这一点。
有序队列
避免饥饿现象需要一种更有组织的等待方式。银行通过隔离带组织大厅里的等待,形成有序的排队。信号量也可以设计成做类似的事情。与其浪费时间不断检查信号量的值,许多获取操作在无法立即成功时,会让进程“休眠”,可以这么说。让计算机或手机“休眠”意味着暂停所有正在运行的应用程序,以便应用程序能够迅速恢复。以同样的方式,如果一个进程无法立即获取信号量,它将被挂起并从处理器中清除,但其内部数据将保存在存储中。
为了实现这一点,计算机的操作系统为每个进程分配一个唯一的标识符。当一个获取操作需要等待时,进程标识符会被放置在该信号量的等待队列末尾。当当前持有该信号量的进程释放它时,队列中的第一个进程会被唤醒。通过这种方式,进程会按照请求信号量的顺序获取它。一个进程可能需要等待获取一个热门信号量,但最终会排到队列的最前面——它不会饿死。
循环等待导致的饥饿现象
尽管信号量在正确实现和使用时可以防止竞态条件,但当进程需要访问由信号量保护的多个数据时,它们可能会导致饥饿现象。
假设 Skylar 和 Trina 的公会开设了一个二级账户,低级别的公会官员也可以访问这个账户,所以现在公会有一个主账户和一个辅助账户。银行系统为每个账户实施了信号量,消除了任何公会交易中的竞态条件。
但是在某一天,Skylar 和 Trina 每个人都在将 200 金币从一个账户转移到另一个账户,方向相反。两个事务都涉及从一个账户借出并存入另一个账户。Skylar 的事务会有这些步骤:
-
获取主账户余额的信号量。
-
获取主账户余额。
-
获取次账户余额的信号量。
-
获取次账户余额。
-
向次账户余额添加 200 金币。
-
从主账户余额中减去 200 金币。
-
更新次账户余额。
-
更新主账户余额。
-
释放次账户的信号量。
-
释放主账户的信号量。
Trina 的事务会像这样运行:
-
获取次账户余额的信号量。
-
获取次账户余额。
-
获取主账户余额的信号量。
-
获取主账户余额。
-
向主账户余额添加 200 金币。
-
从次账户余额中减去 200 金币。
-
更新主账户余额。
-
更新次账户余额。
-
释放主账户的信号量。
-
释放次账户的信号量。
因为所有共享值的访问都通过获取和释放相关的信号量得到了适当的括起来,所以这些事务的重叠执行不会发生竞争条件。然而,假设两个事务几乎同时开始,并且前几个步骤交错执行,如表 8-2 所示。
表 8-2: 多个信号量导致的无限等待
| 步骤 | 描述 | 主账户信号量 | 次账户信号量 |
|---|---|---|---|
| 初始状态。 | 1 | 1 | |
| Skylar 1 | 获取主账户余额的信号量。 | 0 | 1 |
| Skylar 2 | 获取主账户余额。 | 0 | 1 |
| Trina 1 | 获取次账户余额的信号量。 | 0 | 0 |
| Trina 2 | 获取次账户余额。 | 0 | 0 |
| Skylar 3 | 获取次账户余额的信号量。 | 0 | 0 |
| Trina 3 | 获取主账户余额的信号量。 | 0 | 0 |
我只展示了这些步骤,因为这是唯一会发生的步骤。Skylar 和 Trina 的过程都会在步骤 3 停止,因为两者都在尝试获取不可用的信号量。更糟糕的是,它们永远无法变得可用,因为每个信号量都被另一个进程持有。这就像是在一条双车道的路上等待交通清空,好让你左转,但有人从对面要在你后面左转,如图 8-3 所示。

图 8-3:如果两辆白色汽车都在等待左转,那么交通就会停止。
因为在这个例子中,任何一个进程都无法继续执行,直到另一个进程完成,这种情况被称为循环等待。在这种情况下,循环等待只涉及两个进程,但有时循环等待可能涉及多个进程,因此难以检测或预见。循环等待是死锁的一种表现,死锁描述的是一种进程无法继续执行的情况。循环等待是并发导致死锁的一种方式,除非采取预防措施,否则当进程同时持有多个信号量时,就有可能发生循环等待。幸运的是,这类预防措施是相对容易实现的。
一种解决方案是通过规则要求信号量必须按指定的顺序获取。在我们的示例中,游戏的银行管理系统可以为每个账户内部分配一个号码,并要求进程按数字顺序获取账户信号量。或者更广泛地说,进程只能在当前没有持有编号更高账户的信号量时,才可以获取某个账户的信号量。这个规则避免了前面示例中的循环等待。假设主账户是 39785,副账户是 87685。因为主账户的号码较低,Skylar 和 Trina 的进程都会尝试首先获取主账户的信号量。如果两个进程同时尝试,只有一个进程会成功。成功的进程将获取副账户的信号量并完成交易,此时两个账户的信号量都会被释放,允许另一个进程继续执行直到完成。
信号量的性能问题
在适当规则的约束下,信号量能够实现并发,而无需担心竞态条件、死锁或饥饿问题。然而,在我们试图通过让多个处理器共同处理同一任务来提高性能时,强制执行这些信号量规则可能会限制我们希望创造的性能收益。我们没有看到多个处理器一起工作,而是看到大量处理器排队等待工作机会。并发软件可以通过制定额外的规则来缓解这些性能问题。
有时,进程需要访问某一数据项,但不需要更改它。在我们正在运行的公会银行示例中,假设 Skylar 和 Trina 同时检查主公会账户——也就是说,两位玩家都没有进行存款或取款,而只是检查余额。在这种情况下,账户的同时访问不会产生危险。尽管进程可能会有重叠的检索操作,只要它们都没有更新余额,一切都不会出问题。
在“只读”情况下允许同时访问极大地提高了多处理器的性能,这只需要对信号量概念进行修改。我们将不再为每个要共享的数据项设置一个信号量,而是设置两个:一个读信号量和一个写信号量,并遵循以下规则:
• 获取关联的写信号量允许数据被检索或更新,就像前面例子中的信号量一样。
• 获取关联的读信号量可以检索数据,但不能更新数据。
• 只有当没有进程持有该数据的信号量(无论是哪种类型)时,才能获取写信号量。
• 只有当没有进程持有该数据的写信号量时,才能获取读信号量。
遵循这些规则意味着在任何给定时刻,要么一个进程已经获取了某个数据项的写信号量,要么一个或多个进程已经获取了该数据的读信号量。乍一看,这似乎是我们想要的。只要进程仅仅是查看数据而不进行修改,它们就可以共享访问。一旦某个进程需要更改数据,所有其他进程都会被锁住,直到更新进程完成工作。
不幸的是,这些规则可能会重新引入饥饿问题。只要只读进程不断到来,可能需要写信号量的进程可能会一直等待下去。为了防止这种情况发生,我们可以按如下方式修改最后一条规则:“只有在没有进程持有或等待写信号量时,才能获取读信号量。”换句话说,一旦进程尝试获取写信号量,所有之后到达的进程必须排队等待。
另一个可能影响性能的问题被称为粒度,在这个上下文中,粒度是指我们是锁定单个数据元素还是一组数据。例如,银行系统可以使用信号量来保护单个数据元素,如主公会账户的余额,或者它可以为与特定公会财务相关的所有数据应用一个读/写信号量对,例如所有公会账户的余额、允许访问该账户的公会官员名单等。
作为一个整体保护数据可能会导致更多的等待,因为一个只需要数据组中一两个数字的进程将不得不锁住整个数据组,这可能会阻塞另一个需要从该组中获取其他不重叠数据的进程。过细的粒度也会影响性能。获取和释放信号量需要时间,并且如果信号量过多,进程可能会花费大部分时间来处理它们。因此,开发人员必须仔细确定特定应用程序的最佳粒度。
并发的未来
出于几个原因,我们可以预见并发将在未来成为更为重要的问题。
现在,即使在我们最简单的计算设备中,也可以找到多个处理核心。对更强大处理能力的追求将持续进行,直到量子计算等新的处理范式出现为止,更多的处理能力将意味着更多的处理器核心。
多任务处理现在已经成为常态。我们期望我们的计算设备能够同时运行多个应用程序,并且在后台发生有趣的事情时打断我们的前台任务。
数据和设备之间的连接比以往任何时候都更加紧密。数据和处理正越来越多地从客户端设备转移到服务器或相互连接的服务器云上。在计算机游戏中,社交化已成为新的范式,在某些游戏中,即使是单人游戏模式也需要互联网连接。
简而言之,正确处理并发性在日常计算中变得至关重要。看似只有一个计算机在运行单用户应用程序,实际上可能包含一个多处理器系统,提供一个多任务环境,并共享云存储来存储数据。因此,并发的强大能力往往是隐形的。随着向更大并发性的趋势不断发展,我们可能会理所当然地认为,许多进程能够在不互相干扰的情况下协同工作。但计算机技术的未来进步将依赖于并发控制的进一步发展。目前,我们还不清楚当前防止死锁、饥饿和竞争条件的方法是否能随着并发性的增加而保持足够有效。如果当前的方法无法解决未来的挑战,它们将成为瓶颈,直到开发出更好的方法。
第九章:9
地图路线

由于我们可以通过 Google Maps 等网站即时获取方向,我们常常忘记,在不久前,人们常常在前往不熟悉的目的地时迷路。现在,软件为我们规划路线,甚至在途中发生事故或道路封闭时会调整路线。
在计算中,这个任务称为寻找最短路径。尽管名字如此,目标并不总是找到最短路径,更广泛地说是最小化成本,其中成本的定义是可变的。如果成本是时间,软件会找到最快的路线。如果成本是距离,软件则最小化里程,真正找出最短路径。通过改变成本的定义,相同的软件方法可以找到匹配不同目标的路线。
软件看到的地图
尽管软件可以提供路线指引,但它实际上不能读懂地图。相反,它使用数据表格。为了了解我们如何从地图到数据表格的转变,我们从图 9-1 开始,该图显示了一个城市地图的一部分,用于解决一个简单的路径规划问题。目标是找到从 3rd Street 和 West Avenue 交口到 1st Street 和 Morris Avenue 交口的最快路线。街道旁边的编号箭头显示了交叉口之间的平均行驶时间(以秒为单位)。请注意,1st Street 和 Morris Avenue 是单行道。

图 9-1:一个简单的路径规划问题:从 3rd 和 West 找到 1st 和 Morris 的最快路线。
为了生成可以由软件处理的数据表,我们首先将地图重新构思为图 9-2 所示的有向图。在这里,街道交叉点被表示为标记为 A 到 I 的点。图 9-1 中的箭头变成了图中点之间的连接,称为边。

图 9-2:图 9-1 中的地图作为有向图
使用有向图,我们将数据输入到表 9-1 所示的表格形式中。该表包含了图 9-2 中地图的所有信息,软件需要这些信息来找到最快的路线。例如,在图 9-2 中,从 A 到 B 的旅行时间为 23 秒;该信息由表格的第一行提供。请注意,不可能的行驶方向(如从 H 到 G)不会列出。
表 9-1: 图 9-2 中有向图的数据(以表格形式展示)
| 从 | 到 | 时间 |
|---|---|---|
| A | B | 23 |
| A | D | 19 |
| B | A | 15 |
| B | C | 7 |
| B | E | 11 |
| C | B | 9 |
| D | A | 14 |
| D | E | 17 |
| D | G | 18 |
| E | B | 18 |
| E | D | 9 |
| E | F | 33 |
| E | H | 21 |
| F | C | 12 |
| F | E | 26 |
| G | D | 35 |
| G | H | 25 |
| H | E | 35 |
| H | I | 28 |
| I | F | 14 |
最佳优先搜索
现在我们准备在地图上找到最短路径,这意味着在我们的图中找到从 A 到 I 的最低成本路径。解决这个问题有很多方法;我将描述的变种是一种叫做最佳优先搜索的算法。称这种算法为“搜索”可能有点误导,因为这种方法并不以目的地为目标。相反,在每一步,它会找到从起点到任何尚未遍历的点的最佳新路径。最终,这个过程会偶然找到通向目的地的路径,这条路径将是从起点到目标的最便宜路径。
下面是最佳优先搜索如何在我们的示例中工作的。所有从 A 出发的路径必须首先前往 B 或 D。算法首先比较这两个选择,如图 9-3 所示。

图 9-3:我们的最佳优先搜索的第一步。从 A 出发,我们可以前往 B 或 D。
在这些图中,黑色圆圈标记了我们已经找到最佳路径的点,而灰色圆圈表示我们可以直接从某个已标记(黑色)点到达的点。圆圈内的数字表示到达该点的路径成本。在每一步,搜索会检查所有从已标记到未标记点的边,以找到产生最低成本路径的边。在第一步中,选择是 A 到 B 的边还是 A 到 D 的边。由于到 D 的旅行时间比到 B 的时间短,因此最低成本路径是从 A 到 D,如图 9-4 所示。
我们刚刚找到了从 A 到 D 的最便宜路径。不管图的其余部分是什么样子,它都不可能包含比从 A 到 D 的成本更低的路径,因为这是从 A 出发的所有路径中的最低成本路径。同样,每一步都会产生一条新路径,这条路径将是从 A 到其他某个点的最低成本路径。
在第二步,有四条边需要考虑:A 到 B 的边和从 D 延伸出去的三条边。同样,算法将选择创建最快新路径的边。在考虑从 D 延伸出去的边时,我们必须包括从 A 到 D 的 19 秒时间。例如,从 A 到 E 通过 D 的时间是 A 到 D 边的时间(19 秒)与 D 到 E 边的时间(17 秒)之和,共 36 秒。
请注意,从 D 出发的一条边回到 A。在图 9-4 中,这条边末端的圆圈是白色的,表示它永远不会被选择。回到起点并没有什么好处。更一般来说,一旦一个点已经包含在某条路径中(在图中用黑色标记),后续出现的该点会被忽略,因为到达它的更好路径已经找到了。
在这个阶段,最低成本的新路线是通过 A 到 B 的边。这将我们带到了图 9-5 所示的阶段。同样,因为我们已经找到了所有剩余路线中的最低成本路线,这使得 A 到 B 的路线成为从 A 到 B 的最快方式。

图 9-4:在搜索的第二步中,最佳的新路线通向 D。标记 D 会暴露出三种新的路径选择,其中一种会回到起点。

图 9-5:我们最佳优先搜索的第三步找到了通向 B 的最佳路线。
我们接下来需要考虑六条边,尽管返回到 A 的边不是候选边。最佳选择是使用 B 到 C 的边,形成一个从 A 到 C 的 30 秒路线,如图 9-6 所示。

图 9-6:我们搜索的第四步找到了通向 C 的最佳路线。
然而,找到通往 C 的最快路线并没有帮助我们达到最终目标。从 C,我们只能返回到 B,而我们已经知道通往 B 的最快路线。
在这个阶段,最快的新路线是经过 B 到 E,如图 9-7 所示。

图 9-7:我们最佳优先搜索的第五步找到了通往 E 的最佳路线。
该过程持续进行,直到我们达到图 9-8 所示的状态。在这个阶段,最低成本的新路线使用了从 H 到 I 的边,这意味着我们最终确定了从 A 到 I 的最佳路线。

图 9-8:我们最佳优先搜索的第九步和最后一步到达了 I 点。
如图所示,从 A 到 I 的最快路线是 A-B-E-H-I。通过查看我们原始地图中的图 9-1 和其图形等效图图 9-2,我们可以看到这对应于沿着 3rd Street 走到 Kentucky Avenue,再左转进入 1st Street,最后驾车行驶一街区到达目的地。
重用先前的搜索结果
在这个例子中,最佳优先搜索不仅找到了从 A 到 I 的最快路线,还找到了地图上每个其他点的最快路线。尽管这是一个不寻常的结果,最佳优先过程通常会产生过剩的信息。至少,搜索结果将提供起点和目标点之间的中间点的最佳路线。在我们的例子中,从 A 到 I 的最佳路线包含了从 B 到 H、从 E 到 I 的最佳路线,依此类推。因此,最佳优先搜索的结果可以保存以供以后使用。
我们甚至可以在涉及原始地图数据中没有的点的搜索中使用这些数据。为了了解为什么,考虑一下图 9-9。这与图 9-2 中的有向图相同,不同之处在于它包含了一个新的点 J,J 有指向 A 和 B 的边。

图 9-9:图 9-2 中的有向图,增加了一个点 J
假设我们需要找到从 J 到 I 的最快路线。任何从 J 出发的路线都首先经过 A 或 B。我们已经知道从 A 和 B 到 I 的最快路线,结果见于图 9-8。从 A 到 I 的最快路线需要 83 秒。从 B 到 I 的最快路线需要 60 秒;我们通过从 A 到 B 的边的时间 23 秒,减去从 A 到 I 的总时间 83 秒,得出这个结果。
这意味着,从 J 到 I 的路线,如果先经过 A,则需要 102 秒——19 秒到达 A,83 秒从 A 到 I。直接到 B 的路线则需要 96 秒:36 秒到达 B,60 秒从 B 到 I。使用之前的搜索结果使得找到最快的 J 到 I 路线变得更加简单。
一次性找到所有最佳路线
一般来说,存储过去的搜索结果有助于未来的搜索。这个思想可以扩展到高效地找到给定地图上任意两点之间的最佳路线,这被称为 所有点对最短路径 问题。
弗洛伊德算法
我们将使用 弗洛伊德算法(有时称为 弗洛伊德-沃肖尔算法)来解决所有点对最短路径问题,该算法从单一边的简单路线开始,然后通过依次连接地图上的每个点来构建更长的路线。这种方法使用一个网格,网格的初始状态如图 9-10 所示。在每个步骤中,网格包含每对点之间最佳路线的费用。开始时,已知的路线只有直接连接各点的边,这是图 9-2 和表 9-1 中的相同数据。例如,A 行 B 列中的 23 表示从 A 到 B 的旅行费用。当“起点”和“终点”相同时,费用为 0。

图 9-10:弗洛伊德算法的初始数字网格。此时网格中唯一的路线是各个点之间的直接连接。
随着过程的继续,这个网格将会被填写并修改。在最初没有路线的地方会新增路线,比如从 A 到 F。成本更低的路线将替代现有的路线;例如,如果我们能以少于 35 秒的时间从 G 到 D,就会替换网格中当前的 35。
我们从考虑 A 点作为路线连接点开始。从图 9-10 中可以看到,B 和 D 都有通往 A 的路线。因为 A 有通往 B 和 D 的路线,A 可以将 B 连接到 D,也可以将 D 连接到 B。这些新路线在图 9-11 中以灰色方块显示。

图 9-11:使用 A 点作为连接点发现新路线
新路线的成本是我们连接的两条路线成本之和。在图 9-11 中,B 到 D 路线的成本(34)是 B 到 A 路线的成本(15)加上 A 到 D 路线的成本(19),如箭头所示。D 到 B 路线的成本(37)也是如此,它是 D 到 A 路线的成本(14)和 A 到 B 路线的成本(23)之和。
在下一步中,我们使用点 B 来连接现有路线。这将产生 8 条新路线,如图 9-12 所示。

图 9-12:使用点 B 作为连接器发现新路线
和前一步一样,每条新路线的成本是我们连接的两条路线成本之和。例如,新 A 到 E 路线的成本(34)是 A 到 B 路线的成本(23)与 B 到 E 路线的成本(11)之和。
在下一步中,使用 C 连接现有路线,揭示了三条新路线,如图 9-13 所示。

图 9-13:使用点 C 作为连接器发现新路线
在下一步中,我们第一次发现了更好的路线。之前我们找到了从 E 到 A 的 33 秒路线,而在这一步中,我们发现了一条通过 D 从 E 到 A 的 23 秒路线,并将网格更新为更低的成本。还发现了 9 条新路线,达到了图 9-14 所示的状态。

图 9-14:使用点 D 作为连接器发现新路线
这个过程不断进行,依次使用点 E 到点 I 连接路线,最终形成完整的网格,如图 9-15 所示。通过将这些点与原始地图上的街道名称对应,路由软件可以利用这个网格计算地图上任意两点之间的最快时间。如果你想知道从 1st 街与 West 街的交汇处到 3rd 街与 Morris 街的交汇处应该需要多少秒,软件会将其转换为关于图表中 G 到 C 路线的查询。然后,答案可以直接从网格中找到:77 秒。

图 9-15:通过 Floyd 算法生成的完整网格,显示从每个点到其他所有点的最快时间
存储路线方向
正如你可能已经注意到的,这个网格并没有告诉你最短的路线是什么——它只告诉你所需的时间。例如,你可以看到从 A 到 I 的最快路线需要 83 秒,但这条路线是从东边开始还是从南边开始?你在哪个地方转弯?为了记录具体路线,我们必须在更新网格中的路线时间时,记录每条路线的初始方向。
图 9-16 显示了起始网格。和以前一样,网格将用于存储迄今为止找到的最佳路线的成本,但现在它还将存储每条路线的初始行驶方向。这个起始网格仅包含原图的边。第一行第二列的 23 和 B 表示从 A 到 B 的最佳路线花费 23,并且从 A 开始朝 B 方向行驶。

图 9-16:Floyd 算法的初始网格,修改为存储每条路线的行驶方向
在图 9-17 中,我们使用 A 来连接现有的路线,正如我们在图 9-11 中所做的那样。但现在,在网格中添加或更新一条路线也意味着要记录该路线的方向。例如,从 B 到 D 的新路线,首先是前往 A。其逻辑是:“我们刚刚发现了一条从 B 到 D 的路线,它经过 A。已知从 B 到 A 的最快路线直接通往 A。因此,从 B 到 D 的路线也必须从 A 开始。”

图 9-17:使用点 A 作为连接器发现新路线
跳过 B 和 C 的步骤,图 9-18 显示了我们刚刚添加 D 路线后的网格。这里我们发现了一条新的从 B 到 G 的路线,花费了 52 秒。因为这条新路线经过 D,所以该路线必须像前往 D 的路线一样开始——先前往 A。

图 9-18:使用点 D 作为连接器发现新路线
图 9-19 展示了完整的网格,已删除了时间数据以便更清晰地呈现。

图 9-19:由 Floyd 算法生成的完整路由网格,显示了行驶方向。最快的从 A 到 I 的路线被高亮显示。
从 A 到 I 的最快路线在网格中被高亮显示。我们从行 A,列 I 开始,看到从 A 到 I 的最快路线首先是前往 B。然后我们查看行 B,看到从 B 到 I 的最快路线朝 E 走。E 到 H,H 到 I。使用这个网格就像是在每个街角停下,问:“我该往哪个方向走?”
路由的未来
今天的软件可以瞬间提供准确的路线,那么明天的地图软件可能做得更好是什么呢?
地图绘制的改进将来自数据的改进。例如,如果软件能够访问每小时的交通数据,它可以根据旅行的时间定制路线。
实时交通数据也可能会被集成到地图软件中。例如,大多数地图程序在用户请求新路线之前并不知道交通问题。在未来,你的地图软件可能会在你之前发现事故和道路封闭情况,并为你规划绕行路线。天气数据也可能被纳入其中,以提供更准确的旅行时间估算,并满足那些希望避免在大雨或其他不利天气条件下驾驶的驾驶员的偏好。
路线规划只是一个更大范围的软件领域的一个小部分,这个领域被称为地理信息系统(GIS),它使用软件回答关于地图和位置标记数据的问题。一些 GIS 任务与路线规划无关,比如确定一个区域是否拥有足够的潜在顾客来支持一个新的杂货店。但许多有趣的 GIS 项目将本章中的地图路线规划概念与关于地图道路沿线建筑物内的内容的数据相结合。例如,通过追踪学童的居住位置,GIS 软件可以规划出最有效的校车路线。
在未来,路线规划软件可能会扩展到包含更多通用地理信息系统(GIS)工具的功能。当你需要规划一条长途的城市外出行车路线时,软件可能不仅仅会提供你需要转弯的地方,还会突出显示你可能想要停留的地点,比如价格最合适的加油站和提供你最爱食物的餐馆。


浙公网安备 33010602011771号