fbpx

Procedural Minimap

Goal:

Create a minimap that will generate the map content at runtime based on the assets in the scene. The minimap will have a stationary player icon that rotates to indicate the player’s forward direction, procedurally generated roads and infrastructure, and an arrow to show the objective direction.

Prerequisites:

Radical Relocation was created in Unity. At the time of writing, we were using version 2018.3.8f1.

Scripting runtime version is .NET 4x Equivilent and API Compatibility Level is .NET Standard 2.0.

Rotation:

The first step is to make the player icon rotate to show the forward direction. I start by taking the y-axis rotation of the player car and copying that into player icon. Since the game is isometric, I add an offset of 45 degrees to match top-down appearance of the map.

[SerializeField] private RectTransform m_PlayerIcon;
[SerializeField] private Transform m_PlayerCar;
[SerializeField] private float m_RotationOffset = 45f;

private void Update()
{
    // Player icon rotation matches the player car
    m_PlayerIcon.eulerAngles = new Vector3(0f, 0f, -m_PlayerCar.eulerAngles.y + m_RotationOffset);
}

Background Movement:

Since the player icon is stationary in the center, the background will need to move to depict movement. This will happen in the opposite direction to the player.

[SerializeField] private Transform m_PlayerCar;
[SerializeField] private RectTransform m_Background;

private void Update()
{
    // Background moves opposite to car
    Vector3 playerDisplacementFromOrigin = -m_Player.position;
    Vector3 rotatedDisplacement = Quaternion.AngleAxis(-45f, Vector3.up) * playerDisplacementFromOrigin;
    m_Background.anchoredPosition = new Vector2(rotatedDisplacement.x, rotatedDisplacement.z);
}

Scale - Relative To World Bounds:

The next step is to scale the background and the movement based on the bounds of the scene. I will then normalize the movement to make it easier. In the video to the right, notice how the arrow approaches the corners of the grid as the car approaches the corners of the roads.

[SerializeField] private string m_RoadTag;
[SerializeField] private float m_Zoom = 1.0f;
[SerializeField] private float m_Margin = 20.0f;
private Bounds m_Bounds;

private void Start()
{
    m_Bounds = CalculateWorldBounds();
    
    // Scale background to the same aspect ratio as the bounds.
    // x2 for half extents -> full size
    m_Background.sizeDelta = new Vector2(m_Bounds.extents * m_Zoom + m_Margin, m_Bounds.extents.z * m_Zoom + m_Margin) * 2f;
}

private void Update()
{
    // Player icon rotation matches vehicle
    m_PlayerIcon.eulerAngles = new Vector3(0f, 0f, -m_PlayerCar.eulerAngles.y + m_RotationOffset);
    
    SetPosition(GetNormalizedPlayerPosition());
}
    
/// <summary>
/// Calculates the bounds of all roads in the scene.
/// </summary>
private Bounds CalcualteWorldBounds()
{
    // Find all roads.
    GameObject[] roads = GameObject.FindGameObjectsWithTag(m_RoadTag);
    
    // Start from the first road.
    // This ensures the bounds center is contained in the bounds itself.
    Bounds bounds = new Bounds(roads[0].transform.position, Vector3.zero);
    for(int i = 0; i < roads.Length; i++)
    {
        // Calculate the bounds of these roads.
        bounds.Encapsulate(roads[i].transform.position);
    }
    
    return bounds;
}

/// <summary>
/// Returns the player position from -1, 1 on X and Z axes.
/// </summary>
private Vector2 GetNormalizedPlayerPosition()
{
    Vector3 position = m_PlayerCar.position - m_Bounds.center;
    
    // Make sure the extents are never smaller than the width of a road.
    float extentsX = Mathf.Max(m_Bounds.extents.x, 10f);
    float extentsZ = Mathf.Max(m_Bounds.extents.z, 10f);
    
    return new Vector2(position.x / extentsX, position.z / extentsZ);
}

