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.
I expect you, as the reader, to understand the C# language, and some Managed DirectX experience would be appropriate.
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.
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.
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,
floats 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:
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;
Well, that's about all, I think. Of course, you can email or post all questions, suggestions, or tips.
General
News
Question
Answer
Joke
Rant
Admin