Efficient & Artefact Free 2D Tile Mapping in Unity 5.5

As a personal project, I recently embarked on a journey to create a top down, tile based 2D game in Unity. I was surprised by how clunky the (otherwise brilliant) platform’s support for tile mapping is out of the box.

Tile mapping is a staple of 2D game development, from side scrolling arcade games through to top down RPGs, so it was perplexing that even some of the most popular (and pricey) assets on the Unity Asset Store used clunky methods, like instantiating hundreds of game objects to represent tiles, and suffered from terrible texturing artefacts like tearing and bleeding.

While I would loved to have found a nice, simple, out of the box solution to my requirements, I instead had to roll my own. In the hopes of helping others who are facing similar problems, I thought I would share the solutions I found here.

Are you trying to create a 2D game? Are you suffering from one or more of the following problems?

  • Too many game objects!
  • Game running slow!
  • Terrible FPS or high CPU usage!
  • Weird green lines (or *insert colour* lines) appearing between tiles!
  • Textures tearing or otherwise freaking out when the camera moves!

Well folks, this might be the tutorial for you!

All you will need is the following:

I will be using C# scripting in this tutorial, because to be frank at points it makes life a lot easier, but in theory the method described should work just as well with Javascript.

I will also be focusing on a “top down” style of tile mapping, but very similar concepts will apply in side scrolling games, and other types of games that use tiles.

In this tutorial I will cover the following topics in detail:

  1. Creating a better camera for use with tiles; one that supports multiple zoom levels.
  2. Rendering blocks of tiles as a mesh for greater efficiency and less overhead.
  3. Preparing textures (tile sheets) so that they can be rendered without bleeding or other artefacts.
  4. Mapping textures to the tile mesh.
  5. Allowing the camera to move, but constraining it to the boundaries of the tile map.
  6. Dealing with transparent tiles.
  7. Layering blocks of tiles.
  8. Finding the tile that you clicked on (translating mouse coordinates to tiles coordinates).
  9. Ideas for further optimisation of tile mapping systems.

1. Creating a Better Camera

The first major problem I faced when dealing with tile mapping in Unity was figuring out how to configure the camera. Specifically, what value to set the Orthographic Size field to in order to achieve the results I wanted (effectively, with no zoom applied, making my tiles the size I intuitively expected them to be).

This problem got even trickier when I started thinking about letting the player zoom in and out.

But with a bit of thinking, and a bit of research, I was able to come up with a solution that seems to work well.

Create a new script called “TileCamera.cs” and add it to your main camera. You should also check to make sure that the other settings on your camera match the values below (they should, if you chose “2D” when creating your project):

(Click the image to enlarge, if it is too small to read.)

In addition to ensuring you have added the TileCamera script, the main setting you want to play close attention to is the [Projection] field. It must be set to “Orthographic”.

Now let’s open up the TileCamera script and add some fields and basic initialisation:

[SerializeField]
private float pixelsPerUnit = 1.0f;

private float zoomFactor = 1.0f;
private float orthographicSize = 1.0f;

private Camera gameCamera;

void Awake()
{
    this.gameCamera = this.GetComponent();
}

Most of these fields will be calculated, and therefore discussed in more detail at the appropriate time, but I wanted to give a quick nod to the [pixelsPerUnit] field.

Our goal, with no zooming applied, is to have 1 unit in Unity’s measurement system (1 unit in World Space) be rendered as closely to 1 pixel on the screen as possible. This will provide a much more intuitive rendering of the tiles.

So I guess the question is: why have it configurable at all? And the answer to that is simply good practice. One day you will want to use a different unit-to-pixel conversion, and will be grateful the field existed. While 1 unit to 1 pixel might be the most intuitive method of dealing with tiles, there are many reasons you might need to change it, with the most obvious being ensuring that the same number of tiles are shown on the screen regardless of the player’s resolution.

But anyway, for now let’s just stick to the basics and work on the premise that we will be attempting to get 1 unit as close to 1 pixel as possible. To do this, we need to calculate an appropriate Orthographic Size, and update the camera.

void Awake()
{
    this.gameCamera = this.GetComponent();

    this.CalculateOrthoSize();

    this.gameCamera.orthographicSize = this.orthographicSize;
}

private void CalculateOrthoSize()
{
    float scale = this.pixelsPerUnit * this.zoomFactor;

    this.orthographicSize = this.gameCamera.pixelHeight / scale / 2.0f;
}

OK, so how does this work?

Well, put simply, Orthographic Size is a fancy way of saying how many units (in Unity’s measurement system) can fit on half the height of the screen. And that is all we are calculating.

[gameCamera.pixelHeight] gives us the height of the screen in pixels. We then divide that by [pixelsPerUnit * zoomFactor], which by default is 1.0 because we want 1 unit to equal 1 pixel, and finally we divide all that by 2.0 to halve it. Simple.

The “complicating” factor is [zoomFactor]… but all that really does is tweak the ratio of units to pixels.

And speaking of the zoom factor, we might as well add that now, as it is very straight forward.

void Update()
{
    this.HandleInputs();
}

private void HandleInputs()
{
    bool pageUp = Input.GetKeyDown("page up");
    bool pageDown = Input.GetKeyDown("page down");

    if (pageUp && !pageDown)
    {
        this.zoomFactor += (this.zoomFactor < 5.0) ? 1.0f : 0.0f;
    } 
    else if (!pageUp && pageDown)
    {
        this.zoomFactor -= (this.zoomFactor > 1.0) ? 1.0f : 0.0f;
    }
        
    if(pageUp || pageDown) { this.Zoom(); }
}

private void Zoom()
{
    this.CalculateOrthoSize();

    this.gameCamera.orthographicSize = orthographicSize;
}

There is nothing complicated here, we simply increase or decrease [zoomFactor] based on whether the player pressed Page Up or Page Down (applying some basic boundary checking), and then recalculate and update the Orthographic Size.

But this is a little boring. Let’s add a bit of an animation to our zoom. This should also answer a question some more observant readers might have been asking: why don’t we just update the camera’s Orthographic Size in the [CalculateOrthoSize] function?

private bool zoomInProgress = false;

