Stitching Procedural Terrain Chunks

When generating large or infinite terrain, it is impractical to render it all using one giant mesh. Most terrain systems use what’s called “chunking”: splitting the terrain up into chunks to split the generation over multiple threads or frames. If the player moves in a far enough in a certain direction, new terrain chunks will be spawned in front of the player and the furthest chunks will be deleted.

While writing my infinite terrain system, I ran into an annoying issue. I had everything else working as I wanted: the height of the mesh was being generated for each chunk, the chunks would move in front of the player and recalculate as the player moved. However, on the edge of each chunk, I noticed seams. I went back and confirmed that the height of the mesh was correct, so it had to be something else. It turned out to be the normals at the edges were being calculated differently per mesh!

Analysis

This is what the seams look like:

bad terrain

In my procedural terrain implementation, the normals for each mesh vertex are calculated using the surrounding vertices. Here’s a snippet of the code:

// recalculate normals
for (int i = 0; i < refIndices.size(); i += 3)
{
// vertices of a triangle in the mesh
Vertex v0 = refVertices[refIndices[i]];
Vertex v1 = refVertices[refIndices[i + 1]];
Vertex v2 = refVertices[refIndices[i + 2]];

// normal calculation is cross(B - A, C - A)
glm::vec3 normal = glm::normalize(glm::cross(v1.position - v0.position, v2.position - v0.position));

// add the normals to the indices of the triangle
refVertices[refIndices[i]].normal += normal;
refVertices[refIndices[i + 1]].normal += normal;
refVertices[refIndices[i + 2]].normal += normal;
}


A consequence of this method is that normals are limited to the vertices of the containing mesh. When two meshes are placed next to each other, the heights of the vertices may be the same, but the normals come from different sources. Thus, when two edges are placed side by side, the only way to get seamless results is for them to share normals.

The objective has been set: implement a method that allows terrain chunks to share vertices. Now, on to the implementation.

Implementation

The implementation I chose is a simple one. The chunks in my terrain are square grids. Since the issue occurs on the edge of the grid, my solution was to create a grid that extended one row and column in each direction. This creates an overlap since the extended grids are larger than the terrain chunks, and thus the chunks effectively “share” vertices. The actual mesh would use a subset of this grid to generate the terrain.

Here is an illustration of what I’m talking about:

terrain overlap

And here is some relevant code:

// the reference vertices are for calculating normals on the edge of the terrain.
// the reference grid is extended out one row/column in each direction
int refGridSize = gridSize + 2;
float refTerrainSize = FACE_SIZE * refGridSize;
glm::vec3 refOffset = glm::vec3(-refTerrainSize / 2, 0.0f, -refTerrainSize / 2);
for (int i = 0; i <= refGridSize; i++)
{
float z = FACE_SIZE * i;

for (int j = 0; j <= refGridSize; j++)
{
float x = FACE_SIZE * j;

// create vertex, only need position data for now
// height will be calculated later
Vertex v;
v.position = glm::vec3(x, 0.0f, z) + refOffset;
refVertices.push_back(v);

// if triangulation is possible, build a plane out of 2 triangles
if (i > 0 && j > 0)
{
int baseIndex = refVertices.size() - 1;

GLuint index0 = baseIndex;
GLuint index1 = baseIndex - 1;
GLuint index2 = baseIndex - refGridSize - 1;
GLuint index3 = baseIndex - refGridSize - 2;

// triangle 1
refIndices.push_back(index0);
refIndices.push_back(index2);
refIndices.push_back(index1);

// triangle 2
refIndices.push_back(index2);
refIndices.push_back(index3);
refIndices.push_back(index1);
}
}
}


This is the exact same code for generating a normal grid, with one change: I add 2 to the gridSize because each axis is extended by one row or column on each side, and there are two sides per axis (left/right for x axis, top/bottom for y axis).

Now that the mesh chunks have overlapping grids, they share normals and the problem is resolved.

This is what the seamless terrain looks like:

good terrain

Conclusion

Overall, I was happy that I found a solution to my problem, but I know that it can be improved greatly. Due to time constraints and more pressing issues, I moved on after my initial solution.

The biggest red flag is that some of the mesh data is duplicated across multiple chunks. This could be solved by caching the data in arrays that are generated when new chunks are needed. I could also look for a different method for calculating the normals that did not rely on the mesh data.

Thanks for reading!