Stretchy Objects in Unity 3D
In graphics applications, it is very common to need to draw a line from one point to another. In games and simulations we often need to do this trick using 3D objects, and as it happens I recently needed to do this in my own Unity project. In this article we'll be figuring out how to stretch a GameObject in Unity3D along a single axis so that its ends remain attached to two objects as they move around in 3D space. Let's get started!
Plan of Attack
I've decided to approach this by first making a MonoBehaviour that stretches its object between two point vectors in world space. It can recalculate scaling, rotation, and position whenever those points change. Then I'll write a subclass that updates those points based on one or two transforms (if set), creating a continuous tethering effect. For simplicity I'll assume that the stretching object is 1 unit long in the stretching dimension, and for the convenience of using Transform.forward we'll stretch in the Z dimension.
If you'd like to follow along at home, download the Stretchy Demo Project, decompress it, and open it up in Unity. Also find this demo at the Unity-Stretchy project at Github.
Let's Get Coding!
Customarily, I like to keep a cached copy of the transform. This may not really be needed, but it can't hurt. So let's start our class out like so…
public class Stretchy : MonoBehaviour { protected Transform T; protected virtual void Awake() { T = transform; }
Next we'll need storage for our target points, plus a second copy so we can track any changes. We'll make the points public so we can easily change them in the Unity editor outside of debug mode.
public Vector3[] targetPoint = new Vector3[2]; Vector3[] oldTargetPoint = new Vector3[2];
Looking ahead, it might be nice if this object can be nested arbitrarily inside any transform without messing up the scaling and positioning. For this we need the ratio between the lossyScale and the localScale. Let's assume uniform scaling for now so we only need a single float:
protected float scaleFactor = 1f;
Ok, we're off to a good start! It will be useful to be able to get the positions of the ends of the stretched object so we can use them to set the initial target points or for other things. We should get them in world coordinates, since these will be the most useful. For fun we'll implement this as a read-only accessor:
protected Vector3[] endPoints { get { Vector3 pos = T.position, ray = T.forward * T.lossyScale.z / 2; return new Vector3[2] { pos - ray, pos + ray }; } }
Let's take a quick look at the code. First, we need to get the current position, then the relative position of one end of the object. The variable ray calculates the latter by multiplying the forward direction by half of the stretched size (T.lossyScale.z / 2), which gives the rotated point. The returned endpoints are just the current position plus and minus the rotated offset.
Now that we have a way to get the endpoints, we can use them to initialize the target points. This will be necessary to keep any pre-made Stretchy objects in the scene from collapsing into 0,0,0 as soon as they start up. Let's make an initializer and a public setter method so the end-points can be changed by objects of any class.
void InitTargetPoints() { targetPoint = endPoints; oldTargetPoint = endPoints; } public virtual void TetherEndToWorldPoint(int end, Vector3 point) { if (end == 0 || end == 1) targetPoint[end] = point; }
It's vital to always check the input to your functions, so as you can see we added a test to make sure the end index is either 0 or 1. If a bad index is given, nothing will happen.
While we're dealing directly with the targetPoint data, we should add a function so we can switch which end connects to which target point. This might come in handy later.
public virtual void SwapTargetPoints() { Vector3 temp = targetPoint[0]; targetPoint[0] = targetPoint[1]; targetPoint[1] = temp; }
That's kind of ugly, isn't it? I don't know about you, but in my code I need to swap two items pretty frequently. Wouldn't it be nice if C# arrays had a built-in way to do this? What the heck, let's add one!
Extending the Array Class
This is one of those times when you really appreciate the power of C#. We can easily add an extension method that applies to arrays of any type! The magic comes from using the "this" keyword in the first method argument:
public static class SwapExtension { public static void Swap<T>(this T[] v, int i=0) { T temp = v[i]; v[i] = v[++i]; v[i] = temp; } }
With this extension added to our project, now we can call .Swap() to exchange our elements:
public virtual void SwapTargetPoints() { targetPoint.Swap(); }
The "Start" Method
With all our groundwork done, the Start method will be pretty simple. It just needs to initialize scaleFactor and call InitTargetPoints to initialize the targetPoint array:
protected virtual void Start() { scaleFactor = T.lossyScale.x / T.localScale.x; InitTargetPoints(); }
The "Update" Method
To finish the class we only need to write the Update code to do the actual stretching. Before diving in we should take a moment to think about some basic strategy. While the target points are in world coordinates, intuitively it seems like it ought to be easier to do calculations in the reference frame of the Stretchy. Let's try that approach and see how it goes…
protected virtual void Update() { Vector3[] targetLocalPos = new Vector3[2]; for (int i = 0; i < 2; i++) { Vector3 tlp = targetPoint[i]; if (T.parent != null) tlp = T.parent.InverseTransformPoint(tlp); targetLocalPos[i] = tlp; }
So far so good. But you may be wondering what the T.parent stuff is all about. For some reason we're converting the target points from world space to the local space of the Stretchy's parent instead of the local space of the Stretchy itself. Why should we do it that way?
The reason is pretty straightforward. You'll notice that the Stretchy's transform is being rotated, stretched, and moved all the time, so it's not a very stable reference point. (In fact, it's better not to think of what life would be like with the Stretchy as your parent transform.) The important reference frame —the one that will determine how we rotate, scale, and position the Stretchy— is its parent transform T.parent, representing the whole chain of ancestor transforms.
Remember Old Points
Actually, I almost forgot! We created a variable (oldTargetPoint) to remember the old endpoints, and we need to compare them with the current endpoints as part of the Update routine. That way if nothing has changed we can skip the stretching. Let's redo the loop with the extra code added:
bool didMove = false; for (int i = 0; i < 2; i++) { Vector3 tlp = targetPoint[i]; if (T.parent != null) tlp = T.parent.InverseTransformPoint(tlp); targetLocalPos[i] = tlp; if (oldTargetPoint[i] != tlp) { oldTargetPoint[i] = tlp; didMove = true; } } if (!didMove) return;
Hopefully the extra test will save some computation. Moving on, it's time to get into the really essential code that actually aligns the Stretchy with the target points. Let's see the code first:
Vector3 targetDiff = targetLocalPos[1] - targetLocalPos[0]; Vector3 localScale = T.localScale; localScale.z = targetDiff.magnitude; T.localScale = localScale; T.localPosition = (targetLocalPos[0] + targetLocalPos[1]) / 2f; T.localRotation = Quaternion.LookRotation(targetDiff); } // end Update()
Well that's actually not so bad, is it? Here's how it breaks down:
- Get the difference between the targets in parent-local XYZ space. Subtracting the first target position from the second one gives us the distance and orientation of the second target relative to the first.
- Get the Stretchy's current scaling in XYZ. We want to preserve the X and Y scaling as we change Z. This will allow the Stretchy to be dynamically scaled in X and Y.
- The magnitude property gives us the distance between the two target points in parent-local space. We're assuming that the geometry is exactly 1 unit deep, so we don't scale it up or down.
- Set the local scale vector.
- The position is set exactly half-way between the two target points. Averaging the two vectors gives us the middle point.
- Finally, the rotation is set so that Z-forward points towards the second target point.
It's Subclassin' Time
With just this tiny amount of code we've already got enough logic to tether any object to two points in world space. Pretty cool! Consider that class finished for now. We'll come back to it shortly to add some extra enhancements, but first let's jump into the next essential, a subclass that can lasso transforms and stay tied to them as they move.
We'll start out by subclassing Stretchy, adding a pair of transforms into the mix. They're public so they can be set by other code or using drag-and-drop in the Unity editor:
public class StretchyTethered : Stretchy { public Transform[] targetObj = new Transform[2]; public void TetherEndToTransform(int end, Transform target) { if (end == 0 || end == 1) targetObj[end] = target; }
As long as we have a method to tether to things (including null), we should also have an explicit way to untether things as a convenience method:
public void Untether(int end=-1) { if (end == -1) { TetherEndToTransform(0, null); TetherEndToTransform(1, null); } else TetherEndToTransform(end, null); }
And we will need to override TetherEndToWorldPoint so that it also untethers the end from any transform:
public override void TetherEndToWorldPoint(int end, Vector3 point) { if (end == 0 || end == 1) { Untether(end); base.TetherEndToWorldPoint(end, point); } }
The Update handler for our subclass is a piece of cake. It only needs to copy the world positions of its transforms to the target positions, call the base.Update method, and Bob's your uncle:
protected override void Update() { for (int i = 0; i < 2; i++) if (targetObj[i] != null) targetPoint[i] = targetObj[i].position; base.Update(); }
That was surprisingly easy, and we already have a pair of very useful behaviors. But something is still missing. We can only tether to the center of a transform, and the line that we're stretching along always extends from the middle of one transform to the middle of the other. What if we want to attach to some constant offset from a transform, or to some point within the object itself? What if we want to have the stretch end at some distance, instead of always penetrating the target?
Applying World Offsets
It will be useful to have world offsets in cases where, for example, we want the Stretchy to appear in front of the tethered objects from the camera's forward perspective. To begin, we'll need a pair of target offsets:
public Vector3[] targetOffset = new Vector3[2];
The offsets are public so they can be set directly, but we should also have a method to set the offset when the target is set:
public void TetherEndToTransformWithOffset(int end, Transform target, Vector3 offset) { if (end == 0 || end == 1) { targetObj[end] = target; targetOffset[end] = offset; } }
The old TetherEndToTransform method should now be revised so it clears the offset:
public void TetherEndToTransform(int end, Transform target) { if (end == 0 || end == 1) TetherEndToTransformWithOffset(end, target, Vector3.zero); }
Now we need to revise the Update loop so it applies the offsets to each target's position:
for (int i = 0; i < 2; i++) if (targetObj[i] != null) targetPoint[i] = targetObj[i].position + targetOffset[i];
Remember that the basic Stretchy object automatically initializes the target points to the ends of the stretched object in its Start method. The StretchyTethered object should follow the same pattern so that the Stretchy and its targets can be pre-arranged in the level and the target offsets will be set automatically based on these initial positions. Here's the new code we need:
protected override void Start() { base.Start(); RefreshTargetOffsets(); } public void RefreshTargetOffsets() { for (int i = 0; i < 2; i++) if (targetObj[i] != null) targetOffset[i] = endPoints[i] - targetObj[i].position; }
Very simple! We just get the world-distance between each endpoint and its target. If an end has no target object the offset won't be used, so we just leave it alone. I named it RefreshTargetOffsets instead of InitTargetOffsets as an indication that it can be called any time to apply offsets based on the current arrangement of the Stretchy and its targets.
Automatic Swapping
Earlier we made a SwapTargetPoints method for the Stretchy class that switches which target point applies to which end of the stretched object. For our subclass we'll need a method to swap the target objects, but we don't usually want to swap the target points, just the targets and their offsets. So we'll make this a separate method, but include it in our SwapTargets override in case it makes sense to swap everything:
public void SwapTargetObjects() { targetObj.Swap(); targetOffset.Swap(); }
It turns out that the SwapTargets override for this subclass doesn't need to call back to the base class. When we swap target objects, it's better if the targetPoint vectors are left alone. We'll use the "new" keyword to tell the compiler that our SwapTargets conceals Stretchy.SwapTargets, and it will just call SwapTargetObjects:
public new void SwapTargets() { SwapTargetObjects(); }
Misplaced Ends?
As it turns out our SwapTargets methods will be immediately useful. You may notice it's sometimes hard to tell which end is which just by looking at a Stretchy object. It's possible that the ends might be reversed and you'd never notice it. When this happens the offsets end up being way off and the movement tracking appears very strange indeed! So we need to add some code to RefreshTargetOffsets to make sure the right end is associated with each targetObj before setting the target offsets.
Before we jump into the code we should take a moment to think about the logic we're going to need. As a first principle, we shouldn't swap the target objects, because the target objects are most likely set with intention and we don't want to annoy level designers.
So here's what we require for each possible case where the ends are aligned backwards:
- If there are no targets, that's easy, as there's nothing to do.
- If only one end has a target but the stretchy is backwards, we'll just swap the targetPoint vectors that are being used as the global points.
- If both ends have targets we just need to switch which target is used when figuring the offsets. As soon as Update runs the ends will align with the targets and switch places.
With all that in mind, let's begin! Starting out, we want to figure out how many targets there are, and which ones are set. If none are set then we can do nothing and return:
public void RefreshTargetOffsets() { int targetCount = 0; bool[] hasTarget = new bool[2]; for (int i = 0; i < 2; i++) if (hasTarget[i] = targetObj[i] != null) targetCount++; if (targetCount == 0) return;
Through targetCount we now know whether there are 1 or 2 targets set, and hasTarget tells us which ends have targets. Next we need to get the current end points and create a variable to hold our targets' global positions. Later, the endPoints will be subtracted from the target objects' positions to derive the current offsets.
Vector3[] endPt = endPoints, targetWorldPos = new Vector3[2];
We'll use a switch construct to divide up our cases, and the last thing we'll do is calculate the targetOffset vectors based on endPt and targetWorldPos. So the rest of the function consists of this shell code:
switch (targetCount) { // sort out the targets (code below) } for (int i = 0; i < 2; i++) targetOffset[i] = endPt[i] - targetWorldPos[i]; } // end of RefreshTargetOffsets
Knowing where we're headed makes it a bit easier! Here's the code for the case of a single target:
case 1: { int tetherEnd = hasTarget[0] ? 0 : 1, otherEnd = 1 - tetherEnd; Vector3 targetPos = targetObj[tetherEnd].position; if ((endPt[otherEnd] - targetPos).magnitude < (endPt[tetherEnd] - targetPos).magnitude) { SwapTargetPoints(); endPt.Swap(); } targetWorldPos[tetherEnd] = targetPos; targetWorldPos[otherEnd] = endPt[otherEnd]; } break;
Briefly, here's what the above code does:
- The variable tetherEnd equals the index of the end that has a target, while otherEnd is, well, the other end.
- The variable targetPos contains the position of the single target object.
- We compare the other-end-to-target distance to the target-end-to-target distance. If the "other end" is actually closer to the target than the "target end" itself, then we swap the target points and we swap our local copies of the current end points, which will cause the targetOffset vectors to be set based on the swapped endpoints.
- Finally, targetWorldPos is set so the tethered end will be compared with the target position, while the other end will be compared with the farther end's position.
We handle two targets in a very similar way:
case 2: { float[] firstEndDistance = new float[2]; for (int i = 0; i < 2; i++) { targetWorldPos[i] = targetObj[i].position; firstEndDistance[i] = (endPt[0] - targetWorldPos[i]).magnitude; } if (firstEndDistance[1] < firstEndDistance[0]) endPt.Swap(); } break;
The code here should look pretty familiar, yet different. Here's what it does:
- The variable firstEndDistance will hold the distance from the first end to each target.
- In the loop we get the targetWorldPos directly from each target.
- We also calculate firstEndDistance in the loop.
- Finally, we compare the distance from the first end to each target. If the first end is actually closer to the second target, we swap our local copies of the current end points. This leads to the targetOffset vectors being set correctly in the loop after the switch block.
Distance Margin
Now that our world offsets are in bang-up shape, let's implement a "distance margin" so the ends of the Stretchy can be made to maintain a distance from the targets without affecting the orientation. We'll implement this feature in the base Stretchy class, because it can apply to any subclass.
First we'll add an array named targetMargin as a public property. This will allow us to set the margins in the editor and see how changing them affects the simulation.
public float[] targetMargin = new float[2] { 0, 0 };
Before we forget, we should make sure targetMargin is also swapped in SwapTargetObjects so the targetMargin values will be switched whenever the targets are switched. (We can leave this out if we want the margins to be associated with the ends instead of the targets.) We do this by adding the line:
targetMargin.Swap();
Now we just need to change Stretchy.Update so it takes the new margins into account. Here's the code first, then we'll break it down:
Vector3 targetDiff = targetLocalPos[1] - targetLocalPos[0]; // 1 float localDistance = targetDiff.magnitude, // 2 localMargin0 = targetMargin[0] / scaleFactor, // 3 localMargin1 = targetMargin[1] / scaleFactor, lengthScale = localDistance - (localMargin0 + localMargin1); // 4 Vector3 localScale = T.localScale; // 5 localScale.z = (lengthScale > 0) ? lengthScale : 0; // 6 T.localScale = localScale; // 7 T.localPosition = (targetLocalPos[0] + targetLocalPos[1]) / 2f; // 8 T.localRotation = Quaternion.LookRotation(targetDiff); // 9 T.localPosition += (localMargin0 - localMargin1) * T.forward / 2f; // 10 } // end Update
- As before, get the difference between the two targets in local space.
- Get the linear distance between the two targets as our starting-point.
- Get the new targetMargin values, applying the scaleFactor. (I knew that would come in handy!)
- Subtract the margins from our total distance to get the new stretch length.
- Get the localScale into a temporary Vector3 so we can modify it.
- Set the Z scaling to the shortened (or lengthened) distance. We prevent it being set to a negative number, which would cause ugly things to happen.
- Store the new scaling value.
- Position the Stretchy exactly centered between the targets, as before.
- Rotate the Stretchy so it points towards the second target, as before.
- Adjust the Stretchy's position according to the difference between the margins.
That last step is perhaps the most interesting one. Since we already rotated the transform in the previous step, T.forward points towards the second target. So we just multiply that by the difference between the margins, and the Stretchy is adjusted in the proper direction.
Your Unity Homework
At this point Stretchy and StretchyTethered are pretty comprehensive little classes, but they could still use some enhancements to make them all the more useful. Rather than double or triple the length of this article, I will leave these extra features up to you to figure out. Bonus points if you can solve all of them!
- Implement an additional offset within the target, which rotates and scales with the target.
- Allow scaling of X or Y in addition to Z. This would, for example, allow directly scaling cylinders which use the Y axis for their height. (Currently you have to make cylinders a child of the Stretchy and rotate them 90 degrees.)
- Allow the use of geometry that isn't exactly 1 unit long on the stretched axis. Then you can use any model without needing to scale it down on import.
- Add an option for one or more transforms to move and rotate in alignment with the ends of the Stretchy but which don't themselves stretch. This will be useful for things like an arrowhead that stays a constant size, a particle system that simulates a laser blast, etc.
Until next time, have fun tethering things together! I look forward to hearing how you end up using Stretchy objects in your own Unity projects. Please follow me on Twitter and Google+ where I post about Unity, RepRap, and other subjects of interest.