We will begin with the simple example of rendering terrain using heightmaps. A heightmap is a grayscale image, containing the heights of the terrain. The shade of gray that a pixel has determines the height of the terrain at that point, white being the highest, and black being the lowest. When using floating point textures, the principle is the same. Each pixel contains a floating point value, 0.0f being the lowest height, and 1.0f being the highest. Building the terrain mesh from the heightmap texture could be easily done without using vertex textures, by reading the heights at loading time, when building the vertex buffer. As this operation is done only once (at loading) and not at every frame ( in the vertex shader), using vertex textures does not have any performance gains for this particular case. However, this is the simplest and most clear example that shows how Vertex Texture Fetch is used.
Let us begin by creating a new project (for Windows or Xbox360). Add to the project the files Camera.cs and Grid.cs. Then go to the game class (Game1.cs ), and add the VTFTutorial namespace to the using statements.
using VTFTutorial;
Now, add two member to the Game1 class, one for the camera component, and one for the grid, and instantiate them in the constructor of the class. The camera will be added to the Components list, and for the grid, we will set the properties CellSize and Dimension to 4 and 256 respectively. You can try setting the CellSize to any value you want, to see what happens. We will talk about what happens when changing the Dimension property later. Now, in the LoadGraphicsContent function, call grid.LoadGraphicsContent().
Camera camera;
Grid grid;
public Game1()
{
graphics = new GraphicsDeviceManager(this);
content = new ContentManager(Services);
camera = new Camera(this);
this.Components.Add(camera);
grid = new Grid(this);
grid.Dimension = 256;
grid.CellSize = 4;
}
protected override void LoadGraphicsContent(bool loadAllContent)
{
grid.LoadGraphicsContent();
}
We have the geometry, and now we need to create the effect file that will process and render the terrain. Add a new folder to the solution, and name it Shaders. Then Add -> New Item… and create a new text file named VTFDisplacement.fx. Once that is done, open it, and we will begin writing some HLSL (High Level Shading Language) code.
We need three parameters, the world, view and projection matrices. Next, we’ll add the heightmap texture (named displacementTexture, because it tells us how much the vertices will be displaced from the XZ plane), and the sampler for it. This sampler will be used in order to read the height data from the heightmap, inside the vertex shader. In the sampler, we use Point for all the filters, because Linear and Anisotropic are not supported for vertex textures. No error will be generated if we try them, but it will still only use Point filtering.
float4x4 world; // world matrix
float4x4 view; // view matrix
float4x4 proj; // projection matrix
float maxHeight = 128; //maximum height of the terrain
texture displacementMap; // this texture will point to the heightmap
sampler displacementSampler = sampler_state //this sampler will be used to read (sample) the heightmap
Texture = <displacementMap>;
MipFilter = Point;
MinFilter = Point;
MagFilter = Point;
AddressU = Clamp;
AddressV = Clamp;
};
Next, the shader structures for the Vertex Shader input and output. For the input, we have the position, and the texture coordinates. For the output, we have the new transformed position of the vertex, and we also pass the world position of the vertex to the pixel shader through the worldPos field. We will use this to color the terrain based on it’s height. The passed texture coordinates are not used for now.
struct VS_INPUT {
float4 position : POSITION;
float4 uv : TEXCOORD0;
};
struct VS_OUTPUT
{
float4 position : POSITION;
float4 uv : TEXCOORD0;
float4 worldPos : TEXCOORD1;
};
The code for the vertex shader looks like this:
VS_OUTPUT Transform(VS_INPUT In)
{
VS_OUTPUT Out = (VS_OUTPUT)0; //initialize the output structure
float4x4 viewProj = mul(view, proj); //compute View * Projection matrix
float4x4 worldViewProj= mul(world, viewProj); //finally, compute the World * View * Projection matrix
// this instruction reads from the heightmap, the value at the corresponding texture coordinate
// Note: we selected level 0 for the mipmap parameter of tex2Dlod, since we want to read data exactly as it appears in the heightmap
float height = tex2Dlod ( displacementSampler, float4(In.uv.xy , 0 , 0 ) );
// with the newly read height, we compute the new value of the Y coordinate
// we multiply the height, which is in the (0,1) range by a value representing the Maximum Height of the Terrain
In.position.y = height * maxHeight;
//Pass the world position the the Pixel Shader
Out.worldPos = mul(In.position, world);
//Compute the final projected position by multiplying with the world, view and projection matrices
Out.position = mul( In.position , worldViewProj);
Out.uv = In.uv;
return Out;
}
The tex2Dlod instruction reads the height from the displacementTexture, using the texture coordinates we set when building the grid in Grid.cs . Before the input vertex’s position is multiplied with all the matrices, we assign the height we’ve just read to the Y coordinate of the vertex. This way, the terrain is shaped according to the heightmap, and only then transformed using the world, view and projection matrices. All this is done on the graphics processor.
Let’s carry on, with the pixel shader. We simply draw the terrain, colored according to the height: white on top, and black at the bottom. The technique has one pass, using these two shaders. The compile target is vs_3_0 and ps_3_0, because, as pointed out earlier, VTF is a feature of the Shader Model 3.0
float4 PixelShader(in float4 worldPos : TEXCOORD1) : COLOR
{
return worldPos.y / maxHeight;
}
technique GridDraw
{
pass P0
{
vertexShader = compile vs_3_0 Transform();
pixelShader = compile ps_3_0 PixelShader();
}
}
Now that we’re done with the effect file, let’s get back to the Game class. Add a new folder named Textures to the project, and inside it, add the height1.dds file from the resources archive, and set the content processor to Texture(mipmapped) . We need to add a member to the class, to hold the effect file, and a member for the texture.
Effect gridEffect;
Texture2D displacementTexture;
In the LoadGraphicsContent, we need to load the effect and the texture, using these lines
gridEffect = content.Load<Effect>("Shaders\\VTFDisplacement");
displacementTexture = content.Load<Texture2D>("Textures\\height1");
In the Draw function, we add some code to set the effect parameters, and to render the grid. We leave our terrain in the center of the world, so the world matrix is set to Identity, while the view and projection matrix are retrieved from the camera component.
protected override void Draw(GameTime gameTime)
{
graphics.GraphicsDevice.Clear(Color.CornflowerBlue);
graphics.GraphicsDevice.RenderState.CullMode = CullMode.None;
gridEffect.Parameters["world"].SetValue(Matrix.Identity);
gridEffect.Parameters["view"].SetValue(camera.View);
gridEffect.Parameters["proj"].SetValue(camera.Projection);
gridEffect.Parameters["maxHeight"].SetValue(128);
gridEffect.Parameters["displacementMap"].SetValue(displacementTexture);
gridEffect.Begin();
foreach (EffectPass pass in gridEffect.CurrentTechnique.Passes)
{
pass.Begin();
grid.Draw();
pass.End();
}
gridEffect.End();
base.Draw(gameTime);
}
At this point, you should be able to run the program, and see something like this.
Bilinear Filtering
Until now, everything is ok. Let’s see what happens if we try to make the terrain larger, and set the grid.CellSize to 8, grid.Dimension to 512, and the maxHeight to 512.
That looks BAD! But what’s causing it? The heightmap’s size is 256 by 256. When the dimension of the grid was 256, each pixel of the texture corresponded to exactly one vertex in the grid. After we set the dimension to 512, each pixel in the texture now corresponds to TWO vertices in the grid, so every two vertices will have the same height. If we had bilinear filtering, the GPU would have automatically computed a mean value of 4 of the surrounding pixels and the result would have been smooth, not in steps, like it currently is. But since vertex textures do not support bilinear filtering, we will have to do it manually in the vertex shader. So let’s open the VTFDisplacement.fx file, and add the following code.
float textureSize = 256.0f; //size of the texture - these two would be set by the application in a real application
float texelSize = 1.0f / 256.0f; //size of one texel
float4 tex2Dlod_bilinear( sampler texSam, float4 uv )
{
float4 height00 = tex2Dlod(texSam, uv);
float4 height10 = tex2Dlod(texSam, uv + float4(texelSize, 0, 0, 0));
float4 height01 = tex2Dlod(texSam, uv + float4(0, texelSize, 0, 0));
float4 height11 = tex2Dlod(texSam, uv + float4(texelSize , texelSize, 0, 0));
float2 f = frac( uv.xy * textureSize );
float4 tA = lerp( height00, height10, f.x );
float4 tB = lerp( height01, height11, f.x );
return lerp( tA, tB, f.y );
}
This is the code for bilinear filtering. It samples the four pixels nearest to the current coordinates, and interpolates between them, to obtain the average height that the vertex should have. To use this function, in the vertex shader, replace
float height = tex2Dlod ( displacementSampler, float4(In.uv.xy , 0 , 0 ) );
with
float height = tex2Dlod_bilinear( displacementSampler, float4(In.uv.xy,0,0));
Now, for Dimension = 512, cellSize = 8 and maxHeight = 512, the terrain looks like this, which is much better:
Bonus: Texturing
Let us add some textures to the ground. A very good way to do this is to use a pixel shader to blend between several textures (ex: sand, grass, rock, snow), based on some weights, computed according to the height of each vertex. Riemers has a nice tutorial about this here, but in his implementation, the weights used to blend the textures are computed on the CPU, when loading the heightmap. We’ll see how to do this on the GPU. This code is based on Riemers’ formulas, so I take this opportunity to give credit where credit is due.
First, add sand.dds, grass.dds, rock.dds and snow.dds to the project, in the Textures folder. (Note: These textures are at a low resolution, to reduce the download size. Having these textures at thigh resolution greatly improves the quality of the rendering.) Then, open VTFDisplacement.fx and add four texture parameters, and samplers for each of them.
texture sandMap;
sampler sandSampler = sampler_state
{
Texture = <sandMap>;
MipFilter = Linear;
MinFilter = Linear;
MagFilter = Linear;
AddressU = Wrap;
AddressV = Wrap;
};
texture grassMap;
sampler grassSampler = sampler_state
{
Texture = <grassMap>;
MipFilter = Linear;
MinFilter = Linear;
MagFilter = Linear;
AddressU = Wrap;
AddressV = Wrap;
};
texture rockMap;
sampler rockSampler = sampler_state
{
Texture = <rockMap>;
MipFilter = Linear;
MinFilter = Linear;
MagFilter = Linear;
AddressU = Wrap;
AddressV = Wrap;
};
texture snowMap;
sampler snowSampler = sampler_state
{
Texture = <snowMap>;
MipFilter = Linear;
MinFilter = Linear;
MagFilter = Linear;
AddressU = Wrap;
AddressV = Wrap;
};
Now, we need to add some output parameters for the vertex shader. For each vertex, the output will contain four weights that specify what amount of each texture (sand, grass, rock and snow) should be used for the vertex. Thus, a vertex that has a small height, will only have sand, so the weight of sand will be 1, while the weights of the others will be zero. As the height increases, we have to make a transition from sand to grass, so the sand weight will decrease, while the grass weight will increase. Since each weight is a number between 0.0 and 1.0, we can pack these 4 floats inside a single float4 parameter. The new Output structure looks like this:
struct VS_OUTPUT
{
float4 position : POSITION;
float4 uv : TEXCOORD0;
float4 worldPos : TEXCOORD1;
float4 textureWeights : TEXCOORD2; // weights used for multitexturing
};
Let’s move forward, and at the bottom of the vertex shader, we’ll add the following code
float4 TexWeights = 0;
exWeights.x = saturate( 1.0f - abs(height - 0) / 0.2f);
exWeights.y = saturate( 1.0f - abs(height - 0.3) / 0.25f);
exWeights.z = saturate( 1.0f - abs(height - 0.6) / 0.25f);
exWeights.w = saturate( 1.0f - abs(height - 0.9) / 0.25f);
loat totalWeight = TexWeights.x +
TexWeights.y +
TexWeights.z +
TexWeights.w;
exWeights /=totalWeight;
ut.textureWeights = TexWeights;
Each component of the textureWeights vector corresponds to a certain texture, X for sand, Y for grass, Z for rock and W for snow. Each of the texture has an area where it becomes more visible, reaches a maximum of visibility, and then slowly fades, to leave room for the next texture. The last instruction normalizes the values, so the total sum of the weights for each vertex is 1. Otherwise, we would get darker or lighter areas in the transitions.
When sampling the textures in the Pixel Shader, we will multiply the texture coordinates by a value chosen by us (8 in this case), in order to have the texture repeated across the terrain. If we choose a value too low, the textures will be stretched over the whole terrain, and will look bad when viewing the terrain close. If we choose a value too high, the repetitive patter will be visible when viewing the terrain from above. Feel free to experiment with this value. A technique called detail texturing can also be applied to combine these textures with a very detailed texture when we are close to the ground, but I will not cover this technique here.
Finally, in the Pixel Shader, we read the colors from the four textures, and combine them using the weights, in order to achieve the transitions we were looking for.
float4 PixelShader(in float4 uv : TEXCOORD0, in float4 weights : TEXCOORD2) : COLOR
{
float4 sand = tex2D(sandSampler,uv * 8);
float4 grass = tex2D(grassSampler,uv * 8);
float4 rock = tex2D(rockSampler,uv * 8 );
float4 snow = tex2D(snowSampler,uv * 8 );
return sand * weights.x + grass * weights.y + rock * weights.z + snow * weights.w;
}
The final terrain should look like this:
This concludes the first chapter of this tutorial. In this chapter we saw how to use vertex textures to render a terrain from a heightmap, and texture it with multiple textures, everything running on the GPU. We also saw how bilinear filtering is implemented in the Vertex Shader. At this point you may wonder why would you want to move all these calculations on the GPU, where they are done every frame, instead of doing them once, at loading, on the CPU. In the following chapters, we will see how vertex textures can be used to dynamically morph a terrain, and how to add deformations to it. Normally, these things would consume a lot of CPU power, but we will do it all on the GPU, leaving the processor free for whatever you may need it: gameplay, physics, A.I., etc.
The complete code for this chapter can be found in Chapter1.zip .
Pingback: Game Rendering » Bilinear Interpolation
Pingback: Abandoned: Terrain 2 « dev in the making
Pingback: Large Scale Terrain | Phillip Hamlyn