Heightmap Terrain Rendering

Using heightmaps and Java's Mobile 3D Graphics API to create realistic 3D graphics for mobile devices.


May 16, 2006
URL:http://www.drdobbs.com/jvm/heightmap-terrain-rendering/jvm/heightmap-terrain-rendering/187203544

Mikael is a senior programmer for Redikod. He can be contacted at [email protected].


When done right, terrain provides a smooth-surface model of mountains, rivers, cliffs, hills, and other geographic phenomena. The point of terrain is that it gives users the impression they are traversing the real world. From a more abstract perspective, however, terrain really only involves variations in height. For instance, a grassy plain is a terrain with constant height (except for maybe some bumps and hills). On the other hand, a mountain region is a terrain that has significant height variations, creating large glyphs between areas that lend the illusion of mountains. Rivers are plains combined with curves that contain lower heights than the surrounding plain. For example, the terrain in Figure 1 is described by three areas of greater height—the three green hills—and the rest is a deep gorge filled with water.

Figure 1: Typical 3D terrain.

Heightmaps let you create natural terrain by providing an elegant solution to storing variations in height and smoothing surfaces. To illustrate, Figure 2 is a grayscale image that looks like a donut with a white speck in the middle. A grayscale image is a collection of pixels where each pixel goes from 0 to 255, where 0 is black and 255 white. What if you could use each pixel to determine height? If a black pixel (value 0) could be the lowest height and a white pixel (value 255) the highest, you'd have a map that depicts height—a heightmap. Another great thing about this is that since pixels go from 0 to 255, you get automatic interpolation of the terrain (thus creating smooth terrain) if you just blur images.

Figure 2: Grayscale image.

In this article, I examine how to use heightmaps for creating realistic 3D graphics. For purposes of illustration, the platform I use is the Java ME JSR 184 specification (www.jcp.org/en/jsr/detail?id=184), the Mobile 3D Graphics (M3G) API that provides a scalable, small-footprint, interactive 3D API for mobile devices such as cell phones from Sony Ericsson. The complete source code accompanying this article is available at http://www.ddj.com/code/.

Quads

To convert heightmaps into something you can render, you need to read the pixels of the heightmap, then create surfaces that reflect the variations in height. An easy-to-use surface in this problem is a "quad"—two triangles put together to form a rectangular surface.

Figure 3 is a quad that consists of two triangles. As you can see, the quad has four endpoints that are disjointed because we are using two triangles to represent it. These four corners can be given different heights; thus, you have the beginning of describing heights in a 3D world. Because one quad isn't enough to describe an entire terrain, you'll use lots of them if you want the terrain to look realistic.

Figure 3: Quads.

