Codementor Events

Terrain in Unity: Generating & Smoothing 2D Side-scroller

Published Nov 10, 2018Last updated May 08, 2019
Terrain in Unity: Generating & Smoothing 2D Side-scroller

Welcome to this series of tutorials. In each, I'll share a bite-sized building block which you can use to as a step in learning to make your own games. Each tutorial will present a fundamental concept used every day in the games industry, boiled down to its basics, and a sample uses. In some cases we'll scale this idea up through multiple tutorials.

About me: I'm known as Arcane Engineer over on gamedev.stackexchange.com. I'm a freelancing games developer in Cape Town who has also spent several years freelancing on various games and interactive projects in London and Amsterdam.

Building Blocks: Array of random numbers & neighbourhood smoothing

The great thing about a list or array is that it's a 1-dimensional collection of values. This makes it extremely easy to work with, even for the novice coder. Values herein could represent anything.

Smoothing across an array is simple, too: it consists of adjusting some value to be a little more like its nearest neighbours in the array, by using the average of these. This is also known as filtering.

A quick rundown of 2D heightmap terrain, by example

The array we set up below will represent the shape of the terrain in a 2D side-view game, like the classic Lunar Lander, running from left to right across the screen:

2462-2-lunar-lander.jpg

The terrain is a 1D list, you say? How so? Isn't that a 2D terrain we're seeing? Well, the array is a 1D heightmap, i.e. each value in the array represents the height at a consecutive point:

2462-2-lunar-lander -annotated.png

So the 1D heightmap array used to create that 2D terrain would look like:

4,0,0,5,4,7,12,8,1,1,5,2,2,9,6

These being y (height) values. Note that a 1D heightmap creates a 2D terrain; a 2D heightmap creates a 3D terrain, and so on. The reason for this is that the position of each entry in the array, acts as a secondary dimension; for the above, the position being 0,1,2,3,4 etc. would act as x values.

Setting up our class and data container

How do we build an array of heightmap values in Unity? First, let's get the basic project structure set up: Create a new project in Unity called Side Terrain, then a new scene called Tutorial 1, and then create a new GameObject in its scene Hierarchy.

Create a new C# script called SideTerrain, add it to that GameObject, replace that C#' script's contents with the following, and save the file:

using System;
using UnityEngine;

public class SideTerrain : MonoBehaviour
{
    public const int LENGTH = 10;

    int[] heightmap = new int[LENGTH];
}

