Tutorial #5 - Intro To Lighting

27. April 2010 12:13 by Jeromy Walsh in 3D Graphics, Mathematics, XNA 4.0  //  Tags:   //   Comments (3)

In the previous tutorials we've focused on displaying, coloring, and texturing 3D primitives, but have thus far ignored the most important element of a scene's visible appearance - the lighting. In this tutorial, we are going to implement our first global and local illumination models to add a new, dynamic element to our scenes.

The Sample Framework

This is the first tutorial that will explicitly build on the source code from the previous tutorial. So if you'd like to start from the Intro to XNA Tutorial #4 and follow along, you'll want to grab the Tutorial #4 Soure Code first. If you want to skip to the result, you can jump ahead to the end of the tutorial to see a screenshot, or download the Project #5 Source Code and compile and run it on your own PC.

A Brief History

In 1986 David Immel et al. and James T. Kajiya developed what is referred to as the rendering equation. The rendering equation is an integral equation based on the physical property of the conservation of energy, that models the appearance of a scene as all light emitted and added to a scene bounces off of all the objects in that scene. To date, there have been a number of techniques developed (most of which came earlier) that approximate the rendering equation. These include ray tracing, photon mapping, radiosity, and to a lesser degree ambient occlusion. To date, none are feasible in real-time on readily available hardware (though Screen Space Ambient Occlusion is a competent fake). Instead, we rely on cleverly devised tricks to give us results that are "good enough".

All lighting algorithms fall into one of two categories - Local Illumination models, and Global Illumination models. Those mentioned above fall into the global illumination category and are called such because they are concerned with how all light in a scene interacts with each object. That is, how objects are indirectly lit. Key elements of global illumination include shadowing and caustics.

In contrast, local illumination models focus on how light reflects off a single surface (directly lit). For example, some objects have shiny surfaces while others have matte surfaces. Some are rough, while others are smooth. These characteristics make up the perceived material of an object. Often times local lighting models are used in combination with simple global illumination models in order to create surprisingly realistic results, which, most importantly, run in real-time.

The DefaultEffect Class

In this tutorial, I want to look at the changes to the DefaultEffect before looking at the Game1 class. This is primarily because the changes to Game1 are relatively few, while the changes to DefaultEffect help to illustrate the fundamental differences between working in a lit vs. non-lit scene. In specific, DefaultEffect shows the addition of three new fields named ambientColor, isLightEnabled, and light - which is a DirectionalLight.

Note: DirectionalLight is identical to and is the replacement for the BasicDirectionalLight found in XNA versions prior to 4.0. The only noticeable difference is that DirectionalLight has a constructor, while BasicDirectionalLight did not.

As before, DefaultEffect caches the EffectParamters and exposes them for use by the Game1 class.

class DefaultEffect : Effect, IEffectMatrices
{
	...

	EffectParameter ambientColor;
	EffectParameter isLightingEnabled;
	DirectionalLight light;

	public DefaultEffect(Effect effect)
		: base(effect)
	{
		world        = Parameters["World"];
		projection   = Parameters["Projection"];
		texture      = Parameters["DiffuseTexture"];
		ambientColor = Parameters["AmbientColor"];
		isLightingEnabled = Parameters["IsLightingEnabled"];

		light = new DirectionalLight(
			Parameters["LightDirection"],
			Parameters["DiffuseColor"],
			Parameters["SpecularColor"],
			null);
	}

	...

	public Vector3 AmbientLightColor
	{
		get { return ambientColor.GetValueVector3(); }
		set { ambientColor.SetValue(value); }
	}

	public bool IsLightingEnabled
	{
		get { return isLightingEnabled.GetValueBoolean(); }
		set { isLightingEnabled.SetValue(value); }
	}

	public DirectionalLight DirectionalLight
	{
		get { return light; }
	}
}

