Procedural Terrain Generator

Mattias Agentoft Eggen

  • Introduction
  • Initial ideas and goals
  • Perlin Noise
    • Mesh Generation
    • Amplitude
    • Octaves
    • Redistribution
  • Adding Colors
  • Adding Textures
    • Triplanar Mapping
    • Normal Mapping
    • Slope
  • Final Result
  • Future thoughts
  • References


Introduction

The technique of using noise to create procedurally generated terrain is widely used in the game industry. By generating pseudo random noise, one can create visually appealing terrain in a simple and effective way. Creating Perlin noise is simple in itself, but how can you combine it with other techniques to create an visually aesthetic landscape? In this article we will look into ways to combine Perlin noise with textures and other techniques to create realistic and customizable terrain.


Initial ideas and goals

Coming up with an idea for the R&D was more difficult than expected. When the project was introduced I wanted to delve into techniques concerning multiplayer. However, after some days of gathering resources and inspiration I found that it could become too difficult for me to enjoy. Therefore, I changed my subject to procedural content generation.
I have always been fond of the visual aspect of games, which is one of the reasons I wanted to create a terrain generator.


Perlin noise

Perlin noise

As mentioned, a common way to create pseudo random noise is by using a noise function such as Perlin noise. Perlin noise was developed by Ken Perlin in 1983 for Disney’s computer animated motion picture Tron. The algorithm has made a huge impact in the computer generated imagery industry, which is why Perlin was awarded an Academy award for developing the algorithm in 1997.
The technique works by storing float values between 0 and 1, typically in a two-dimensional array, where 0 corresponds to black, and 1 to white. This is what we will be using as our height map.

    for (int y = 0; y < mapHeight; y++)
    {
        for (int x = 0; x < mapWidth; x++)
        {
            float nx = x/mapWidth;
            float ny = y/mapHeight;
            height[x,y] = Mathf.PerlinNoise(nx, ny);
        }
    }

This is how we create a simple noise map in C#. All we need to do is create a two-dimensional array and populate it with Perlin noise values on each index. Next, we can assign these height values to our mesh.


Mesh generation

Our mesh will consist of a triangles with a customizable width and height. First off, we need an array to store our vertices. Now, we loop through the array given the width and height, and add the vertices.

    for (int  z = 0, i = 0; z <= mapHeight; z++)
        {
            for  (int  x = 0; x <= mapWidth; x++, i++)
            {
                float  y = height[x, z];
                vertices[i] = new Vector3 (x, y, z);
            }
        }

When we create our mesh, we have to make sure that it has the same dimension and contains the same amount of vertices as elements in our height map. When adding vertices to the two-dimensional array, we assign the y-value of each vertex with y = height[x,z]. Now our vertices will have height values corresponding with our noise map.

When the vertices are in place we can create the triangles in which our mesh will consist off. Triangles are defined through an array of vertex indexes. Each triangle consist of three vertices, and they are created by going clockwise through the vertices starting at the bottom right vertex in the quad.

Triangles are created by adding vertices in a clockwise manner
    int[] tris = new int[mapWidth * mapHeight * 6];
        for (int ti = 0, vi = 0, z = 0; z < mapHeight; z++, vi++)
        {
            for (int x = 0; x < mapWidth; x++, ti += 6, vi++)
            {
                tris[ti] = vi;
                tris[ti + 3] = tris[ti + 2] = vi + 1;
                tris[ti + 4] = tris[ti + 1] = vi + mapWidth + 1;
                tris[ti + 5] = vi + mapWidth + 2;
            }
        }



Amplitude

Looking back at our noise map, we see that it consist of values between 0 and 1, which will result in very small height variations. To control the amplitude of each vertex, we multiply the height value by a value amplitude: y = height[x,z] * amplitude.
At the same time, we can make the scale and offset of the terrain customizable by dividing the noise value for x and y by a dynamic value scale multiplied by offset.

    float nx = x/scale * offsetX;
    float ny = y/scale * offsetY;
    height[x,y] = Mathf.PerlinNoise(nx, ny);



Octaves

Our mesh has working elevation, but it is way too smooth to resemble real life terrain. In the real world terrain is a lot more rough and uneven. To mimic this we can add more layers of noise to our noise map. These layers are called octaves.

When creating an octave, we increase the frequency as well as decrease the amplitude.

    for (int i = 0; i < octaves; i++)
    {
        float freq = Mathf.Pow(2, i);
        amplitude += 1 / (Mathf.Pow(2, i));
        e += (1 / (Mathf.Pow(2, i))) * Mathf.PerlinNoise(freq * nx, freq * ny);
    }

For each octave, we multiply the noise value with 1/2i , as well as multiplying nx and ny inside the noise function with 2i . Consequently, the amplitude and frequency decreases and increases on each octave respectively. I.e., this is what it will look like if we use 4 octaves.

Octaves
1   * Mathf.PerlinNoise(1 * nx, 1 * ny) +
1/2 * Mathf.PerlinNoise(2 * nx, 2 * ny) +
1/4 * Mathf.PerlinNoise(4 * nx, 4 * ny) +
1/8 * Mathf.PerlinNoise(8 * nx, 8 * ny);

As we can see, the height value for the octaves in this case will be the total of the amplitudes: 1.875. To correct this, we divide the height value with the accumulated amplitude: e = e / amplitude;



Redistribution

Redistribution

We are starting to get a result that looks similar to real life terrain. However, it is a bit too noisy; we want some valleys and fields. To achieve this we can use a technique called redistribution.

By raising the height value e by an exponent, the lower values will decrease faster than the higher values of the exponent, until it reaches zero.

    height[x, y] = Mathf.Pow(e, exp);



Adding Colors

Color map

Now that we have something that resembles real life terrain, we can start to add colors. After researching, I ended up trying two different techniques to color the terrain. The first was inspired by Sebastian Lague’s way of creating a color map based on height values, which works by creating a custom texture based on the colors on each corresponding height.

I was not quite satisfied with the result, and wanted to make something more unique. Therefore, I started to look into ways to create a shader that could color the terrain instead. After some googling I stumbled across a thread discussing the subject, but with gradients on a sphere, not flat terrain. After some tweaking, I got it working either way. It works by first calculating the lowest and highest point on the terrain, and passing this into the shader. We find these values by checking if each vertex we create is lower or higher than the current lowest and highest vertex.

    if (y < minHeight)
    {
        minHeight = y;
    }
    else if (y > maxHeight)
    {
        maxHeight = y;
    }

In the shader it gets a bit more complicated. Creating a gradient with two colors is simple, but when we start to add three colors with a dynamic gradient we need to get creative. The ideal result is to have a gradient with three colors where we can have a dynamic middle value which decides where the interpolation happens. The result consist of few, but a bit complex lines of code. It works by first interpolating between the bottom and the middle color multiplied by the step between the height of the vertex and the middle value. Next, we add the interpolation from the middle and top color multiplied with the step between the middle value and the vertex’ height.

    float dist = (IN.worldPos.y - _MinHeight) / (_MaxHeight - _MinHeight);
    
    fixed4 c = lerp(_ColorBot, _ColorMid, dist / _Median) * step(dist, _Median);
    c += lerp(_ColorMid, _ColorTop, (dist - _Median) / (1 - _Median)) * step(_Median, dist);
Height gradient shader

The end result is interesting, however it is just not satisfying enough. The terrain does not look realistic in a sense. I decided to scrap this idea, and move on to research the possibilities of adding textures instead.









Adding Textures – Triplanar Mapping

Triplanar Mapping


