Navigate through virtual worlds using Java 3D

Use level-of-detail and fly-through behaviors

Page 2 of 5
import java.io.*;
/**
 *  This class is a specialization of the ElevationFile class created
 * specifically to load DEM format data from the USGS archives.
 *
 * @author  Mark Pendergast
 * @version 1.0 February 2003
 *  @see ElevationFile
 */
public class DemFile extends ElevationFile {
public static final int ARECORD_LENGTH = 1024; public static final int QUADRANGLE_NAME_LENGTH = 144; public static final int MIN_ARECORD_TOKENS = 39;
/**
*  Create DemFile object from data contained in specified file.
*
*  @param aFileName name of the DEM file to load. File name should be a
*  fully qualified file name.
*  @exception IllegalArgumnetException thrown whenever an invalid data
* file is given as an argument.
*/
public DemFile(String aFileName) throws IllegalArgumentException
{
  try{
      char[] Arecord = new char[ARECORD_LENGTH];
      fileName = new String(aFileName);
      FileReader file = new FileReader(fileName);   
      BufferedReader bReader = new BufferedReader(file);  
//
// Read and parse out A record.
//
      if(bReader.read(Arecord,0,ARECORD_LENGTH) == -1)
      {
       bReader.close();
       System.out.println("Invalid file format (bad arecord) : "+fileName);
       throw(new IllegalArgumentException("Invalid file format : "+ fileName));
     }
      quadrangleName = new String(Arecord, 0, QUADRANGLE_NAME_LENGTH);
      quadrangleName = quadrangleName.trim();
      minElevation = (int)parseDemDouble(new String(Arecord,738,24));
      maxElevation = (int)parseDemDouble(new String(Arecord,762,24));
      groundCoordinates.sw[GroundCoordinates.LONGITUDE] = 
         Math.abs(parseDemDouble(new String(Arecord,546,24)));
      groundCoordinates.sw[GroundCoordinates.LATITUDE] = 
         Math.abs(parseDemDouble(new String(Arecord,570,24)));
      groundCoordinates.nw[GroundCoordinates.LONGITUDE] = 
         Math.abs(parseDemDouble(new String(Arecord,594,24)));
      groundCoordinates.nw[GroundCoordinates.LATITUDE] =
         Math.abs(parseDemDouble(new String(Arecord,618,24)));
      groundCoordinates.ne[GroundCoordinates.LONGITUDE] =
         Math.abs(parseDemDouble(new String(Arecord,642,24)));
      groundCoordinates.ne[GroundCoordinates.LATITUDE] = 
         Math.abs(parseDemDouble(new String(Arecord,666,24)));
      groundCoordinates.se[GroundCoordinates.LONGITUDE] =
         Math.abs(parseDemDouble(new String(Arecord,690,24)));
      groundCoordinates.se[GroundCoordinates.LATITUDE] = 
         Math.abs(parseDemDouble(new String(Arecord,714,24)));
      nColumns = (int)parseDemDouble(new String(Arecord,858,6));
//
//  Use a StreamTokenizer to parse B records, one record for each column.
//  Set the StreamTokenizer to use a space a delimiter and convert all
//  tokens to strings.
//
    StreamTokenizer st = new StreamTokenizer(bReader); // Stream prepositioned to start of B record.
    st.resetSyntax();
    st.whitespaceChars(' ',' ');
    st.wordChars(' '+1,'z');
    for(int column = 0; column < nColumns; column++)
    {
     int ttype;
     double rowCoordinateLat, rowCoordinateLong;
     st.nextToken(); // Skip row ID.
     st.nextToken(); // Skip column ID.
     ttype = st.nextToken(); // Number of rows.
     nRows = (int)parseDemInt(st.sval);
     if(elevations == null) // Allocate array if necessary.
       elevations = new int[nRows][nColumns];
     for(int i=0; i < 6; i++)  // Skip 6 fields.
       st.nextToken();
     for(int row = 0; row<nRows; row++) // Read in elevation data.
     {
       st.nextToken();
       elevations[row][column]=  parseDemInt(st.sval);
     }
    }
    bReader.close();
   } // End try.
   catch(IOException e){
       System.out.println("IE Exception when loading from [" + fileName + "] error: " + 
       e.getMessage());
       throw new IllegalArgumentException("File I/O failure : "+fileName);
   }
   catch(NumberFormatException e){
       System.out.println("NumberFormat Exception when loading from [" + fileName + "] ");
       throw new IllegalArgumentException("Invalid file format : "+fileName);
   }
   System.gc();  // Clean out memory.
}
/**
 * This method parses a double from a string.  Note, DEM data uses
 * the old FORTRAN notation for storing doubles using a 'D' instead of an
 * 'E'.
 * @param in  string to parse
 * @return double value from string
 * @exception NumberFormatException thrown when string is not a valid double
 */
public double parseDemDouble(String in) throws NumberFormatException
{
  String st = in.replace('D','E');  // Convert FORTRAN format to modern.
  return Double.parseDouble(st.trim());
}
public int parseDemInt(String in) throws NumberFormatException
{
  String st = in.replace('D','E');  // Convert FORTRAN format to modern.
  return Integer.parseInt(st.trim());
}
}

