Chapter 11. Procedural Texture Shaders

The fact that we have a full-featured, high-level programming language to express the processing at each fragment means that we can algorithmically compute a pattern on an object’s surface. We can use this new freedom to create a wide variety of rendering effects that wouldn’t be possible otherwise.

In the previous chapter, we discussed shaders that achieve their primary effect by reading values from texture memory. This chapter focuses on shaders that do interesting things primarily by means of an algorithm defined by the shader. The results from such a shader are synthesized according to the algorithm rather than being based primarily on precomputed values such as a digitized painting or photograph. This type of shader is sometimes called a PROCEDURAL TEXTURE SHADER, and the process of applying such a shader is called PROCEDURAL TEXTURING. Often the texture coordinate or the object coordinate position at each point on the object is the only piece of information needed to shade the object with a shader that is entirely procedural.

In principle, procedural texture shaders can accomplish many of the same tasks as shaders that access stored textures. In practice, there are times when it is more convenient or feasible to use a procedural texture shader and times when it is more convenient or feasible to use a stored texture shader. When deciding whether to write a procedural texture shader or one that uses stored textures, keep in mind some of the main advantages of procedural texture shaders.

  • Textures generated procedurally have very low memory requirements compared with stored textures. The only representation of the texture is in the algorithm defined by the code in the procedural texture shader. This representation is extremely compact compared with the size of stored 2D textures. Typically, it is a couple of orders of magnitude smaller (e.g., a few kilobytes for the code in a procedural shader versus a few hundred kilobytes or more for a high-quality 2D texture). This means procedural texture shaders require far less memory on the graphics accelerator. Procedural texture shaders have an even greater advantage when the desire is to have a 3D (solid) texture applied to an object (a few kilobytes versus tens of megabytes or more for a stored 3D texture).

  • Textures generated by procedural texture shaders have no fixed area or resolution. They can be applied to objects of any scale with precise results because they are defined algorithmically rather than with sampled data, as in the case of stored textures. There are no decisions to be made about how to map a 2D image onto a 3D surface patch that is larger or smaller than the texture, and there are no seams or unwanted replication. As your viewpoint gets closer and closer to a surface rendered with a procedural texture shader, you won’t see reduced detail or sampling artifacts like you might with a shader that uses a stored texture.

  • Procedural texture shaders can be written to parameterize key aspects of the algorithm. These parameters can easily be changed, allowing a single shader to produce an interesting variety of effects. Very little can be done to alter the effect from a stored texture after it has been created.

Some of the disadvantages of using procedural shaders rather than stored textures are as follows.

  • Procedural texture shaders require the algorithm to be encoded in a program. Not everyone has the technical skills needed to write such a program, whereas it is fairly straightforward to create a 2D or 3D texture with limited technical skills.

  • Performing the algorithm embodied by a procedural texture shader at each location on an object can be a lot slower than accessing a stored texture.

  • Procedural texture shaders can have serious aliasing artifacts that can be difficult to overcome. Today’s graphics hardware has built-in capabilities for antialiasing stored textures (e.g., filtering methods and mipmaps).

  • Because of differences in arithmetic precision and differences in implementations of built-in functions such as noise, procedural texture shaders could produce somewhat different results on different platforms.

The ultimate choice of whether to use a procedural shader or a stored texture shader should be made pragmatically. Things that would be artwork in the real world (paintings, billboards, anything with writing, etc.) are good candidates for rendering with stored textures. Objects that are extremely important to the final “look” of the image (character faces, costumes, important props) can also be rendered with stored textures because this presents the easiest route for an artist to be involved. Things that are relatively unimportant to the final image and yet cover a lot of area are good candidates for rendering with a procedural shader (walls, floors, ground).

Often, a hybrid approach is the right answer. A golf ball might be rendered with a base color, a hand-painted texture map that contains scuff marks, a texture map containing a logo, and a procedurally generated dimple pattern. Stored textures can also control or constrain procedural effects. If our golf ball needs grass stains on certain parts of its surface and it is important to achieve and reproduce just the right look, an artist could paint a gray scale map that would direct the shader to locations where grass smudges should be applied on the surface (for instance, black portions of the grayscale map) and where they should not be applied (white portions of the grayscale map). The shader can read this CONTROL TEXTURE and use it to blend between a grass-smudged representation of the surface and a pristine surface.

All that said, let’s turn our attention to a few examples of shaders that are entirely procedural.

Regular Patterns

In Chapter 6, we examined a procedural shader for rendering bricks. Our first example in this chapter is another simple one. We try to construct a shader that renders stripes on an object. A variety of man-made objects can be rendered with such a shader: children’s toys, wallpaper, wrapping paper, flags, fabrics, and so on.

The object in Figure 11.1 is a partial torus rendered with a stripe shader. The stripe shader and the application in which it is shown were both developed in 2002 by LightWork Design, a company that develops software to provide photorealistic views of objects created with commercial CAD/CAM packages. The application developed by LightWork Design contains a graphical user interface that allows the user to interactively modify the shader’s parameters. The various shaders that are available are accessible on the upper-right portion of the user interface, and the modifiable parameters for the current shader are accessible in the lower-right portion of the user interface. In this case, you can see that the parameters for the stripe shader include the stripe color (blue), the background color (orange), the stripe scale (how many stripes there will be), and the stripe width (the ratio of stripe to background; in this case, it is 0.5 to make blue and orange stripes of equal width).

Closeup of a partial torus rendered with the stripe shader described in Section 11.1. (Courtesy of LightWork Design)

Figure 11.1. Closeup of a partial torus rendered with the stripe shader described in Section 11.1. (Courtesy of LightWork Design)

