Curves & You: Part 2

Getting our Feet Wet


Ok, let's start building some curves in Unity to see what we can do. These examples are not necessarily going to be things that you can immediately apply to game development, rather these are an opportunity to become familiar with some core techniques for working with curves.

The Circle Round


Circles are about the simplest curve there is, so we'll start with them.

public Transform cube;
float radius=6f;
int count=24;
void MakeCubeRing()
{
    float angleBetweenCubes = 2f*Mathf.PI/count;
    for(int i=0;i<count;i++)
    {
        Transform t = (Transform)GameObject.Instantiate(cube);
        float a = angleBetweenCubes*i;
        t.localPosition = radius*(new Vector3(Mathf.Sin(a),0f,Mathf.Cos(a)));
    }
}

Note that the basically first thing we do is bake the curve into waypoints. This is for two reasons. The first is that since curves are abstract concepts we're using as a tool in a 3D setting there's no native way to display them, and the second is that making circles (and a lot of the curve families we'll be looking at) use a lot of trigonometry, and those Sin and Cos functions are performance killers in bulk.

That's all well and good, but the cubes are all facing the same direction. How to orient objects along curves in general is actually going to be the sole focus of one or two articles in this series. Luckily circles are an easy case- we will set their rotations manually since we know what they need to be ahead of time.

public Transform cube;
float radius=6f;
int count=24;
void MakeCubeRing2()
{
    float angleBetweenCubes = 2f*Mathf.PI/count;
    for(int i=0;i<count;i++)
    {
        Transform t = (Transform)GameObject.Instantiate(cube);
        float a = angleBetweenCubes*i;
        t.localPosition = radius*(new Vector3(Mathf.Sin(a),0f,Mathf.Cos(a)));
        t.localRotation = Quaternion.Euler(0f,a*Mathf.Rad2Deg,0f);
    }
}


None we have a much greater sense of curvature even though the objects haven't actually changed position:

This is because we are very used to seeing organic shapes move in slow, smooth transitions.

Spacing and Granularity


You'll also notice that changing the radius and number of waypoints we generate both affect the distance between individual waypoints. This relationship is an important one- it's vital that you choose a level of granularity that is appropriate to your function. Technically these two cubes are on the same circle, but it doesn't really convey a sense of roundness, does it?

And while the 1000 cubes below very clearly define a circle, it's a waste of resources to use this many - 50 would serve nearly identically but with far less overhead.

While it's possible that you want to create the illusion of a larger shape by placing hundreds or thousands of cubes together, a better approach would be to build that shape in a 3D modeler in the first place. Spacing is important for more than performance reasons, though.

Only one of these chain loops looks correct:

In this case we have the same number of links in each ring, and changing the radius causes the spacing issue. We can't address that problem without knowing the length of our curve. Luckily, determining the circumference of a circle is easy, so let's fix that problem.

Here's the original code for making the ring link:

public Transform link;
public float radius=3.5f;
public int count=18;
void MakeChainLinkRing()
{
    List<Transform> links = new List<Transform>();
    float angleBetweenLinks = 2f*Mathf.PI/count;
    for(int i=0;i<count;i++)
    {
        Transform t = (Transform)GameObject.Instantiate(link);
        t.parent=transform;
        float a = angleBetweenLinks*i;
        float b = angleBetweenLinks*(i+1);
        t.localPosition = radius*(new Vector3(Mathf.Sin(a),0f,Mathf.Cos(a)));
        links.Add(t);
    }
    for(int i=0;i<count;i++)
    {
        links[i].LookAt(links[(i+1)%count]);
        if(i%2==0)
        {
            links[i].localRotation*=Quaternion.Euler(0f,0f,90f);
        }
    }
}


Now we need to change it so that we are no longer supplying the number of links to the code. It has to figure that out itself. We also need to make sure that there are an even number of links so that we don't end up with two horizontal links trying to connect to each other- one example of the additional constraints imposed on us when we make use of closed loops.

public Transform link;
public float radius=3.5f;
public float length=0.45f;
void MakeChainLinkRing()
{
    float circumference = 2f*radius*Mathf.PI;
    int count = Mathf.RoundToInt(circumference/length);
    if(count%2==1)
    {
        count++;
    }
    List<Transform> links = new List<Transform>();
    float angleBetweenLinks = 2f*Mathf.PI/count;
    for(int i=0;i<count;i++)
    {
        Transform t = (Transform)GameObject.Instantiate(link);
        t.parent=transform;
        float a = angleBetweenLinks*i;
        t.localPosition = radius*(new Vector3(Mathf.Sin(a),0f,Mathf.Cos(a)));
        links.Add(t);
    }
    for(int i=0;i<count;i++)
    {
        links[i].LookAt(links[(i+1)%count]);
        if(i%2==0)
        {
            links[i].localRotation*=Quaternion.Euler(0f,0f,90f);
        }
    }
}

To make sure things fit, we will add an extra link if the calculated number to fill out the ring is odd. Note that the length of the link here is not the length of the chain link model- its the distance between the points just inside it's ends. Using that as the distance means that the links will overlap in a nice way regardless of our radius:


Stretch Marks


The technique above worked because we know ahead of time what the length of our curve is and because the curve progresses uniformly as our parameter increases. Let's see what happens when we add a little variation. We'll introduce a sharp wobble into our chain by swapping out the position and rotation code for each object with this:

radius*(new Vector3(Mathf.Sin(a),0.35f*Mathf.Sin(8f*a),Mathf.Cos(a)));

Now the position of our links along the y-axis will vary, too. Doing this will substantially change the length of the curve, as we're no longer making a simple circle. Unfortunately, calculating the true arclength of our parametric function is going to be difficult (and for many curves it would be impossible). Instead, we will determine the total distance by approximating it manually, and then use that along with our link length to determine how many links to build into the chain.
public Transform link;
public float radius=3.5f;
public float length=0.45f;
void MakeChainLinkRing()
{
    int approximationSteps=500;
    float distance=0f;
    float angleBetweenLinks = 2f*Mathf.PI/approximationSteps;
    float a;
    float b;
    for(int i=0;i<approximationSteps;i++)
    {
        a=angleBetweenLinks*i;
        b=angleBetweenLinks*(i+1);
        Vector3 point1 = FindPosition(i,approximationSteps);
        Vector3 point2 = FindPosition(i+1,approximationSteps);
        distance+=Vector3.Distance(point1,point2);
    }
    int count = Mathf.RoundToInt(distance/length);
    if(count%2==1)
    {
        count++;
    }

    List<Transform> links = new List<Transform>();
    angleBetweenLinks = 2f*Mathf.PI/count;
    for(int i=0;i<count;i++)
    {
        Transform t = (Transform)GameObject.Instantiate(link);
        t.parent=transform;
        a = angleBetweenLinks*i;
        b = angleBetweenLinks*(i+1);
        t.localPosition = FindPosition(i,count);
        links.Add(t);
    }
    for(int i=0;i<count;i++)
    {
        links[i].LookAt(links[(i+1)%count]);
        if(i%2==0)
        {
            links[i].localRotation*=Quaternion.Euler(0f,0f,90f);
        }
    }
}

Vector3 FindPosition(int posNumber, int totalNumber)
{
    float angleBetweenLinks = 2f*Mathf.PI/totalNumber;
    float a = angleBetweenLinks*(posNumber%totalNumber);
    return radius*(new Vector3(Mathf.Sin(a),0.35f*Mathf.Sin(8f*a),Mathf.Cos(a)));
}

All told, that gives us something along these lines:

What we are seeing here is that the curve "bunches up" at the peaks and troughs and gets "stretched out" during the parts in between. This ruins our spacing- we have links that don't interlink, links which appear to be welded to other links, and links too close together to mesh well. What's going on?

Even though we've estimated the distance with a high degree of precision and even though we take that into account when we place our links, they aren't lining up as well as we'd like them to. Even though our parameter a goes up a uniform amount each link we add, the distance between the corresponding links isn't uniform. One way to think about it is like a road- and we're driving along it. Now our parameter is time. On the straightaways we can floor the gas and each minute we drive we cover a lot of distance... but on the turns we have to slow down, and now the distance we travel between the ticks of a clock substantially less.

What we want to do is find a way to apply cruise control, so we add links at a steady rate.

In the next article we'll look at some techniques for normalizing distance along our curve. This is very important if you plan to use your parametric curve as the basis for any kind of racing or movement game- the player needs to feel like changes in apparent speed are the result of their actions, not artifacts of your level generation approach.

You might also notice that on the peaks of our link chain here some of the links don't necessarily fit "between" their neighbors. That's happening because LookAt() is a friendly function that's easy to use, but isn't very robust. Just as normalizing distance is going to be important if we want to make full use of curves, normalizing orientation is going to be crucial as well. In a later article we'll devote quite a lot of time addressing some of the challenges and potential solutions to the problem of orienting objects on curves in general.

Closing


I hope you've found this series informative so far. Many readers may find the discussion here a bit elementary. If that's the case for you, you may want to check in on the later articles, which deal with some of the pitfalls you may encounter working with curves and approaches you can take to work around them.

If you want to download the scenes used in this article, we'll be posting them shortly. Note that the screenshots in these articles make use of Pro image effects in order to bewitch and entice the viewer into a better learning experience. You may also run into problems with Unity trying to import the included bender files. Instructions on how to to resolve this can be found here.

You are invited and encouraged to use any code in these articles for whatever purpose you like. However: please do not use it to destroy the Earth; it is where I keep all my stuff.

PrintView Printer Friendly Version

EmailEmail Article to Friend

« Post-mortem: Writing Music at the 2012 Global Game Jam (Part 1) | Main | For those of you about to jam: we salute you! »