First up, we have a couple of packages we need to import with the using statement, so that we can get various things working. (If interested in why, remove one or both using statements after completing this tutorial, to see the errors Unity throws up. Then you'll know why we need them.)

Next, we have our class SideTerrain. This is a script that we will drop on an object in the Unity editor (the Main Camera in the Hierarchy view will do), thereby getting the script to run when we press Play. We have the name of the class, SideTerrain which we have chosen, and the class it extends, MonoBehaviour, which Unity requires us to use, for the script to be runnable.

Then we have a constant (unchanging) value LENGTH, that defines how many heightmap values represent our terrain. This can be whatever you want, but it should be a positive, non-zero number, and probably not more than in the hundreds or thousands unless you want your system to crash.

Before the class ends, we have an array of values called heightmap. This our essential data. An array is a series of data stored contiguously in memory (i.e. back to back as a single, compact collection - think of your DVDs stacked on a shelf). In this case, what is stored back to back are integers (whole numbers) indicating the height at each point along our terrain, from left to right. Here we can see that the LENGTH we've specified is used to construct the new array with the given length. What is the = here? Well, if we only had:

int[] heightmap;

...then that would mean we had a reference (handle) to some integer array (int[]), but no actual array to reference! So, that's like holding a broken-off coffee mug handle with no mug attached to it: pretty pointless, right? Instead, here we've created the mug that's going to contain all your values, attached the handle heightmap, and given you that handle.

But... the mug doesn't hold anything useful, yet. So how do we work with the heightmap array to put values into it? For that, we'll need a function (actually called a method, as it resides within a class).

Setting up a function to create and display data

Unity has a method called Start() that you can set up within any MonoBehaviour, that gets run the moment you hit Play in the Unity Editor. Let's set that up.

using System;
using UnityEngine;

public class SideTerrain : MonoBehaviour
{
    public const int LENGTH = 10;

    int[] heightmap = new int[LENGTH];

    void Start()
    {
        for (int i = 0; i < LENGTH; i++)
            heightmap[i] = i;
    }
}

Now we have a function definition for Start(), but what's inside the curly-brace function body?

Looks like a for loop. We're going to step through from 0 to LENGTH, so 10 steps, and i will increase at each step within that range, from 0 to 9. Then, as we step, we set up a heightmap value for the current position [i] in our heightmap array.

If you change Start() to look like this:

    void Start()
    {
        for (int i = 0; i < LENGTH; i++)
        {
            heightmap[i] = i;
            Debug.Log("heightmap["+i+"]: "+heightmap[i]);
        }
    }

Then you'll see the following results in Unity's debug console:

heightmap[0]: 0
heightmap[1]: 1
heightmap[2]: 2
...
heightmap[9]: 9

Debug.Log() is a Unity function called inside the for loop, which will write out to Unity's console, thereby telling us what each value in heightmap looks like, after we've set it.

Those values are predictable, since the value matches the position; so let's change Start() to:

    void Start()
    {
        for (int i = 0; i < LENGTH; i++)
        {
            heightmap[i] = Random.Range(1, LENGTH);
            Debug.Log("heightmap["+i+"]: "+heightmap[i]);
        }
    }

We should see more interesting values now, for example (though Random.Range() will likely give you different values from mine):

heightmap[0]: 6
heightmap[1]: 4
heightmap[2]: 1
...

So, when we call Random.Range(), we're saying we want some random value between 1 (inclusive) and LENGTH (non-inclusive). Since we have a LENGTH of 10, that means we could see values anywhere from 1 to 9 in our heightmap array. Each time you hit Play, you'll get different values.

Great, we have some numbers spitting out into the console. Not much of a game yet, is it? Sure, but it also took less than 5 minutes to get this far. Still, how is this related to our proposed heightmap terrain seen in Lander? Let's use console text for a moment more, just to see how these values might represent terrain. Replace your older Debug.Log() line with this:

            Debug.Log(new String('#', heightmap[i]));

The new String('#', heightmap[i]) creates a new string whose value is '#' repeated as many times as the value of heightmap[i], which is the random value we just generated.

And you should see in your console something like:

tut0_heightmap_console.png

Which, if you tilt your head or your screen sideways, and ignore the Unity debug text, looks a lot like undulating terrain:

tut0_heightmap_console - Copy.png

Aha! A little dash of imagination and you can see mountains and valleys.

Now we can use our data to display some graphics instead of console text.

Graphics: Grid of Cubes

"Show me the money!", I hear echoing from the peanut gallery.

Let's add a line near the top of our class, below our heightmap, explained shortly:

int[] heightmap = new int[LENGTH]; //existing
GameObject[] heightGOs = new GameObject[LENGTH]; //add me!

Now, in order to see graphics displaying in Unity's Game and Scene views, we'll set up some code to display each column as a box, rather than as a collection of # symbols in line (column) of the console, which as you'll recall, we achieved with this line:

Debug.Log(new String('#', heightmap[i]));

You can remove that line, and replace it with the following:

      GameObject gameObject = GameObject.CreatePrimitive(PrimitiveType.Cube);
      heightGOs[i] = gameObject;
            
      TransformGO(i);

What do these lines do?

  1. creates a new GameObject in the root of our scene, with a cube geometry (mesh) attached to it, so that the GameObject looks like a cube. We keep a reference / handle to this in the local variable gameObject, so that we can manipulate it next.

  2. then puts this object into the new array we just created, heightGOs (short for "height gameObjects"), which we'll access in the function written below.

  3. calls a new function, TransformGO(), to appropriately place and size each newly created box. Let us define this new function, which can be placed below Start():

    void TransformGO(int i)
    {
        GameObject gameObject = heightGOs[i];
        gameObject.transform.localScale = new Vector3(1, heightmap[i] ,1);
        gameObject.transform.localPosition = new Vector3(i, heightmap[i] / 2f, 0);
    }

This function retrieves and then sizes and positions the box we had just created in Start():

  1. TransformGO(int i) accepts the index i of the heightmap column we're currently working on, as it's only parameter, to be used below.
  2. stores the height at this index i for use in the next few lines.
  3. retrieves and stores the box/column gameObject we just created in Start, from our new array heightGOs at its index i.
  4. adjusts this box's size to suit the height value we generated.
  5. adjusts this box's x position, i.e. how far along from left to right we are (otherwise they will all overlap at the same x position of 0), by i; and adjusts the y position of up by half its height, because Unity cubes are ordinarily positioned around their centre, and we have just scaled ours to its given height (try replacing the term height / 2f with 0, and hit Play to see what happens if we do not take this precaution).

We can optionally add the following line at the very end of TransformGO():

    gameObject.name = "column "+i+" (height="+heightmap[i]+")";

...which shows the name and characteristics of this box gameObject, allowing us, once we hit Play, to examine in Unity's Hierarchy view each box / column individually, which is informative.

We are also going to add a line right at the end of Start(), outside our for loop, to ensure that our terrain appears centred within the camera's viewport (otherwise you will have to switch from Game to Scene view each time to see what is going on):

          TransformGO(i);
      }
      
      Camera.main.transform.position = new Vector3(LENGTH / 2, LENGTH / 2, -LENGTH);
   }

