The essentials of a cube-based world

In this recipe, we'll build a small framework to generate optimized cube meshes, which can be used to create large-scale worlds. This framework will consist of an AppState object to handle user actions, a class called CubeWorld that will store the terrain data, and a class called CubeCell that will store the data for individual cells. In addition, there is a CubeUtil class that will help us to generate meshes.

Getting ready

This is an advanced recipe that requires an understanding the generation of a basic terrain, which can be found earlier in the chapter, and the building blocks of meshes and how to create custom meshes.

Before we begin, we will create a class called CubeUtil and populate it with some shaped data that we will need later. Since each of the cells is of a box shape, we can borrow some fields from the Box and AbstractBox classes and save some time in setting it up. Just copy the GEOMETRY_INDICES_DATA, GEOMETRY_NORMALS_DATA, and GEOMETRY_TEXTURE_DATA fields to the CubeUtil class.

At the bottom of the class, there is a method called doUpdateGeometryVertices that contains a float array. Copy this float array too and call its vertices. This array contains data for the 24 vertices needed to create a cube with normal. It in turn relies on references to eight original vertex positions. We can get these from the AbstractBox class and the computeVertices method. The Vector3f center referenced here can be replaced with Vector3f.ZERO. The xExtent, yExtent , and zExtent parameters can be replaced with 0.5f to get a square box with 1f sides.

How to do it...

We start by creating the object that contains the cell data. This will have the following seven steps:

  1. First, create a new class called CubeCell.
  2. It contains a Mesh field call mesh, an array of six Booleans called neighbors, and another Boolean called refresh.
  3. In addition, there is enum called Type where we can put names such as Rock, Sand, and Grass. Then, add a Type field called type.
  4. Create a method called hasNeighbor that takes an integer parameter as an input and return the corresponding Boolean from the array.
  5. Then, add a method called setNeighbor that takes both an integer parameter called direction and a Boolean parameter called neighbor as the input. If the current Boolean at the position of the direction is not the same as that of the neighbor, store the neighbor at that location and set refresh to true.
  6. Add a method called requestRefresh that sets refresh to true.
  7. For a mesh, add a getMesh method, and inside this, call a method called CubeUtil.createMesh if the mesh is null or refresh it if it is true. This will also set refresh to false as follows:
    if(mesh == null || refresh){
      mesh = CubeUtil.createMesh(this);
      refresh = false;
    }
    return mesh;

Now, let's return to the CubeUtil class where we add some helper methods to generate the world. This section has the following steps:

  1. First, add a createMesh method that takes a CubeCell parameter as the input. This method will create a mesh for the cell, and here you'll use the data we set up in the Getting Ready section of this recipe.
  2. First of all, place the vertex data in the mesh with the following line of code:
    m.setBuffer(VertexBuffer.Type.Position, 3, BufferUtils.createFloatBuffer(vertices));
  3. Add indices to the sides of the mesh that are exposed and check the neighbors to see which ones these are. Then, add six indices (for two triangles) for each mesh to a list using GEOMETRY_INDICES_DATA, as follows:
    List<Integer> indices = new ArrayList<Integer>();
    for(intdir = 0; dir < 6; dir++){
      if(!cube.hasNeighbor(dir)){
        for(int j = 0; j < 6; j++){
          indices.add(GEOMETRY_INDICES_DATA[dir * 6 + j]);
        }
      }
    }
  4. To add these to the mesh, first convert them into an array. Then, set the array as the index buffer, as follows:
    m.setBuffer(VertexBuffer.Type.Index, 1, BufferUtils.createIntBuffer(indexArray));
  5. For texture coords and vertex normals, simply use the data we have already set up as follows:
    m.setBuffer(VertexBuffer.Type.TexCoord, 2, BufferUtils.createFloatBuffer(GEOMETRY_TEXTURE_DATA));
    m.setBuffer(VertexBuffer.Type.Normal, 3, GEOMETRY_NORMALS_DATA);
  6. Now, return the mesh to the calling method.
  7. Add one more method called generateBlock to the CubeUtil class and create a 3D array of CubeCell and return it. The principle for it is the same as the heightmap we created in the Using noise to generate a terrain recipe, except here we use three dimensions instead of two. The following code with generate a CubeCell class in a 3D pattern:
    CubeCell[][][] terrainBlock = new CubeCell[size][size][size];
    for(int y = 0; y < size; y++){
      for(int z = 0; z < size; z++){
        for(int x = 0; x < size; x++){
          double value = fractalSum.value(x, y, z);
          if(value >= 0.0f){
            terrainBlock[x][y][z] = new CubeCell();
          }
        }
      }
    }

