OpenGL:Tutorials:Virtualized Lights with OpenGL and GLSL

From GPWiki
Jump to: navigation, search

Before You Begin

It is important to know that this tutorial assumes basic understanding of C++, OpenGL, GLSL, shaders, as well as the math used to calculate Lambertian-based lighting! It is also assumed that you have already created functions for calling shaders, and dealing with uniform variables.


Introduction

OpenGL provides the user with eight available enumerated lights, each with its own properties such as position, diffuse contribution, and attenuation values. On today’s advanced hardware, the maximum number of lights the GPU can process in hardware often stretches beyond one thousand (the actual number can be determined by querying the graphics adapter to return GL_MAX_LIGHTS). When playing a game such as Half-Life, you can easily see that there are more than eight lights being displayed on the screen. To take advantage of the power of modern GPU’s, OpenGL’s lights must be virtualized, rendering eight at a time, creating the illusion of thousands of lights being rendered at once.


Creating The Lighting Class

Before getting into the actual lighting aspect of all this, we first must create a base class, to manage the virtualized lights.
lighting.h:

  #define MAX_LIGHTS 1000
 
  typedef struct
  {
    	  float pos[4];
 
    	  float diffuse[4];
    	  float ambient[4];
 
    	  float constantAttenuation;
    	  float linearAttenuation;
    	  float quadraticAttenuation;
 
     	  float brightness;
  } lightdata, *ld;
 
  class lighting
  {
  public:
  	  lighting();
  	  virtual ~lighting();
 
  	  char createlight(float x, float y, float z,
  		           float Dr, float Dg, float Db,
  		           float Ar, float Ag, float Ab,
  		           float cA, float lA, float qA);
  	  void dolighting(obj objectP);
  };
 
  extern lightdata lightP[MAX_LIGHTS];

We first define the maximum number of virtual lights with #define MAX_LIGHTS. Here we set it to 1000, but you can change this to whatever you want to suit your purpose. Next we create a struct. This will store all the information about any given light. If you look closer at the structure (which I’ve named “lightdata”), there is a floating-point variable called “brightness”. This will be used later to store the brightness or priority of the light, when deciding which lights to render.

The next thing we do is create a class, called “lighting”. Within the class declaration, function prototypes are created and declared as “public”. If you look at the function dolighting() you’ll see that it takes an argument of type “obj”. obj is a structure I use in my own engine that holds information about objects, the same way our structure holds information about lights. You can replace this argument with a pointer to your own object class, or remove it all together.

The last thing we do is define an array called “lightP” with our light struct. The array’s size is that of MAX_LIGHTS.


Coding The Functions

lighting.cpp

lightdata lightP[MAX_LIGHTS];
int num_lights = 0;
 
int lightBrightnessCompare(const void *a, const void *b)
{
lightdata *ld1 = (lightdata *) a;
lightdata *ld2 = (lightdata *) b;
GLfloat diff;
 
/* The brighter lights get sorted close to top of the list. */
diff = ld2->brightness - ld1->brightness;
 
if (diff > 0)
  return 1;
if (diff < 0)
  return -1;
return 0;
}
 
char lighting::createlight(float x, float y, float z,
		float Dr, float Dg, float Db,
		float Ar, float Ag, float Ab,
		float cA, float lA, float qA)
{
lightP[num_lights].pos[0] = x;
lightP[num_lights].pos[1] = y;
lightP[num_lights].pos[2] = z;
lightP[num_lights].pos[3] = 1.0f;
 
lightP[num_lights].diffuse[0] = Dr;
lightP[num_lights].diffuse[1] = Dg;
lightP[num_lights].diffuse[2] = Db;
lightP[num_lights].diffuse[3] = 1.0f;
 
lightP[num_lights].ambient[0] = Ar;
lightP[num_lights].ambient[1] = Ag;
lightP[num_lights].ambient[2] = Ab;
lightP[num_lights].ambient[3] = 1.0f;
 
lightP[num_lights].constantAttenuation = cA;
lightP[num_lights].linearAttenuation = lA;
lightP[num_lights].quadraticAttenuation = qA;
 
num_lights++;
return (1);
}
 