/// <summary>
/// Sets the position of the background.
/// </summary>
/// <param name="position">Must be normalized.</param>
private void SetPosition(Vector2 position)
{
    // Divide by 2 for half extents
    float backgroundX = m_Bounds.extents.x * m_Zoom;
    float backgroundY = m_Bounds.extents.z * m_Zoom;
    
    // Make sure it's not smaller than the width of a road.
    backgroundX = Mathf.Max(backgroundX, 10f);
    backgroundY = Mathf.Max(backgroundY, 10f);
    
    // Background moves opposite to car
    Vector3 playerDisplacementFromOrigin = new Vector3(-position.x * backgroundX, 0f, -position.y * backgroundY);
    Vector3 rotatedDisplacement = Quaternion.AngleAxis(-45f, Vector3.up) * playerDisplacementFromOrigin;
    
    m_Background.anchoredPosition = new Vector2(rotatedDisplacement.x, rotatedDisplacement.z);
}

Spawning Roads

Roads will be created with a grid of image components. Corners and end points will have an alpha map applied, everything else will be a solid square. I loop through every road and spawn it as a child element of the background image.

private void SpawnRoads()
{
    // Find all the roads.
    GameObject[] roads = GameObject.FindGameObjectsWithTag(m_RoadTag);
    
    for (int i = 0; i < roads.Length; i++)
    {
        // Create a new road in the map.
        GameObject road = new GameObject($"Road {i.ToString("00")}");
        road.transform.SetParent(m_Background);
        
        // RectTransform for UI elements.
        RectTransform rect = road.AddComponent<RectTransform>();
        
        // Add the image.
        Image image = road.AddComponent<Image>();
        
        // Find the bounds of this mesh
        Bounds bounds = GetMeshExtents(elements[i].transform);
        
        // Position the road image.
        rect.anchoredPosition = WorldToMapPoint(bounds.center);
        
        // Rotate the image.
        rect.localEulerAngles = new Vector3(0f, 0f, roads[i].transform.eulerAngles.y);
        
        // Scale the image.
        rect.localScale = Vector3.one;
        rect.sizeDelta = WorldToMapDimensions(bounds.extents);
    }
}

private Vector2 WorldToMapPoint(Vector3 worldPosition)
{
    Vector3 positionRelativeToBoundsCenter = worldPosition - m_Bounds.center;
    return new Vector2(positionRelativeToBoundsCenter.x, positionRelativeToBoundsCenter.z) * m_Zoom;
}

private Vector2 WorldToMapDimensions(Vector3 meshExtents)
{
    // x2 for half extents -> full size
    return new Vector2(meshExtents.x, meshExtents.z) * m_Zoom * 2f;
}

/// <summary>
/// Get the mesh extents of the object including all child objects.
/// </summary>
private Bounds GetMeshExtents(Transform target)
{
    // Look for all children.
    Transform[] children = target.GetComponentsInChildre<Transform>();
    
    // Find all meshes in the children
    List<MeshRenderer> meshes = new List<MeshRenderer>();
    for (int i = 0; i < children.Length; i++)
    {
        MeshRenderer mesh = children[i].GetComponent<MeshRenderer>();
        if (mesh) meshes.Add(mesh);
    }
    
    // No meshes found - this object is empty.
    if (meshes.Count == 0) return default;
    
    // Initialize with the first mesh.
    Bounds bounds = meshes[0].bounds;
    
    // Get the bounds of all meshes.
    for (int i = 0; i < meshes.Count; i++)
    {
        bounds.Encapsulate(meshes[i].bounds);
    }
    
    return bounds;
}

Refactoring

Next, I refactor the road spawning to allow spawning of any mesh in the scene. To do this, I create a list of tags and their corresponding sprites. To make life easier, I use Naughty Attributes. This allows me to display the array as a reorderable list in the inspector. Notice in the video to the right how there are trees, houses and corners. To add a new mesh in the minimap, I simply add a tag, and a sprite with an alpha map.

[System.Serializable]
protected struct MiniMapElement
{
    public string tag;
    public Sprite sprite;
}

[SerializeField, ReorderableList] protected MiniMapElement[] m_MiniMapElements;

private void Start()
{
    for (int i = 0; i < m_MiniMapElements.Length; i++)
    {
        SpawnElements(m_MiniMapElements[i].tag, m_MiniMapElements[i].sprite, m_MiniMapElements[i].color);
    }
}