For our stripe shader to work properly, the application needs to send down only the geometry (vertex values) and the texture coordinate at each vertex. The key to drawing the stripe color or the background color is the t texture coordinate at each fragment (the s texture coordinate is not used at all). The application must also supply values that the vertex shader uses to perform a lighting computation. And the aforementioned stripe color, background color, scale, and stripe width must be passed to the fragment shader so that our procedural stripe computation can be performed at each fragment.

Stripes Vertex Shader

The vertex shader for our stripe effect is shown in Listing 11.1.

Example 11.1. Vertex shader for drawing stripes

uniform vec3  LightPosition;
uniform vec3  LightColor;
uniform vec3  EyePosition;
uniform vec3  Specular;
uniform vec3  Ambient;
uniform float Kd;

varying vec3  DiffuseColor;
varying vec3  SpecularColor;

void main()
{
    vec3 ecPosition = vec3(gl_ModelViewMatrix * gl_Vertex);
    vec3 tnorm      = normalize(gl_NormalMatrix * gl_Normal);
    vec3 lightVec   = normalize(LightPosition - ecPosition);
    vec3 viewVec    = normalize(EyePosition - ecPosition);
    vec3 hvec       = normalize(viewVec + lightVec);

    float spec = clamp(dot(hvec, tnorm), 0.0, 1.0);
    spec = pow(spec, 16.0);

    DiffuseColor    = LightColor * vec3(Kd * dot(lightVec, tnorm));
    DiffuseColor    = clamp(Ambient + DiffuseColor, 0.0, 1.0);
    SpecularColor   = clamp((LightColor * Specular * spec), 0.0, 1.0);

    gl_TexCoord[0]  = gl_MultiTexCoord0;
    gl_Position     = ftransform();
}

There are some nice features to this particular shader. Nothing in it really makes it specific to drawing stripes. It provides a good example of how we might do the lighting calculation in a general way that would be compatible with a variety of fragment shaders.

As we mentioned, the values for doing the lighting computation (LightPosition, LightColor, EyePosition, Specular, Ambient, and Kd) are all passed in by the application as uniform variables. The purpose of this shader is to compute DiffuseColor and SpecularColor, two varying variables that will be interpolated across each primitive and made available to the fragment shader at each fragment location. These values are computed in the typical way. A small optimization is that Ambient is added to the value computed for the diffuse reflection so that we send one less value to the fragment shader as a varying variable. The incoming texture coordinate is passed down to the fragment shader as the built-in varying variable gl_TexCoord[0], and the vertex position is transformed in the usual way.

Stripes Fragment Shader

The fragment shader contains the algorithm for drawing procedural stripes. It is shown in Listing 11.2.

Example 11.2. Fragment shader for drawing stripes

uniform vec3  StripeColor;
uniform vec3  BackColor;
uniform float Width;
uniform float Fuzz;
uniform float Scale;

varying vec3  DiffuseColor;
varying vec3  SpecularColor;

void main()
{
    float scaledT = fract(gl_TexCoord[0].t * Scale);

    float frac1 = clamp(scaledT / Fuzz, 0.0, 1.0);
    float frac2 = clamp((scaledT - Width) / Fuzz, 0.0, 1.0);

    frac1 = frac1 * (1.0 - frac2);
    frac1 = frac1 * frac1 * (3.0 - (2.0 * frac1));

    vec3 finalColor = mix(BackColor, StripeColor, frac1);
    finalColor = finalColor * DiffuseColor + SpecularColor;

    gl_FragColor = vec4(finalColor, 1.0);
}

The application provides one other uniform variable, called Fuzz. This value controls the smooth transitions (i.e., antialiasing) between stripe color and background color. With a Scale value of 10.0, a reasonable value for Fuzz is 0.1. It can be adjusted as the object changes size to prevent excessive blurriness at high magnification levels. It shouldn’t really be set to a value higher than 0.5 (maximum blurriness of stripe edges).

The first step in this shader is to multiply the incoming t texture coordinate by the stripe scale factor and take the fractional part. This computation gives the position of the fragment within the stripe pattern. The larger the value of Scale, the more stripes we have as a result of this calculation. The resulting value for the local variable scaledT ranges from [0,1).

We’d like to have nicely antialiased transitions between the stripe colors. One way to do this would be to use smoothstep in the transition from StripeColor to BackColor, and use it again in the transition from BackColor to StripeColor. But this shader uses the fact that these transitions are symmetric to combine the two transitions into one.

So, to get our desired transition, we use scaledT to compute two other values, frac1 and frac2. These two values tell us where we are in relation to the two transitions between BackColor and StripeColor. For frac1, if scaledT/Fuzz is greater than 1, that indicates that this point is not in the transition zone, so we clamp the value to 1. If scaledT is less than Fuzz, scaledT/Fuzz specifies the fragment’s relative distance into the transition zone for one side of the stripe. We compute a similar value for the other edge of the stripe by subtracting Width from scaledT, dividing by Fuzz, clamping the result, and storing it in frac2.

These values represent the amount of fuzz (blurriness) to be applied. At one edge of the stripe, frac2 is 0 and frac1 is the relative distance into the transition zone. At the other edge of the stripe, frac1 is 1 and frac2 is the relative distance into the transition zone. Our next line of code (frac1 = frac1 * (1.0 - frac2)) produces a value that can be used to do a proper linear blend between BackColor and StripeColor. But we’d actually like to perform a transition that is smoother than a linear blend. The next line of code performs a Hermite interpolation in the same way as the smoothstep function. The final value for frac1 performs the blend between BackColor and StripeColor.

