Lab 3: Shader programming in OpenGL

WARNING: OLD LAB. THIS IS WRITTEN FOR OpenGL 2, AND HAS BEEN REPLACED BY MODERNIZED MATERIAL.

Goal: In this lab, you will try your hands at shader programming in OpenGL. You will write both vertex shaders (which calculate vertex positions for primitives) and fragment shaders (which calculate the final colour value for pixels). You will also implement the Phong lighting model.

If you run into problems, there are several resources. You have your textbook.OpenGL Reference Pages describe all the OpenGL API functions. OpenGL Shading Language Specification (found via http://www.opengl.org/documentation/specs/, "OpenGL Shading Language Specification") describes GLSL.


1) Setup

If you haven't done so already, download and unpack all packages required for lab 1 and lab 2.

Download the lab package from lab2008-3.tar.gz and unpack it at a suitable location.

To compile this assignment, perform make lab3-1 on the command line.

Run the assignment by performing ./lab3-1. It should show a grey teapot. This teapot is rendered using shaders written in the GLSL language.

Try pressing F1-F4. These keys enable/disable four corresponding lightsources. Currently, the lightsources do not affect the teapot. (You will implement that support later in the lab.) The first lightsource is a directional light, while the three coloured lightsources are point lights.

Try using the mouse:
Left mouse button and movement rotates the object.
Right mouse button and up/down moves the object back/forth along the Z axis.
Middle mouse button and movement rotates all the lights.

Open lab3-1.c and have a look inside init(). You will find three new function calls there:

  GLuint vertexShader = createShaderFromFile(GL_VERTEX_SHADER, "lab3-1.vs");
GLuint fragmentShader = createShaderFromFile(GL_FRAGMENT_SHADER, "lab3-1.fs");
shaderProgram = createShaderProgram(vertexShader, fragmentShader);

createShaderFromFile() attempts to load in a text file and create a vertex/fragment shader from its contents.
createShaderProgram() attempts to create a shader program from a vertex shader/fragment shader pair. The shader program is subsequently activated inside display() by performing glUseProgram(shaderProgram).


2) A short introduction to GLSL

Goal: To give an overview of GLSL, vertex shaders and fragment shaders. Use this section as a reference when completing subsequent assignments.

GLSL

GLSL code is similar to C code, but with a strong emphasis on computation.

Most GLSL code performs floating-point calculations. Common datatypes used are float, vec2, vec3 and vec4. These datatypes represent 1D, 2D, 3D and 4D vectors. Arithmetic operations can be performed directly on these datatypes.
For integer calculations (such as counting loop iterations), int is available. The bool datatype is also available.

A small GLSL function can look like:

vec4 applyDirectionalLight(vec3 normal, vec4 originalColor)
{
vec3 lightDirection = normalize(vec3(0.5, 0.8, 0.7));
float strength = dot(lightDirection, normal);
if (strength < 0.0)
strength = 0.0;
vec4 color = originalColor.xyxx * strength;
return color;
}

vec3(0.5, 0.8, 0.7) constructs a new vec3 from three floating-point values.
dot() calls a predefined math function.
originalColor.xyxx performs "swizzling" on the original vector: the result is a vec4 whose XYZW elements are taken from the X, Y, X and X elements of originalColor, respectively.

You can find a complete list of built-in mathematical functions in the GLSL Language Specification.

GLSL and shaders

Vertex shaders perform per-vertex calculations. That's where vertices are transformed, per-vertex lighting calculations are done, and where skeletal animation systems do most of their work.
Fragment shaders perform per-pixel calculations. That's where texture and lighting colours are combined into one final pixel colour value.

The code for a shader program is enclosed inside the main() function. It takes no arguments, and returns nothing. Communications between OpenGL, the vertex shader and the fragment shader is done by reading/writing global variables.

Variables can have a few different qualifiers:
const - the value is specified at compile-time; it is read-only for OpenGL, vertex and fragment shaders.
uniform - the value is constant over an entire polygon; it is read/write for OpenGL, and read-only for fragment and vertex shaders.
varying - the value will be interpolated over the surface of a polygon; read/write for OpenGL and vertex shaders, read-only for fragment shaders.
attribute - a per-vertex attribute; OpenGL supplies this (typically from a vertex/normal/texcoord array). Read-only for vertex and fragment shaders.

All variables whose names begin with "gl_" are predefined by OpenGL. These are always present, and they can be used without declaring them first.

Here are some variables that available for reading in both vertex and fragment shaders:

Type

GLSL symbol name

Meaning

uniform mat4

gl_ModelViewMatrix

