.:[ Please always use http://www.32bits.co.uk to access the site. Thanks! ]:.
Last updated: 10:55 AM Monday October 2008
![]()
It's been a while, hasn't it! But, let's put the past behind us and get on with this tutorial. This is one of those tutorials you'll hate me for showing you how it's done & ruining the magic, and at the same time you'll love being able to do it. What am I talking about? This:
Fig 2.1: No, were not turning into Flipcode IOTD.
Oh yes. It's the terrain demo from the Products section. It's time for the terrain engine tutorial, and you're about to learn the principles behind the simple heightmap engine and the skybox - 2 extremely popular effects that you'll find plenty of uses for. The most important concept you'll learn in this tutorial is actually something called an indexbuffer, which we'll come back to shortly.Before we start, I'd like to make a few things clear. First of all, this is only one of very many ways you can create a nice looking terrain engine. It has the benefit of being the first method invented (and thus the simplest), but it has many restrictions. As you'll see shortly, the use of a heightmap only allows us to create variations in the height of our terrain - we can't create caves, ridges, overhangs or any other "objects" that require vertices to be placed above each other on the Y axis. If you enjoy this tutorial and want to learn more, you can find a small section at the end of this tutorial discussing other options and enhancements you can make. Secondly, if you code your own engine and submit it to Flipcode IOTD (also known as Flipcode Terrain Of The Day), I'll be very, very unhappy. I actually feel guilty about showing more people how to make a terrain engine :). Okay, enough talking, let's get to the good bit. You'll be very surprised at how little code it takes to create this engine - I was originally going to split the terrain and skybox tutorials into 2 seperate documents, but we'll fit it all in here no problem.
First, we need to discuss the principles behind creating the terrain itself. The basis of this terrain engine is a heightmap, which is nothing more than a greyscale bitmap representing our terrain:
Fig 2.2: Our heightmapAs our bitmap is reduced to a colour depth of only 256 colours, it logically follows that every single pixel on the heightmap has an RGB value from 0 to 255. Nothing complicated there. Note that our bitmap is also handily sized to dimensions of 128 * 128. So, what do we do with it? Well, replace the word "RGB" with "height" in the first sentance, and you have this: "every single pixel on the heightmap has a height value from 0 to 255". Making sense? The heightmap holds 128 * 128 different pixels, with the RGB value of each representing a height from 0 to 255. Our heightmap literally represents the height of each part of our terrain.
Why do we create a greyscale image? Good question, and there's a simple answer - it's easier! If our image is greyscale, the value of the red, green and blue components for each pixel are exactly the same, therefore when we read the heights out of the heightmap it won't matter which RGB component we use. As a side note, there are more advanced uses for heightmaps where it would be useful to split the RGB channels out. Imagine that we wanted to do some nice lighting, for example. As we learnt in DirectX Basics Series 3 Tutorial 5, lighting is dependant on having the correct vertex normals set. Because our terrain is static (it doesn't change shape, because it's defined by the heightmap), we know the angle every pixel on our heightmap represents. So, instead of doing lengthy calculations at runtime, we could simply pre-calculate the vertex normals for every pixel on the heightmap, and store the X, Y and Z components of each normal in the heightmap itself. This is much easier than it sounds - formats such as .PNG support an alpha channel, so we have the red, green, blue and alpha channels to play with in our heightmap image. Set the alpha component of the pixel to the desired height (0-255), and set the R, G and B components the pixel to the X, Y and Z components of it's normal. Hey presto, in one source you have a heightmap and a "normalmap", all rolled into one. Incidentally, before you go off to do this manually, there are tools than can automate this for you.
But how do we get from a bitmap to a terrain? It's easier than you think. Imagine that every pixel on the heightmap has a vertex associated with it, and the Y axis position of the vertice is equal to the RGB value of the pixel. For example, a vertice that was associated with a pixel that has an RGB value of 128,128,128 will be placed at exactly 128 units from the origin on the Y axis. Here's a diagram to help you get this clear:
Fig 2.3: Zoom of the heightmap
In Fig 2.3 you can see a magnified portion of the heightmap. When our code scans the heightmap, it will read the RGB value of each pixel and translate it to a Y coordinate for each vertex. In this magnified section you can see we'll have a steep slope, as the RGB of these pixels goes from 255,255,255 to 0,0,0 within the space of a few pixels.Now you know how we use a heightmap to create our terrain. The problem is, how do we texturemap it? This is where we cheat and make life easy for ourselves. To texturemap this heightmap and make it look like a real terrain, we simply stretch a texture across the mesh.
What's a mesh? In simple terms, it's a whole lot of vertices that belong together. You'll see the word "mesh" used more in the future, and it's mainly used in relation to 3D objects that have been exported from 3D GFX apps such as Maya or 3DS. I'm using the word mesh here to represent all the vertices we'll use for our terrain, as they do "belong" together.
Here's how our heightmap mesh looks before we texturemap it. The first image is in greyscale, and the second is in wireframe. This should clear up any confusion on how the heightmap bitmap relates to the terrain:
Fig 2.4: Greyscale terrain, created by our heightmap
Fig 2.5: The same screenshot, shown in wireframe
If you're still having trouble picturing what's happening, download the workspace (below), open the island.bmp file in any paint package, draw some white lines on it and run the demo. You'll be able to see exactly what's going on then. And now here's our mesh after texturemapping:
Fig 2.6: The same screenshot, textured
Okay, hold on. Aside from the fact that we don't have a fancy background (I removed the skybox, which we'll come back to), that screenshot doesn't look the same as Fig2.1. Why? The answer is that part of the trick of doing heightmapped terrain lies in setting the correct renderstates. Take a look at this version:
Fig 2.7: The same screenshot again, with renderstates
See the difference? Here we're using colour modulation between the vertex diffuse colour, and the texture itself (we discussed texture stages in DirectX Basics Series 3 Tutorial 4). This is a handy way to make our terrain look nicer, at no extra cost. The final piece of the puzzle is the actual texture itself. If you look at islandmap.bmp in the workspace download, you'll see this image:
Fig 2.8: The terrain map for our terrain
Hopefully now it all makes sense! Once we create our terrain mesh from our heightmap, we simply stretch the entire "ground map" shown in Fig 2.8 across the mesh and hey presto - we have our terrain!At this point, I'd like to share a little advice with you. First of all, your terrain will only ever look as good as your ground map. For this terrain demo, I used (ie: completely stole :) the heightmap and groundmap that comes with T2 by Keith Ditchburn. T2 is one of many texturemap generators (commonly called texgens), and it unfortunately has a few bugs. Taking Fig 2.8 as an example, you can see all the black pixels on the groundmap - this should never happen. Anyway, I suggest you have a play with some different texgens - I personally recommend Terragen (which you can also use for generating skybox textures, as you'll see shortly). Make big groundmaps, and make them good quality.
My second piece of advice relates to the mesh itself. When I wrote the first version of this demo, I created an extremely tight mesh (ie: lots of vertices, placed close together). If you take a look at the demo page, you can see a screenshot of this. I showed this first version to Sol, and he pointed out something extremely obvious. There's absolutely no need to use a mesh that tight, you can produce a great looking terrain with a smaller mesh that's more widely spaced. It's the groundmap quality that counts, which is why the heightmap is only 128*128, whilst the groundmap is 1024*1024.
My third piece of advice is to do with vertex positioning. Remember that your groundmap is going to be mapped directly onto the vertices that make up your terrain. Therefore, try to keep very steep slopes to a minimum. The reason for this is simply texture quality. If 2 vertices are 50 units apart on the Y axis, and only 1 unit apart on the X axis they will obviously only use a few pixels of the groundmap. This means that those few pixels will have to be stretched over a greater distance than if they were 5 units apart on the Y axis. Here's an example:
Fig 2.9: Texture stretching due to badly placed vertices
The white rectangle marks the area where the texture has been stretched badly. I've drawn an example triangle onto the terrain to show you why. Because that section of terrain has a large variation in height over a short distance, only a few pixels of the groundmap are used. D3D is then forced to stretch the texture over the quad, which results in the loss of image quality. Compare that section of terrain to the rest of the screenshot - because the other terrain has relatively small height variations between vertices, the texture quality is much higher. So in summary, keep the height variations between vertices (ie: between pixels on your heightmap) as small as possible.Before I show you the code, there's one last piece of theory we need to cover. Have a think for a moment about how we create terrain from a heightmap. First, we create a heightmap. Then, we create a groundmap. We create a set of vertices, one for each pixel on the heightmap, and we texturemap them with our groundmap.
Hold on a second, that can't work. As we know from DirectX Basics Series 3, when we create our vertex struct we can add 2 floats to it to represent the 0.0f to 1.0f scale for texturemapping. That's fine, but doing that will require us to create loads of duplicate vertices. Have a look at this:
Fig 2.10: Uhoh, trouble ahead.
In Fig2.10, I've drawn 4 squares, and divided them up into 8 triangles. Nothing complicated there. Now, assuming I wanted to texturemap the same texture across all 4 squares, I've marked the texcoords I'd use. As normal, the top left vertex gets a texcoord of 0.0, 0.0, and the bottom right vertex gets a texcoord of 1.0, 1.0. But take a look at the middle row of vertices for a second. The left and right vertices are used by 3 triangles, and the middle vertex is used by a huge 6 triangles! And, hold on, all the other vertices are used by at least 2 other triangles. What a nightmare - we're going to need stacks of vertices just to create 4 squares! And just think how many vertices our terrain mesh is going to require. We're screwed!Oh no we're not :) The technical term for this situation is called "shared vertices", and we have a solution. It's called an index buffer, and it's very, very handy for cutting down vertices. Fig 2.10 is a classic example of shared vertices, and where an index buffer would shine. Whilst we have 4 squares made up of 8 triangles, we only actually have 9 unique vertices. Count them - 3 across the top, 3 across the middle and 3 across the bottom. Yes, they are used more than once by different triangles, but there are only 9 of them.
So what does an index buffer do, and how does it solve this problem? In simple terms, an index buffer allows us to forget about vertex ordering. Think back to all the other code we've written so far - when we created the cubes here and here, we had to specify the vertices in the correct order for culling and rendering. In other words, we didn't specify the front top left vertice as vertice number 1 in our vertex buffer, and then the back bottom right vertex as vertice number 2, because of course when D3D parses the vertex buffer and tries to render the primitives, it won't be using the correct sequence of vertices.
But this is exactly what an index buffer allows us to do - it literally creates an indexed list of vertices in the order required. This means we can create a vertex buffer containing all our vertices in any order we want, and then use the index buffer to tell D3D which vertices we want in what order. An index buffer simply contains a list of numbers representing positions in the associated vertex buffer. D3D reads each number out of the index buffer in sequence, and picks the appropriate vertex from the vertex buffer. Here's an example:
Fig 2.11: Index buffers at work
In Fig2.11 you can see a comparison between rendering using straight vertexbuffers, and rendering using indexbuffers. On the left half of the diagram, you can see the old DrawPrimitive() method. DrawPrimitive() runs through the vertexbuffer sequentially, picking out 3 vertices at a time (which I've marked for you). As you can see, the order of the vertices in the vertexbuffer is the order they are used to create a triangle. On the right half however, we can see that an indexbuffer makes the order of the vertices in the vertex buffer irrelevant. DrawIndexedPrimitive() steps through the indexbuffer sequentially, and uses the numbers in it as indexes into the vertexbuffer. As a practical example, DrawIndexedPrimitive() will read the number 5 out of the indexbuffer first. It then knows to take the vertex at index number 5 (remember, 0 based!) from the vertexbuffer, and use that as vertex number 1 for the first triangle. Next, it reads 1 from the indexbuffer, and then takes the vertex at index 1 from the vertex buffer as the second vertex for the triangle. Finally it reads 0 from the indexbuffer, so it takes the first vertex from the vertexbuffer, and uses it as the last vertex for the triangle. If you're still unsure about how this works, go through the second triangle yourself.The great thing about index buffers is that they allow us to use a vertex in the vertex buffer more than once, we just need to add it's index into the indexbuffer every time we want to use it. Have a think about the vertex reduction we can do now - all our cubes in previous tutorials originally used 36 vertices, but now we can reduce that to just 8 shared ones!
There's one important thing you need to keep in mind though. Index buffers are great for reducing vertices, but they can cause a problem with texturemapping and lighting. Take a look at the following screenshot:
Fig 2.12: Index buffers can't always help
It's the cube from the lighting tutorial back in Series 3. On it, I've marked out the triangles used to create the top, front and left faces, and a vertex that's shared between all 3 faces. Now, remembering that we are using the same vertex for triangles on all 3 faces, see if you can work out the correct texture coordinates for it. I'll start you off - for the front face, that vertice would require texcoords of (0, 0).Really, try and work it out, don't just read on :)
Okay, given up? Thought you might, because that question is impossible to answer. The vertex I've circled is shared between 3 faces. For the front face, it's texcoords would be (0, 0). For the top face, it's texcoords would be (1.0, 1.0), and for the left face it's texcoords would be (1.0, 0.0). This means that it requires 3 different texcoords to allow us to texturemap the cube correctly, and as we know there's no way to specify more than one set of texture coordinates for the same texture stage. In other words, this is a situation where we have shared vertices, but we can't use an indexbuffer due to texturemapping problems. The same issue occurs for lighting, as every vertex requires a correct normal for the face it belongs to.
Okay, okay, before someone emails me and points out that I'm not completely correct. Yes, it is technically possible to texturemap this cube correctly whilst using shared vertices in an indexbuffer. I can think of 2 ways to do it, and both of them are pretty pointless. The first one, using stages, is probably not supported by many cards (it's definately not supported by the Geforce2 or less). The second one, manually changing the contents of the vertexbuffer, is not worth considering - indexbuffers are a good way to cut down on vertices and speed our rendering up. By manually changing the contents of the vertexbuffer at render time, we'd be removing all the benefits of an indexbuffer. In fact, we'd be doing more harm than good.
The basic rule of thumb is that if the faces of shared vertices are going to be texturemapped using the same texture in the same texcoord range, you can use an indexbuffer. Take the cube above as an example. Each face is texturemapped individually, and has texcoords running from (0.0, 0.0) to (1.0, 1.0) within the confines of each face. We can't use an indexbuffer here, as we just discussed. But for our terrain engine, where out texcoords will start at (0.0, 0.0) at the very top left vertex, and finish at (1.0, 1.0) at the very bottom right vertex, we can use an indexbuffer. In other words, if the texture coordinates (and normals, if you're doing lighting) of a vertex will be the same, regardless of which triangle it's associated with, you can use an indexbuffer.Now you know the theory behind the heightmap terrain engine, and how indexbuffers work. We're going to come back to the skybox later, but for now it's time to look at the code.
[ Download complete VC6 workspace as .zip ]
Terrain.cpp [ View source code in this window ] [ View source code in a new window ] [ Source code in plain ascii .cpp file ]TerrainEngine.cpp [ View source code in this window ] [ View source code in a new window ] [ Source code in plain ascii .cpp file ]
TerrainEngine.h [ View source code in this window ] [ View source code in a new window ] [ Source code in plain ascii .cpp file ]
Skybox.cpp [ View source code in this window ] [ View source code in a new window ] [ Source code in plain ascii .cpp file ]
Skybox.h [ View source code in this window ] [ View source code in a new window ] [ Source code in plain ascii .cpp file ]
It looks like a lot of code, but you'll be surprised at just how easy this is. Just so you know, I've implemented the terrain engine and the skybox as self contained classes, so the driver code (terrain.cpp) is pretty much standard - we'll run through it quickly last. I've also done something slightly different with the source bitmaps this time; I've included them as resources in the application so I can show you the code to load a bitmap from a resource.
Wondering what a resource is? "Resource" is the generic name given to data stored as part of the application, rather than as seperate files. In other words, instead of distributing an executable plus some .bmp and .mp3 files, you include them as resources in your executable. The linker then links these resources into a special section of your executable, and produces a single .exe file with all your compiled code, bitmaps, mp3s and whatever else you want inside. Hey presto, only one file to distribute :)
Let's get to it! First up, here's the class & vertex struct definition in TerrainEngine.h:
typedef struct _tagTerrainVertex
{
float x, y, z;
D3DCOLOR dwColour;
float tu1, tv1;
} TERRAINVERTEX;
#define D3DFVF_TERRAIN (D3DFVF_XYZ | D3DFVF_DIFFUSE | D3DFVF_TEX1 )
class CTerrainEngine
{
public:
LPDIRECT3DTEXTURE8 m_pTextureHeightmap; // heightmap texture
LPDIRECT3DTEXTURE8 m_pTextureGround; // texture for ground level
LPDIRECT3DVERTEXBUFFER8 m_pVertexBuffer; // VB for terrain mesh
LPDIRECT3DINDEXBUFFER8 m_pIndexBuffer; // Index buffer for terrain
int m_nHeightArray[256][256]; // 0-255*0-255 array for Y vals of each heightmap pixel
TERRAINVERTEX m_vTerrain[128*128]; // 6 verts per quad, one quad per pixel
short m_sIndices[(6*(128*128))]; // Index buffer indices
CTerrainEngine();
~CTerrainEngine();
HRESULT Initialise(LPDIRECT3DDEVICE8& pDevice, char* pszHeightmap);
HRESULT Render(LPDIRECT3DDEVICE8& pDevice, LPDIRECT3DSURFACE8& pBackBuffer);
};
Nothing to worry about here, I think you'll agree. We need 2 texture surfaces, one for the heightmap and one for the groundmap, and a vertexbuffer and indexbuffer to render our tri's that make up the mesh. For ease, I've created a 2D array of int's to hold the Y value for each vertex - we'll fill this array with the RGB value from each pixel in the heightmap. In case you're wondering why the array is 256*256, but the heightmap is only 128*128, I did this for ease of calculations as you'll see when we get to the Initialise() method. Our heightmap is 128*128, and we're working on a scale of 1pixel = 1 quad, so I created an array of 128*128 TERRAINVERTEX vertices, which we'll copy into our vertexbuffer during initialisation. Finally the m_sIndices member is going to be used for the indexbuffer vertex order list. The indexbuffer works in the same way as the vertexbuffer - we create an array of indices, then copy it into the buffer, as you'll see in a second. We calculate the number of indices because of our scale. On a 1pixel = 1 quad scale, we have 128*128 pixels in our heightmap. Every quad has 6 vertices (a quad is 2 triangles, remember!), so we need 6*(128 * 128) vertex indices in our indexbuffer.Now, onto the implementation of our terrain engine. Our constructor is empty, and our destructor just deallocates COM references, so the first interesting method is CTerrainEngine::Initialise().
rslt=D3DXCreateTextureFromResourceEx(pDevice, GetModuleHandle( NULL ), MAKEINTRESOURCE(IDB_ISLAND), D3DX_DEFAULT, D3DX_DEFAULT, D3DX_DEFAULT, 0, D3DFMT_UNKNOWN, D3DPOOL_MANAGED, D3DX_DEFAULT, D3DX_DEFAULT, 0, NULL, NULL, &m_pTextureHeightmap);
Here's the load from resource thing I was talking about. D3DX makes it incredibly easy to load a bitmap from a resource. First of all you need to add the resource to your app, which you do via the VC6 Insert -> Resource menu option. Select the Bitmap option, and click OK. VC6 is a bit wierd if you don't already have a bitmap resource, and occasionally creates it's own blank resources for no reason. Anyway, once you have a "Resources" tab in your workspace view window, you can right click the "Bitmaps" folder and choose "Import". You'll then be able to import your existing bitmaps as resources. Don't forget to rename them - IDB_BITMAP1 isn't the most recognisable name :). If you've never dealt with resources, there are 2 things to remember. First, importing resources still requires them to be present on disk - VC doesn't store them for you, so delete them and they're gone! Second, when you add resources, VC creates a resource.h file. #include this header file in all the .cpp files you want to access resources in. We repeat this code twice, once to load the heightmap and once to load the groundmap.
D3DLOCKED_RECT lockedrect; ::ZeroMemory(&lockedrect, sizeof(lockedrect)); rslt=m_pTextureHeightmap->LockRect(0, &lockedrect, NULL, 0); if(FAILED(rslt)) { return D3DError(rslt, __LINE__, __FILE__, "Could not lock heightmap surface"); } DWORD* pBits=(DWORD*)lockedrect.pBits; int index=0; // Display offset (bytes)=((Pitch * y) + x) * Num Bytes per pixel { for(int y=0; y<256;y+=2) { for(int x=0; x<256; x+=2) { m_nHeightArray[y][x]=(getr(pBits[(y*256)+x]))/5; } } }
Next up we need to lock the heightmap texture surface, and read out the RGB value of each pixel into out height array. I'm not going to recap this code as we've seen it all before. We lock and read from/write to a texture surface in exactly the same was as we do it with the back buffer, so for a complete explanation have a read of DirectX Basics Series 1 Tutorial 5. The interesting part of the code is the single line in the nested for() statements. I've used a little macro called getr() which retrieves an integer (0-255) from a DWORD representing an ARGB colour. If you open D3DFuncs.h in the workspace download (above), you'll see that I've provided 4 macros - geta(), getr(), getg() and getb(), which take a DWORD as a parameter and return an int representing the alpha, red, green or blue colour value respectively. As discussed above, because our heightmap is greyscale the red, green and blue channels will all have the same colour value, so we can use any one of the 3 to extract data from the heightmap. I chose red for absolutely no reason. Note that the return value of getr() is divided by 5. I did this just to cut down on the height of the terrain - going from 0 to 255 units high made it look very out-of-proportion. Try removing the /5 and see what happens.
m_pTextureHeightmap->UnlockRect(0);
m_pTextureHeightmap->Release();
m_pTextureHeightmap=NULL;
I've included this code to show that once we've read the values from the heightmap into our height array, we don't require the heightmap any more. No point using up memory when we don't need it! Next we need to create our vertices. I'll go through this bit slowly.
int z=128; // Our starting Z axis position, 128 units "into" the screen float texX, texY; // Texture coordinates int nArrayIndex=0; // And an index into the array // First we index through each row, and create our vertex buffer - one vertice for every pixel // on the heightmap for(int nIndexY=0; nIndexY<256; nIndexY+=2) { texY=(float)(((float)nIndexY/(float)2)/(float)128);
First, we initialise a couple of variables we're going to use. We want our terrain to be centered around 0 on the X axis and 0 on the Z axis, and for the lowest part of the terrain to be at 0 on the Y axis. We're going to double-space our vertices, so that whilst the heightmap is only 128*128 our terrain will actually be 256*256. Before we go any further, keep this cleari n your mind. The heightmap is 2D, and we copied all the values into the height array, which is also 2D. Therefore we need to loop through a 2D array, row by row, creating our vertices in 3D space. This means that every time we start reading a new row from the 2D height array, we need to decrease the Z axis position of all the vertices in the row. nIndexY represents the current ROW in the height array (or the heightmap, if you prefer to think of it that way). We calculate the Y component of the texcoords for all vertices in this row first, as follows. We take the current row number, and divide it by 2 (remember, we're double-spacing the vertices). We then divide the result by 128, which was the original height of the heightmap. This gives us a value from 0.0 to 1.0 that we can use as the Y part of our texcoords for all the vertices in this row.
for(int nIndexX=0; nIndexX<256; nIndexX+=2)
{
int x=nIndexX-128;
texX=(float)(((float)nIndexX/(float)2)/(float)128);
Next we do exactly the same with each column in the height array. First, we create a temporary var x, which is the current column index - 128 to center the terrain around 0 on the X axis. Then we calculate the X component of the texcoord in the same way as before - the current index divided by 2, because we're doublespacing, and then divide the result by 128 to produce a value between 0.0 and 1.0. Now we have our texcoords for every vertex!
m_vTerrain[((nIndexY/2) * 128)+(nIndexX/2)].x=(float)x;
Now we need to set up each vertex correctly. First, we need to select the correct vertex in the array. We do this via a simple calculation - we know that vertices in a 1D array are sequential, so we divide the current nIndexY (the row we're working on) by 2, to remove the doublespacing. Then, we multiply the result by 128, because there are 128 vertices in each row. We then divide nIndexX (the column we're working on) by 2 to remove the doublespacing, and add it to the previous result. This gives us the correct array item number we're working on. Here's a practical work through. Assume we're working on the second row (nIndexY = 2), 3rd pixel in the heightmap/height array (nIndexX = 6):m_vTerrain[((2 / 2) * 128) + (6 / 2)]
m_vTerrain[(1 * 128) + 3]
m_vTerrain[128 + 3]
m_vTerrain[131]
Which is correct! So, now that we have calculated the correct array index to work with, we simply set it's X coordinate to the x var we created a few lines back.
m_vTerrain[((nIndexY/2) * 128)+(nIndexX/2)].y=(float)m_nHeightArray[nIndexY][nIndexX];
That was the X coordinate of the current vertex, now we need to set the Y coordinate. This is simple matter of pulling the value out of the height array, as you can see. If you're a little confused here, scroll back up to where we extracted the heightmap data to the height array, and pay special attention to our for() code - note that we're advancing 2 elements at a time. It does mean the array is twice as large as it needs to be, but it keeps things simple for the moment.
m_vTerrain[((nIndexY/2) * 128)+(nIndexX/2)].z=(float)z;
m_vTerrain[((nIndexY/2) * 128)+(nIndexX/2)].tu1=texX;
m_vTerrain[((nIndexY/2) * 128)+(nIndexX/2)].tv1=texY;
int col=m_nHeightArray[nIndexY][nIndexX]*5;
m_vTerrain[((nIndexY/2) * 128)+(nIndexX/2)].dwColour=D3DCOLOR_ARGB(255, col, col, col);
}
z-=2;
}
Here's the rest of the loop. We set the Z coordinate of the current vertex equal to our z var (remember we set this at the top of our loops?), and then set our vertex's texcoords. Next, I did something a little extra. Remembering that whilst we're using our heightmap/heightarray for setting the Y coordinate of our vertices, it's also a greyscale colour too. As we discussed, the closer to white the RGB value, the higher the vertex Y coordinate is. So, why not turn this to our advantage! By setting the vertex diffuse colour to the greyscale RGB colour of the heightmap, we've effectively turned our heightmap 3D as you can see in Fig2.4. Combine this with some nice renderstates, and it really improves the look of the terrain - mountain peaks are whiter, and valleys are darker! It's not proper lighting, but it does give a nice effect. Take note of where I multiply the height array by 5 - remember that we divided it by 5 previously to even out the look of the terrain. Again, this code could do with a lot of optimisation. Finally, we decrease our z var by 2, bringing the next row of vertices 2 units closer to the origin.
{
int nArrayIndex=0;
for(int nIndexY=0; nIndexY<128; nIndexY++)
{
for(int nIndexX=0; nIndexX<127; nIndexX++)
{
m_sIndices[nArrayIndex]=((nIndexY+1) * 128)+nIndexX;
m_sIndices[nArrayIndex+1]=(nIndexY * 128)+nIndexX;
m_sIndices[nArrayIndex+2]=((nIndexY+1) * 128)+nIndexX+1;
m_sIndices[nArrayIndex+3]=((nIndexY+1) * 128)+nIndexX+1;
m_sIndices[nArrayIndex+4]=(nIndexY * 128)+nIndexX;
m_sIndices[nArrayIndex+5]=(nIndexY * 128)+nIndexX+1;
nArrayIndex+=6;
}
}
}
Now we can create our indexbuffer data. Whereas before we were working per-vertex for the vertex buffer (we knew we had a 256*256 region that we wanted to have 128*128 vertices spread evenly across), now we're working per-quad and specifying 6 vertices at a time. These nested loops move from row to row, creating a quad using adjacent vertices. As we discussed, it's the indexbuffer that controls the order vertices are used to create triangles, so we have to specify the vertice indices in the correct order for CCW culling. If the above code looks a little confusing, ignore all the nIndexX and nIndexY stuff, and just concentrate on which vertices we'd be specifying if we only had 1 quad:
Fig 2.13: Vertex indices in an index buffer
In case you're wondering why it's nIndexY * 128, and not nIndexY + 1, remember that the vertex array is linear and not 2D - the second row of vertices starts at Start of vertexbuffer + 128 elements. Next up we need to copy the vertex and index data into the appropriate buffers. I'm going to skip right over the vertexbuffer code, because we've seen this before. Let's take a look at the indexbuffer code:
BYTE* pIndexData=0;
// Create the index buffer...
rslt=pDevice->CreateIndexBuffer(sizeof(m_sIndices), D3DUSAGE_WRITEONLY, D3DFMT_INDEX16, D3DPOOL_DEFAULT, &m_pIndexBuffer);
if(FAILED(rslt)) { return D3DError(rslt, __LINE__, __FILE__, "Failed to lock Index Buffer."); }
// ...lock it
rslt=m_pIndexBuffer->Lock(0, sizeof(m_sIndices), &pIndexData, 0);
if(FAILED(rslt)) { return D3DError(rslt, __LINE__, __FILE__, "Failed to lock Index Buffer."); }
// ...copy the indices in, and unlock
CopyMemory(pIndexData, &m_sIndices, sizeof(m_sIndices));
m_pIndexBuffer->Unlock();
It looks almost the same as the vertexbuffer code, except for a few subtle but important differences. Here's the prototype for IDirect3DDevice8::CreateIndexBuffer():
HRESULT CreateIndexBuffer(UINT Length, DWORD Usage, D3DFORMAT Format, D3DPOOL Pool, IDirect3DIndexBuffer8** ppIndexBuffer);
These parameters should be familiar to you, as they are almost the same as IDirect3DDevice8::CreateVertexBuffer(). I want to take a special look at parameter 3 - Format - though. There are 2 possible values for this parameter: D3DFMT_INDEX16 and D3DFMT_INDEX32, representing 16bit and 32bit indexbuffers respectively. The decision of which format to use simply comes down to numbers - a 32bit value can obviously store more digits than a 16bit value. Because we don't have enough vertices to warrant using a 32bit indexbuffer, we just stick with the 16bit type by passing D3DFMT_INDEX16. This format parameter does have one other side-effect though. Take a look at the class definition, and you'll see that we created m_sIndices as a short, not an int. This is very deliberate, because a short is a 16bit value, whereas an int is a 32bit value. You cannot mix an int array with a 16bit indexbuffer, or a short array with a 32bit indexbuffer. Be careful to choose the right var type!
I'm going to repeat that last sentence again, because it's really important. You cannot mix an int array with a 16bit indexbuffer, or a short array with a 32bit indexbuffer. You have been warned!
The rest of the code is exactly the same as the vertexbuffer code, so I'll leave you to read through it.Now, believe it or not, we've just done everything we need to for a fully working terrain engine. Yep! Now we just need to render it, in the handily titled Render() method. There's nothing much new here, just some state changes and a little bit of code for our indexbuffer.
rslt=pDevice->SetVertexShader(D3DFVF_TERRAIN);
rslt=pDevice->SetTexture(0, m_pTextureGround);
rslt=pDevice->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_TEXTURE);
rslt=pDevice->SetTextureStageState(0, D3DTSS_COLORARG2, D3DTA_DIFFUSE);
rslt=pDevice->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_ADDSIGNED);
rslt=pDevice->SetTextureStageState(0, D3DTSS_ALPHAOP, D3DTOP_DISABLE);
rslt=pDevice->SetStreamSource(0, m_pVertexBuffer, sizeof(TERRAINVERTEX));
I've removed the comments to save some room. You've seen all this code before, so I'll quickly recap. First, we set our custom FVF define as our shader, and set our groundmap as our texture. We then set up a blend between the texture colour and the vertex diffuse colour using an ADDSIGNED colour operation to make everything nice and bright. Next we disable the alpha stage, as we don't need it. If you're wondering about these stages, we discussed them in depth here. Lastly, we associate our vertex buffer with the stream as usual. We don't need to do anything else to tell D3D we want to use this vertexbuffer with an indexbuffer - simply selecting it is enough.
rslt=pDevice->SetIndices(m_pIndexBuffer, 0);
Back to the indexbuffer! Before D3D can use the indexbuffer, we need to tell it that it actually exists. Here's the prototype for IDirect3DDevice8::SetIndices():
HRESULT SetIndices(IDirect3DIndexBuffer8* pIndexData, UINT BaseVertexIndex);
Parameter one is a pointer to an IDirect3DIndexBuffer8 interface, for which we just supply our indexbuffer instance. Parameter 2 is a handy way of creating an offset into the indexbuffer. If you remember back to DirectX Basics Series 3 Tutorial 2, I showed you how you could put more than one object's vertices into a single vertexbuffer, and render them using different primitive types by using offsets. This parameter does exactly the same thing for indexbuffers - D3D will use any number specified here as an offset when retrieving vertices by index. This is a little confusing to explain, so imagine the following. If we created an indexbuffer where the first item was set to "3", we know that normally D3D will retrieve the 3rd vertex in the vertexbuffer. However, if we supplied 2 for SetIndices()'s BaseVertexIndex parameter, D3D would add 2 to every vertex index. Therefore when it reads 3 from the indexbuffer, it will actually retrieve vertex number 5. It's a great way to confuse everyone :)
for(int nCount=0; nCount < 128; nCount++)
{
rslt=pDevice->DrawIndexedPrimitive(D3DPT_TRIANGLELIST, 0, 768, nCount*768, 256);
if(FAILED(rslt)) { return D3DError(rslt, __LINE__, __FILE__, "Failed to draw primitives"); }
}
Finally, we're ready to draw our terrain mesh. Before we look at DrawIndexedPrimitive(), let me explain why I've decided to draw the terrain one row at a time. To be honest, it wasn't actually my choice. When I originally wrote the demo, I threw the entire index & vertexbuffers at the gfxcard in one shot, and it worked absolutely fine on my machine. However it was getting mixed results on other peoples systems, even ones with exactly the same gfxcard as me. After a short bit of playing around, it became very frustrating to debug something I couldn't get to happen on my system, so I gave up and batched them using the for() loop above. Now that's honesty for you! Here's the prototype:
HRESULT DrawIndexedPrimitive(D3DPRIMITIVETYPE Type, UINT MinIndex, UINT NumVertices, UINT StartIndex, UINT PrimitiveCount);
As you can see, we use DrawIndexedPrimitive() directly in place of DrawPrimitive(). Parameter one is the normal D3DPT_ primitive identifier - we set our vertex order in the indexbuffer to be the correct order for a triangle list, so we specify D3DPT_TRIANGLELIST for this param. Parameter 2 is a little confusing, so bear with me. When D3D uses the indexbuffer to select vertices out of the vertexbuffer, the order it selects those vertices is obviously controlled by the values stored in the indexbuffer. Until D3D processes the indexbuffer, it doesn't know which vertices are required. However, because we put the vertices in the vertex buffer, we definately do know which vertices are going to be selected by D3D when it reads the contents of our indexbuffer. If our indexbuffer only points to a small set of vertices in the vertexbuffer, we can tell D3D where that set begins by using this parameter. For example, assume we have a vertexbuffer with 10 vertices in it (beginning at index 0, ending at index 9). If our indexbuffer only points to vertices from index 5 to index 9, we can specify 5 for the DrawIndexedPrimitive() MinIndex parameter. D3D is then able to optimise the processing of our vertexbuffer by deliberately ignoring all vertices prior to vertex index 5. This is a nice speed enhancement, but beware - if the indexbuffer points to a vertex that is NOT inside the index range we set with this parameter, DrawIndexedPrimitive() will return an error. Note that the effective minimum vertex index in the vertexbuffer is actually the value of the DrawIndexedPrimitive() MinIndex parameter added to the BaseVertexIndex parameter of the SetIndices() method call, above. As you can see, use of this parameter can potentially get a little confusing, so if you do make use of it make sure you keep careful track of the accumulated vertex range.Back to the parameters, and NumVertices is simply the number of vertices we want DrawIndexedPrimitive() to process. For example, specifying 10 for this parameter will cause DrawIndexedPrimitive() to read the first 10 index values from the indexbuffer, and therefore process 10 vertices. In a way this parameter is named slightly wrong - I think it should really be called NumIndices. StartIndex is the index of the first value in the indexbuffer you want D3D to start processing from, which is easy enough (and works in exactly the same way that the StartVertex parameter of DrawPrimitive() does). The final parameter, PrimitiveCount, is simply the number of primitives DrawIndexedPrimitive() will be rendering on this call. So, let's take another look at the line of code again:
rslt=pDevice->DrawIndexedPrimitive(D3DPT_TRIANGLELIST, 0, 768, nCount*768, 256);
Now you know that we're rendering a triangle list (parameter 1), and we're not supplying a minimum offset in the vertexbuffer (parameter 2), therefore potentially we could be using every vertex available in the buffer. We're going to read 768 indices from the indexbuffer (parameter 3), beginning from index number nCount*768 (parameter 4, used so we can render one row at a time). Finally we'll be rendering 256 primitives (parameter 5), because we're rendering 128 quads per row, which split into 2 triangles per quad of course!And that is all we need to render our nice looking terrain! Now, time for the skybox. I'm not actually going to take you through the code for this - it's so basic, you've already written code that's far more complex, believe it or not! Instead, I'm going to show you how it's done and leave you to read through the code yourself.
Skyboxes were originally invented to overcome performance limitations on gfxcards. Quite simply, when rendering outdoor scenes it was impractical (and usually impossible!) to get a large enough scene rendered so that it looked realistic over distance. If you imagine a game where the player stands on top of a hill and can look around, somehow you have to provide the illusion that the game-world stretches off into the distance, and make it look realistic. You could of course use a static background that scrolled slightly (which is what old racing games used to do, such as Lotus Esprit Turbo Challenge), but that would only work as long as the player didn't (or couldn't) look around too much. So a technique was needed to give the illusion of distance, and to look good no matter where the camera was pointed.
And thus skyboxes were born. Rather than explain it, here's what a skybox is:
Fig 2.14: Skybox with visible mesh
As you can see in Fig2.14, a skybox is simply a cube with it's texture on the inside. We create a cube as normal (in this case, it's not a symmetrical cube, and looks more like a squashed box), ensuring that it's dimensions are large enough that our terrain fits inside it. We then take 6 seperate textures, one each for the back, front, left, right, top and bottom of the skybox, and texturemap them onto the appropriate face of the cube. Now, providing we keep the camera within the skybox, it gives the illusion of distance with very little loss of framerate. Looking at the image above you'd never believe it works, but hey - the demo proves it does!There are a couple of important things to remember with skyboxes. First of all, just like the groundmap for our terrain, the quality of the skybox texture is really important. All 6 textures should fit together seamlessly to create a continuous image. Open up the bitmaps included in the workspace download, and paste them together in a paint package - see how they line up exactly? As mentioned before, Terragen is an excellent tool for creating them. Second, always remember to rotate the skybox with the same matrix as you rotate the rest of the world. In other words, when we rotate the terrain via the world transformation, we need to make sure that same rotation is applied to the skybox. Finally, remember to optimise. Using a skybox is much faster than really rendering terrain into the distance, but remember it's a massive cube that needs to be depth tested, textured etc. The most obvious optimisation is to drop faces of the cube. If you take a look in the skybox.cpp file, you'll notice that I don't create a bottom face for the cube. Because the terrain covers the entire bottom of the skybox, it's a waste of processing power to render a face we can't see!
If you're like me, you probably create all your polygons in one vertex order, so that your culling will always be CW or CCW (my personal choice). If you do, remember to disable (or reverse) the culling mode before rendering the skybox. Because the camera should always be inside the skybox, the vertex order will be reversed compared to viewing it from the outside.
Before we move onto the main driver code, a few last words about skyboxes. Skyboxes are very simple and effective ways to fake distance in a scene, but they do have problems. One of the biggest issues is artifacts. With some textures & skyboxes, when the camera is at a certain angle and looking at a certain point, you can clearly see the seams in the skybox (ie: the joins between the faces of the cube). There are a couple of things you can do to minimise this problem, and one of them is to not use skyboxes. Skydomes are literally what they sound like, a dome created from polygons that stretches over the entire scene. As they don't have hard edges, they don't suffer from the artifacts that skyboxes do. In addition, they're a favourite for texturemapping with perlin clouds, as the curvature of the dome helps give a natural perspective.Okay, time to look at the driver code. Because we've put all our terrain and skybox code into self-contained classes, the main source file is nice and simple. First up we create global instances of our terrain and skybox:
CTerrainEngine g_Terrain;
CSkyBox g_SkyBox;
Easy. Moving onto GameInit():
g_pDevice->SetRenderState(D3DRS_CULLMODE, D3DCULL_CCW);
g_pDevice->SetRenderState(D3DRS_LIGHTING, FALSE);
g_pDevice->SetRenderState(D3DRS_ZENABLE, D3DZB_TRUE);
// Use bilinear filtering to smooth out the texture
g_pDevice->SetTextureStageState( 0, D3DTSS_MAGFILTER, D3DTEXF_LINEAR);
g_pDevice->SetTextureStageState( 0, D3DTSS_MINFILTER, D3DTEXF_LINEAR);
g_pDevice->SetTextureStageState( 0, D3DTSS_MIPFILTER, D3DTEXF_LINEAR);
Here we're setting our normal renderstates with CCW culling, and lighting turned off. The new code here is the D3DRS_ZENABLE statement. This method call enables depth buffering, which is a technique where D3D removes hidden surfaces. Surfaces are considered hidden when a polygon that makes up the surface is situated completely behind another polygon in relation to the camera's viewpoint. In this instance D3D will discard the farther polygon instead of rendering it and then rendering the nearer polygon on top. The SetTextureStageState() calls simply enable bilinear filtering, which we discussed in tutorial 1 of this series.
g_Terrain.Initialise(g_pDevice, "island.bmp");
g_SkyBox.Initialise(g_pDevice);
The remaining code in this function we've seen before, except for the 2 lines above. These initialise our terrain and skybox, respectively. Technically I was a little lazy here. As we discussed, instead of the old load-from-disk method we've used in the past, I've implemented code to load source images from resources. Both the terrain and the skybox classes do this, however I left the original code to load images from disk in the terrain class, albeit commented out. Therefore whilst we pass "island.bmp" as a parameter to the terrain class, it's only there as an example of the difference between the new method (resources) and the old method (disk files), and it doesn't actually do anything.Finally, onto the Render() function:
D3DXMATRIX WorldMatrix;
D3DXMatrixRotationY(&WorldMatrix, (timeGetTime()/1500.0f));
g_pDevice->SetTransform(D3DTS_WORLD, &WorldMatrix);
//g_pDevice->SetRenderState(D3DRS_FILLMODE, D3DFILL_WIREFRAME);
g_pDevice->SetRenderState(D3DRS_CULLMODE, D3DCULL_NONE);
g_SkyBox.Render(g_pDevice, g_pBackSurface);
g_pDevice->SetRenderState(D3DRS_CULLMODE, D3DCULL_CCW);
g_Terrain.Render(g_pDevice, g_pBackSurface);
First, we create a matrix for our world transform, and turn it into a rotation matrix around the Y axis. Note that we set the world transform before rendering both the skybox and the terrain - as discussed, it's important that the skybox rotates with the rest of the scene to keep the illusion going. Moving on, we'll skip that line of code that I've commented out for the moment. Finally, before rendering the skybox, we disable culling for the reasons we discussed above. We call the skybox class' Render() method, then reset the culling mode to normal CCW culling before rendering the terrain. It's as easy as that, and we now have a fully working terrain engine with skybox!Back to that line of code we skipped. Had I not commented it out, it would have looked something like this:
g_pDevice->SetRenderState(D3DRS_FILLMODE, D3DFILL_WIREFRAME);
No doubt you can guess what it does. In case you've been wondering how I've shown you those wireframe screenshots in the previous tutorials, this is the magical line of code. Try uncommenting it and rebuilding the code. You'll find it's extremely handy for debugging corrupt images, especially when you can't figure out which polygons are causing the problem. I use it a fair bit to get a good picture of how my 3D scenes are building up. You can use D3DFILL_SOLID for the second parameter to revert back to "normal" rendering.Well, congratulations are in order! You may not have realised it, but this tutorial was a major step into building scenes with D3D. First of all, you've created something more than just a cube or a triangle. Because terrain has a sense of scale everyone can relate to, hopefully you can now clearly see how object space and world space units relate to 3D objects (our terrain is nothing more than a object!). Second of all, you now have all the basic skills you need to create pretty much anything you want with D3D. Everything from here is techniques and methods. Third, you're learning things without realising it - using the skybox principles from this tutorial, and the render to texture principles from the previous tutorial you have all the skills and information you need to create cubic environment mapping.
Before wrapping up this tutorial, there are a couple of points left to say. As I mentioned at the start of this tutorial, heightmaps are only one (very limited) way of creating terrains. If you're interested in learning more, search the web for information on ROAM, geomipmapping and LOD (level of detail). We can improve our terrain in a million ways, and here are some examples:
- Use detailmapping to improve the quality of the terrain close to the camera
- Create a terrain "patch" system, so that we can use multiple heightmaps and meshes, and stitch them together as the camera moves to create a larger scene
- Calculate vertex normals for our quads, and implement lighting
- Split our quads into individual polys for smoother slopes
- Add some distance fog to blur the line between terrain and skybox
- Implement a skydome
and finally....
- IMPROVE OUR GROUNDMAP TEXTURE!
Hopefully this tutorial has given you some ideas, and shown you how to do one of the most popular effects around. Enjoy playing around with it, and here are some exercises to give you some ideas:Exercise 1: Recode the skybox class so that it doesn't contain hard-coded positions for the vertices. Turn it into a general-purpose class that you can pass a top left coordinate, a width and height, and the class generates the coordinates for you.
Exercise 2: Recode the terrain engine so that we don't read the heightmap RGB data into an array. Instead, read the data and set the vertex Y coordinates "on the fly".
Exercise 3: Create your own heightmaps and groundmaps - you'll quickly find out the software you like best!
Have fun, and see you in the next tutorial!