private void SpawnElements(string tag, Sprite sprite, Color color)
{
    // Find all the elements.
    GameObject[] elements = GameObject.FindGameObjectsWithTag(tag);

    for (int i = 0; i < elements.Length; i++)
    {
        // Create a new element in the map.
        GameObject element = new GameObject($"{tag}-{i.ToString("00")}");
        element.transform.SetParent(m_Background);

        // Add the image.
        RectTransform rect = element.AddComponent<RectTransform>();
        Image image = element.AddComponent<Image>();

        image.sprite = sprite;
        
        // Find the bounds of this mesh
        Bounds bounds = GetMeshExtents(elements[i].transform);

        // Position the image.
        rect.anchoredPosition = WorldToMapPoint(bounds.center);

        // Rotate the image.
        float angle = Mathf.Repeat(elements[i].transform.rotation.eulerAngles.y, 360f);

        // Fix a weird issue where objects with euler angles (0, 90, 0) or (0, 270, 0) would
        // be flipped by 180 degrees. Not sure what causes this.
        const float THRESHOLD = 2f;
        if (Mathf.Abs(angle - 90f) < THRESHOLD || Mathf.Abs(angle - 270f) < THRESHOLD) angle += 180f;

        rect.localRotation = Quaternion.Euler(0f, 0f, angle);

        // Set scale
        rect.localScale = Vector3.one;
        rect.sizeDelta = WorldToMapDimensions(bounds.extents);
    }
}

Colors

I now add an extra variable to the MiniMapElement struct: color. I can then modify the color of any element which allows me to draw more or less attention to certain parts. For example, trees are not so important, so I will make them closer in color to the background. At this point, I also remove the grid texture on the background.

[System.Serializable]
protected struct MiniMapElement
{
    public string tag;
    public Sprite sprite;
    public Color color;
}

Objectives

The last part is the objectives marker. When it is within border of the minimap it will display as a circle. When it is beyond the border of the minimap, it will display on the edge of the minimap. Notice the small red arrow that turns into a circle as you approach the objective.

[SerializeField, Required] protected Transform m_ObjectiveLocation;
[SerializeField, Required] protected Image m_ObjectiveIcon;
[SerializeField] protected Sprite m_ObjectiveIconInMinimap;
[SerializeField, Required] protected Sprite m_ObjectiveIconOnEdge;
[SerializeField] protected float m_ObjectiveMaxRadius;

private void SetObjectivePosition()
{
    Vector3 positionRelativeToPlayer = m_ObjectiveLocation.position - m_PlayerCar.position;
    Vector2 mapSpacePosition = new Vector2(positionRelativeToPlayer.x, positionRelativeToPlayer.z) * m_Zoom;
    Vector2 rotatedPosition = Quaternion.AngleAxis(m_RotationOffset, Vector3.forward) * mapSpacePosition;

    // Change the icon when the objective is on the edge.
    if (rotatedPosition.magnitude > m_ObjectiveMaxRadius) m_ObjectiveIcon.sprite = m_ObjectiveIconOnEdge;
    else m_ObjectiveIcon.sprite = m_ObjectiveIconInMinimap;

    // Rotate towards the center
    float angle = Quaternion.FromToRotation(Vector3.right, -rotatedPosition).eulerAngles.z + 90f;
    m_ObjectiveIcon.rectTransform.eulerAngles = new Vector3(0f, 0f, angle);

    // Restrict it to the edge of the minimap.
    rotatedPosition = Vector2.ClampMagnitude(rotatedPosition, m_ObjectiveMaxRadius);

    m_ObjectiveIcon.rectTransform.anchoredPosition = rotatedPosition;
}

Wrapping it Up!

The last part is the objectives marker. When it is within border of the minimap it will display as a circle. When it is beyond the border of the minimap, it will display on the edge of the minimap. Notice the small red arrow that turns into a circle as you approach the objective.

// (C) Matthew Inglis 2019
    
using NaughtyAttributes;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
    
public class MiniMap : MonoBehaviour
{
    #region ----CONFIG----
    [SerializeField, Required] protected RectTransform m_PlayerIcon;
    [SerializeField, Required] protected Transform m_PlayerCar;
    [SerializeField] protected float m_RotationOffset;