On hitting Play, you should see something like this in the Game view:

tut0_cubes_10.png

Remember that a new Vector3 always has its arguments in the order (x, y, z). We don't care about z here, but try replacing the x and y values in gameObject.transform.localPosition = new Vector3(i, height / 2f, 0); as follows: (0, height / 2f, 0), (i, height, 0), (i, 0, 0), to see how these affect layout of your columns. You should begin to see why we chose (i, height / 2f, 0) in the first place! Don't forget to revert back to this latter, once you're done playing.

You should also look at the various objects that have been created in Unity's Hierarchy view - these are the columns our code is creating, numbered from column 0 (leftmost), onward to the right.

On changing the value of LENGTH to 100, you should see something similar to the following:

tut0_cubes_100.png

And our class now looks as follows:

public class SideTerrain : MonoBehaviour
{
    public const int LENGTH = 100;
    public const int SCAN_RADIUS = 2;

    int[] heightmap = new int[LENGTH];
    GameObject[] heightGOs = new GameObject[LENGTH];

    void Start()
    {
        for (int i = 0; i < LENGTH; i++)
        {
            heightmap[i] = UnityEngine.Random.Range(1, LENGTH);
      
            GameObject gameObject = GameObject.CreatePrimitive(PrimitiveType.Cube);
            heightGOs[i] = gameObject;
      
            TransformGO(i);
        }
    
        Camera.main.transform.position = new Vector3(LENGTH / 2, LENGTH / 2, -LENGTH);
    }
  
    void TransformGO(int i)
    {
        GameObject gameObject = heightGOs[i];
        gameObject.transform.localScale = new Vector3(1, heightmap[i] ,1);
        gameObject.transform.localPosition = new Vector3(i, heightmap[i] / 2f, 0);
        gameObject.name = "column "+i+" (height="+heightmap[i]+")";
    }
}

Removing jaggediness

Here's the thing about purely random data - it's not very smooth when generated and viewed without amendment. You might have noticed that we get some awfully jagged "mountain ranges" even when part of the terrain is smooth.

To avoid this, we're going to create a new Smooth() method to smooth values across the data array (heightmap) so that it looks more like a natural landscape. First, let's look at how we'll trigger our proposed Smooth()ing: whenever we press the spacebar, we're going to smooth our terrain once (more), interactively.

Like Start(), we'll need another of MonoBehaviour's special methods: Update(). Add this function after Start() and before TransformGO():

    void Update ()
    {
        if (Input.GetKeyDown(KeyCode.Space))
            Smooth();
    }

Here we are saying that several times every second, we want to:

  1. Check state of the keyboard, and if we've pressed Space:

  2. Smooth our array of data, heightmap, and visible boxes/columns heightGOs to reflect same.

Now let's partially define our Smooth() method, by adding it below TransformGO():

