2.2 Vectors
pbrt provides both 2D and 3D vector classes. Both are parameterized by the type of the underlying vector element, thus making it easy to instantiate vectors of both integer and floating-point types.
In the following, we will generally only include implementations of Vector3 methods; all have Vector2 parallels that have straightforward implementation differences.
A vector is represented with a tuple of components that gives its representation in terms of the , , (in 3D) axes of the space it is defined in. The individual components of a 3D vector will be written , , and .
An alternate implementation would be to have a single template class that is also parameterized with an integer number of dimensions and to represent the coordinates with an array of that many T values. While this approach would reduce the total amount of code, individual components of the vector couldn’t be accessed as v.x and so forth. We believe that in this case, a bit more code in the vector implementations is worthwhile in return for more transparent access to elements.
However, some routines do find it useful to be able to easily loop over the components of vectors; the vector classes also provide a C++ operator to index into the components so that, given a vector v, v[0] == v.x and so forth.
For convenience, a number of widely used types of vectors are given a typedef, so that they have more concise names in code elsewhere.
Readers who have been exposed to object-oriented design may question our decision to make the vector element data publicly accessible. Typically, data members are only accessible inside their class, and external code that wishes to access or modify the contents of a class must do so through a well-defined API of selector and mutator functions. Although we generally agree with this design principle (though see the discussion of data-oriented design in the “Further Reading” section of Chapter 1), it is not appropriate here. The purpose of selector and mutator functions is to hide the class’s internal implementation details. In the case of vectors, hiding this basic part of their design gains nothing and adds bulk to code that uses them.
By default, the values are set to zero, although the user of the class can optionally supply values for each of the components. If the user does supply values, we check that none of them has the floating-point “not a number” (NaN) value using the Assert() macro. When compiled in optimized mode, this macro disappears from the compiled code, saving the expense of verifying this case. NaNs almost certainly indicate a bug in the system; if a NaN is generated by some computation, we’d like to catch it as soon as possible in order to make isolating its source easier. (See Section 3.9.1 for more discussion of NaN values.)
The code to check for NaNs just calls the std::isnan() function on each of the , , and components.
Addition and subtraction of vectors are done component-wise. The usual geometric interpretation of vector addition and subtraction is shown in Figures 2.3 and 2.4.
The code for subtracting two vectors is similar and therefore not shown here.
A vector can be multiplied component-wise by a scalar, thereby changing its length. Three functions are needed in order to cover all of the different ways that this operation may be written in source code (i.e., v*s, s*v, and v *= s):
Similarly, a vector can be divided component-wise by a scalar. The code for scalar division is similar to scalar multiplication, although division of a scalar by a vector is not well defined and so is not permitted.
In the implementation of these methods, we use a single division to compute the scalar’s reciprocal and then perform three component-wise multiplications. This is a useful trick for avoiding division operations, which are generally much slower than multiplies on modern CPUs.
We use the Assert() macro to make sure that the provided divisor is not zero; this should never happen and would indicate a bug elsewhere in the system.
The Vector3 class also provides a unary negation operator that returns a new vector pointing in the opposite direction of the original one:
Finally, Abs() returns a vector with the absolute value operation applied to its components.
2.2.1 Dot and Cross Product
Two useful operations on vectors are the dot product (also known as the scalar or inner product) and the cross product. For two vectors and , their dot product is defined as:
The dot product has a simple relationship to the angle between the two vectors:
where is the angle between and , and denotes the length of the vector . It follows from this that is zero if and only if and are perpendicular, provided that neither nor is degenerate—equal to . A set of two or more mutually perpendicular vectors is said to be orthogonal. An orthogonal set of unit vectors is called orthonormal.
It immediately follows from Equation (2.1) that if and are unit vectors, their dot product is the cosine of the angle between them. As the cosine of the angle between two vectors often needs to be computed for rendering, we will frequently make use of this property. A few basic properties directly follow from the definition. For example, if , , and are vectors and is a scalar value, then:
We will frequently need to compute the absolute value of the dot product as well. The AbsDot() function does this for us so that a separate call to std::abs() isn’t necessary.
The cross product is another useful operation for 3D vectors. Given two vectors in 3D, the cross product is a vector that is perpendicular to both of them. Given orthogonal vectors and , then is defined to be a vector such that form an orthogonal coordinate system.
The cross product is defined as:
A way to remember this is to compute the determinant of the matrix:
where , , and represent the axes , , and , respectively. Note that this equation is merely a memory aid and not a rigorous mathematical construction, since the matrix entries are a mix of scalars and vectors.
In the implementation here, the vector elements are converted to double-precision (regardless of the type of Float) before the subtractions in the Cross() function. Using extra precision for 32-bit floating-point values here protects against error from catastrophic cancellation, a type of floating-point error that can happen when subtracting two values that are very close together. This isn’t a theoretical concern: this change was necessary to fix bugs that came up from this issue previously. See Section 3.9 for more information on floating-point rounding error.
From the definition of the cross product, we can derive
where is the angle between and . An important implication of this is that the cross product of two perpendicular unit vectors is itself a unit vector. Note also that the result of the cross product is a degenerate vector if and are parallel.
This definition also shows a convenient way to compute the area of a parallelogram (Figure 2.5). If the two edges of the parallelogram are given by vectors and , and it has height , the area is given by . Since , we can use Equation (2.2) to see that the area is .
2.2.2 Normalization
It is often necessary to normalize a vector—that is, to compute a new vector pointing in the same direction but with unit length. A normalized vector is often called a unit vector. The notation used in this book for normalized vectors is that is the normalized version of . To normalize a vector, it’s first useful to be able to compute its length.
Normalize() normalizes a vector. It divides each component by the length of the vector, . It returns a new vector; it does not normalize the vector in place:
2.2.3 Miscellaneous Operations
A few additional operations are useful when working with vectors. The MinComponent() and MaxComponent() methods return the smallest and largest coordinate value, respectively.
Related, MaxDimension() returns the index of the component with the largest value.
Component-wise minimum and maximum operations are also available.
Finally, Permute() permutes the coordinate values according to the index values provided.
2.2.4 Coordinate System from a Vector
We will frequently want to construct a local coordinate system given only a single 3D vector. Because the cross product of two vectors is orthogonal to both, we can apply the cross product two times to get a set of three orthogonal vectors for the coordinate system. Note that the two vectors generated by this technique are unique only up to a rotation about the given vector.
The implementation of this function assumes that the vector passed in, v1, has already been normalized. It first constructs a perpendicular vector by zeroing one of the components of the original vector, swapping the remaining two, and negating one of them. Inspection of the two cases should make clear that v2 will be normalized and that the dot product must be equal to zero. Given these two perpendicular vectors, a single cross product gives the third, which by definition will be perpendicular to the first two.