private void HandleInputs()
{
    if (!this.zoomInProgress)
    {
        bool pageUp = Input.GetKeyDown("page up");
        bool pageDown = Input.GetKeyDown("page down");

        if (pageUp && !pageDown)
        {
            this.zoomFactor += (this.zoomFactor < 5.0) ? 1.0f : 0.0f; 
        } 
        else if (!pageUp && pageDown) 
        { 
            this.zoomFactor -= (this.zoomFactor > 1.0) ? 1.0f : 0.0f;
        }

        if (pageUp || pageDown) { this.Zoom(); }
    }
}

private void Zoom()
{
    this.zoomInProgress = true;

    this.CalculateOrthoSize();

    this.StartCoroutine(this.AnimatedZoom());
}

private IEnumerator AnimatedZoom()
{
    float start = this.gameCamera.orthographicSize;
    float duration = 0.5f;
    float timer = duration;

    while (timer > 0.0f)
    {
        float target = this.orthographicSize;
        float progress = 1.0f - (timer / duration);

        this.gameCamera.orthographicSize = Mathf.Lerp(start, target, progress);

        timer -= Time.deltaTime;

        yield return null;
    }

    this.gameCamera.orthographicSize = orthographicSize;

    this.zoomInProgress = false;
}

Nothing too complicated here, we are simply using Unity’s handy co-routine system to gradually move the camera to its new zoom via linear interpolation, rather than simply “jumping” there. This should make for a smoother (and cooler) experience.

It is worth noting that because zooming now takes time, we had to add some protection to prevent double triggering of the [AnimatedZoom] co-routine. This is done via the new [zoomInProgress] field, and checks in relevant places.

The last thing we want to do with our camera (for now) is calculate an origin point for our tiles. This needs to be done in our camera script, as it is quite dependant on state of the camera.

It is also important we calculate this origin point with no zoom applied (or default zoom applied), as the origin point should be fixed once and never change. A fixed origin point allows us to zoom in to whatever is in the centre of our camera, and will (later on) help us create a moving camera that allows us to explore a tile map much large than can fit on our screen.

private Vector3 initialTopLeft = Vector3.zero;

public Vector3 worldOrigin { get { return this.initialTopLeft; } }

void Awake()
{
    this.gameCamera = this.GetComponent();

    this.CalculateOrthoSize();
        
    this.gameCamera.orthographicSize = this.orthographicSize;

    this.initialTopLeft = this.gameCamera.ScreenToWorldPoint(new Vector3(0, Screen.height, 0));
}

I am not going to go in to too much detail about the [gameCamera.ScreenToWorldPoint] function at this point, but basically it allows you to pass in a pixel coordinate (in this case the top most, left most pixel on the screen), and convert it to a unit coordinate (a coordinate in World Space).

It is important to note that in Unity, the pixel coordinate (0,0) is bottom most, left most pixel… not the top most. For some this might be intuitive, but for me it is not. This is why we are setting our origin to (0, Screen.height) instead.

2. Rendering Tiles as a Mesh

Now that we have a workable camera, we can start looking at drawing the actual tiles.

To minimise overhead, we are going to draw our tiles to a single game object using a mesh. Or more specifically, we are going to draw a block of tiles to a single game object; depending on your requirements, you may want to break your map up in to several blocks.

For the purposes of this tutorial, we are going draw a 100 x 100 block of tiles (10,000 tiles) to a single game object.

But before we can do any of this, we need some way to represent our tile map. To be frank, this is an entire subject in its own right, so for this tutorial we are just going to keep it simple.

Create a new C# script called “BlockMesher.cs” and add the following code:

[RequireComponent(typeof(MeshFilter))]
public class BlockMesher : MonoBehaviour
{
    private byte[,] tileMap;

    void Start()
    {
        this.GenerateTileMap();
    }
	
    private void GenerateTileMap()
    {
        this.tileMap = new byte[100, 100];

        for (int ty = 0; ty < this.tileMap.GetLength(1); ty++)
        {
            for (int tx = 0; tx < this.tileMap.GetLength(0); tx++)
            {
                int tileIndex = tx + (ty * this.tileMap.GetLength(0));
                bool isEven = (tileIndex % 2 == 0);

                this.tileMap[tx, ty] = (byte)((isEven)? 0: 1);
            }
        }
    }
}

The [RequireComponent] attribute above the class will ensure we can only add this to game objects with a MeshFilter component. Any component using this class will require both a MeshFilter and a MeshRenderer to work correctly.

The [tileMap] array is a simple multi-dimensional array (x,y) that we will use as a basic representation of our tile map.

We populate our tile map on Start() using our GenerateTileMap() function. All this function does is set evenly numbered tiles to 1 and oddly numbered tiles to 0. Down the track we will use this difference to apply two different textures to our tiles.

In any real application of these tile mapping methods, you will likely have a more complex representation of your tiles. But this will be very case specific, and so a simple representation will work for us for now.

So let’s get in to the actual drawing of the tiles.

private Mesh mesh;

void Awake()
{
    this.mesh = this.GetComponent().mesh;
}

To start with we need to get a reference to the mesh we will be drawing too. Nothing too complicated here, though it is worth noting that the Awake() step of the life cycle is the best place to do it.

Next we need to set up some basic configuration fields, and use them to calculate the scale of our game object:

[SerializeField]
private float tileSize = 32.0f;

[SerializeField]
private float tileScale = 1.0f;

void Awake()
{
    this.mesh = this.GetComponent().mesh;

    // Scale to match desired unit to pixel ratio.

    float scale = this.tileSize * this.tileScale;

    this.transform.localScale = new Vector3(scale, scale, 1.0f);
}

Much like the [pixelsPerUnit] field in our TileCamera script, the [tileScale] field in this script is provided as a matter of good practice. Ideally we will target a scale of 1.0, where each tile will appear on the screen at its actual size. But there maybe cases down the track where this is not desirable.

The more important field in the short term is [tileSize], which is the number of pixels wide and high our tiles are. We are assuming square tiles in this tutorial, but the methods could easily be adapted to irregular tiles.

The point of these fields is to help us calculate the scale of our game object.

If we draw each tile to the mesh at exactly 1 unit wide (and 1 unit high) using Unity’s measurement system, it will provide us with a powerful way to convert between screen space and tile space. But if we are to do this, we need to scale up the size of game object containing the mesh so that our tiles appear at the desired size.

There is nothing complicated in how this it is done, but it is worth mentioning that if you look back at the new code in the Awake() function, we have only set the X and Y scales. The Z scale is left at 1.0, as it has minimal impact in a 2D space (it is applicable to layering of tiles, but even in that scenario is fairly simple).

