11.2. 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.
11.2.1. 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.
Listing 11.3. Values for uniform variables used by the toy ball shader
11.2.2. 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).
Listing 11.4. Vertex shader for drawing a toy ball
11.2.3. 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 = dot(p, HalfSpace0); distance = dot(p, HalfSpace1); distance = dot(p, HalfSpace2); distance = 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.
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).
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;
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.
Listing 11.5. Fragment shader for drawing a toy ball