public static Mesh createQuad(short[] heights, int cullFlags)
  {
    // The vertrices of the quad
    short[] vertrices = {-255, heights[0], -255,
      255, heights[1], -255,
      255, heights[2], 255,
      -255, heights[3], 255};
Listing One

// Create the model's vertex colors
VertexArray colorArray = new VertexArray(color.length/3, 3, 1);
colorArray.set(0, color.length / 3, color);

// Compose VertexBuffer out of previous vertrices  // and texture coordinates
VertexBuffer vertexBuffer = new VertexBuffer();
vertexBuffer.setPositions(vertexArray, 1.0f, null);
vertexBuffer.setColors(colorArray);

// Create indices and face lengths
int indices[] = new int[] {0, 1, 3, 2};
int[] stripLengths = new int[] {4};

// Create the model's triangles
triangles = new TriangleStripArray(indices, stripLengths);
Listing Two

// Create the appearance
  Appearance appearance = new Appearance();
  PolygonMode pm = new PolygonMode();
  pm.setCulling(cullFlags);
  pm.setPerspectiveCorrectionEnable(true);
  pm.setShading(PolygonMode.SHADE_SMOOTH);
  appearance.setPolygonMode(pm);
Listing Three

To implement a quad, I start with an x-z plane with variable y-coordinates for varying height. I introduce the method createQuad to my MeshFactory (available in the full source code archive at http://www.ddj.com/code/). All createQuad needs to know is the different heights at the different corners of the quad, along with the culling flags. Listing One is a quad consisting of four vertices, each with varying y-components but static x and z. Listing Two creates the arrays necessary for describing a Mesh in an M3G system. The VertexBuffer holds two vertex arrays—the color array and position array. Listing Three, the next step in creating the quad, includes standard appearance stuff. However, the smooth shading means that colors of the vertices are interpolated over the whole surface, creating a smooth appearance. Now all that's left is to create the Mesh, which is straightforward:


// Finally create the Mesh
    Mesh mesh = 

       new Mesh(vertexBuffer, 
       triangles, appearance);

Creating Quads from Heightmaps

You're now ready to create a quad with varying height. Because you need lots of them for realistic terrains, the challenge is how to convert heightmaps into quads. Figure 4 is a white grid superimposed on the previous heightmap. Looking at each piece of the grid, you see that the rectangular grid sector is another heightmap but smaller. If you create a high-resolution grid, you realize that the grid sectors become very small and thus easy to approximate with a single quad. In other words, to approximate a heightmap, just split it into many small parts, each representing a quad.

Figure 4: Grid superimposed over a heightmap.

To create a quad:

  1. Divide the image into many small sectors (a minimum size of 2×2 pixels).
  2. Take each sector's corner pixels and read their values (0-255).
  3. Assign these values as the heights of the quad (see method declaration).

After creating the quads from a heightmap, render them one after another and you have a heightmap. As the resolution of the heightmap grid increases, so does the smoothness of the terrain, as you use more quads to represent the terrain. However, you are also drastically increasing the memory footprint and increasing the number of polygons that the GPU has to push. This is a trade-off that needs to be done on every mobile phone depending on available memory, GPU power, and so on.

Implementation

To implement a heightmap in M3G:

  1. Load a heightmap.
  2. Create a new array that is scaled proportionally to the grid size.
  3. Read pixels from the heightmap and store in the new array.
  4. Use the array to generate quads with varying heights.

It's a straightforward procedure that begins by inspecting the private members of the HeightMap class; see Listing Four. The heightMap array is the scaled array that holds the heights. It is not holding the pixels from the heightmap image. The Mesh table is holding all the generated Quads you render. Finally, the water Mesh is a blue plane that represents the water (rivers, lakes, and so on) in the terrain. Listing Five creates a HeightMap by first checking for invalid resolution values. Invalid values are values beyond 1.0f (a quad has four corners, so the smallest grid sector is a 2×2) and below 0.0001f (a very low resolution that more or less creates the entire terrain with one quad).

// Actual heightmap containing the Y-coords of our triangles
    private short[] heightMap;
    private int[] data;
    private int imgw, imgh;
   
    // Map dimensions
    private int mapWidth;
    private int mapHeight;
   
    // Actual quads
    private Mesh[][] map;
   
    // Water
    private Mesh water;
   
    // Local transform used for internal calculations
    private Transform localTransform = new Transform();
Listing Four

public HeightMap(String imageName, float resolution, int waterLevel)
throws IOException
    {
        // Check for invalid resolution values
        if(resolution <= 0.0001f || resolution > 1.0f)
            throw new IllegalArgumentException("Resolution too small or too large");
       
        // Load image and allocate the internal array
        loadImage(imageName, resolution);
       
        // Create quads
        createQuads();
       
        // Create the water
        createWater(waterLevel);
    }
Listing Five

Next, Listing Six loads the image supplied as a constructor parameter and extracts its pixel values. Using the resolution parameter supplied from the constructor, you then create a size grid and fill it with pixel values. Last, you perform manual garbage collection to get rid of unnecessary data because the loadImage method is a memory-intensive method and you want to ensure garbage data isn't taking up vital memory for the next few tasks.

// Load actual image
        Image img = Image.createImage(path);
       
        // Allocate temporary memory to store pixels
        data = new int[img.getWidth() * img.getHeight()];
       
        // Get its rgb values
        img.getRGB(data, 0, img.getWidth(), 0, 0, img.getWidth(), img.getHeight());
       
        imgw = img.getWidth();
        imgh = img.getHeight();
       
        // Clear image
        img = null;
        System.gc();
       

        // Calculate new width and height
        mapWidth = (int)(res * imgw);
        mapHeight = (int)(res * imgh);
       
        // Allocate heightmap
        heightMap = new short[mapWidth * mapHeight];
       
        // Calculate height and width offset into image
        int xoff = imgw / mapWidth;
        int yoff = imgh / mapHeight;
       
        // Set height values
        for(int y = 0; y < mapHeight; y++)
        {
            for(int x = 0; x < mapWidth; x++)
            {
                heightMap[x + y * mapWidth] = (short)((data[x * xoff + y* yoff * imgw] & 0x000000ff) * 10);
            }
        }       
       
        // Clear data
        data = null;
   img = null;
        System.gc();
        
Listing Six

The createQuads method in the constructor body is a straightforward method that takes the generated heightMap array and creates quads from it; see Listing Seven. Here, you iterate over the heightMap table and extract four values, which are used as the height values in the MeshFactory.createQuad method. The MeshFactory.createPlane method then creates a large plane textured with a watery mesh.

private void createQuads()
    {
        map = new Mesh[mapWidth][mapHeight];
        short[] heights = new short[4];
       
        for(int x = 0; x < (mapWidth - 1); x++)
        {
            for(int y = 0; y < (mapHeight - 1); y++)
            {
                // Set heights
                setQuadHeights(heights, x, y, mapWidth);
               
                // Create mesh
                map[x][y] = MeshFactory.createQuad(heights,PolygonMode.CULL_NONE);
            }
        }
    }
Listing Seven

Rendering

How do you render the generated quads? The answer to this question is in the render method of the HeightMap class; see Listing Eight. All you need to do is go through the table of quads and render them at their given position in space. The user of the render method may supply a transform to be applied to each quad after the local transform, which is only putting each quad in its own place. Finally, you place the water mesh at the height level defined during heightmap creation.

public void render(Graphics3D g3d, Transform t)
    {
        for(int x = 0; x < map.length - 1; x++)
        {
            for(int y = 0; y < map[x].length - 1; y++)
            {
                localTransform.setIdentity();

                localTransform.postTranslate(x * 5.0f, 0.0f, (mapHeight- y) * -5.0f);
                localTransform.postMultiply(t);
                g3d.render(map[x][y], localTransform);
            }
        }
       
        localTransform.setIdentity();
        localTransform.postScale(255, 255, 255);
        localTransform.postRotate(-90, 1.0f, 0.0f, 0.0f);
        g3d.render(water, localTransform);
    }
Listing Eight

Putting It All Together

Now you are finally ready to use the HeightMap class to load and render a heightmap from an existing grayscale image; see Listing Nine. Nothing out of the ordinary here. You just load the heightmap and do some transforms on it, since the transform is supplied to the HeightMap's render method later on. We want to scale it a lot since terrain is normally huge.

private void createScene()
    {
        try
        {
            // We're using a pretty high resolution. If you want to test this on an actual
            // handheld, try using a lower resolution, such as 0.20 or 0.10
         hm = new HeightMap("/res/heightmap4.png", 0.30f, 40);       
                 
         t.postTranslate(0.0f, -2.0f, -5.0f);
         t.postScale(0.01f, 0.01f, 0.01f);
        
         camTrans.postTranslate(0.0f, 5.0f, 0.0f);
         //camTrans.postTranslate(0.0f, 5.0f, 2.0f);
        }
        catch(Exception e)
        {
            System.out.println("Heightmap error: " + e.getMessage());
            e.printStackTrace();
            TutorialMidlet.die();
        }
    }
Listing Nine

It is also important to note that for clarity, the HeightMap I present here is rendered without culling, something needed with large terrains. Finally, Listing Ten presents the draw method for rendering the HeightMap. While the HeightMap.render(g3d, t) method is straightforward, the controls might be unfamiliar. Move the camera with the joystick—up, down, and rotate left and right. To move the camera forward, use the FIRE key.

// Get the Graphics3D context
            g3d = Graphics3D.getInstance();
           
         // First bind the graphics object. We use our pre-defined rendering hints.
         g3d.bindTarget(g, true, RENDERING_HINTS);
        
         // Clear background
         g3d.clear(back);
        
         // Bind camera at fixed position in origo
         g3d.setCamera(cam, camTrans);
        
         // Render everything
         hm.render(g3d, t);
        

         // Check controls for camera movement
         if(key[UP])
         {
             camTrans.postTranslate(0.0f, 1.0f, 0.0f);
         }
         if(key[DOWN])
         {
             camTrans.postTranslate(0.0f, -1.0f, 0.0f);
         }
         if(key[LEFT])
         {
             camTrans.postRotate(5, 0.0f, 1.0f, 0.0f);
         }
         if(key[RIGHT])
         {
             camTrans.postRotate(-5, 0.0f, 1.0f, 0.0f);
         }
        
         // Fly forward
         if(key[FIRE])
             camTrans.postTranslate(0.0f, 0.0f, -1.0f);
Listing Ten

Conclusion

At this point, why don't you load the other heightmaps supplied in the source code files available electronically? See what kind of terrains come out. Or even better, create your own heightmap image!

Converting 3D Games to Landscape Mode

Mobile devices such as the Sony Ericsson W550 and W600 Walkman phones support two game-playing styles—portrait and landscape, both specifically designed for gaming and camera use. But while users can change between playing styles, there is no way for a program to detect if a phone is used in portrait or landscape—the Java VM is not aware of any screen configuration changes.

One mobile application developer that has experienced landscape game development first hand is Digital Chocolate (www.digitalchocolate.com/). Digital Chocolate has converted its Mobile Java 3D game Extreme Air Snowboarding 3D to landscape mode. The lessons learned from this development experience helped Digital Chocolate port four more games to the landscape game-play style for Sony Ericsson mobile phones.

Digital Chocolate started the landscape mode porting project for Extreme Air Snowboarding 3D by writing and adding a new canvas implementation to its core gaming library. This implementation, together with changes to Digital Chocolate's core toolkit, handles all the hard work and, if the game is designed properly, the game itself requires few (if any) changes to existing code. The canvas simply receives information whenever the orientation of the screen is changed, and it reacts to this information by enabling/disabling an extra backbuffer, to which the graphics are rendered before drawing them to the screen, and by signaling the game to recalculate screen offsets of graphical elements, tile counts, and other data required for rendering the output.

The game continues to draw into the graphics object it receives as usual, but the canvas implementation rotates the backbuffer by 90 degrees before drawing it to the actual screen graphics object. If the game code is designed to support this feature from the ground up, this is theoretically all that is needed. Most of Digital Chocolate's games have been designed so that they support scalable screen sizes, so porting has been relatively easy.

"For best portability, it's extremely important that the positions of the graphical elements are not hard-coded to fixed-screen locations; instead, locations should be specified relatively to screen edges. Better yet, a percentage scale should be used whenever possible," explains Miikka Kukkosuo, a Digital Chocolate game engineer.

Determining a good layout for screen items requires some extra thinking at the beginning of the development cycle, as rotating the screen radically changes the display's aspect ratio. This poses an extra challenge for the graphic designers, who are often already working with tight graphics memory budgets and have to find an effective way to fit textboxes, score displays, and other items onto the screen. Also, the game components need to be designed so that they won't look bad even if the orientation changes, as usually only one graphics/tile set can be used for each game.

"3D solves many of the graphics scaling issues as it's easier to change the scale of the graphics and field-of-view (FOV) than in 2D. Usually good results can be achieved simply by tweaking the camera aspect ratio and FOV, and by making sure the camera views do not show anything that is not supposed to be seen outside screen borders," Kukkosuo comments.

"So far we haven't had to change anything in the actual 3D scenes, but at some point this may become necessary, as some games can be more sensitive to what's actually seen in the viewport than others," he explains, "If multiple cameras are used, extra care should be taken to make sure that the visibility angles for all the cameras actually get fixed, otherwise some nasty surprises may surface during the game play."

Digital Chocolate discovered that adding landscape mode support to an existing game was relatively easy in both 2D and Mobile Java 3D if the game was well designed from the ground up. Digital Chocolate recommends spending some time to build flexible core classes so that fixed-screen locations and parameters are kept to a minimum. This also enhances the portability of the game to other mobile phones and screen sizes.

Code snippets from the canvas implementation used by Digital Chocolate in the landscape version of Extreme Air Snowboarding 3D are available in the source code download for the June issue of DDJ. Note in the code examples that when the orientation of the screen is changed, the graphics buffer should be reallocated; see the setScreenRotated() method.

Terms of Service | Privacy Statement | Copyright © 2024 UBM Tech, All rights reserved.