The first new field in the DefaultEffect class is the ambientColor field. In this tutorial we are going to implement the most simplistic global illumination model possible - ambient lighting. The ambient lighting model is based on the premise that all light is reflecting off all objects in a scene and scattering light that is more or less a single color. This creates a "base" amount of light that is reflected by all surfaces, even those that lack any direct lighting. This is frequently used in combination with local lighting models in order to prevent scenes from being overly dark. Consider this: If you've got a cube, with three of the six sides facing a directional light, such as the sun, then without any form of global illumination, such as ambient lighting, the three surfaces which face away from the light source would be completely black. See the figure to the right. Ambient lighting aims to prevent this by contributing a small amount of light (10-20%) to all surfaces.

In lighting equations we generally use modulation to determine how light mixes with the diffuse color of objects. Modulation is a fancy way of saying "multiplication”. Let us say you have an object that is completely blue (0f, 0f, 1f). Now let us say you have a bright light that shines white (1f, 1f, 1f). If you shine this light on the object, the final color you see would be the original blue (0f, 0f, 1f) because the material is said to reflect 100% of the blue component of any light source. Mathematically this is found by multiplying each component of the light with each component of the diffuse color: (0f, 0f, 1f) * (1f, 1f, 1f) = (0f, 0f, 1f).

As another example, assume you have the same blue object (0f, 0f, 1f), and you instead shine a red light on it (1f, 0f, 0f). The result is black: (0f, 0f, 1f) * (1f, 0f, 0f) = (0f, 0f, 0f). Intuitively this makes sense. If the material of the object is designed to reflect blue light, but you shine a light on it that has no blue component, there is nothing to reflect and the final color will demonstrate this.

With all this said, pure white (1f, 1f, 1f) is the brightest color we can display. Therefore, if we created an ambient light that is pure white, every object in the scene would be its maximum color and there would be no appearance of lighting. As a result, most people (if they use ambient lighting at all) use an ambient value around (0.2f, 0.2f, 0.2f). This manifests as a very slight gray light added to the surface of all objects. The equation looks something like this:

FinalColor = DiffuseColor * Ambient;

The second new field in the DefaultEffect class should be self-explanatory, so let us move on to the third new field; the directional light. In 1760, a physicist and mathematician named Johann Heinrich Lambert published his research on the mathematics of illumination in a book called Photometria. One of the principles he discussed is called Lambertian Surfaces. Lambertian Surfaces are material surfaces that when illuminated, present no relationship between the position of the observer, and the position of the light source. That is, Lambertian Surfaces have no specular highlighting. No matter where the observer stands, the light source is reflected the same. This lead to the development of Lambert's Cosine Law.

Lambert's Cosine Law states that the amount of light reflected by a Lambertian Surface is directly proportional to the cosine of the angles between the surface normal and the light vector. In plain English, if a light is shining perpendicular to a surface, the surface will reflect 100% of the light. If a light is shining parallel to a surface, the surface will reflect 0% of the light. The amount of light reflected between parallel and perpendicular angles is a straight linear interpolation.

In mathematics, the Dot Product (Scalar Product) of two vectors is represented by:

AB = |A||B|Cos(θ)

In English, the dot product of A and B is the product of the magnitudes of A and B, multiplied by the Cosine of the angle 'a' between them. If A and B are both unit vectors (magnitude of 1), then the dot product of two vectors IS the cosine of the angle between them. When applied to Lambert's Cosine Law it means we can easily determine the amount of light reflected by a Lambertian Surface, simply by taking the dot product of the surface normal, and the light vector. Or...

FinalColor = (NL) * DiffuseLight * DiffuseColor;

In order to create a scene which incorporates both our global and local illumination models we must add the equations together and then factor out the common terms. The final result is:

FinalColor = DiffuseLight + AmbientLight

FinalColor = ((NL) * DiffuseLight + AmbientLight) * DiffuseColor

With all that said, we now see how we're going to use both the ambient light color as well as the directional light to add illumination to the scene, but we're still missing one element - the surface normals used in Lambert's Cosine Law. Let us look at the Game1 class.

The Game1 Class

Game1 has relatively few changes and includes no new fields. This is because the changes necessary to add lighting to a scene are mathematical in nature and are primarily located in our DefaultEffect class as well as in our Real-Time Shaders. However, we do need to add surface normals for our primitives so we have something to Dot in our Real-Time Shaders. This is all done in the Initialize method.

public class Game1 : Microsoft.Xna.Framework.Game
{
	...

	protected override void Initialize()
	{
		// Create the transforms for the two shapes we want to draw
		ResetTranslation();

		Vector2 topLeft = new Vector2(0.0f, 0.0f);
		Vector2 topCenter = new Vector2(0.5f, 0.0f);
		Vector2 topRight = new Vector2(1.0f, 0.0f);
		Vector2 bottomLeft = new Vector2(0.0f, 1.0f);
		Vector2 bottomRight = new Vector2(1.0f, 1.0f);

		Vector3 triFront = Vector3.Normalize(new Vector3( 0.0f, 1.0f,  1.0f));
		Vector3 triBack  = Vector3.Normalize(new Vector3( 0.0f, 1.0f, -1.0f));
		Vector3 triLeft  = Vector3.Normalize(new Vector3(-1.0f, 1.0f,  0.0f));
		Vector3 triRight = Vector3.Normalize(new Vector3( 1.0f, 1.0f, 0.0f));

		// Initialize the triangle's data
		// Initialize the triangle's data (with Vertex Colors)
		VertexPositionNormalTexture[] triangleData = new VertexPositionNormalTexture[]
		{
			// Front
			new VertexPositionNormalTexture(new Vector3(1.0f, -1.0f, 1.0f), 
				triFront, bottomRight),
			new VertexPositionNormalTexture(new Vector3(-1.0f, -1.0f, 1.0f), 
				triFront, bottomLeft),
			new VertexPositionNormalTexture(new Vector3(0.0f, 1.0f, 0.0f), 
				triFront, topRight),

			// Right
			new VertexPositionNormalTexture(new Vector3(1.0f, -1.0f, -1.0f), 
				triRight, bottomRight),
			new VertexPositionNormalTexture(new Vector3(1.0f, -1.0f, 1.0f), 
				triRight, bottomLeft),
			new VertexPositionNormalTexture(new Vector3(0.0f, 1.0f, 0.0f), 
				triRight, topRight),

			...
		};

		// Initialize the Rectangle's data (Do not need vertex colors)
		VertexPositionNormalTexture[] boxData = new VertexPositionNormalTexture[]
		{
			// Front Surface
			new VertexPositionNormalTexture(new Vector3(-1.0f, -1.0f, 1.0f), 
				Vector3.Backward, bottomLeft),
			new VertexPositionNormalTexture(new Vector3(-1.0f, 1.0f, 1.0f), 
				Vector3.Backward, topLeft), 
			new VertexPositionNormalTexture(new Vector3(1.0f, -1.0f, 1.0f), 
				Vector3.Backward, bottomRight),
			new VertexPositionNormalTexture(new Vector3(1.0f, 1.0f, 1.0f), 
				Vector3.Backward, topRight),  

			...
		};

The first thing to note about the Initialize method is that our pyramid and box are now comprised of vertices of type VertexPositionNormalTexture. This is another handy vertex format provided to us by the XNA Framework Library. As the name suggests, the vertex format is made up of three separate fields, a position, a normal, and UV coordinates for a texture. In this function I use the same UV coordinates used in the previous tutorial, and add a few new utility vectors to the function named triFront, triBack, triLeft, and triRight. These normalized (magnitude 1) vectors point in the direction of the surface normals for our pyramid. I create no such utility vectors for the box, because I rely on the built-in vectors of the Vector3 class; specifically, Vector3.Backward, Vector3.Right, etc...

    ...
	
	pyramidVB = new VertexBuffer(GraphicsDevice, 
		VertexPositionNormalTexture.VertexDeclaration, 12, BufferUsage.WriteOnly);
	pyramidIB = new IndexBuffer(GraphicsDevice, IndexElementSize.SixteenBits, 
		12, BufferUsage.WriteOnly);

	pyramidVB.SetData(triangleData);
	pyramidIB.SetData(pyramidIndices);
	triangleData = null;
	pyramidIndices = null;

	boxVB = new VertexBuffer(GraphicsDevice, VertexPositionNormalTexture.VertexDeclaration, 
		24, BufferUsage.WriteOnly);
	boxIB = new IndexBuffer(GraphicsDevice, IndexElementSize.SixteenBits, 
		36, BufferUsage.WriteOnly);

	boxVB.SetData(boxData);
	boxIB.SetData(boxIndices);
	boxData = null;
	boxIndices = null;

	base.Initialize();
}

Along with changing the type of vertex format, it is also necessary to make a few changes to the creation of our vertex buffers. Specifically, we need to tell it we are using a VertexPositionNormalTexture now by passing in the appropriate vertex declaration. We also need to change the generic SetData method to use the new vertex format. That's it! With the above changes, we now provide our HLSL shader all the information it needs to compute the Lambertian factor for each surface.             

I should make a quick note here for the purists. This tutorial is mimicking flat shading, not smooth shading. In flat shading, each surface is assumed to have precisely one normal vector that is used in the lighting equation. In spite of this, we still provide one normal vector per vertex and allow the Lambertian factor to be interpolated. Because all of the normal vectors on the same triangle point in the same direction, it gives the appearance of flat shading. I will revisit Gouraud shading in the next tutorial.

The next method to look at is the LoadContent method. LoadContent is nearly the same with the addition of four new statements.

protected override void LoadContent()
{
	...
	
	// Note: If the light is not enabled, changes aren't pushed to the card
	effect.DirectionalLight.Enabled = true;
	effect.AmbientLightColor = new Vector3(0.2f, 0.2f, 0.2f);
	effect.DirectionalLight.DiffuseColor = new Vector3(1, 1, 1);
	effect.DirectionalLight.Direction = 
		Vector3.Normalize(new Vector3(0.5f, -0.5f, -0.5f));
}

The first new statement tells the effect to enable the directional light stored in the DefaultEffect. If the light is disabled, it does not push the directional light properties to the card and the result is that only the ambient light term is applied. The second line is the assignment of our ambient light color. Note that I use a relatively low value of 20%. The final two lines assign a diffuse color and direction to the directional light. This tells the HLSL shader what our light vector is, and what light color to modulate with the diffuse color of object. I want the diffuse light to be white so that 100% of the diffuse color of the object will shine through when it is perpendicular to the light source.

The update method also has one new change. Pressing 'L' will toggle lighting on and off. This allows you to see the difference between what the scene looks like with and without lighting.   

protected override void Update(GameTime gameTime)
{
	...
	
	// If the user presses the F1 key, toggle FullScreen Mode
	if (keyboard.IsKeyDown(Keys.L) && prevKeyboardState.IsKeyUp(Keys.L))
		effect.IsLightingEnabled = !effect.IsLightingEnabled;

	...

	// Cache the keyboard state for the next update cycle
	prevKeyboardState = keyboard;
}  

The final method I wanted to include is the Draw method, but note that there are no changes to the Draw method. Because vertex declarations are bound to the VertexBuffer objects in XNA 4.0, it means we can change entire lighting algorithms without a single change to our Draw routine.

protected override void Draw(GameTime gameTime)
	{
		...
	}
}

