Rendering my balls in a fragment shader


This is a page about writing basic OpenGL fragment shaders

I'm a programmer who spends part of his time each week teaching kids about computers and electronics. In our recent classes, we've been learning about various computer vector graphic libraries and the usefulness of linear algebra. To give the kids a bit of fun, I decided we would write a 2D pool table simulation as a further demonstration of programming and high school math.

After having completed most of the features we wanted, including accurate physics, ball path mapping and replay, table time freezing with zooming and panning, as well as a mathematics overlay, I wanted to work on the visual aesthetics and presentation of the program. As this is a top down 2D pool simulation, we've been using colored circles to represent the pool balls in our simulation. After implementing a detailed table view with zooming, I realized that using circles as a representation of pool balls is both boring and a bit too unrealistic. Circles neither convey a sense of movement or the mechanics of a ball spinning and rolling.

Starting out flat

So this weekend I thought I'd implement a surprise for my kids. Since we were using an OpenGL fragment shader to convert the squares into circles, I thought about writing a fragment shader to render fully 3D looking billiard balls.

This is not a 3D rendered sphere. It's just a square.
The above image is a capture of the pixels I've created to replace the flat circles in our pool simulator. I used the term pixels, because there is no geometry used in creating the pool ball render above. Instead I am still using the same squares, but I've replaced the circle generation in an OpenGL fragment program with one that is a bit more advanced.

Here is how it works

First, we start out with a blank square.

A blank square of 512px size.
1void main() {
2  gl_FragColor = vec4(0);
3}
I'm rendering these pool balls at a size of 512px so that we can see the details. Impressive isn't it? :)

Generating a circle

The next thing I wanted was to create a circle. I decided to use the frame size to determine the final size of the ball.

A circle is generated like this.

Our circle uses a radius equal to half the frame size.
01uniform float size;
02 
03void main() {
04    // convert resolution to coordinates in the 0..1 range
05    vec2 coord = gl_FragCoord.xy / size;
06    // use the center to create a circle mask
07    vec2 center = vec2(0.5, 0.5);
08    float d = distance(center, coord) / 0.5;
09    float a = d > 0.99 ? 1.0 - smoothstep(0.99, 1.0, d) : 1.0;
10    gl_FragColor = vec4(1.0, 1.0, 1.0, a);
11}
Okay, so what is going on here? Well I begin by converting our current pixel
gl_FragCoord
into a coordinate range between 0 and 1. Then I create the center of our circle
vec2(0.5, 0.5)
in order to test the distance to the current pixel.

But what is going on with this variable
a
? Well if you didn't know, fragment shaders do not create smooth edges. You can read more about that problem here in an article I wrote 15 years ago! The short of it is, if we want a smooth edge to our circle inside a fragment shader, we need to smooth it ourselves, and that's exactly what
smoothstep
is doing for us.

Make it a sphere with normals

Now we need to turn our circle into a 3D looking sphere. To do that we need to create surface normals for our circle. A normal is a unit length vector pointing in the "up" direction of a surface.

Here is how we can create the normals for our circle.

A normal map is computed for our circle.
1// create uv coords in the -1..1 range
2vec2 uv = (2.0 * coord - 1.0);
3// build our normal using xy and a calculated z
4vec3 n = vec3(uv, sqrt(1.0 - clamp(dot(uv, uv), 0.0, 1.0)));
5// generate a surface normal map
6vec3 map = 0.5 + 0.5 * n;
7gl_FragColor = vec4(map, a);
To build our normal we begin by converting our coord to a range between -1 and 1. Then we use those xy values and compute a z value. Finally, we map each component to rgb float values and display that result.

Adding light and color

We're ready to add basic lighting, turning our circle into a sphere. While testing we're going to control the light position with the computer mouse. Values passed from our CPU program to our GPU program are sent by way of uniform values. Our light position uniform is
uniform vec2 lightpos
.

And this is our basic lit and colored sphere.

Using basic lighting and a color.
1// create a primary light source
2vec3 light = vec3(lightpos.x, -lightpos.y * 3.0, 200.0);
3// normalize the light as a direction
4light = normalize(light);
5// use dot product to find the brightness of the pixel on the sphere
6float brightness = clamp(dot(light, n), 0.1, 1.0);
7vec3 yellow = vec3(1.000, 0.843, 0.000);
8gl_FragColor = vec4(yellow * brightness, a);
In the above fragment shader code we're creating a light using the mouse input stored in uniform
lightpos
. As the mouse is a 2D input device we're using it to controll the x and y position of the light, while setting the z to
200.0
which is in front of the sphere. Next we normalize the light, as we want to simulate a distant light and not a point light. We use the
dot
product function to determine the brightness of the pixel and
clamp
it to a min of 0.1 and max of 1.0. The clamping min value simulates an ambient lighting value.

To compute the final sphere pixel color we simply multiply our select color
yellow
by our computed
brightness
, making sure to mask the circle shape again with
a
.

Solids and stripes

Our pool balls need to have solid or stripe diffuse patterns along with a circle for the ball numbers. To accomplish this we are going to write a
colorLookup
function that takes a point on the sphere and a ball number. This function will determine whether a ball is the cue ball, a solid, or a stripe. Later it will also map the number textures to our ball.

This is our ball with the diffuse color pattern computed.

It's starting to look like a pool ball.
01uniform int ball;
02 
03const vec3 white = vec3(1.0);
04const vec3 yellow = vec3(1.000, 0.843, 0.000);
05 
06vec3 colorLookup(vec3 point, int number) {
07  // the cue ball is white
08  if (number == 0)
09    return white;
10  // all other balls have a zero based index
11  number--;
12  float d;
13  // use a fixed color of yellow for now
14  vec3 color = yellow;
15  // if we are in a striped ball
16  if (number > 7)
17    if (abs(point.y) > 0.55) {
18      // smooth the stripe
19      d = abs(point.y);
20      d = smoothstep(0.55, 0.56, d);
21      return mix(color, white, d);
22    }
23  // generate the circle for the number area
24  d = distance(point.xy, vec2(0));
25  // smooth the circle
26  if (d < 0.4) return white;
27  d = smoothstep(0.4, 0.41, d);
28  return mix(white, color, d);
29}
30 
31// in our main function
32vec3 diffuse = colorLookup(n, ball) * brightness;
33gl_fragColor = vec4(diffuse, a);
Okay, so there is a bit to unpack here. First, we've introduced the uniform
ball
to select the ball number, which is then used by the shader to determine the diffuse pattern and color to some extent. For now, we either get a white cue ball or a yellow ball.

The
colorLookup
function first checks if we if it's being asked to get the color of a cue ball. If that is the case then it simply returns white. After that it decrements the ball number as we'll later use it as a zero based index for calculating texture coordinates and accessing a color array.

Next, we check if the ball is a stripe. We only need to address the stripe if
abs(point.y) > 0.55
. What this means is that the colored areas of both solid and striped balls are handled the same if they are in the lower half of the y area.

Then we leave a white circle for our eventual ball numbers by checking if the xy coords are less than 0.4 from the z axis.

Note, we are smoothing both the stripe and the circle just as we smoothed the edge of the circle previously.

Numbering and coloring

It's time to prepare for texture mapping numbers unto our billiard balls as well as picking the correct diffuse colors based on the ball number. I couldn't find the exact font matching the style frequently seen on the balls in so many billiard rooms, so I finagled some comparable looking numbers using Inkscape the vector drawing program.

A 1..16 ball number texture.
01// ball color data
02vec3 colorData[8] = vec3[](
03  vec3(1.000, 0.843, 0.000),
04  vec3(1.000, 0.000, 0.000),
05  vec3(0.000, 0.000, 1.000),
06  vec3(0.502, 0.000, 0.502),
07  vec3(1.000, 0.647, 0.000),
08  vec3(0.000, 0.502, 0.000),
09  vec3(0.549, 0.000, 0.102),
10  vec3(0.100, 0.100, 0.100)
11);
In the above code, we've created an array to hold the lookup values for our ball colors.

Texture mapping

We're ready to map the texture to our balls. We're going to modify the
colorLookup
function to check if the xy coordinates on either side of the ball are within the area we want to texture.

Here is how it's done.

We have a number.
01// area reserved for ball number texture
02const float square = 0.28;
03// clean up factor for texture artifacts
04const float edge = 0.0033;  
05 
06vec3 colorLookup(vec3 point, int number) {
07  // the cue ball is white
08  if (number == 0)
09    return white;
10  // all other balls hae a zero based index
11  number--;
12  vec3 color;
13  // if we're in the area where a number should be mapped
14  if (abs(point.x) < square && abs(point.y) < square) {
15    // convert the point to a texture coordinate
16    vec2 tex = (point.xy + vec2(square)) / (square * 2.);
17    // flip it on the y
18    tex.y = 1. - tex.y;
19    // if we're on the backside flip the x
20    if (point.z < 0.)
21      tex.x = 1. - tex.x;
22    // our number texture uses a 4x4 grid with 16 numbers
23    tex = tex / 4.0;
24    tex.x = tex.x + mod(number, 4) * 0.25;
25    tex.y = tex.y + number / 4 * 0.25;
26    color = texture2D(texture0, tex).rgb;
27    // reset the texure coord to 0, 0
28    tex.x = tex.x - mod(number, 4) * 0.25;
29    tex.y = tex.y - number / 4 * 0.25;
30    // and cut off texture artifacts from the edges
31    if (tex.x < edge) return white;
32    if (tex.x + edge > 0.25) return white;
33    if (tex.y < edge) return white;
34    if (tex.y + edge > 0.25) return white;
35    return color;
36  }
37  float d;
38  // get ball color from our color data
39  color = colorData[int(mod(number, 8))];
40  // if we are in a striped ball
41  if (number > 7)
42    if (abs(point.y) > 0.55) {
43      // generate a smooth stripe
44      d = abs(point.y);
45      d = smoothstep(0.55, 0.56, d);
46      return mix(color, white, d);
47    }
48  // generate the circle for the number area
49  d = distance(point.xy, vec2(0));
50  // antialias the circle
51  if (d < 0.4) return white;
52  d = smoothstep(0.4, 0.41, d);
53  return mix(white, color, d);
54}
Using the constant
square
we can check if the point x and y components are within the area we want to texture map. We then copy the point to
vec2 tex
and scale it to a range of 0..1. As OpenGL texture coordinates start in the bottom left we flip the y axis by subtracting it from 1. If the texture is on the negative side of z we flip the x axis, as we don't want our back numbers to be mirrored in reverse.

Next, we compute the texture coordinates mindful that our texture has the numbers arranged in order using an even 4x4 grid. We use the
mod
built-in function with the number 4 to find the column, and integer
/
divide operator to find the row. We locate the texel color by using
texture2D
.

The last bit is some clean up, as on my configuration of hardware and video driver occasionally generates unsightly seam artifacts. They are cleaned up using an edge constant.

Subtle lighting improvements

Although our render is starting to look decent, we can make a few improvements. I wanted to add a rim light to better visualize the outline of our object in the darkened areas. To do that we can simply add another light but make it somewhat behind our object.

Here is my subtle rim light addition.

Notice the rim light in the lower right area of the billiard ball.
1// create a secondary backlight
2vec3 backlight = normalize(vec3(50.0, -90.0, -80.0));
3// calculate the backlight rim intensity
4float rim = dot(backlight, n);
5// clamp and reduce the rim intensity
6rim = pow(clamp(rim, 0.0, 1.0), 3.0);
7gl_FragColor = vec4(diffuse * (brightness + rim), a);
This is quite easy. We're repeating what we've already done with the main light, but it's positioned behind the ball. Note it's mostly from behind with a z value of -80. It's important that we
clamp
the dot product because it will contain negative values as it's behind the object.

The rim light is simply added to the brightness and then they are used to moderate the diffuse color.

Putting it all together

We're ready to put on the final touches. We can add a specular light and begin animating our billiard ball viewing it from any angle. Here is how it looks with a simple random orbit animation.

Our ball with specular highlights and animation.

The complete shader program

And at last, here is a list of all the shader program pieces put together. Feel free to copy or make your own changes without restriction.
001#define PI 3.14159265358
002 
003// a texture with the numbers 1..16
004uniform sampler2D texture0;
005// size of the render area
006uniform int size;
007// light source controlled by the mouse
008uniform vec2 lightpos;
009// animated angle used to rotate the ball
010uniform float angle;
011// ball number 0..16
012uniform int ball;
013// optional specular highlights
014uniform int highlight;
015 
016// the default ball color
017const vec3 white = vec3(1.);
018// area reserved for ball number texture
019const float square = 0.28;
020// clean up factor for texture artifacts
021const float edge = 0.0033;
022 
023// rotate on the X axis
024mat4 rotationX(float angle ) {
025    return mat4(
026    1., 0., 0., 0.,
027    0., cos(angle), -sin(angle), 0.,
028    0., sin(angle), cos(angle), 0.,
029    0., 0., 0., 1);
030}
031 
032// rotate on the Y axis
033mat4 rotationY(float angle) {
034  return mat4(
035    cos(angle), 0., sin(angle), 0.,
036    0., 1.0, 0., 0.,
037    -sin(angle), 0., cos(angle), 0.,
038    0., 0., 0., 1);
039}
040 
041// rotate on the Z axis
042mat4 rotationZ(float angle) {
043    return mat4(
044    cos(angle), -sin(angle), 0., 0.,
045    sin(angle), cos(angle), 0., 0.,
046    0., 0., 1, 0.,
047    0., 0., 0., 1);
048}
049 
050// ball color data
051vec3 colorData[8] = vec3[](
052  vec3(1.000, 0.843, 0.000),
053  vec3(1.000, 0.000, 0.000),
054  vec3(0.000, 0.000, 1.000),
055  vec3(0.502, 0.000, 0.502),
056  vec3(1.000, 0.647, 0.000),
057  vec3(0.000, 0.502, 0.000),
058  vec3(0.549, 0.000, 0.102),
059  vec3(0.100, 0.100, 0.100)
060);
061 
062// lookup the color and texture pattern for a point on the ball
063vec3 colorLookup(vec3 point, int number) {
064  // the cue ball is white
065  if (number == 0)
066    return white;
067  // all other balls have a zero based index
068  number--;
069  vec3 color;
070  // if the area where a number should be then we texure map
071  if (abs(point.x) < square && abs(point.y) < square) {
072    // convert the point to a texture coordinate
073    vec2 tex = (point.xy + vec2(square)) / (square * 2.);
074    // flip it on the y
075    tex.y = 1. - tex.y;
076    // if we're on the backside flip the x
077    if (point.z < 0.)
078      tex.x = 1. - tex.x;
079    // our number texture uses a 4x4 grid with 16 numbers
080    tex = tex / 4.0;
081    tex.x = tex.x + mod(number, 4) * 0.25;
082    tex.y = tex.y + number / 4 * 0.25;
083    color = texture2D(texture0, tex).rgb;
084    // reset the texture coord to 0, 0
085    tex.x = tex.x - mod(number, 4) * 0.25;
086    tex.y = tex.y - number / 4 * 0.25;
087    // and cut off texture artifacts from the edges
088    if (tex.x < edge) return white;
089    if (tex.x + edge > 0.25) return white;
090    if (tex.y < edge) return white;
091    if (tex.y + edge > 0.25) return white;
092    return color;
093  }
094  float d;
095  // get ball color from our color data
096  color = colorData[int(mod(number, 8))];
097  // if we are in a striped ball
098  if (number > 7)
099    if (abs(point.y) > 0.55) {
100      // generate a smooth stripe
101      d = abs(point.y);
102      d = smoothstep(0.55, 0.56, d);
103      return mix(color, white, d);
104    }
105  // generate the circle for the number area
106  d = distance(point.xy, vec2(0));
107  // antialias the circle
108  if (d < 0.4) return white;
109  d = smoothstep(0.4, 0.41, d);
110  return mix(white, color, d);
111}
112 
113void main() {
114    // convert resolution to coordinates in the 0..1 range
115    vec2 coord = gl_FragCoord.xy / size;
116    // use the center to create a circle mask
117    vec2 center = vec2(0.5, 0.5);
118    float d = distance(center, coord) / 0.5;
119    float a = d > 0.99 ? 1.0 - smoothstep(0.99, 1.0, d) : 1.0;
120    // create uv coords in the 0..1 range
121    vec2 uv = (2.0 * coord - 1.0);
122    // build our normal using xy and a calculated z
123    vec3 n = vec3(uv, sqrt(1.0 - clamp(dot(uv, uv), 0.0, 1.0)));
124    // create a primary light source
125    vec3 light = vec3(lightpos.x, -lightpos.y * 3., 200.);
126    // normalize the light as a direction
127    light = normalize(light);
128    // optional specularity
129    float spec = 0.;
130    if (highlight > 0) {
131      // generate the specular shine
132      vec3 r = reflect(-light, n);
133      vec3 spot = n * 10.;
134      spot.z = 400.;
135      vec3 v = normalize(spot);
136      float bounce = max(dot(r, v), 0.);
137      spec = pow(bounce, 10.);
138      spec = smoothstep(0.5, 1.0, spec);
139      spec = pow(spec, 30.) * 0.9;
140    }
141    // map the normal to a vertex
142    vec4 vert = vec4(n, 0.);
143    // rotate the vertex to spin the ball
144    vert = rotationX(angle) * vert;
145    vert = rotationZ(angle * PI / 3.) * vert;
146    vert = rotationY(angle / 3.) * vert;
147    // lookup the color of the vertex for a given ball
148    vec3 diffuse = colorLookup(vert.xyz, ball);
149    // calculate the light as brightness intensity with 0.1 ambient lighting
150    float brightness = clamp(dot(light, n), 0.1, 1.);
151    // create a secondard back light
152    vec3 backlight = normalize(vec3(50., -90., -80.));
153    // calculate the backlight rim intensity
154    float rim = dot(backlight, n);
155    rim = pow(clamp(rim, 0., 1.), 3.);
156    // calculate the final diffuse color
157    diffuse = diffuse * (brightness + rim);
158    // output the color while masking the circle
159    gl_FragColor = vec4(diffuse + spec, a);
160}