Graphs, visualizing data

In this tutorial we'll write some C# scripts to display increasingly complex graphs in Unity 4. You'll learn to

You're assumed to know your way around Unity's editor and know the basics of creating C# scripts. If you've completed the Clock tutorial you're good to go.

Note that I will often omit chunks of code that have remained the same. The context of the new code should be clear.

Fancy data visualization.

Preparations

We start by opening a new project without any packages. We'll be creating our graphs inside a unit cube, placed between (0, 0, 0) and (1, 1, 1). Let's set up the editor so we get a good view of this area. The 4 Split is a handy predefined view configuration so let's select that. It's in Window / Layout / 4 Spit or in the dropdown list at the top right of the screen. Set the view mode of all of them to Textured. Also rotate the perspective view so that all three axes are pointing towards you.

Now we create a cube via GameObject / Create Other / Cube and set its position to (0.5, 0.5, 0.5). This gives us a reference point for calibrating our views. Now zoom and pan the views so they're focused on the unit cube.

Finally, select the Main Camera and make it match the perpective view via GameObject / Align With View (we did the opposite in the Clock tutorial). If that doesn't work right, make sure the correct view is active by clicking in it and then try again.

Scene views and camera focused on cube.
The cube is no longer needed, so we remove it. We then create a particle system via GameObject / Create Other / Particle System and reset its transform. Right now it's producing random particles, which we don't want. So we deactivate everything except its renderer.

Uncheck Looping, Play On Awake, Emission, and Shape. This leaves us with an inert particle system that we can use to visualize graph data.

inspector
Inert particle system.

Creating the first graph

We begin with a simple line graph where the value of Y depends on the value of X. We'll use the positions of the particles to visualize this.

Rename the particle system object to Graph 1, create a C# script named Grapher1 as a minimal GameObject class, and add it as a component to our object.

using UnityEngine;

public class Grapher1 : MonoBehaviour {}
Graph 1 with empty Grapher1 component.
The first thing we need to do is create some particles that will serve as the points of our graph. We'll create them inside the special Start method, which is a Unity event method that is called once before updates start happening.

How much particles should we use? The more particles, the higher the sample resolution of our graph. Let's make it customizable, with a default resolution of 10.

using UnityEngine;

public class Grapher1 : MonoBehaviour {

	public int resolution = 10;

	private ParticleSystem.Particle[] points;

	void Start () {
		points = new ParticleSystem.Particle[resolution];
	}
}
Grapher1 with configurable resolution.
Now we can set resolution to any number we want, which is a bit too generous. Technically it must be at least zero, and a very high resolution will result in very bad performance.

We can make sure that the variable is within bounds when we initialize the array. In case the resolution is out of bounds, we'll reset it to the minimum and log a warning message. Let's use a sane range of 10–100.

	void Start () {
		if (resolution < 10 || resolution > 100) {
			Debug.LogWarning("Grapher resolution out of bounds, resetting to minimum.", this);
			resolution = 10;
		}
		points = new ParticleSystem.Particle[resolution];
	}
Now that we have the points it's time to position them along the X axis. The first point should be placed at 0 and the last should be placed at 1. All other points should be placed in between. So the distance – or X increment – between two points is 1 / (resolution - 1).

Besides the position, we can also use color to provide the same information. We'll make the red color component of the points equal to their position along the X axis.

We'll use a for loop to iterate over all points and set both their position and color, which are struct values of type Vector3 and Color. We also need to set the size of the particles, otherwise they won't show up. A size of 0.1 is fine.

	void Start () {
		if (resolution < 10 || resolution > 100) {
			Debug.LogWarning("Grapher resolution out of bounds, resetting to minimum.", this);
			resolution = 10;
		}
		points = new ParticleSystem.Particle[resolution];
		float increment = 1f / (resolution - 1);
		for (int i = 0; i < resolution; i++) {
			float x = i * increment;
			points[i].position = new Vector3(x, 0f, 0f);
			points[i].color = new Color(x, 0f, 0f);
			points[i].size = 0.1f;
		}
	}
So far, it doesn't work. When playing, nothing shows up. That's because we need to feed our particles to the particle system. Conveniently, every component has a particleSystem property that we can use to access its particle system, if it has one. All we need to do is call its SetParticles methods, providing our array of particles and the amount of particles we wish it to use. Because we want the system to use all particles, we simply provide the array's length. We will add an Update method to do this every frame.
	void Update () {
		particleSystem.SetParticles(points, points.Length);
	}
