JME 3 Tutorial (10) - Hello Terrain

Previous: Hello Collision, Next: Hello Audio

One way to create a 3D landscape is to sculpt a huge terrain model. This will give you a lot of artistic freedom – but rendering such a huge model can be quite slow. jME supports heightmaps to solve this common performance issue of terrains.

This tutorial explains how to create terrains from heightmaps and how to use splat textures to make the terrain look good.

Sample Code

package jme3test.helloworld;
 
import com.jme3.app.SimpleApplication;
import com.jme3.material.Material;
import com.jme3.renderer.Camera;
import com.jme3.terrain.geomipmap.TerrainLodControl;
import com.jme3.terrain.heightmap.AbstractHeightMap;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.terrain.heightmap.HillHeightMap; // is used in example 2
import com.jme3.terrain.heightmap.ImageBasedHeightMap;
import com.jme3.texture.Texture;
import com.jme3.texture.Texture.WrapMode;
import java.util.ArrayList;
import java.util.List;
import jme3tools.converters.ImageToAwt;
 
public class HelloTerrain extends SimpleApplication {
 
  private TerrainQuad terrain;
  Material mat_terrain;
 
  public static void main(String[] args) {
    HelloTerrain app = new HelloTerrain();
    app.start();
  }
 
  @Override
  public void simpleInitApp() {
    flyCam.setMoveSpeed(50);
 
    /** 1. Create terrain material and load four textures into it. */
    mat_terrain = new Material(assetManager, "Common/MatDefs/Terrain/Terrain.j3md");
 
    /** 1.1) Add ALPHA map (for red-blue-green coded splat textures) */
    mat_terrain.setTexture("m_Alpha",
               assetManager.loadTexture("Textures/Terrain/splat/alphamap.png"));
 
    /** 1.2) Add GRASS texture into the red layer (m_Tex1). */
    Texture grass = assetManager.loadTexture("Textures/Terrain/splat/grass.jpg");
    grass.setWrap(WrapMode.Repeat);
    mat_terrain.setTexture("m_Tex1", grass);
    mat_terrain.setFloat("m_Tex1Scale", 64f);
 
    /** 1.3) Add DIRT texture into the green layer (m_Tex2) */
    Texture dirt = assetManager.loadTexture("Textures/Terrain/splat/dirt.jpg");
    dirt.setWrap(WrapMode.Repeat);
    mat_terrain.setTexture("m_Tex2", dirt);
    mat_terrain.setFloat("m_Tex2Scale", 32f);
 
    /** 1.4) Add ROAD texture into the blue layer (m_Tex3) */
    Texture rock = assetManager.loadTexture("Textures/Terrain/splat/road.jpg");
    rock.setWrap(WrapMode.Repeat);
    mat_terrain.setTexture("m_Tex3", rock);
    mat_terrain.setFloat("m_Tex3Scale", 128f);
 
    /** 2. Create the height map */
    final Texture heightMapImage =
        assetManager.loadTexture("Textures/Terrain/splat/mountains512.png");
    final AbstractHeightMap heightmap =
        new ImageBasedHeightMap(
            ImageToAwt.convert(
                heightMapImage.getImage(), false, true, 0));
    heightmap.load();
 
    /** 3. We have prepared material and heightmap. Now we create the actual terrain:
     * 3.1) We create a TerrainQuad and name it "my terrain".
     * 3.2) A good value for terrain tiles is 64x64 -- so we supply 64+1=65.
     * 3.3) We prepared a heightmap of size 512x512 -- so we supply 512+1=513.
     * 3.4) As LOD step scale we supply Vector3f(1,1,1).
     * 3.5) At last, we supply the prepared heightmap itself.
     */
    terrain = new TerrainQuad("my terrain", 65, 513, heightmap.getHeightMap());
 
    /** 4. We give the terrain its material, position & scale it, and attach it. */
    terrain.setMaterial(mat_terrain);
    terrain.setLocalTranslation(0, -100, 0);
    terrain.setLocalScale(2f, 1f, 2f);
    rootNode.attachChild(terrain);
 
    /** 5. The LOD (level of detail) depends on were the camera is: */
    List<Camera> cameras = new ArrayList<Camera>();
    cameras.add(getCamera());
    TerrainLodControl control = new TerrainLodControl(terrain, cameras);
    terrain.addControl(control);
 
  }
}

When you run this sample you should see a landscape with dirt mountains, grass plains, plus some winding roads in between.

What is a Heightmap?

Heightmaps are an efficient way of representing the shape of a hilly landscape. Picture a heightmap as a float array containing height values between 0f and 255f. Here is a very simple example of a heightmap with 25 height values.

