Previous: Hello Collision, Next: Hello Audio
One way to create a 3D landscape is to sculpt a huge terrain model. This gives you a lot of artistic freedom – but rendering such a huge model can be quite slow. This tutorial explains how to create fast-rendering terrains from heightmaps, and how to use texture splatting to make the terrain look good.
package jme3test.helloworld;
import com.jme3.app.SimpleApplication;
import com.jme3.material.Material;
import com.jme3.terrain.geomipmap.TerrainLodControl;
import com.jme3.terrain.heightmap.AbstractHeightMap;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.terrain.geomipmap.lodcalc.DistanceLodCalculator;
import com.jme3.terrain.heightmap.HillHeightMap; // for exercise 2
import com.jme3.terrain.heightmap.ImageBasedHeightMap;
import com.jme3.texture.Texture;
import com.jme3.texture.Texture.WrapMode;
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("Alpha", assetManager.loadTexture(
"Textures/Terrain/splat/alphamap.png"));
/** 1.2) Add GRASS texture into the red layer (Tex1). */
Texture grass = assetManager.loadTexture(
"Textures/Terrain/splat/grass.jpg");
grass.setWrap(WrapMode.Repeat);
mat_terrain.setTexture("Tex1", grass);
mat_terrain.setFloat("Tex1Scale", 64f);
/** 1.3) Add DIRT texture into the green layer (Tex2) */
Texture dirt = assetManager.loadTexture(
"Textures/Terrain/splat/dirt.jpg");
dirt.setWrap(WrapMode.Repeat);
mat_terrain.setTexture("Tex2", dirt);
mat_terrain.setFloat("Tex2Scale", 32f);
/** 1.4) Add ROAD texture into the blue layer (Tex3) */
Texture rock = assetManager.loadTexture(
"Textures/Terrain/splat/road.jpg");
rock.setWrap(WrapMode.Repeat);
mat_terrain.setTexture("Tex3", rock);
mat_terrain.setFloat("Tex3Scale", 128f);
/** 2. Create the height map */
AbstractHeightMap heightmap = null;
Texture heightMapImage = assetManager.loadTexture(
"Textures/Terrain/splat/mountains512.png");
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) 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) We supply the prepared heightmap itself.
*/
int patchSize = 65;
terrain = new TerrainQuad("my terrain", patchSize, 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.
Heightmaps are an efficient way of representing the shape of a hilly landscape. Not every pixel of the landscape is stored, instead, a grid of sample values is used to outline the terrain height at certain points. The heights between the samples is interpolated.
In Java, a heightmap is a float array containing height values between 0f and 255f. Here is a very simple example of a terrain generated from a heightmap with 5x5=25 height values.
Important things to note:
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, like a topographical map:
Look at the next screenshot: In the top left you see a 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.
The first step of terrain creation is the heightmap. You can create one 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 an image heightmap.
Here is how you create the heightmap object in your jME code:
ImageToAwt.convert()
ed image file.AbstractHeightMap heightmap = null; Texture heightMapImage = assetManager.loadTexture( "Textures/Terrain/splat/mountains512.png"); heightmap = new ImageBasedHeightMap( ImageToAwt.convert(heightMapImage.getImage(), false, true, 0)); heightmap.load();
Previously you learned how to create a material for a simple shape such as a cube. All sides of the cube have the same color. You can apply the same material to a terrain, but then you have one big meadow, one big rock desert, etc. This is not always what you want.
Texture splatting allows you create a custom material, and "paint" textures on it like with a "paint brush". 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.
Splat textures are based on the Terrain.j3md
material defintion. If you open the Terrain.j3md file, and look in the Material Parameters section, you see that you have several texture layers to paint on: Tex1
, Tex2
, Tex3
, etc.
Before you can start painting, you have to make a few decisions:
Tex1
Tex2
Tex3
Now you start painting the texture:
mountains512.png
. You want it as a reference for the shape of the landscape.alphamap.png
.alphamap.png
in a graphic editor and switch the image mode to color image.
⇒
As usual, you create a Material object. 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");
Load four textures into this material. The first one, Alpha
, is the alphamap that you just created.
mat_terrain.setTexture("Alpha", assetManager.loadTexture("Textures/Terrain/splat/alphamap.png"));
The three other textures are the layers that you have previously decided to paint: grass, dirt, and road. You create texture objects and load the three textures as usual. Note how you assign them to their respective texture layers (Tex1, Tex2, and Tex3) inside the Material!
/** 1.2) Add GRASS texture into the red layer (Tex1). */ Texture grass = assetManager.loadTexture( "Textures/Terrain/splat/grass.jpg"); grass.setWrap(WrapMode.Repeat); mat_terrain.setTexture("Tex1", grass); mat_terrain.setFloat("Tex1Scale", 64f); /** 1.3) Add DIRT texture into the green layer (Tex2) */ Texture dirt = assetManager.loadTexture( "Textures/Terrain/splat/dirt.jpg"); dirt.setWrap(WrapMode.Repeat); mat_terrain.setTexture("Tex2", dirt); mat_terrain.setFloat("Tex2Scale", 32f); /** 1.4) Add ROAD texture into the blue layer (Tex3) */ Texture rock = assetManager.loadTexture( "Textures/Terrain/splat/road.jpg"); rock.setWrap(WrapMode.Repeat); mat_terrain.setTexture("Tex3", rock); mat_terrain.setFloat("Tex3Scale", 128f);
The individual texture scales (e.g. mat_terrain.setFloat("Tex3Scale", 128f);
) depend on the size of the textures you use.
Use setWrap(WrapMode.Repeat)
to make the small texture fill the wide area. If the repetition is too visible, try adjusting the respective Tex*Scale
value.
Internally, the generated terrain mesh is broken down into tiles and blocks. This is an optimization to make culling easier. You do not need to worry about "tiles and blocks" too much, just use recommended values for now – 64 is a good start.
Let's assume you want to generate a 512x512 terrain. You already have created the heightmap object. Here are the steps that you perform everytime you create a new terrain.
Create a TerrainQuad with the following arguments:
my terrain
.Here's the code:
terrain = new TerrainQuad( "my terrain", // name 65, // tile size 513, // block size heightmap.getHeightMap()); // heightmap
You have created the terrain object.
terrain.setMaterial(mat_terrain);
rootNode.attachChild(terrain);
Tip: Terrain.j3md is an unshaded material definition, so you do not need a light source. You can also use TerrainLighting.j3md plus a light, if you want a shaded terrain.
JME3 includes an optimization that adjusts the level of detail (LOD) 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 JME3 improves performance by rendering them less detailed. This way you can afford to load huge terrains with no penalty caused by invisible details.
What happens when you swap two layers, for example Tex1
and Tex2
?
... mat_terrain.setTexture("Tex2", grass); ... mat_terrain.setTexture("Tex1", dirt);
You see it's easier to swap layers in the code, than to change the colors in the alphamap.
The following two lines generate the heightmap object based on your user-defined image:
Texture heightMapImage = assetManager.loadTexture( "Textures/Terrain/splat/mountains512.png"); heightmap = new ImageBasedHeightMap( ImageToAwt.convert(heightMapImage.getImage(), false, true, 0), 1f);
Instead, you can also let JME3 generate a random landscape for you:
HillHeightMap heightmap = null; try { heightmap = new HillHeightMap(513, 1000, 50, 100, (byte) 3); } catch (Exception ex) { ex.printStackTrace(); }
Tip: For this exercise, you can keep using the splat Material from the sample code above. Just don't be surprised that the Material does not match the shape of the newly randomized landscape. If you want to generate real matching splat textures for randomized heightmaps, you need to write a custom method that, for example, creates an alphamap from the heightmap by replacing certain grayscales with certain RGB values.
Can you combine what you learned here and in Hello Collision, and make the terrain solid?
You have learned how to create terrains that are more efficient as loading one giant model. You know how to create generate random or handmade heightmaps. You can add a LOD control to render large terrains faster. You are aware that you can combine what you learned about collison detection to make the terrain solid to a physical player. You are also able to texture a terrain "like a boss" using layered Materials and texture splatting. You are aware that the jMonkeyPlatform provides a TerrainEditor that helps with most of these manual tasks.
Do you want to hear your players say "ouch!" when they bump into a wall or fall off a hill? Continue with learning how to add sound to your game.
See also: