Codementor Events

Terrain in Unity: Mesh-based 2D Side-scroller

Published Nov 12, 2018Last updated May 10, 2019
Terrain in Unity: Mesh-based 2D Side-scroller

In our last tutorial, we learned basic procedural generation techniques: how to generate a 2D side-scroller terrain using a heightmap, then how to smooth that terrain using a neighbourhood-averaging linear filter. In this tutorial we will improve the terrain we generated last time to make it appear still more natural.

This will be a long tutorial, as dynamic mesh generation is not trivial. Brace yourself!

Building Blocks: float heights & mesh-based terrain

Instead of only allowing integer values, floats allow anything in between, i.e. fractions. This helps us to show a more varied series of heights from our heightmap, and improves the linear averaging filter which we implemented last time, by allowing for more accurate averages (no rounding).

Mesh generation is key to modern procedural generation. We often need exact, efficient representations of whatever it is we are generating. 3D meshes can be constructed, vertex by vertex and face by face, to offer the fine control we need. Much detail may be packed into a single mesh, such that we can limit the flow of information between CPU and GPU, potentially improving a game's performance.

Floating point heights

We start with this feature as it is trivial to change our existing code to match, and will also be helpful when setting up the Vector3s used in our Mesh.

For each column in our heightmap, we will now allow fractional values.

You will need to change your SideTerrain script from last tutorial's final code (note this is just a list of lines you need to change, so you'll need to find and replace each one's int(s) with float(s)):

int[] heightmap = new int[LENGTH];
int height = heightmap[i];
int heightSum = 0;
int heightCount = 0;
int heightOfNeighbour = heightmap[n];
int heightAverage = heightSum / heightCount;

Note that for the first line mentioned above, int[] heightmap = new int[LENGTH];, you'll need to replace both int with float, or Unity will throw up an error.

You should notice that hitting the spacebar leads to an even smoother terrain now than it did before, with finer increments between columns.

Our SideTerrain class now looks as follows:

using System;
using UnityEngine;

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

    float[] heightmap = new float[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++)
        {
            float height = heightmap[i];
            
            float heightSum = 0;
            float heightCount = 0;
            
            for (int n = i - SCAN_RADIUS;
                     n < i + SCAN_RADIUS + 1;
                     n++)
            {
                if (n >= 0 &&
                    n < heightmap.Length)
                {
                    float heightOfNeighbour = heightmap[n];

                    heightSum += heightOfNeighbour;
                    heightCount++;
                }
            }

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

Basic mesh generation

Using float makes for better smoothing; but it is still possible to see "teeth" on the edges of each rectangular heightmap column. Let's add a new tool to our toolkit: we'll learn to build a simple quad-shaped mesh in Unity - that is, a 2D box.

Use your project from the last tutorial (or set up a new one in the same way). You can open the same scene you created, and access your GameObject in its scene Hierarchy to which the SideTerrain script is attached (look at the inspector to be sure). We're going to gut this class competely, and replace it as below, so you can make a copy of it if you don't want to lose the older contents.

using UnityEngine;
using System.Collections.Generic;

public class SideTerrain
{
    public Mesh mesh;
    public MeshFilter meshFilter;
    
    void Awake()
    {
    	BuildMesh();
    }
    
    void BuildMesh()
    {
    }
}

We specify a couple of modules that we will need to use, with the using statement:

  1. allows use of MonoBehavior, Mesh, MeshFilter, all Unity-related classes and functions.
  2. allows use of List<T>, that is, a generic list (see usage below).

We then have the class body, with the name SideTerrain; this class extends MonoBehaviour as is mandatory for runnable scripts in Unity.

We then have two lines in the class body, above the function:

  1. This is where we declare a reference to our Mesh. Notice that there is no = new Mesh(); here, so this is, for now, an unassigned reference (read: do not use until it is assigned!)
  2. This is where we declare a reference to our MeshFilter. This component is needed to draw the Mesh. Notice that it, too, is unassigned for now.
  3. We then define MonoBehaviour's special Awake() function, which is run the moment you hit Play; this immediately calls BuildMesh, which is self-descriptive.
  4. And we have the BuildMesh() method.

Right, we need to jump on assigning that mesh reference before we forget, but changing this:

public Mesh mesh;

to this, won't work:

public Mesh mesh = new Mesh();

As you'll get a compile-time error in the console. Instead, we must assign to the mesh reference in Start() or Awake() (special MonoBehaviour methods used for ordered initialisation), because Unity does not allow us to create Meshes before our MonoBehaviour-derived class has been initialised. So add the Awake() method:

    void Awake()
    {
        mesh = new Mesh();
        mesh.name = "Terrain mesh";
        meshFilter.mesh = mesh;
    }

Awake() is only ever run once, which is all we need for this example, whereas Start() is run every time the GameObject this class script is attached to, is activated. In this case it's all the same. If we had specified Awake() and Start(), the former would always run first.

Here we are not only constructing the empty Mesh object, but also giving it a name visible in the Unity editor, and handing it to our MeshFilter, so it can be drawn in the Game / Scene views.

Let's now begin to flesh out the BuildMesh method.

    void BuildMesh()
    {
        mesh.Clear();
        List<Vector3> positions = new List<Vector3>();
        List<int> triangles = new List<int>();
        
        //TODO set up geometry here.
        
        mesh.vertices = positions.ToArray();
        mesh.triangles = triangles.ToArray();
        mesh.RecalculateNormals();
    }
  1. clears mesh of any old vertex data, so we can start fresh.
  2. declares and constructs a new List<Vector3> ("list of 3D vectors"), to which we can easily add our vertex positions.
  3. declares and constructs a new List<int> ("list of integers"), to which we can can easily add indices into our vertex position list vertices (see above), thereby creating visible faces for our quad.
  4. sets mesh's vertices to our temporary vertex position list, converted to array.
  5. sets mesh's triangles to our temporary triangle / face index list, converted to array.
  6. Recalculates face normals for mesh - a formality to ensure everything is visible.

Now, that's all just supporting code. If you run the script, nothing will happen. That's because what we really need, is to create some geometry:

    void BuildMesh()
    {
        mesh.Clear();
        List<Vector3> positions = new List<Vector3>();
        List<int> triangles = new List<int>();
        
        positions.Add(new Vector3(0,0,0)); //lower left
        positions.Add(new Vector3(1,0,0)); //lower right
        positions.Add(new Vector3(0,1,0)); //upper left
        positions.Add(new Vector3(1,1,0)); //upper right
        
        mesh.vertices = positions.ToArray();
        mesh.triangles = triangles.ToArray();
        mesh.RecalculateNormals();
    }

We've created the four corners of our 2D quad, in 3D space. Recall that a Vector3's components are listed as (x, y, z); here we are arraying the points on the xy plane, which, being orthogonal to the camera's usual z viewing axis in Unity, lets us easily see what we're making.

If you hit Play to run this, you'll get an error:

tut2_error1.png

So as per that error message, we need to ensure our script's GameObject has a MeshFilter component on it, by going to the inspector, hitting the Add Component button, and then typing in MeshFilter and selecting it from the dropdown.

We will then need to click on the name Mesh Filter written in bold, and drag it to the Mesh Filter field of our SideTerrain script / component. (This Mesh Filter with a space, is actually the public MeshFilter meshFilter that we just wrote into the SideTerrain class.)

We'll also add a MeshRenderer that allows the Mesh to be rendered in the scene. This implicitly utilises the MeshFilter we just created, to do so.

tut2_assignmeshstuff.gif

You can optionally give your MeshRenderer a material, by clicking its Materials property, clicking on the circular button to the right of Element 0, and selecting one of Unity's materials from the list.

Once done, hit Ctrl-S to save these changes to the scene.

You'll be now able to run this without error, but you won't see anything in the Game / Scene views. However if you select your GameObject (e.g. via the Hierarchy view) that holds your script, and look at Unity's inspector, you will notice that the Mesh assigned to the MeshFilter has the name we gave it in code, i.e. Terrain Mesh. If you double-click that mesh, you should see that a darkened panel appear at the bottom of the inspector, listing the mesh name and 4 verts, 0 tris below that, which means that our 4 corners of the quad which we put into our Mesh via its vertices array, are indeed present. Both these factors assure us that our code is indeed affecting the scene.

tut2_assigned_mesh.png

But on pressing Play, we still aren't seeing a quad - why? - because a mesh isn't just a set of points.

We also need to direct the graphics hardware as to how those points are connected to create the surfaces we see on 3D objects. This is where the concept of triangles, or mesh faces, comes in. For that, we'll update BuildMesh() to show the first triangle of our quad:

    void BuildMesh()
    {
        mesh.Clear();
        List<Vector3> positions = new List<Vector3>();
        List<int> triangles = new List<int>();
        
        positions.Add(new Vector3(0,0,0)); //lower left
        positions.Add(new Vector3(1,0,0)); //lower right
        positions.Add(new Vector3(0,1,0)); //upper left
        positions.Add(new Vector3(1,1,0)); //upper right
        
        triangles.Add(0);
        triangles.Add(2);
        triangles.Add(1);
        
        mesh.vertices = positions.ToArray();
        mesh.triangles = triangles.ToArray();
        mesh.RecalculateNormals();
    }

Hitting Play, you should see a triangle onscreen:

tut2_game_2.png

Add the following code below that of your first triangle setup:

        triangles.Add(1);
        triangles.Add(2);
        triangles.Add(3);

And you should see two triangles of the same colour, side by side forming a square:

tut2_game_1.png

To you and I, our triangles array looks like this after Add()ing elements:

0, 2, 1, 1, 2, 3

Remember that these are indices into the vertices array.

But the graphics hardware sees this:

0, 2, 1 (first triangle), 1, 2, 3 (second triangle) etc.

The hardware assumes that each triplet in the array or buffer represents a triangle that it must draw. And this is how everything in GPU-accelerated games up until very recently has been drawn - as sets of triangles, often numbering in the millions.

Another point to note here is the ordering - 0, 2, 1 - why not in increasing order? That's because triangles have what is known as winding order: they wind either clockwise or counterclockwise. Unity's graphics engine assumes that a clockwise winding order denotes front-facing polygons, while counter-clockwise denotes - you guessed it - backward or inward faces. You can adjust these yourself in order to see flipped triangles, but you'll need to get into the Scene view, and fly around to look at your GameObject from the side opposite to the camera.

With this knowledge to hand, we can now set about constructing a more complex mesh. Code so far:

using System.Collections.Generic;
using UnityEngine;

public class SideTerrain : MonoBehaviour
{
    public MeshFilter meshFilter;
    public Mesh mesh;

    void BuildMesh()
    {
        mesh.Clear();
        List<Vector3> positions = new List<Vector3>();
        List<int> triangles = new List<int>();
        
        positions.Add(new Vector3(0,0,0)); //lower left - at index 0
        positions.Add(new Vector3(1,0,0)); //lower right - at index 1
        positions.Add(new Vector3(0,1,0)); //upper left - at index 2
        positions.Add(new Vector3(1,1,0)); //upper right - at index 3
        
        triangles.Add(0);
        triangles.Add(2);
        triangles.Add(1);
        
        triangles.Add(1);
        triangles.Add(2);
        triangles.Add(3);
        
        mesh.vertices = positions.ToArray();
        mesh.triangles = triangles.ToArray();
        mesh.RecalculateNormals();
    }
    
    void Awake()
    {
        mesh = new Mesh();
        mesh.name = "Terrain mesh";
        meshFilter.mesh = mesh;
        
        BuildMesh();
    }
}

Preparing our workspace for a terrain mesh

Phew! That was a lot to go through, but at least now we are ready to begin terrain construction. Let's roll.

Remove the function definition, and any calls, for TransformGO(), and we will also remove heightGOs, and all lines in our Start() function that contain the word gameObject / GameObject. We are thus effectively vanishing our older rendering code, as we will be re-writing it from scratch:

using System;
using System.Collections.Generic;
using UnityEngine;

public class SideTerrain : MonoBehaviour
{
    public const int MIN = 1;
    public const int MAX = 30;
    public const int LENGTH = 100;
    public const int SCAN_RADIUS = 2;

    float[] heightmap = new float[LENGTH];

    void Start()
    {
        for (int i = 0; i < LENGTH; i++)
        {
            heightmap[i] = UnityEngine.Random.Range(MIN, MAX);
        }
        
        Camera.main.transform.position = new Vector3(LENGTH / 2, LENGTH / 2, -LENGTH);
    }
    
    void Update ()
    {
        if (Input.GetKeyDown(KeyCode.Space))
            Smooth();
    }
    
    void Smooth()
    {
        for (int i = 0; i < heightmap.Length; i++)
        {
            float height = heightmap[i];
            
            float heightSum = 0;
            float heightCount = 0;
            
            for (int n = i - SCAN_RADIUS;
                     n < i + SCAN_RADIUS + 1;
                     n++)
            {
                if (n >= 0 &&
                    n < heightmap.Length)
                {
                    float heightOfNeighbour = heightmap[n];

                    heightSum += heightOfNeighbour;
                    heightCount++;
                }
            }

            float heightAverage = heightSum / heightCount;
            heightmap[i] = heightAverage;
        }
    }
    
    void BuildMesh()
    {
        mesh.Clear();
        List<Vector3> positions = new List<Vector3>();
        List<int> triangles = new List<int>();
        
        positions.Add(new Vector3(0,0,0)); //lower left - at index 0
        positions.Add(new Vector3(1,0,0)); //lower right - at index 1
        positions.Add(new Vector3(0,1,0)); //upper left - at index 2
        positions.Add(new Vector3(1,1,0)); //upper right - at index 3
        
        triangles.Add(0);
        triangles.Add(2);
        triangles.Add(1);
        
        triangles.Add(1);
        triangles.Add(2);
        triangles.Add(3);
        
        mesh.vertices = positions.ToArray();
        mesh.triangles = triangles.ToArray();
        mesh.RecalculateNormals();
    }
    
    void Awake()
    {
        mesh = new Mesh();
        mesh.name = "Terrain mesh";
        meshFilter.mesh = mesh;
        
        BuildMesh();
    }
}

Notice how we have held onto our new BuildMesh() and Awake() functions.

Notice also that I've surreptitiously added using System.Collections.Generic; to make things simpler in the next section; as well as MIN and MAX as suggested in tutorial, so we can have more control over the lay of the land.

Run this now, and you should (a) get no errors and (b) see nothing in the Game view. If so, that means we're ready to begin.

Terrain mesh generation: columns basics

Last tutorial, we had many GameObjects (cubes, as you'll recall) representing our terrain, each of whose shapes we had to update each time we smoothed the terrain.

We now make things more efficient by having just one GameObject, containing oneMeshthat will adapt to whatever ourheightmapdata dictates. ThatGameObjectis the one that ourSideTerrain` script is attached to.

As per our testbed version of SideTerrain above, we will need to call BuildMesh() from somewhere; here we'll change Update() so that whenever we hit the spacebar to call Smooth(), the mesh is rebuilt directly thereafter.

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

We will also, from now on, use Awake() rather than Start(), so let's consolidate these two into one:

void Awake()
{
        mesh = new Mesh();
        mesh.name = "Terrain mesh";
        meshFilter.mesh = mesh;
        
        for (int i = 0; i < LENGTH; i++)
        {
            heightmap[i] = UnityEngine.Random.Range(MIN, MAX);
        }
        
        Camera.main.transform.position = new Vector3(LENGTH / 2, LENGTH / 2, -LENGTH);
        
        BuildMesh();
    }

You can see how we are now mixing the code from this and the last tutorial, on our way to creating new functionality!

The next thing we need to do, is change BuildMesh() so that instead of just building one quad out of two triangles, it builds our many heightmap columns, as before, only this time as a unified mesh. We'll need a for loop for that.

    void BuildMesh()
    {
        
        mesh.Clear();
        List<Vector3> positions = new List<Vector3>();
        List<int> triangles = new List<int>();
        
        int t = 0;
        for (int i = 0; i < LENGTH; i++)
        {
            float h = heightmap[i];
        }
        
        mesh.vertices = positions.ToArray();
        mesh.triangles = triangles.ToArray();
        mesh.RecalculateNormals();
    }

We start by replacing the main part of the function, where previously we had our vertices.Add() and triangles.Add(), with a for loop to walk over each index / column of heightmap. We read the height into h. i here is functionally equivalent to an x value: remember how the heightmap is supposed to be a collection of heights ranging from left to right? So, in stepping through the array, we are stepping from left to right, hence, i is basically x.

Of course, this is currently just walking the loop, and caching h, but not doing anything more. What was our goal again? Ah, to create geometry, that's right.

    void BuildMesh()
    {
        
        mesh.Clear();
        List<Vector3> positions = new List<Vector3>();
        List<int> triangles = new List<int>();
        
        for (int i = 0; i < LENGTH; i++)
        {
            float h = heightmap[i];
            
            positions.Add(new Vector3(i+0,0,0)); //lower left - at index 0
            positions.Add(new Vector3(i+1,0,0)); //lower right - at index 1
            positions.Add(new Vector3(i+0,h,0)); //upper left - at index 2
            positions.Add(new Vector3(i+1,h,0)); //upper right - at index 3
        }
        
        mesh.vertices = positions.ToArray();
        mesh.triangles = triangles.ToArray();
        mesh.RecalculateNormals();
    }

Now we've added 4 vertex positions, just as before, for the current heightmap column, heightmap[i]. If you try to run this, you'll run into the same problem as earlier: nothing will appear. Why? Because again, a mesh consists of more than just points. Again, we need to relate these points to one another as triangles. So let's try exactly what we did before:


        for (int i = 0; i < LENGTH; i++)
        {
            float h = heightmap[i];
            
            //create the 4 vertices we will use to create the 2 triangles below:
            positions.Add(new Vector3(i+0,0,0)); //lower left - at index 0
            positions.Add(new Vector3(i+1,0,0)); //lower right - at index 1
            positions.Add(new Vector3(i+0,h,0)); //upper left - at index 2
            positions.Add(new Vector3(i+1,h,0)); //upper right - at index 3
            
            //triangle 1:
            triangles.Add(0);
            triangles.Add(2);
            triangles.Add(1);
            
            //triangle 2:
            triangles.Add(1);
            triangles.Add(2);
            triangles.Add(3);
        }

Running this will lead you to an interesting conclusion:

tut2_game_3.png

Something was drawn, but that looks like just one column there, near the bottom. But can it really be? Surely our for loop should have ensured that we wrote all columns to the mesh? Well, let's check the mesh vertex count: click your GameObject in hierarchy view, double-click the mesh from the MeshFilter, and count the verts and tris: we should have 100 columns i.e. LENGTH, times 4 vertices per column as created in the loop above. And as for tris, we should have 100 columns, times 2 triangles per column, just as with our earlier quad example - 2 triangles per square face. So 400 verts, and 200 tris, which you should see in the inspector.

Thus, we know that the loop is working, so we must be missing something else.

In fact, our triangles.Add() calls are accessing the wrong vertex indices.

Terrain mesh generation: columns done right... almost

In our basic quad mesh example, there were only 4 vertices to make up one quad. That meant we only needed to use 4 indices to make up our triangles - 0, 1, 2, 3 - remembering that array indexing is always zero-based. Yet here we are with several hundred vertices, but still we are only referencing the first 4! Obviously, that's wrong: we wouldn't create vertices we weren't planning to use.

So, how do we use them? Well, at every loop step, we create 4 new vertex positions, and 6 new entries into the triangles array. The triangles array should, at each step, be accessing only those vertices we have just then created, not just the initial 4, but rather, the last 4. How do we know what the last 4 are?

Well, every time we create 4 vertices, we can add 4 to a running counter, to keep track of how many have been created in the past. Then we can use this as an offset for indexing into positions. Notice the new variable offset, and how it is used:

    void BuildMesh()
    {
        
        mesh.Clear();
        List<Vector3> positions = new List<Vector3>();
        List<int> triangles = new List<int>();
        
        int offset = 0;
        for (int i = 0; i < LENGTH; i++)
        {
            offset = i * 4;
            
            float h = heightmap[i];
            
            //create the 4 vertices we will use to create the 2 triangles below:
            positions.Add(new Vector3(i+0,0,0)); //lower left - at index 0
            positions.Add(new Vector3(i+1,0,0)); //lower right - at index 1
            positions.Add(new Vector3(i+0,h,0)); //upper left - at index 2
            positions.Add(new Vector3(i+1,h,0)); //upper right - at index 3
            
            //triangle 1:
            triangles.Add(offset+0);
            triangles.Add(offset+2);
            triangles.Add(offset+1);
            
            //triangle 2:
            triangles.Add(offset+1);
            triangles.Add(offset+2);
            triangles.Add(offset+3);
        }
        
        mesh.vertices = positions.ToArray();
        mesh.triangles = triangles.ToArray();
        mesh.RecalculateNormals();
    }

First example:

We're on column i=0, the leftmost column. Let's say it has a height of 4. We add two positions at the bottom (y=0), and two positions at the top of the column (y=4). For each of the top and bottom pairs of vertices, one is on the left (x=0, because i is 0 and so i+0=0) and one on the right (x=1, because i is still 0 - we haven't moved to the next column yet - so i+1=1).

Now we create this column's first and second triangles, out of these points we have just created. i=0, thus offset=i*4=0. So we just create the triangle indices out of 0, 1, 2, 3 added to offset, which again, is 0.


Second example:

We're on column i=5, the 6th column from the left (because remember: arrays are zero-based!). Let's say it has a height of 7. We add our 4 positions, as before. This time, their left side is i+0=5, and their right side is at i+1=6. Their bottom (as always) is at y=0, and their top is at y=7.

Now we create our two triangles by referencing these points we have just created. i=5, thus offset=i*4=20. This means that we skip 20 indices of the vertices array, and use only the last 4, which are indices 20 + 0, 20 + 1, 20 + 2, 20 + 3 i.e. 20, 21, 22, 23 which we have just now created, in this same loop iteration where i=5. If you think about it, we're on the 6th column, and are skipping 5 columns worth of 4 vertices for each of those columns we've already put behind us.


You can see how this loop will skip 4 vertex indices for every column already created, which makes complete sense, since every column consists of 4 vertices, and at each loop cycle, in terms of creating triangles, we're never interested in any but the last 4 vertices created.

On hitting Play, you'll see something reminiscent of Tutorial 1's final output:

tut2_game_4.png

And with some spacebar-mashing:

tut2_game_5.png

Terrain mesh generation: columns done better

See how we still have the little jaggedy teeth on the top of that mountain range? - Say goodbye! In our BuildMesh() for loop, add an hn value below h:

            float h = heightmap[i];
            float hn = heightmap[i+1];

hn, as you can see, is indexed as i+1, which means it's our next neighbour to the right in our heightmap. To use it, we will replace our final added position with this:

positions.Add(new Vector3(i+1,hn,0)); //upper right - at index 3

Notice how we have now included hn as the y value. What are we actually doing here? Well, let's look at the whole set of positions:

            float h = heightmap[i];
            float hn = heightmap[i+1];
            positions.Add(new Vector3(i+0,0,0)); //lower left - at index 0
            positions.Add(new Vector3(i+1,0,0)); //lower right - at index 1
            positions.Add(new Vector3(i+0,h,0)); //upper left - at index 2
            positions.Add(new Vector3(i+1,hn,0)); //upper right - at index 3

No longer do we use both upper corners of a whole column to represent the height at our current index, i, which was what created those jaggedy little teeth. Before, each column was a box-like affair, whereas now, our left top corner uses the height at the current position, while our right top corner uses the height at the next position. As our for loop walks to the next index i, the height value at the top left of the new column is the same as the height value at the top right of our last column, leading to a smoothly flowing surface instead of the discontinuities we saw earlier.

Lastly, be sure to change your for loop's upper limit to LENGTH - 1, or your next-neighbour check at heightmap[hn] will meet with an array out-of-bounds error:

for (int i = 0; i < LENGTH - 1; i++)
{

Hit Play, click in Game view and hit space bar a few times, the jaggies are gone:

tut2_game_6.png

What we've done here is to shift the use of heightmap's values from describing plateaus or intervals, to describing infinitesimal points that separate those intervals. This is the essence of heigtmaps in modern games.

Our final code listing:

using System;
using System.Collections.Generic;
using UnityEngine;

public class SideTerrain : MonoBehaviour
{
    public const int MIN = 1;
    public const int MAX = 30;
    public const int LENGTH = 100;
    public const int SCAN_RADIUS = 2;

    float[] heightmap = new float[LENGTH];
    GameObject[] heightGOs = new GameObject[LENGTH];
   
    public MeshFilter meshFilter;
    public Mesh mesh;

    void Awake()
    {
        mesh = new Mesh();
        mesh.name = "Terrain mesh";
        meshFilter.mesh = mesh;
        
        for (int i = 0; i < LENGTH; i++)
        {
            heightmap[i] = UnityEngine.Random.Range(MIN, MAX);
        }
        
        Camera.main.transform.position = new Vector3(LENGTH / 2, LENGTH / 2, -LENGTH);
        
        BuildMesh();
    }
    
    void Update ()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            Smooth();
            BuildMesh();
        }
    }
    
    void Smooth()
    {
        for (int i = 0; i < heightmap.Length; i++)
        {
            float height = heightmap[i];
            
            float heightSum = 0;
            float heightCount = 0;
            
            for (int n = i - SCAN_RADIUS;
                     n < i + SCAN_RADIUS + 1;
                     n++)
            {
                if (n >= 0 &&
                    n < heightmap.Length)
                {
                    float heightOfNeighbour = heightmap[n];

                    heightSum += heightOfNeighbour;
                    heightCount++;
                }
            }

            float heightAverage = heightSum / heightCount;
            heightmap[i] = heightAverage;
        }
    }
    
    void BuildMesh()
    {
        mesh.Clear();
        List<Vector3> positions = new List<Vector3>();
        List<int> triangles = new List<int>();
        
        int offset = 0;
        for (int i = 0; i < LENGTH - 1; i++)
        {
            offset = i * 4;
            
            float h = heightmap[i];
            float hn = heightmap[i+1];
            positions.Add(new Vector3(i+0,0,0)); //lower left - at index 0
            positions.Add(new Vector3(i+1,0,0)); //lower right - at index 1
            positions.Add(new Vector3(i+0,h,0)); //upper left - at index 2
            positions.Add(new Vector3(i+1,hn,0)); //upper right - at index 3
            
            triangles.Add(offset+0);
            triangles.Add(offset+2)[;
            triangles.Add(offset+1);
            
            triangles.Add(offset+1);
            triangles.Add(offset+2);
            triangles.Add(offset+3);
        }
        
        mesh.vertices = positions.ToArray();
        mesh.triangles = triangles.ToArray();
        mesh.RecalculateNormals();
    }
}

Caveats

Although it renders and smooths very nicely, there are two things wrong with the current approach:

  1. We are creating more vertex positions than we need. Because of the sharing of that next-neighbour position between this and the following column, we are actually re-creating the same vertex position twice - once as the top right corner of the former column, and once as the top left corner of the next column.

  2. You will have noticed that we no longer walk the entire array, but rather only till LENGTH - 1. Why? Because each loop iteration requires us to check the next neighbour, and if we walked to LENGTH, there would be no next neighbour! This can be solved, but only once we move onto our next topic: terrain chunking.

Conclusion

We have learned how to construct a mesh in Unity and have it display in the scene, using an array of heightmap values; we've seen how to apply that knowledge to rendering smooth 2D terrain; and we've seen that we can use floating point values to produce more accurate smoothing than we able to when using only integers.

I hope you have enjoyed this second part of the Eye-Openers series on game development fundamentals in Unity. Next tutorial, we'll extend our terrain as per infinite runner style games.

Discover and read more posts from Nick Wiggill
get started
post commentsBe the first to share your opinion
Pumpedupjellos Blue
4 years ago

Thanks, this is just what I needed to get started on Meshes in Unity!

Show more replies