We can now look at how to tie these two classes together and start generating some cubes. This will be performed in the following steps:

  1. We turn our attention to the CubeWorld class that will hold the information about all our cubes. It has a Node field called world, an integer file called batchSize, and array of Material called materials and, for this example, a single CubeCell[][][] array called terrainBlock.
  2. After initializing the worldNode class in the constructor, create a public method called generate. Inside this, call CubeUtil.generateBlock(4, batchSize) and store it in terrainBlock.
  3. Then, call and create another method called generateGeometry that will put all the CubeCell classes together into a Node class.
  4. First, check whether the worldNode class already has a node with a given name. If it does, detach it. In either case, create a new BatchNode field with the same name we checked for.
  5. Now, parse through the whole of the terrainBlock array and all the locations where there is a CubeCell class; we will check 6 directions (either side of it). For each side, check whether there is a neighbor there; there will be one if the position is not null. In that case, call setNeighbor on the cell you're checking for and supply the direction of the current as follows:
      for(int y = 0; y < batchSize; y++){
        repeat for x and z
        if(terrainBlock[x][y][z] != null){
          for(inti = 0; i < 6; i++){
            Vector3f coords = CubeUtil.directionToCoords(i);
            if(coords.y + y > -1 && coords.y + y < batchSize){
              repeat for x and z
              if(terrainBlock[(int)coords.x + x][(int)coords.y y][(int)coords.z + z] != null){terrainBlock[x][y][z].setNeighbor(i, true);
              } else {terrainBlock[x][y][z].setNeighbor(i, false);
              }
            }
          }
        }
      }
  6. The next step is to create geometries for the CubeCell instances. Do this by again parsing through the terrainBlock field, and where the corresponding CubeCell is not null, create a new Geometry class by calling the CubeCell'sgetMesh' method. Then, move it to the right position using x, y, and z that we're iterating over, and apply a material and attach it to the batch node as follows:
    Geometry g = new Geometry("Cube", terrainBlock[x][y][z].getMesh() );
    g.setLocalTranslation(x, y, z);
    g.setMaterial(materials[0]);
    node.attachChild(g);
  7. Finally, in the generateGeometry method, call node.updateModelBound() and node.batch() to optimize it before attaching it to worldNode.
  8. The basic of the generation process is now in place, and you can create a new class called CubeWorldAppState that extends AbstractAppState. In this case, add a CubeWorld field called cubeWorld.
  9. Override the initialize method and declare a new cubeWorld instance.
  10. Then, load a new material based on the Lighting material's definition and supply it to cubeWorld. After this, call cubeWorld and generate and attach worldNode through its getter method.
  11. Also, add a light to see anything since we're using the Lighting material.
  12. Now, create an application where we attach this Appstate instance and we should see our block of CubeCell in the world. It's static, however, and it's very common to want to change the world.

Let's see how we can add the functionality to pick up and place blocks. The following figure is of a resulting terrain block:

How to do it...
  1. Begin in CubeWorldAppState by implementing ActionListener to handle user input. Add a CubeCell field called takenCube to store a CubeCell field that has been picked up.
  2. Add mappings to inputManager to pick up and place a CubeCell field. Use the left and right mouse button as shown in the following lines of code:
    inputManager.addMapping("take", new MouseButtonTrigger(MouseInput.BUTTON_LEFT));
    inputManager.addMapping("put", new MouseButtonTrigger(MouseInput.BUTTON_RIGHT));
  3. Then, create a method called modifyTerrain that takes a Boolean called pickupCube as the input.
  4. To control what is picked up or aimed at, use a pattern that we have established in the Firing in FPS recipe of Chapter 2, Cameras and Game Controls. Use a ray that originates from the camera and moves toward the camera's direction.
  5. Now, collide it with the worldnode class of cubeWorld. If it collides with something and the distance is lower than two (or some other arbitrary number) and pickupCube is true, we will pick up a cube. Get the worldTranslation vector of the geometry that the ray has collided with. Then, call a method called changeTerrain in cubeWorld. We'll create the method in a short while. Now, supply it with the coordinates of the geometry it collides with and the currently empty takenCube field as follows:
    if(coll != null && coll.getDistance() < 2f && pickupCube){
      Vector3f geomCoords = coll.getGeometry().getWorldTranslation();
      takenCube = cubeWorld.changeTerrain(geomCoords, takenCube);
    }
  6. If instead, there is no collision or the collision is too far away, and at the same time pickupCube is false and takenCube is not null, try to place takenCube in the world. Since we don't have a collision point, move some way along the direction of the camera and round it off to the nearest integer. Then, call cubeWorld.changeTerrain again with the coordinates along with takenCube, as follows:
    Vector3f geomCoords = cam.getLocation().add(cam.getDirection().mult(2f));
    geomCoords.set(Math.round(geomCoords.x), Math.round(geomCoords.y), Math.round(geomCoords.z));
    takenCube = cubeWorld.changeTerrain(geomCoords, takenCube);
  7. In the onAction method, add the logic for the corresponding key press and call modifyTerrain, supplying either true if we're picking up or false if we're instead trying to place a CubeCell field.
  8. In the CubeWorld class, create this changeTerrain method that takes a Vector3f parameter called coords and a CubeCell parameter called blockToPlace as the input. The Coords parameters represent the location of a CubeCell instance. The changeTerrain method returns a CubeCell instance.
  9. The first thing we will do is define a CubeCell field called changedBlock where we store the incoming blockToPlace.
  10. Then, do a check to make sure the supplied coordinate is within the bounds of the terrainBlock array and then check whether changedBlock is null. If it is, pick up the CubeCell instance from this location and populate changedBlock with the CubeCell instance. Then, set the location's CubeCell to null as follows:
    if(changedBlock == null){
      changedBlock = terrainBlock[x][y][z];
      terrainBlock[x][y][z] = null;
    }
  11. If instead the CubeCell instance at this location is null (we already know that changedBlock is not null), set the CubeCell instance over here to changedBlock and changedBlock to null. Also, call requestRefresh on the CubeCell instance to force it to update the mesh, as follows:
    else if(terrainBlock[x][y][z] == null){
      terrainBlock[x][y][z] = changedBlock;
      terrainBlock[x][y][z].requestRefresh();
      changedBlock = null;
    }
  12. Finally, if there has been a change made, call generateGeometry and return changedBlock to the calling method.

How it works...

This recipe is mostly about creating meshes that are as optimized as possible. Cubes are great building blocks, but each has 12 triangles, and rendering them all for hundreds or thousands will quickly slow down most systems. In the first part of the recipe, we implemented functionalities to create meshes that only had the exposed sides of the cube's generated triangles. We found this out by checking which of the positions next to the cube were occupied by other cubes.

Once all the cubes were generated, we added them to BatchNode and batched it to create one mesh for all the cubes. Even if the polygon count is the same, decreasing the number of objects greatly enhances the performance.

Having a single mesh means we can't change a single object in the mesh without regenerating the whole batch. If we plan to scale this up and generate a whole world, we need to keep the size of the batch to a size where we can regenerate it without creating slowdowns. Exploring a way to generate it on a separate thread might be a good next step.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.191.176.194