4.2 点和向量

本文是Game Engine Architecture的一部分
免责声明

大多数的现代3D游戏是由虚拟世界中的三维物体组成的。游戏引擎需要跟踪每一个对象的位置、朝向和缩放,让它们在游戏世界中运作,然后将它们的坐标转换到屏幕空间,使它们能渲染在屏幕上。游戏中的物体几乎总是由三角形组成的,三角形的顶点则以点来表示。因此,在我们学习如何表示整个三维物体前,让我们先看一看点,以及它的近亲向量。

4.2.1 点和笛卡尔坐标系

从技术上讲,一个代表着一个n维空间中的位置。(在游戏里,n通常等于2或者3。)笛卡尔坐标系是目前为止最常为游戏程序员所使用的坐标系统。它使用二到三条相互垂直的轴来指定一个二维或三维空间中的位置。因此,一个点P是用一个二元组(Px, Py)或者一个三元组(Px, Py, Pz)来表示的。

当然了,笛卡尔坐标系并不是我们唯一的选择。一些其它常见的坐标系统包括:

  • 柱坐标系。该系统采用了一个垂直的“高度”轴h,一个由垂直方向发射出来的径向轴r,以及一个夹角theta(θ)。在柱坐标系中,一个点P由一个三元组(Ph, Pr, Pθ)表示。见图4.2。
  • 球坐标系。该系统采用了两个夹角phi(φ)和theta(θ),以及一个径向测量量r。一个点因此可以由一个三元组(Pr, Pφ, Pθ)表示。见图4.3.

笛卡尔坐标系是目前为止在游戏编程中最广泛使用的坐标系统。然而要记住,我们总应该选择最能反映手头上问题的坐标系。例如,在Midway Home Entertainment开发的游戏Crank the Weasel中,主角Crank在一座充满艺术感的城市中一边游荡一遍收集战利品。我希望让这些战利品绕着Crank的身体旋转飞舞,并越来越靠近直至消失。我将战利品的坐标表示在以Crank当前位置为中心的柱坐标系中。为了实现这样一个螺旋动画,我只需要简单地为战利品的θ赋予一个恒角速度,为其r轴赋予一个非常小的向内的恒线速度,再为其h轴赋予一个非常小的向上的恒线速度好让物品能飞进Crank的口袋。这个简单的动画看起来效果非常棒,并且使用柱坐标系来为其建模远比使用笛卡尔坐标系建模简单。

4.2.2 左手笛卡尔坐标系 vs 右手笛卡尔坐标系

在三维笛卡尔坐标系中,我们可使用两种方式来安排这三条相互垂直的轴:右手方式(RH)和左手方式(LH)。在右手笛卡尔坐标系中,当你的右手绕z轴弯曲,你的拇指将指向z轴正方向,你的其余手指从x轴绕向y轴。在左手笛卡尔坐标系中,情况完全相同,只是你要把右手换成左手。左手和右手坐标系唯一的区别在于其中一条轴的朝向。例如,如果y轴正方向朝上且x轴正方向朝右,那么在右手坐标系中z轴正方向朝向我们,而在左手坐标系中z轴正方向则背向我们。左手及右手笛卡尔坐标系如图4.4所示。

将左手坐标系转换成右手坐标系是非常容易的,反之亦然。我们只需要反转任意一条坐标轴的朝向,并使另外两条坐标轴的朝向保持不变。要记住的是,在左手和右手坐标系中数学规则并没有改变。只有我们对数字的解释 —— 在我们脑海中对数字如何映射到三维空间中的映像 —— 改变了。左手和右手公约仅适用于视图层,而对于底层的数学没什么关系。(事实上,当处理物理模拟中的叉乘时这是很有关系的。但对于绝大多数游戏编程任务来说,我们可以很安全地忽略这些微妙之处。有关更多信息,请参见http://en.wikipedia.org/wiki/Pseudovector。)

数值表示以及视图表示之间的映射完全取决于作为程序员或者是数学家的我们。我们可以选择让y轴指向上,z轴指向前,x轴指向左(RH)或指向右(LH)。或者我们也可以选择让z轴指向上。或者我们还可以让x轴指向上。重要的是,我们在做出了决定以后,应该始终坚持它。

话虽然这么说,但有时候一些约定确实在某些应用中工作得更好。例如,3D图形程序员通常使用一个左手坐标系,其中y轴向上,x轴向右且z轴指向远离观察者的方向(也就是说,沿着摄像机的朝向)。当3D图形使用该坐标系统渲染到2D屏幕上时,增加的y轴坐标对应着增加的景深(也就是距离摄像机的距离)。正如我们将要在接下来的几节中见到的,这就是为什么需要使用z缓冲方案来实现深度遮挡的原因。

4.2.3 向量

向量是一个在空间中既有大小又有方向的量。向量可以看做是一个从尾端点伸展向头端点的有向线段。相比之下,常用于表示大小的标量(也就是普通的实数值)是没有方向的。

通常标量以斜体表示(如v),而向量以黑体表示(如v)。

正如点一样,向量可以由一个由标量组成的三元组(x, y, z)表示。点和向量之间的区别实际上是非常微妙的。技术上来说,一个向量通常只是相对于一个已知点的偏移量。向量可以被移动到三维空间中的任意地方 —— 只要它的大小和方向不变,它还是同一个向量。

向量通常可以用来表示点,只要我们把它的尾部固定在坐标系的原点。这样的向量有时被称之为位置向量半径向量。根据我们目的的不同,我们可以将任意标量三元组解释为点或向量,只要我们记住位置向量的约束是把尾部定位在坐标系的原点就可以了。这意味着点和向量在数学中会以非常微妙的形式区分对待。有人把点称作绝对坐标,而把向量称作相对坐标。

在严格的线性代数场景下,大部分程序员使用名词“向量”来同时表示点(位置向量)和向量。大部分的3D数学库也是这样使用名词“向量”的。在本书中,当差别很显著时,我们会使用名词“方向向量”或只是“方向”。注意,你总是应该在脑海中保持对于点和方向的差异的清晰认识(即使你的数学库不作区分)。正如我们将要在章节4.3.6.1看到的,当需要把它们转换进齐次坐标系以执行4×4矩阵操作时,点和方向需要被区分对待。因此,将两种向量混合有可能会导致你的代码出现bug。

4.2.3.1 笛卡尔基本向量

为笛卡尔坐标系的三条轴分别定义三个正交的单位向量(也就是说,三个向量之间相互垂直,且长度均为一)总是很有用的。沿x轴的单位向量常被称作i,沿y轴单位向量常被称作j,沿z轴单位向量常被称作k。向量ijk有时候被称为笛卡尔基本向量。任意点或向量都可以通过标量(实数)乘以单位向量的和来表示。例如,
(5, 3, –2) = 5i + 3j – 2 k

4.2.4 向量操作

大多数你可以在标量上执行的数学操作也可以应用到向量上。也有一些新的操作只适用于向量。

4.2.4.1 向量乘以标量

将一个向量a和一个标量s相乘是通过将向量a中的每一部分分别与标量s相乘来完成的:

向量乘以标量将使向量的大小缩放,而保留其方向不变,如图4.5所示。将向量乘以-1将会反转向量的方向(头变尾尾变头)。

沿每个轴的缩放因子可以各不相同。这种缩放我们称之为非均匀缩放,它可以被表示为一个向量中的每一部分逐个乘以另一个缩放向量。这种运算我们使用⊗运算符表示。技术上说,我们把两个向量间的这种特殊乘法称之为Hadamard乘法。它很少在游戏产业中被使用 —— 实际上,非均匀缩放是它唯一在游戏中使用的地方:

我们将会在章节4.3.7.3中看到,缩放向量实际上只是3×3对角缩放矩阵S的一个压缩表示形式。因此方程(4.1)的另一种形式如下:

4.2.4.2 加法和减法

两个向量ab之间的加法被定义为ab中的每个部分各自相加。这可以被看做是将向量a和向量b头尾相接 —— 和将是从向量a的尾部指向向量b的头部的向量:

向量相减a – b只不过是a和-b(也就是将b乘以-1,使其反转)的加法操作。这使得结果向量的每一部分等于向量ab的每一部分分别做加法所得的差:

向量加法和减法如图4.6所示。

将点和方向相加或相减

你可以自由地对两个方向向量做加减法。然而技术上说,点却不可以相互加在一起 —— 你只可以把一个方向加到一个点上,该操作的最终结果是另一个点。同样地,你可以将两个点相减,从而得到一个方向向量。这些操作可概括如下:

  • 方向 + 方向 = 方向
  • 方向 – 方向 = 方向
  • 点 + 方向 = 点
  • 点 – 点 = 方向
  • 点 + 点 = 毫无意义(别这样做!)

4.2.4.3 大小

向量的大小是一个标量,它代表着向量在二维或三维空间中的长度。向量的大小以在向量的黑体符号旁边放置一条垂直的直线来表示。我们可以使用毕达哥拉斯定理来计算向量的大小,如图4.7所示:


4.2.4.4 在实际中运用向量操作

信不信由你,通过到迄今为止我们刚学过的向量运算知识,我们已经可以解决游戏中各种各样的问题了。当试图解决一个问题时,我们可以对已知的数据使用如加、减、乘和求长度等操作来生成新的数据。例如,假设我们拥有一个AI角色的当前位置向量P1,以及它当前的速度向量v,我们就可以通过将速度向量v与时间间隔Δt相乘,再将结果加上P1,以求得AI在下一帧的位置P2。如图4.8所示,结果方程式是P2 = P1 + (Δt)v。(这就是所谓的显式欧拉积分 —— 实际上它只在速度为常量时有效,但你懂的。)
再举个例子,假设我们有两个球体,并且我们想要知道它们是否相交。给出两个球的圆心坐标C1和C2,我们可以简单地让两点相减以求出两球之间的方向向量,d = C1 – C2。向量的大小d = |d|决定了两球中心相距多远。如果这个距离小于两球的半径之和,则它们是相交的,否则就是不想交。这如图4.9所示。

对于大多数电脑来说平方根运算的代价是昂贵的,因此游戏程序员总应该使用平方大小的方法来替代:

对于比较两个向量长度(“向量a是否长于向量b?”),以及比较其他一些(平方过的)标量时,使用平方大小是有效的。因此在我们的球体碰撞检测中,我们应该计算d^2 = |d|^2,并将之与两球半径的平方和(r1 + r2)^2进行比较,以获得最佳速度。当编写高性能软件时,永远别在不需要使用平方根的地方使用平方根!

4.2.4.5 规格化及单位向量

单位向量是指大小(长度)为一的向量。我们将会在接下来看到,单位向量在3D数学和游戏编程中是非常有用的。给定具有任意长度v = |v|的向量v,我们可以将其转换为单位向量u,且u具有和v相同的方向,但其具有单位长度。要做到这一点,我们可以简单地把v乘以它大小的倒数。我们把这种操作称之为规格化


4.2.4.6 法向量

如果一个向量垂直于某平面,我们则认为向量是这个平面的法向量。法向量在游戏和计算机图形学中是非常有用的。例如,一个平面可以由一个点和一个法向量来定义。在三维图形学的光照计算中大量使用了法向量,来定义光线和表面碰撞的方向。

法向量通常具有单位长度,但他们并不需要如此。小心不要混淆了术语“规格化(normalization)”和术语“法向量(normal vector)”。规格化的向量是任何具有单位长度的向量。法向量则是任何垂直于平面的向量,但它不必具有单位长度。

4.2.4.7 点乘和投影

向量之间可以相互作乘法,但与标量乘法不一样,向量乘法有许多种不同类型。在游戏编程中,我们最常使用以下两种乘法:

  • 点乘(也被称作标量乘或内积),和
  • 叉乘(也被称作向量乘或外积)。

两个向量之间的点乘将产生一个标量,它被定义为向量各部分乘积的和:

点乘还可以被写作是两向量的大小乘积再乘以两向量夹角的余弦值:
点乘符合交换率(也就是说做乘法的两个向量的顺序可以倒转)和加法分配率

点乘和标量乘法的结合如下所示:

向量投影

如果u是一个单位向量(|u| = 1),那么点乘(a · u)代表了向量a投影在由向量u的方向所定义的无限长直线上的投影长度,如图4.10所示。这个概念同等地适用于二维、三维以及更高的维度,以解决各种各样的三维问题。

作为点乘的大小

一个向量的平方大小可以由向量与自身的点乘得出。因此向量的大小可以简单地做平方根运算后得出:

这之所以可行是因为角度0的余弦值为1,因此剩下来的就是|a||a| = |a|^2。

点乘测试

点乘非常适合用来测试两个向量是否共线或垂直,以及他们是否大致上指向同一方向或相反方向。对于两个任意的向量ab,游戏程序员通常使用以下测试,如图4.11所示:

  • 共线。  (a ⋅ b) = |a||b|= ab(也就是说,两个向量之间的夹角正好为0度 —— 当两个向量均为单位向量时,结果为+1)。
  • 共线但反向。 (a ⋅ b) = –a(也就是说,两个向量之间的夹角为180度 —— 当两个向量均为单位向量时,结果为-1)。
  • 垂直。 (a ⋅ b) = 0(也就是说,两个向量之间的夹角为90度)。
  • 同向。(a ⋅ b) > 0(也就是说,他们之间的夹角小于90度)。
  • 反向。(a ⋅ b) < 0(也就是说,他们之间的夹角大于90度)。

点乘的一些其他应用

点乘可运用于游戏编程中的方方面面。例如,假设我们想要知道一个敌人是处于玩家的身前还是处于玩家的身后。我们可以通过将玩家的位置P与敌人的位置E相减(v = E – P)。假设我们拥有一个向量f指向玩家面对的方向。(我们将会在4.3.10.3中看到,向量f可以直接从玩家的模型世界矩阵中取得。)点乘d = v ⋅ f可以被用来测试敌人是否在玩家身前 —— 如果结果为正数则敌人在玩家身前,负数则是在身后。

点乘还可以用于判断一个点是处于平面的上方还是下方(这在编写一个月球登陆游戏中可能会很有用)。我们可以使用两个向量来定义一个平面:平面上的任意一点Q,以及一个垂直于平面的单位向量n(法向量)。为了找出点P相对于平面的高度h,我们首先要计算出从平面上任意点(点Q就很好)指向点P的向量。因此我们有了v = P – Q。向量v与单位向量n的点乘结果就是向量v在由向量n定义的直线上的投影。这正是我们想要寻找的高度。因此,h = v=(– Q)⋅n。这如图4.12所示。

4.2.4.8 叉乘

两个向量的叉乘(也被称作外积和向量积)将产生另一个垂直于这两个向量的向量,如图4.13所示。以下仅给出三维的叉乘运算:
叉乘的大小

叉乘结果向量的大小是每个被乘向量大小的积再乘以两向量夹角的正弦值。(这与点乘大小的定义十分相似,不过要把余弦换成正弦)。

叉乘|b|的大小等于以ab为边的平行四边形的面积,如图4.14所示。因为一个三角形是一个平行四边形的一半,以位置向量V1、V2和V3围成的三角形面积可以由其任意两条边的外积的大小的一半求得:

叉乘的方向

当使用右手坐标系时,你可以使用右手定则来确定叉乘的方向。将你的手握成杯状,手指从向量a转向向量b,则拇指指向的方向即为叉乘(b)的方向。

注意当使用左手坐标系时,叉乘的方向由左手定则确定。这意味着叉乘的方向取决于所选择的坐标系统。这可能在最开始看上去有点奇怪,但请记住,坐标系统的选择并不会影响我们将要进行的数学计算 —— 它仅改变了数字在三维空间中的可视化效果。当将右手坐标系和左手坐标系相互转换时,所有点和向量的数值保持不变,只是一条轴发生了反转。因此如果叉乘正好发生在我们进行了反转的那条轴(例如z轴)上时,我们需要将那一条轴所对应的值反转。如果不这样做,那我们就需要对叉乘本身的数学定义进行修改,以使得叉乘后z轴的值会在心坐标系中取反。我不会让这些规则导致我失眠。只需要记住:当可视化叉乘时,在右手坐标系中使用右手定则,而在左手坐标系中使用左手定则。

叉乘的属性

叉乘不符合交换率(因此是顺序相关的):

× b ≠ × a

然而,它符合反交换率:
× b = × a

叉乘符合加法分配律
× (c) = (× b) + (× c)

它和标量的乘法结合如下:

(sa) × b = a × (sb) = s(a × b)

笛卡尔基本向量与叉乘的关系如下:
(i x j) = -(j x i) = k
(j x k) = -(k x j) = i
(k x i) = -(i x k) = j

这三个叉乘定义了绕轴的正方向旋转。从x到y的正方向旋转(绕z轴),从y到z的正方向旋转(绕x轴),以及从z到x的正方向旋转(绕y轴)。正如我们将会看到的,这给了我们一个提示,那就是为什么绕y轴旋转的矩阵看上去与绕x或绕z轴旋转的矩阵相反。

实际中运用的叉乘

叉乘在游戏中有许多应用。其中一个最常见的用法是用来寻找垂直于另两个向量的向量。正如我们将在章节4.3.10.2所看到的,如果我们已经知道一个对象的本地单位向量组(本地向量ijk),那么我们就可以很容易找到表示这个对象朝向的矩阵。假设我们只知道一个对象的本地向量k —— 也就是该物体面对的方向。如果我们假设物体没有绕k方向的旋转,我们就可以通过将本地向量k(我们已知的)与世界坐标系的上方向向量(等于[0 1 0])做叉乘,以求得本地向量i。然后我们就可以通过简单地对ik做如下叉乘j = k x i以求得本地向量j

非常相似的方法可以用来求出一个三角形或其它形状的表面的法向量。给出平面上的三点P1、P2和P3,法向量n = normalize[(P2 – P1) x (P3 – P1)]。

叉乘还用于物理模拟。当一个力施加到一个对象上时,它将使对象产生旋转运动(如果力并非施加在对象的重心)。这个旋转力被称作力矩,它是如下计算的。给定力F,以及从重心到受力点的向量r,力矩N = r x F

4.2.5 点和向量的线性插值

在游戏中,我们经常需要找到介于两个已知向量中间的向量。例如,我们希望在两秒内将物体从A点移动到B点,帧率为每秒30帧,我们就需要在AB之间计算出60个中间位置。

线性插值是一种用于寻找两个已知点之间的中间点的简单的数学运算。该运算通常被缩写为LERP(linear interpolation)。该运算定义如下,其中β的范围是[0, 1]:

从几何上来说,L = LERP(AB, β)是某点由A开始,沿着AB方向移动百分之β距离后的位置,如图4.15所示。从数学上说,LERP函数只是求两个输入向量的加权平均,两个向量的权重分别为(1 – β)和β。注意到权重总是从0加到1,这是任何加权平均操作的通用要求。

3.1 C++回顾与最佳实践

本文是Game Engine Architecture的一部分
免责声明

3.1.1 面向对象编程简要回顾

本书中我们大部分要讨论的地方将假设你对面向对象的设计原则有着坚实的理解。如果你的知识有些生锈,那么接下来的章节将会是一个愉快的快速回顾。如果你完全不知道我在这一章里说了些什么,那么我建议你在最好继续之前先捡起一两本关于面向对象编程(如[5])的书籍,特别是C++方面的书阅读一下。

3.1.1.1 类与对象

是属性(数据)和行为(代码)的集合,它们一起构成了一个有用的、有意义的整体。类是一种规格说明书,它描述了被称为对象的类的个体实例该如何被构造。举例来说,你的宠物Rover是“狗”这个类的一个实例。因此,类与实例之间存在一个一对多的关系。

3.1.1.2 封装

封装意味着一个对象仅对外部提供有限的接口,对象内部的状态以及实现细节是对外部隐藏的。封装使得这个类的使用者的生活变得简单,因为他/她只需要去理解这个类有限的接口,而不是其潜在的复杂的实现细节。封装还允许类的作者保证类的实例总是在逻辑上具有一致的状态。

3.1.1.3 继承

继承允许新定义的类对已存在的类进行拓展。新的类可以对原有类的数据、接口和行为进行修改或拓展。如果类Child拓展自类Parent,那么我们就说Child继承自派生自Parent。在这种关系中,类Parent被称作基类或者是超类,而类Child被称作派生类或者是子类。显然,继承导致了类之间的(树形)层级关系。

继承创造了类之间的“is-a”关系。例如,圆形是形状的一种。因此如果我们在编写一个2D绘图程序,也许让圆形这个类继承自一个被称为形状的类会比较好。

我们可以使用约定好的统一建模语言(UML)来为类的层级结构绘图。在这种表示方法中,矩形代表类,空心箭头线代表着继承。继承箭头从子类指向父类。图3.1是一个由UML静态类图所表示的简单类继承。

多重继承

一些语言支持多重继承(MI),这意味着一个类可以拥有多个父类。理论上MI可以很优雅,但实际上这种设计通常会引起很多混乱和技术困难(参见http://en.wikipedia.org/wiki/Multiple_inheritance)。这是因为多重继承将一颗简单的变成了一个具有潜在复杂性的。图会带来这种各样的问题,而这些问题从来都不存在于树中 —— 例如,致命的钻石型继承(http://en.wikipedia.org/wiki/Diamond_problem),在这种情况下派生类最终包含了祖父类的两个副本(见图3.2)。在C++中,你可以使用虚拟继承来避免重复保存祖父类的数据。

大部分C++程序员完全避免使用多重继承,或者只是用其中一种受限的形式。常见的原则是,仅允许无父母的简单类被多继承进一个严格执行单继承的层次关系中。这些类有时被称作混合类,因为它们可以被用作在一颗类树的任意点引入新的方法。图3.3是一个被用作例子而设计出来的混合类。

3.1.1.4 多态

多态是一种语言特性,它允许使用一个单一的公共接口来操纵一个拥有多种不同类型对象的集合。从使用的角度看来,公共接口使得一个由异构物体组成的集合看上去像是同构的。

例如,一个2D绘图程序也许能在屏幕绘制一系列不同形状的图形。一种绘制这个异构集合的办法是使用switch语句来为每个单独的形状执行不同的绘制命令。

void drawShapes(std::list shapes)
{
     std::list::iterator pShape = shapes.begin();
     std::list::iterator pEnd = shapes.end();
     for ( ; pShape != pEnd; ++pShape)
     {
          switch (pShape-> mType)
          {
          case CIRCLE:
               // draw shape as a circle
               break
          case RECTANGLE:
               // draw shape as a rectangle
               break;
          case TRIANGLE:
                // draw shape as a triangle
               break;
               //...
          }
     }
}

这种方法的问题在于,drawShapes()需要“知道”所有能够被绘制的图形的种类。在这个简单的例子中这样做没什么问题,但随着代码在规模和复杂度上的增长,为现有系统增加新种类的形状会变得越来越困难。一旦一个新形状被增加,我们就必须在代码中找到所有嵌入了这一组形状类型处理逻辑的地方 —— 比如这个switch语句 —— 并添加一个case来处理新类型。

解决方案是将这一组处理特定类型对象的逻辑与其余绝大部分代码隔离开来。为了实现这个目标,我们可以为每一种我们希望支持的形状类型定义一个类。所有这些类均会继承自一个公共的基类。一个叫做Draw()的虚函数 —— C++语言中处理多态的主要机制 —— 将会在基类中被定义,每一个形状子类将分别以不同的方式实现该方法。由于不需要“知道”当前绘制的是哪一种图形,绘图函数现在可以简单地依次调用每一个形状的Draw()方法了。

struct Shape
{
     virtual void Draw() = 0; // pure virtual function
};

struct Circle : public Shape
{
     virtual void Draw()
     {
          // draw shape as a circle
     }
};

struct Rectangle : public Shape
{
     virtual void Draw()
     {
          // draw shape as a rectangle
     }
};

struct Triangle : public Shape
{
     void Draw()
     {
          // draw shape as a triangle
     }
};

void drawShapes(std::list shapes)
{
     std::list::iterator pShape = shapes.begin();
     std::list::iterator pEnd = shapes.end();
     for ( ; pShape != pEnd; ++pShape)
      {
          pShape->Draw();
     }
}

3.1.1.5 组合和聚合组合的做法是使用一组相互交互的对象来完成高级任务。组合创造了类之间的一种“has-a”或“uses-a”关系。(从技术的角度来说,关系“has-a”被称作组合,而关系“uses-a”被称作聚合)。例如,宇宙飞船拥有一个引擎,还拥有一个燃料箱。组合/聚合通常使得每个类变得更为单一而专注。没有经验的面向对象程序员往往过于依赖继承,而没有充分使用组合和聚合。举个例子,假设我们正在设计一款图形用户界面用作我们的游戏的前端。我们有一个叫做Window的类来代表任何矩形的GUI元素。我们还有一个叫做Rectangle的类封装了数学概念上的矩形。一个天真的程序员也许会让Window类直接继承自Rectangle类(使用“is-a”关系)。但在一个更为灵活且封装性更好的设计中,Windows类应该更倾向于包含有Rectangle类(采用“has-a”或“uses-a”关系)。这使得两个类的设计更为简洁、专注,并使它们易于测试、调试和复用。

3.1.1.6 设计模式

当同一类型的问题重复出现,且大多数程序员对于该类型问题都采用非常相似的解决方案时,我们就认为一个设计模式出现了。在面向对象编程中,一些常见的设计模式在许多书籍中被定义和描述。关于这一话题最著名的书籍可能是“四人帮”的书[17]

以下是几个常见的通用设计模式的例子。

  • 单例模式。该模式确保一个特定的类只有一个实例(单例),并对外提供访问该实例的全局方法。
  • 迭代器模式。迭代器提供了访问某个集合中元素的有效方法,且不会暴露集合底层的实现。因为迭代器“知道”集合实现的细节,所以用户不需要知道。
  • 抽象工厂模式。抽象工厂提供了接口用以创建一系列相关的类,但不需要指定具体的类。

游戏产业有着自己的一套设计模式,用以解决从渲染到碰撞到动画到音效等各个领域的困难。从某种意义上说,本书所讲的其实是现代3D游戏引擎设计中流行的高层次设计模式。

3.1.2 编码规范:为什么需要以及需要多少

工程师之间关于编码规范的讨论往往将导致狂热的“宗教式”辩论。我不希望在这里引发任何这样的辩论,但我依然认为至少去遵循一些最低限度的编码规范是一个好主意。编码规范之所以存在主要有两个原因。

  1. 其中一些规范使得代码更易于阅读、理解和维护。
  2. 另一些规范有助于防止程序员们搬起石头砸自己的脚。例如,某种编码规范可能鼓励程序员只使用编程语言的一个更小、更易于测试和更不容易出错的子集。C++语言中充斥着滥用的可能性,因此对于C++来说,这种编码规范是特别重要的。

在我看来,编码规范中最应该达成的有以下几点。

  • 接口为王。保持你的接口(.h文件)干净、简单、最小化、易于理解且注释充分。
  • 好的名字有助于理解并有利于避免混淆。对于类、函数和变量坚持使用直观表意的名字。避免让程序员不得不通过查表的方式来解密你的代码。记住,像C++这样的高级语言是为了让人类阅读的。(如果你不同意,问问你自己为什么你不直接使用机器语言来编写所有的软件。)
  • 不要污染全局命名空间。使用C++命名空间或一个公共的命名前缀以确保你的符号不会与库中的其它符号相冲突。(但注意不要过度使用命名空间,或将它们嵌套的太深。)对于使用#define定义的符号要格外小心;记住C++预处理宏仅仅只做文本替换,因此它们将跨越所有的C/C++域和命名空间。
  • 遵循C++最佳实践。像ScottMeyers所著的Effective C++系列书籍[31,32],Meyers所著的Effective STL[33],和John Lakos所著的Large-Scale C++ Software Design[27]中均提供了优秀的指导方针,这将帮助你摆脱困境。
  • 保持一致。我一直尝试使用以下规则:如果你正在从头开始编写代码,你可以按照你的喜好使用任何编码规范 —— 然后保持下去。当编辑已存在的代码时,尝试去遵循代码中已有的编码规范。
  • 突出错误。Joel Spolsky写了一篇非常好的关于编码规范的文章,可以在 http://www.joelonsoftware.com/articles/Wrong.html看到。Joel认为最“干净”的代码不一定是那些在表面上看起来干净整洁的代码,而是那些使得常见编程错误易于显现的代码。Joel的文章通常非常有趣且富有教育意义,我强烈推荐这一篇文章。