Creating a Specular Lighting Shader

Introduction

In the previous tutorial, we covered the process of expanding our ambient light shader into a diffuse light shader. In this tutorial, we will continue on with the process, and add specular lighting to the shader. We will start with a discussion of how specular lighting works, and then proceed on to the shader code for doing specular lighting. Lastly, we will make the necessary changes to our XNA game to allow our shader to draw correctly.

Specular Lighting

Specular lighting is the bright, shiny spots that appear on smoother surfaces. Usually, they are the color of the light but occasionally will take on a component of the material they are reflecting off of. Specular lighting can be kind of tricky to perform. This is because, unlike diffuse lighting, specular lighting moves around, depending on where the viewer is, as well as where the light is. The math for it is a little more complicated.

The Specular Lighting Shader

Once again, we are going to expand the shader we developed in the last tutorial. Like before, I recommend that you keep a copy of the original diffuse lighting shader so that you can easily restart if you get mixed up. I have just made a copy of my shader file and called it "Specular.fx". If you didn't go through the previous tutorial, the code for the diffuse shader is below:

float4x4 World;
float4x4 View;
float4x4 Projection;

float4 AmbientColor = float4(1, 1, 1, 1);
float AmbientIntensity = 0.1;

float4x4 WorldInverseTranspose;

float3 DiffuseLightDirection = float3(1, 0, 0);
float4 DiffuseColor = float4(1, 1, 1, 1);
float DiffuseIntensity = 1.0;

struct VertexShaderInput
{
    float4 Position : POSITION0;    
    float4 Normal : NORMAL0;
};

struct VertexShaderOutput
{
    float4 Position : POSITION0;
    float4 Color : COLOR0;
};

VertexShaderOutput VertexShaderFunction(VertexShaderInput input)
{
    VertexShaderOutput output;

    float4 worldPosition = mul(input.Position, World);
    float4 viewPosition = mul(worldPosition, View);
    output.Position = mul(viewPosition, Projection);

    float4 normal = mul(input.Normal, WorldInverseTranspose);
    float lightIntensity = dot(normal, DiffuseLightDirection);
    output.Color = saturate(DiffuseColor * DiffuseIntensity * lightIntensity);

    return output;
}

float4 PixelShaderFunction(VertexShaderOutput input) : COLOR0
{
    return saturate(input.Color + AmbientColor * AmbientIntensity);
}

technique Ambient
{
    pass Pass1
    {
        VertexShader = compile vs_2_0 VertexShaderFunction();
        PixelShader = compile ps_2_0 PixelShaderFunction();
    }
}

To get started, we need a few more variables to store stuff related to specular lighting. We will want to add the following four variables, up at the top where we added all of the other variables:

float Shininess = 200;
float4 SpecularColor = float4(1, 1, 1, 1);    
float SpecularIntensity = 1;
float3 ViewVector = float3(1, 0, 0);

The first value indicates how shiny our surface is. A low value indicates that the surface should have broad highlights, and is used for duller surfaces. Shinier surfaces have higher values. Most metallic surfaces have a value between about 100 and 500, while theoretically, a mirror would have an infinitely large value. I've chosen 200 just as a default so that we get some decent specular highlights.

The second variable specifies the color of the specular light. This is often similar or identical to the color of the light, but some materials, like metal, cause the specular highlights to have a tint that is the color of the object they are reflecting off of. Doing that is a little more complicated. For now, we will just keep our specular light color the same as the diffuse light color.

The specular intensity just indicates what portion of the light is reflected by the surface. Lower intensities will result in dimmer highlights. In this case, I have chosen 1 as a default, so that we get good bright highlights.

Lastly, the view vector indicates the direction that the camera or "eye" is looking in. This is going to be important in our calculations because the locations of the specular lights are dependent on where the viewer is located, compared to the object. Later on, in our XNA game, we will calculate and set this value.

The second step is to update our structures. The vertex shader input (we called ours VertexShaderInput) does not need to be changed in this case, but the vertex shader output (ours is called VertexShaderOutput) will need a small modification. We will add one line into this structure, which will change the VertexShaderOutput class from:

struct VertexShaderOutput
{
    float4 Position : POSITION0;
    float4 Color : COLOR0;
};

to:

struct VertexShaderOutput
{
    float4 Position : POSITION0;
    float4 Color : COLOR0;
    float3 Normal : TEXCOORD0; // This is the new line
};

