This step will focus on how to utilize lighting in OpenGL. I've provided a template project for this step that you'll use. It's in step3.zip. Unzip this to some local directory so you can work on it.
When you compile and run this program, you'll see a solid-shaded cube. We're going to light that cube using the OpenGL lighting model.
| I have added the CGrCamera camera control class to this application. This is a simple class that allows you to control the camera position. If you press the mouse over your application window and move it around, you will see what it can do. Instructions on how to install CGrCamera are on the class home page if you want to add it to other applications. |
OpenGL supports a fixed number of lights. The number that are available depends on the implementation, but will always be at least 8 (the max number can be accessed through GL_MAX_LIGHTS). Go to the line after glEnable(GL_CULL_FACE); in ChildView.cpp and add the following:
// Enable lighting
glEnable(GL_LIGHTING);
glEnable(GL_LIGHT0);
When you compile, you'll discover that this does not really do anything different other than that the cube may look a little more grey. The light is on, but it's not being used, yet. The reason is that OpenGL needs to know a "normal" for every vertex it is lighting. OpenGL does its lighting computations only at the vertices for efficiency reasons.
| At every vertex, OpenGL needs to know which way "up" is. This is what the normal vector tells it. You are responsible for supplying a normal vector for every vertex in a lit graphical model. |
A normal is a normalized vector that points in the "up direction" relative to the surface. Normalized means that the length of the vector is one. You compute the length of a 3D vector by taking the square root of the same of the squares of the components. As an example, the following code computes the length of a vector:
double Length3dv(double *v)
{
return sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
}
Be sure to include the file <cmath> if you use this. If you have a vector that is not normalized, simply divide each component by the length of the vector and it will be normalized:
double len = Length3dv(v);
v[0] /= len;
v[1] /= len;
v[2] /= len;
The front face of the cube is currently defined in my code like this in the function CChildView::Box:
// Front
glBegin(GL_QUADS);
glVertex3dv(a);
glVertex3dv(b);
glVertex3dv(c);
glVertex3dv(d);
glEnd();
A normal is the direction that is up if you are standing on the visible surface. Were I standing on the front face of the cube, up would be in the Z direction, directly out from the cube. This direction is along the Z axis, so the normal will be (0, 0, 1). So, add the following line before the first vertex is defined:
glNormal3d(0, 0, 1);
Your code should look like this:
// Front
glBegin(GL_QUADS);
glNormal3d(0, 0, 1);
glVertex3dv(a);
glVertex3dv(b);
glVertex3dv(c);
glVertex3dv(d);
glEnd();
Add this line for each of the 6 faces, but with the correct normal for each face. Can you figure out what the normals will be? As an example, the normal for the bottom face will be (0, -1, 0). These normals are all easy to figure out for this simple box because they are parallel with coordinate axis. Select the spin option in the Lab stuff menu to spin your lit cube.
| When you call glNormal, you are setting a vertex normal that will be used for all subsequent glVertex calls. This is a state in OpenGL that you can't push or pop. Normally, you'll either call glNormal before every surface or before every vertex (more on that later). Vectors passed to glNormal do have to be normalized. glNormal must be called before glVertex. |
By default, the light in OpenGL is white and is coming from the Z direction. You can probably tell this from the way the cube looks. In OpenGL, and most graphics systems, lights can be at a point in space or at infinity. We'll always use a 4D vector to describe the position of a light. The vector has values x, y, z, and w. If w is 1.0, we are defining a light at a point in space. If w is 0.0, the light is at infinity. As an example, try adding this code right after you enable the light:
GLfloat lightpos[] = {.5, 2., 1., 0.};
glLightfv(GL_LIGHT0, GL_POSITION, lightpos);
This says that a light exists in the direction (0.5, 2., 1.). This is a vector. These vectors do not have to be normalized, OpenGL will normalize them for you. Try lightpos = {0, 1, 0, 0}; as an other example. Be sure to spin the cube to see what happens.
Now, try lightpos = {10, 10, 10, 1.0}; This puts the light near the corner of the cube.
Light positions are subject to the same types of transformations as other objects in space. If you want the light to appear fixed, you define its position after the gluLookAt call, but before any model transformations. Try moving the glLightfv call after the glRotated call (the one right before the call to Box()) and see what happens when you spin the cube. Move it back when you are done.
When you enable lighting, the glColor call no longer has any effect. Instead, we move to a different model for color of an object. We'll set the "material properties" for the object. There are several material properties you can set:
| GL_DIFFUSE | Surfaces can be considered to have two lighting characteristics: diffuse reflection and specular reflection. Diffuse reflection reflects light in all directions, regardless of where it came from. Specular reflection reflects more light in the mirror direction. A perfect mirror has no diffuse reflection and tons of specular reflection. Perfectly flat paint has diffuse reflection and no specular reflection. Most things are in-between. This is generally the property you'll use to set the color of a surface. |
| GL_AMBIENT | Ambient light is light that comes from all directions. Systems like OpenGL only directly simulate light coming from some light source. The don't simulate the natural occurrence of light bouncing off of other sources or being diffused by the atmosphere. Consequently, any surface that does not have a light shining on it directly is not lit at all by that light. A hack to deal with this problem is ambient light. If you set an ambient color the same as the diffuse color and use a small amount of ambient lighting, you'll be able to see all surfaces no matter where the light is. Because AMBIENT and DIFFUSE are often set at the same time, there is an GL_AMBIENT_AND_DIFFUSE option for glMaterial. |
| GL_SPECULAR | This property sets the specular color. Note that the specular color for most surfaces is white, even if the surface is a different color. |
| GL_EMISSION | The emissive property is how much a surface generates it's own light. |
| GL_SHININESS | This determine how shiny a surface is. Values range from 0 to 128. |
Let's set a material property for our Cube. Before the Box call, add this:
GLfloat msugreen[] = {0.f, .47f, .2f, 1.f};
glMaterialfv(GL_FRONT, GL_DIFFUSE, msugreen);
| Note: for some reason, OpenGL does not let you specify colors with double values. Note that "f" on the end of the constants. If you leave it off, 0.47 will be a double and you'll get an initializer warning. The "f" simply means that this constant is a float, not a double. You have to supply the "alpha" value here. Just use 1 for no transparency. |
You should experiment with ambient lighting as well. Note that by default the first light has a diffuse color of white and no ambient color. Look up the glLightfv function to see how to set light colors. Try a differently color light and ambient lighting to see what happens.
You can set the specular properties if you like, but they won't do much, yet.
Just for fun, let's light a slightly more complex surface. I've added to your application the code to draw a human liver. This is not necessarily my liver, though it is similar. It is not a functional liver, though it does have an approximately correct appearance. Add a new menu with options: Box, Liver, and Sphere. Use an enum variable to determine which thing you are going to draw:
enum {BOX, LIVER, SPHERE} m_todraw;
Add this line to the CChildView constructor:
m_todraw = BOX;
Then use your menu options to select the object we will draw. As an example, the event handler for the Box menu option might look like this:
void CChildView::OnDrawBox()
{
m_todraw = BOX;
Invalidate();
}
Sphere will be added later, but for now, when the LIVER option is selected, we want to call DrawLiver(). This object is much better for illustrating how lighting works. In OnGlDraw function change the call to Box and the existing material setting to this switch statement:
GLfloat msugreen[] = {0.f, .47f, .2f, 1.f};
GLfloat liverred[] = {0.854f, .427f, .314f, 1.f};
switch(m_todraw)
{
case BOX:
glMaterialfv(GL_FRONT, GL_DIFFUSE, msugreen);
Box(5, 5, 5);
break;
case LIVER:
glMaterialfv(GL_FRONT, GL_DIFFUSE, liverred);
DrawLiver();
break;
}
| In case you are wondering where the liver came from, it's a 3D Studio Max object that I have converted to OpenGL using a program called Deep Exploration. This program converts graphics formats and can actually convert to OpenGL code in some cases, though the result is often a bit buggy. |
Graphics systems nearly always simulate a curved surface using polygons. Even if some fancy curved surface primitive seems to be available, underneath everything is usually polygons. The simulation of a surface using lots of small polygons is called tessellation. We're going to create a tessellated sphere using triangulation.
Add the two following functions to your program:
void CChildView::Sphere(double p_radius)
{
GLdouble a[] = {1, 0, 0};
GLdouble b[] = {0, 0, -1};
GLdouble c[] = {-1, 0, 0};
GLdouble d[] = {0, 0, 1};
GLdouble e[] = {0, 1, 0};
GLdouble f[] = {0, -1, 0};
int recurse = 1;
SphereFace(recurse, p_radius, d, a, e);
SphereFace(recurse, p_radius, a, b, e);
SphereFace(recurse, p_radius, b, c, e);
SphereFace(recurse, p_radius, c, d, e);
SphereFace(recurse, p_radius, a, d, f);
SphereFace(recurse, p_radius, b, a, f);
SphereFace(recurse, p_radius, c, b, f);
SphereFace(recurse, p_radius, d, c, f);
}
void CChildView::SphereFace(int p_recurse, double p_radius, GLdouble *a,
GLdouble *b, GLdouble *c)
{
glBegin(GL_TRIANGLES);
glNormal3dv(a);
glVertex3d(a[0] * p_radius, a[1] * p_radius, a[2] * p_radius);
glNormal3dv(b);
glVertex3d(b[0] * p_radius, b[1] * p_radius, b[2] * p_radius);
glNormal3dv(c);
glVertex3d(c[0] * p_radius, c[1] * p_radius, c[2] * p_radius);
glEnd();
}
Be sure to all add these lines to the CChildView class definition:
void Sphere(double p_radius);
void SphereFace(int p_recurse, double p_radius, GLdouble *a, GLdouble *b, GLdouble *c);
Add the ability under menu control to call: Sphere(5.). I'm going to draw a sphere of radius 5.
When you run this, it should be obvious that what it's really drawing is two pyramids attached at the base. But, the 6 vertices are all on the surface of the sphere. You can probably guess that we're going to do something with recursion here. What is being passed to SphereFace is not a vertex, but rather a vector pointing in the direction of the point on the sphere we want to use as a vertex. If you multiply the normal by the radius, you get the vertex.
Also note that I provide a vertex normal for every vertex, not one for each surface. The idea is that we want OpenGL to compute the color at the vertex as if that were a point on the surface of the sphere. So, a normal at each point on the sphere will be a vector pointing from the center of the sphere to the vertex. We have that already, that's what we passed to SphereFace. OpenGL will then interpolate the color over the face of the triangle. The idea is that we provide the information to make the color correct at the vertex and OpenGL fills in in between to make the rest of the sphere the right color.
Clearly, we don't have a sphere, yet. You should see this instead:

But, suppose we took the triangle passed to SphereFace and broke it into 4 triangles like this:

We started with one big triangle and end up with four smaller ones. If we had a vector pointing at a and a vector pointing at b, then a vector pointing at f would be half way between them. We can compute this by simple averaging.
Add this code to the beginning of SphereFace:
if(p_recurse > 1)
{
// Compute vectors halfway between the passed vectors
GLdouble d[3] = {a[0] + b[0], a[1] + b[1], a[2] + b[2]};
GLdouble e[3] = {b[0] + c[0], b[1] + c[1], b[2] + c[2]};
GLdouble f[3] = {c[0] + a[0], c[1] + a[1], c[2] + a[2]};
Normalize3(d);
Normalize3(e);
Normalize3(f);
SphereFace(p_recurse-1, p_radius, a, d, f);
SphereFace(p_recurse-1, p_radius, d, b, e);
SphereFace(p_recurse-1, p_radius, f, e, c);
SphereFace(p_recurse-1, p_radius, f, d, e);
}
You'll need a function to normalize the vectors. Add this anywhere in your code outside of a function and before SphereFace:
inline void Normalize3(GLdouble *v)
{
GLdouble len = sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
v[0] /= len;
v[1] /= len;
v[2] /= len;
}
I made this an inline function so it will be very fast. Be sure to define it before you use it.
When you first run this, it won't do anything different. Change the value of recurse in Sphere to 2 and run it. Then try a value of 5. Try a value of 10. Why does it go so slow? Try the values between 2 and 5 to see the quality get better and better.
Be sure you understand how each face is being subdivided. You should be able to draw this on paper.
To get a better idea of what is happening, try turning off all but one of the SphereFace calls in Sphere and run the program with recurse values from 1 to 5.
You will probably notice that the outline is clearly lines, but the surface looks very smooth. This is because the surfaces are not being drawn with a single color, but rather shaded smoothly. Try adding the following line before the call to Sphere:
glShadeModel(GL_FLAT);
Flat shading only uses one color for a polygon face. Notice the difference? When smooth shading is turned on, the color is computed at each vertex and smoothly interpolated over the surface of the polygon. This nicely simulates curved surfaces.
When done, remove the glShadeModel line and set the recursion to 5.
Clearly, this is only an approximation of the sphere. The error between a perfect sphere and our approximation is called geometric aliasing. We choose how much geometric aliasing we will have based on a tradeoff between quality and performance. Less aliasing requires more polygons and, therefore, more time. recurse of 10 draws 1,048,576 triangles, but has very little aliasing.
Let's make your sphere shiny. Add the following material properties before you define the sphere. You can put this at the beginning of the CChildView::Sphere() function:
GLfloat white[] = {0.8f, 0.8f, 0.8f, 1.0f};
GLfloat cyan[] = {0.f, .8f, .8f, 1.f};
glMaterialfv(GL_FRONT, GL_DIFFUSE, cyan);
glMaterialfv(GL_FRONT, GL_SPECULAR, white);
GLfloat shininess[] = {50};
glMaterialfv(GL_FRONT, GL_SHININESS, shininess);
Run this and see what you get. Try varying the shininess value up and down. Note the bright white dot. This is called a "specular highlight". Look at curved surfaces around you to see natural examples. The size of the highlight depends on the shininess. More shiny means a smaller highlight.
Try spinning the sphere. Because it's completely symmetrical, you would expect to not see the spin at all. If you use a recurse value less than 7, you probably see quite a bit of difference as it spins, though. Geometric aliasing that may not be visible when something is still can become visible when it moves. This is called Temporal Aliasing. The computations for the colors vary as the angle of the sphere face is changed. This is a common problem in almost all animation.
The last thing I would like you to do in this step is to add a second light (GL_LIGHT1). Note that lights other than LIGHT0 have default colors of black and you will have to set the specular and diffuse color for the light. If you put the light at a different location, you should see two specular highlights on your sphere. You'll have to look up how to set the color for the light.
There are several ways to compute normals. In come cases, they are obvious from the characteristics of the surface. This is true for the box and the sphere in this step. But, what if I gave you the following completely arbitrary object:

This object can be found in the function DrawFunky(). Create a menu option to draw this, though you won't be able to tell much about it yet, since there not any valid code for supplying normals, yet. Let's compute a normal for the front face. A normal is orthogonal to the surface. This means the normal must be at right angles to the surface. We know the three vertices for the front face:
GLdouble a[] = {5, 5.5, 2};
GLdouble b[] = {-3.7, 2, -3.3};
GLdouble c[] = {3.5, -2.3, 1.1};
If the normal is at right angles to the face, it must also be at right angles to the edges of the face. Let's compute two vectors: ab and ac:
Vector ab = b - a = (-3.7, 2, -3.3) - (5, 5.5, 2) = (-8.7, -3.5, -5.3)
Vector ac = c - a = (3.5, -2.3, 1.1) - (5, 5.5, 2) = (-1.5, -7.8, -0.9)

You can compute a vector that is orthogonal to two other vectors using the cross product. The definition of a cross product of two vectors is:
(x1, y1, z1) x (x2, y2, z2) = (y1 z2 - y2 z1, z1 x2 - z2 x1, x1 y2 - x2 y1)
So, the cross product will be: o = ab x ac = (-8.7, -3.5, -5.3) x
(-1.5, -7.8, -0.9) =
(-3.5 * -0.9 - -5.3 * -7.8, -5.3 * -1.5 - -8.7 * -0.9,
-8.7 * -7.8 - -3.5 * -1.5) = (-38.19, 0.12, 62.61)
Now, we need to normalize this vector to make it a valid OpenGL Normal. I compute the length as 73.34. Dividing each term by the length, I get a normalized vector of (-0.521, 0.002, 0.854). This is the normal for the surface.
BTW, I created a quick Excel spreadsheet to compute these values.
| There are actually two normalized vectors orthogonal to
this surface. The negative of the computed normal would also be
orthogonal to the surface. It turns out to be what we get if we do
ac x ab (reversing the terms). We determine the one to use by using
the right-hand rule: If you point your thumb in the direction of the
normal, your figures will be in the direction you should supply the
vectors to the cross product. This figure illustrates the idea:
|
If I want the normal for the face consisting of the vertices a, d, b, I would compute ad x ab and normalize.
Provide the correct vertex normals for the entire Funky figure.

กก