Important things to note:

Now when looking at Java data types to hold an array of floats between 0 and 255, the Image class comes to mind. Storing a terrain's height values as a grayscale image has one big advantage: The outcome is a very userfriendly, almost topographical, representation of a landscape:

Look at the next screenshot: In the top left you see the 128x128 grayscale image (heightmap) that was used as a base to generate the depicted terrain. To make the hilly shape better visible, the mountain tops are colored white, valleys brown, and the areas inbetween green:

}

In a real game, you will want to use more complex and smoother terrains than the simple heightmaps shown here. Heightmaps typically have square sizes of 512x512 or 1024x1024, and contain hundred thousands to 1 million height values. No matter which size, the concept is the same as described here.

Looking at the Heightmap Code

The first step is always to create the heightmap. You can create it yourself in any standard graphic application. Make sure it has the following properties:

The file mountains512.png that you see here is a typical example of a heightmap.

Here is how you create the heightmap object in your jME code:

  1. Create a Texture object
  2. Load your prepared heightmap texture into the texture object
  3. Create an AbstractHeightmap object from an ImageBasedHeightMap.
    ImageBasedHeightMap expects the following parameters:
    1. An ImageToAwt.convert()ed image file
    2. A boolean whether you are using 16 bit – here false.
    3. A boolean whether you are using an alphamap – here true.
  4. Load the heightmap.
    final Texture heightMapImage = assetManager.loadTexture("Textures/Terrain/splat/mountains512.png");
    final AbstractHeightMap heightmap =
        new ImageBasedHeightMap(
            ImageToAwt.convert(
                heightMapImage.getImage(), false, true, 0));
    heightmap.load();

What is Texture Splatting?

Texture splatting allows you create a custom textured material and "paint" on it. This is very useful for terrains: As you see in the example here, you can paint a grass texture into the valleys, a dirt texture onto the mountains, and free-form roads inbetween.

How is it done? We have three texture layers to paint on, m_Tex1, m_Tex2 and m_Tex3 (these names are found by opening the Terrain.j3md file, under the Material Parameters section; they may be changed) . before we start we have to make a few decisions:

  1. You choose three textures. For example grass.jpg, dirt.jpg, and road.jpg. jmonkeyengine.googlecode.com_svn_trunk_engine_src_test-data_textures_terrain_splat_road.jpg jmonkeyengine.googlecode.com_svn_trunk_engine_src_test-data_textures_terrain_splat_dirt.jpg jmonkeyengine.googlecode.com_svn_trunk_engine_src_test-data_textures_terrain_splat_grass.jpg
  2. You will "paint" three texture layers with three colors: Red, blue and, green. We arbitrarily chose that…
    1. … everything red will be grass – this goes into layer m_Tex1
    2. … everything green will be dirt – this goes into layer m_Tex2
    3. … everything blue will be roads – this goes into layer m_Tex3

Now we start painting the texture:

  1. Make a copy of your terrains heightmap, mountains512.png, so you know the shape of the landscape.
  2. Name the copy alphamap.png.
  3. Open alphamap.png in a graphic editor and switch the image mode to color image.
    1. Paint the black valleys in the image red – this will be the grass.
    2. Paint the white hills in shades of green – this will be the dirt of the mountains.
    3. Paint blue lines where you want roads to cross the landscape.
  4. The end result should look similar to this:

Note: In the future, the jMonkeyPlatform will take over some of these steps so you don't have to worry about the details.

Looking at the Texturing Code

As usual, we create a Material object. We base it on the Material Definition Terrain.j3md that is included in the jME3 framework.

Material mat_terrain = new Material(assetManager, "Common/MatDefs/Terrain/Terrain.j3md");

Now we load four textures into this material. The first one, m_Alpha, is the alphamap that we just created.

mat_terrain.setTexture("m_Alpha",
    assetManager.loadTexture("Textures/Terrain/splat/alphamap.png"));

Three other textures are the layers that we have previously decided to paint: grass, dirt, and road. We create texture objects and load the three textures as usual. Note how we assign them to their respective texture layers (m_Tex1, m_Tex2, and m_Tex3) inside the Material!

    /** 1.2) Add GRASS texture into the red layer (m_Tex1). */
    Texture grass = assetManager.loadTexture("Textures/Terrain/splat/grass.jpg");
    grass.setWrap(WrapMode.Repeat);
    mat_terrain.setTexture("m_Tex1", grass);
    mat_terrain.setFloat("m_Tex1Scale", 64f);
 
    /** 1.3) Add DIRT texture into the green layer (m_Tex2) */
    Texture dirt = assetManager.loadTexture("Textures/Terrain/splat/dirt.jpg");
    dirt.setWrap(WrapMode.Repeat);
    mat_terrain.setTexture("m_Tex2", dirt);
    mat_terrain.setFloat("m_Tex2Scale", 32f);
 
    /** 1.4) Add ROAD texture into the blue layer (m_Tex3) */
    Texture rock = assetManager.loadTexture("Textures/Terrain/splat/road.jpg");
    rock.setWrap(WrapMode.Repeat);
    mat_terrain.setTexture("m_Tex3", rock);
    mat_terrain.setFloat("m_Tex3Scale", 128f);

The individual texture scales (e.g. mat_terrain.setFloat("m_Tex3Scale", 128f);) depend on the size of the textures you use. You can tell you picked a too small scale if, for example, your road tiles appear like tiny grains of sand, or to big if the blades of grass look like twigs.

We use setWrap(WrapMode.Repeat) to make the small texture fill the wide area. If the repetition is too visible, try adjusting the Tex*Scale value.

Later, after we have created the actual terrain object, we must not forgot to set the material on it:

terrain.setMaterial(mat_terrain);

Looking at the Terrain Generation Code

Internally, the generated terrain mesh is broken down into tiles and blocks. This is an optimization for culling. You do not need to worry about tiles and blocks too much, just use recommended values for now.

Let's assume we want to generate a small 512x512 terrain. We already have created the heightmap object. Here are the steps that we perform everytime we create a new terrain.

We create a TerrainQuad with the following arguments:

  1. Name: E.g. my terrain.
  2. Tile size: We want to create terrain tiles of size 64x64, so we supply 64+1 = 65.
    • In general, 64 is a good value for terrain tiles.
  3. Block size: Since we prepared a heightmap of size 512x512, we supply 512+1 = 513.
    • If the the block size is double the heightmap size (1024+1=1025), you get a stretched out, wider, flatter terrain.
    • If the the block size is half the heightmap size (256+1=257), you get a smaller, more detailed terrain.
  4. Finally, we supply the 512x512 heightmap object that we have previously created.

Here's the code:

terrain = new TerrainQuad(
  "my terrain",               // name
  65,                         // tile size
  513,                        // block size
  heightmap.getHeightMap());  // heightmap

Don't forget to attach the terrain to the rootNode. You can scale and translate the terrain just like any other Spatial.

Looking at the Level of Detail Code

jME3 includes an optimization that adjusts the level of detail of the rendered terrain depending on how close or far the camera is.

    List<Camera> cameras = new ArrayList<Camera>();
    cameras.add(getCamera());
    TerrainLodControl control = new TerrainLodControl(terrain, cameras);
    terrain.addControl(control);

Close parts of the terrain are rendered in full detail. Terrain parts that are further away are not clearly visible anyway, and jME improves performance by rendering them less detailed. This way you can afford to load huge terrains with no penalty caused by invisible details.

Exercises

Exercise 1: Texture Layers

What happens if you swap two layers, for example m_Tex1 and m_Tex2?

...
mat_terrain.setTexture("m_Tex2", grass);
...
mat_terrain.setTexture("m_Tex1", dirt);

It's easier to swap layers in the code than to change the color in the alphamap.

Exercise 2: Randomized Terrains

These two lines generate the hightmap from a user defined image:

Texture heightMapImage = assetManager.loadTexture("Textures/Terrain/splat/mountains512.png");
    heightmap = new ImageBasedHeightMap(
      ImageToAwt.convert(heightMapImage.getImage(), false, true, 0), 1f);

You can also let jME create a random landscape.

  1. What result do you get when you replace the two heightmap lines by the following lines and run the sample?
HillHeightMap heightmap = null;
 
try {
    heightmap = new HillHeightMap(513, 1000, 50, 100, (byte) 3);
} catch (Exception ex) {
    ex.printStackTrace();
}
  1. Change one value at a time and the run the sample again. Note the differences. Can you find out which of the values has which effect on the generated terrain?
    • Which value controls the size?
      • What happens if the size is not a square number +1 ?
    • Which value controls the number of hills generated?
    • Which values control the minimum and maximum radius of the hills?
      • What happens if the minimum is bigger than the maximum?
    • Which value controls the flattening of the hills?
      • What happens if this value is 1 ?

Tip: You can keep using the splatted texture from the sample code above. Just don't be surprised that the textures do not automatically adjust to the randomized landscape.

Conclusion

You have learned how to create terrains that are more efficient as loading a giant model. You are now also able to texture a terrain.

In the next chapter, you will learn how to add sounds to your game.


See also: Terrain Collision

beginner, beginner,, heightmap, documentation, terrain, texture

view online version