Rendering terrains with Managed DirectX
There aren't many 3D games without a terrain. Creating and rendering a terrain, and the physics involved when, for example, driving on it with a car can be quite difficult. This article will demonstrate one technique to create a terrain: a simple technique to implement, but one that will look good.
In order to compile the code from this article, you�ll need the following:
- A C# compiler, preferably Visual Studio .NET.
- The DirectX 9.0c Software Development Kit.
- A graphics card that supports Pixelshader 2.0 would come in handy, because else you would have to use a Reference device (Very, very slow: around 10 seconds per frame).
I expect you, as the reader, to understand the C# language, and some Managed DirectX experience would be appropriate.
The Beginning
The first thing that you�ll need when rendering a terrain would be a way to represent the terrain. Often, grayscale height maps are used. Since it�s an easy way, this article will use a grayscale height map. This is a height map I�ve used, but you can easily modify it with whatever program you like.
We will use two textures. One grass, the other some sort of rock or stone. The idea is that the higher a pixel from the terrain is, the less that pixel is grass. So, we sort of blend the two textures dependent of the height of the pixel.
The High Level Shader Language
In order to do this, we�ll use the High Level Shader Language. With this language, one can write their own pixelshaders and vertexshaders to have greater control over how the vertices and pixels are rendered. The HLSL is a language very similar to C. So, it wouldn�t take much time to learn that language. Here�s the vertexshader we�ll use to render the terrain:
float4x4 WorldViewProj;float4 light;
void Transform(
in float4 inPos : POSITION0,
in float2 inCoord : TEXCOORD0,
in float4 blend : TEXCOORD1,
in float3 normal : NORMAL,
out float4 outPos : POSITION0,
out float2 outCoord : TEXCOORD0,
out float4 Blend : TEXCOORD1,
out float3 Normal : TEXCOORD2,
out float3 lightDir : TEXCOORD3 )
{
outPos = mul(inPos, WorldViewProj);
outCoord = inCoord;
Blend = blend;
Normal = normalize(mul(normal,WorldViewProj));
lightDir = inPos.xyz - light;
}
It looks like an ordinary C method except for one thing: in addition to just the names, the input and output variables are also marked with a semantic. This links the vertexshader input with the vertexdata from the application and the vertexshader output with the pixelshader input. You�ll probably notice that the semantic TEXCOORD
is used a lot; this is because the TEXCOORD
semantic can be used to pass application specific data, a variable that doesn�t represent the position, normal etc. The HLSL contains a number of math intrinsic, e.g., mul()
, normalize()
. The complete list of them can be found at MSDN. For more information about the High Level Shader Language, I would recommend you to look at some websites, because there�s a lot to say about it and this article would get a little too long when I would go deeper on it here.
I�ll describe briefly what the vertexshader does: first, the input position is multiplied with the world view projection matrix. So the vertex is transformed from object space to camera space. The input texture coordinate and the blend value are passed to the pixelshader. The normal is also transformed and then normalized. At last, the direction of the light is calculated by subtracting the position of the light from the position of the vector in world space (in this case, world space is the same as object space since there isn�t any translation, rotation, or scaling) to pass it to the pixelshader. This is the pixelshader:
Texture Texture1;
Texture Texture2;
sampler samp1 = sampler_state { texture = <Texture1>;
minfilter = LINEAR; mipfilter = LINEAR; magfilter = LINEAR;};
sampler samp2 = sampler_state { texture = <Texture2>;
minfilter = LINEAR; mipfilter = LINEAR; magfilter = LINEAR;};
float4 TextureColor(
in float2 texCoord : TEXCOORD0,
in float4 blend : TEXCOORD1,
in float3 normal : TEXCOORD2,
in float3 lightDir : TEXCOORD3) : COLOR0
{
float4 texCol1 = tex2D(samp1, texCoord*4) * blend[0];
float4 texCol2 = tex2D(samp2, texCoord) * blend[1];
return (texCol1 + texCol2) * (saturate(dot(normalize(normal),
normalize(light)))* (1-ambient) + ambient);
}
As you can see, the pixelshader takes almost every variable from the vertexshader, except for the POSITION0
variable, since that�s a variable that every vertexshader must output and our pixelshader wouldn�t use it. First, the two texture colors are calculated using the tex2D()
intrinsic, note that this tex2D
method doesn�t take a texture but a sampler. These colors are multiplied by the blend values and the addition of this two multiplied with the intensity of the light at that pixel, that value is returned. Instead of void
, this pixelshader returns a float4 marked with the COLOR0
semantic, every pixelshader must return a variable marked as COLOR0
or it won�t compile.
Back to C#
In order to get your app communicating with the shaders, you must also have a VertexDeclaration
. This will tell DirectX what the data in the VertexBuffer
represents and how it relates to the input variables of the vertexshader. This is the VertexDeclaration
used for the terrain:
VertexElement[] v = new VertexElement[]
{
new VertexElement(0,0,DeclarationType.Float3,DeclarationMethod.Default,
DeclarationUsage.Position,0),
new VertexElement(0,12,DeclarationType.Float3,DeclarationMethod.Default,
DeclarationUsage.Normal,0),
new VertexElement(0,24,DeclarationType.Float2,DeclarationMethod.Default,
DeclarationUsage.TextureCoordinate,0),
new VertexElement(0,32,DeclarationType.Float4,DeclarationMethod.Default,
DeclarationUsage.TextureCoordinate,1),
VertexElement.VertexDeclarationEnd
};
decl = new VertexDeclaration(device,v);
As you can see, this VertexDeclaration
contains an array of VertexElements
describing the struct
that I use. Speaking of that struct
, we can�t use one of the CustomVertex
members because we want to have the possibility to add the proportions of the textures in relation to each other for every vertex. So, this is the struct
we�ll use:
public struct Vertex
{
Vector3 pos;
Vector3 nor;
float tu,tv;
float b1,b2,b3,b4;
public Vertex(Vector3 p,Vector3 n,
float u,float v,float B1,float B2,
float B3, float B4, bool normalize)
{
pos = p;nor = n;tu = u;tv = v;
b1=B1; b2=B2; b3=B3;b4 = B4;
float total = b1 + b2 + b3 + b4;
if ( normalize)
{
b1 /= total;
b2 /= total;
b3 /= total;
b4 /= total;
}
}
public static VertexFormats Format =
VertexFormats.Position | VertexFormats.Normal |
VertexFormats.Texture0 | VertexFormats.Texture1;
}
It contains a Vector3
for position, a Vector3 for a normal
and, float
s for the texture coordinates and the blend values. In order to assign to the members of this struct
, it also contains a constructor. It also contains a Format
variable to pass to the VertexBuffer
.
In order to get DirectX communicating with the effect, we�ll need just one thing though: the Effect
class. Create an effect as follows:
The Effect class
String s = null;
effect = Effect.FromFile(device, @"..\..\simple.fx", null,
ShaderFlags.None, null, out s);
if ( s != null)
{
MessageBox.Show(s);
return;
}
By default, you can�t debug shaders, so when you�ve typed one thing incorrectly, you can spend hours looking for what�s wrong, not having a clue where to look. To avoid that we use the overload of the Effect
constructor which has an out parameter through which the effect gives the CompilationErrors
. Therefore, this overload succeeds even when it has failed compiling your shaders, only then the output string isn�t null anymore but the effect still is. So, if there is an error, a MessageBox
is shown showing these errors.
Well, except for the class that contains the entry point, this app also contains a Terrain
class. This class reads all the data from the bitmap and creates the VertexBuffer
and IndexBuffer
. We specify the height of the terrain by passing min and max values to the constructor. And to assure that min and max values get reached, we get the darkest and lightest pixel first. Every pixel from the bitmap will be a vertex, and the quads that arise when these vertices are connected will be split to triangles, forming a TriangleList
. The Draw
method assumes that effect.BeginScene()
is already called when the Draw
method is called. effect.BeginScene()
tells the effect that it now will receive things to render, and effect.EndScene()
that it should stop processing the VertexData
. Furthermore, the VertexDeclaration
, VertexBuffer
, and IndexBuffer
are set, and the DrawPrimitives
is finally called.
In order to modify the values of the global variables of your shader, the Effect
class contains the SetValue()
method. You could pass in a string to identify the variable you want to change:
effect.SetValue("Texture1", t1);
An other way is to create an EffectHandle
, like this:
EffectHandle handle = effect.GetParameter(null,"ambient");
effect.SetValue(handle,0.5f);
This way you don�t have to pass a string to the method, so it will be faster. So, variables that have to be assigned only once, can be assigned using the first way, but if a variable is changed multiple times, it would be better to use the second way. Note that this doesn�t only work with variables but also on techniques. With a technique, you choose which shaders you want to use; since an Effect file can contain multiple vertex- and pixelshaders, every HLSL file must have at least one technique. A technique is declared as follows:
technique TransformTexture
{
pass P0
{
VertexShader = compile vs_2_0 Transform();
PixelShader = compile ps_2_0 TextureColor();
}
}
A technique consists of one or more passes, in this case, only one with the name P0
; and for each pass, you assign a Vertex- and Pixelshader. The compilation target is set (there are quite a lot of versions of HLSL compilers: 1_1, 2_0, 3_0. The higher the version numbers, the more possibilities they offer but the less support there will be among the graphics cards). Transform()
and TextureColor()
are the names of the vertex- and pixelshaders.
In a pass, you can also set RenderState
values. If you would like to set the Device.RenderState.Cullmode
to Cull.None
, you would have to insert this line as the first line of the pass:
CullMode = None;
The Keys
- Escape: Quit
- Up: Increase ambient light.
- Down: Decrease ambient light.
- Space: Change camera position.
- Enter: Render normals.
Conclusion
Well, that's about all, I think. Of course, you can email or post all questions, suggestions, or tips.