A line of ten particles.
That's it! We now get a nice black to red line of points along the X axis. How many points are shown depends on what we put in the resolution field before entering play mode.
resolution 10 resolution 100
Line with resolution 10 and 100.
Right now, resolution is only taken into account when the graph is initialized. Updating its value while in play mode doesn't do anything. Let's change that.

A simple way to detect a change of resolution is by storing it twice and then constantly checking whether both values are still the same. If at some point they're different, we need to rebuild the graph. We'll create a private variable currentResolution for this purpose.

Because rebuilding the points is the same as their first initialization, let's move that code into a new private method which we name CreatePoints. That way we can reuse the code.

using UnityEngine;

public class Grapher1 : MonoBehaviour {

	public int resolution = 10;

	private int currentResolution;
	private ParticleSystem.Particle[] points;

	void Start () {
		CreatePoints();
	}

	private void CreatePoints () {
		if (resolution < 10 || resolution > 100) {
			Debug.LogWarning("Grapher resolution out of bounds, resetting to minimum.", this);
			resolution = 10;
		}
		currentResolution = resolution;
		points = new ParticleSystem.Particle[resolution];
		float increment = 1f / (resolution - 1);
		for(int i = 0; i < resolution; i++){
			float x = i * increment;
			points[i].position = new Vector3(x, 0f, 0f);
			points[i].color = new Color(x, 0f, 0f);
			points[i].size = 0.1f;
		}
	}

	void Update () {
		if (currentResolution != resolution) {
			CreatePoints();
		}
		particleSystem.SetParticles(points, points.Length);
	}
}
Now the graph is recreated as soon as we change the value of resolution. However, you'll notice that the console will spit out warnings whenever resolution goes out of bounds, even while typing. We can make this experience better by using the Range attribute to tell the Unity editor to use a slider instead of a number box.

As we only care about valid editor input and won't change our resolution via code, we can now remove our own resolution check, though you may decide to keep it.

	[Range(10, 100)]
	public int resolution = 10;
Grapher1 with resolution range slider.
Now it's time to set the Y position of the points. Let's start simple and make Y equal to X. In other words, we're visualizing the mathematical equation y = x or the function f(x) = x. To do this, we need to loop over all points, get their position, use the X value to compute the Y value, then set their new position. Once again we use a for loop, which we'll execute each update.
	void Update () {
		if (currentResolution != resolution) {
			CreatePoints();
		}
		for (int i = 0; i < resolution; i++) {
			Vector3 p = points[i].position;
			p.y = p.x;
			points[i].position = p;
		}
		particleSystem.SetParticles(points, points.Length);
	}
The function f(x) = x.
The next step is to make the point's green color component the same as its Y position. As red plus green becomes yellow, this will cause the line to go from black to yellow.
	void Update () {
		if (currentResolution != resolution) {
			CreatePoints();
		}
		for (int i = 0; i < resolution; i++) {
			Vector3 p = points[i].position;
			p.y = p.x;
			points[i].position = p;
			Color c = points[i].color;
			c.g = p.y;
			points[i].color = c;
		}
		particleSystem.SetParticles(points, points.Length);
	}
Red plus green becomes yellow.
You might have noticed that when you change the code and go back to Unity while you're still in play mode, you will be presented with NullReferenceException error messages. This is because our private points variable was not remembered by Unity when everything got reloaded.

We could solve this problem by checking whether points is null besides checking for a resolution change. This will allow us to stay in play mode all the time while editing our code, which is quite convenient. Note that this check also removes the need for the Start method, so we can delete it.

using UnityEngine;

public class Grapher1 : MonoBehaviour {

	[Range(10, 100)]
	public int resolution = 10;

	private int currentResolution;
	private ParticleSystem.Particle[] points;

	private void CreatePoints () {
		currentResolution = resolution;
		points = new ParticleSystem.Particle[resolution];
		float increment = 1f / (resolution - 1);
		for(int i = 0; i < resolution; i++){
			float x = i * increment;
			points[i].position = new Vector3(x, 0f, 0f);
			points[i].color = new Color(x, 0f, 0f);
			points[i].size = 0.1f;
		}
	}

	void Update () {
		if (currentResolution != resolution || points == null) {
			CreatePoints();
		}
		for (int i = 0; i < resolution; i++) {
			Vector3 p = points[i].position;
			p.y = p.x;
			points[i].position = p;
			Color c = points[i].color;
			c.g = p.y;
			points[i].color = c;
		}
		particleSystem.SetParticles(points, points.Length);
	}
}

Showing multiple graphs

Just one graph is a little boring. It would be nice if we had multiple graphs to show. All that's needed is different ways to compute p.y, the rest of the code can stay the same. Let's make this explicit by extracting the code that computes p.y and put it in its own method, which we'll call Linear. All this method does is mimic the mathematical function f(x) = x. We're making this method static because it doesn't require an object to function. All it needs is an input value.
	void Update () {
		if (currentResolution != resolution || points == null) {
			CreatePoints();
		}
		for (int i = 0; i < resolution; i++) {
			Vector3 p = points[i].position;
			p.y = Linear(p.x);
			points[i].position = p;
			Color c = points[i].color;
			c.g = p.y;
			points[i].color = c;
		}
		particleSystem.SetParticles(points, points.Length);
	}

	private static float Linear (float x) {
		return x;
	}
It's easy to add other mathematical functions by creating more methods and calling them instead of Linear. Let's add three new method. The first is Exponential, which calculates f(x) = x2. The second is Parabola, which calculates f(x) = (2x - 1)2. The third is Sine, which calculates f(x) = (sin(2πx) + 1) / 2.
	private static float Exponential (float x) {
		return x * x;
	}

	private static float Parabola (float x){
		x = 2f * x - 1f;
		return x * x;
	}

	private static float Sine (float x){
		return 0.5f + 0.5f * Mathf.Sin(2 * Mathf.PI * x);
	}
linear exponential parabola sine
The four functions graphed.
Having to change the code each time we want to switch between these three options isn't very handy, even though we can remain in play mode. Let's create an enumeration type which contains an entry for each function we want to show. We call it FunctionOption, but because we define it inside our class it's officially known as Grapher1.FunctionOption.

Add a public variable named function of the new type. This gives us a nice field in the inspector for selecting functions.

	public enum FunctionOption {
		Linear,
		Exponential,
		Parabola,
		Sine
	}

	public FunctionOption function;
function dropdown
Selecting which function to use.
Selecting a function in the inspector is nice, but it doesn't do anything yet. Each update, we need to decide which method to call based on the value of function. There are various ways to do this and we'll use an array of delegates.

We first define a delegate type for methods that have a single float as both input and output, which corresponds to our function methods. We call it FunctionDelegate. Then we add a static array named functionDelegates and fill it with delegates to our methods, in the same order that we named them in our enumeration.

Now we can select the desired delegate from the array based on our function variable, by casting it to an integer. We store this delegate in a temporary variable and use it to calculate the value of Y.

	private delegate float FunctionDelegate (float x);
	private static FunctionDelegate[] functionDelegates = {
		Linear,
		Exponential,
		Parabola,
		Sine
	};

	void Update () {
		if(currentResolution != resolution){
			CreatePoints();
		}
		FunctionDelegate f = functionDelegates[(int)function];
		for(int i = 0; i < resolution; i++){
			Vector3 p = points[i].position;
			p.y = f(p.x);
			points[i].position = p;
			Color c = points[i].color;
			c.g = p.y;
			points[i].color = c;
		}
		particleSystem.SetParticles(points, points.Length);
	}
Finally, we can change which function is graphed while in play mode!

Although we need to recreate the graph each time we select another function, the rest of the time nothing really changes. So it's not required to compute the points each update. However, things change if we add time to the functions. As an example, let's change the Sine method so it calculates f(x) = (sin(2πx + Δ) + 1) / 2, where Δ is equal to the play time. This results in a slowly animating sine wave. Here's the entire script.

using UnityEngine;

public class Grapher1 : MonoBehaviour {
	
	public enum FunctionOption {
		Linear,
		Exponential,
		Parabola,
		Sine
	}
	
	private delegate float FunctionDelegate (float x);
	private static FunctionDelegate[] functionDelegates = {
		Linear,
		Exponential,
		Parabola,
		Sine
	};

	public FunctionOption function;
	
	[Range(10, 100)]
	public int resolution = 10;
	
	private int currentResolution;
	private ParticleSystem.Particle[] points;
	
	private void CreatePoints () {
		currentResolution = resolution;
		points = new ParticleSystem.Particle[resolution];
		float increment = 1f / (resolution - 1);
		for (int i = 0; i < resolution; i++) {
			float x = i * increment;
			points[i].position = new Vector3(x, 0f, 0f);
			points[i].color = new Color(x, 0f, 0f);
			points[i].size = 0.1f;
		}
	}
	
	void Update () {
		if (currentResolution != resolution || points == null) {
			CreatePoints();
		}
		FunctionDelegate f = functionDelegates[(int)function];
		for (int i = 0; i < resolution; i++) {
			Vector3 p = points[i].position;
			p.y = f(p.x);
			points[i].position = p;
			Color c = points[i].color;
			c.g = p.y;
			points[i].color = c;
		}
		particleSystem.SetParticles(points, points.Length);
	}
	