In addition to scaling out game object, we will also want to reposition it, so that our tiles start in the top left of the screen, as one would intuitively expect them too. Normally this would require us to apply an offset to the position of every tile, but one of the advantages of drawing out tiles to a mesh within a game object is that we can simply move the game object:

void Start()
{
    this.GenerateTileMap();

    // Reposition to match the desired origin point (original top left of screen).

    TileCamera tileCamera = Camera.main.GetComponent();

    this.transform.position = new Vector3(tileCamera.worldOrigin.x, tileCamera.worldOrigin.y, 0.0f);
}

Thankfully we have already calculated the desired world origin in our TileCamera script, so all we need to do is reference it. Doing so in the Start() phase of the object life cycle is the safest option.

Much like during scaling, we are really only concerned with the X and Y position. In this case, however, it is important that our Z position is 0.0, or more specifically, it is important that our Z position is further away from the viewer than the camera is, which is set to -10.0 (where negative values move the object closer to the viewer).

With our game object correctly scaled and positioned, we can now actually dive in to building the mesh that will represent our tiles.

To build our mesh, we need to divide our tile map up in to a series of triangles that will make up the tiles. Why triangles? Well that is not really in scope of this tutorial, but just know that triangles are the preferred polygons of graphics engines everywhere.

For our purposes, it will be sufficient to represent each tile as two triangles.

The first thing we need to do, in order to tell Unity which triangles we need, is to give Unity a list of the vertices that will make up those triangles. For those who do not know, vertices are simply positions in space, and in the case of our triangles, represent each of the corners.

Because we are drawing each tile exactly 1 unit wide (and high) in Unity’s measurement system, these corners will correspond directly to the position of our tiles within the tile map (see the image above). The top left corner of each tile will be the row and column number (X,Y) of the tile within our [tileMap] array, the top right will be 1 unit to the right, and so on, and so forth.

Calculating the vertices in code is therefore fairly straight forward:

List<Vector3> vertices = new List<Vector3>();

private void ConstructTile(int tx, int ty, int texture)
{
    Vector3 v0 = new Vector3(tx, ty, 0);
    Vector3 v1 = new Vector3(tx + 1, ty, 0);
    Vector3 v2 = new Vector3(tx + 1, ty - 1, 0);
    Vector3 v3 = new Vector3(tx, ty - 1, 0);

    vertices.Add(v0);
    vertices.Add(v1);
    vertices.Add(v2);
    vertices.Add(v3);
}

With the vertices calculated, the next step will be to index each vertex. What do I mean by that? Well giving Unity a list of vertices is all good and well, but we also need to tell Unity which vertices make up which triangles. To do this we need to know the position of each vertex within the list:

List<Vector3> triangles = new List<Vector3>();