    [Space]
    [SerializeField, Required] protected RectTransform m_Background;

    [Space]
    [SerializeField] protected string m_RoadTag;

    [Space]
    [SerializeField] protected float m_Zoom = 1f;
    [SerializeField] protected float m_Margin = 20f;

    [SerializeField, ReorderableList] protected MiniMapElement[] m_MiniMapElements;

    [SerializeField, Required] protected Transform m_ObjectiveLocation;
    [SerializeField, Required] protected Image m_ObjectiveIcon;
    [SerializeField] protected Sprite m_ObjectiveIconInMinimap;
    [SerializeField, Required] protected Sprite m_ObjectiveIconOnEdge;
    [SerializeField] protected float m_ObjectiveMaxRadius;

    [System.Serializable]
    protected struct MiniMapElement
    {
        public string tag;
        public Sprite sprite;
        public Color color;
    }
    #endregion

    #region ----STATE----
    private Bounds m_Bounds;
    #endregion

    private void Start()
    {
        m_Bounds = CalculateWorldBounds();

        // Scale background to the same aspect ratio as the bounds.
        // Times 2 to go from half extents to size
        m_Background.sizeDelta = new Vector2(m_Bounds.extents.x * m_Zoom + m_Margin, m_Bounds.extents.z * m_Zoom + m_Margin) * 2f;

        for (int i = 0; i < m_MiniMapElements.Length; i++)
        {
            SpawnElements(m_MiniMapElements[i].tag, m_MiniMapElements[i].sprite, m_MiniMapElements[i].color);
        }
    }

    private void Update()
    {
        // Player icon rotation matches vehicle
        m_PlayerIcon.eulerAngles = new Vector3(0f, 0f, -m_PlayerCar.eulerAngles.y + m_RotationOffset);

        SetMapPosition(GetNormalizedPlayerPosition());
        SetObjectivePosition();
    }

    /// <summary>
    /// Calculates the bounds of all roads in the scene.
    /// </summary>
    private Bounds CalculateWorldBounds()
    {
        // TODO: include all elements

        // Find all roads
        GameObject[] roads = GameObject.FindGameObjectsWithTag(m_RoadTag);

        // Start from the first road.
        // This ensures the bounds center is contained in the bounds itself.
        Bounds bounds = new Bounds(roads[0].transform.position, Vector3.zero);
        for (int i = 0; i < roads.Length; i++)
        {
            // Calculate the bounds of these roads
            bounds.Encapsulate(roads[i].transform.position);
        }

        return bounds;
    }

    private void SpawnElements(string tag, Sprite sprite, Color color)
    {
        // Find all the elements.
        GameObject[] elements = GameObject.FindGameObjectsWithTag(tag);

        for (int i = 0; i < elements.Length; i++)
        {
            // Create a new element in the map.
            GameObject element = new GameObject($"{tag}-{i.ToString("00")}");
            element.transform.SetParent(m_Background);

            // Add the image.
            RectTransform rect = element.AddComponent<RectTransform>();
            Image image = element.AddComponent<Image>();

            image.color = color;
            image.sprite = sprite;

            // Find the bounds of this mesh
            Bounds bounds = GetMeshExtents(elements[i].transform);

            // Position the image.
            rect.anchoredPosition = WorldToMapPoint(bounds.center);

            // Rotate the image.
            float angle = Mathf.Repeat(elements[i].transform.rotation.eulerAngles.y, 360f);

            // Fix a weird issue where objects with euler angles (0, 90, 0) or (0, 270, 0) would
            // be flipped by 180 degrees. Not sure what causes this.
            const float THRESHOLD = 2f;
            if (Mathf.Abs(angle - 90f) < THRESHOLD || Mathf.Abs(angle - 270f) < THRESHOLD) angle += 180f;

            rect.localRotation = Quaternion.Euler(0f, 0f, angle);

            // Set scale
            rect.localScale = Vector3.one;
            rect.sizeDelta = WorldToMapDimensions(bounds.extents);
        }
    }

