In this tutorial, we go from using user-provided arrays with simple, colored primitives, to using buffered primitives with full texture mapping and filtering. We will also look at some of the changes in the new GraphicsDevice class.
In this series of tutorials, I have so far been following the path of the NeHe OpenGL tutorials. The idea was to demonstrate how easy it is to complete those same tutorials using XNA. However, starting with this fourth tutorial I am diverging from the NeHe tutorials because they, at times, develop a little slower than many would like, and also don’t go into enough depth. I will on occasion go back and use the NeHe tutorials for inspiration, but in general, this tutorial series will now follow its own path.
For those who are familiar with the NeHe series of tutorials, this tutorial will cover roughly the same material as covered in the NeHe tutorials 4-6 (and some of 7). In specific, we are going to be drawing full 3D objects now instead of just single primitives, we are going to be doing a bit more rotation and manipulation of the world transforms of our objects, and we are going to be performing both texture sampling and filtering as part of our coverage of texture mapping.
As usual, all rendering will be done with HLSL and our own custom effect classes, rather than using the pre-built *Effect classes which ship with XNA 4.0. Note, however, that everything we are going to do in this tutorial is possible with the BasicEffect class. With no further ado, let's get started.
The Sample Framework
The first thing you will need to do is grab hold of the sample framework. If you have been following along with the previous tutorials, you have seen the first version of the sample framework, which was provided as part of Tutorial #1. However, because we will usually call the ResetProjection method from within our OnResize handler, I have gone ahead, added that to the base code, and provided a second download. You will need to get the updated base code here.
The Game Class
With the new base code in hand, it is time to get started making changes to Game1. The first thing we are going to do is add some new fields. We need an instance of the DefaultEffect class, which we will use to perform our rendering, we will need some matrices to perform some transformations, and we will need a pair of buffers for each of our 3D objects.
public class Game1 : Microsoft.Xna.Framework.Game
{
GraphicsDeviceManager graphics;
KeyboardState prevKeyboardState = Keyboard.GetState();
DefaultEffect effect;
Matrix triangleTransform;
Matrix rectangleTransform;
VertexBuffer pyramidVB;
IndexBuffer pyramidIB;
VertexBuffer boxVB;
IndexBuffer boxIB;
TextureFilter textureFilter = TextureFilter.Linear;
private float triangleAngle;
private float rectangleAngle;
bool isAnimating = true;
float depth = -6.0f;
Part of the purpose of this tutorial is for you to see how a filtering mode changes the appearance of textures on your primitives. In order to accomplish this, you need to be able to switch between different filtering methods at run-time. The textureFilter field will identify what the current filtering method is. Another objective of this tutorial is to have a box and pyramid rotating around in circles. We will use the two angle fields to identify the current rotation of the objects. The associated isAnimating field will determine whether the game "is animating".
The first method we are going to look at is the ResetTranslation method. In previous projects, the transformation matrices were built once in the Initialize method. In this project, we are going to allow the user to move the object closer or further away in order to get a better look at the texture mapping. To make this easier, I have provided a ResetTranslation method which will be called both from within Initialize, as well as from within Update.
protected void ResetTranslation()
{
triangleTransform = Matrix.CreateTranslation(new Vector3(-1.5f, 0.0f, depth));
rectangleTransform = Matrix.CreateTranslation(new Vector3(1.5f, 0.0f, depth));
}
Now let's take a look at the Initialize method. The first thing we do is call ResetTranslation. This creates the world transformations for both the pyramid and the box, based on the starting depth of -6.0f. The next part of Initialize is the creation of two vertex arrays and index lists - one set for the pyramid, and one set for the box. In the previous tutorial, we used the built-in VertexPositionColor structure to represent our 3D vertices. However, because we are planning to use texture mapping in this project, we will need a different vertex structure. Specifically, one that has a position for each vertex and a set of texture coordinates (sometimes called UV coordinates), which identify which texel within a texture we'd like to map to the current vertex. Fortunately, XNA once again comes through with a pre-built structure called VertexPositionTexture.
protected override void Initialize()
{
ResetTranslation();
Vector2 topLeft = new Vector2(0.0f, 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);
VertexPositionTexture[] triangleData = new VertexPositionTexture[]
{
new VertexPositionTexture(new Vector3(1.0f, -1.0f, 1.0f), bottomRight),
new VertexPositionTexture(new Vector3(-1.0f, -1.0f, 1.0f), bottomLeft),
new VertexPositionTexture(new Vector3(0.0f, 1.0f, 0.0f), topRight),
new VertexPositionTexture(new Vector3(1.0f, -1.0f, -1.0f), bottomRight),
new VertexPositionTexture(new Vector3(1.0f, -1.0f, 1.0f), bottomLeft),
new VertexPositionTexture(new Vector3(0.0f, 1.0f, 0.0f), topRight),
new VertexPositionTexture(new Vector3(-1.0f, -1.0f, -1.0f), bottomRight),
new VertexPositionTexture(new Vector3(1.0f, -1.0f, -1.0f), bottomLeft),
new VertexPositionTexture(new Vector3(0.0f, 1.0f, 0.0f), topRight),
new VertexPositionTexture(new Vector3(-1.0f, -1.0f, 1.0f), bottomRight),
new VertexPositionTexture(new Vector3(-1.0f, -1.0f, -1.0f), bottomLeft),
new VertexPositionTexture(new Vector3(0.0f, 1.0f, 0.0f), topRight),
};
VertexPositionTexture[] boxData = new VertexPositionTexture[]
{
// Front Surface
new VertexPositionTexture(new Vector3(-1.0f, -1.0f, 1.0f),bottomLeft),
new VertexPositionTexture(new Vector3(-1.0f, 1.0f, 1.0f),topLeft),
new VertexPositionTexture(new Vector3(1.0f, -1.0f, 1.0f),bottomRight),
new VertexPositionTexture(new Vector3(1.0f, 1.0f, 1.0f),topRight),
// Front Surface
new VertexPositionTexture(new Vector3(1.0f, -1.0f, -1.0f),bottomLeft),
new VertexPositionTexture(new Vector3(1.0f, 1.0f, -1.0f),topLeft),
new VertexPositionTexture(new Vector3(-1.0f, -1.0f, -1.0f),bottomRight),
new VertexPositionTexture(new Vector3(-1.0f, 1.0f, -1.0f),topRight),
// Left Surface
new VertexPositionTexture(new Vector3(-1.0f, -1.0f, -1.0f),bottomLeft),
new VertexPositionTexture(new Vector3(-1.0f, 1.0f, -1.0f),topLeft),
new VertexPositionTexture(new Vector3(-1.0f, -1.0f, 1.0f),bottomRight),
new VertexPositionTexture(new Vector3(-1.0f, 1.0f, 1.0f),topRight),
// Right Surface
new VertexPositionTexture(new Vector3(1.0f, -1.0f, 1.0f),bottomLeft),
new VertexPositionTexture(new Vector3(1.0f, 1.0f, 1.0f),topLeft),
new VertexPositionTexture(new Vector3(1.0f, -1.0f, -1.0f),bottomRight),
new VertexPositionTexture(new Vector3(1.0f, 1.0f, -1.0f),topRight),
// Top Surface
new VertexPositionTexture(new Vector3(-1.0f, 1.0f, 1.0f),bottomLeft),
new VertexPositionTexture(new Vector3(-1.0f, 1.0f, -1.0f),topLeft),
new VertexPositionTexture(new Vector3(1.0f, 1.0f, 1.0f),bottomRight),
new VertexPositionTexture(new Vector3(1.0f, 1.0f, -1.0f),topRight),
// Bottom Surface
new VertexPositionTexture(new Vector3(-1.0f, -1.0f, -1.0f),bottomLeft),
new VertexPositionTexture(new Vector3(-1.0f, -1.0f, 1.0f),topLeft),
new VertexPositionTexture(new Vector3(1.0f, -1.0f, -1.0f),bottomRight),
new VertexPositionTexture(new Vector3(1.0f, -1.0f, 1.0f),topRight),
};

If you look at the last argument to the constructor of each of the VertexPositionTexture structures you will see one of four variables; topLeft, bottomLeft, topRight, and bottomRight. These correspond with the four corners of a texture. For those unfamiliar with texture mapping, the goal of texture mapping is to map the image found in a 2D texture onto the surface of a 3D primitive. In order to do this, we assign UV coordinates to each of the vertices of a triangle and then let the interpolator, executed between the vertex shader and the pixel shader, determine the UV coordinates of all the pixels in-between. Unlike world coordinates, or even screen coordinates, texels are represented as (u,v) pairs, in which each coordinate is a percent of the texture from 0.0f to 1.0f. For a visual representation of this, see the image to the right.
Ok, after that sojourn into texture mapping, let us return to the code at hand. Just after the collection of VertexPositionTexture structures there are two small arrays of short integers. These will be the indices for our 3D primitives.
short[] pyramidIndices = new short[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 };
short[] boxIndices = new short[] {
0, 1, 2, 2, 1, 3,
4, 5, 6, 6, 5, 7,
8, 9, 10, 10, 9, 11,
12, 13, 14, 14, 13, 15,
16, 17, 18, 18, 17, 19,
20, 21, 22, 22, 21, 23
};
For those not familiar with indices, consider this: A box has six sides, with two triangles per side. If we were to include each of the vertices for each of the triangles in our triangle list, we would have 36 vertices! If we think about it carefully, many of the vertices (two per side) are duplicates. They have the exact same position and UV coordinates as a vertex on other triangle of the same quad. By using index buffers, we can reduce the number of vertices pushed to the device, and re-use the ones needed by a different triangle.
With all that said, our current vertex structure has only two fields and our buffer is only 36 vertices long. That is not many vertices to send to the video card, nor is it an overly complex format. So why use index buffers? Simple, when you do not use index buffers, the vertex caching mechanism is disabled on most video cards, which forces all the vertices to be re-ran through your shaders. So not only does using index buffers reduce the number of vertices sent to the video card, but it can also reduce the number of vertices that must be shaded.
The next part of the Initialize method is the creation and assignment of the vertex and index buffers for both the pyramid and the box. For those people coming from a previous version of XNA, you may notice that in XNA 4.0 the VertexBuffer constructor now takes an argument of type VertexDeclaration. This is new, and you’ll see why when we get to the Draw method.
pyramidVB = new VertexBuffer(GraphicsDevice, VertexPositionTexture.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, VertexPositionTexture.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();
}
The LoadContent method has a couple new changes in it. First, after the ResetProjection method, which is responsible for computing and setting the projection matrix, we use the ContentManager to load a Texture2D object and set it on the effect. Simply put, this tells the effect which image you want to draw with.
protected override void LoadContent()
{
Effect tempEffect = Content.Load("Effects/Default");
effect = new DefaultEffect(tempEffect);
tempEffect = null;
ResetProjection();
effect.Texture = Content.Load("Textures/Crate");
GraphicsDevice.SamplerStates[0] = new SamplerState()
{
Filter = textureFilter
};
}
If we were to render all primitives perpendicular to the camera, and we were to draw them all at such a distance that there was precisely one texel for each corresponding pixel on the screen, then texture mapping would be simple. However, this is rarely (read never) the case (except in pre-transformed 2D rendering). In reality, 3D primitives are drawn at odd angles, sometimes with only a sliver on the screen. Additionally, objects are constantly moving back and forth, closer and further from the screen. When this happens, the video card and driver must apply a filter to the texture in order to determine how texels will be mapped to pixels.
When an object is positioned or scaled in such a way that multiple pixels on the screen map to a single texel, this is called texture magnification, as it appears as though the textures are being magnified. When a single pixel on the screen maps to multiple texels on the texture it is called texture minification. In previous editions of XNA, it was necessary to set the filtering method for magnification and minification separately. In XNA 4.0, however, both are set with a single property on the SamplerState class called Filter. This property can have any of the values of the TextureFilter enumeration, which as of XNA 4.0 includes:
- Linear
- Point
- Anisotropic
- LinearMipPoint
- PointMipLinear
- MinLinearMagPointMipLinear
- MinLinearMagPointMipPoint
- MinPointMagLinearMipLinear
- MinPointMapLinearMipPoint
The first three values are straightforward and instruct the device to use Linear, Point, or Anisotropic Filtering when doing minification, magnification, or mipmapping (which I will talk about more in a later tutorial). The latter six are a bit more difficult to read, but are no more complex, and instruct the device which filtering methods to use for each of the different filtering modes. For example, MinLinearMagPointMipLinear tells the device to lerp for both texture minification and mipmapping, but to use nearest-neighbor filtering for magnification.
The next method to look at is the Update method. This project adds several new conditions to the Update method. First, we check to see if any of the following keys were pressed: A, R, F, PageUp, and PageDown. The A key is used to toggle whether or not the shapes are being animated and the R key is used to "reset" the objects back to their original orientations. The PageUp and PageDown keys are used to adjust at what depth the object are being drawn at, with PageUp moving the objects further away, and PageDown moving them closer. Finally, the F key is used to iterate between the different filtering methods so you can see what impact they have on the rendering of our objects.
The last thing the Update method does is, if animating, update the pyramid and box rotation angles so the objects appear to move.
protected override void Update(GameTime gameTime)
{
if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
this.Exit();
KeyboardState keyboard = Keyboard.GetState();
if (keyboard.IsKeyDown(Keys.Escape))
Exit();
if (keyboard.IsKeyDown(Keys.F11) && prevKeyboardState.IsKeyUp(Keys.F11))
IsFullScreen = !IsFullScreen;
if (keyboard.IsKeyDown(Keys.A) && prevKeyboardState.IsKeyUp(Keys.A))
isAnimating = !isAnimating;
if (keyboard.IsKeyDown(Keys.R) && prevKeyboardState.IsKeyUp(Keys.R))
{
triangleAngle = 0f;
rectangleAngle = 0f;
}
if (keyboard.IsKeyDown(Keys.F) && prevKeyboardState.IsKeyUp(Keys.F))
{
textureFilter = textureFilter + 1;
if (textureFilter > TextureFilter.MinPointMagLinearMipPoint)
textureFilter = TextureFilter.Linear;
GraphicsDevice.SamplerStates[0] = new SamplerState()
{
Filter = textureFilter
};
}
if (keyboard.IsKeyDown(Keys.PageUp))
{
depth -= 0.05f;
ResetTranslation();
}
if (keyboard.IsKeyDown(Keys.PageDown))
{
depth += 0.05f;
ResetTranslation();
}
if (isAnimating)
{
triangleAngle += MathHelper.ToRadians(1.0f);
if (triangleAngle > MathHelper.TwoPi)
triangleAngle -= MathHelper.TwoPi;
rectangleAngle -= MathHelper.ToRadians(0.75f);
if (rectangleAngle < 0.0f)
rectangleAngle += MathHelper.TwoPi;
}
prevKeyboardState = keyboard;
}
The final method we will look at of the Game1 class is the Draw method. While arguably one of the most important methods of our class, it is in many ways the least remarkable as there are relatively few changes to it.
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(Color.CornflowerBlue);
effect.World = Matrix.CreateRotationY(triangleAngle) * triangleTransform;
effect.CurrentTechnique.Passes[0].Apply();
GraphicsDevice.SetVertexBuffer(pyramidVB);
GraphicsDevice.Indices = pyramidIB;
GraphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, 5, 0, 4);
effect.World = Matrix.CreateFromYawPitchRoll(rectangleAngle,
rectangleAngle, rectangleAngle) * rectangleTransform;
effect.CurrentTechnique.Passes[0].Apply();
GraphicsDevice.SetVertexBuffer(boxVB);
GraphicsDevice.Indices = boxIB;
GraphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, 24, 0, 12);
}
}
First, there are two additional statements before each of our Draw calls. The first is to SetVertexBuffer and the latter is the assignment of our Indices. In previous versions of XNA, the vertex buffer was set similarly to the Indices with the assignment of Vertices[slot], where "slot" identified the stream number you wanted to bind to. In XNA 4.0, however, we use a method call. It is important to note that SetVertexBuffer is overloaded and provides a second version that takes an index as the second parameter, which is used to specify the stream number to bind to.
Another new addition to XNA 4.0 with respect to vertices is the SetVertexBuffers method. SetVertexBuffers takes a VertexBufferBinding[] array, and can be used to set all the vertex streams in a single call. This could be useful if you are serious about performance and are breaking up your streams, or if you are doing hardware instancing and you need to reset multiple streams frequently.
One thing you should notice missing from this method is the assignment of the VertexDeclaration. In previous versions of XNA it was required that you set those manually so the device knew the format of the vertices. In XNA 4.0, however, the vertex declaration is bound to the vertex buffer, so it is set automatically when you call SetVertexBuffer.
The other thing that is new in the Draw method is which Draw method we call on the device. In previous tutorials, we called DrawUserPrimitive, but in this tutorial, since we are using indexed, buffered vertices, we call the corresponding DrawIndexedPrimitives method. This method is far more performant, especially for Draw calls with a larger number of primitives.
The DefaultEffect Class
With the Game1 class out of the way, we can finally turn our attention to the DefaultEffect class. Absent from this class are the shader index and diffuse material color properties which were added in tutorial 3. They are unnecessary in this tutorial because we are only using a single technique, and pulling our diffuse material color from a texture, rather than setting it via an EffectParameter. What is new in this version, however, is the addition of the Texture property. As you can see in the constructor, the EffectParameter for the texture is obtained and cached in the same method as any other shader parameter, and is used by the Texture property to set the texture for the effect. Overall, there is nothing particularly new or exciting here if you have been following along.
class DefaultEffect : Effect, IEffectMatrices
{
EffectParameter world;
EffectParameter projection;
EffectParameter texture;
public DefaultEffect(Effect effect)
: base(effect)
{
world = Parameters["World"];
projection = Parameters["Projection"];
texture = Parameters["DiffuseTexture"];
}
public Matrix World
{
get { return world.GetValueMatrix(); }
set { world.SetValue(value); }
}
public Matrix View
{
get { return Matrix.Identity; }
set { }
}
public Matrix Projection
{
get { return projection.GetValueMatrix(); }
set { projection.SetValue(value); }
}
public Texture2D Texture
{
get { return texture.GetValueTexture2D(); }
set { texture.SetValue(value); }
}
}
The DefaultEffect.fx File
The last part of this tutorial is the effect file for our default effect. There are two new global variables in this effect file. The first is an object of type Texture2D, which stores the texture object we will sample. The second is an object of type Sampler2D.
float4x4 World;
float4x4 Projection;
Texture2D DiffuseTexture;
sampler2D DiffuseSampler = sampler_state
{
Texture = ;
};
Samplers are a complex type that can have many different properties in them. However, we use only one in this tutorial, the Texture property, which we set to be a reference to our DiffuseTexture object. The other properties, which can be set in a sampler state, are the same as they are for XNA versions prior to 4.0, as well as DirectX 9. These include:
- AddressU
- AddressV
- AddressW
- BorderColor
- Filter
- MaxAnisotropy
- MaxLOD
- MinLOD
- MipLODBias
The next part of the effect file is the vertex declaration. Using the same convention as XNA, I named the struct after the elements that make up the vertex format. In this case, I call it VertexPositionTexture, just like that provided by XNA.
struct VertexPositionTexture
{
float4 Position : POSITION0;
float2 UV : TEXCOORD0;
};
Finally, we get to the vertex and pixel shaders. The vertex shader is very similar to the previous shaders. The first line creates an instance of the VertexPositionTexture structure, and is then followed by a simple computation which converts the input vertex position into projection space. The new part of the function is the assignment of the UV coordinates to the output structure. As we discussed in the previous tutorial, anything returned from the vertex shader is interpolated over the triangle and passed on to the pixel shader for further processing. This is precisely what we want, since we need each projected pixel on the screen to sample a texel within the texture.
Looking ahead to the pixel shader you can see that it has only a single line. In the pixel shader, we use the intrinsic function tex2D to sample a 2D texture given a sampler state and a texture coordinate. As our sampler, we use the global variable DiffuseSampler previously discussed. For the texture coordinate, we pass in the interpolated UV values by the interpolator before being passed into the pixel shader.
VertexPositionTexture TexturedVertexShader(VertexPositionTexture input)
{
VertexPositionTexture output;
output.Position = mul(input.Position, World);
output.Position = mul(output.Position, Projection);
output.UV = input.UV;
return output;
}
float4 TexturedPixelShader(VertexPositionTexture input) : COLOR0
{
return tex2D(DiffuseSampler, input.UV);
}
technique DefaultEffect
{
Pass
{
VertexShader = compile vs_2_0 TexturedVertexShader();
PixelShader = compile ps_2_0 TexturedPixelShader();
}
}
If you obtain the base code and make all of the previous changes, or, if you download the complete project file located at the bottom of this tutorial and compile/run it, you will see an image that looks similar to the following:
