Unity2D: Creating a Radar Graph

Recently, I had a thought about what it might be like to create a visual representation of progress between different stats on a graph. This was not necessarily out of the blue, but because I needed to do so for a project I’m working on in my spare time.

There are a number of different types of graphs out there that could have achieved this, but I was most interested in the technical challenge of a Radar Graph:

A Radar Graph is a graphical method of displaying multivariate data in the form of a two-dimensional chart of three or more quantitative variables, the magnitude of each variable contributes to the change of the inner shape, in our case, a mesh.

Sure, it’s not the most practical representation of statistics compared to most AAA titles, but it’s a fun UI challenge in Unity, and we’ll learn how to tackle it.

Like in every post, let’s define our acceptance criteria for this project, so that we don’t subject ourselves to “scope creep” in this example, and get too carried away.

Acceptance Criteria

  • Features 4 attributes: Strength, Magic, Defense, and Agility

  • Determines which attribute is the highest

  • Inner graph stays within the bounds of the radar graph

  • There will be no negative value attributes ( no negative numbers )

  • The attribute value range should be 0 - 1. ( e.g. 0% - 100% )

  • The attributes should display in a diamond shaped radar graph

  • When an attribute changes the graph must update automatically

With our scope clearly defined, let’s get started!

Aggregating our Statistics to a Central Manager

Before we can graph anything, we have to be able to manage our statistics. We can facilitate this by creating a class called, StatisticsManager and writing some basic boilerplate:


using System.Collections.Generic;
using UnityEngine;

namespace RadarGraphTutorial
{
    public class StatisticsManager : MonoBehaviour
    {
        // Our statistics value range
        public static int STAT_MIN = 0;
        public static int STAT_MAX = 1;

        // Holds name of the highest ranking statistic
        [SerializeField] string highestStatistic;

        // All supported statistics and their default values
        private Dictionary<string, float> statistics = new Dictionary<string, float>
        {
            [Statistic.Strength.ToString()] = 0.1f,
            [Statistic.Defense.ToString()] = 0.1f,
            [Statistic.Agility.ToString()] = 0.1f,
            [Statistic.Magic.ToString()] = 0.1f,
        };

        // Enum to avoid "magic strings" for our key names
        public enum Statistic
        {
            Strength,
            Defense,
            Agility,
            Magic,
        }
    }
}

We are setting up variables in which we can control the min/max range of a our statistics, view the highest value statistic, and determine the supported statistics themselves.

We keep the range constraints simple with 0 and 1, respectively, to keep the numbers small enough to translate well when we apply it to our radar graph on the Unity Canvas renderer.

The reason to choose a Dictionary<string, float> here is to establish unique keys that represent each statistic more clearly than any integer key would, and it comes with a set of native methods that will be helpful to us further down the line.

Additionally, we make use of an enum type in C# to handle representing our string keys in the Dictionary so that we can avoid nasty “magic strings”…

With the Dictionary, coupled with the “System.Linq” library, it is easy to satisfy our acceptance criteria for tracking the highest ranking statistic by name.

Let’s add an Update method to the class with the following:


// within StatisticsManager.cs
// add "using System.Linq;" to top of file
void Update()
{
    // Order the current statistics from highest to lowest
    // and set resulting maxStat key name as the highest statistic
    KeyValuePair<string, float> maxStat = statistics.OrderByDescending(x => x.Value).First();
    highestStatistic = maxStat.Key;
}

We access a new method OrderByDescending() from Linq on our statistics Dictionary which will sort the current statistics descending by order of value, and allows us to determine what to set as the highest ranking statistic.

It’s worth noting that the Linq library methods can be notoriously slow if abused, but since we have a very small and finite list of statistics in this example, and it is not sorting by expensive string comparisons, we don’t have the same concerns about performance.

In order to satisfy our requirement to have alterable statistical values, we’ll provide an alter() method that will also dispatch an event used to notify our future graphing script about changes to the attribute values.

Here’s how we’ll add that to our StatisticsManager:


// Added to StatisticsManager.cs
// Added "use System;"

// Event to notify radar graph about changes
public static event EventHandler<Statistic> OnStatChanged

public void alter(Statistic stat, float adjustment)
{
    // Apply adjustment to existing statistic, but never allow
    // the value to fall below zero or above 1
    float clampedAdjustment = Mathf.Clamp01(statistics[stat.ToString()] + adjustment);

    // Update public stat reference
    if (statistics.ContainsKey(stat.ToString()))
    {
        statistics[stat.ToString()] = clampedAdjustment;
        OnStatChanged?.Invoke(this, stat);
    }
}

In the first line of alter() we satisfy our criteria of non-negative numbers by clamping the sum/difference between 0 and 1.

We protect ourselves with a condition to make sure it’s a valid statistic we’re updating, and then dispatch our new OnStatChanged event, which will later notify our graph to update the visuals.

Finally, here is what the full StatisticsManager.cs script looks like for you TLDR; folks:


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

namespace RadarGraphTutorial
{
    public class StatisticsManager : MonoBehaviour
    {
        // Our statistics value range
        public static int STAT_MIN = 0;
        public static int STAT_MAX = 1;

        public static event EventHandler<Statistic> OnStatChanged;

        // Holds name of the highest ranking statistic
        [SerializeField] string highestStatistic;

        // All supported statistics and their default values
        private Dictionary<string, float> statistics = new Dictionary<string, float>
        {
            [Statistic.Strength.ToString()] = 0.1f,
            [Statistic.Defense.ToString()] = 0.1f,
            [Statistic.Agility.ToString()] = 0.1f,
            [Statistic.Magic.ToString()] = 0.1f,
        };

        // Enum to avoid "magic strings" for our key names
        public enum Statistic
        {
            Strength,
            Defense,
            Agility,
            Magic,
        }

        void Update()
        {
            // Always be checking which is the highest stat and saving it
            KeyValuePair<string, float> maxStat = statistics.OrderByDescending(x => x.Value).First();
            highestStatistic = maxStat.Key;
        }

        public void alter(Statistic stat, float adjustment)
        {
            // Apply adjustment to existing statistic, but never allow
            // the value to fall below zero or above 1
            float clampedAdjustment = Mathf.Clamp01(statistics[stat.ToString()] + adjustment);

            // Update public stat reference
            if (statistics.ContainsKey(stat.ToString()))
            {
                statistics[stat.ToString()] = clampedAdjustment;
                OnStatChanged?.Invoke(this, stat);
            }
        }

        // Helps us return our stat value
        public float getStatistic(Statistic statistic)
        {
            return statistics[statistic.ToString()];
        }
    }
}

Statistics on the Radar Graph

Now that our StatisticsManager is set up to hold and control the attributes we want to graph, our goal now is to create a script which will utilize our manager class to listen for changes and prepare the the specifications to build our radar graph.

In order to do this, we’re going to need some important references in our script, which we’ll call StatisticsGrapher.cs:


using UnityEngine;

namespace RadarGraphTutorial
{
    public class StatisticsGrapher : MonoBehaviour
    {
        [SerializeField] private CanvasRenderer raderMeshCanvasRenderer;
        [SerializeField] private Material radarMaterial;
        [SerializeField] private float radarMaxChartSize = 175f;
        [SerializeField] private Texture2D radarTexture;

        private StatisticsManager tracker;
    }
}

The radarMeshCanvasRenderer represents the CanvasRenderer instance that is going to be receiving the final mesh and material for the radar graph to display the inner diamond on the chart. We’ll eventually place this through our Unity editor, later in the post.

The radarMaterial input will control how the inner diamond will be skinned.

The radarMaxChartSize will determine how far each node of the inner diamond mesh will push out to its respective corner, in pixels.

Lastly, the radarTexture will provide an easy way to update the texture of the inner diamond material if you want it to look any differently.

