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.
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.
1
void
main() {
2
gl_FragColor =
vec4
(0);
3
}
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.

01
uniform
float
size;
02
03
void
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
}
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.

1
// create uv coords in the -1..1 range
2
vec2
uv = (2.0 * coord - 1.0);
3
// build our normal using xy and a calculated z
4
vec3
n =
vec3
(uv, sqrt(1.0 - clamp(dot(uv, uv), 0.0, 1.0)));
5
// generate a surface normal map
6
vec3
map = 0.5 + 0.5 * n;
7
gl_FragColor =
vec4
(map, a);
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 isuniform vec2 lightpos
.And this is our basic lit and colored sphere.

1
// create a primary light source
2
vec3
light =
vec3
(lightpos.x, -lightpos.y * 3.0, 200.0);
3
// normalize the light as a direction
4
light = normalize(light);
5
// use dot product to find the brightness of the pixel on the sphere
6
float
brightness = clamp(dot(light, n), 0.1, 1.0);
7
vec3
yellow =
vec3
(1.000, 0.843, 0.000);
8
gl_FragColor =
vec4
(yellow * brightness, a);
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 acolorLookup
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.

01
uniform
int
ball;
02
03
const
vec3
white =
vec3
(1.0);
04
const
vec3
yellow =
vec3
(1.000, 0.843, 0.000);
05
06
vec3
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
32
vec3
diffuse = colorLookup(n, ball) * brightness;
33
gl_fragColor =
vec4
(diffuse, a);
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.
01
// ball color data
02
vec3
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
);
Texture mapping
We're ready to map the texture to our balls. We're going to modify thecolorLookup
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.

01
// area reserved for ball number texture
02
const
float
square = 0.28;
03
// clean up factor for texture artifacts
04
const
float
edge = 0.0033;
05
06
vec3
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
}
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.

1
// create a secondary backlight
2
vec3
backlight = normalize(
vec3
(50.0, -90.0, -80.0));
3
// calculate the backlight rim intensity
4
float
rim = dot(backlight, n);
5
// clamp and reduce the rim intensity
6
rim = pow(clamp(rim, 0.0, 1.0), 3.0);
7
gl_FragColor =
vec4
(diffuse * (brightness + rim), a);
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
004
uniform
sampler2D texture0;
005
// size of the render area
006
uniform
int
size;
007
// light source controlled by the mouse
008
uniform
vec2
lightpos;
009
// animated angle used to rotate the ball
010
uniform
float
angle;
011
// ball number 0..16
012
uniform
int
ball;
013
// optional specular highlights
014
uniform
int
highlight;
015
016
// the default ball color
017
const
vec3
white =
vec3
(1.);
018
// area reserved for ball number texture
019
const
float
square = 0.28;
020
// clean up factor for texture artifacts
021
const
float
edge = 0.0033;
022
023
// rotate on the X axis
024
mat4
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
033
mat4
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
042
mat4
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
051
vec3
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
063
vec3
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
113
void
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
}