3.2 n-元组基类(n-Tuple Base Classes

pbrt 中表示二维和三维点、向量以及表面法线的类,均基于通用的 \( n \)-元组类,我们将从这些类的定义开始。这些类的定义以及继承自它们的类型都在 pbrt 源代码的 util/vecmath.hutil/vecmath.cpp 中定义。

尽管本节及接下来的几节定义的类在大多数方法实现中只有简单的逻辑,但它们比我们在 pbrt 中通常使用的更充分地利用了高级 C++编程技术。这样做减少了实现点、向量和法线类所需的冗余代码,并使它们在之后可以更好地扩展。如果您不是 C++专家,可以略过这些细节,专注于理解这些类提供的功能。或者,您也可以借此机会学习语言的更多细节。

Tuple2Tuple3 都是模板类。它们不仅在存储坐标值的类型上进行了模板化,还在继承自它的类的类型上进行了模板化,以定义特定的二维或三维类型。如果之前没有见过,这是一种奇怪的构造:通常,继承就足够了,基类不需要知道子类的类型。†(这种继承形式在C++中通常被称为 奇异递归模板模式(curiously recurring template pattern)(CRTP)。)在这种情况下,让基类知道子类的类型使得编写操作子类型并返回子类型值的泛型方法成为可能,正如我们稍后将看到的。

/** Tuple2 定义 */
template <template <typename> class Child, typename T>
class Tuple2 {
  public:
    /** Tuple2 公有方法 */
    /** Tuple2 公有成员 */
};

二维元组将其值存储为 xy ,并将它们作为公有成员变量提供。每个变量后的一对大括号确保成员变量被 默认初始化(default initialized);对于数值类型,会将其初始化为 0。

/** Tuple2 公有成员 */
T x{}, y{};

我们将专注于本节剩余部分的 Tuple3 实现。 Tuple2 除了少一个坐标,几乎完全相同。

/** Tuple3 定义 */
template <template <typename> class Child, typename T>
class Tuple3 {
  public:
    /** Tuple3 公有方法 */
    /** Tuple3 公有成员 */
};

默认情况下,\( (x,y,z) \) 的值被设置为零,当然类的用户可以选择为每个分量提供初始值。如果用户确实提供了初始值,构造函数会使用 DCHECK() 宏检查这些值中是否有浮点数“不是一个数字(not a number)”(NaN)。在优化模式下编译时,该宏会从编译后的代码中消失,从而节省验证此情况的开销。NaN 几乎肯定表示系统中存在错误;如果某个计算生成了 NaN,我们希望尽快捕获它,以便更容易地隔离其来源。(有关 NaN 值的更多讨论,请参见第 6.8.1 节。)

/** Tuple3 公有方法 */
Tuple3(T x, T y, T z) : x(x), y(y), z(z) { DCHECK(!HasNaN()); }

接触过面向对象设计的读者可能会质疑我们将元组分量值公开访问的决定。通常,成员变量仅在其类内部可访问,外部代码如果希望访问或修改类的内容,必须通过一个明确定义的 API 来实现,该 API 可能包括选择器(selector)和修改器(mutator)函数。尽管我们对封装(encapsulation)原则表示同情,但在这里并不合适。选择器和修改器函数的目的是隐藏类的内部实现细节。在三维元组的情况下,隐藏其设计的基本部分没有任何好处,反而增加了使用它们的代码的复杂性。

/** Tuple3 公有成员 */
T x{}, y{}, z{};

HasNaN() 测试单独检查每个分量。

/** Tuple3 公有方法 */
bool HasNaN() const { return IsNaN(x) || IsNaN(y) || IsNaN(z); }

这两个元组类的另一种实现方式是使用一个单一的模板类,该类用一个整数参数作为维度,并用一个包含相应数量 T 值的数组来表示坐标。虽然这种方法通过消除对单独的二维和三维元组类型的需求来减少代码总量,但向量的各个分量无法像 v.x 那样被访问。我们认为,在这种情况下,向量实现中多一些代码是值得的,以换取对分量的更透明访问。然而,一些例程确实发现能够轻松遍历向量的分量是有用的;元组类还提供了一个 C++运算符来索引分量,例如给定实例 vv[0] == v.x 等。

/** Tuple3 公有方法 */
T operator[](int i) const {
    if (i == 0) return x;
    if (i == 1) return y;
    return z;
}

如果元组类型不是 const ,则索引返回一个引用,允许设置元组的分量。

/** Tuple3 公有方法 */
T &operator[](int i) {
    if (i == 0) return x;
    if (i == 1) return y;
    return z;
}

我们现在可以转向对存储在元组中的值进行算术运算的实现。它们的代码相当难懂。例如,下面是将两个某种类型的三元组相加的方法(例如, Child 可能是 Vector3 ,即即将出现的三维向量类型)。

/** Tuple3 公有方法 */
template <typename U>
auto operator+(Child<U> c) const -> Child<decltype(T{} + U{})> {
    return {x + c.x, y + c.y, z + c.z};
}

在实现 operator+ 时,有几点需要注意。由于它是基于另一种类型 U 的模板方法,因此它支持将两个同为 Child 模板类型的元素相加,尽管它们可能使用不同的类型来存储其分量(这里代码中的 TU )。然而,由于该方法参数的基类型是 Child ,因此此方法只能相加两个相同子类型的值。如果该方法的参数改为 Tuple3 ,那么它将默认允许与任何继承自 Tuple3 的类型进行相加,这可能不是预期的。

在方法参数列表后的 -> 运算符右侧,返回类型的声明中有两个有趣的地方。首先,基本返回类型是 Child ;因此,如果将两个 Vector3 值相加,返回的值将是 Vector3 类型。这也消除了一类潜在的错误:如果返回了一个 Tuple3 ,那么就可以将两个 Vector3 相加并将结果赋值给一个 Point3 ,这显然是没有意义的。其次,返回类型的分量类型是基于类型为 TU 的值相加后表达式的类型来确定的。因此,这个方法遵循 C++的标准类型提升规则:如果一个存储整数值的 Vector3 与一个存储 Float 的 Vector3 相加,那么结果是一个存储 Float 的 Vector3

出于篇幅考虑,我们将不在此处包含其他 Tuple3 的算术运算符,也不会包含对它们执行逐元素操作的各种其他工具函数。 Tuple2Tuple3 提供的完整功能列表:

  • 每个分量的基本算术运算符,包括加法、减法和取反,以及它们的“复合赋值(in place)”形式(例如,operator+= )。
  • 使用标量值按分量进行的乘法和除法,包括“复合赋值”变体。
  • Abs(a) ,它返回一个值,其中包含元组类型的每个分量的绝对值。
  • Ceil(a)Floor(a) ,分别返回一个值,其中分量被向上或向下取整到最接近的整数值。
  • Lerp(t, a, b) ,它返回线性插值的结果 (1-t)a + tb
  • FMA(a, b, c) ,它接受三个元组并返回按分量融合乘加(fused multiply-add)的结果 a*b + c
  • Min(a, b)Max(a, b) ,分别返回给定元组的分量最小值和最大值。
  • MinComponentValue(a)MaxComponentValue(a) ,分别返回元组分量的最小值和最大值。
  • MinComponentIndex(a)MaxComponentIndex(a) ,分别返回具有最小值或最大值的元组元素的零基索引。
  • Permute(a, perm) ,它根据索引数组返回元组的排列(permutation)。
  • HProd(a) ,它返回横向积(horizontal product)——各个分量值相乘。