3D terrain using a heightmap

Making 3D terrain is often hard and tedious. Heightmaps make this a lot easier. This tutorial is written for GameMaker, but you can interpret the GML as pseudo-code and port it to another language.

Example terrain

An example of terrain generated using a heightmap

Table of contents

  1. What is a heightmap? And how does it work?
  2. Making the heightmap
  3. Reading the heightmap
  4. Drawing the terrain
  5. Requesting the z-value of a position
  6. Stretching a texture over the entire terrain
  7. FAQ
  8. Examples

What is a heightmap? And how does it work?

A heightmap is (usually) a black-and-white print of a landscape. Here is a simple example:

example

Exhibit A

The heightmap will be ‘read’, and converted into a 2D array of heights. These heights are based on the value (as in hsv value, as in hue, saturation, value value, which is basically the brightness of an image) of the pixels. Initially you can define a maximum height. Let’s pick 100 as an example. This is how it works:

  1. Find the value of all the pixels on the heightmap and store them in a 2D array;
  2. Divide each of these values by 255;
  3. Multiply each of these values by the maximum height;

Why the division by 255? Because value ranges from 0 to 255. A completely black pixel stands for a height of 0, because the color value of black is 0: (0/255) * 100 = 0. A completely white pixel stands (in our case) for 100, because its value is 255: (255/255) * 100 = 100. Ergo, our values range from 0 to ‘maximum value’.

Making the heightmap

Making a heightmap isn’t very hard in GameMaker.

  • Make a sprite. The bigger the heightmap, the more detailed the terrain, but the bigger the heightmap, the slower it loads.
  • After this, fill the entire background with some greyscale color. Remember: the closer to white, the higher, the closer to black, the lower.
  • After this, use the spray-paint tool to add lighter or darker grey at your own wishes.
  • When you’re done, go to imageblur. Turn on Blur Colors, but turn off Blur Transparency. After this, choose how much the heightmap should be blurred. Repeat this step until the shades of grey transition smoothly.

Congratulations, you finished your heightmap!

Reading the heightmap

That wraps up the creative part, we’ll go to coding now. So how are we going to do this?

First of all we’ll have to define two variables: gridsize and gridparts, respectively the size of a cel on our heightmap, comparable to grid size in the room editor, and the number of cels horizontally and vertically. Remember that the heightmap image should always be 1 pixel larger than the number of cels – the pixels on the heightmap define the height of the corners of the triangles. If you want a 50×50 grid, you will have to use a heightmap of 51×51.

Here is an image for clarification:

gridparts and gridsize explained

gridparts and gridsize explained

The total size of the terrain equals gridparts * gridsize, but the size of the heightmap image is (gridparts + 1) * (gridparts + 1).

So let’s get started on the script. Our first part will be:

///terrain_create(heightmap)
//
// heightmap: the sprite of the heightmap to be used

globalvar gridparts, gridsize;
gridparts = sprite_get_width(argument0)-1; // because the sprite has to be 1px larger
gridsize = room_width/gridparts;

Now we’ll be able to find the height values of the sprite. First of all we’ll draw the sprite using draw_sprite (which means you’ll have to execute this script before enabling 3D). After that, we’ll find the color values of every pixel using color_get_value(draw_getpixel(x, y)). The code looks like this:

draw_sprite(argument0,-1,0,0);
var i, j;
globalvar height;
for ( i=0; i<=gridparts; i+=1) { // because we use '<=' instead of '<' all pixels will be read
 for ( j=0; j<=gridparts; j+=1) {
  height[i, j] = color_get_value( draw_getpixel( i, j) )/255 * argument1;
 }
}

As you can see, I used argument1. This is the maximum height (corresponding with the color white). Our script now looks like this:

///terrain_create(heightmap,maxheight)
//
// heightmap: the sprite of the heightmap to be used
// maxheight: the maximum height of the terrain

globalvar gridparts, gridsize;
gridparts = sprite_get_width(argument0)-1; // because the sprite has to be 1px larger
gridsize = room_width/gridparts;