If you recall from our prior script, StatisticsManager.cs, we are dispatching an event called OnStatChanged.

In StatisticsGrapher we’ll want to listen for that event so we can update our radar mesh vertices, let’s hook into those:


private void OnEnable()
{
    StatisticsManager.OnStatChanged += StatisticsManager_OnStatChanged;
}

private void OnDisable()
{
    StatisticsManager.OnStatChanged -= StatisticsManager_OnStatChanged;
}

private void StatisticsManager_OnStatChanged(object sender, StatisticsManager.Statistic e)
{
    UpdateRadarVertices(e);
}

private void UpdateRadarVertices(StatisticsManager.Statistic e)
{
    // TODO: Update the Radar Graph vertices on the CanvasRenderer
}

This is the equivalent of subscribing and unsubscribing to an event as an object is enabled and disabled, and UpdateRadarVertices will give us our jump off point to performing our calculations.

First, let’s set some variables for some expectations on our visible graph:


// Set variables 
Mesh mesh = new Mesh();
Vector3[] vertices = new Vector3[5];
Vector2[] uv = new Vector2[5];

// We multiply by 3 because we're constructing this shape with triangles, 
// and thus we require 3 vertex per triangle for our 4 stats
int[] triangles = new int[3 * 4];

// Split area into 4 sections for our 4 stats
float angleIncrement = 360f / 4;

Next, we must start adding the vertices for our triangles to display, which essentially means we’re setting the x, y, and z position of each stat vertices on the canvas.


// Set each stat vertex that controls the max distance of the mesh from centerpoint 0
// This is where the mesh will connect to other stat point vertexes at their max distances
vertices[0] = Vector3.zero;
int strengthVertexIndex = AddVertex(vertices, 1, StatisticsManager.Statistic.Strength, 0);
int defenseVertexIndex = AddVertex(vertices, 2, StatisticsManager.Statistic.Defense, 1);
int agilityVertexIndex = AddVertex(vertices, 3, StatisticsManager.Statistic.Agility, 2);
int magicVertexIndex = AddVertex(vertices, 4, StatisticsManager.Statistic.Magic, 3);

// Nested method; Not used anywhere else, and no plans to open it up through the component api, 
// so we just nest this function to avoid duplicate code per vertex
int AddVertex(Vector3[] vertices, int statIndex, StatisticsManager.Statistic stat, int angleIndex)
{
    Quaternion anglePositioning = Quaternion.Euler(0, 0, -angleIncrement * angleIndex);
    Vector3 vertex = anglePositioning * Vector3.up * radarMaxChartSize * tracker.getStatistic(stat);
    vertices[statIndex] = vertex;
    return statIndex;
}

With a closer look, we keep ourselves from replicating code by adding a nested function within our UpdateRadarVertices method. You should never really reach for this unless in this particular edge case where it requires no external dependencies and is not planned to be used in other scripts in the project, so use it sparingly.

Next, we’ll need to be sure that we map our new vertices indexes to the triangle data matrix we will be passing to the mesh. This is important because it is how we effectively emulate a solid diamond shaped mesh; it is essentially just built out of a set of less complex triangle shapes put next to each-other:


// Now we construct our triangles, and make sure that the outermost vertex shares a vertex with the
// outermost vertex of the next feeling. Each triangle starts at zero so we end up with a right triangle
// in each section defined earlier by our angleIncrement. The final triangle must loop back to the first 
// index to close the loop for the mesh.
triangles[0] = 0;
triangles[1] = strengthVertexIndex;
triangles[2] = defenseVertexIndex;

triangles[3] = 0;
triangles[4] = defenseVertexIndex;
triangles[5] = agilityVertexIndex;

triangles[6] = 0;
triangles[7] = agilityVertexIndex;
triangles[8] = magicVertexIndex;

triangles[9] = 0;
triangles[10] = magicVertexIndex;
triangles[11] = strengthVertexIndex;