The result of this effort is a smoothly “fuzzed” boundary in the transition region between the stripe colors. Without this fuzzing effect, we would have abrupt transitions between the stripe colors that would flash and pop as the object is moved on the screen. The fuzzing of the transition region eliminates those artifacts. A closeup view of the fuzzed boundary is shown in Figure 11.2. (More information about antialiasing procedural shaders can be found in Chapter 17.)

Extreme closeup view of one of the stripes that shows the effect of the “fuzz” calculation from the stripe shader. (Courtesy of LightWork Design)

Figure 11.2. Extreme closeup view of one of the stripes that shows the effect of the “fuzz” calculation from the stripe shader. (Courtesy of LightWork Design)

Now all that remains to be done is to apply the diffuse and specular lighting effects computed by the vertex shader and supply an alpha value of 1.0 to produce our final fragment color. By modifying the five basic parameters of our fragment shader, we can create a fairly interesting number of variations of our stripe pattern, using the exact same shader.

Toy Ball

Programmability is the key to procedurally defining all sorts of texture patterns. This next shader takes things a bit further by shading a sphere with a procedurally defined star pattern and a procedurally defined stripe. The author of this shader, Bill Licea-Kane, was inspired to create a ball like the one featured in one of Pixar’s early short animations, Luxo Jr. This shader is quite specialized. As Bill will tell you, “It shades any surface as long as it’s a sphere.” The reason is that the fragment shader exploits the following property of the sphere: The surface normal for any point on the surface points in the same direction as the vector from the center of the sphere to that point on the surface. This property is used to analytically compute the surface normal used in the shading calculations within the fragment shader.

The key to this shader is that the star pattern is defined by the coefficients for five half-spaces that define the star shape. These coefficients were chosen to make the star pattern an appropriate size for the ball. Points on the sphere are classified as “in” or “out,” relative to each half space. Locations in the very center of the star pattern are “in” with respect to all five half-spaces. Locations in the points of the star are “in” with respect to four of the five half-spaces. All other locations are “in” with respect to three or fewer half-spaces.

Fragments that are in the stripe pattern are simpler to compute. After we have classified each location on the surface as “star,” “stripe,” or “other,” we can color each fragment appropriately. The color computations are applied in an order that ensures a reasonable result even if the ball is viewed from far away. A surface normal is calculated analytically (i.e., exactly) within the fragment shader. A lighting computation that includes a specular highlight calculation is also applied at every fragment.

Application Setup

The application only needs to provide vertex positions for this shader to work properly. Both colors and normals are computed algorithmically in the fragment shader. The only catch is that for this shader to work properly, the vertices must define a sphere. The sphere can be of arbitrary size because the fragment shader performs all the necessary computations, based on the known geometry of a sphere.

A number of parameters to this shader are specified with uniform variables. The values that produce the images shown in the remainder of this section are summarized in Listing 11.3.

Example 11.3. Values for uniform variables used by the toy ball shader

LightDir          0.57735, 0.57735, 0.57735, 0.0
HVector           0.32506, 0.32506, 0.88808, 0.0
BallCenter        0.0, 0.0, 0.0, 1.0
SpecularColor     0.4, 0.4, 0.4, 60.0

Red               0.6, 0.0, 0.0, 1.0
Blue              0.0, 0.3, 0.6, 1.0
Yellow            0.6, 0.5, 0.0, 1.0

HalfSpace0        1.0, 0.0, 0.0, 0.2
HalfSpace1         0.309016994,  0.951056516, 0.0, 0.2
HalfSpace2        -0.809016994,  0.587785252, 0.0, 0.2
HalfSpace3        -0.809016994,  -0.587785252, 0.0, 0.2
HalfSpace4         0.309016994,  -0.951056516, 0.0, 0.2

InOrOutInit       -3.0
StripeWidth       0.3
FWidth            0.005

Vertex Shader

The fragment shader is the workhorse for this shader duo, so the vertex shader needs only to compute the ball’s center position in eye coordinates, the eye-coordinate position of the vertex, and the clip space position at each vertex. The application could provide the ball’s center position in eye coordinates, but our vertex shader doesn’t have much to do, and doing it this way means the application doesn’t have to keep track of the modelview matrix. This value could easily be computed in the fragment shader, but the fragment shader will likely have a little better performance if we leave the computation in the vertex shader and pass the result as a varying variable (see Listing 11.4).

Example 11.4. Vertex shader for drawing a toy ball

varying vec4 ECposition;    // surface position in eye coordinates
varying vec4 ECballCenter;  // ball center in eye coordinates
uniform vec4 BallCenter;    // ball center in modeling coordinates

void main()
{
    ECposition   = gl_ModelViewMatrix * gl_Vertex;
    ECballCenter = gl_ModelViewMatrix * BallCenter;
    gl_Position  = ftransform();
}

Fragment Shader

The toy ball fragment shader is a little bit longer than some of the previous examples, so we build it up a few lines of code at a time and illustrate some intermediate results. Here are the definitions for the local variables that are used in the toy ball fragment shader:

vec4 normal;         // Analytically computed normal
vec4 p;              // Point in shader space
vec4 surfColor;      // Computed color of the surface
float intensity;     // Computed light intensity
vec4 distance;       // Computed distance values
float inorout;       // Counter for computing star pattern

The first thing we do is turn the surface location that we’re shading into a point on a sphere with a radius of 1.0. We can do this with the normalize function:

p.xyz = normalize(ECposition.xyz - ECballCenter.xyz);
p.w   = 1.0;

We don’t want to include the w coordinate in the computation, so we use the component selector .xyz to select the first three components of ECposition and ECballCenter. This normalized vector is stored in the first three components of p. With this computation, p represents a point on the sphere with radius 1, so all three components of p are in the range [–1,1]. The w coordinate isn’t really pertinent to our computations at this point, but to make subsequent calculations work properly, we initialize it to a value of 1.0.

Next, we perform our half-space computations. We initialize a counter called inorout to a value of –3. We increment the counter each time the surface location is “in” with respect to a half-space. Because five half-spaces are defined, the final counter value will be in the range [–3,2]. Values of 1 or 2 signify that the fragment is within the star pattern. Values of 0 or less signify that the fragment is outside the star pattern.

inorout = InOrOutInit;    // initialize inorout to -3

We could have defined the half-spaces as an array of five vec4 values, done our “in” or “out” computations and stored the results in an array of five float values. But we can take a little better advantage of the parallel nature of the underlying graphics hardware if we do things a bit differently. You’ll see how in a minute. First, we compute the distance between p and the first four half-spaces by using the built-in dot product function:

distance[0] = dot(p, HalfSpace0);
distance[1] = dot(p, HalfSpace1);
distance[2] = dot(p, HalfSpace2);
distance[3] = dot(p, HalfSpace3);

The results of these half-space distance calculations are visualized in (A)–(D) of Figure 11.3. Surface locations that are “in” with respect to the half-space are shaded in gray, and points that are “out” are shaded in black.

Visualizing the results of the half-space distance calculations (Courtesy of ATI Research, Inc.)

Figure 11.3. Visualizing the results of the half-space distance calculations (Courtesy of ATI Research, Inc.)

You may have been wondering why our counter was defined as a float instead of an int. We’re going to use the counter value as the basis for a smoothly antialiased transition between the color of the star pattern and the color of the rest of the ball’s surface. To this end, we use the smoothstep function to set the distance to 0 if the computed distance is less than –FWidth, to 1 if the computed distance is greater than FWidth, and to a smoothly interpolated value between 0 and 1 if the computed distance is in between those two values. By defining distance as a vec4, we can perform the smooth step computation on four values in parallel. smoothstep implies a divide operation, and because FWidth is a float, only one divide operation is necessary. This makes it all very efficient.

distance = smoothstep(-FWidth, FWidth, distance);

Now we can quickly add the values in distance by performing a dot product between distance and a vec4 containing all 1s:

inorout += dot(distance, vec4(1.0));

Because we initialized inorout to –3, we add the result of the dot product to the previous value of inorout. This variable now contains a value in the range [–3,1], and we have one more half-space distance to compute. We compute the distance to the fifth half-space, and we do the computation to determine whether we’re “in” or “out” of the stripe around the ball. We call the smoothstep function to do the same operation on these two values as was performed on the previous four half-space distances. We update the inorout counter by adding the result from the distance computation with the final half-space. The distance computation with respect to the fifth half-space is illustrated in (E) of Figure 11.3.

distance.x = dot(p, HalfSpace4);
distance.y = StripeWidth - abs(p.z);
distance = smoothstep(-FWidth, FWidth, distance);
inorout += distance.x;

(In this case, we’re performing a smooth step operation on a vec4, and we only really care about two of the components. The performance will probably be fine on a graphics device designed to process vec4 values in parallel, but it might be somewhat inefficient on a graphics device with a scalar architecture. In the latter case, however, the OpenGL Shading Language compiler may very well be smart enough to realize that the results of the third and fourth components were never consumed later in the program, so it might optimize away the instructions for computing those two values.)

The value for inorout is now in the range [–3,2]. This intermediate result is illustrated in Figure 11.4 (A). By clamping the value of inorout to the range [0,1], we obtain the result shown in Figure 11.4 (B).

Intermediate results from “in” or “out” computation. Surface points that are “in” with respect to all five half-planes are shown in white, and points that are “in” with respect to four half-planes are shown in gray (A). The value of inorout is clamped to the range [0,1] to produce the result shown in (B). (Courtesy of ATI Research, Inc.)

Figure 11.4. Intermediate results from “in” or “out” computation. Surface points that are “in” with respect to all five half-planes are shown in white, and points that are “in” with respect to four half-planes are shown in gray (A). The value of inorout is clamped to the range [0,1] to produce the result shown in (B). (Courtesy of ATI Research, Inc.)

inorout = clamp(inorout, 0.0, 1.0);

At this point, we can compute the surface color for the fragment. We use the computed value of inorout to perform a linear blend between yellow and red to define the star pattern. If we were to stop here, the result would look like Color Plate 13A. If we take the results of this calculation and do a linear blend with the color of the stripe, we get the result shown in Color Plate 13B. Because we used smoothstep, the values of inorout and distance.y provide a nicely antialiased edge at the border between colors.

surfColor = mix(Yellow, Red, inorout);
surfColor = mix(surfColor, Blue, distance.y);

The result at this stage is flat and unrealistic. Performing a lighting calculation will fix this. The first step is to analytically compute the normal for this fragment, which we can do because we know the eye-coordinate position of the center of the ball (it’s provided in the varying variable ECballCenter) and we know the eye-coordinate position of the fragment (it’s passed in the varying variable ECposition). (This approach could have been used with the earth shader discussed in Section 10.2 to avoid passing the surface normal as a varying variable and using the interpolated results.) As a matter of fact, we’ve already computed this value and stored it in p:

// normal = point on surface for sphere at (0,0,0)
normal = p;

The diffuse part of the lighting equation is computed with these three lines of code:

intensity  = 0.2; // ambient
intensity += 0.8 * clamp(dot(LightDir, normal), 0.0, 1.0);
surfColor *= intensity;

The result of diffuse-only lighting is shown in Color Plate 13C. The final step is to add a specular contribution with these three lines of code:

intensity  = clamp(dot(HVector, normal), 0.0, 1.0);
intensity  = pow(intensity, SpecularColor.a);
surfColor += SpecularColor * intensity;

Notice in Color Plate 13D that the specular highlight is perfect! Because the surface normal at each fragment is computed exactly, there is no misshapen specular highlight caused by tesselation facets like we’re used to seeing. The resulting value is written to gl_FragColor and sent on for final processing before ultimately being written into the frame buffer.

gl_FragColor = surfColor;

Voila! Your very own toy ball, created completely out of thin air! The complete listing of the toy ball fragment shader is shown in Listing 11.5.

Example 11.5. Fragment shader for drawing a toy ball

varying vec4  ECposition;   // surface position in eye coordinates
varying vec4  ECballCenter; // ball center in eye coordinates

uniform vec4  LightDir;      // light direction, should be normalized
uniform vec4  HVector;       // reflection vector for infinite light

uniform vec4  SpecularColor;
uniform vec4  Red, Yellow, Blue;

uniform vec4  HalfSpace0;   // half-spaces used to define star pattern
uniform vec4  HalfSpace1;
uniform vec4  HalfSpace2;
uniform vec4  HalfSpace3;
uniform vec4  HalfSpace4;

uniform float InOrOutInit;  // = -3
uniform float StripeWidth;  // = 0.3
uniform float FWidth;       // = 0.005

void main()
{
    vec4 normal;               // Analytically computed normal
    vec4 p;                    // Point in shader space
    vec4 surfColor;            // Computed color of the surface
    float intensity;           // Computed light intensity
    vec4  distance;            // Computed distance values
    float inorout;             // Counter for computing star pattern

    p.xyz = normalize(ECposition.xyz - ECballCenter.xyz);
    p.w   = 1.0;

    inorout = InOrOutInit;     // initialize inorout to -3.0

    distance[0] = dot(p, HalfSpace0);
    distance[1] = dot(p, HalfSpace1);
    distance[2] = dot(p, HalfSpace2);
    distance[3] = dot(p, HalfSpace3);

    distance = smoothstep(-FWidth, FWidth, distance);

    inorout += dot(distance, vec4(1.0));

    distance.x = dot(p, HalfSpace4);
    distance.y = StripeWidth - abs(p.z);
    distance = smoothstep(-FWidth, FWidth, distance);
    inorout += distance.x;

    inorout = clamp(inorout, 0.0, 1.0);

    surfColor = mix(Yellow, Red, inorout);
    surfColor = mix(surfColor, Blue, distance.y);

    // normal = point on surface for sphere at (0,0,0)
    normal = p;

    // Per-fragment diffuse lighting
    intensity = 0.2; // ambient
    intensity += 0.8 * clamp(dot(LightDir, normal), 0.0, 1.0);
    surfColor *= intensity;

    // Per-fragment specular lighting
    intensity = clamp(dot(HVector, normal), 0.0, 1.0);
    intensity = pow(intensity, SpecularColor.a);
    surfColor += SpecularColor * intensity;

    gl_FragColor = surfColor;
}

Lattice

Here’s a little bit of a gimmick. In this example, we show how not to draw the object procedurally.

In this example, we look at how the discard command can be used in a fragment shader to achieve some interesting effects. The discard command causes fragments to be discarded rather than used to update the frame buffer. We use this to draw geometry with “holes.” The vertex shader is the exact same vertex shader used for stripes (Section 11.1.1). The fragment shader is shown in Listing 11.6.

Example 11.6. Fragment shader for procedurally discarding part of an object

varying vec3  DiffuseColor;
varying vec3  SpecularColor;

uniform vec2  Scale;
uniform vec2  Threshold;
uniform vec3  SurfaceColor;

void main()
{
    float ss = fract(gl_TexCoord[0].s * Scale.s);
    float tt = fract(gl_TexCoord[0].t * Scale.t);

    if ((ss > Threshold.s) && (tt > Threshold.t)) discard;

    vec3 finalColor = SurfaceColor * DiffuseColor + SpecularColor;
    gl_FragColor = vec4(finalColor, 1.0);
}

The part of the object to be discarded is determined by the values of the s and t texture coordinates. A scale factor is applied to adjust the frequency of the lattice. The fractional part of this scaled texture-coordinate value is computed to provide a number in the range [0,1]. These values are compared with the threshold values that have been provided. If both values exceed the threshold, the fragment is discarded. Otherwise, we do a simple lighting calculation and render the fragment.

In Color Plate 14, the threshold values were both set to 0.13. This means that more than three-quarters of the fragments were being discarded! And that’s what I call a “holy cow!”

Bump Mapping

We have already seen procedural shaders that modified color (brick, stripes) and opacity (lattice). Another whole class of interesting effects can be applied to a surface with a technique called BUMP MAPPING. Bump mapping involves modulating the surface normal before lighting is applied. We can perform the modulation algorithmically to apply a regular pattern; we can add noise to the components of a normal; or we can look up a perturbation value in a texture map. Bump mapping has proved to be an effective way of increasing the apparent realism of an object without increasing the geometric complexity. It can be used to simulate surface detail or surface irregularities.

