top of page

Realistic lighting may not always be the desired effect. If you are working to a cartoon-ish specification then Cel shading would be more appropriate. This simple effect uses a single dot product to calculate and clamp the intensities of a light source to give a scene that hand-drawn feel. The full source code can be found here.

The Pros

A small and simple effect this lighting model is great for any application that is set in a cartoon styled world. It has a customisable level of lights depending on the clamps to ensure everything looks that way a designer wants it. Cel shading also works hand in hand with edge and feature detection and makes flat colours look great.

The Cons

Again, this lighting effect is not suitable for realistic styles. As seen above it can also look very out of place with high quality textures though it is not impossible to get the two styles working together (See Prince of Persia) using edge and feature detection.

The Code

As usual this shader uses the same light passthrough vertex shader as most of the others and therefore will not be explained. There is a link to the source code for the vertex shader at the top of this page so that you know what you will need.

(Please note code comments are removed from these snippets)

#version 330

in vec3 position;
in vec3 uNormals;
in vec2 texCoord;

layout (location = 0) out vec4 FragColour;


uniform sampler2D textureMap;

#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 position of our camera, which can be hard coded however in this demo we are able to move around in the world space, and our texture map, set as type sampler2D, meaning 2D texture.

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.

And finally there is the output value. Note that this will usually be seen as "out vec4 FragColour" however the layout location refers to which buffer in the Frame Buffer Object (FBO) this will be saved to if multiple outputs are required. If a custom FBO isn't being used then location 0 will simply refer to the default output so it's good practice to just write it like that.

void main()
{
    vec3 normals = normalize(uNormals);
    vec3 cel = vec3(0.0);
    vec4 texture = texture(textureMap, texCoord.xy);

Not much is needed here. We normalize the normals for the dot products, doing so in the fragment shader to make sure they are interpolated properly, we initialise the vec3 that will contain the final result of the lighting and we get the colour of the texture for the specific fragment.

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

        vec3 D = vec3(0.0);

        if (NdotL > 0.95)
            D = light[i].lightColour * 1.0;
        else if (NdotL > 0.5)
            D = light[i].lightColour * 0.6;
        else if (NdotL > 0.25)
            D = light[i].lightColour * 0.4;
        else
            D = light[i].lightColour * 0.2;

        cel += D;
    }

After setting the lights direction vector and calculating the dot product between the light direction and the current fragments normals we do the calculation. You may notice this is very similar to the Lambertian diffuse calculation but instead of getting the raw result of the dot product we clamp it to certain multipliers based on ranges of the dot product and multiply this multiplier to the light colour. This results in our levels of light.

If the dot product is between 1.0 and 0.96 then we return the light colour as it is, between 0.95 and 0.5 we return the light colour dimmed to 60%, and so on. We also check to see if the dot product returns a value of anything lower than 0.25 and clamp it to 20% of the original light. This prevents any black areas caused by light not directly hitting the fragment and therefore acts as our ambient light.

    cel = pow(cel, vec3(2.2));
    vec3 final = texture * cel;
    final = pow(final, vec3(1.0 / 2.2));
    FragColour = vec4(final, 1.0);

}

Due to the cartoon-y effect we're going for gamma correction isn't necessary here so we multiply the final lighting value by 2.2 so that it is unaffected by the gamma correction calculation, resulting in raw colour values.

We then multiply the lighting results with the objects colour and put it through gamma correction for the benefit of the texture map before outputting the final colour.

Note that we don't use a specular contribution here. In some cases Cel shaded projects choose not to use specularity however if it is necessary then you can always add the Blinn-Phong specular highlight to the code and clamp the final result to 1.0 for a sharp, pure white specular dot.

bottom of page