draw_sprite(argument0,-1,0,0);
var i, j;
globalvar height;
for ( i=0; i<=gridparts; i+=1) { // because we use '<=' instead of '<' all pixels will be read
 for ( j=0; j<=gridparts; j+=1) {
  height[i, j] = color_get_value( draw_getpixel( i, j) )/255 * argument1;
 }
}

We now have the height values, but we can’t draw them yet. They’re just numbers, after all. To build a model, we’ll use trianglestrips:

globalvar terrain;
terrain = d3d_model_create();

for ( j=0; j<gridparts; j+=1)
{
 d3d_model_primitive_begin(terrain,pr_trianglestrip)
 for ( i=0; i<=gridparts; i+=1)
 {
  terrain_get_normal(i*gridsize, j*gridsize + gridsize);
  d3d_model_vertex_normal_texture(terrain, i*gridsize, j*gridsize + gridsize, height[i,j+1], global.xx, global.yy, global.zz, i, j + 1);
  terrain_get_normal(i*gridsize, j*gridsize);
  d3d_model_vertex_normal_texture(terrain, i*gridsize, j*gridsize, height[i,j], global.xx, global.yy, global.zz, i, j);
 }
 d3d_model_primitive_end(terrain);
}

This script makes a model called ‘terrain’, using the heights we read from the heightmap. We use i * gridsize, j * gridsize for the x- and y-positions, and every other time we add +gridsize to fill every cel on the grid.

This script also uses another script, terrain_get_normal. This script calculates the normals of the terrain to make sure the lighting works correctly. The working of this script is a little complicated, you don’t really have to understand it.

///terrain_get_normal(x,y)
// result is saved in global.xx, global.yy and global.zz

globalvar gridsize;
var d;
global.xx = terrain_get_z(argument0-gridsize, argument1)-terrain_get_z(argument0+gridsize, argument1);
global.yy = terrain_get_z(argument0, argument1-gridsize)-terrain_get_z(argument0, argument1+gridsize);
global.zz = gridsize*2;

d = sqrt(sqr(global.xx) + sqr(global.yy) + sqr(global.zz));

global.xx /= d;
global.yy /= d;
global.zz /= d;

This script uses a third script: terrain_get_z. How this script works will be explained later.

So now we have the following script:

///terrain_create(heightmap,maxheight)
//
// heightmap: the sprite of the heightmap to be used
// maxheight: the maximum height of the terrain

globalvar gridparts, gridsize;
gridparts = sprite_get_width(argument0)-1; // because the sprite has to be 1px larger
gridsize = room_width/gridparts;

draw_sprite(argument0,-1,0,0);
var i, j;
globalvar height;
for ( i=0; i<=gridparts; i+=1) { // because we use '<=' instead of '<' all pixels will be read
 for ( j=0; j<=gridparts; j+=1) {
  height[i, j] = color_get_value( draw_getpixel( i, j) )/255 * argument1;
 }
}

globalvar terrain;
terrain = d3d_model_create();

for ( j=0; j<gridparts; j+=1)
{
 d3d_model_primitive_begin(terrain,pr_trianglestrip)
 for ( i=0; i<=gridparts; i+=1)
 {
  terrain_get_normal(i*gridsize, j*gridsize + gridsize);
  d3d_model_vertex_normal_texture(terrain, i*gridsize, j*gridsize + gridsize, height[i,j+1], global.xx, global.yy, global.zz, i, j + 1);
  terrain_get_normal(i*gridsize, j*gridsize);
  d3d_model_vertex_normal_texture(terrain, i*gridsize, j*gridsize, height[i,j], global.xx, global.yy, global.zz, i, j);
 }
 d3d_model_primitive_end(terrain);
}

Drawing the terrain

Finally, we’ll have to draw the terrain:

///terrain_draw(tex)
//
// tex: the texture to be drawn on the terrain. 
//
// Please note that the texture will be drawn over every cel
// If you want to stretch the texture over the entire terrain,
// scroll down a little to chapter 6
//
// Second, you really have to give the texture id, not a background id. 
// Use terrain_draw(background_get_texture(bck_grass)),
// and not terrain_draw(bck_grass)

texture_set_repeat(true);
d3d_model_draw(terrain,0,0,0,argument0)