The technique does not truly alter the surface being shaded, it merely “tricks” the lighting calculations. Therefore, the “bumping” does not show up on the silhouette edges of an object. Imagine modeling a planet as a sphere and shading it with a bump map so that it appears to have mountains that are quite large relative to the diameter of the planet. Because nothing has been done to change the underlying geometry, which is perfectly round, the silhouette of the sphere always appears perfectly round, even if the mountains (bumps) go right up to the silhouette edge. In real life, you would expect the mountains on the silhouette edges to prevent the silhouette from looking perfectly round. For this reason, it is a good idea to use bump mapping to apply only “small” effects to a surface (at least relative to the size of the surface). Wrinkles on an orange, embossed logos, and pitted bricks are all good examples of things that can be successfully bump-mapped.

Bump mapping adds apparent geometric complexity during fragment processing, so once again the key to the process is our fragment shader. This implies that the lighting operation must be performed by our fragment shader instead of by the vertex shader where it is often handled. Again, this points out one of the advantages of the programmability that is available through the OpenGL Shading Language. We are free to perform whatever operations are necessary, in either the vertex shader or the fragment shader. We don’t need to be bound to the fixed functionality ideas of where things like lighting are performed.

The key to bump mapping is that we need a valid surface normal at each fragment location, and we also need a light source and viewing direction vectors. If we have access to all these values in the fragment shader, we can procedurally perturb the normal prior to the light source calculation to produce the appearance of “bumps.” In this case, we really are attempting to produce bumps or small spherical nodules on the surface being rendered.

The light source computation is typically performed with dot products. For the result to have meaning, all the components of the light source calculation must be defined in the same coordinate space. So if we used the vertex shader to perform lighting, we would typically define light source positions or directions in eye coordinates and would transform incoming normals and vertex values into this space to do the calculation.

However, the eye-coordinate system isn’t necessarily the best choice for doing lighting in the fragment shader. We could normalize the direction to the light and the surface normal after transforming them to eye space and then pass them to the fragment shader as varying variables. However, the light direction vector would need to be renormalized after interpolation to get accurate results. Moreover, whatever method we use to compute the perturbation normal, it would need to be transformed into eye space and added to the surface normal; that vector would also need to be normalized. Without renormalization, the lighting artifacts would be quite noticeable. Performing these operations at every fragment might be reasonably costly in terms of performance. There is a better way.

Let us look at another coordinate space called the SURFACE-LOCAL COORDINATE SPACE. This coordinate system varies over a rendered object, and it assumes that each point is at (0, 0, 0) and that the unperturbed surface normal at each point is (0, 0, 1). This would be a pretty convenient coordinate system in which to do our bump mapping calculations. But, to do our lighting computation, we need to make sure that our light direction, viewing direction, and the computed perturbed normal are all defined in the same coordinate system. If our perturbed normal is defined in surface-local coordinates, that means we need to transform our light direction and viewing direction into surface-local space as well. How is that accomplished?

What we need is a transformation matrix that transforms each incoming vertex into surface-local coordinates (i.e., incoming vertex (x, y, z) is transformed to (0, 0, 0)). We need to construct this transformation matrix at each vertex. Then, at each vertex, we use the surface-local transformation matrix to transform both the light direction and the viewing direction. In this way, the surface local coordinates of the light direction and the viewing direction are computed at each vertex and interpolated across the primitive. At each fragment, we can use these values to perform our lighting calculation with the perturbed normal that we calculate.

But we still haven’t answered the real question. How do we create the transformation matrix that transforms from object coordinates to surface-local coordinates? An infinite number of transforms will transform a particular vertex to (0, 0, 0). To transform incoming vertex values, we need a way that gives consistent results as we interpolate between them.

The solution is to require the application to send down one more attribute value for each vertex, a tangent value. Furthermore, we require the application to send us tangents that are consistently defined across the surface of the object. By definition, this tangent vector is in the plane of the surface being rendered and perpendicular to the incoming surface normal. If defined consistently across the object, it serves to orient consistently the coordinate system that we derive. If we perform a cross-product between the tangent vector and the surface normal, we get a third vector that is perpendicular to the other two. This third vector is called the binormal, and it’s something that we can compute in our vertex shader. Together, these three vectors form an orthonormal basis, which is what we need to define the transformation from object coordinates into surface-local coordinates. Because this particular surface-local coordinate system is defined with a tangent vector as one of the basis vectors, this coordinate system is sometimes referred to as TANGENT SPACE.

The transformation from object space to surface-local space is shown in Figure 11.5. We transform the object space vector (Ox, Oy, Oz) into surfacelocal space by multiplying it by a matrix that contains the tangent vector (Tx, Ty, Tz) in the first row, the binormal vector (Bx, By, Bz) in the second row, and the surface normal (Nx, Ny, Nz) in the third row. We can use this process to transform both the light direction vector and the viewing direction vector into surface-local coordinates. The transformed vectors are interpolated across the primitive, and the interpolated vectors are used in the fragment shader to compute the reflection with the procedurally perturbed normal.

Transformation from object space to surface-local space

Figure 11.5. Transformation from object space to surface-local space

Application Setup

For our procedural bump map shader to work properly, the application must send a vertex position, a surface normal, and a tangent vector in the plane of the surface being rendered. The application passes the tangent vector as a generic vertex attribute, and binds the index of the generic attribute to be used to the vertex shader variable tangent by calling glBindAttribLocation. The application is also responsible for providing values for the uniform variables LightPosition, SurfaceColor, BumpDensity, BumpSize, and SpecularFactor.

You must be careful to orient the tangent vectors consistently between vertices; otherwise, the transformation into surface-local coordinates will be inconsistent, and the lighting computation will yield unpredictable results. Consistent tangents can be computed algorithmically for mathematically defined surfaces. Consistent tangents for polygonal objects can be computed with neighboring vertices and by application of a consistent ordering with respect to the object’s texture coordinates.