	private static float Linear (float x) {
		return x;
	}
	
	private static float Exponential (float x) {
		return x * x;
	}

	private static float Parabola (float x){
		x = 2f * x - 1f;
		return x * x;
	}

	private static float Sine (float x){
		return 0.5f + 0.5f * Mathf.Sin(2 * Mathf.PI * x + Time.timeSinceLevelLoad);
	}
}

Adding an extra dimension

Up to this points we're only using the X axis for input, and in one case time as well. Now we will make a new graph object that uses the Z axis too, thus producing a grid instead of a line.

Make sure that you're not in play mode. Create a new Unity object just like Graph 1 along with a new grapher script, calling them Graph 2 and Grapher2 instead. You can speed this up by duplicating them and then making the necessary changes. Disable Graph 1 by toggling the checkbox in front of its name field, because we're not using it anymore. Copy the code from Grapher1 to Grapher2, only changing the class name to Grapher2. We'll modify the rest of the code in a moment.

The fastest way to do the above is to duplicate the script and edit its class name, then duplicate the object, rename it, and drag the new script on top of the old one.

graph 1 disabled graph 2 hierarchy
switching to a new graph
To change the line into a square grid, we change the CreatePoints method of Grapher2. We need to create a lot more points and use a nested for loop to initialize them. We now set the Z position and the blue color component too.
	private void CreatePoints () {
		currentResolution = resolution;
		points = new ParticleSystem.Particle[resolution * resolution];
		float increment = 1f / (resolution - 1);
		int i = 0;
		for (int x = 0; x < resolution; x++) {
			for (int z = 0; z < resolution; z++) {
				Vector3 p = new Vector3(x * increment, 0f, z * increment);
				points[i].position = p;
				points[i].color = new Color(p.x, 0f, p.z);
				points[i++].size = 0.1f;
			}
		}
	}
A flat grid.
Now we have a nice flat grid! But shouldn't it show the Linear function? It does, but currently only for the first row of points along the Z axis. If you select a different function, only these points will change while the rest remain as they are. This is because in the Update method currently only loops over resolution points, while it should loop over all of them.
	void Update () {
		if (currentResolution != resolution || points == null) {
			CreatePoints();
		}
		FunctionDelegate f = functionDelegates[(int)function];
		for (int i = 0; i < points.Length; i++) {
			Vector3 p = points[i].position;
			p.y = f(p.x);
			points[i].position = p;
			Color c = points[i].color;
			c.g = p.y;
			points[i].color = c;
		}
		particleSystem.SetParticles(points, points.Length);
	}
linear exponential
parabola sine
The four functions on a grid.
Now we can see our functions again, extended along to Z axis. However, there's something going on that's pretty weird. Try rotating the perspective view while displaying the parabola. From some angles, the graph is drawn wrong. This is because the particles are drawn in the order that we've created them, they don't take view direction into account. You can fix this by setting the Sort Mode of the particle system's Renderer module to By Distance instead of None. While this makes sure that the graph is shown correctly from all view angles, it results in a performance hit as well. So don't use it when you're displaying a huge amount of points. Fortunately, if we only look at the graphs from the correct direction, we can get away with not sorting at all.
unsorted unsorted settings
sorted sorted settings
Sort modes None and By Distance.
Let's update our function code so we can take advantage of the new dimension. First change the input paramaters of FunctionDelegate to a vector and a float instead of just a single float. While we could specify the X and Z position separately, we'll simply give it the entire position vector. We'll also include the current time, instead of having to look it up inside the functions themselves.
	private delegate float FunctionDelegate (Vector3 p, float t);
Now we need to update the function methods accordingly and change how the delegate is called.
	void Update () {
		if (currentResolution != resolution || points == null) {
			CreatePoints();
		}
		FunctionDelegate f = functionDelegates[(int)function];
		float t = Time.timeSinceLevelLoad;
		for (int i = 0; i < points.Length; i++) {
			Vector3 p = points[i].position;
			p.y = f(p, t);
			points[i].position = p;
			Color c = points[i].color;
			c.g = p.y;
			points[i].color = c;
		}
		particleSystem.SetParticles(points, points.Length);
	}

	private static float Linear (Vector3 p, float t) {
		return p.x;
	}

	private static float Exponential (Vector3 p, float t) {
		return p.x * p.x;
	}

	private static float Parabola (Vector3 p, float t){
		p.x = 2f * p.x - 1f;
		return p.x * p.x;
	}

	private static float Sine (Vector3 p, float t){
		return 0.5f + 0.5f * Mathf.Sin(2 * Mathf.PI * p.x + t);
	}