void lighting::dolighting(obj objectP)
{
	// Calculate each lights brightness based on it's position, the object position and the eye position.
	for(int i=0; i<num_lights; i++)
	{
		float dx = lightP[i].pos[0] - objectP->pos.x;
		float dy = lightP[i].pos[1] - objectP->pos.y;
		float dz = lightP[i].pos[2] - objectP->pos.z;
 
		float quadraticAttenuation = dx * dx + dy * dy + dz * dz;
		//lightP[i].brightness = -quadraticAttenuation;
 
		// -- DETERMINE LIGHT CONTRIBUTION -- // 
		GLfloat ex, ey, ez;
		GLfloat nx, ny, nz;
		GLfloat distance;
		GLfloat diffuseReflection;
 
		// Determine eye point location (remember we can rotate by angle).
		ex = View.x;
		ey = View.y;
		ez = View.z;
 
		// Calculated normalized object to eye position direction (nx,ny,nz).
		nx = (ex - objectP->pos.x);
		ny = (ey - objectP->pos.y);
		nz = (ez - objectP->pos.z);
		distance = sqrt(nx * nx + ny * ny + nz * nz);
		nx = nx / distance;
		ny = ny / distance;
		nz = nz / distance;
 
		// True distance needed, take square root.
		distance = sqrt(quadraticAttenuation);
 
		// Calculate normalized object to light position direction (dx,dy,dz)
		dx = dx / distance;
		dy = dy / distance;
		dz = dz / distance;
 
		diffuseReflection = nx * dx + ny * dy + nz * dz;
		lightP[i].brightness = diffuseReflection / quadraticAttenuation;  
	}
	// -- SORT LIGHTS, AND ASSIGN TO HARDWARE -- //
	qsort(lightP, num_lights, sizeof(lightP[0]), lightBrightnessCompare);  
 
	for(int j=0; j<3; j++)
	{
		if(!_ceFrustum.PointInFrustum(lightP[j].pos[0], lightP[j].pos[1], lightP[j].pos[2]))
		{
			j++;
		}
 
		glLightfv(GL_LIGHT0+j, GL_POSITION, lightP[j].pos);
		glLightfv(GL_LIGHT0+j, GL_DIFFUSE, lightP[j].diffuse);
		glLightfv(GL_LIGHT0+j, GL_AMBIENT, lightP[j].ambient);
		glLightf(GL_LIGHT0+j, GL_CONSTANT_ATTENUATION, lightP[j].constantAttenuation);
		glLightf(GL_LIGHT0+j, GL_LINEAR_ATTENUATION, lightP[j].linearAttenuation);
		glLightf(GL_LIGHT0+j, GL_QUADRATIC_ATTENUATION, lightP[j].quadraticAttenuation);
 
	}
}

Before getting into any functions, we re-declare “lightP” (it was defined as external in the header), and define “num_lights” and set it to zero. This variable will be used to keep track of how many lights, and what lights have been created.

Our first function, lightBrightnessCompare(), is a function used by qsort() to sort our array of lights in order from brightest to dimmest. The function follows the format usually required by qsort() (do a Google search or search MSDN for examples using the qsort() function).

The next function we use is called “createlight()”. This function sets the variables of a single light, with the arguments specified by the function prototype. The light set is determined by the variable set earlier, num_lights. This variable is used as the array index number for lightP, our pointer to the lightdata structure. After all the variables have been set, num_lights is increased by one so that the next time the function is called, lightP’s array index number is different.

The last function used is called “dolighting()”. This function contains quite a bit more code than the others. The function begins by setting a “for loop”, that cycles through all the lights, where i<num_lights. The quadratic attenuation is then calculated with the object to light vector. Next the diffuse component is calculated. I’m not going to regurgitate the math for this here. If you’re looking for a tutorial on lighting math, try doing a Google search. There are literally thousands of resources on 3d math. All you need to know for the scope of this tutorial, is that brightness = diffuseComponant / quadraticAttenuation. After the brightness of the light is determined, qsort() is then called, sorting the lights created so far in order from brightest to dimmest, using our lightBrightnessCompare() function we created earlier. Finally, we set another for loop in motion, where j<3. This “for loop” is used to cycle through the virtual lights. The values for the light are set using glLightfv(). These values will be used using built-in lighting functions in the GLSL shader. It is important to remember that we do not need to enable the light or lighting with glEnable(), because we are using shaders!


Creating The GLSL Shaders

There’s only a bit more code to sift through before we’re done. The last thing we need to do is create our shaders. These are lambertian.vert and lambertian.frag
lambertian.vert

varying vec3 N;
varying vec3 v;
varying vec2 varTexCoord;
 
void main(void)
{
 v = vec3(gl_ModelViewMatrix * gl_Vertex);
 
 N = normalize(gl_NormalMatrix * gl_Normal);
 
 gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
 
 varTexCoord = vec2(gl_MultiTexCoord0);
}

I’m not going to explain what this code does, as it is fairly straightforward, and I do not intend to teach you GLSL with this tutorial. All it does is define the view vector, vertex normals, and sets texture coordinates.

lambertian.frag

uniform sampler2D baseMap;
 
varying vec3 N;
varying vec3 v;
varying vec2 varTexCoord;
 