void Smooth()
    {
        for (int i = 0; i < heightmap.Length; i++)
        {
            int height = heightmap[i];
            
            int heightSum = 0;
            int heightCount = 0;

            int heightAverage = heightSum / heightCount;
            heightmap[i] = heightAverage;
        }

    }

Here we are stepping over each of the heightmap columns we randomised earlier (current), then stepping over that current's neighbour columns (take note!), and trying to get current to fit in with its neighbours to either side by becoming the average height between them.

  1. The outer for loop walks through the entire heightmap array.
  2. We grab the height at the current column's index, [i].
  3. We prepare a heightSum (currently 0) that we will use for averaging.
  4. We prepare a heightCount (currently 0) that we will use for averaging.
  5. Calculate the average by division of the above two terms.
  6. We then replace heightmap[i]'s value (see step 2) with this average.

Of course, the problem is we are not yet setting either heightSum or heightCount to anything other than 0, so the final step is useless. Let's remedy that by adding this between int heightCount = 0; and heightmap[i] = heightSum / heightCount;:

            for (int n = i - SCAN_RADIUS;
                     n < i + SCAN_RADIUS + 1;
                     n++)
            {
                int heightOfNeighbour = heightmap[n];

                heightSum += heightOfNeighbour;
                heightCount++;
            }

  1. Here we set up an inner loop within the outer one, by which we walk the neighbourhood all around the current column i (as gotten in the outer loop), which then gives us n (as per the inner loop), whereby we interview each neighbour heightmap[n] within SCAN_RADIUS columns of heightmap[i].
  2. We sum these neighbour's heights (heightSum).
  3. We also keep count of how many neighbours we've interviewed (heightCount).
  4. With these, we can now get the average which we lacked in our initial Smooth().

OK, let's now use our pic of Lunar Lander, to run through how Smooth() works:

2462-2-lunar-lander -annotated.png

  1. We step first of all over the full length of heightmap, using index i. We'll call heightmap[i] the current column. Let's assume i now equals 6; with the value here being 12 (remember that array indices start counting at 0).

  2. Then our inner for loop walks over the neighbours of current, including current itself, in order to calculate an average. We have a SCAN_RADIUS of 2, so we can expect to check current, plus two either side - a total of five columns. This goes as follows: around current, we have values 4, 7 , 12 (current), 8, 1. We gradually sum these up: first heightSum is 0 (before the first loop runs), then 4, then 11, then 23, then 31, then at last 32. It also counts how many (heightCount) it has walked over, which in this case is 5, and thus divides the result by 5 to get the average, 32 / 5 = 6 (round down). Finally, it replaces the 12 at the current position, with this local neighbourhood average of 6.

  3. Then the inner loop ends, and the outer loop steps to the next column to do the same process over again, only within a different neighbourhood.

Actual smoothing: Mm-mm-mm...

...Almost. If, after making these changes and saving, you hit Play, click in the Game window (to get keyboard focus), and subsequently press the spacebar, nothing will happen to your columns, but you will get a runtime error in Unity's console:

tut0_error.png

That's because there is a catch in our algorithm: Say we are looking at (current) column i=0. Then in looking at its neighbours, the very first neighbour column would be 2 steps before that, so n=-2. Arrays like heightmap don't accept indexes below 0, so Unity is telling us it can't access heightmap[-2]. The same will happen if we are too near to the end of the array (see LENGTH).

We solve this by checking whether the proposed neighbour index, n, is out of heightmap's allowable bounds, within our inner loop:


            for (int n = i - SCAN_RADIUS;
                     n < i + SCAN_RADIUS + 1;
                     n++)
            {
                if (n >= 0 && //big enough,
                    n < heightmap.Length) //but not too big!
                {
                    int heightOfNeighbour = heightmap[n];

                    heightSum += heightOfNeighbour;
                    heightCount++;
                }
            }

Now we have no problems, as we do not access heightmap[n] if n is out of bounds.

The last thing we need to do is update our visible boxes / columns, using this newly-updated data, so after this line in the outer loop,

heightmap[i] = heightSum / heightCount;

We add:

    TransformGO(i);

If you hit Play, click in the Game window (to get keyboard focus), and subsequently press the spacebar repeatedly, you should now see something like this:

tut0_smoothed_0.png

