10 min read

3D Graphics with XNA Game Studio 4.0

3D Graphics with XNA Game Studio 4.0

A step-by-step guide to adding the 3D graphics effects used by professionals to your XNA games.

  • Improve the appearance of your games by implementing the same techniques used by professionals in the game industry
  • Learn the fundamentals of 3D graphics, including common 3D math and the graphics pipeline
  • Create an extensible system to draw 3D models and other effects, and learn the skills to create your own effects and animate them

We will look at a technique called region growing to add plants and trees to the terrain’s surface, and finish by combining the terrain with our sky box, water, and billboarding effects to create a mountain scene:

Building a terrain from a heightmap

A heightmap is a 2D image that stores, in each pixel, the height of the corresponding point on a grid of vertices. The pixel values range from 0 to 1, so in practice we will multiply them by the maximum height of the terrain to get the final height of each vertex. We build a terrain out of vertices and indices as a large rectangular grid with the same number of vertices as the number of pixels in the heightmap.

Let’s start by creating a new Terrain class. This class will keep track of everything needed to render our terrain: textures, the effect, vertex and index buffers, and so on.

public class Terrain
 {
 VertexPositionNormalTexture[] vertices; // Vertex array
 VertexBuffer vertexBuffer; // Vertex buffer
 int[] indices; // Index array
 IndexBuffer indexBuffer; // Index buffer
 float[,] heights; // Array of vertex heights
 float height; // Maximum height of terrain
 float cellSize; // Distance between vertices on x and z axes
 int width, length; // Number of vertices on x and z axes
 int nVertices, nIndices; // Number of vertices and indices
 Effect effect; // Effect used for rendering
 GraphicsDevice GraphicsDevice; // Graphics device to draw with
 Texture2D heightMap; // Heightmap texture
 }

The constructor will initialize many of these values:

public Terrain(Texture2D HeightMap, float CellSize, float Height,
GraphicsDevice GraphicsDevice, ContentManager Content)
{
this.heightMap = HeightMap;
this.width = HeightMap.Width;
this.length = HeightMap.Height;

this.cellSize = CellSize;
this.height = Height;

this.GraphicsDevice = GraphicsDevice;

effect = Content.Load<Effect>(“TerrainEffect”);

// 1 vertex per pixel
nVertices = width * length;

// (Width-1) * (Length-1) cells, 2 triangles per cell, 3 indices per
// triangle
nIndices = (width – 1) * (length – 1) * 6;

vertexBuffer = new VertexBuffer(GraphicsDevice,
typeof(VertexPositionNormalTexture), nVertices,
BufferUsage.WriteOnly);

indexBuffer = new IndexBuffer(GraphicsDevice,
IndexElementSize.ThirtyTwoBits,
nIndices, BufferUsage.WriteOnly);
}


Before we can generate any normals or indices, we need to know the dimensions of our grid. We know that the width and length are simply the width and height of our heightmap, but we need to extract the height values from the heightmap. We do this with the getHeights() function:

private void getHeights()
{
// Extract pixel data
Color[] heightMapData = new Color[width * length];
heightMap.GetData<Color>(heightMapData);

// Create heights[,] array
heights = new float[width, length];

// For each pixel
for (int y = 0; y < length; y++)
for (int x = 0; x < width; x++)
{
// Get color value (0 – 255)
float amt = heightMapData[y * width + x].R;

// Scale to (0 – 1)
amt /= 255.0f;

// Multiply by max height to get final height
heights[x, y] = amt * height;
}
}


This will initialize the heights[,] array, which we can then use to build our vertices. When building vertices, we simply lay out a vertex for each pixel in the heightmap, spaced according to the cellSize variable. Note that this will create (width – 1) * (length – 1) “cells”—each with two triangles:

The function that does this is as shown:

private void createVertices()
{
vertices = new VertexPositionNormalTexture[nVertices];

// Calculate the position offset that will center the terrain at
(0, 0, 0)
Vector3 offsetToCenter = -new Vector3(((float)width / 2.0f) *
cellSize, 0, ((float)length / 2.0f) * cellSize);

// For each pixel in the image
for (int z = 0; z < length; z++)
for (int x = 0; x < width; x++)
{
// Find position based on grid coordinates and height in
// heightmap
Vector3 position = new Vector3(x * cellSize,
heights[x, z], z * cellSize) + offsetToCenter;

// UV coordinates range from (0, 0) at grid location (0, 0) to
// (1, 1) at grid location (width, length)
Vector2 uv = new Vector2((float)x / width, (float)z / length);

// Create the vertex
vertices[z * width + x] = new VertexPositionNormalTexture(
position, Vector3.Zero, uv);
}
}


When we create our terrain’s index buffer, we need to lay out two triangles for each cell in the terrain. All we need to do is find the indices of the vertices at each corner of each cell, and create the triangles by specifying those indices in clockwise order for two triangles. For example, to create the triangles for the first cell in the preceding screenshot, we would specify the triangles as [0, 1, 4] and [4, 1, 5].

private void createIndices()
{
indices = new int[nIndices];

int i = 0;

// For each cell
for (int x = 0; x < width – 1; x++)
for (int z = 0; z < length – 1; z++)
{
// Find the indices of the corners
int upperLeft = z * width + x;
int upperRight = upperLeft + 1;
int lowerLeft = upperLeft + width;
int lowerRight = lowerLeft + 1;

// Specify upper triangle
indices[i++] = upperLeft;
indices[i++] = upperRight;
indices[i++] = lowerLeft;

// Specify lower triangle
indices[i++] = lowerLeft;
indices[i++] = upperRight;
indices[i++] = lowerRight;
}
}


The last thing we need to calculate for each vertex is the normals. Because we are creating the terrain from scratch, we will need to calculate all of the normals based only on the height data that we are given. This is actually much easier than it sounds: to calculate the normals we simply calculate the normal of each triangle of the terrain and add that normal to each vertex involved in the triangle. Once we have done this for each triangle, we simply normalize again, averaging the influences of each triangle connected to each vertex.

private void genNormals()
{
// For each triangle
for (int i = 0; i < nIndices; i += 3)
{
// Find the position of each corner of the triangle
Vector3 v1 = vertices[indices[i]].Position;
Vector3 v2 = vertices[indices[i + 1]].Position;
Vector3 v3 = vertices[indices[i + 2]].Position;

// Cross the vectors between the corners to get the normal
Vector3 normal = Vector3.Cross(v1 – v2, v1 – v3);
normal.Normalize();

// Add the influence of the normal to each vertex in the
// triangle
vertices[indices[i]].Normal += normal;
vertices[indices[i + 1]].Normal += normal;
vertices[indices[i + 2]].Normal += normal;
}

// Average the influences of the triangles touching each
// vertex
for (int i = 0; i < nVertices; i++)
vertices[i].Normal.Normalize();
}


We’ll finish off the constructor by calling these functions in order and then setting the vertices and indices that we created into their respective buffers:

createVertices();
createIndices();
genNormals();

vertexBuffer.SetData<VertexPositionNormalTexture>(vertices);
indexBuffer.SetData<int>(indices);


Now that we’ve created the framework for this class, let’s create the TerrainEffect.fx effect. This effect will, for the moment, be responsible for some simple directional lighting and texture mapping. We’ll need a few effect parameters:

float4x4 View;
float4x4 Projection;

float3 LightDirection = float3(1, -1, 0);
float TextureTiling = 1;

texture2D BaseTexture;
sampler2D BaseTextureSampler = sampler_state {
Texture = <BaseTexture>;
AddressU = Wrap;
AddressV = Wrap;
MinFilter = Anisotropic;
MagFilter = Anisotropic;
};


The TextureTiling parameter will determine how many times our texture is repeated across the terrain’s surface—simply stretching it across the terrain would look bad because it would need to be stretched to a very large size. “Tiling” it across the terrain will look much better.

We will need a very standard vertex shader:

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

struct VertexShaderOutput
{
float4 Position : POSITION0;
float2 UV : TEXCOORD0;
float3 Normal : TEXCOORD1;
};

VertexShaderOutput VertexShaderFunction(VertexShaderInput input)
{
VertexShaderOutput output;

output.Position = mul(input.Position, mul(View, Projection));
output.Normal = input.Normal;
output.UV = input.UV;

return output;
}


The pixel shader is also very standard, except that we multiply the texture coordinates by the TextureTiling parameter. This works because the texture sampler’s address mode is set to “wrap”, and thus the sampler will simply wrap the texture coordinates past the edge of the texture, creating the tiling effect.

float4 PixelShaderFunction(VertexShaderOutput input) : COLOR0
{
float light = dot(normalize(input.Normal),
normalize(LightDirection));
light = clamp(light + 0.4f, 0, 1); // Simple ambient lighting

float3 tex = tex2D(BaseTextureSampler, input.UV * TextureTiling);
return float4(tex * light, 1);
}


The technique definition is the same as our other effects:

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


In order to use the effect with our terrain, we’ll need to add a few more member variables to the Terrain class:

Texture2D baseTexture;
float textureTiling;
Vector3 lightDirection;


These values will be set from the constructor:

public Terrain(Texture2D HeightMap, float CellSize, float Height,
Texture2D BaseTexture, float TextureTiling, Vector3 LightDirection,
GraphicsDevice GraphicsDevice, ContentManager Content)
{
this.baseTexture = BaseTexture;
this.textureTiling = TextureTiling;
this.lightDirection = LightDirection;

// etc…


Finally, we can simply set these effect parameters along with the View and Projection parameters in the Draw() function:

effect.Parameters[“BaseTexture”].SetValue(baseTexture);
effect.Parameters[“TextureTiling”].SetValue(textureTiling);
effect.Parameters[“LightDirection”].SetValue(lightDirection);


Let’s now add the terrain to our game. We’ll need a new member variable in the Game1 class:

Terrain terrain;


We’ll need to initialize it in the LoadContent() method:

terrain = new Terrain(Content.Load<Texture2D>(“terrain”), 30, 4800,
Content.Load<Texture2D>(“grass”), 6, new Vector3(1, -1, 0),
GraphicsDevice, Content);


Finally, we can draw it in the Draw() function:

terrain.Draw(camera.View, camera.Projection);


Multitexturing

Our terrain looks pretty good as it is, but to make it more believable the texture applied to it needs to vary—snow and rocks at the peaks, for example. To do this, we will use a technique called multitexturing, which uses the red, blue, and green channels of a texture as a guide as to where to draw textures that correspond to those channels. For example, sand may correspond to red, snow to blue, and rock to green. Adding snow would then be as simple as painting blue onto the areas of this “texture map” that correspond with peaks on the heightmap. We will also have one extra texture that fills in the area where no colors have been painted onto the texture map—grass, for example.

To begin with, we will need to modify our texture parameters on our effect from one texture to five: the texture map, the base texture, and the three color channel mapped textures.

texture RTexture;
sampler RTextureSampler = sampler_state
{
texture = <RTexture>;
AddressU = Wrap;
AddressV = Wrap;
MinFilter = Anisotropic;
MagFilter = Anisotropic;
};

texture GTexture;
sampler GTextureSampler = sampler_state
{
texture = <GTexture>;
AddressU = Wrap;
AddressV = Wrap;
MinFilter = Anisotropic;
MagFilter = Anisotropic;
};

texture BTexture;
sampler BTextureSampler = sampler_state
{
texture = <BTexture>;
AddressU = Wrap;
AddressV = Wrap;
MinFilter = Anisotropic;
MagFilter = Anisotropic;
};

texture BaseTexture;
sampler BaseTextureSampler = sampler_state
{
texture = <BaseTexture>;
AddressU = Wrap;
AddressV = Wrap;
MinFilter = Anisotropic;
MagFilter = Anisotropic;
};

texture WeightMap;
sampler WeightMapSampler = sampler_state {
texture = <WeightMap>;
AddressU = Clamp;
AddressV = Clamp;
MinFilter = Linear;
MagFilter = Linear;
};


Second, we need to update our pixel shader to draw these textures onto the terrain:

float4 PixelShaderFunction(VertexShaderOutput input) : COLOR0
{
float light = dot(normalize(input.Normal), normalize(
LightDirection));
light = clamp(light + 0.4f, 0, 1);

float3 rTex = tex2D(RTextureSampler, input.UV * TextureTiling);
float3 gTex = tex2D(GTextureSampler, input.UV * TextureTiling);
float3 bTex = tex2D(BTextureSampler, input.UV * TextureTiling);
float3 base = tex2D(BaseTextureSampler, input.UV * TextureTiling);

float3 weightMap = tex2D(WeightMapSampler, input.UV);

float3 output = clamp(1.0f – weightMap.r – weightMap.g –
weightMap.b, 0, 1);
output *= base;

output += weightMap.r * rTex + weightMap.g * gTex +
weightMap.b * bTex;

return float4(output * light, 1);
}


We’ll need to add a way to set these values to the Terrain class:

public Texture2D RTexture, BTexture, GTexture, WeightMap;


All we need to do now is set these values to the effect in the Draw() function:

effect.Parameters[“RTexture”].SetValue(RTexture);
effect.Parameters[“GTexture”].SetValue(GTexture);
effect.Parameters[“BTexture”].SetValue(BTexture);
effect.Parameters[“WeightMap”].SetValue(WeightMap);


To use multitexturing in our game, we’ll need to set these values in the Game1 class:

terrain.WeightMap = Content.Load<Texture2D>(“weightMap”);
terrain.RTexture = Content.Load<Texture2D>(“sand”);
terrain.GTexture = Content.Load<Texture2D>(“rock”);
terrain.BTexture = Content.Load<Texture2D>(“snow”);


LEAVE A REPLY

Please enter your comment!
Please enter your name here