Since specular highlights are usually pretty small, we will get better results if we do the math in the pixel shader, because we can get more detail there, which may be especially important if our polygons are fairly large. In this change, we have set up a place to store the vertex's normal. Notice that the semantic we are using is the TEXCOORD0 semantic, and we aren't even dealing with texture coordinates! The exact semantic that you use really is insignificant, though the different types may get interpolated differently. You can almost put any type of information with any type of semantic.

In our vertex shader for diffuse lighting, we already calculated the normal of the vertex. But now we will want to store that information in the field that we just created so that it is available in our pixel shader. We will only need to add one line to our vertex shader to accomplish this. In your vertex shader, after we calculate the normal, but before we actually return, add the following line of code:

output.Normal = normal;

So now our vertex shader should look something like this:

VertexShaderOutput VertexShaderFunction(VertexShaderInput input)
{
    VertexShaderOutput output;

    float4 worldPosition = mul(input.Position, World);
    float4 viewPosition = mul(worldPosition, View);
    output.Position = mul(viewPosition, Projection);

    float4 normal = normalize(mul(input.Normal, WorldInverseTranspose));
    float lightIntensity = dot(normal, DiffuseLightDirection);
    output.Color = saturate(DiffuseColor * DiffuseIntensity * lightIntensity);

    output.Normal = normal;

    return output;
}

The next thing we need to do is to rewrite the pixel shader to include the specular lighting. We are going to need to change it fairly significantly, so just go ahead and replace the entire pixel shader (remember, its the one called PixelShaderFunction())with this:

float4 PixelShaderFunction(VertexShaderOutput input) : COLOR0
{
    float3 light = normalize(DiffuseLightDirection);
    float3 normal = normalize(input.Normal);
    float3 r = normalize(2 * dot(light, normal) * normal - light);
    float3 v = normalize(mul(normalize(ViewVector), World));

    float dotProduct = dot(r, v);
    float4 specular = SpecularIntensity * SpecularColor * max(pow(dotProduct, Shininess), 0) * length(input.Color);

    return saturate(input.Color + AmbientColor * AmbientIntensity + specular);
}

In the first two lines, we just get a copy of the light direction and normal that is normalized. The light direction may very well be normalized, though it depends on the programmer's input. Since normalizing this vector for every pixel (remember the pixel shader will be executed for every pixel that it draws) will be expensive, it is probably more efficient to not normalize this here, but just remember that it needs to be a normalized vector when you set it in your XNA game.

The second thing that we do is to calculate a reflection vector, which we called r. This is the direction that light directly from the light source will be reflecting, once it bounces off the point that we are currently looking at. The math for this can be fairly complicated, but as long as our light vector and normal vector are normalized, the math simplifies a little bit.

Next, we calculate where the view vector, v is at. To do this, we take the ViewVector variable that will be set by the XNA program and transform it by the world matrix. Once again, we use the normalize() function to create a normalized vector.

We then calculate the amount of specular lighting at the pixel of interest in two steps, just to make things easier to understand. We first calculate the dot product of
the reflection vector r and the view vector v. Remember that the dot product is often used as a measurement of the angle between two vectors. So this measurement will give us an idea of how far away the camera is from receiving the reflected light. In the center of the next line, we raise the dot product to a power equal to the shininess value. This gives us an idea of how broad the specular highlight should be. With this value, we calculate the amount of specular light at this pixel in a similar manner to what we have done before with diffuse lighting, by multiplying it by the specular color and intensity. Notice, however, that we also factor in the amount of diffuse color at that point at the end (length(input.Color);). This is done so that in places where there is little diffuse light, the specular highlight will also be dimmer. This makes it more realistic looking.

In the final line of the shader, we take the diffuse lighting value that we calculated in the vertex shader, throw in the ambient lighting which we calculate quickly on the spot, and then add in the specular lighting that we just calculated. Notice that we use the intrinsic function saturate(), which ensures that the color fits in a valid range of 0 to 1 for all of the components (red, green, and blue).

Additionally, you'll probably want to change the name of your technique from "Diffuse". I've called mine "Specular".

technique Specular // I changed this name here
{
    pass Pass1
    {
        VertexShader = compile vs_2_0 VertexShaderFunction();
        PixelShader = compile ps_2_0 PixelShaderFunction();
    }
}

By the way, Wikipedia has a fairly good description of the different versions of vertex and pixel shaders available on their HLSL page.