After realizing colors would not give a satisfying result, I changed my approach and looked into ways I could map my terrain with textures. When dealing with terrain it is rarely a good idea to overlay a 2D texture on the mesh. Doing so will result in stretched out textures on steep slopes. A work around for this is to use Triplanar Mapping, a technique which renders the texture three times, and applies it on the X, Y and Z axes. It then blends the texture based on the angle in which they are mapped. There is no UV-mapping required for this technique, which is why it is also called “UV free texturing”.



Triplanar mapping gives a clear and detailed texture even on steep slopes. As we remember from the color map, it will still be necessary to add intervals of different textures, to account for different terrain types. A minimal requirement will be to implement three textures: sand, grass and snow, as well as adding water.

Three textures
    if (height < lowerLimit)
    {
        texture = sand;
    }
    else if (height < upperLimit)
    {
        texture = grass;
    }
    else
    {
        texture = snow;
    }
Pseudo code

As we can see from the image, the textures do not interpolate, which gives an unpleasing result. Creating interpolation between the textures is done by creating a blend size, which tells us which range we want two textures to interpolate between.

    float blendValue = (normalizedHeight - lowerLimit) / blendSize;

    if (height < lowerLimit)
    {
        texture = sand;
    }
    else if (height < lowerLimit + blendSize)
    {
        texture = lerp(sand, grass, blendValue);
    }
    ...
Pseudo code


Water is essential when creating terrain. After looking into water shaders, I found that an implementation of it would be too time consuming and difficult to get a pleasing result. As another solution, a simple transparent plane would have to suffice. This works well, since the topography beneath the water surface gives a good illusion of depth. Therefore, we can use the lowest texture as a sand-like texture, the middle texture as a grass texture, and the top texture as snow.


Normal Mapping

The textures are starting to look as expected, but they still look a bit too flat. As a consequence of this, I researched how to implement normal mapping for triplanar textures. Triplanar mapping does not use regular UV’s as a normal texture. Therefore, we cannot simply add the normal maps on top of the textures. A brilliant guide by Ben Golus goes into great depth on the subject, and discusses different techniques on which one can add normal mapping for a triplanar shader. The essential parts of normal mapping in my code is as follows.

    half2 uvX = IN.worldPos.zy / _TextureScale
    half2 uvY = IN.worldPos.xz / _TextureScale;
    half2 uvZ = IN.worldPos.xy / _TextureScale;

    half3 tnormalX = UnpackNormal(tex2D(_BumpMap, uvX));    
    half3 tnormalY = UnpackNormal(tex2D(_BumpMap, uvY));
    half3 tnormalZ = UnpackNormal(tex2D(_BumpMap, uvZ));

    tnormalX = blend_rnm(half3(IN.worldNormal.zy, absVertNormal.x), tnormalX);
    tnormalY = blend_rnm(half3(IN.worldNormal.xz, absVertNormal.y), tnormalY);
    tnormalZ = blend_rnm(half3(IN.worldNormal.xy, absVertNormal.z), tnormalZ);

    half3 worldNormal = normalize(
        tnormalX.zyx * triblend.x +
        tnormalY.xzy * triblend.y +
        tnormalZ.xyz * triblend.z
        );

First, we calculate the UV-sets using the world position of the fragment. Each two-dimensional space of the world position will give a planar map for the remaining axis. I.e., the ZY-world position of the fragment will give the projection on the X-axis.
From the UV-sets we can add the normal maps, and match them to the tangent space. Finally, we match the tangent normals to the world normal and blend everything together.

The code segment for checking which texture to map where is too long to fit in this article, so I have added a pseudo code instead. Keep in mind, each time we assign a texture, we assign three albedo and three normal textures. Here, we also see how the textures interpolate between each interval.

    if (height < lowerLimit)
    {
        texture = water;
        normal = normalWater;
    }
    else if (height < lowerLimit + blendSize)
    {
        texture = lerp(sand, grass, blendValue);
        normal = lerp(normalSand, normalGrass, blendValue);
    }
    else if (height < upperLimit)
    {
        texture = grass;
        normal = normalGrass;
    }
    else if (height < upperLimit + blendSize)
    {
        texture = lerp(grass, snow, blendValue);
        normal = lerp(normalGrass, normalSnow, blendValue);
    }
    else
    {
        texture = snow;
        normal = normalSnow;
    }
