In order to write solid textures that describe complex surface appearances, it is helpful to be able to introduce some controlled variation to the process. Consider a wood floor made of individual planks; each plank’s color is likely to be slightly different from the others. Or consider a windswept lake; we might want to have waves of similar amplitude across the entire lake, but we don’t want them to be homogeneous over all parts of the lake (as they might be if they were constructed from a sum of sine waves, for example). Modeling this sort of variation in a texture helps make the final result look more realistic.
One difficulty in developing textures like these is that the renderer evaluates the surface’s texture functions at an irregularly distributed set of points, where each evaluation is completely independent of the others. As such, procedural textures must implicitly define a complex pattern by answering queries about what the pattern’s value is at all of these points. In contrast, the explicit pattern description approach is embodied by the PostScript® language, for example, which describes graphics on a page with a series of drawing commands. One difficulty that the implicit approach introduces is that the texture can’t just call RNG::UniformFloat() at each point at which it is evaluated to introduce randomness: because each point would have a completely different random value from its neighbors, no coherence would be possible in the generated pattern.
An elegant way to address this issue of introducing controlled randomness to procedural textures in graphics is the application of what is known as a noise function. In general, noise functions used in graphics are smoothly varying functions taking , for at least , without obvious repetition. One of the most crucial properties of a practical noise function is that it be band limited with a known maximum frequency. This makes it possible to control the frequency content added to a texture due to the noise function so that frequencies higher than those allowed by the Nyquist limit aren’t introduced.
Many of the noise functions that have been developed are built on the idea of an integer lattice over . First, a value is associated with each integer position in space. Then, given an arbitrary position in space, the eight surrounding lattice values are found. These lattice values are then interpolated to compute the noise value at the particular point. This idea can be generalized or restricted to more or fewer dimensions , where the number of lattice points is . A simple example of this approach is value noise, where pseudo-random numbers between and are associated with each lattice point, and actual noise values are computed with trilinear interpolation or with a more complex spline interpolant, which can give a smoother result by avoiding derivative discontinuities when moving from one lattice cell to another.
For such a noise function, given an integer lattice point, it must be possible to efficiently compute its parameter value in a way that always associates the same value with each lattice point. Because it is infeasible to store values for all possible points, some compact representation is needed. One option is to use a hash function, where the coordinates are hashed and then used to look up parameters from a fixed-size table of precomputed pseudo-random parameter values.
10.6.1 Perlin Noise
In pbrt we will implement a noise function introduced by Ken Perlin (1985a, 2002); as such, it is known as Perlin noise. It has a value of zero at all integer lattice points. Its variation comes from gradient vectors at each lattice point that guide the interpolation of a smooth function in between the points (Figure 10.24). This noise function has many of the desired characteristics of a noise function described above, is computationally efficient, and is easy to implement.
Figure 10.25 shows the value of the noise function rendered on a sphere.
For convenience, there is also a variant of Noise() that takes a Point3f directly:
The implementation first computes the integer coordinates of the cell that contains the given point and the fractional offsets of the point from the lower cell corner:
It next calls Grad() to get eight weight values, one for each corner of the cell that the point lies inside. Grad() uses the cell indices to index into a table; for efficiency we ensure that all of the indices are within the range of the table here by zeroing any high bits that would put a component past the table’s size. (The table size must be a power of two for this trick to work—otherwise an expensive integer modulus operation would be needed in place of the bitwise “and.”)
Each integer lattice point has a gradient vector associated with it. The influence of the gradient vector for any point inside the cell is obtained by computing the dot product of the vector from the gradient’s corner to the lookup point and the gradient vector (Figure 10.26); this is handled by the Grad() function. Note that the vectors to the corners other than the lower left one can be easily computed incrementally based on that vector.
The gradient vector for a particular integer lattice point is found by indexing into a precomputed table of integer values, NoisePerm. The four low-order bits of the table’s value for the lattice point determine which of 16 gradient vectors is associated with it. In a preprocessing step, this table of size NoisePermSize was filled with numbers from 0 to NoisePermSize-1 and then randomly permuted. These values were then duplicated, making an array of size 2*NoisePermSize that holds the table twice in succession. The second copy of the table makes lookups in the following code slightly more efficient.
Given a particular (ix,iy,iz) lattice point, a series of table lookups gives a value from the random-number table:
By doing three nested permutations in this way, rather than NoisePerm[ix+iy+iz], for example, the final result is more irregular. The first approach doesn’t usually return the same value if ix and iy are interchanged, as the second does. Furthermore, since the table was replicated to be twice the original length, the lookups can be done as described above, eliminating the need for modulus operations in code along the lines of
Given a final value from the permutation table that determines the gradient number, the dot product with the corresponding gradient vector must be computed. However, the gradient vectors do not need to be represented explicitly. All of the gradients use only , , or in their coordinates, so that the dot products reduce to addition of some (possibly negated) components of the vector. The final implementation is the following:
Given these eight contributions from the gradients, the next step is to trilinearly interpolate between them at the point. Rather than interpolating with dx, dy, and dz directly, though, each of these values is passed through a smoothing function. This ensures that the noise function has first- and second-derivative continuity as lookup points move between lattice cells.
10.6.2 Random Polka Dots
A basic use of the noise function is as part of a polka dot texture that divides texture space into rectangular cells (Figure 10.27). Each cell has a 50% chance of having a dot inside of it, and the dots are randomly placed inside their cells. DotsTexture takes the usual 2D mapping function, as well as two Textures, one for the regions of the surface outside of the dots and one for the regions inside. It is defined in the files textures/dots.h and textures/dots.cpp.
The evaluation function starts by taking the texture coordinates and computing integer sCell and tCell values, which give the coordinates of the cell they are inside. We will not consider antialiasing of the polka dots texture here; an exercise at the end of the chapter outlines how this might be done.
Once the cell coordinate is known, it’s necessary to decide if there is a polka dot in the cell. Obviously, this computation needs to be consistent so that for each time this routine runs for points in a particular cell it returns the same result. Yet we’d like the result not to be completely regular (e.g., with a dot in every other cell). Noise solves this problem: by evaluating the noise function at a position that is the same for all points inside this cell—(sCell+.5, tCell+.5)—we can compute an irregularly varying but consistent value for each cell. If this value is greater than zero, a dot is placed in the cell.
If there is a dot in the cell, the noise function is used again to randomly shift the center of the dot within the cell. The points at which the noise function is evaluated for the center shift are offset by arbitrary constant amounts, however, so that noise values from different noise cells are used from them, eliminating a possible source of correlation with the noise value used to determine the presence of a dot in the first place. (Note that the dot’s radius must be small enough so that it doesn’t spill over the cell’s boundary after being shifted; in that case, points where the texture was being evaluated would also need to consider the dots based in neighboring cells as potentially affecting their texture value.)
Given the dot center and radius, the texture needs to decide if the coordinates are within the radius of the shifted center. It does this by computing their squared distance to the center and comparing it to the squared radius.
10.6.3 Noise Idioms and Spectral Synthesis
The fact that noise is a band-limited function means that its frequency content can be adjusted by scaling the domain over which it is evaluated. For example, if Noise(p) has some known frequency content, then the frequency content of Noise(2*p) will be twice as high. This is just like the relationship between the frequency content of and . This technique can be used to create a noise function with a desired rate of variation.
For many applications in procedural texturing, it’s useful to have variation over multiple scales—for example, to add finer variations to the base noise function. One effective way to do this with noise is to compute patterns via spectral synthesis, where a complex function is defined by a sum of contributions from another function :
for a set of weight values and parameter scale values . If the base function has a well-defined frequency content (e.g., is a sine or cosine function or a noise function), then each term also has a well-defined frequency content as described earlier. Because each term of the sum is weighted by a weight value , the result is a sum of contributions of various frequencies, with different frequency ranges weighted differently.
Typically, the scales are chosen in a geometric progression such that and the weights are . The result is that as higher frequency variation is added to the function, it has relatively less influence on the overall shape of . Each additional term is called an octave of noise, since it has twice the frequency content of the previous one. When this scheme is used with Perlin noise, the result is often referred to as fractional Brownian motion (fBm), after a particular type of random process that varies in a similar manner.
Fractional Brownian motion is a useful building block for procedural textures because it gives a function with more complex variation than plain noise, while still being easy to compute and still having well-defined frequency content. The utility function FBm() implements the fractional Brownian motion function. Figure 10.28 shows two graphs of it.
In addition to the point at which to evaluate the function and the function’s partial derivatives at that point, the function takes an omega parameter, which ranges from zero to one and affects the smoothness of the pattern by controlling the falloff of contributions at higher frequencies (values around work well), and maxOctaves, which gives the maximum number of octaves of noise that should be used in computing the sum.
The implementation here uses a technique called clamping to antialias the fBm function. The idea is that when we are computing a value based on a sum of components, each with known frequency content, we should stop adding in components that would have frequencies beyond the Nyquist limit and instead add their average values to the sum. Because the average value of Noise() is zero, all that needs to be done is to compute the number of octaves such that none of the terms has excessively high frequencies and not evaluate the noise function for any higher octaves.
Noise() (and thus the first term of as well) has a maximum frequency content of roughly . Each subsequent term represents a doubling of frequency content. Therefore, we would like to find the appropriate number of terms such that if the sampling rate in noise space is , then
which ensures that there are frequencies right up to the Nyquist frequency but not past it. Equivalently,
where we’ve used the identity to write the last expression in a more convenient form for the following.
The squared sampling rate can be computed with one over the maximum of the squared length of the differentials and , which we’ll denote by . We can turn this inversion into a negation of the logarithm, and equivalently write this as:
Finally, the integral number of octaves up to the Nyquist limit are added together and the last octave is faded in, according to the fractional part of n. This ensures that successive octaves of noise fade in gradually, rather than appearing abruptly, which can cause visually noticeable artifacts at the transitions. The implementation here actually increases the frequency between octaves by 1.99, rather than by a factor of 2, in order to reduce the impact of the fact that the noise function is zero at integer lattice points. This breaks up that regularity across sums of octaves of noise, which can also lead to subtle visual artifacts.
The SmoothStep() function takes a minimum and maximum value and a point at which to evaluate a smooth interpolating function. If the point is below the minimum zero is returned, and if it’s above the maximum one is returned. Otherwise, it smoothly interpolates between zero and one using a cubic Hermite spline.
Closely related to the FBm() function is the Turbulence() function. It also computes a sum of terms of the noise function but takes the absolute value of each one:
Taking the absolute value introduces first-derivative discontinuities in the synthesized function, and thus the turbulence function has infinite frequency content. Nevertheless, the visual characteristics of this function can be quite useful for procedural textures. Figure 10.29 shows two graphs of the turbulence function.
The Turbulence() implementation here tries to antialias itself in the same way that FBm() did. As described earlier, however, the first-derivative discontinuities in the function introduce infinitely high-frequency content, so these efforts can’t hope to be perfectly successful. The Turbulence() antialiasing here at least eliminates some of the worst of the artifacts; otherwise, increasing the pixel sampling rate is the best recourse. In practice, this function doesn’t alias too terribly when used in procedural textures, particularly compared to the aliasing from infinitely high frequencies from geometric and shadow edges.
The average value of the absolute value of the noise function is roughly 0.2; this value should be added to the sum for the octaves where the noise function’s estimated frequency would be higher than the sampling rate.
10.6.4 Bumpy and Wrinkled Textures
The fBm and turbulence functions are particularly useful as a source of random variation for bump mapping. The FBmTexture is a Float-valued texture that uses FBm() to compute offsets, and WrinkledTexture uses Turbulence() to do so. They are demonstrated in Figures 10.30 and 10.31 and are implemented in textures/fbm.h, textures/fbm.cpp, textures/wrinkled.h, and textures/wrinkled.cpp.
10.6.5 Windy Waves
Application of fBm can give a reasonably convincing representation of waves (Ebert et al. 2003). Figures 1.11 and 4.1 use this texture for the water in those scenes. This Texture is based on two observations. First, across the surface of a wind-swept lake (for example), some areas are relatively smooth and some are more choppy; this effect comes from the natural variation of the wind’s strength from area to area. Second, the overall form of individual waves on the surface can be described well by the fBm-based wave pattern scaled by the wind strength. This texture is implemented in textures/windy.h and textures/windy.cpp.
The evaluation function uses two calls to the FBm() function. The first scales down the point P by a factor of 10; as a result, the first call to FBm() returns relatively low-frequency variation over the surface of the object being shaded. This value is used to determine the local strength of the wind. The second call determines the amplitude of the wave at the particular point, independent of the amount of wind there. The product of these two values gives the actual wave offset for the particular location.
Another classic use of the noise function is to perturb texture coordinates before using another texture or lookup table. For example, a facsimile of marble can be made by modeling the marble material as a series of layered strata and then using noise to perturb the coordinate used for finding a value among the strata. The MarbleTexture in this section implements this approach. Figure 10.32 illustrates the idea behind this texture. On the left, the layers of marble are indexed directly using the coordinate of the point on the sphere. On the right, fBm has been used to perturb the value, introducing variation. This texture is implemented in textures/marble.h and textures/marble.cpp.
The texture takes the usual set of parameters to control the FBm() function that will be used to perturb the lookup coordinate. The variation parameter modulates the magnitude of the perturbation.
An offset into the marble layers is computed by adding the variation to the point’s component and using the sine function to remap its value into the range . The <<Evaluate marble spline at t>> fragment uses the value as the evaluation point for a cubic spline through a series of colors that are similar to those of real marble.