top of page

Gouraud shading is among the cheapest lighting techniques available to compute. It has a diffuse, ambient and specular contribution but is calculated per-vertex and interpolated across fragments. The full source code can be found here.

The Pros

A big majority of the lighting calculations are done in the vertex shader and final colours are sent up to the fragment shader. In doing this, the colours are interpolated between vertices instead of needing to calculate the light for every single fragment, saving on a lot of GPU power especially on low polygon models.

The Cons

Of course, this also means the accuracy of the lighting is entirely down to how many vertices there are. The lower the number of vertices, the more incorrect the light will look and this is most visible in the specular dot, the errors of which can be seen above. This is caused by the specular dots intensity being locked to a vertex then being interpolated out to the other vertices.

The Code

So now lets look into the vertex shaders code...

(Please note code comments are removed from these snippets)

#version 330

uniform vec3 camPos;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix;

#define NUM_LIGHTS 2

uniform struct Light
{
    vec3 lightPos;
    vec3 lightColour;
    float shininess;
    float glossiness;
    float specularCoefficient;
} light[NUM_LIGHTS];

As with all shaders we start with the setup stuff. Firstly we tell the compiler to compile for GLSL version 330, or OpenGL 3.3 (The chosen version number for the demo project).

Then there are the uniforms. These are the values we have passed in from the CPU. Here we have the matrices required to bring everything into world space and the position of our camera. This value can be hard coded however in this demo we are able to move around in the world space.

There is also a uniform for a structure, containing many values. This structure holds all the properties of a light and allows for multiple lights to be passed into the shader up to the defined amount.

out vec3 Gouraud;
out vec2 texCoord;

layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inNormal;
layout(location = 2) in vec2 inTexCoord;

Next are the outs. These are the outputs of the vertex shaders and the inputs of the fragment shader. Gouraud will be the results of the lighting calculation and the texture coordinates will also be sent up for the texture map which will have to be done in the fragment shader.

And finally are the inputs of our vertex buffer object (VBO). This contains the information of the current vertices position, normal vector and texture coordinates.

void main()
{    
    vec3 position = vec3(0.0);
    vec3 normals = vec3(0.0);
    float ambientCoefficient = 0.01;
    vec3 diffuse = vec3(0.0);
    vec3 specular = vec3(0.0);
    vec3 ambientColour = vec3(0.0);

    position = vec3((viewMatrix * modelMatrix) * vec4(inPosition, 1.0));
    normals = normalize(normalMatrix * inNormal);
    texCoord = inTexCoord;

    vec3 camDir = normalize(camPos - position);

On to the main function! Sort of. So here we're just initialising all the values that we're going to be using to zero as well as setting the ambient coefficient, or multiplier. This will darken the ambient light so that it only makes a subtle change.

Here we also transform the vertex position and its normals to world space using the matrices and pass the texture coordinates through to the fragment shader. These will typically be seen in a vertex shader. The vector for the camera direction is also set. This is done outside the loop as it only needs to be done once, as opposed to once per light.

    for (int i = 0; i < NUM_LIGHTS; i++)
    {
        vec3 lightDir = normalize(light[i].lightPos - position);
        vec3 halfAngle = normalize(camDir + lightDir);
        float NdotL = clamp(dot(normals, lightDir), 0.0, 1.0);
        float NdotH = clamp(dot(normals, halfAngle), 0.0, 1.0);

        vec3 D = light[i].glossiness * (light[i].lightColour * NdotL); 
        float specularHighlight = pow(NdotH, light[i].shininess); 
        vec3 S = light[i].specularCoefficient * (light[i].lightColour * specularHighlight);

        diffuse += D;
        specular += S;
        ambientColour += light[i].lightColour;
    }

Now the actual loop! Just to note the lighting doesn't actually have to be in a loop, but if you want multiple lights in your scene, and I'm pretty sure you would, you need to add the sum of all the lights together. So one light per loop.

Similarly to the camera direction vector we calculate the current light direction vector and use both to find the half way angle between the two. This is where the perceived light is reflected and will be used for the specular contribution.

Dot products are also calculated between the current fragments surface normal and the aforementioned vectors.

Diffuse

The diffuse contribution is simple Lambertian. We use the dot product between the surface normal and the light direction to get a value between 1 and -1 (clamped to 1 and 0 as anything below 0 would be facing away from the light). This essentially acts as a multiplier that means a normal which faces directly towards the light will be most brightly lit. We multiply this value by the lights colour so that the result gets its desired tint and then again by the diffuse coefficient, or glossiness.

Specular

The specular contribution is a little more complex. As previously mentioned we need the half angle between the light direction and the view direction for the specular dot. We use the dot product again to obtain our intensity falloff and multiply it to the power of a value. The bigger this value the smaller yet more intense the specular dot will be. This value translates to shininess.

Taking this final value we do a similar calculation to the diffuse contribution, multiplying the highlight by the lights colour for the tint and then again by a coefficient value.

Finally we send these two values up outside the loop, adding the result to the current values. This is so we have the sum of all the lights if two or more were to overlap. We also add the colours together for the colour of our ambient light and send that out of the loop.

    vec3 ambience = ambientCoefficient * ambientColour;

    Gouraud = ambience + diffuse + specular;

    gl_Position = (projectionMatrix * (viewMatrix * modelMatrix)) * vec4(inPosition, 1.0);
}

Finally in the vertex shader we multiply the ambient colour with the previously defined coefficient, put the whole lighting calculation together and send it up to the fragment shader! We also define the vertices position in world space by multiplying the MVP matrix with the vertex position. For those who used the earlier versions of OpenGL this line equates to ftransform().

Now we move onto the fragment shader! But don't worry, this one's very small.

#version 330

uniform sampler2D textureMap;

in vec3 Gouraud;
in vec2 texCoord;

out vec4 FragColour;

void main()
{
    vec4 texture = texture(textureMap, texCoord.xy);
    vec3 final = texture.rgb * Gouraud;
    final = pow(final, vec3(1.0 / 2.2)); //Apply gamma correction to the final values.
    FragColour = vec4(final, 1.0);
}

A lot of the setup is similar to the vertex shader. We define the compiler version, uniform in our texture map, take the inputs from our vertex shader and define the output for our fragment shader.

To use the texture map we need to find the colour for the specific fragment we're calculating. We obtain this using the texture coordinates that we passed up. the texture function looks up and returns the colour on the defined texture map at the specific texel.

We multiply the texture colour by the result of the lighting for our final fragment colour. Gamma correction is applied for more accurate results. More on gamma correction here. This result is then sent to the fragments output where it can then be displayed by OpenGL. Done!

bottom of page