Create the geometry

Once the DEM file has loaded, the elevation data can convert into Java 3D geometry objects. As stated previously, to support fly-through and other real-time screen update sequences, we must use the most efficient data structures and level-of-detail optimizations. My experience has shown that TriangleStripArrays using interleaved-by-reference data handling is the most efficient in terms of memory and processor usage. While modeling the entire terrain model as a single TriangleStripArray object is possible, that does not allow you to take full advantage of Java 3D's level-of-detail feature. For LOD to work, you must have some distant objects drawn at low resolution, and closer objects drawn at full resolution. Therefore, the region divides into numerous segments; in my demonstration program, I divided the region into a six-by-six grid, as illustrated in Figure 3.

Figure 3. ElevationModel LOD grid

ElevationModel

The ElevationModel object is the top-level object in the terrain-modeling hierarchy. It divides the terrain data into a series of segments. Each segment is implemented as a LODSegment. Each LODSegment object creates three different ElevationSegment objects for its segment of the terrain. One segment is at full resolution, another plots every fifth elevation, and the third segment plots every tenth elevation. The system is created such that the segment where the viewer is located and the next adjacent segment are seen in full resolution, middle range segments are drawn at every fifth level, and distant segments are drawn at every tenth level. Figure 4 depicts what a cliff face looks like when viewed in the various levels of detail (from the same observation point).

Figure 4. Level-of-detail differences

The code segment below details how ElevationModel constructs the LODSegment objects. ElevationModel first determines how many segments are needed by dividing the SECONDS_PER_SEGMENT constant into the geographic length and width. A LODSegment array is then allocated to hold references to the segments. A set of nested for loops does all the work. During each iteration, a set of groundCoordinates, maximum and minimum x, z display coordinates, and the starting and stopping indexes into the elevation array are calculated. These pass to the LODSegment constructor. An important note: the end of one segment matches the start of the next segment. This prevents visible seams in the display. For example, one segment's maxX and stopColumn equals the minX and startColumn of the segment to its right.

Once the LODSegment is created, as shown in the code below, it is added as a child to the ElevationModel object (recall, ElevationModel is a BranchGroup). Once all segments have been created, the normals along their edges are adjusted to remove seams, and the scene is compiled to enhance performance. Normals are vectors attached to each vertex indicating the surface's orientation for lighting purposes. Refer to the section describing the ElevationSegment for more information on normals. To make the display more interesting, the ElevationModel class uses an exaggeration factor to make the elevation differences more apparent:

//
 //   Create LODSegments
 //
   sColumns = (int)Math.ceil(groundCoordinates.lengthSeconds()/SECONDS_PER_SEGMENT);
   sRows = (int)Math.ceil(groundCoordinates.widthSeconds()/SECONDS_PER_SEGMENT);
   segments = new LODSegment[sRows][sColumns];
   GroundCoordinates gc = new GroundCoordinates();
   int rowRatio = (int) (1.0d*file.nRows/sRows); int colRatio = (int) (1.0d*file.nColumns/sColumns);
   deltaRow = (north_Z-south_Z)/sRows;
   deltaCol = (east_X-west_X)/sColumns;
   for(int row = 0; row < sRows; row++)
   {
     float minX, maxX, minZ, maxZ;
     int startRow, stopRow, startCol, stopCol;
     gc.sw[GroundCoordinates.LATITUDE] = groundCoordinates.sw[GroundCoordinates.LATITUDE] + 
        row*SECONDS_PER_SEGMENT;
     gc.se[GroundCoordinates.LATITUDE] = groundCoordinates.sw[GroundCoordinates.LATITUDE]+ 
        row*SECONDS_PER_SEGMENT;
     gc.nw[GroundCoordinates.LATITUDE] = groundCoordinates.sw[GroundCoordinates.LATITUDE] + 
        row+1)*SECONDS_PER_SEGMENT;
     gc.ne[GroundCoordinates.LATITUDE] = groundCoordinates.sw[GroundCoordinates.LATITUDE]+ 
        (row+1)*SECONDS_PER_SEGMENT;
     minZ = south_Z + row*deltaRow;
     maxZ = south_Z +(row+1.0f)*deltaRow;
     startRow = row*(rowRatio);
     stopRow = (row+1)*(rowRatio);
     for(int col = 0 ; col < sColumns; col++)
     {
      if(stat != null)
         stat.setLabel2("Creating geometry segment ",row*sColumns+col+1,sRows*sColumns);
     minX = west_X + col*deltaCol;
     maxX = west_X +(col+1.0f)*deltaCol;
     startCol = col*(colRatio);
     stopCol = (col+1)*(colRatio);
     gc.sw[GroundCoordinates.LONGITUDE] = groundCoordinates.sw[GroundCoordinates.LONGITUDE] 
        - col*SECONDS_PER_SEGMENT;
     gc.nw[GroundCoordinates.LONGITUDE] = roundCoordinates.sw[GroundCoordinates.LONGITUDE] 
        -  col*SECONDS_PER_SEGMENT;
     gc.se[GroundCoordinates.LONGITUDE] = groundCoordinates.sw[GroundCoordinates.LONGITUDE]
        -  (col+1)*SECONDS_PER_SEGMENT;
     gc.ne[GroundCoordinates.LONGITUDE] = groundCoordinates.sw[GroundCoordinates.LONGITUDE]
        - (col+1)*SECONDS_PER_SEGMENT;
     segments[row][col] = new LODSegment(file.elevations,  startRow,startCol, stopRow,stopCol,
        minElevation, maxElevation, gc, exageration, minX, maxX, minZ, maxZ);
      addChild(segments[row][col]);
    }
 }
...
if(stat != null)
     stat.setLabel2("Compiling/Optimizing the geometry");
  compile(); // Compile the model

LODSegment

The LODSegment object creates the level-of-detail components for one segment of the terrain model. To implement a level of detail, we must create Switch and DistanceLOD objects. Switch provides the capability to selectively display one of its children (ElevationSegment). The DistanceLOD object is a behavior object that tells Switch which of its children to display. LODSegment is based on a BranchGroup so that it can be used as a single point of reference for the DistanceLOD, Switch, and all ElevationSegments.

The LODSegment constructor is shown in the code segment below. LODSegment first creates a Switch object, then creates three ElevationSegment objects at different resolutions, and adds them to Switch. LODSegment then creates a DistanceLOD object and initializes it with its position, distance array, and bounds. The position passed to the DistanceLOD object is calculated to be a location at the center of the region and at the model's highest elevation. The bounds passed to the DistanceLOD object are set to infinite so the segment can be seen from any location. The distance array, sized to have one fewer items than the resolution array, determines which segment will display. If the distance from the segment to the viewer is less than the first entry, then the first segment is used; if the distance from the segment to the view is less than the second entry, then the second segment is used; and so forth.

In my code, I calculated the distance array such that distances are based on twice a segment's length. This ensures that the segment where the viewer is currently located and the one immediately adjacent to it display in full detail. LODSegment then passes to the DistanceLOD object a reference to the Switch object that it will control. LODSegment must add both the DistanceLOD and the Switch objects. Examine LODSegment below:

public LODSegment( int elevations[][], int startRow, int startColumn, int stopRow, int stopColumn,
        int minEl, int maxEl, GroundCoordinates gc ,float exageration, 
       float minX, float maxX, float minZ, float maxZ)
  {
   super();
   groundCoordinates = gc;
  //
  // Initialize the switch node and create the child segments in varying resolutions
  //
 switchNode.setCapability(Switch.ALLOW_SWITCH_WRITE);
 segments = new ElevationSegment[resolutions.length];
  for(int i = 0; i < resolutions.length; i++)
  {
   segments[i] = new ElevationSegment(elevations, startRow,startColumn, stopRow,stopColumn,
      minEl,maxEl,groundCoordinates,
      exageration,minX,maxX,minZ,maxZ,resolutions[i]);
  switchNode.addChild(segments[i]);
 }
 //
 // Set the position and bounds of the object
 //
 Point3f position = new Point3f((float)((maxX+minX)/2),  maxEl*exageration,(float)((maxZ+minZ)/2));
 Bounds bounds = new BoundingSphere(new Point3d(0,0,0),Double.MAX_VALUE);
 //
 //  Calculate distances based on size of segment (east-west length)
 //
 distances = new float[resolutions.length-1];
 for(int i=0; i < distances.length; i++)
      distances[i] = Math.abs((float)((i+1)*2*(maxX-minX)));
//
//  Create the distanceLOD object
//
  dLOD = new DistanceLOD(distances,position);
  dLOD.setSchedulingBounds(bounds);
  dLOD.addSwitch(switchNode);
//
// Add the switch and the distance LOD to this object
//
 addChild(dLOD);
 addChild(switchNode);
 }

ElevationSegment

ElevationSegment is based on the Shape3D Java 3D object. Shape3D is a leaf node object that contains the actual geometry displayed on the screen. This geometry is based on the TriangleStripArray using the interleaved and by-reference parameters. A TriangleStripArray is a geometric primitive consisting of an array of vertices that form a series of triangles. The set of vertices is divided into a number of strips; each strip holds many triangles, as shown in Figure 5.

Figure 5. Triangle strips

Vertices 0, 1, and 2 create the first triangle; 1, 2, and 3 make up the second; 2, 3, and 4 create the third; and so on. The interleaved parameter indicates that all data for each vertex is contained in the same array. In my demonstration application, this data includes color, normal, and coordinate. The data array contains the color for vertex 0, normal for vertex 0, coordinate for vertex 0, then the color for vertex 1, normal for vertex 1, coordinate for vertex 1, and so forth. Colors require three floats (red, green, blue), normals require three floats (x, y, z), and coordinates require three floats (x, y, z). Thus, each vertex requires nine float data items in the array. Using interleaved data complicates coding, but speeds rendering. The by-reference parameter indicates that the Java 3D rendering routines and the application code share the data, saving space and time.

The ElevationSegment constructor's main task is to create the array of float data used as the basis for the TriangleStripArray. Class ElevationSegment is given the starting and stopping indexes into the elevations array, x and z resolutions, the y exaggeration amount, and ranges for the x and z coordinates. It then becomes a task of allocating the vertexData array to a proper size and filling in the values. My code completes this task with nested for loops, a strip at a time by a row at a time. Notice in the inner loop of the ElevationSegment constructor (shown below), each row requires the calculation of two vertices; also, at this time, only the color and coordinate information is filled in. Normal vectors are calculated later.

Once the vertexData array has generated, an InterleavedTriangleStripArray object can be created and attached to the Shape3D. I created InterleavedTriangleStripArray, a specialization of the Java 3D TriangleStripArray, because I wanted a reusable object that supported the in-place generation of normals for the vertices. Java 3D does provide a NormalsGenerator object capable of calculating for you. However, it does not use memory efficiently. In addition, since my terrain scene is divided into adjacent regions, it is desirable to have the normals set to the same values where the edges meet. This prevents visible seams from appearing along the joints. The algorithms used to calculate the normals and average them along the edges reaches beyond this article's scope, but the code is in Resources for the interested reader.

Back to creating an InterleavedTriangleStripArray: First, you create an array indicating the number of vertices in each strip, then you create the InterleavedTriangleStripArray object itself using the interleaved and by-reference flags. Other flags indicate that colors, normals, and coordinates are all included in the interleaved data. You then tell the InterleavedTriangleStripArray to calculate the normals.

ElevationSegment's constructor also calls a method to set up Shape3D's appearance, including material color, shading, and lighting properties. These properties must be set for the scene lighting to work. In my example, I selected the SHADE_GOURAUD color attribute. This attribute causes Java 3D to use smooth shading to vary the colors across the face of a triangle based on the color specified for each vertex. Optionally, the code could have used SHADE_FLAT, in which case each triangle would be given a single color for its entire face.

Related:
| 1 2 3 4 5 Page 2