Basic Spline – Curve

carlitos-Green-Worm

You can get the project here

Use git to clone the project on your computer.

This article will introduce a mathematical concept to create curvy shape using LineRenderer component.

Defining the classes

Before jumping into coding, we should put down all the classes we think we need (there will be some we forgot but at least a basic design). First, we need to define the limits of the article, we will not deal with input and collision for now, simply rendering the worm.

We want a WormController class whose purpose is to handle how many nodes, the distance between them and creating all the required top level components.

If we mention node, maybe we need a Node class as well. A node is just a container for the previous node and the following node creating a chain of nodes. This is the basic of the LinkedList collection type. Our worm is then like a chain.

Last, we want to render our worm with a WormRendering class. Render here is used in a broad meaning, we will not directly communicate with the GPU, just get the position of the vertices to be given to the LineRenderer component.

So to sum it up, a controller that initialises, some nodes to create a chain that we cover with color or texture and finally a renderer that takes care of setting the line renderer based on the position of each node.

Let’s get it on! (that’s what she says)

We add an empty game object and drag the WormController and WormRenderer.

We add a few lines to our Node class:

public class Node : MonoBehaviour {

    private static int naming = 0;
    private Node prevNode = null;
    private Node nextNode = null;

    public Node PrevNode
    {
        get{return this.prevNode;} 
    }
    public Node NextNode
    {
        get { return this.nextNode; }
    }
    public void SetNode(Node prev, Node next, float distance) 
    {
        this.name = this.name + naming.ToString();
        naming++;
        this.prevNode = prev;
        this.nextNode = next;
        if (this.prevNode == null) { return; }
        Vector3 pos = this.prevNode.transform.position;
        pos.z += distance;
        this.transform.position = pos;
    }
}

The Node class is mainly encapsulating the previous and next node that are set in SetNode.

The WormController takes care of how many nodes and the distance between them.

public class WormController : MonoBehaviour 
{
    [Tooltip("Defines how many nodes to be created")]
    [SerializeField] private int nodeLength = 2;
    [Tooltip("Defines the distance between two nodes")]
    [SerializeField] private float distance = 2f;
   
    private Node [] nodeObjects = null;
    public Node[] NodeObjects { get { return this.nodeObjects; } }

    private void Start() 
    {
        this.nodeObjects = new Node[this.nodeLength];
        for (int i = 0; i < this.nodeLength; i++ )
        {
            this.nodeObjects[i] = new GameObject("Worm_Node_").AddComponent<Node>();
            this.nodeObjects[i].transform.parent = this.transform;
        }
        for (int i = 0; i &lt; this.nodeLength; i++) 
        {
            if (i == 0) 
            {
                this.nodeObjects[i].SetNode(null, this.nodeObjects[i + 1], this.distance) ;
                continue;
            }
            else if (i == this.nodeLength - 1) 
            {
                this.nodeObjects[i].SetNode(this.nodeObjects[i - 1], null, this.distance);
                continue;
            }
            this.nodeObjects[i].SetNode(this.nodeObjects[i - 1], this.nodeObjects[i + 1], this.distance);
        }
        this.gameObject.GetComponent<WormRendering>().Init(this.nodeObjects);
    }
}

All the nodes are stored in an array and parented to the controller game object.
The first has no previous and last has no next and are passed null.

public class WormRendering : MonoBehaviour 
{
    [SerializeField] private Color startColor = Color.white;
    [SerializeField] private Color endColor = Color.blue;
    [SerializeField] private float widthStart = 0.5f;
    [SerializeField] private float widthEnd = 0.5f;

    private Node[] nodes = null;
    private LineRenderer lineRenderer = null;
    private int nodeLength = 0;

    public void Init (Node[] nodeArray) 
    {
        this.nodes = nodeArray;
        this.nodeLength = this.nodes.Length;

        this.lineRenderer = this.gameObject.AddComponent&lt;LineRenderer&gt;();
        this.lineRenderer.material = new Material(Shader.Find(&quot;Particles/Additive&quot;));
        this.lineRenderer.SetColors(startColor, endColor);
        this.lineRenderer.SetWidth(widthStart,widthEnd);
        this.lineRenderer.SetVertexCount(this.nodes.Length);
    }
}

Most of the data here are taken from the LineRenderer documentation on Unity. So nothing special, it is just the basic creation of a LineRenderer.

Knowing what you need

Bézier curve is an advanced and complex topic consisting of finding the tangent of a point based on the surrounding points so that you know where to approximately put it (this is highly simplified!!!). Best is to look it up on Wikipedia. There are also Hermite, Casteljau and so on. They all tend to do the same thing in a more precise or more efficient way. But they all do splines.

We will use a slightly simplified version called Catmull-Rom centripetal spline. DirectX

0.5f * ((2f * p2) + (-p1 + p3) * 
t + (2f * p1 - 5f * p2 + 4f * p3 - p4) *
Mathf.Pow(t, 2f) + (-p1 + 3f * p2 - 3f * p3 + p4) * Mathf.Pow(t, 3f))

The idea behind this equation is to create a discrete interpolation between two points (p2 and p3) using those points and the previous (p1) and next (p4). We interpolate between p2 and p3 adding extra points as we interpolate using the result tangent.

The t variable indicates how many interpolation we want. The more interpolation, the curvier it gets, but it also requires more computation. See the animation below, as we add points between p2 and p3, the curve looks better but the amount of iteration of the equation increases as well.

output_JjlR91

public static class Helper
{
    public static Vector3 CatmullRom(Vector3 p1, Vector3 p2, Vector3 p3, Vector3 p4, float t)
    {
        return 0.5f*( (2f*p2) + (-p1 + p3)*t + (2f*p1 - 5f*p2 + 4f*p3 - p4)*Mathf.Pow(t, 2f) + (-p1 + 3f*p2 - 3f*p3 + p4)*Mathf.Pow(t, 3f));
    }
}

Using our equation

The CatmullRom method takes 5 parameters. The four first are Vector3 and the last is a float. The four first represents the four points of the curve animation (p1, p2, p3, p4) and t is the amount of points we want in the curve.
So, p2 and p3 are the “main” points, when running the method, we are getting the curve between those two. p1 and p4 are only there to obtain the tangent value. The vector (p1, p2) is the initial tangent while (p3, p4) is the exit tangent. Based on those and the position of p2 and p3, we can get points in between.

[SerializeField] private int accuracy = 1;
private int length = 0;
public void Init (Node[] nodeArray) 
{
    this.nodes = nodeArray;
    this.nodeLength = this.nodes.Length;

    this.lineRenderer = this.gameObject.AddComponent<LineRenderer>();
    this.lineRenderer.material = new Material(Shader.Find("Particles/Additive"));
    this.lineRenderer.SetColors(this.startColor, this.endColor);
    this.lineRenderer.SetWidth(this.widthStart, this.widthEnd);
    this.length = this.nodes.Length + ((this.nodeLength - 1) * this.accuracy);
    this.lineRenderer.SetVertexCount(this.length);
}

We add an accuracy variable, this will define the t value of our method. The length variable is just there to save the amount of vertices. Note how we get the new value for this:

this.length = this.nodes.Length * (this.accuracy - 1) + 1;

The size of the array gives us how many nodes, each of them being a vertex for the line renderer, accuracy tells us how many edges we want to get between two points, so if we want 5 edges, we need to add 4 vertices.

p1–|–|–|–|–p2

See we have 5 spaces between p1 and p2 but only four vertical bars (vertices). Each multiplication is from p1 to the last vertex before p2 then from p2 to last vertex before p3 and so on. So our very last vertex pn will be missing so we add a +1 for it.

So if you have 5 nodes and you want accuracy of 5 edges between two vertices:

5 * (5 – 1) + 1 = 5 * 4 + 1 = 20 + 1 = 21 vertices

And finally our main method:

private void Update()
{
    int j = 0;
    float acc = 1f / (float)accuracy;
    for (int index = 0; index < this.nodeLength - 1; index++ )
    {
        Node n1 = this.nodes[index].PrevNode;
        Vector3 p1 = (n1 == null) ? this.nodes[index].transform.position : n1.transform.position;

        Vector3 p2 = this.nodes[index].transform.position;
        Vector3 p3 = this.nodes[index + 1].transform.position;

        Node n4 = this.nodes[index + 1].NextNode;
        Vector3 p4 = (n4 == null) ? this.nodes[index + 1].transform.position : n4.transform.position;

        float t = 0f;
        for (; t < 1; t = t + acc, j++)
        {
            Vector3 pos = WormCurve.CatmullRom(p1, p2, p3, p4, t);
            lineRenderer.SetPosition(j, pos);
        }
    }
    lineRenderer.SetPosition(length - 1, this.nodes[this.nodeLength-1].transform.position);
}

The variable j is the vertex counter. It gets increased in the for loop each time a vertex is used. The acc value is the inverse of the accuracy so that we get a normalized value (between 0 and 1).

Then start a loop iterating through each node except the last one.

Node n1 = this.nodes[index].PrevNode;
Vector3 p1 = (n1 == null) ? this.nodes[index].transform.position : n1.transform.position;

We get all four points from our nodes, if the node does not exist, then we turn the value to the same point. This will fix the problem of first and last node that are missing previous or next. We could also check the value of index to see if we are on the first or last.Since the tangent between two points of same position is 0, this will not affect our curve.

And comes the call for the CatmullRom equation:

float t = 0f;
for (; t < 1; t = t + acc, j++)
{
    Vector3 pos = Helper.CatmullRom(p1, p2, p3, p4, t);
    lineRenderer.SetPosition(j, pos);
}

The for loop takes care of increasing the t and j values. t is increased by the ratio we got from the inverse of the accuracy.

If we want accuracy of 5, we get the invert with acc = 1 / accuracy => 0.2.
When we start the loop, t is 0 and each round gets increased by that value until it reaches 1 which is ignored.

In use

So remember we need to have an empty game object to which the WormController and WormRenderer are attached. We can pass values, for instance:

WormController :

  • NodeLength => 5
  • distance => 2

WormRenderer:

  • StartColor => white
  • EndColor => blue
  • width start and end => 1
  • accuracy => 5.

And here is the result:

Untitled0

In the inspector, under the main game object, you will find 5 game objects called Worm_Node_X, grab one and pull it. Based on the value of accuracy, the curve will start getting edgy if you pull too much. Moving a little I get this result:

Untitled1

You can see the vertices created in the line renderer. With these small angles, accuracy of 5 is ok but if I were to pull more:

Untitled1

You can start to see that accuracy 5 is limited for this kind of shape and angles. It is a design decision to consider the final value based on visual expectation and device limitations.

Conclusion

This is it for now. I leave it up to you to enhance the code (there are a bunch of cache you could do to speed it up a bit). You got the basic for spline, if you need a circle then just add the last node to the previous of first and the first node to the next of last.
Also, you can try to add input and movement, maybe next part when I get the time for it.

Leave a Reply

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

WordPress.com Logo

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

Twitter picture

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

Facebook photo

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

Google+ photo

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

Connecting to %s