We're ready to include Z in our mathematical functions! For example, change the Parabola function to f(x,z) = 1 - (2x - 1)2 × (2z - 1)2. We can also go wild with the Sine function, layering multiple sines to get a complex oscillating effect.
	private static float Parabola (Vector3 p, float t){
		p.x += p.x - 1f;
		p.z += p.z - 1f;
		return 1f - p.x * p.x * p.z * p.z;
	}

	private static float Sine (Vector3 p, float t){
		return 0.50f +
			0.25f * Mathf.Sin(4f * Mathf.PI * p.x + 4f * t) * Mathf.Sin(2f * Mathf.PI * p.z + t) +
			0.10f * Mathf.Cos(3f * Mathf.PI * p.x + 5f * t) * Mathf.Cos(5f * Mathf.PI * p.z + 3f * t) +
			0.15f * Mathf.Sin(Mathf.PI * p.x + 0.6f * t);
	}
parabola sine
More interesting parabola and sine functions.
Let's finish Grapher2 by adding a Ripple function, which is a single sine wave emanating from the center of the grid. Here's the entire script.
using UnityEngine;

public class Grapher2 : MonoBehaviour {

	public enum FunctionOption {
		Linear,
		Exponential,
		Parabola,
		Sine,
		Ripple
	}

	private delegate float FunctionDelegate (Vector3 p, float t);
	private static FunctionDelegate[] functionDelegates = {
		Linear,
		Exponential,
		Parabola,
		Sine,
		Ripple
	};

	public FunctionOption function;

	[Range(10, 100)]
	public int resolution = 10;

	private int currentResolution;
	private ParticleSystem.Particle[] points;

	private void CreatePoints () {
		currentResolution = resolution;
		points = new ParticleSystem.Particle[resolution * resolution];
		float increment = 1f / (resolution - 1);
		int i = 0;
		for (int x = 0; x < resolution; x++) {
			for (int z = 0; z < resolution; z++) {
				Vector3 p = new Vector3(x * increment, 0f, z * increment);
				points[i].position = p;
				points[i].color = new Color(p.x, 0f, p.z);
				points[i++].size = 0.1f;
			}
		}
	}

	void Update () {
		if (currentResolution != resolution || points == null) {
			CreatePoints();
		}
		FunctionDelegate f = functionDelegates[(int)function];
		float t = Time.timeSinceLevelLoad;
		for (int i = 0; i < points.Length; i++) {
			Vector3 p = points[i].position;
			p.y = f(p, t);
			points[i].position = p;
			Color c = points[i].color;
			c.g = p.y;
			points[i].color = c;
		}
		particleSystem.SetParticles(points, points.Length);
	}

	private static float Linear (Vector3 p, float t) {
		return p.x;
	}

	private static float Exponential (Vector3 p, float t) {
		return p.x * p.x;
	}

	private static float Parabola (Vector3 p, float t){
		p.x = 2f * p.x - 1f;
		p.z = 2f * p.z - 1f;
		return 1f - p.x * p.x * p.z * p.z;
	}

	private static float Sine (Vector3 p, float t){
		return 0.50f +
			0.25f * Mathf.Sin(4 * Mathf.PI * p.x + 4 * t) * Mathf.Sin(2 * Mathf.PI * p.z + t) +
			0.10f * Mathf.Cos(3 * Mathf.PI * p.x + 5 * t) * Mathf.Cos(5 * Mathf.PI * p.z + 3 * t) +
			0.15f * Mathf.Sin(Mathf.PI * p.x + 0.6f * t);
	}

	private static float Ripple (Vector3 p, float t){
		p.x -= 0.5f;
		p.z -= 0.5f;
		float squareRadius = p.x * p.x + p.z * p.z;
		return 0.5f + Mathf.Sin(15f * Mathf.PI * squareRadius - 2f * t) / (2f + 100f * squareRadius);
	}
}
Ripple function.

Full-blown 3D

It's time to add the third dimension! This will turn our graph from a grid into a cube, which we can use for volumetric representations. In other words, we'll create a tiny voxel system.

Duplicate Graph 2 and Grapher2 and change them into Graph 3 and Grapher3, just like we did for the second graph. Don't forget to disable Graph 2 and make sure that you're not in play mode.

All three graph objects.
We'll make a few changes to Grapher3. First, we'll limit resolution to 30, which translates to 27,000 points. Make sure you adjust the resolution slider so it's in range. If you created it from a duplicate graph set to a higher resolution, it will still have this value.

