Graphs

visualizing data

Introduction

In this tutorial we'll write some C# scripts to display increasingly complex graphs. 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.

This tutorial is an updated version of the original Graphs tutorial. It uses the Shuriken particle system, introduced in Unity 3.5.

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 flip the perspective view around by clicking on the box at the center of the compass.

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
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. This leaves us with an inert particle system that we can use to visualize graph data.
scene inspector
inert particle system

Creating the first graph

The first graph we'll create will be 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 drag it to our object so it gets added as a component.

using UnityEngine;

public class Grapher1 : MonoBehaviour {}
Graph 1
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];
	}
}
configurable 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 equals 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.

using UnityEngine;

public class Grapher1 : MonoBehaviour {

	public int resolution = 10;

	private ParticleSystem.Particle[] points;

	void Start () {
		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 do this in the Update method.
using UnityEngine;

public class Grapher1 : MonoBehaviour {

	public int resolution = 10;

	private ParticleSystem.Particle[] points;

	void Start () { … }

	void Update () {
		particleSystem.SetParticles(points, points.Length);
	}
}
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
playing with a resolution of 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 () {
		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 errors whenever resolution drops below 2, even while typing. This is because the caluculation of increment will result in a division by zero. One way to prevent this is to make sure that resolution is always at least 2.
using UnityEngine;

public class Grapher1 : MonoBehaviour {

	public int resolution = 10;

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

	void Start () { … }

	private void CreatePoints () {
		if(resolution < 2){
			resolution = 2;
		}
		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 () { … }
}
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. (The code snippet below only contains the Update method, everything else stays the same.)
	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);
	}
red line goes up
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);
	}
line becomes yellow

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){
			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
four functions graphed
Having to change the code each time we want to switch between these three options isn't very handy. 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.

using UnityEngine;

public class Grapher1 : MonoBehaviour {

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

	public FunctionOption function;
	public int resolution = 10;

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

	…
}
configure the 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.

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;
	public int resolution = 10;

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

	void Start () { … }

	private void CreatePoints () { … }

	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 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;
	public int resolution = 10;

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

	void Start () {
		CreatePoints();
	}

	private void CreatePoints () {
		if(resolution < 2){
			resolution = 2;
		}
		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();
		}
		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.

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.

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 () {
		if(resolution < 2){
			resolution = 2;
		}
		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){
			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);
	}
resolution 10 resolution 100
a fully functional grid
Now we can see our functions again, extended along to Z axis. Increasing the resolution results in a nice smooth surface. However, you'll notice that if you set the resolution to a really high value performance will suffer a lot. So it's a good idea to limit the resolution to something like 100, which translates to 10000 points.
	private void CreatePoints () {
		if(resolution < 2){
			resolution = 2;
		}
		else if(resolution > 100){
			resolution = 100;
		}
		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;
			}
		}
	}
There's something else 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. 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.
scene wrong inspector no sorting
scene right inspector with sorting
without and with sorting
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){
			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 = 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);
	}
parabola sine
grid versions of parabola and sine
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;
	public int resolution = 10;

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

	void Start () {
		CreatePoints();
	}

	private void CreatePoints () {
		if(resolution < 2){
			resolution = 2;
		}
		else if(resolution > 100){
			resolution = 100;
		}
		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){
			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){
		float squareRadius = (p.x - 0.5f) * (p.x - 0.5f) + (p.z - 0.5f) * (p.z - 0.5f);
		return 0.5f + Mathf.Sin(15 * Mathf.PI * squareRadius - 2f * t) / (2f + 100f * squareRadius);
	}
}
grid with a ripple

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.

the third graph
We'll make a few changes to Grapher3. First, we'll limit resolution to 40, which translates to 27000 points. We also need to initialize the Y position of the points and the green color component.
private void CreatePoints () {
		if(resolution < 2){
			resolution = 2;
		}
		else if(resolution > 30){
			resolution = 30;
		}
		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 seems a little brighter. 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){
			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 is quite solid
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 = 2f * p.x - 1f;
		p.z = 2f * 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){
		float squareRadius =
			(p.x - 0.5f) * (p.x - 0.5f) +
			(p.y - 0.5f) * (p.y - 0.5f) +
			(p.z - 0.5f) * (p.z - 0.5f);
		return Mathf.Sin(4 * 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;
	}
sines produce 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 bool absolute;
	public float threshold = 0.5f;
	public FunctionOption function;
	public int resolution = 10;

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

	void Start () {
		CreatePoints();
	}

	private void CreatePoints () {
		if(resolution < 2){
			resolution = 2;
		}
		else if(resolution > 30){
			resolution = 30;
		}
		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){
			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 = 2f * p.x - 1f;
		p.z = 2f * 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){
		float squareRadius =
			(p.x - 0.5f) * (p.x - 0.5f) +
			(p.y - 0.5f) * (p.y - 0.5f) +
			(p.z - 0.5f) * (p.z - 0.5f);
		return Mathf.Sin(4 * Mathf.PI * squareRadius - 2f * t);
	}
}
scene inspector
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.

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 Particle type outside of ParticleSystem, but this type is not used by Unity's Shuriken particle system.

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 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.
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 it's 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 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 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;