vec4 light0 ()
{
vec4 color;
 
 
vec3 L = normalize(gl_LightSource[0].position.xyz - v); 
vec3 E = normalize(-v); // we are in Eye Coordinates, so EyePos is (0,0,0)
vec3 R = normalize(-reflect(L,N)); 
 
float dist = length(L);
 
float atten = 1.0 / (gl_LightSource[0].constantAttenuation +
			gl_LightSource[0].linearAttenuation * dist +
			gl_LightSource[0].quadraticAttenuation * dist * dist);
 
//calculate Ambient Term:
vec4 Iamb = gl_LightSource[0].ambient*gl_FrontMaterial.ambient;
 
//calculate Diffuse Term:
vec4 Idiff = (gl_LightSource[0].diffuse * max(dot(N,L), 0.0))*gl_FrontMaterial.diffuse;
 
// calculate Specular Term:
vec4 Ispec = gl_FrontMaterial.specular * pow(max(dot(R,E),0.0),0.3*gl_FrontMaterial.shininess);
 
color = gl_FrontMaterial.emission+Iamb+atten*(gl_FrontMaterial.ambient + Idiff + Ispec);
return color;
}
 
vec4 light1 ()
{
vec4 color;
 
 
vec3 L = normalize(gl_LightSource[1].position.xyz - v); 
vec3 E = normalize(-v); // we are in Eye Coordinates, so EyePos is (0,0,0)
vec3 R = normalize(-reflect(L,N)); 
 
float dist = length(L);
 
float atten = 1.0 / (gl_LightSource[1].constantAttenuation +
			gl_LightSource[1].linearAttenuation * dist +
			gl_LightSource[1].quadraticAttenuation * dist * dist);
 
//calculate Ambient Term:
vec4 Iamb = gl_LightSource[1].ambient*gl_FrontMaterial.ambient;
 
//calculate Diffuse Term:
vec4 Idiff = (gl_LightSource[1].diffuse * max(dot(N,L), 0.0))*gl_FrontMaterial.diffuse;
 
// calculate Specular Term:
vec4 Ispec = gl_FrontMaterial.specular * pow(max(dot(R,E),0.0),0.3*gl_FrontMaterial.shininess);
 
color = gl_FrontMaterial.emission+Iamb+atten*(gl_FrontMaterial.ambient + Idiff + Ispec);
return color;
}
 
vec4 light2 ()
{
vec4 color;
 
 
vec3 L = normalize(gl_LightSource[2].position.xyz - v); 
vec3 E = normalize(-v); // we are in Eye Coordinates, so EyePos is (0,0,0)
vec3 R = normalize(-reflect(L,N)); 
 
float dist = length(L);
 
float atten = 1.0 / (gl_LightSource[2].constantAttenuation +
			gl_LightSource[2].linearAttenuation * dist +
			gl_LightSource[2].quadraticAttenuation * dist * dist);
 
//calculate Ambient Term:
vec4 Iamb = gl_LightSource[2].ambient*gl_FrontMaterial.ambient;
 
//calculate Diffuse Term:
vec4 Idiff = (gl_LightSource[2].diffuse * max(dot(N,L), 0.0))*gl_FrontMaterial.diffuse;
 
// calculate Specular Term:
vec4 Ispec = gl_FrontMaterial.specular * pow(max(dot(R,E),0.0),0.3*gl_FrontMaterial.shininess);
 
color = gl_FrontMaterial.emission+Iamb+atten*(gl_FrontMaterial.ambient + Idiff + Ispec);
return color;
}
 
void main (void)
{
// write Total Color:
gl_FragColor = (light0()+light1()+light2())*texture2D(baseMap, varTexCoord);
}

You may be wondering why there are three separate lighting functions: light0(), light1(), and light2(). This is done for two reasons. One is that current drivers will not let you use a variable index with gl_LightSource without first declaring with a size. The second reason is that with current drivers, you can’t declare gl_LightSource. This means that the lighting index must be hard-coded.

There is also one other issue. In the introduction, I explained how OpenGL provides eight enumerated lights. You may have noticed that we only use three in our lighting for loops, and three in our shader. This is because of the driver issues mentioned above. Theoretically, you could just add another 5 functions exactly like the three already included in the shader; however, current hardware (or at least the hardware I’ve tested on) only allows three lighting calculation functions. Beyond that, it exceeds the allowed number of GPU ALU instructions, and attempts to run the shaders in software, which is ridiculously slow. While this does seem to present a problem, it isn’t all that bad, as will be explained below.


Putting It All Together

Now that you’ve learned about how to create these functions and shaders, you now need to know how to use them properly. To create a light, you simply call createlight(), and set the arguments. The dolighting() function is a little different. This function is called on a per-object basis. For example, if you’re looping through all your objects to draw them, at the end of the loop you would call dolighting(), since this function requires knowledge of an arbitrary object position. This means that each object can be illuminated by three lights (or up to eight, once the earlier mentioned driver issues are solved).


Build On It

Try and think of ways to improve and expand on the material covered here. One way the method could be improved, is to further cull lights that can’t be seen (such as a light behind a wall) with occlusion culling. If a light that can’t be seen is rendered, it tends to throw off the lights that should be rendered, creating weird lighting effects.


External Links

http://www.clockworkcoders.com/oglsl/tutorial5.htm (per-pixel lighting with GLSL)