Terrain Collisions
Once we have created a terrain, the next step is to detect collisions to avoid traversing through it. If you recall from previous chapter, a terrain is composed by blocks, and each of those blocks is constructed from a height map. The height map is used to set the height of the vertices of the triangles that form the terrain.
In order to detect a collision we must compare current position
$y$
value with the
$y$
value of the point of the terrain we are currently in. If we are above terrain’s
$y$
value there’s no collision, otherwise we need to get back. It's a simple concept, isn't it? Indeed it is, but we need to perform several calculations before we are able to do that comparison.
The first thing we need to define is what we understand for the term "current position". Since we do not have yet a player concept the answer is easy, the current position will be the camera position. So we already have one of the components of the comparison, thus, the next thing to calculate is terrain height at the current position.
As it's been said before, the terrain is composed by a grid of terrain blocks as shown in the next figure.
Each terrain block is constructed from the same height map mesh, but is scaled and displaced precisely to form a terrain grid that looks like a continuous landscape.
So first we need to determine which terrain block the camera is in. In order to do that, we will calculate the bounding box of each terrain block taking into consideration the displacement and the scaling. Since the terrain will not be displaced or scaled at runtime, we can perform those calculations in the Terrain class constructor. By doing so we can access them later at any time without repeating those operations in each game loop cycle.
We will create a new method that calculates the bounding box of a terrain block, named getBoundingBox.
1
private Box2D getBoundingBox(GameItem terrainBlock) {
2
float scale = terrainBlock.getScale();
3
Vector3f position = terrainBlock.getPosition();
4
5
float topLeftX = HeightMapMesh.STARTX * scale + position.x;
6
float topLeftZ = HeightMapMesh.STARTZ * scale + position.z;
7
float width = Math.abs(HeightMapMesh.STARTX * 2) * scale;
8
float height = Math.abs(HeightMapMesh.STARTZ * 2) * scale;
9
Box2D boundingBox = new Box2D(topLeftX, topLeftZ, width, height);
10
return boundingBox;
11
}
Copied!
The Box2D class is a simplified version of the java.awt.Rectangle2D.Float class which we created to avoid using AWT.
Now we need to calculate the world coordinates of the terrain blocks. In the previous chapter you saw that all of our terrain meshes were created inside a quad with its origin set to [STARTX, STARTZ]. Thus, we need to transform those coordinates to the world coordinates taking into consideration the scale and the displacement as shown in the next figure.
Model to world coordinates
As it’s been said above, this can be done in the Terrain class constructor since it won't change at run time. So we need to add a new attribute which will hold the bounding boxes:
1
private final Box2D[][] boundingBoxes;
Copied!
In the Terrain constructor, while we are creating the terrain blocks, we just need to invoke the method that calculates the bounding box.
1
public Terrain(int terrainSize, float scale, float minY, float maxY, String heightMapFile, String textureFile, int textInc) throws Exception {
2
this.terrainSize = terrainSize;
3
gameItems = new GameItem[terrainSize * terrainSize];
4
5
ByteBuffer buf = null;
6
int width;
7
int height;
8
try (MemoryStack stack = MemoryStack.stackPush()) {
9
IntBuffer w = stack.mallocInt(1);
10
IntBuffer h = stack.mallocInt(1);
11
IntBuffer channels = stack.mallocInt(1);
12
13
URL url = Texture.class.getResource(heightMapFile);
14
File file = Paths.get(url.toURI()).toFile();
15
String filePath = file.getAbsolutePath();
16
buf = stbi_load(filePath, w, h, channels, 4);
17
if (buf == null) {
18
throw new Exception("Image file [" + filePath + "] not loaded: " + stbi_failure_reason());
19
}
20
21
width = w.get();
22
height = h.get();
23
}
24
25
// The number of vertices per column and row
26
verticesPerCol = width - 1;
27
verticesPerRow = height - 1;
28
29
heightMapMesh = new HeightMapMesh(minY, maxY, buf, width, height, textureFile, textInc);
30
boundingBoxes = new Box2D[terrainSize][terrainSize];
31
for (int row = 0; row < terrainSize; row++) {
32
for (int col = 0; col < terrainSize; col++) {
33
float xDisplacement = (col - ((float) terrainSize - 1) / (float) 2) * scale * HeightMapMesh.getXLength();
34
float zDisplacement = (row - ((float) terrainSize - 1) / (float) 2) * scale * HeightMapMesh.getZLength();
35
36
GameItem terrainBlock = new GameItem(heightMapMesh.getMesh());
37
terrainBlock.setScale(scale);
38
terrainBlock.setPosition(xDisplacement, 0, zDisplacement);
39
gameItems[row * terrainSize + col] = terrainBlock;
40
41
boundingBoxes[row][col] = getBoundingBox(terrainBlock);
42
}
43
}
44
45
stbi_image_free(buf);
46
}
Copied!
So, with all the bounding boxes pre-calculated, we are ready to create a new method that will return the height of the terrain taking as a parameter the current position. This method will be named getHeightVector and is defined like this.
1
public float getHeight(Vector3f position) {
2
float result = Float.MIN_VALUE;
3
// For each terrain block we get the bounding box, translate it to view coordinates
4
// and check if the position is contained in that bounding box
5
Box2D boundingBox = null;
6
boolean found = false;
7
GameItem terrainBlock = null;
8
for (int row = 0; row < terrainSize && !found; row++) {
9
for (int col = 0; col < terrainSize && !found; col++) {
10
terrainBlock = gameItems[row * terrainSize + col];
11
boundingBox = boundingBoxes[row][col];
12
found = boundingBox.contains(position.x, position.z);
13
}
14
}
15
16
// If we have found a terrain block that contains the position we need
17
// to calculate the height of the terrain on that position
18
if (found) {
19
Vector3f[] triangle = getTriangle(position, boundingBox, terrainBlock);
20
result = interpolateHeight(triangle[0], triangle[1], triangle[2], position.x, position.z);
21
}
22
23
return result;
24
}
Copied!
The first thing that to we do in that method is to determine the terrain block that we are in. Since we already have the bounding box for each terrain block, the algorithm is simple. We just simply need to iterate over the array of bounding boxes and check if the current position is inside (the class Box2D provides a method for this).
Once we have found the terrain block, we need to calculate the triangle we are in. This is done in the getTriangle method that will be described later on. After that, we have the coordinates of the triangle that we are in, including its height. But we need the height of a point that is not located at any of those vertices but in a place in between. This is done in the
$interpolateHeight$
method. We will also explain how this is done later on.
Let’s first start with the process of determining the triangle that we are in. The quad that forms a terrain block can be seen as a grid in which each cell is formed by two triangles Let’s define some variables first:
• $boundingBox.x$
is the
$x$
coordinate of the origin of the bounding box associated to the quad.
• $boundingBox.y$
is the
$z$
coordinates of the origin of the bounding box associated to the quad (Although you see a “
$y$
”, it models the
$z$
axis).
• $boundingBox.width$
is the width of the quad.
• $boundingBox.height$
is the height of the quad.
• $cellWidth$
is the width of a cell.
• $cellHeight$
is the height of a cell.
All of the variables defined above are expressed in world coordinates. To calculate the width of a cell we just need to divide the bounding box width by the number of vertices per column:
$cellWidth = \frac{boundingBox.width}{verticesPerCol}$
And the variable cellHeight is calculated analogously:
$cellHeight = \frac{boundingBox.height}{verticesPerRow}$
Once we have those variables we can calculate the row and the column of the cell we are currently in, which is quite straightforward:
$col = \frac{position.x - boundingBox.x}{boundingBox.width}$
$row = \frac{position.z - boundingBox.y}{boundingBox.height}$
The following picture shows all the variables described above for a sample terrain block.
Terrain block variables
With all that information we are able to calculate the positions of the vertices of the triangles contained in the cell. How we can do this? Let’s examine the triangles that form a single cell.
Cell
You can see that the cell is divided by a diagonal that separates the two triangles. To determine which is the triangle associated to the current position, we check if the
$z$
coordinate is above or below that diagonal. In our case, if current position
$z$
value is less than the
$z$
value of the diagonal setting the
$x$
value to the
$x$
value of current position we are in T1. If it's greater than that we are in T2.
We can determine that by calculating the line equation that matches the diagonal.
If you remember your school math classes, the equation of a line that passes from two points (in 2D) is:
$y-y1=m\cdot(x-x1),$
where m is the line slope, that is, how much the height changes when moving through the
$x$
axis. Note that, in our case, the
$y$
coordinates are the
$z$
ones. Also note that we are using 2D coordinates because we are not calculating heights here. We just want to select the proper triangle and to do that
$x$
an
$z$
coordinates are enough. So, in our case the line equation should be rewritten like this.
$z-z1=m\cdot(x-x1)$
The slope can be calculated in the following way:
$m=\frac{z1-z2}{x1-x2}$
So the equation of the diagonal to get the
$z$
value given a
$x$
position is like this:
$z=m\cdot(xpos-x1)+z1=\frac{z1-z2}{x1-x2}\cdot(xpos-x1)+z1$
Where
$x1$
,
$x2$
,
$z1$
and
$z2$
are the
$x$
and
$z$
coordinates of the vertices
$V1$
and
$V2$
, respectively.
So the method to get the triangle that the current position is in, named getTriangle, applying all the calculations described above can be implemented like this:
1
protected Vector3f[] getTriangle(Vector3f position, Box2D boundingBox, GameItem terrainBlock) {
2
// Get the column and row of the heightmap associated to the current position
3
float cellWidth = boundingBox.width / (float) verticesPerCol;
4
float cellHeight = boundingBox.height / (float) verticesPerRow;
5
int col = (int) ((position.x - boundingBox.x) / cellWidth);
6
int row = (int) ((position.z - boundingBox.y) / cellHeight);
7
8
Vector3f[] triangle = new Vector3f[3];
9
triangle[1] = new Vector3f(
10
boundingBox.x + col * cellWidth,
11
getWorldHeight(row + 1, col, terrainBlock),
12
boundingBox.y + (row + 1) * cellHeight);
13
triangle[2] = new Vector3f(
14
boundingBox.x + (col + 1) * cellWidth,
15
getWorldHeight(row, col + 1, terrainBlock),
16
boundingBox.y + row * cellHeight);
17
if (position.z < getDiagonalZCoord(triangle[1].x, triangle[1].z, triangle[2].x, triangle[2].z, position.x)) {
18
triangle[0] = new Vector3f(
19
boundingBox.x + col * cellWidth,
20
getWorldHeight(row, col, terrainBlock),
21
boundingBox.y + row * cellHeight);
22
} else {
23
triangle[0] = new Vector3f(
24
boundingBox.x + (col + 1) * cellWidth,
25
getWorldHeight(row + 2, col + 1, terrainBlock),
26
boundingBox.y + (row + 1) * cellHeight);
27
}
28
29
return triangle;
30
}
31
32
protected float getDiagonalZCoord(float x1, float z1, float x2, float z2, float x) {
33
float z = ((z1 - z2) / (x1 - x2)) * (x - x1) + z1;
34
return z;
35
}
36
37
protected float getWorldHeight(int row, int col, GameItem gameItem) {
38
float y = heightMapMesh.getHeight(row, col);
39
return y * gameItem.getScale() + gameItem.getPosition().y;
40
}
Copied!
You can see that we have two additional methods. The first one, named getDiagonalZCoord, calculates the
$z$
coordinate of the diagonal given a
$x$
position and two vertices. The other one, named getWorldHeight, is used to retrieve the height of the triangle vertices, the
$y$
coordinate. When the terrain mesh is constructed the height of each vertex is pre-calculated and stored, we only need to translate it to world coordinates.
At this point we have the triangle coordinates that the current position is in. Finally, we are ready to calculate terrain height at the current position. How can we do this? Well, our triangle is contained in a plane, and a plane can be defined by three points, in this case, the three vertices that define a triangle.
The plane equation is as follows:
$a\cdot x+b\cdot y+c\cdot z+d=0$
The values of the constants of the previous equation are:
$a=(B_{y}-A_{y}) \cdot (C_{z} - A_{z}) - (C_{y} - A_{y}) \cdot (B_{z}-A_{z})$
$b=(B_{z}-A_{z}) \cdot (C_{x} - A_{x}) - (C_{z} - A_{z}) \cdot (B_{z}-A_{z})$
$c=(B_{x}-A_{x}) \cdot (C_{y} - A_{y}) - (C_{x} - A_{x}) \cdot (B_{y}-A_{y})$
Where
$A$
,
$B$
and
$C$
are the three vertices needed to define the plane.
Then, with previous equations and the values of the
$x$
and
$z$
coordinates for the current position we are able to calculate the y value, that is the height of the terrain at the current position:
$y = (-d - a \cdot x - c \cdot z) / b$
The method that performs the previous calculations is the following:
1
protected float interpolateHeight(Vector3f pA, Vector3f pB, Vector3f pC, float x, float z) {
2
// Plane equation ax+by+cz+d=0
3
float a = (pB.y - pA.y) * (pC.z - pA.z) - (pC.y - pA.y) * (pB.z - pA.z);
4
float b = (pB.z - pA.z) * (pC.x - pA.x) - (pC.z - pA.z) * (pB.x - pA.x);
5
float c = (pB.x - pA.x) * (pC.y - pA.y) - (pC.x - pA.x) * (pB.y - pA.y);
6
float d = -(a * pA.x + b * pA.y + c * pA.z);
7
// y = (-d -ax -cz) / b
8
float y = (-d - a * x - c * z) / b;
9
return y;
10
}
Copied!
And that’s all! We are now able to detect the collisions, so in the DummyGame class we can change the following lines when we update the camera position:
1
// Update camera position
2
Vector3f prevPos = new Vector3f(camera.getPosition());
3
camera.movePosition(cameraInc.x * CAMERA_POS_STEP, cameraInc.y * CAMERA_POS_STEP, cameraInc.z * CAMERA_POS_STEP);
4
// Check if there has been a collision. If true, set the y position to
5
// the maximum height
6
float height = terrain.getHeight(camera.getPosition());
7
if ( camera.getPosition().y <= height ) {
8
camera.setPosition(prevPos.x, prevPos.y, prevPos.z);
9
}
Copied!
As you can see, the concept of detecting terrain collisions is easy to understand, but we need to carefully perform a set of calculations and be aware of the different coordinate systems we are dealing with.
Besides that, although the algorithm presented here is valid in most of the cases, there are still situations that need to be handled carefully. One effect that you may observe is the one called tunnelling. Imagine the following situation: we are travelling at a fast speed through our terrain and because of that, the position increment gets a high value. This value can get so high that, since we are detecting collisions with the final position, we may have skipped obstacles that lay in between.
Tunnelling
There are many solutions to the tunnelling effect, the simplest one being to split the calculation to be performed in smaller increments.