We also need to initialize the Y position of the points and the green color component.

	[Range(10, 30)]
	public int resolution = 10;

	private void CreatePoints () {
		currentResolution = resolution;
		points = new ParticleSystem.Particle[resolution * resolution * resolution];
		float increment = 1f / (resolution - 1);
		int i = 0;
		for (int x = 0; x < resolution; x++) {
			for (int z = 0; z < resolution; z++) {
				for (int y = 0; y < resolution; y++) {
					Vector3 p = new Vector3(x, y, z) * increment;
					points[i].position = p;
					points[i].color = new Color(p.x, p.y, p.z);
					points[i++].size = 0.1f;
				}
			}
		}
	}
Right now Graph 3 looks the same as Graph 2, except that it might seem a little more solid. This is because we still set the Y positions in the Update method. So all points with the same X and Z position will collapse to the same Y position. We must no longer set the Y position, but the color's alpha component instead. That way our functions will define the volume's density.
	void Update () {
		if (currentResolution != resolution || points == null) {
			CreatePoints();
		}
		FunctionDelegate f = functionDelegates[(int)function];
		float t = Time.timeSinceLevelLoad;
		for (int i = 0; i < points.Length; i++) {
			Color c = points[i].color;
			c.a = f(points[i].position, t);
			points[i].color = c;
		}
		particleSystem.SetParticles(points, points.Length);
	}
resolution 10 resolution 30
Volumetric graph cube at resolutions 10 and 30.
Now our graph looks like a mostly solid cube. The functions aren't very visible, because they do not vary along the Y axis. Only the two animated functions, Sine and Ripple, produce somewhat interesting results.

Let's change the function computed by Linear into f(x,y,z) = 1 - x - y - z. That way it starts solid at (0, 0, 0) and fades to transparent along a straight line. We can do a similar thing with Exponential as well. Even better, let's animate them a bit so it's more interesting to look at.

	private static float Linear (Vector3 p, float t) {
		return 1f - p.x - p.y - p.z + 0.5f * Mathf.Sin(t);
	}

	private static float Exponential (Vector3 p, float t) {
		return 1f - p.x * p.x - p.y * p.y - p.z * p.z + 0.5f * Mathf.Sin(t);
	}
linear exponential
Linear and exponential volumes.
Next, we update Parabola so it will produce a cylinder, once again with a little pulsating animation. We also add the third dimension to Ripple, turning it into a sphere-spawning animation.
	private static float Parabola (Vector3 p, float t){
		p.x += p.x - 1f;
		p.z += p.z - 1f;
		return 1f - p.x * p.x - p.z * p.z + 0.5f * Mathf.Sin(t);
	}

	private static float Ripple (Vector3 p, float t){
		p.x -= 0.5f;
		p.y -= 0.5f;
		p.z -= 0.5f;
		float squareRadius = p.x * p.x + p.y * p.y + p.z * p.z;
		return Mathf.Sin(4f * Mathf.PI * squareRadius - 2f * t);
	}
parabola ripple
Parabola and ripple volumes.
Finally, we'll update Sine too. We transform it into eight blobs by multiplying the square sines of X, Y, and Z together. We only animate the Z-based sine, but we make a distinction here. The top and bottom half of the graph will move in opposite directions.
	private static float Sine (Vector3 p, float t){
		float x = Mathf.Sin(2 * Mathf.PI * p.x);
		float y = Mathf.Sin(2 * Mathf.PI * p.y);
		float z = Mathf.Sin(2 * Mathf.PI * p.z + (p.y > 0.5f ? t : -t));
		return x * x * y * y * z * z;
	}
Volumetric sine produces marching blobs.
A nice variant of our current graph would be one where all voxels are either fully visible or fully transparent. It would result in a solid but pixelated appearance. Let's add an absolute field to toggle such behaviour, along with a threshold field that determines how solid a voxel must be before it becomes visible. Each update, we check whether asbolute is on and use that to decide how to set the alpha of the points. Here's the complete script.
using UnityEngine;

public class Grapher3 : MonoBehaviour {

	public enum FunctionOption {
		Linear,
		Exponential,
		Parabola,
		Sine,
		Ripple
	}

	private delegate float FunctionDelegate (Vector3 p, float t);
	private static FunctionDelegate[] functionDelegates = {
		Linear,
		Exponential,
		Parabola,
		Sine,
		Ripple
	};

	public FunctionOption function;
	public bool absolute;
	public float threshold = 0.5f;
	