    /// <summary>
    /// Returns the player position from -1, 1 on X and Z axes.
    /// </summary>
    private Vector2 GetNormalizedPlayerPosition()
    {
        Vector3 position = m_PlayerCar.position - m_Bounds.center;

        float extentsX = Mathf.Max(m_Bounds.extents.x, 10f);
        float extentsZ = Mathf.Max(m_Bounds.extents.z, 10f);

        return new Vector2(position.x / extentsX, position.z / extentsZ);
    }

    /// <summary>
    /// Sets the position of the background.
    /// </summary>
    /// <param name="position">Must be normalized.</param>
    private void SetMapPosition(Vector2 position)
    {
        // Divide by 2 for half extents
        float backgroundX = m_Bounds.extents.x * m_Zoom;
        float backgroundY = m_Bounds.extents.z * m_Zoom;

        // Make sure it's not smaller than the width of a road.
        backgroundX = Mathf.Max(backgroundX, 10f * m_Zoom);
        backgroundY = Mathf.Max(backgroundY, 10f * m_Zoom);

        // Backgrounds moves opposite to car
        Vector3 playerDisplacementFromOrigin = new Vector3(-position.x * backgroundX, 0f, -position.y * backgroundY);
        Vector3 rotatedDisplacement = Quaternion.AngleAxis(-45f, Vector3.up) * playerDisplacementFromOrigin;

        m_Background.anchoredPosition = new Vector2(rotatedDisplacement.x, rotatedDisplacement.z);
    }

    private void SetObjectivePosition()
    {
        Vector3 positionRelativeToPlayer = m_ObjectiveLocation.position - m_PlayerCar.position;
        Vector2 mapSpacePosition = new Vector2(positionRelativeToPlayer.x, positionRelativeToPlayer.z) * m_Zoom;
        Vector2 rotatedPosition = Quaternion.AngleAxis(m_RotationOffset, Vector3.forward) * mapSpacePosition;

        // Change the icon when the objective is on the edge.
        if (rotatedPosition.magnitude > m_ObjectiveMaxRadius) m_ObjectiveIcon.sprite = m_ObjectiveIconOnEdge;
        else m_ObjectiveIcon.sprite = m_ObjectiveIconInMinimap;

        // Rotate towards the center
        float angle = Quaternion.FromToRotation(Vector3.right, -rotatedPosition).eulerAngles.z + 90f;
        m_ObjectiveIcon.rectTransform.eulerAngles = new Vector3(0f, 0f, angle);

        // Restrict it to the edge of the minimap.
        rotatedPosition = Vector2.ClampMagnitude(rotatedPosition, m_ObjectiveMaxRadius);

        m_ObjectiveIcon.rectTransform.anchoredPosition = rotatedPosition;
    }

    private Vector2 WorldToMapPoint(Vector3 worldPosition)
    {
        Vector3 positionRelativeToBoundsCenter = worldPosition - m_Bounds.center;
        return new Vector2(positionRelativeToBoundsCenter.x, positionRelativeToBoundsCenter.z) * m_Zoom;
    }

    private Vector2 WorldToMapDimensions(Vector3 meshExtents)
    {
        // x2 for half extents -> dimensions
        return new Vector2(meshExtents.x, meshExtents.z) * m_Zoom * 2f;
    }

    /// <summary>
    /// Get the mesh extents of the object including all child objects.
    /// </summary>
    private Bounds GetMeshExtents(Transform target)
    {
        // Look for all children
        Transform[] children = target.GetComponentsInChildren<Transform>();

        // Find all meshes in the children
        List<MeshRenderer> meshes = new List<MeshRenderer>();
        for (int i = 0; i < children.Length; i++)
        {
            MeshRenderer mesh = children[i].GetComponent<MeshRenderer>();
            if (mesh) meshes.Add(mesh);
        }

        // No meshes found - this object is empty.
        if (meshes.Count == 0) return default;

        // Initialize with the first mesh
        Bounds bounds = meshes[0].bounds;

        // Get the bounds of all meshes
        for (int i = 0; i < meshes.Count; i++)
        {
            bounds.Encapsulate(meshes[i].bounds);
        }

        return bounds;
    }
}

Found that interesting?

Join the beta testing Team and see it in action!

Radical relocation car with luggage
Scroll to Top