Finally, we will set each texture UV to the max of one which will remove any transparency at all, then we will be applying the mappings to the mesh and passing the mesh along with our texture we injected into the Canvas Renderer we defined earlier in the class:


// Setting our texture UVs to max, no transparency
uv[0] = Vector2.zero;
uv[strengthVertexIndex] = Vector2.one;
uv[defenseVertexIndex] = Vector2.one;
uv[agilityVertexIndex] = Vector2.one;
uv[magicVertexIndex] = Vector2.one;

// Apply geometry and uvs to mesh
mesh.vertices = vertices;
mesh.uv = uv;
mesh.triangles = triangles;

// Set the canvas mesh and the material/texture of the mesh
raderMeshCanvasRenderer.SetMesh(mesh);
raderMeshCanvasRenderer.SetMaterial(radarMaterial, radarTexture);

Let’s see what that script looks like in its full form:


using UnityEngine;

namespace RadarGraphTutorial
{
    public class StatisticsGrapher : MonoBehaviour
    {
        [SerializeField] private CanvasRenderer raderMeshCanvasRenderer;
        [SerializeField] private Material radarMaterial;
        [SerializeField] private float radarMaxChartSize = 175f;
        [SerializeField] private Texture2D radarTexture;

        private StatisticsManager tracker;

        private void OnEnable()
        {
            StatisticsManager.OnStatChanged += StatisticsManager_OnStatChanged;
        }

        private void OnDisable()
        {
            StatisticsManager.OnStatChanged -= StatisticsManager_OnStatChanged;
        }

        private void StatisticsManager_OnStatChanged(object sender, StatisticsManager.Statistic e)
        {
            UpdateRadarVertices(e);
        }

        private void UpdateRadarVertices(StatisticsManager.Statistic e)
        {
            // Set variables 
            Mesh mesh = new Mesh();
            Vector3[] vertices = new Vector3[5];
            Vector2[] uv = new Vector2[5];

            // We multiply by 3 because we're constructing this shape with triangles, and thus we require 3 vertex
            // per triangle for our 4 stats
            int[] triangles = new int[3 * 4];

            // Split area into 4 sections for our 4 stats
            float angleIncrement = 360f / 4;

            // Set each stat vertex that controls the max distance of the mesh from centerpoint 0
            // This is where the mesh will connect to other stat point vertexes at their max distances
            vertices[0] = Vector3.zero;
            int strengthVertexIndex = AddVertex(vertices, 1, StatisticsManager.Statistic.Strength, 0);
            int defenseVertexIndex = AddVertex(vertices, 2, StatisticsManager.Statistic.Defense, 1);
            int agilityVertexIndex = AddVertex(vertices, 3, StatisticsManager.Statistic.Agility, 2);
            int magicVertexIndex = AddVertex(vertices, 4, StatisticsManager.Statistic.Magic, 3);

            // Not used anywhere else, and no plans to open it up through the component api, so we just nest this function
            // to avoid duplicate code per vertex
            int AddVertex(Vector3[] vertices, int statIndex, StatisticsManager.Statistic stat, int angleIndex)
            {
                Quaternion anglePositioning = Quaternion.Euler(0, 0, -angleIncrement * angleIndex);
                Vector3 vertex = anglePositioning * Vector3.up * radarMaxChartSize * tracker.getStatistic(stat);
                vertices[statIndex] = vertex;
                return statIndex;
            }

            // Now we construct our triangles, and make sure that the outermost vertex shares a vertex with the
            // outermost vertex of the next feeling. Each triangle starts at zero so we end up with a right triangle
            // in each section defined earlier by our angleIncrement. The final triangle must loop back to the first 
            // index to close the loop for the mesh.
            triangles[0] = 0;
            triangles[1] = strengthVertexIndex;
            triangles[2] = defenseVertexIndex;

            triangles[3] = 0;
            triangles[4] = defenseVertexIndex;
            triangles[5] = agilityVertexIndex;

            triangles[6] = 0;
            triangles[7] = agilityVertexIndex;
            triangles[8] = magicVertexIndex;

            triangles[9] = 0;
            triangles[10] = magicVertexIndex;
            triangles[11] = strengthVertexIndex;

            // Setting our texture UVs to max, no transparency
            uv[0] = Vector2.zero;
            uv[strengthVertexIndex] = Vector2.one;
            uv[defenseVertexIndex] = Vector2.one;
            uv[agilityVertexIndex] = Vector2.one;
            uv[magicVertexIndex] = Vector2.one;

            // Apply geometry and uvs to mesh
            mesh.vertices = vertices;
            mesh.uv = uv;
            mesh.triangles = triangles;

            // Set the canvas mesh and the material/texture of the mesh
            raderMeshCanvasRenderer.SetMesh(mesh);
            raderMeshCanvasRenderer.SetMaterial(radarMaterial, radarTexture);
        }
    }
}

