Unity 2.5D Tilemap Depth

Hello, Gahwon here. Here’s another Unity tutorial!

Have you ever tried to make a 2.5D world, only to realize that your trees are rendering behind the player all the time? Well, fear not!

Although many sources exist on creating this feature using sprites, this tutorial instead uses the new Unity Tilemap to apply depth.

For those who want the whole source code, here it is.

https://github.com/creativitRy/TilemapHeightTest

Feel free to use this anywhere (probably not the sprites since I drew them and I’m not an artist).

Here’s a TL;DR version of the system: Each tile contains a height property. This height is compared with the player’s height every frame to see if the tile should overlap or not. Overlapping tiles are moved to a new Tilemap layer that is always rendered in front of the player. This layer is cleared at the start of every frame.

Keeping a Height Property for Each Sprite

This is done outside of Unity’s tilemap system. I stored all heights in a ScriptableObject like so:

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

namespace TilemapHeightTest
{
	[CreateAssetMenu(menuName = "Tile Depth Group")]
	public class TileHeightGroup : ScriptableObject
	{
		public List Tiles;

		[Serializable]
		public class TileHeight
		{
			public Sprite Sprite;
			public float Height;

			public TileHeight(Sprite sprite, float height)
			{
				Sprite = sprite;
				Height = height;
			}
		}
	}
}

Each TileHeightGroup has a list of TileHeights because I thought it would be easier to keep them grouped together instead of having one ScriptableObject per tile. This also allows you to separate a tree’s height values with a house’s height values for instance.

Loading the Height Properties

I have a quick and dirty singleton (a type that has only 1 instantiated object) like so:

using UnityEngine;

namespace TilemapHeightTest
{
	public class TileHeightManager : MonoBehaviour
	{
		public static TileHeightManager Instance { get; set; }

		private void Awake()
		{
			Instance = this;
		}
	}
}

Then, in the awake method, I add all tile heights into a Dictionary.

public List TileHeightGroups;
private readonly Dictionary _tileHeights = new Dictionary();

private void Awake()
{
	Instance = this;
	
	if (TileHeightGroups != null)
	{
		foreach (var group in TileHeightGroups)
		{
			foreach (var tile in group.Tiles)
			{
				_tileHeights.Add(tile.Sprite, tile.Height);
			}
		}
	}
}

Dictionaries allow O(1) lookup, which means that you don’t need a loop to find if a Sprite exists in the Dictionary or not.

Player Movement

The player has to report to the TileHeightManager where it is currently located. This can be done in the update method.

private void Update()
{
	// movement code
	var horiz = Input.GetAxis("Horizontal");
	var vert = Input.GetAxis("Vertical");

	_body.velocity = new Vector2(horiz, vert) * MoveSpeed;

	// report position
	TileHeightManager.Instance.ReportPosition(transform.position, _sprite.sprite.bounds);
}

The TileHeightManager receives the information in this method here:

public void ReportPosition(Vector3 position, Bounds bounds)
{
	
}

Looping through All Tiles to Check

While players have floating point positions, tiles have integer positions. Therefore, we floor the sprite bounds to convert to an int. If a tile is located at (0, 0), any floating point position within the square of side length 1 centered at (0.5, 0.5) will round down to (0, 0).

Now we simply loop through all tiles in this area.

public Tilemap Tilemap;
public Tilemap Overlay;

public void ReportPosition(Vector3 position, Bounds bounds)
{
	// project bounds to world position
	bounds.center += position;
	var playerDepth = bounds.min.y;
	
	// for all affected tiles
	var min = Vector2Int.FloorToInt(bounds.min);
	var max = Vector2Int.FloorToInt(bounds.max);
	for (var y = min.y; y <= max.y; y++)
	{
		for (var x = min.x; x <= max.x; x++)
		{
			
			// inner code
			
		}
	}
}

Tilemap and Overlay will be used to actually handle the depth system. Tilemap contains all depth-shifting tiles, while Overlay is an empty tilemap. Compared to the player's sort order, Tilemap has a SMALLER sort order while Overlay has a LARGER sort order.

Comparing the heights

Here’s the code inside the double for loop: (edit: some code here are not being displayed properly. Until I fix this issue, please refer to this page for the code)

for (var y = min.y; y <= max.y; y++)
{
	var playerLocalHeight = y - playerDepth;
	for (var x = min.x; x  playerLocalHeight)
		{
			var pos = new Vector3Int(x, y, 0);
			Overlay.SetTile(pos, Tilemap.GetTile(pos));
		}
		
	}
}

Some positions do not even have tiles, so make sure to skip all nulls.

The tile height might not be in the loaded tile heights, so I set the default height to 0. If the tile height is taller than the player's local height, then I move the tile to the Overlay Tilemap.

Resetting the Overlay Tilemap

The goal is to store all moved tiles so we can move them back later. I used a HashSet because it allows O(1) add, access, and removal.

Let’s modify the previous code to record which tiles were moved to Overlay.

if (tileHeight > playerLocalHeight)
{
	var pos = new Vector3Int(x, y, 0);
	_returnBack.Add(pos);
	Overlay.SetTile(pos, Tilemap.GetTile(pos));
}

Now, we can add these lines at the top of the method.

private readonly HashSet _returnBack = new HashSet();

public void ReportPosition(Vector3 position, Bounds bounds)
{
	// clear previous overlay tiles
	foreach (var pos in _returnBack)
	{
		Overlay.SetTile(pos, null);
	}
	_returnBack.Clear();

	// stuff
}

With that out of the way, here’s the final code for the TileHeightManager: (edit: some code here are not being displayed properly. Until I fix this issue, please refer to this page for the code)

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

namespace TilemapHeightTest
{
	public class TileHeightManager : MonoBehaviour
	{
		public static TileHeightManager Instance { get; set; }

		public List TileHeightGroups;
		private readonly Dictionary _tileHeights = new Dictionary();
		private readonly HashSet _returnBack = new HashSet();

		public Tilemap Tilemap;
		public Tilemap Overlay;

		private void Awake()
		{
			Instance = this;
			
			if (TileHeightGroups != null)
			{
				foreach (var group in TileHeightGroups)
				{
					foreach (var tile in group.Tiles)
					{
						_tileHeights.Add(tile.Sprite, tile.Height);
					}
				}
			}
		}

		public void ReportPosition(Vector3 position, Bounds bounds)
		{
			// clear previous overlay tiles
			foreach (var pos in _returnBack)
			{
				Overlay.SetTile(pos, null);
			}
			_returnBack.Clear();

			// project bounds to world position
			bounds.center += position;
			var playerDepth = bounds.min.y;
			
			// for all affected tiles
			var min = Vector2Int.FloorToInt(bounds.min);
			var max = Vector2Int.FloorToInt(bounds.max);
			for (var y = min.y; y <= max.y; y++)
			{
				var playerLocalHeight = y - playerDepth;
				for (var x = min.x; x  playerLocalHeight)
					{
						var pos = new Vector3Int(x, y, 0);
						_returnBack.Add(pos);
						Overlay.SetTile(pos, Tilemap.GetTile(pos));
					}
					
				}
			}
		}
	}
}

 

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s