Contains the GL_MODELVIEW matrix

uniform mat4

gl_ProjectionMatrix

Contains the GL_PROJECTION matrix

uniform mat3

gl_NormalMatrix

Similar to gl_ModelViewMatrix, but used for transforming normals

uniform vec4

gl_LightSource[i].position

GL_POSITION setting for GL_LIGHTi

uniform vec4

gl_LightSource[i].ambient

GL_AMBIENT setting for GL_LIGHTi

uniform vec4

gl_LightSource[i].diffuse

GL_DIFFUSE setting for GL_LIGHTi

uniform vec4

gl_LightSource[i].specular

GL_SPECULAR setting for GL_LIGHTi

The vertex shader is called once for every vertex that has been queued up for rendering via glBegin()/glEnd(), glDrawElements() or any other mechanism. It can read the current vertex attributes from the following global variables:

Type

GLSL symbol name

Meaning

attribute vec4

gl_Color

Vertex colour

attribute vec3

gl_Normal

Vertex normal

attribute vec4

gl_Vertex

Vertex position

attribute vec4

gl_MultiTexCoord0

Vertex texture coordinate

... and it can output a transformed and projected vertex, along with its attributes to:

Type

GLSL symbol name

Meaning

vec4

gl_Position

Transformed and projected vertex position (must always be written)

varying vec4

gl_FrontColor

Vertex colour value

varying vec4

gl_TexCoord[i]

Texture coordinate set i

OpenGL will take the output from the vertex shader, interpolate the resulting values over the surface of any neighboring polygons, and then run the fragment shader once for every pixel which the polygon is supposed to render to. Any extra varying variables in the vertex shader will also be interpolated over the polygon, and the result is available to the fragment shader.

Here is a subset of variables which the fragment shader can read:

Type

GLSL symbol name

Meaning

vec4

gl_FragCoord

Screen coordinates for the fragment

varying vec4

gl_Color

Interpolated result from vertex shader's gl_FrontColor output

varying vec4

gl_TexCoord[i]

Interpoolated result from vertex shader's gl_TexCoord[i] output

The fragment shader can read these items, any extra varying variables, sample from textures, etc, and must then output a color value to the following variable:

Type

GLSL symbol name

Meaning

vec4

gl_FragColor

Final pixel colour (must be written!)

You can find a full list of pre-defined variables in the GLSL Language Specification.


3) Vertex shaders

Copy lab3-1.* to lab3-3.*. Update lab3-3.c to load in lab3-3.vs and lab3-3.fs. Make this section's changes to lab3-3.*.

Goal: To inspect what role the vertex shader has in the rendering pipeline; to deform the mesh in the vertex shader.

Open lab3-3.vs. The vertex shader reads gl_Vertex and gl_Normal. These values are supplied from the object's vertex/normal arrays.

Introduce a typographical error to the code. Build and run; ensure that the test program will abort with an error message. Remove the error.

Make the object 1/10th as wide as it used to be, by applying a scale along the object's local X axis. Also, add on a slight deformation which you calculate using the sin() function.

Questions:

How do you scale along the model's coordinate axes? How do you scale along a view coordinate axes?
What is the purpose of the gl_FrontColor = transformedNormal.zzzz statement?
Why is there a separate gl_NormalMatrix for normals? Shouldn't gl_ModelViewMatrix be good for transforming normals as well?


4) Sending constants from the application to the shaders

Copy lab3-3.* to lab3-4.*. Update lab3-4.c to load in lab3-4.vs and lab3-4.fs. Make this section's changes to lab3-4.*.

Goal: To send constants from the OpenGL application to shaders. To add time-based animation to the vertex shader.

First, you need to add the variable to the shader program. Create a new global variable in your vertex shader:

uniform float time;

The new variable can now be written to from the OpenGL application. Open lab3-4.c, and when the application has performed createShaderProgram(), do:

    shaderTimeLocation = getUniformLocation(shaderProgram, "time");

getUniformLocation will return an identifier for the variable, or -1 if time is not found within the shader program. Create a global variable that holds the location.

After the shader program has been activated with glUseProgram(), update the shader's time variable by performing:

    glUniform1f(shaderTimeLocation, getElapsedTime());

The variable time will now be increasing at a steady pace. Use the value of time in your vertex shader to make the sin()-based deformation move slowly along the teapot.

Questions:

How do you increase/decrease the speed at which the waves move along the teapot?


5) Per-pixel diffuse lighting

Copy lab3-4.* to lab3-5.*. Update lab3-5.c to load in lab3-5.vs and lab3-5.fs. Make this section's changes to lab3-5.*.