Visualizing our Data on a Canvas

For any of this to matter, we’re going to need to display this information on the screen to the player in the game.

For the sake of a simplicity, I’m going to just render the graph in the center of the screen, but make it flexible enough to be placed anywhere on a canvas, and re-sized if needed.

Let’s create a test script that will randomize our attributes in 2 second intervals:


using UnityEngine;

namespace RadarGraphTutorial
{
    public class RadarGraphTester : MonoBehaviour
    {
        [SerializeField] float changeTimer = 2f;

        StatisticsManager statisticsManager;

        // Start is called before the first frame update
        void Start()
        {
            // Import StatisticsManager, FindObjectOfType is not the most performant option but for
            // our test case it is fine. Consider using GetComponent() or making statisticsManager 
            // a SerializeField and directly injecting the instance through the Unity Editor
            statisticsManager = FindObjectOfType<StatisticsManager>();
        }

        // Update is called once per frame
        void Update()
        {
            // Decrease timer over time until zero
            if (changeTimer > 0)
            {
                changeTimer -= Time.deltaTime;
                return;
            }

            // Apply randomized values to statistics
            statisticsManager.alter(StatisticsManager.Statistic.Strength, Random.Range(-0.2f, 0.2f));
            statisticsManager.alter(StatisticsManager.Statistic.Agility, Random.Range(-0.2f, 0.2f));
            statisticsManager.alter(StatisticsManager.Statistic.Defense, Random.Range(-0.2f, 0.2f));
            statisticsManager.alter(StatisticsManager.Statistic.Magic, Random.Range(-0.2f, 0.2f));

            // Reset timer
            changeTimer = 2f;
        }
    }
}

The RadarGraphTester is a trivial script that uses our StatisticsManager.alter() method to apply randomized changes to the statistic float amount between -0.2 and 0.2 , respectively.

The changeTimer is used to determine when to re-apply a fresh set of data, which should trigger our OnStatChanged event that ultimately re-builds the radar graph mesh with the new vertices positions for each triangle.

Let’s take a look at how we can put this all together in Unity, and see if it will actually function for us!



A Reflection

We’ve successfully written a few scripts that let us create radar graph for our statistical data about our player attributes, equipped with a test script that randomizes these values to test the different extremes of our attributes and make sure the shape is retained.

I learned a lot about how we can utilize simple shapes to construct more complex shapes that have dynamic vertices. The most interesting part of that processes was actually calculating the angle of which the vertices positions would be placed because I haven’t messed around a whole lot with Quaternions and Euler angles, so I feel a bit more dangerous in that area now.

As always, I challenge you to try and make this a bit better, perhaps you can:

  • Create a neat graphic to go behind the graph that shows the extremes

  • Add more statistics and update the scripts to include the new triangles to the algorithm

… or, maybe there is something new and interesting you can use this concept for in your UI.

Good luck, and happy coding!

Previous
Previous

Unity: Object Pooling in ECS

Next
Next

Unity3D: Creating a Mechanic to Inspect Objects in World Space