The DefaultEffect.fx File

With the DefaultEffect and Game1 classes out of the way, it is time to look at our HLSL code. The file begins the same, and as with the DefaultEffect class includes a few more fields. Specifically, we have added the IsLightingEnabled, AmbientColor, LightDirection, and DiffuseColor global variables.

While the DefaultEffect class had only three new variables, our HLSL has four, because LightDirection and DiffuseColor combine into a single field in the DefaultEffect class - light.

float4x4 World;
float4x4 Projection;
Texture2D DiffuseTexture;

sampler2D DiffuseSampler = sampler_state
{
	Texture = <DiffuseTexture>;
};

bool   IsLightingEnabled;
float3 AmbientColor;
float3 LightDirection;
float3 DiffuseColor;

The next change to our HLSL file is the creation of two new vertex structures. The first, VertexPositionNormalTexture is our input format, and contains the position, normal, and uv coordinates passed in from the C# code.

struct VertexPositionNormalTexture
{
    float4 Position : POSITION0;
	float3 Normal	: NORMAL0;
	float2 UV       : TEXCOORD0;
};

struct VertexPositionColorTexture
{
    float4 Position : POSITION0;
	float4 Color	: COLOR0;
	float2 UV       : TEXCOORD0;
};

The second structure contains the position, color, and texture coordinates passed on to the pixel shader from the vertex shader. The reason they are different is that we are performing the Lambert Cosine Law calculations in the vertex shader instead of the pixel shader, and then passing on the computed color to interpolate across the triangle surface. We could alternatively pass along the normal vector to interpolate, and then perform the Lambert calculations in the pixel shader. However, because we are using flat shading the results will be the same either way, with a significant performance difference for doing the lighting calculations per-pixel.