private void ConstructTile(int tx, int ty, int texture)
{
    Vector3 v0 = new Vector3(tx, ty, 0);
    Vector3 v1 = new Vector3(tx + 1, ty, 0);
    Vector3 v2 = new Vector3(tx + 1, ty - 1, 0);
    Vector3 v3 = new Vector3(tx, ty - 1, 0);

    vertices.Add(v0);
    vertices.Add(v1);
    vertices.Add(v2);
    vertices.Add(v3);

    int tileIndex = tx + (Mathf.Abs(ty) * this.tileMap.GetLength(0));

    int i0 = (tileIndex * 4);
    int i1 = (tileIndex * 4) + 1;
    int i2 = (tileIndex * 4) + 2;
    int i3 = (tileIndex * 4) + 3;

    triangles.Add(i0);
    triangles.Add(i1);
    triangles.Add(i3);

    triangles.Add(i1);
    triangles.Add(i2);
    triangles.Add(i3);

Finding the position of the vertex with in the list is fairly straight forward. We know there are 4 vertices per tile, therefore as long as we know the index of the tile (the number of the tile, if all tiles in the map were laid out side-by-side), we can use some simple math to figure out the index of the vertex.

It is well worth noting that although we have calculated the index of a tile before (to figure out whether a tile is even or odd), in this particular case we needed to wrap the [ty] in a Math.Abs() call. This is because we will be passing in a negative [ty] value. The reasons why will be discussed in more detail later.

int tileIndex = tx + (Mathf.Abs(ty) * this.tileMap.GetLength(0));

With the vertices indexed, we can now move on to defining our triangles. The code to do this is already included in the snippet above, but it requires further explanation:

triangles.Add(i0);
triangles.Add(i1);
triangles.Add(i3);

triangles.Add(i1);
triangles.Add(i2);
triangles.Add(i3);

The way we tell Unity about triangles can be a little unintuitive at first, as we do so by providing a simple, one dimensional list.

I find the easiest way to think about this is is as a step-by-step instruction manual for Unity. First go to Vertex0, now go to Vertex1, now go to Vertex3, and you have a triangle. Now go to Vertex1, now go to Vertex2, and so on and so forth.

But how do we decide what order to build the triangles (or which triangles to use)? I mean the configuration of triangles shown in the examples I have been providing is just one way to do it… rotate the square 90° and you will have another configuration.

Well this is a little complicated. Even though we are working in 2D, the Unity graphics engine works with 3D spaces. Using the triangle configuration I have shown in my examples, and drawing them in the order I have proposed will make sure that the triangles are facing towards the screen.

If we were to use a different configuration, the triangles would face away from the screen, which is not just confusing, but also would make them invisible, as for efficiencies sake, graphics engines cull anything that is not facing the screen.

Now I could go in to detail about why the configuration I have proposed is the one we need to use, and explain half a dozen different ways to correct the problem if we wanted to use different configurations, but that is not really relevant to this tutorial. If you would like to know more, research the term “normals” and how it applies to 3D engines… but for now, let’s just accept that if we build our tiles using the configuration shown in the provided examples and snippets, it should work for our purposes.

OK, so we now have a function that will build our triangles for us, but we still need to pull it all together:

void Start()
{
    this.GenerateTileMap();
    this.GenerateMesh();

    // Reposition to match the desired origin point (original top left of screen).

    TileCamera tileCamera = Camera.main.GetComponent();

    this.transform.position = new Vector3(tileCamera.worldOrigin.x, tileCamera.worldOrigin.y, 0.0f);
}

private void GenerateMesh()
{
    for (int ty = 0; ty < this.tileMap.GetLength(1); ty++)
    {
        for (int tx = 0; tx < this.tileMap.GetLength(0); tx++)
        {
            this.ConstructTile(tx, -ty);
        }
    }

    mesh.Clear();
    mesh.vertices = vertices.ToArray();
    mesh.triangles = triangles.ToArray();
    mesh.RecalculateNormals();

    vertices.Clear();
    triangles.Clear();
}

The GenerateMesh() function will cycle through every tile in our tile map, add its vertices and triangles to a list, and then tell Unity about those lists by assigning them to game object’s mesh.

We trigger the GenerateMesh() function by adding a call to it from the Start() step in the game object’s life cycle.

If your tile map is static (can not be changed in game), on start is the only time you will need to call the generate function.

If, on the other hand your tile map can be changed by player actions, you will need to recall the generate function each time it changes. We will actually demonstrate this later on.

Now there is nothing too complicated about the GenerateMesh() function, but it is worth taking a quick look at the order in which we are “drawing” the tiles:

for (int ty = 0; ty < this.tileMap.GetLength(1); ty++)
{
    for (int tx = 0; tx < this.tileMap.GetLength(0); tx++)
    {
        this.ConstructTile(tx, -ty);
    }
}

We are drawing the tiles one row at a time, from top to bottom. Within each row we are drawing from left to right.

For most tile based systems, this will have no meaningful impact, but it is worth keeping in mind if you have a complex/dynamic texturing system.

The good news is you should be able to call the ConstructTile() function in any order you like, so long as we call it for every tile in the map.

Oh, and one more important thing to take notice of:

When we call the ConstructTile() function, we are passing in positive (+) [tx], but we are passing in negative (-) [ty]… why is this?

Well as I mentioned earlier, in Unity the point (0,0) is actually in the bottom left of the screen. I prefer it to be in the top left of the screen (as I believe this is more intuitive), and have set my world origin accordingly. But to compensate for this, to make the rows of tiles draw down the screen rather than up, we need to make sure we pass in negative (-) [ty].

OK, our mesh builder is now complete. So let’s add it to the game and see what happens.

Create a new game object called “TileBlock” and add a MeshFilter and MeshRenderer component to it. Once this is done, add our BlockMesher script to the game object.

With any luck, when you run your game now you should see giant magenta block. In fact, depending on your resolution, it will almost certainly take up the entire screen.

So what is the deal? Why is there only one giant block when we drew 10,000 tiles? We the answer is that we have not actually textured the tiles yet, so they all appear identical (and magenta because that is the default material in Unity).

3. Preparing Our Textures

At this point we need to create some textures for our tiles. If you would like, you can simply download the sample texture I have provided, but we still need to talk about how this texture is prepared, and the best way to import it.

Because Unity is actually using a 3D engine, it does not strictly deal in pixels when applying textures. While we are attempting to render our tiles as closely to their real size as possible, there will be scenarios under which Unity is actually dealing with fractions of a pixel. This can create weird artefacts, the most common of which is “bleeding.”

Texture bleeding is when part of one texture bleeds over to another, or where transparency bleeds in to the texture allowing the background to show through.

The easiest way to avoid bleeding is to simply compensate for it within the texture. In the example above (which it should be noted has been blown up to double size), the red pixels are “bleed pixels”.

The bleed pixels should be replaced with a copy of the edges of the actual texture, or at least a colour that blends well with the edges.

The yellow pixels should be replaced with transparency. This should not strictly be necessary, but it helps keep the texture separated and evenly spaced.

Once our texture is prepared, we need to import it in to our game. But we have to do this carefully.

By default, Unity attempts to compress textures as the are imported. This is not a bad thing for the types of large textures that are used in 3D games, but it can be ruinous to the type of pixel perfect textures needed in a tile mapping solution.

The easiest way to avoid this is to simply disable automatic compression by going to the Edit menu, and selecting Preferences:

Under the General tab in the preferences window, there is an option named “Compress Assets on Import”. Simply untick the box next to this option.

With automatic compression disabled, we can now safely import the texture in to our project.

Once the texture has been imported, open it up in the inspector and make sure it is properly set up for our needs:

The [TextureType] needs to be set to “Sprite (2D and UI)”, with [SpriteMode] set to “Single” and [PixelsPerUnit] set to “1”.

Under “Advanced”, the box next to [GenerateMipMaps] should be disabled.

Finally, the [WrapMode] should be set to “Clamp” and the [FilterMode] to “Point (no filter)”.

Beyond this, default settings should be fine.

With the texture set up correctly, we now need to create a material so that we can apply it to our tile mesh.

Create a new material named “TestMaterial” and open it up in the inspector:

Under the “Shader” drop down, choose the “Default” option from the “Sprites” category.

The default settings for this shader type are perfect for our purposes, so no further changes are needed.

But now we need to apply our texture to the new material, which due to a quirk in the Unity UI, is actually a fairly unintuitive process.

The “Sprites/Default” material shader was designed to be used with Unity’s Sprite Renderer… but as we are not (and can not) use the Sprite Render for our purposes, we need to find another way to apply the texture.

The quickest way I have found to do this is to open up the material and change the “Shader” setting back to “Standard”.

We can now drag our texture in to the little gray box next to the [Albedo] setting:

Once this is done, simply change the “Shader” type back to “Sprites/Default” and it should retain our texture.

With this done, we can now simply drag our material on to our game object (make sure to drag the material, not the texture).

Mapping Our Textures to the Tile Mesh

Now that we have a valid texture (and material) applied to our mesh, we can start the job of actually mapping the textures to our tiles.

If you run the game at the moment, you should no longer see the big magenta square. In fact, you should see nothing at all.

This is because Unity now knows that a texture should be applied to the mesh, but it doesn’t have enough information to do the job.

Let’s start by opening up our BlockMesher script and adding two new properties:

[SerializeField]
private int textureTileWidth = 1;

[SerializeField]
private int textureTileHeight = 1;

Using the inspector, update these fields to reflect the number of tiles wide and high that our texture is. If you are using the provided test texture, it is both 2 tiles wide and high:

Mapping the texture to our tiles is fairly straight forward, but requires us to figure out where in the texture (tile sheet) the texture we want to use is, and then converting it to the UV coordinate system that Unity uses to understand textures.

Put simply, UV coordinates are expressed as values between 0.0 and 1.0, where 0.0 is one side of the texture (bottom or left) and 1.0 is the other side of the texture (top or right).

The first thing we need to do is figure out the position of the texture within the texture sheet.

We are going to stick with something simple here, and assume that the value stored in the tile map is the number of the texture we want to use. This makes it fairly simple to figure out the position of the texture within the sheet:

private void GenerateMesh()
{
    for (int ty = 0; ty < this.tileMap.GetLength(1); ty++)
    {
        for (int tx = 0; tx < this.tileMap.GetLength(0); tx++)
        {
            this.ConstructTile(tx, -ty, tileMap[tx, ty]);
        }
    }
    
    ...
}

private void ConstructTile(int tx, int ty, int texture)
{
    ...

    float textureX = texture % this.textureTileWidth;
    float textureY = Mathf.Floor(texture / this.textureTileHeight);
}

Nothing too complicated here. Do note, however, that we have added a new [texture] parameter to the ConstructTile() function, allowing us to pass through the value stored in the tile map.

Next we need to start calculating the values that will help us convert the texture’s position in to the UV coordinate system.

private void ConstructTile(int tx, int ty, int texture)
{
    ...

    float textureX = texture % this.textureTileWidth;
    float textureY = Mathf.Floor(texture / this.textureTileHeight);

    float uPerPixel = 1.0f / ((this.tileSize + 4.0f) * (float)this.textureTileWidth);
    float vPerPixel = 1.0f / ((this.tileSize + 4.0f) * (float)this.textureTileHeight);

    float uPerTile = this.tileSize * uPerPixel;
    float uPerTilePadded = (this.tileSize + 4.0f) * uPerPixel;

    float vPerTile = this.tileSize * vPerPixel;
    float vPerTilePadded = (this.tileSize + 4.0f) * vPerPixel;
}

Once again, nothing too complicated here.

In addition to figuring out how many UV units there are per pixel, we have also used that information to figure out how many there are per tile, and how many there are per padded tile (with bleed pixels and transparent padding).

With this information, we can now actually convert the position of the texture in to UV coordinates, and add them to a list that we will provide to Unity:

List<Vector2> uvCoordinates = new List<Vector2>();

private void ConstructTile(int tx, int ty, int texture)
{
    ...

    float textureX = texture % this.textureTileWidth;
    float textureY = Mathf.Floor(texture / this.textureTileHeight);

    float uPerPixel = 1.0f / ((this.tileSize + 4.0f) * (float)this.textureTileWidth);
    float vPerPixel = 1.0f / ((this.tileSize + 4.0f) * (float)this.textureTileHeight);

    float uPerTile = this.tileSize * uPerPixel;
    float uPerTilePadded = (this.tileSize + 4.0f) * uPerPixel;

    float vPerTile = this.tileSize * vPerPixel;
    float vPerTilePadded = (this.tileSize + 4.0f) * vPerPixel;

    float left = (textureX * uPerTilePadded) + (2.0f * uPerPixel);
    float right = left + uPerTile;
    float top = (textureY * vPerTilePadded) + (2.0f * vPerPixel);
    float bottom = top + vPerTile;

    uvCoordinates.Add(new Vector2(left, bottom));
    uvCoordinates.Add(new Vector2(right, bottom));
    uvCoordinates.Add(new Vector2(right, top));
    uvCoordinates.Add(new Vector2(left, top));
}

Pretty straight forward. The order in which the UV coordinates are added to the list is important, but for the purposes of this tutorial, simply following the order shown in the snippet above is sufficient.

Finally, we simply need to update our GenerateMesh() function to tell Unity about the list of UV coordinates:

private void GenerateMesh()
{
    for (int ty = 0; ty < this.tileMap.GetLength(1); ty++)
    {
        for (int tx = 0; tx < this.tileMap.GetLength(0); tx++)
        {
            this.ConstructTile(tx, -ty, tileMap[tx, ty]);
        }
    }

    mesh.Clear();
    mesh.vertices = vertices.ToArray();
    mesh.triangles = triangles.ToArray();

    mesh.uv = uvCoordinates.ToArray();

    mesh.RecalculateNormals();

    vertices.Clear();
    triangles.Clear();
    uvCoordinates.Clear();
}

Remember as well that our camera script allows us to zoom in and out, so play around with the Page Up and Page Down keys to try viewing the tiles at different zoom settings. If we have set our textures up correctly, there should be no bleeding or artefacts, even during the zoom animations.

The more observant among you might also notice that the textures showing are the bottom two textures within our tile sheet, even though the values of the tiles we are using are 0 and 1. This is because, like many things in Unity (and 3d engines in general), UV coordinates work from the bottom up.

Let’s try tweaking the GenerateTileMap() function to change which textures are used, and while we are at it, let’s try making the tile map 99×99 instead of 100×100:

private void GenerateTileMap()
{
    this.tileMap = new byte[99, 99];

    for (int ty = 0; ty < this.tileMap.GetLength(1); ty++)
    {
        for (int tx = 0; tx < this.tileMap.GetLength(0); tx++)
        {
            int tileIndex = tx + (ty * this.tileMap.GetLength(0));
            bool isEven = (tileIndex % 2 == 0);

            this.tileMap[tx, ty] = (byte)((isEven)? 0: 2);
        }
    }
}

Adding Movement to the Camera

With our tiles drawn and textured, we are in a pretty good spot. But most of our tiles are currently off screen and inaccessible. We need to add the ability for our camera to move around the tile map.

The first thing we should do is open up our TileCamera script and refactor the HandleInput() function to make our lives a little easier/neater:

private void HandleInputs()
{
    this.HandleZoomInput();
    this.HandleMovementInput();
}

private void HandleZoomInput()
{
    if (!this.zoomInProgress)
    {
        bool pageUp = Input.GetKeyDown("page up");
        bool pageDown = Input.GetKeyDown("page down");

        if (pageUp && !pageDown)
        {
            this.zoomFactor += (this.zoomFactor < 5.0) ? 1.0f : 0.0f; } else if (!pageUp && pageDown) { this.zoomFactor -= (this.zoomFactor > 1.0) ? 1.0f : 0.0f;
        }

        if (pageUp || pageDown) { this.Zoom(); }
    }
}

private void HandleMovementInput()
{
}

Nothing complicated here, we are just moving the zoom code in to its own function, and creating a new function to handle our movement code.

We also need to add some configuration fields to help us control the speed of the camera’s movement:

[SerializeField]
private float tileWidth = 32.0f;

[SerializeField]
private float tilesPerSecond = 8.0f;

There are lots of ways we could do this, but I find one of the most intuitive is to define the camera’s movement speed as a number of tiles per second. To do this we need to know the width of the tiles, and how many tiles we want to move by each second.

With the required information now available to us, getting the camera moving is actually pretty straight forward:

private void HandleMovementInput()
{
    float cameraSpeed = this.tileWidth * this.tilesPerSecond;

    if (Input.GetKey(KeyCode.LeftArrow))
    {
        this.transform.Translate(Vector3.left * cameraSpeed * Time.deltaTime);
    }

    if (Input.GetKey(KeyCode.RightArrow))
    {
        this.transform.Translate(Vector3.right * cameraSpeed * Time.deltaTime);
    }

    if (Input.GetKey(KeyCode.UpArrow))
    {
        this.transform.Translate(Vector3.up * cameraSpeed * Time.deltaTime);
    }

    if (Input.GetKey(KeyCode.DownArrow))
    {
        this.transform.Translate(Vector3.down * cameraSpeed * Time.deltaTime);
    }
}

For the purposes of this tutorial, we have bound the movement to the arrow keys, but similar methods could be adapted to mouse/axis based movement, or could also be used for scripted movement (for cut-scenes or transitions).

In effect, all we are doing is moving the camera by [tileWidth * tilesPerSecond] every second. The reason this works is because we have previous changed the camera’s orthographic size to make 1 unit be equal, as close as possible, to 1 pixel.

The need to multiple the movement speed by [Time.deltaTime] is simple because the Update() function, where we are calling our handler functions, is called many times per second. [Time.deltaTime] reflects how much time has passed between Update() calls in fractions of a second.

If you run the game now, you should be able to use the arrow keys to move freely around the tile map. But there is still one problem that we need to solve, and that is that we can move our camera well beyond the edges of the tile map:

Unfortunately, our camera can not directly figure out what the boundaries of the tile map are. It has no knowledge of the tile map. We will have to calculate these boundaries within our BlockMesher script, but before we do that, we will need to add some fields to our TileCamera script to allow the boundaries to be passed through:

public float movementBoundaryTop = 0.0f;
public float movementBoundaryBottom = 0.0f;
public float movementBoundaryLeft = 0.0f;
public float movementBoundaryRight = 0.0f;

Now that we have our fields in place, let’s open up our BlockMesher script and actually set them:

void Start()
{
    this.GenerateTileMap();
    this.GenerateMesh();

    // Reposition to match the desired origin point (original top left of screen).

    TileCamera tileCamera = Camera.main.GetComponent();

    // Calculate movement boundaries for the tile camera.

    this.transform.position = new Vector3(tileCamera.worldOrigin.x, tileCamera.worldOrigin.y, 0.0f);

    tileCamera.movementBoundaryTop = tileCamera.worldOrigin.y;
    tileCamera.movementBoundaryBottom = tileCamera.worldOrigin.y - (mesh.bounds.size.y * this.transform.localScale.y);
    tileCamera.movementBoundaryLeft = tileCamera.worldOrigin.x;
    tileCamera.movementBoundaryRight = tileCamera.worldOrigin.x + (mesh.bounds.size.x * this.transform.localScale.x);
}

It is critical that we calculate the boundaries after the mesh has been generated. It is also worth noting that if you regenerate the mesh, you will need to recalculate the boundaries.

Calculating the boundaries is actually pretty straight forward. The top and left boundaries simply correspond to the world origin. The bottom and right boundaries are simply the top and left boundaries plus the width and height of the mesh. But the size of the mesh is given in local scale (1 unit per tile), so we need to multiply the mesh size by the scale of our game object:

(mesh.bounds.size.x * this.transform.localScale.x)

It is worth noting that we are dealing with “world space” here, which is to say we are dealing in Unity’s measurement system.

It is also worth a reminder that in Unity’s measurement system, the coordinates (0,0) are at the bottom of the screen. We have to keep this in mind when calculating boundaries, which is why we subtract (-) the height of the mesh when calculating the bottom boundary:

tileCamera.movementBoundaryBottom = tileCamera.worldOrigin.y - (mesh.bounds.size.y * this.transform.localScale.y);

Now that we have calculated our boundaries, we can return to our TileCamera script and start enforcing those boundaries.

To do this, we first need to know the current “world space” coordinates of the top left, and bottom right of the screen:

private void HandleMovementInput()
{
    float cameraSpeed = this.tileWidth * this.tilesPerSecond;

    Vector3 topLeft = this.gameCamera.ScreenToWorldPoint(new Vector3(0, Screen.height, 0));
    Vector3 bottomRight = this.gameCamera.ScreenToWorldPoint(new Vector3(Screen.width, 0, 0));

    ...
}

With the conversion done, enforcing the boundaries is quite straight forward:

private void HandleMovementInput()
{
    float cameraSpeed = this.tileWidth * this.tilesPerSecond;

    Vector3 topLeft = this.gameCamera.ScreenToWorldPoint(new Vector3(0, Screen.height, 0));
    Vector3 bottomRight = this.gameCamera.ScreenToWorldPoint(new Vector3(Screen.width, 0, 0));

    float top = topLeft.y;
    float bottom = bottomRight.y;
    float left = topLeft.x;
    float right = bottomRight.x;

    if (Input.GetKey(KeyCode.LeftArrow) && left > this.movementBoundaryLeft)
    {
        this.transform.Translate(Vector3.left * cameraSpeed * Time.deltaTime);
    }

    if (Input.GetKey(KeyCode.RightArrow) && right < this.movementBoundaryRight)
    {
        this.transform.Translate(Vector3.right * cameraSpeed * Time.deltaTime);
    }

    if (Input.GetKey(KeyCode.UpArrow) && top < this.movementBoundaryTop)
    { 
        this.transform.Translate(Vector3.up * cameraSpeed * Time.deltaTime);
    }
    
    if (Input.GetKey(KeyCode.DownArrow) && bottom > this.movementBoundaryBottom)
    {
        this.transform.Translate(Vector3.down * cameraSpeed * Time.deltaTime);
    }
}

When you run the game now, you should be prevented from moving too far beyond the boundaries of the tile map.

It is worth noting that this solution is not pixel perfect. You may occasionally move one or two pixels beyond the edge. This is not difficult to resolve, but it does start to enter the realm of a stylistic choice, mostly because of how zooming works.

If you zoom right in, move as far right as you can, and then zoom right out again… to maintain the position / focus of the camera, the empty space beyond the tile map must become visible. The only alternative is to force the camera back to the edge, which would break its focus.

If you would like to correct this, and always force the camera to be within the boundaries of the tile map, the quickest solution would be to implement something like this:

private void HandleInputs()
{
    this.HandleZoomInput();
    this.HandleMovementInput();
    this.CorrectPosition();
}

public void CorrectPosition()
{
    Vector3 topLeft = this.gameCamera.ScreenToWorldPoint(new Vector3(0, Screen.height, 0));
    Vector3 bottomRight = this.gameCamera.ScreenToWorldPoint(new Vector3(Screen.width, 0, 0));

    float top = topLeft.y;
    float bottom = bottomRight.y;
    float left = topLeft.x;
    float right = bottomRight.x;

    if (left < this.movementBoundaryLeft) { this.transform.Translate(Vector3.right * Mathf.Abs(left - this.movementBoundaryLeft)); } if (right > this.movementBoundaryRight)
    {
        this.transform.Translate(Vector3.left * Mathf.Abs(right - this.movementBoundaryRight));
    }

    if (top > this.movementBoundaryTop)
    {
        this.transform.Translate(Vector3.down * Mathf.Abs(top - this.movementBoundaryTop));
    }

    if (bottom < this.movementBoundaryBottom)
    {
        this.transform.Translate(Vector3.up * Mathf.Abs(bottom - this.movementBoundaryBottom));
    }
}

A small amount of the edge might bleed in during the zoom animation, but beyond this the camera should always be restricted to the boundaries of the tile map.

Dealing with Transparent Tiles

Not all tile mapping implementations will need to deal with transparent tiles, so if yours does not, feel free to skip this section. But transparent tiles can be useful, particularly when you get in to layering of tiles.

The quickest way to implement transparent tiles would be to simply use a transparent texture, but this would be somewhat wasteful. Why waste the graphics processor’s time dealing with tiles that no one can see?

Instead, we should simply not call the ConstructTile() function for tiles we want to be transparent.

Using our simple tile map generator, let’s change it so that all even tiles are set to 255:

private void GenerateTileMap()
{
    this.tileMap = new byte[99, 99];

    for (int ty = 0; ty < this.tileMap.GetLength(1); ty++)
    {
        for (int tx = 0; tx < this.tileMap.GetLength(0); tx++)
        {
            int tileIndex = tx + (ty * this.tileMap.GetLength(0));
            bool isEven = (tileIndex % 2 == 0);

            this.tileMap[tx, ty] = (byte)((isEven)? 255: 2);
        }
    }
}

And now let’s update our GenerateMesh() function so that we don’t call the ConstructTile() function if we detect a tile is set to 255:

private void GenerateMesh()
{
    for (int ty = 0; ty < this.tileMap.GetLength(1); ty++)
    {
        for (int tx = 0; tx < this.tileMap.GetLength(0); tx++)
        {
            int tileValue = (int)tileMap[tx, ty];

            if (tileValue != 255)
            {
                this.ConstructTile(tx, -ty, tileValue);
            }
        }
    }

    ...
}

Simple right? Well not quite.

The problem is that if you run your game now, you are going to get an error. This is because Unity (and most graphics engines) are very particular about the instructions you give them. The list of triangles needs to neatly line up with the list of vertices.

If we are simply not calling the ConstructTile() function for some tiles, our lists are going to very rapidly get out of sync. This is because we are using the tile index to help us calculate the index of vertices.

Instead, we need to change our code so that we have a tile counter that we increment every time ConstructTile() is called:

private int tileCount = 0;

private void ConstructTile(int tx, int ty, int texture)
{
    ...

    int i0 = (tileCount * 4);
    int i1 = (tileCount * 4) + 1;
    int i2 = (tileCount * 4) + 2;
    int i3 = (tileCount * 4) + 3;

    ...

    tileCount++;
}

We also need to make sure to update our GenerateMesh() function to reset the tile count each time we finish with a mesh:

private void GenerateMesh()
{
    ...

    vertices.Clear();
    triangles.Clear();
    uvCoordinates.Clear();

    tileCount = 0;
}

And that should be it. Run your game now, and every even tile should now be transparent (you should be able to see the background colour in its place).

Layering Tile Blocks

Layering is a useful technique to use in conjunction with tile maps. It allows you to create a background (the ground, for example), and then layer other elements on top of it (trees, rocks, etc.).

And it is fairly easy to accomplish as well. All we have to do is change the Z position of our object depending on what layer we want it to be. The higher up the stack we want our tiles to be, the closer to the screen we want the tiles to be:

[SerializeField]
private int order = 0;

void Start()
{
    ...

    // Calculate movement boundaries for the tile camera.

    this.transform.position = new Vector3(tileCamera.worldOrigin.x, tileCamera.worldOrigin.y, -(0.1f * this.order));

    ...

}

Remember that items that are closer to the screen will have a negative Z position.

Because we are using orthographic projection, we don’t even have to adjust for the new Z values. It should simply work.

Our new [order] field works like this: 0 is the default (bottom) layer. Values above zero (1, 2, 3, etc) will move the tiles closer to the screen.

The only problem is that we need different tiles on each layer. This is where the simplicity of our tile map representation breaks down. In a more realistic situation, we would be passing the tile map in to our BlockMesher script, but there are so many different ways to represent a tile map that it is just not worth simulating that here.

Instead, let’s just alter our GenerateTileMap() function so that it creates a solid grass plane for the default layer (0), and our alternating pattern of dirt and transparency for any other layer:

private void GenerateTileMap()
{
    this.tileMap = new byte[99, 99];

    for (int ty = 0; ty < this.tileMap.GetLength(1); ty++)
    {
        for (int tx = 0; tx < this.tileMap.GetLength(0); tx++) { int tileIndex = tx + (ty * this.tileMap.GetLength(0)); bool isEven = (tileIndex % 2 == 0); if (this.order > 0)
            {
                this.tileMap[tx, ty] = (byte)((isEven) ? 255 : 2);
            }
            else
            {
                this.tileMap[tx, ty] = (byte)1;
            }
        }
    }
}

Once this change is made, all we need to do is duplicate our TileBlock game object and changes the order of the duplicate to 1:

Once this is done, run you game and you should have an alternating field of grass and dirt.

Not very impressive in this context, but it does demonstrate that layering is possible. With more advanced textures, and clever application of layering, this can be very useful. Having a character disappear behind a tree but remain above the grass, for example.

Finding Clicked Tiles

Another very useful utility when working with tile maps is the ability to translate between mouse coordinates and tile coordinates. The ability to find out which tile the player clicked on.

Because we have drawn each tile exactly 1 unit wide in Unity’s measurement system, doing this translation is actually extremely easy.

The first step is to edit our BlockMesher script so that it can intercept mouse clicks:

void Update()
{
    if (Input.GetButtonDown("Fire1"))
    {
        Vector3 mousePos = Camera.main.ScreenToWorldPoint(Input.mousePosition);

    }
}

Here we are using the “Fire1” key code, which by default in Unity is the key code that the left mouse button is bound too.

Once we know the mouse has been clicked, and we have the position of the mouse, the conversion is very simple thanks to the Unity utility function InverseTransformPoint():

void Update()
{
    if (Input.GetButtonDown("Fire1"))
    {
        Vector3 mousePos = Camera.main.ScreenToWorldPoint(Input.mousePosition);

        Vector3 localPos = this.transform.InverseTransformPoint(mousePos);

        float tx = Mathf.FloorToInt(Mathf.Abs(localPos.x));
        float ty = Mathf.FloorToInt(Mathf.Abs(localPos.y));


    }
}

The InverseTransformPoint() function converts world coordinates in to the local coordinates of the mesh, and because we built each tile to be exactly 1 unit wide and tall within the mesh, that is all we need to know to figure out what tile we are on!

So what can we do with this information? Well why don’t we update the tile we clicked on. Change it to be our dark stone texture for example:

void Start()
{
    this.GenerateTileMap();
    this.UpdateModel();
}

void Update()
{
    if (Input.GetButtonDown("Fire1"))
    {
        Vector3 mousePos = Camera.main.ScreenToWorldPoint(Input.mousePosition);

        Vector3 localPos = this.transform.InverseTransformPoint(mousePos);

        int tx = Mathf.FloorToInt(Mathf.Abs(localPos.x));
        int ty = Mathf.FloorToInt(Mathf.Abs(localPos.y));

        this.tileMap[tx, ty] = (byte)3;

        this.UpdateModel();
    }
}

private void UpdateModel()
{
    this.GenerateMesh();

    // Reposition to match the desired origin point (original top left of screen).

    TileCamera tileCamera = Camera.main.GetComponent();

    // Calculate movement boundaries for the tile camera.

    this.transform.position = new Vector3(tileCamera.worldOrigin.x, tileCamera.worldOrigin.y, -(0.1f * this.order));

    tileCamera.movementBoundaryTop = tileCamera.worldOrigin.y;
    tileCamera.movementBoundaryBottom = tileCamera.worldOrigin.y - (mesh.bounds.size.y * this.transform.localScale.y);
    tileCamera.movementBoundaryLeft = tileCamera.worldOrigin.x;
    tileCamera.movementBoundaryRight = tileCamera.worldOrigin.x + (mesh.bounds.size.x * this.transform.localScale.x);
}

Because we need to regenerate the mesh after updating the tile map, and perform other tasks like recalculating boundaries, the first thing we needed to do was refactor out this code from the Start() function in to something we could call at any point. So we created the UpdateModel() function.

Beyond this, it was simple. Use the coordinates we calculated to change the value in the tile map, and then call UpdateModel() to regenerate the mesh.

There is a flaw with this implementation, however, which is that it is not correctly handling layering. Instead the tile update is being applied to all layers.

This is not a difficult problem to solve, but because the way we represent the data of our tile map is so simplistic (for the purposes of this tutorial), I will leave this as an exercise for the reader.

Another reason the handling of layers must be left to the reader is that the behaviour will be extremely dependant on the type of game you are building. If tile you clicked on is empty in the highest layer, should it be filled in? Or should the value of the next layer down be change?

This is why it is difficult to create a “generic” way of representing the data of a tile map, and why we stuck to something so simple. It is very circumstantial.

Ideas for Further Optimisation

The tile maps we have dealt with in this tutorial have been 100 x 100 (10,000 tiles), or close to it. With 4 vertices and 2 triangles per tile, that is 20,000 triangles and 40,000 vertices.

Normally such a large number of triangles / vertices on a single object would be entering dangerous territory, but because of the simplicity of our calls, and the highly batch-able nature of our materials, most computers will handle our tile meshes without breaking a sweat.

And yet…

Where possible we should always try to reduce the number of triangles / vertices the graphics processor has to deal with. Particularly if we are dealing with less powerful devices, like mobile phones.

The ideal way to optimise our tile meshes would be to cull unnecessary vertices. There are 4 vertices per tile, but we know the tiles share vertices (share the same point in space) with their neighbours. Structurally, we could very easily reduce the number of vertices to barely more than the number of tiles.

10,000 tiles could (structurally) be represented by around 10,200 vertices. That is a lot less than 40,000.

The problem is that textures complicate things. The list of UV coordinates has to line up with the list of vertices. If every tile used a different texture, this type of optimisation would be doomed. But even when many tiles share the same textures, it still greatly complicates things.

You need to figure out which tiles share textures, then you need to figure out which ones are neighbours (and can therefore be optimised), and so on and so forth.

If you are targeting older devices, or devices with severe performance restrictions, this type of optimisation is worth while. But if you are targeting relatively modern machines, it might not be worth while, particularly when you consider most of the tiles will be off screen at any given time.

(Note: if you are using completely static tile maps, it may be worth to initial computational overhear to calculate the optimisations regardless of your target platforms, as you only need to do it once.)

A more realistic next step for optimisations would be for us to break our tile map up in to smaller blocks.

While graphics engines are very good at culling vertices that can not be seen, it is always better to not force them to deal with the vertices in the first place.

If we divided our tile map up in to smaller (say 20×20) blocks instead, and only ever brought blocks in to play when they were needed, we could dramatically reduce the number of triangles / vertices we were sending to the graphics card.

If you are having performance issues with your tile map, I would recommend breaking your tile map up in to smaller blocks as a first step.

You may also like...

Leave a Reply

Your email address will not be published. Required fields are marked *