This completes the changes that we need to make in our specular lighting shader. The entire code is show below:

float4x4 World;
float4x4 View;
float4x4 Projection;

float4 AmbientColor = float4(1, 1, 1, 1);
float AmbientIntensity = 0.1;

float4x4 WorldInverseTranspose;

float3 DiffuseLightDirection = float3(1, 0, 0);
float4 DiffuseColor = float4(1, 1, 1, 1);
float DiffuseIntensity = 1.0;

float Shininess = 200;
float4 SpecularColor = float4(1, 1, 1, 1);    
float SpecularIntensity = 1;
float3 ViewVector = float3(1, 0, 0);

struct VertexShaderInput
{
    float4 Position : POSITION0;    
    float4 Normal : NORMAL0;   
};

struct VertexShaderOutput
{
    float4 Position : POSITION0;
    float4 Color : COLOR0; 
    float3 Normal : TEXCOORD0;
};

VertexShaderOutput VertexShaderFunction(VertexShaderInput input)
{
    VertexShaderOutput output;

    float4 worldPosition = mul(input.Position, World);
    float4 viewPosition = mul(worldPosition, View);
    output.Position = mul(viewPosition, Projection);

    float4 normal = normalize(mul(input.Normal, WorldInverseTranspose));
    float lightIntensity = dot(normal, DiffuseLightDirection);
    output.Color = saturate(DiffuseColor * DiffuseIntensity * lightIntensity);

    output.Normal = normal;

    return output;
}

float4 PixelShaderFunction(VertexShaderOutput input) : COLOR0
{
    float3 light = normalize(DiffuseLightDirection);
    float3 normal = normalize(input.Normal);
    float3 r = normalize(2 * dot(light, normal) * normal - light);
    float3 v = normalize(mul(normalize(ViewVector), World));

    float dotProduct = dot(r, v);
    float4 specular = SpecularIntensity * SpecularColor * max(pow(dotProduct, Shininess), 0) * length(input.Color);

    return saturate(input.Color + AmbientColor * AmbientIntensity + specular);
}

technique Specular
{
    pass Pass1
    {
        VertexShader = compile vs_2_0 VertexShaderFunction();
        PixelShader = compile ps_2_0 PixelShaderFunction();
    }
}

Changes in the XNA Game

We now need to make a couple of changes in our XNA game to make this effect work. First of all, if you are using a different effect file completely, like me, you will have to change the name of the effect file you read in.

Additionally, we will need to set a value for the ViewVector property of our shader. The view vector is usually pretty easy to compute when you set up the view matrix. Usually, when we create the view matrix, we use the static method in the class that looks like Matrix.CreateLookAt(CameraPosition, CameraTarget, UpVector);. With this information, the view vector is simply Vector3 ViewVector = CameraTarget - CameraPosition;. If you are working in your own code, you will need to determine exactly how to calculate this. If you are using the code that we created in the third tutorial of the HLSL tutorials, I'll show you what to do to get this vector. First, we will need to add an instance variable to our class to store the vector, so add the following line of code to your class as an instance variable:

Vector3 viewVector;

Next, in the update method, we will add tode to calculate and store the view vector. Replace the line that says:

view = Matrix.CreateLookAt(distance * new Vector3((float)Math.Sin(angle), 0, (float)Math.Cos(angle)), new Vector3(0, 0, 0), new Vector3(0, 1, 0));

with:

Vector3 cameraLocation = distance * new Vector3((float)Math.Sin(angle), 0, (float)Math.Cos(angle));
Vector3 cameraTarget = new Vector3(0, 0, 0);
viewVector = Vector3.Transform(cameraTarget - cameraLocation, Matrix.CreateRotationY(0));
viewVector.Normalize();
view = Matrix.CreateLookAt(cameraLocation, cameraTarget, new Vector3(0, 1, 0));

Finally, in the DrawModelWithEffect() method, next to where we set all of the other properties, add the following line to set the ViewVector property:

effect.Parameters["ViewVector"].SetValue(viewVector);

If you've done everything right, you should be able to run the program and see your model with specular lighting!

screenshot1.png

What's Next?

This tutorial has taken us through specular lighting, and we have now covered ambient, diffuse, and specular lighting. We've covered enough about lighting for now. So we're going to move on to another important component of shaders: texturing.


Troubleshooting.png Having problems with this tutorial? Try the troubleshooting page!