By far the most complicated script in this tutorial.

Requesting the z-value of a position

We’re not finished yet. If you draw the terrain now, you’ll find out that you can just walk through the map.

So we’ll need some form of collision checking. Maybe you remember that we saved the height values in the global array height. Let’s use that.

To start, we have to determine on which cel of the grid our x,y position is:

var gridx, gridy;
gridx = floor(argument0/gridsize)
gridy = floor(argument1/gridsize)

Then we have to find out where on the cel the player is exactly (since a cel is composed of 2 triangles):

var offsetx, offsety;
offsetx = argument0-gridsize*gridx
offsety = argument1-gridsize*gridy

Next up, we have to find the 4 z-positions of the corners of the cel:

var z1, z2, z3, z4;
z1 = height[gridx,gridy]
z2 = height[gridx+1,gridy]
z3 = height[gridx+1,gridy+1]
z4 = height[gridx,gridy+1]

Using these variables, we can calculate the z-value:

var zz;
if offsetx>offsety
zz = z1 - offsetx*(z1-z2)/gridsize - offsety*(z2-z3)/gridsize
else 
zz = z1 - offsetx*(z4-z3)/gridsize - offsety*(z1-z4)/gridsize

So this would be our final script:

///terrain_get_z(x,y)
//
// x: the x-position
// y: err..

globalvar gridparts, gridsize, height;
var gridx, gridy, offsetx, offsety, z1, z2, z3, z4, zz;

gridx = max(0, min(gridparts - 1, floor(argument0 / gridsize)));
gridy = max(0, min(gridparts - 1, floor(argument1 / gridsize)));

offsetx = argument0 - gridsize * gridx;
offsety = argument1 - gridsize * gridy;

z1 = height[gridx, gridy];
z2 = height[gridx + 1,gridy];
z3 = height[gridx + 1,gridy + 1];
z4 = height[gridx, gridy + 1];

if(offsetx > offsety)
 zz = z1 - offsetx * (z1 - z2) / gridsize - offsety * (z2 - z3) / gridsize;
else 
 zz = z1 - offsetx * (z4 - z3) / gridsize - offsety * (z1 - z4) / gridsize;

return zz;

Stretching a texture over the entire terrain

To do this, you will have to change the last 6 lines of our creation script to this:

for ( j=0; j<gridparts; j+=1) {
 d3d_model_primitive_begin(terrain, pr_trianglestrip);
for ( i=0; i<=gridparts; i+=1) {
 terrain_get_normal(i*gridsize, j*gridsize+gridsize);
 d3d_model_vertex_normal_texture(terrain, i*gridsize, j*gridsize+gridsize, height[i,j+1], global.xx, global.yy, global.zz, i/gridparts, (j+1)/gridparts);
 terrain_get_normal(i*gridsize, j*gridsize);
 d3d_model_vertex_normal_texture(terrain, i*gridsize, j*gridsize, height[i,j], global.xx, global.yy, global.zz, i/gridparts, j/gridparts);
  }
 d3d_model_primitive_end(terrain);
  }
}

FAQ (Troubleshooting)

Here is a short FAQ if something isn’t working:

Q: My game crashes when it’s still loading. The screen turns black and the game doesn’t start.
A: The heightmap you used was probably to big. The bigger the better of course, but it’ll also be heavier for your CPU. Preferably use 31*31 or 51*51 or so.

Q: My game lags since I started using these heightmap scripts.
A: There can be a couple of reasons for this. The most probable one is that the texture you are using is way to big. Don’t ever use textures over a size of 1024*1024. Besides that, the problem could also be the size of the terrain itself. Don’t make rooms with a size of 10000*10000. GameMaker can’t handle everything.

Q: My terrain isn’t being drawn.
A: Your mistake! In all seriousness, did you put the object that executes these scripts in the room? If you can’t get it working, download one of these examples:

Examples

Two examples, but please note that the scripts they used are commented in Dutch.

The first example shows you how to use the scripts to load a heightmap: download here.

The second example shows you how to make the camera walk around in first person view on the terrain. Download here.

Leave a Reply

Name and website fields are completely optional.