	[Range(10, 30)]
	public int resolution = 10;

	private int currentResolution;
	private ParticleSystem.Particle[] points;

	private void CreatePoints () {
		currentResolution = resolution;
		points = new ParticleSystem.Particle[resolution * resolution * resolution];
		float increment = 1f / (resolution - 1);
		int i = 0;
		for (int x = 0; x < resolution; x++) {
			for (int z = 0; z < resolution; z++) {
				for (int y = 0; y < resolution; y++) {
					Vector3 p = new Vector3(x, y, z) * increment;
					points[i].position = p;
					points[i].color = new Color(p.x, p.y, p.z);
					points[i++].size = 0.1f;
				}
			}
		}
	}

	void Update () {
		if (currentResolution != resolution || points == null) {
			CreatePoints();
		}
		FunctionDelegate f = functionDelegates[(int)function];
		float t = Time.timeSinceLevelLoad;
		if (absolute) {
			for (int i = 0; i < points.Length; i++) {
				Color c = points[i].color;
				c.a = f(points[i].position, t) >= threshold ? 1f : 0f;
				points[i].color = c;
			}
		}
		else {
			for (int i = 0; i < points.Length; i++) {
				Color c = points[i].color;
				c.a = f(points[i].position, t);
				points[i].color = c;
			}
		}
		particleSystem.SetParticles(points, points.Length);
	}
	
	private static float Linear (Vector3 p, float t) {
		return 1f - p.x - p.y - p.z + 0.5f * Mathf.Sin(t);
	}

	private static float Exponential (Vector3 p, float t) {
		return 1f - p.x * p.x - p.y * p.y - p.z * p.z + 0.5f * Mathf.Sin(t);
	}

	private static float Parabola (Vector3 p, float t){
		p.x += p.x - 1f;
		p.z += p.z - 1f;
		return 1f - p.x * p.x - p.z * p.z + 0.5f * Mathf.Sin(t);
	}

	private static float Sine (Vector3 p, float t){
		float x = Mathf.Sin(2 * Mathf.PI * p.x);
		float y = Mathf.Sin(2 * Mathf.PI * p.y);
		float z = Mathf.Sin(2 * Mathf.PI * p.z + (p.y > 0.5f ? t : -t));
		return x * x * y * y * z * z;
	}

	private static float Ripple (Vector3 p, float t){
		p.x -= 0.5f;
		p.y -= 0.5f;
		p.z -= 0.5f;
		float squareRadius = p.x * p.x + p.y * p.y + p.z * p.z;
		return Mathf.Sin(4f * Mathf.PI * squareRadius - 2f * t);
	}
}
absolute settings
Hard-edged volumes.
We can now produce nice graphs from data with up to three dimensions! It's possible to create very intricate volumetric stuff, only your imagination and mathematical knowledge is the limit.

Enjoyed the tutorial? Help me make more by becoming a patron!

Downloads

graphs.unitypackage
The finished project.

Questions & Answers

When is Start called exactly?
The Start method is called after the component is created, once it's active, and before the first time its Update method is called. It's only called once.
How do arrays work?
Arrays are objects of fixed length that contain a linear sequence of variables. When declaring a variable, putting square brackets behind its type indicates that you want an array of that type. So int myVariable; gets you an integer, while int[] myVariable; get you an array of integers.

Accessing one of the entries inside an array is done by putting its array index – not its position – between square brackets behind the variable. So myVariable[0] gets you the first entry in the array, myVariable[1] gets you the second, and so on.

Actually creating an array and assigning it to the variable is done with myVariable = new int[10]; which in this case creates a new array with room for 10 entries. Alternatively, you can create one implicitly by listing its initial values between curly brackets, like myVariable = {1, 2, 3}; does.

What's a ParticleSystem.Particle?
ParticleSystem.Particle is a struct type that is used to hold the data of a particle. There's a dot in the type name because it's actually a nested type. The Particle type has been defined inside the ParticleSystem type. Hence, to the outside world it is known as ParticleSystem.Particle.

Note that there is also a legacy Particle type outside of ParticleSystem, but this type is not used by Unity's Shuriken particle system.

What does new do?
The new keyword is used to construct a new instance of an object or a struct value. It's followed by calling a special constructor method, which has the same name as the class or struct it belongs to.
What does Debug.LogWarning do?
It is a static method from Unity's Debug class that allows you to write text entries to the console. You can use Log, LogWarning, or LogError to indicate the type of message. The first argument of these methods is the text to log. The optional second argument allows you to link the message to an object, which will be highlighted in the editor when you click the message.
What's this?
The this keyword refers to the current object or struct who's method is being called. It's being used implicitly all the time when referring to stuff from the same class. For example, whenever we've written resolution we could've also written this.resolution. You typically only use this when passing a reference to the object itself, like we did with Debug.LogWarning.
How does a for loop work?
A for loop is a compact way of writing a loop that iterates over something. In this case we use an integer named i as the iterator. The first part declares the iterator, the second part checks the loop's condition, and the third part increments the iterator.