Pseudo code



Slope

This is starting to look pretty good. A final implementation is to map a rock-like texture where the slope is too steep for vegetation to grow. Finding the slope of a vertex is done by calculating the difference between 1 and the y-component of the vertex’ world position. Next, we check if the slope is steep enough, and if not, we add the height based textures as earlier.

Textures based on slope
    float slope = 1.0f - IN.worldNormal.y;

    if (slope > slopeLimit)
    {
        texture = stone;
    }
    else
    {
        if (height < lowerLimit)
        ...
    }

Now the stone texture is mapped where the slope is steep enough, but as we can see from the image, it does not interpolate between the other textures. It is not possible to use the same solution as when interpolate between the height mapped textures, since this texture has nothing to do with height. Coming up with a solution to blend between the stone texture and the other textures was quite tricky. There are few resources and examples online that does something similar in HLSL or other shader languages.

After finding which texture to use, we check if the slope is steep enough. Finally, we check if the slope is near the slope limit, and if so, we interpolate between the texture that is already there and the stone texture.

    if (slope > slopeLimit)
    {
        texture = stone;
    }
    
    if (slopeLimit - 0.1 < slope < slopeLimit)
    {
        texture = lerp(texture, stone, blendSize);
    }
Pseudo code



Final Result



Future thoughts

My initial goal was to make a terrain that should be both procedurally generated and customizable. There are some key functions that I was not able to implement, but the outcome is more aesthetically pleasing than I expected, which I am satisfied with. Looking forward, there are a few key ideas that I would implement.
Primarily, I would implement more diversity in the terrain, like rivers and other biomes. Right now the terrain consist of a single mesh, and a very exciting idea would be to add other objects, like rocks, roads and vegetation. I was looking into adding trees, but purposefully decided not to because it is quite hard to make it look good – which has been my main goal. A way to make it look even prettier is to add post processing, like bloom and other effects.
Another addition would be to increase the customizability of the map, by implementing a brush for manipulating the terrain.



References

Flick, J. (2016, January 30) Hex Map. Catlike Coding.

https://catlikecoding.com/unity/tutorials/hex-map

Flick, J. Procedural Grid. Catlike Coding.

https://catlikecoding.com/unity/tutorials/procedural-grid/

Golus, B. (2017, September 17) Normal Mapping for a Triplanar Shader. bgolus.medium.com.

https://bgolus.medium.com/normal-mapping-for-a-triplanar-shader-10bf39dca05a#ddd9

Lague, S. (2016, January 31) Procedural Landmass Generation. Youtube.

https://www.youtube.com/watch?v=wbpMiKiSKm8&list=PLFt_AvWsXl0eBW2EiBtl_sxmDtSgZBxB3&ab_channel=SebastianLague

Palko, M. (2014, March 20) Triplanar Mapping. Martin Palko.

https://www.martinpalko.com/triplanar-mapping/

Patel, A. (2020, May) Making maps with noise functions. Red Blob Games.

https://www.redblobgames.com/maps/terrain-from-noise/

Thirslund, A. (2017, May 24) GENERATING TERRAIN in Unity – Procedural Generation Tutorial. Brackeys.

https://www.youtube.com/watch?v=vFvwyu_ZKfU&ab_channel=Brackeys

Vivo, P.G. (2015) Noise. The Book of Shaders.

https://thebookofshaders.com/11/

Ken Perlin. Worldbuilding Institute.

https://worldbuilding.institute/people/ken-perlin

Git repository

https://github.com/mattiaseggen/game-lab

Related Posts