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.
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.
We start by creating the object that contains the cell data. This will have the following seven steps:
CubeCell
.Mesh
field call mesh
, an array of six Booleans called neighbors
, and another Boolean called refresh
.Type
where we can put names such as Rock
, Sand
, and Grass
. Then, add a Type
field called type
.hasNeighbor
that takes an integer parameter as an input and return the corresponding Boolean from the array.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
.requestRefresh
that sets refresh
to true
.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:
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.m.setBuffer(VertexBuffer.Type.Position, 3, BufferUtils.createFloatBuffer(vertices));
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]); } } }
m.setBuffer(VertexBuffer.Type.Index, 1, BufferUtils.createIntBuffer(indexArray));
m.setBuffer(VertexBuffer.Type.TexCoord, 2, BufferUtils.createFloatBuffer(GEOMETRY_TEXTURE_DATA)); m.setBuffer(VertexBuffer.Type.Normal, 3, GEOMETRY_NORMALS_DATA);
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:
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
.worldNode
class in the constructor, create a public method called generate
. Inside this, call CubeUtil.generateBlock(4, batchSize)
and store it in terrainBlock
.generateGeometry
that will put all the CubeCell
classes together into a Node
class.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.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); } } } } }
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);
generateGeometry
method, call node.updateModelBound()
and node.batch()
to optimize it before attaching it to worldNode
.CubeWorldAppState
that extends AbstractAppState
. In this case, add a CubeWorld
field called cubeWorld
.initialize
method and declare a new cubeWorld
instance.cubeWorld
. After this, call cubeWorld
and generate and attach worldNode
through its getter method.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:
CubeWorldAppState
by implementing ActionListener
to handle user input. Add a CubeCell
field called takenCube
to store a CubeCell
field that has been picked up.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));
modifyTerrain
that takes a Boolean called pickupCube
as the input.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); }
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);
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.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.CubeCell
field called changedBlock
where we store the incoming blockToPlace
.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; }
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; }
generateGeometry
and return changedBlock
to the calling method.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.
18.220.124.177