Tutorial 3: Introduction to Lighting

This tutorial will focus on how to utilize lighting in OpenGL.  I've provided a template for this tutorial that you'll use.  It's in tutorial3.zip.    

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 have been placed on the class home page if you want to add it to other applications. 

Enabling Light 0

OpenGL supports a fixed number of lights.  The number that are available depends on the implementation, but will always be at least 3.  Go to the line after glEnable(GL_CULL_FACE); and add the following:

// Enable lighting

When you compile, you'll discover that this does not really do anything different.  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 at the vertices only for efficiency reasons.  

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 Normal3dv(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.

The front face of the cube is defined in my code like this:

// Front

A normal would be the direction that is up if you were standing on the visible surface.  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:

   glNormal3d(0, 0, 1);

Add this line for each of the 6 faces.  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 because they are parallel with coordinate axis.  Select the spin option in the Lab stuff menu to spin your lit cube.

Note:  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).

Setting the Light Position

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, 1., 1., 0.};
glLightfv(GL_LIGHT0, GL_POSITION, lightpos);

This says that a light exists in the direction (0.5, 1., 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. 

Note that 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 and see what happens when you spin the cube.  Move it back when you are done.

Setting Material Properties

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 cyan[] = {0.f, .8f, .8f, 1.f};
glMaterialfv(GL_FRONT, GL_DIFFUSE, cyan);

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.8 will be a double and you'll get an initializer error.  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.

The Liver

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;

Then use your menu options to select the object we will draw.  Sphere will be added in the next step, but for now, when the LIVER option is selected, call DrawLiver().  This object is much better illustrate how lighting works.

Checkpoint 2:  At this point, please demonstrate that you have your lit liver working.

In case you are wondering where the liver came from, it's a 3D Studio Max object that I have converted to OpenGL using 3D Exploration.  This program (now replaced with "Deep Exploration") converts graphics formats.

Tessellating a Sphere

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)
      glVertex3d(a[0] * p_radius, a[1] * p_radius, a[2] * p_radius);
      glVertex3d(b[0] * p_radius, b[1] * p_radius, b[2] * p_radius);
      glVertex3d(c[0] * p_radius, c[1] * p_radius, c[2] * p_radius);

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.

Clearly, we don't have a sphere, yet.  But, suppose we took the triangle passed to SphereFace and broke it into 4 triangles.  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]};


   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:

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 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:


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.  

Geometric Aliasing

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.

Specular Reflection

Let's make your sphere shiny.  Add the following material properties before you define the sphere:

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.

At this point, please demonstrate that you have your sphere working.

Temporal Aliasing

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.  

Adding Another Light

The last thing I would like you to do in this tutorial 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.