You can use a while loop to get the exact same result, but it doesn't conveniently group the iterator code.

for(int i = 0; i < 10; i++) { DoStuff(i); }

is the same as

int i = 0; while(i < 10) { DoStuff(i); i++; }

What does i++ do?
The ++ operation increments a variable by one. It can also be used inside an expression, in which case it counts as the old value if it's written behind the variable, or the new value if it's written in front of it. For example,

int x = 0; int y = 10 + x++;

results in an x of 1 and a y of 10, while

int x = 0; int y = 10 + ++x;

results in an x of 1 and a y of 11.

You can also use -- in the same way for decrementing.

What's an attribute?
An attribute is a means to attach metadata to variables, methods, and classes. For example, you can tell Unity how to display a variable in the editor, whether to save or not save a variable, indicate that a component requires other components, and lots of other stuff.

Attributes are added between brackets in front of whatever they're attached to and can have arguments, like [Nice] int number and [Nice(42)] int number. Multiple attributes are separated by commas, like [Nice, Sweet] int number.

What's null?
The default value of a variable that's not a simple value is null. This means that the variable doesn't reference anything. Trying to invoke or access anything from a variable that is null results in an error. You can test for this value to make sure that doesn't happen. You can also set such a variable to null yourself, in case you no longer need whatever it was referencing.

Note that objects don't automatically cease to exist when setting a reference to them to null. Only when there's nobody left with a reference to them will they be become candidates for removal by the garbage collector.

Why not directly set position.y?
It would work if position were an object reference, but it is a struct value.

A unique object instance exists only once, but can be referenced from multiple variables. Assigning it to a variable doesn't copy it. It's like multiple people shouting someone's name and giving orders. If someone else learns this person's name as well, that won't suddenly duplicate this person.

Simple values like integers and struct instances aren't like this. If you ask for one then you get a duplicate that you have to remember yourself and can change independent of all the others. It's like the person's name, but shouting it won't get you any response.

So when we ask for the point's position, we get a duplicate. Changing that duplicate won't have any effect unless we copy it back to the point.

What does return do?
You use the return keyword to incidate that a method is finished and what its result is. What you return must match the type of the method. If it's a void method then you simply return nothing.

It's not needed to have a return statement at the end of a void or a special constructor method, for all other methods it's required.

It is possible to have multiple return statements inside a method. In that case there are multiple possible exit points. You'd typically use if statements to determine which return gets used.

Does it need to be static?
No, in this case the methods don't need to be. However, later on these methods will be used in a static array, which does require them to be static as well.

While in a lot of cases you can do without static, I consistently use it whenever the variable or method involved can exist on its own, indepenent of any object instances.

What's Mathf?
Mathf is a struct that contains a collection of static methods and values useful for mathematical operations with floats. It's a function library.
What's an enum?
You use enum to define an enumeration type, which is an ordered list of names. A variable of this type can have one of these names as its value. Each of these names corresponds to a number, by default starting at zero.

Enumerations are useful whenever you need a limited list of options.

What's a delegate?
If you can store values and object references in a variable, is it possible to store method references as well? Yes, it's known as a delegate.

You define a delegate type as if you're creating a method, except there's no code body. After that, you can use this type to create a delegate variable, to which you can assign any method that matches the type. You can then use this variable as if it were a method.

Delegates are actually more complex than this. They behave like lists and can be used for complex event handling, but we don't need that in this tutorial.

Why list the method names twice?
It's important to realize that we're writing the exact same words in completely different contexts.

Inside the FunctionOption enumeration, it's just a list of words. That they're the same as our method names is by design, but in no way required.

Inside the declaration of the functionDelegates array, the words are actual references to our methods. Here the names must match or it doesn't work.

What does += do?
The code x += y; adds x and y together and assigns the result back to x. You can consider it a short alternative for the code x = x + y;

There are other operators that behave in a similar fashion, like -=, *=, and /=.

What does the question mark do?
The question mark, combined with a colon, is an in-line short version of an if statement. For example,

int y; if(x > 1) { y = 10; } else { y = 20; }

can be condensed into

int y = x > 1 ? 10 : 20;