After the vertex structures declarations we have the new vertex shader. The vertex shader computes the projected world position as previously and passes along the UV coordinates to interpolate and process by the pixel shader. What is new is the if-block, and everything inside of it.

VertexPositionColorTexture TexturedLitVertexShader(VertexPositionNormalTexture input)
{
    VertexPositionColorTexture output = (VertexPositionColorTexture)1;

	// Transform the position
    output.Position = mul(input.Position, World);    
    output.Position = mul(output.Position, Projection);

	// Set the UV's
	output.UV = input.UV;
	
	if( IsLightingEnabled) 
	{
		// Compute the transformed Normal Vectors
		float3 N = mul(input.Normal, World);
		float3 L = -LightDirection;

		// Computing the color
		float3 Diffuse = max(0,dot(N, L)) * DiffuseColor + AmbientColor;	
		output.Color = float4( Diffuse, 1 );
	}
		
    return output;
}

If the lighting is enabled the vertex shader computes the Lambertian Factor by first transforming the normal vector into world space, and then taking the dot product of the normal vector and the negative light vector. The light vector is negated so that the dot product accurately computes the angle between the two vectors.

Some things to note in my vertex shader is that I neither normalize any vectors, saturate the dot product, or transform the surface normal vectors by the inverse transpose of the world matrix. These are all optimizations. First, I do not normalize anything because I know all of the vectors are pre-normalized in the C# code. If you cannot make that guarantee, you'll want to normalize your vectors before performing the dot product.

Second, I do not multiply the normal vectors by the inverse transpose of the world matrix because there is no non-uniform scaling or sheering in my world transformation. Therefore, it is sufficient to multiply by the world transform. If you are going to perform non-uniform scaling or sheering, you will want to pass in the inverse-transpose world matrix, and use that for transforming the normal vectors.

Finally, I use max(0, dot(N,L)) instead of saturate because I know the result dot product is already less than one because of the normalized vectors. The only thing necessary is to ensure the result is greater than or equal to 0.

After the Lambertian factor is computed it is multiplied by the diffuse light color and added to the ambient light color. This satisfies everything about our lighting equation except for the modulation of our total light color and the diffuse material color. This is done in the pixel shader.

float4 TexturedLitPixelShader(VertexPositionColorTexture input) : COLOR0
{
    return tex2D(DiffuseSampler, input.UV) * input.Color;
}

In the pixel shader, the diffuse material color is pulled from the box texture we began using in the previous tutorial. This value is modulated with the light color passed in from the vertex shader and the result is the final color at that pixel location on the screen.

technique DefaultEffect
{
    Pass
    {
        VertexShader = compile vs_2_0 TexturedLitVertexShader();
        PixelShader  = compile ps_2_0 TexturedLitPixelShader();
    }
}

If you make the above changes, or download the code for Project #5 and run the application you should see the following.

 

Download Project #5

Comments (3) -

artpoz
artpoz
1/14/2011 7:46:26 AM #

There is an error in code. It should be:
GraphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, 12, 0, 4);

Roger the Overly Doting Rocket
Roger the Overly Doting Rocket
3/26/2011 5:03:30 AM #

No more tuts?? Frown

Illidari
Illidari
3/28/2011 7:17:52 PM #

Very awesome stuff

I do have to point out that artpoz is right and you need to make that change or the triangle doesn't fully form.

Other than that its great!

About the author

Jeromy Walsh is a professional game programmer with multiple credited and uncredited AAA game titles. Jeromy's primary area of expertise is in Tools/Engine development, though he likes to fiddle with other areas as well.

Month List