Goal: To interpolate data that is generated in the vertex shader over the polygons; to perform correct diffuse per-pixel lighting in the fragment shader.

Remove the scaling and deformation from the vertex shader. Also, remove the write to gl_FrontColor.

Provide a surface in every pixel by copying the vertex normal into a varying vec3 in the vertex shader. Then, in the fragment shader, use the surface normal to compute a dot product against a lightsource of your choosing. (gl_LightSource[0].position is a good first choice.) Use gl_LightSource[0].diffuse to tint the final output color.

Since gl_FrontColor no longer is written in the vertex shader, gl_Color is now undefined in the fragment shader.

Add support for the other three lights as well. Lights 1-3 are positional; this is signalled by position.w being non-zero.

Note that when a lightsource is disabled, its position is vec4(0.0, 0.0, 0.0, 0.0).

Questions:

How do you efficiently check for a null-vector in GLSL?
How do you compute a light direction vector from the surface to the lightsource, when the light is positional?
How does a lightsource contribute to the lighting on the opposite side of the mesh? Do you have any special logic for handling that properly?


6) Per-pixel specular lighting

Copy lab3-5.* to lab3-6.*. Update lab3-6.c to load in lab3-6.vs and lab3-6.fs. Make this section's changes to lab3-6.*.

Goal: To add per-pixel specular lighting computations to the fragment shader.

Light 1 has a nonzero specular colour. Implement specular lighting calculations for that light (optional: implement it for all lights). To calculate lighting according to the Phong light model, you will need to form all vectors required by the Phong model in every pixel: the surface normal, a vector from the surface to the lightsource (and subsequently a reflected vector), and a vector from the surface to the eye.

Once the specular lighting calculations are in place, you have successfully implemented the Phong lighting model.

Questions:

Perhaps you made some approximations, such as calculating a half-vector. Which approximations is your implementation using? Why did you do those approximations?
How do you generate a vector from the surface to the eye?
How can you implement specular reflections for a directional lightsource, even though the lightsource itself does not have any explicit position?
Which vectors need renormalization in the fragment shader?


7) Texturing

Copy lab3-6.* to lab3-7.*. Update lab3-7.c to load in lab3-7.vs and lab3-7.fs. Make this section's changes to lab3-7.*.

Goal: To add texturing to the fragment shader.

Open lab3-7.fs and add the following global variable:

uniform sampler2D texture;

That variable acts as a placeholder for a texture. In order to sample from the texture, perform this at the end of your shader:

gl_FragColor = texture2D(texture, gl_TexCoord[0].xy);

This will overwrite the result from the lighting calculations with a colour fetched from the texture. However, the texture lookup is referring two variables which have not been supplied yet: the texture, and the texture coordinates.

Open lab3-7.vs. Here, you should forward the data from gl_MultiTexCoord0 to gl_TexCoord[0].

Secondly, the OpenGL application needs to supply a texture to the shader. Load in a texture during init(). Around the time when you perform glUseProgram(), activate the texture by performing:

  glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, textureId);

This enables the textures as texture stage 0. (OpenGL supports multiple texture stages.)

Next, go back to init() again. After the shaderTimeLocation has been located, perform the following operation:

  shaderTextureLocation = getUniformLocation(shaderProgram, "texture");

Finally, head down to where you used to set the time variable in the shaders. Add the following line of code:

  glUniform1i(shaderTextureLocation, 0);

That call associates the texture sampler with texture stage 0. With all this in place, you should now have a texture displayed on your teapot.

Combine the results of the lighting with the results from the texture lookup somehow.

Load a second texture. Apply it using the same set of texture coordinates. Combine colours from the two textures somehow; for instance, you could have the first texture applied to the top half of the mesh, and the second texture applied to the lower half, with a small transition range in between.

Questions:

How did you choose to combine the texture colour and the lighting colour?

How did you choose to combine the two textures?


8) Freely chosen assignment

Copy lab3-7.* to lab3-8.*. Update lab3-8.c to load in lab3-8.vs and lab3-8.fs. Make this section's changes to lab3-8.*.

Goal: Specialize yourself.

Implement one of the suggestions below:
* Cartoon shading: quantize the results from the lighting computation into a few distinct colours.
* Gloss mapping. Use one texture to affect the shininess parameter.
* Per-pixel texture coordinate generation. By using the pixel position information (as in the lighting steps) use that for generating texture coordinates with cylindrical texture mapping. Now, what about that edge problem?
* ... or if you have an idea of your own, talk with your lab assistant to see if it is within suitable scope.


That concludes lab 3. Good work!