After the first keypress:

tut0_smoothed_1.png
After the second keypress:

tut0_smoothed_2.png

After the third keypress:

tut0_smoothed_3.png

And so on to your heart's desire. You can also adapt the SCAN_RADIUS to see how smaller or larger values will affect each smoothing step.

At last, we have relatively smooth terrain. In a real game, rather than using spacebar to smoothe interactively, we would typically call Smooth() in a for loop at level startup, however many times we might feel is necessary to get the sort of look and feel we want for our terrain.

Here is the final state of our code:

using System;
using UnityEngine;

public class SideTerrain : MonoBehaviour
{
    public const int LENGTH = 100;
    public const int SCAN_RADIUS = 2;

    int[] heightmap = new int[LENGTH];
    GameObject[] heightGOs = new GameObject[LENGTH];

    void Start()
    {
        for (int i = 0; i < LENGTH; i++)
        {
            heightmap[i] = UnityEngine.Random.Range(1, LENGTH);
            
            GameObject gameObject = GameObject.CreatePrimitive(PrimitiveType.Cube);
            heightGOs[i] = gameObject;
            
            TransformGO(i);
        }
        
        Camera.main.transform.position = new Vector3(LENGTH / 2, LENGTH / 2, -LENGTH);
    }
    
    void Update ()
    {
        if (Input.GetKeyDown(KeyCode.Space))
            Smooth();
    }
    
    void TransformGO(int i)
    {
        GameObject gameObject = heightGOs[i];
        gameObject.transform.localScale = new Vector3(1, heightmap[i] ,1);
        gameObject.transform.localPosition = new Vector3(i, heightmap[i] / 2f, 0);
        gameObject.name = "column "+i+" (height="+heightmap[i]+")";
    }
    
    void Smooth()
    {
        for (int i = 0; i < heightmap.Length; i++)
        {
            int height = heightmap[i];
            
            int heightSum = 0;
            int heightCount = 0;
            
            for (int n = i - SCAN_RADIUS;
                     n < i + SCAN_RADIUS + 1;
                     n++)
            {
                if (n >= 0 &&
                    n < heightmap.Length)
                {
                    int heightOfNeighbour = heightmap[n];

                    heightSum += heightOfNeighbour;
                    heightCount++;
                }
            }

            int heightAverage = heightSum / heightCount;
            heightmap[i] = heightAverage;
            
            TransformGO(i);
        }
    }
}

Adjustables

Increase LENGTH, and you get a higher resolution heightmap.

Increase SCAN_RADIUS, and you get a higher degree of homogeneity across the array, i.e. everyone conforms a lot more, because we're sampling a larger neighbourhood and need to conform to that larger group. Decrease it, and you're only ensuring similarity with your very closest neighbours, which leads to more heterogeneity across the heightmap. (You may have noticed in RL that the smallest and most exclusive social groups are often the quirkiest, whereas larger groups have to stay closer to "normal" to get along. There's a similar thing going on here.)

You may notice also, that I've used LENGTH not only for the number of columns, but also a restrictor for the maximum height that your heightmap may have, here:

heightmap[i] = UnityEngine.Random.Range(1, LENGTH);

...although it would be more correct to set this to UnityEngine.Random.Range(MIN, MAX), and specify MIN and MAX at the top of the class, beside LENGTH. This was done purely to keep things simple for the reader; it also incidentally ensures the camera will fit everything into the viewport.

Conclusion

We've created a heightmap data set, viewed it in the console, and created a cube-based terrain in order to view it Unity's Game view.

Better yet, we've learned to smooth a 1-dimensional dataset, and as we will see in future, this is useful in more than just terrain, and scales up to more dimensions.

Next tutorial, we will set up improved smoothing using floating point (fractional) numbers, and create a smoother, vector-based landscape as in Lunar Lander.

I hope you have enjoyed this tutorial. Feel free to leave any questions below, and like my post if it helped you learn something new and useful.

Discover and read more posts from Nick Wiggill
get started
post commentsBe the first to share your opinion
Ewald Horn
5 years ago

This is excellent! I’ve been looking at building an infinite scroller, though not in Unity, and this article has taught me a few neat tricks. Well done Nick!

Show more replies