This tutorial we get to have a little fun. We are going to load the terrain for our game. There are a few goals for the style of terrain I want to use:
We will be building on our Framework from Lesson 2. First, start by removing all the Sphere rendering code. We no longer need this example. You should have a fairly clean framework to work with now. Now, the terrain we are going to make is going to be rather large. So, for the time being, I want to alter the position of the Camera to keep it in view. So, in initSystem change:
Vector3f loc = new Vector3f(0.0f, 0.0f, 25.0f);
to:
Vector3f loc = new Vector3f(500.0f, 150.0f, 500.0f);
This moves up far back to insure we have a decent view of the terrain.
Now, inside the initGame method we are going to add a call to a new method, and add a TerrainBlock to the scene. This TerrainBlock is called tb and should be defined at the top of the class. This new method is called buildTerrain and should be called just before adding tb to the scene graph. You should have something like the following:
protected void initGame() { scene = new Node("Scene graph node"); buildTerrain(); scene.attachChild(tb); // update the scene graph for rendering scene.updateGeometricState(0.0f, true); scene.updateRenderState(); }
Which leads us to the guts of this tutorial, buildTerrain.
There are three parts to our terrain building:
AbstractHeightMap defines a method for storing height data. At its core, it's basically a two dimensional matrix of data, where any point (x, y) gives a height z. While this does not allow for complex terrain (caves, overhangs, etc) it provides a very solid base for terrain and is everything we need for Flag Rush.
We will create a MidPointHeightMap which generates terrain using a Midpoint displacement fractal. This will allow for enough interest and realism in the terrain, providing us with some bumps to jump from.
Creating this height map is straight forward, and the first line in our buildTerrain method:
/** * build the height map and terrain block. */ private void buildTerrain() { // Generate a random terrain data MidPointHeightMap heightMap = new MidPointHeightMap(64, 1f); //... }
We create a new heightMap object and call MidPointHeightMap's constructor. It only takes two parameters: The size and the “roughness”.
The size for MidPointHeightMap must be a power of two. That is, 2, 4, 8, 16, 32, 64, and so on. In our case, we chose 64. This provides a decent sized map for our needs (our action will be contained in a fairly small arena). The roughness value is where the interesting stuff happens. The lower this number the rougher the terrain, higher the smoother. We chose 1 which at first, makes the terrain look like the pits of hell with spires. However, we aren't done yet, these spikes will be toned down next.
We will define a terrain scale factor. This will basically stretch and squeeze the terrain mesh into the size we desire. So, add
// Scale the data Vector3f terrainScale = new Vector3f(20, 0.5f, 20);
to the method. This means the following: We are going to stretch the X and Z values of the terrain by 20. This will make the terrain much larger feeling (20 times larger in fact). While at the same time, decreasing the size of the heights by half. This will give us the bumps we want, but keep them at a reasonable size (not huge spikes).
Now, that we have our data set up, we can actually build the mesh. We are going to build a TerrainBlock which is a single Geometry. This will be added to the scene just like adding the Sphere in previous tutorials.
// create a terrainblock tb = new TerrainBlock("Terrain", heightMap.getSize(), terrainScale, heightMap.getHeightMap(), new Vector3f(0, 0, 0)); tb.setModelBound(new BoundingBox()); tb.updateModelBound();
The TerrainBlock takes in a number of parameters, most are straight forward. First, the name of the terrain. The size of the heightmap is given next along with the terrainScale we set earlier. The actual heightmap data is given next. The next parameter defines the origin of the terrain. We have no reason to set anything fancy here, so just give a basic (0,0,0) origin.
We then set up the BoundingVolume of the terrain.
You can probably go ahead and run the game now, and see something similar to:
Not a lot to see here, as the terrain is just one big hunk of white. We need to apply a texture to help give it a little dimension.
Creating the texture will be done using ProceduralTextureGenerator. This class will generate a texture based on the heights of the height map and blend between multiple textures. A texture is assigned a height area, and they are then blended into a single texture map. This allows us to create a fairly realistic looking terrain easily. In our case, we are going to use three textures, a grass texture for the low areas, rocks for the mid levels and snow for the high areas.
// generate a terrain texture with 3 textures ProceduralTextureGenerator pt = new ProceduralTextureGenerator(heightMap); pt.addTexture(new ImageIcon(Lesson3.class.getClassLoader() .getResource("jmetest/data/texture/grassb.png")), -128, 0, 128); pt.addTexture(new ImageIcon(Lesson3.class.getClassLoader() .getResource("jmetest/data/texture/dirt.jpg")), 0, 128, 255); pt.addTexture(new ImageIcon(Lesson3.class.getClassLoader() .getResource("jmetest/data/texture/highest.jpg")), 128, 255, 384); pt.createTexture(32);
You'll notice that every texture has three values. This represents the low, optimal and high elevations that this texture will be applied to. For example (dirt.jpg) will be blended from elevation 0 to 255. The heightmap generates values from 0 to 255. So this means, the dirt will be at its strongest (most visible) at height 128, and blend out to nothing at 0 and 255. While the other two textures fill in the low and high areas.
addTexture takes an ImageIcon object to define the texture data. In this example we build an ImageIcon from a URL obtained from the getResource method of our class. This looks for the images in the classpath. This, of course, is not required, and the ImageIcon can be built in a way that is most appropriate for your application.
createTexture actually creates the image that we will use for the texture. In this case, I tell it to generate a texture of size 32×32 pixels. While this may seem very small, I don't want it to be detailed. This is just for the base color. Later on we will add detail textures and objects.
For example, during a run of the game, I saved a generated texture. It looks like:
You can see how the three textures (grass, rocks and snow) were blended into a single texture. The white areas, would be a high spot in the terrain, and the grass low.
Now that we have generated the texture, we put it into a TextureState and apply it to the terrain.
// assign the texture to the terrain TextureState ts = display.getRenderer().createTextureState(); Texture t1 = TextureManager.loadTexture(pt.getImageIcon().getImage(), Texture.MinificationFilter.BilinearNearestMipMap, Texture.MagnificationFilter.Bilinear, true); ts.setTexture(t1, 0); tb.setRenderState(ts);
And with this the terrain is done. You can now run the game and see something similar to:
NOTE: I keep saying similar because we are using a random method for generating the terrain, so it will be different everytime.
Even with the texture it is very difficult to make out the terrain itself. That's because there is no light and shadows to help distinguish parts of the terrain. So, let's go ahead and add a “sun”. Add a buildLighting to your initGame. We will add a DirectionalLight to shine on our terrain.
There are two parts to adding the lights. First, creating the DirectionalLight and then adding it to a LightState.
private void buildLighting() { /** Set up a basic, default light. */ DirectionalLight light = new DirectionalLight(); light.setDiffuse(new ColorRGBA(1.0f, 1.0f, 1.0f, 1.0f)); light.setAmbient(new ColorRGBA(0.5f, 0.5f, 0.5f, 1.0f)); light.setDirection(new Vector3f(1,-1,0)); light.setEnabled(true); /** Attach the light to a lightState and the lightState to rootNode. */ LightState lightState = display.getRenderer().createLightState(); lightState.setEnabled(true); lightState.attach(light); scene.setRenderState(lightState); }
The DirectionalLight is set to shine in the (1,-1,0) direction (down and to the right). It is then added to the LightState and applied to the scene. This adds a lot more dimension to the scene, and you can make out the terrain features much better:
We now have the level on which to race around. However, there is still that annoying black background. Next lesson, we will take care of that.
package jmetest.flagrushtut; import javax.swing.ImageIcon; import com.jme.app.BaseGame; import com.jme.bounding.BoundingBox; import com.jme.image.Texture; import com.jme.input.InputSystem; import com.jme.input.KeyBindingManager; import com.jme.input.KeyInput; import com.jme.light.DirectionalLight; import com.jme.light.PointLight; import com.jme.math.Vector3f; import com.jme.renderer.Camera; import com.jme.renderer.ColorRGBA; import com.jme.scene.Node; import com.jme.scene.state.LightState; import com.jme.scene.state.TextureState; import com.jme.system.DisplaySystem; import com.jme.system.JmeException; import com.jme.util.TextureManager; import com.jme.util.Timer; import com.jmex.terrain.TerrainBlock; import com.jmex.terrain.util.MidPointHeightMap; import com.jmex.terrain.util.ProceduralTextureGenerator; /** * Tutorial 3 Loads a random terrain for uses at the game level. * framework for Flag Rush. For Flag Rush Tutorial Series. * * @author Mark Powell */ public class Lesson3 extends BaseGame { private TerrainBlock tb; protected Timer timer; // Our camera object for viewing the scene private Camera cam; // the root node of the scene graph private Node scene; // display attributes for the window. We will keep these values // to allow the user to change them private int width, height, depth, freq; private boolean fullscreen; /** * Main entry point of the application */ public static void main(String[] args) { Lesson3 app = new Lesson3(); // We will load our own "fantastic" Flag Rush logo. Yes, I'm an artist. app.setConfigShowMode(ConfigShowMode.AlwaysShow, Lesson3.class.getClassLoader() .getResource("jmetest/data/images/FlagRush.png")); app.start(); } /** * During an update we only look for the escape button and update the timer * to get the framerate. * * @see com.jme.app.SimpleGame#update() */ protected void update(float interpolation) { // update the time to get the framerate timer.update(); interpolation = timer.getTimePerFrame(); // if escape was pressed, we exit if (KeyBindingManager.getKeyBindingManager().isValidCommand("exit")) { finished = true; } } /** * draws the scene graph * * @see com.jme.app.SimpleGame#render() */ protected void render(float interpolation) { // Clear the screen display.getRenderer().clearBuffers(); display.getRenderer().draw(scene); } /** * initializes the display and camera. * * @see com.jme.app.SimpleGame#initSystem() */ protected void initSystem() { // store the properties information width = settings.getWidth(); height = settings.getHeight(); depth = settings.getDepth(); freq = settings.getFrequency(); fullscreen = settings.isFullscreen(); try { display = DisplaySystem.getDisplaySystem(settings.getRenderer()); display.createWindow(width, height, depth, freq, fullscreen); cam = display.getRenderer().createCamera(width, height); } catch (JmeException e) { e.printStackTrace(); System.exit(1); } // set the background to black display.getRenderer().setBackgroundColor(ColorRGBA.black); // initialize the camera cam.setFrustumPerspective(45.0f, (float) width / (float) height, 1, 1000); Vector3f loc = new Vector3f(500.0f, 150.0f, 500.0f); Vector3f left = new Vector3f(-1.0f, 0.0f, 0.0f); Vector3f up = new Vector3f(0.0f, 1.0f, 0.0f); Vector3f dir = new Vector3f(0.0f, 0.0f, -1.0f); // Move our camera to a correct place and orientation. cam.setFrame(loc, left, up, dir); /** Signal that we've changed our camera's location/frustum. */ cam.update(); /** Get a high resolution timer for FPS updates. */ timer = Timer.getTimer(); display.getRenderer().setCamera(cam); KeyBindingManager.getKeyBindingManager().set("exit", KeyInput.KEY_ESCAPE); } /** * initializes the scene * * @see com.jme.app.SimpleGame#initGame() */ protected void initGame() { scene = new Node("Scene graph node"); buildTerrain(); scene.attachChild(tb); buildLighting(); // update the scene graph for rendering scene.updateGeometricState(0.0f, true); scene.updateRenderState(); } /** * creates a light for the terrain. */ private void buildLighting() { /** Set up a basic, default light. */ DirectionalLight light = new DirectionalLight(); light.setDiffuse(new ColorRGBA(1.0f, 1.0f, 1.0f, 1.0f)); light.setAmbient(new ColorRGBA(0.5f, 0.5f, 0.5f, 1.0f)); light.setDirection(new Vector3f(1,-1,0)); light.setEnabled(true); /** Attach the light to a lightState and the lightState to rootNode. */ LightState lightState = display.getRenderer().createLightState(); lightState.setEnabled(true); lightState.attach(light); scene.setRenderState(lightState); } /** * build the height map and terrain block. */ private void buildTerrain() { // Generate a random terrain data MidPointHeightMap heightMap = new MidPointHeightMap(64, 1f); // Scale the data Vector3f terrainScale = new Vector3f(20, 0.5f, 20); // create a terrainblock tb = new TerrainBlock("Terrain", heightMap.getSize(), terrainScale, heightMap.getHeightMap(), new Vector3f(0, 0, 0)); tb.setModelBound(new BoundingBox()); tb.updateModelBound(); // generate a terrain texture with 3 textures ProceduralTextureGenerator pt = new ProceduralTextureGenerator( heightMap); pt.addTexture(new ImageIcon(Lesson3.class.getClassLoader() .getResource("jmetest/data/texture/grassb.png")), -128, 0, 128); pt.addTexture(new ImageIcon(Lesson3.class.getClassLoader() .getResource("jmetest/data/texture/dirt.jpg")), 0, 128, 255); pt.addTexture(new ImageIcon(Lesson3.class.getClassLoader() .getResource("jmetest/data/texture/highest.jpg")), 128, 255, 384); pt.createTexture(32); // assign the texture to the terrain TextureState ts = display.getRenderer().createTextureState(); ts.setEnabled(true); Texture t1 = TextureManager.loadTexture(pt.getImageIcon().getImage(), Texture.MinificationFilter.Trilinear, Texture.MagnificationFilter.Bilinear, true); ts.setTexture(t1, 0); tb.setRenderState(ts); } /** * will be called if the resolution changes * * @see com.jme.app.SimpleGame#reinit() */ protected void reinit() { display.recreateWindow(width, height, depth, freq, fullscreen); } /** * clean up the textures. * * @see com.jme.app.SimpleGame#cleanup() */ protected void cleanup() { } }