The problem with inconsistently defined normals is illustrated in Figure 11.6. This diagram shows two triangles, one with consistently defined tangents and one with inconsistently defined tangents. The gray arrowheads indicate the tangent and binormal vectors (the surface normal is pointing straight out of the page). The white arrowheads indicate the direction toward the light source (in this case, a directional light source is illustrated).

Inconsistently defined tangents can lead to large lighting errors

Figure 11.6. Inconsistently defined tangents can lead to large lighting errors

When we transform vertex 1 to surface-local coordinates, we get the same initial result in both cases. When we transform vertex 2, we get a large difference because the tangent vectors are very different between the two vertices. If tangents were defined consistently, this situation would not occur unless the surface had a high degree of curvature across this polygon. And if that were the case, we would really want to tessellate the geometry further to prevent this from happening.

The result is that in case 1, our light direction vector is smoothly interpolated from the first vertex to the second and all the interpolated vectors are roughly the same length. If we normalize this light vector at each vertex, the interpolated vectors are very close to unit length as well.

But in case 2, the interpolation causes vectors of wildly different lengths to be generated, some of them near zero. This causes severe artifacts in the lighting calculation.

OpenGL does not have a defined vertex attribute for a tangent vector. The best choice is to use a generic vertex attribute to pass in the tangent value. We don’t need to compute the binormal in the application; we have the vertex shader compute it automatically.

The shaders described in the following section are descendants of the “bumpy/shiny” shader that John Kessenich and I developed for the SIGGRAPH 2002 course, State of the Art in Hardware Shading.

Vertex Shader

The vertex shader for our procedural bump map shader is shown in Listing 11.7. This shader is responsible for computing the surface-local direction to the light and the surface-local direction to the eye. To do this, it accepts the incoming vertex position, surface normal, and tangent vector; computes the binormal; and transforms the eye space light direction and viewing direction, using the created surface-local transformation matrix. The texture coordinates are also passed on to the fragment shader because they are used to determine the position of our procedural bumps.

Example 11.7. Vertex shader for doing procedural bump mapping

varying vec3 LightDir;
varying vec3 EyeDir;

uniform vec3 LightPosition;

attribute vec3 Tangent;

void main()
{
    EyeDir         = vec3(gl_ModelViewMatrix * gl_Vertex);
    gl_Position    = ftransform();
    gl_TexCoord[0] = gl_MultiTexCoord0;

    vec3 n = normalize(gl_NormalMatrix * gl_Normal);
    vec3 t = normalize(gl_NormalMatrix * Tangent);
    vec3 b = cross(n, t);

    vec3 v;
    v.x = dot(LightPosition, t);
    v.y = dot(LightPosition, b);
    v.z = dot(LightPosition, n);
    LightDir = normalize(v);

    v.x = dot(EyeDir, t);
    v.y = dot(EyeDir, b);
    v.z = dot(EyeDir, n);
    EyeDir = normalize(v);
}

Fragment Shader

The fragment shader for doing procedural bump mapping is shown in Listing 11.8. A couple of the characteristics of the bump pattern are parameterized by being declared as uniform variables, namely, BumpDensity (how many bumps per unit area) and BumpSize (how wide each bump will be). Two of the general characteristics of the overall surface are also defined as uniform variables: SurfaceColor (base color of the surface) and SpecularFactor (specular reflectance property).

The bumps that we compute are round. Because the texture coordinate is used to determine the positioning of the bumps, the first thing we do is multiply the incoming texture coordinate by the density value. This controls whether we see more or fewer bumps on the surface. Using the resulting grid, we compute a bump located in the center of each grid square. The components of the perturbation vector p are computed as the distance from the center of the bump in the x direction and the distance from the center of the bump in the y direction. (We only perturb the normal in the x and y directions. The z value for our perturbation normal is always 1.0.) We compute a “pseudodistance” d by squaring the components of p and summing them. (The real distance could be computed at the cost of doing another square root, but it’s not really necessary if we consider BumpSize to be a relative value rather than an absolute value.)

To perform a proper reflection calculation later on, we really need to normalize the perturbation normal. This normal must be a unit vector so that we can perform dot products and get accurate cosine values for use in the lighting computation. We normalize a vector by multiplying each component of the normal by 1.0 / sqrt(x2 + y2 + z2). Because of our computation for d, we’ve already computed part of what we need (i.e., x2 + y2). Furthermore, because we’re not perturbing z at all, we know that z2 will always be 1.0. To minimize the computation, we just finish computing our normalization factor at this point in the shader by computing 1.0 / sqrt(d + 1.0).

Next, we compare d to BumpSize to see if we’re in a bump or not. If we’re not, we set our perturbation vector to 0 and our normalization factor to 1.0. The lighting computation happens in the next few lines. We compute our normalized perturbation vector by multiplying through with the normalization factor f. The diffuse and specular reflection values are computed in the usual way, except that the interpolated surface-local coordinate light and view direction vectors are used. We get decent results without normalizing these two vectors as long as we don’t have large differences in their values between vertices.

Example 11.8. Fragment shader for procedural bump mapping

varying vec3 LightDir;
varying vec3 EyeDir;

uniform vec3 SurfaceColor;     // = (0.7, 0.6, 0.18)
uniform float BumpDensity;     // = 16.0

uniform float BumpSize;        // = 0.15
uniform float SpecularFactor;  // = 0.5

void main()
{
    vec3 litColor;
    vec2 c = BumpDensity * gl_TexCoord[0].st;
    vec2 p = fract(c) - vec2(0.5);

    float d, f;
    d = p.x * p.x + p.y * p.y;
    f = 1.0 / sqrt(d + 1.0);

    if (d >= BumpSize)
        { p = vec2(0.0); f = 1.0; }

    vec3 normDelta = vec3(p.x, p.y, 1.0) * f;
    litColor = SurfaceColor * max(dot(normDelta, LightDir), 0.0);
    vec3 reflectDir = reflect(LightDir, normDelta);

    float spec = max(dot(EyeDir, reflectDir), 0.0);
    spec = pow(spec, 6.0)
    spec *= SpecularFactor;
    litColor = min(litColor + spec, vec3(1.0));

    gl_FragColor = vec4(litColor, 1.0);
}

The results from the procedural bump map shader are shown applied to two objects, a simple box and a torus, in Color Plate 15. The texture coordinates are used as the basis for positioning the bumps, and because the texture coordinates go from 0.0 to 1.0 four times around the diameter of the torus, the bumps look much closer together on that object.

Normal Maps

It is easy to modify our shader so that it obtains the normal perturbation values from a texture rather generating them procedurally. A texture that contains normal perturbation values for the purpose of bump mapping is called a BUMP MAP or a NORMAL MAP.

An example of a normal map and the results applied to our simple box object are shown in Color Plate 16. Individual components for the normals can range from [–1,1]. To be encoded into an RGB texture with 8 bits per component, they must be mapped into the range [0,1]. The normal map appears chalk blue because the default perturbation vector of (0,0,1) is encoded in the normal map as (0.5,0.5,1.0). The normal map could be stored in a floating-point texture. Today’s graphics hardware supports textures with 16-bit floating-point values per color component and textures with 32-bit floating-point values per color component. If you use a floating-point texture format for storing normals, your image quality tends to increase (for instance, reducing banding effects in specular highlights). Of course, textures that are 16 bits per component require twice as much texture memory as 8-bit per component textures, and performance might be reduced.

The vertex program is identical to the one described in Section 11.4.2. The fragment shader is almost the same, except that instead of computing the perturbed normal procedurally, the fragment shader obtains it from a normal map stored in texture memory.

Summary

A master magician can make it look like something is created out of thin air. With procedural textures, you, as a shader writer, can express algorithms that turn flat gray surfaces into colorful, patterned, bumpy, or reflective ones. The trick is to come up with an algorithm that expresses the texture you envision. By coding this algorithm as an OpenGL shader, you too can create something out of thin air.

In this chapter, we only scratched the surface of what’s possible. We created a stripe shader, but grids and checkerboards and polka dots are no more difficult. We created a toy ball with a star, but we could have created a beach ball with snowflakes. Shaders can be written to procedurally include or exclude geometry or to add bumps or grooves. Additional procedural texturing effects are illustrated later in this book. Chapter 15 shows how an irregular function (noise) can achieve a wide range of procedural texturing effects. Shaders for generating procedural textures with a more complex mathematical function (the Mandelbrot and Julia sets) and for creating non-photorealistic effects are also described later in the book.

Procedural textures are mathematically precise, are easy to parameterize, and don’t require large amounts of texture memory. The end goal of a vertex shader/fragment shader pair is to produce a color value (and possibly a depth value) that will be written into the frame buffer. Because the OpenGL Shading Language is a procedural programming language, the only limit to this computation is your imagination.

Further Information

The book Texturing and Modeling: A Procedural Approach, Third Edition, by Ebert et al. (2002) is entirely devoted to creating images procedurally. This book contains a wealth of information and inspires a ton of ideas for the creation and use of procedural models and textures.

The shaders written in the RenderMan Shading Language are often procedural in nature, and The RenderMan Companion by Steve Upstill (1990) and Advanced RenderMan: Creating CGI for Motion Pictures by Apodaca and Gritz (1999) contain some notable examples.

Bump mapping was invented by Jim Blinn and described in his 1978 SIGGRAPH paper, Simulation of Wrinkled Surfaces. A very good overview of bump mapping techniques can be found in a paper titled A Practical and Robust Bump-mapping Technique for Today’s GPUs by Mark Kilgard (2000).

A Photoshop plug-in for creating a normal map from an image is available at NVIDIA’s developer Web site at http://developer.nvidia.com/object/photoshop_dds_plugins.html.

  1. Apodaca, Anthony A., and Larry Gritz, Advanced RenderMan: Creating CGI for Motion Pictures, Morgan Kaufmann Publishers, San Francisco, 1999. http://www.renderman.org/RMR/Books/arman/materials.html

  2. Blinn, James, Simulation of Wrinkled Surfaces, Computer Graphics (SIGGRAPH ’78 Proceedings), pp. 286–292, August 1978.

  3. Ebert, David S., John Hart, Bill Mark, F. Kenton Musgrave, Darwyn Peachey, Ken Perlin, and Steven Worley, Texturing and Modeling: A Procedural Approach, Third Edition, Morgan Kaufmann Publishers, San Francisco, 2002. http://www.texturingandmodeling.com

  4. Kilgard, Mark J., A Practical and Robust Bump-mapping Technique for Today’s GPUs, Game Developers Conference, NVIDIA White Paper, 2000. http://developer.nvidia.com/object/Practical_Bumpmapping_Tech.html

  5. NVIDIA developer Web site. http://developer.nvidia.com

  6. Rost, Randi J., The OpenGL Shading Language, SIGGRAPH 2002, Course 17, course notes. http://3dshaders.com/pubs

  7. Upstill, Steve, The RenderMan Companion: A Programmer’s Guide to Realistic Computer Graphics, Addison-Wesley, Reading, Massachusetts, 1990.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
44.210.151.5