Stretchy Objects in Unity 3D - Part 2

In Part 1 of this tutorial we got acquainted with the mathematics and methods to scale and rotate an object between any two points in world space. We took advantage of Unity's built-in methods to significantly reduce the amount of code and trigonometric voodoo we might otherwise need. We extended our base class so we could tether each end to a transform or a point. And we enhanced our subclass by adding a simple world offset for each target. Good job!

Last time I assigned you some homework. I'm sure you found it easy as pie. To recap, I asked you to take on four additional challenges:

  1. Implement an additional offset within the target, which rotates and scales with the target.
  2. Allow scaling of X or Y in addition to Z.
  3. Allow the use of geometry that isn't exactly 1 unit long on the stretched axis.
  4. Add an "arrowhead" option: a transform that's kept aligned with the end but isn't scaled.

Today I'd like to focus on just the first task. Implementing this feature is actually pretty easy, but first we need to take some time to prepare the code for the next stage. This will require enough thinking —and rethinking— to occupy an entire article, as we will see!

If you'd like to see the full code and follow along with the bouncing objects, please download the updated Stretchy Demo Project, decompress it, and open up the StretchyDemo.unity scene file in Unity. Also find this demo at the Unity-Stretchy project at Github.

When Arrays Attack

Last time, we implemented the StretchyTethered subclass, an extended Stretchy that can tether to transforms in addition to points. We gave it two target pointers and two offset vectors:

public Transform[] targetObj = new Transform[2];
public Vector3[] targetWorldOffset = new Vector3[2];

We could continue to go forward in the same way for the new local offset feature and just add another new property like so:

public Vector3[] targetLocalOffset = new Vector3[2];

…but we don't want to keep doing that as we add more properties. It may seem like splitting hairs, but if we continue to add more properties in this manner, it will tend to make the code ugly and disorganized. Even now, the relationship between these arrays is less than obvious. We're also wasting precious bytes allocating space for offsets and transforms that may never even be needed.

The transform and world offsets should really be grouped together into an object. That way we'll only need a single array declaration:

public StretchyTarget[] target = new StretchyTarget[2];

But do we really want to create a whole new class just for this purpose? It might be more hassle than it's worth!

Questions like these are hard to answer in any absolute way. Whether or not it makes sense to manage these properties within the StretchyTethered class depends on our intended usage. The class we built last time allows us to tether to any Transform we want, and that has some advantages. But we have understandable worries about such a change. If we move these properties into a new class, will we lose the ability to tether to any transform?

Components to the Rescue

One of the things that makes Unity so powerful is its Component system. In case you aren't completely in love with Components, let me briefly explain why they're so cool. In short, a component system makes it possible to dynamically attach behaviors and properties to a game object during run-time. When you no longer need the behavior provided by some component you can just remove it and throw it away, freeing up any resources it was using. Virtually everything a Unity game object does, whether it's rendering a mesh, emitting particles, applying physics, or anything else, is implemented as a Component. Even the base Transform can be treated as a Component.

To make a new behavior component in Unity you simply create a new script file (either Javascript or C#) and implement a subclass of MonoBehaviour. As soon as you do this you'll be able to attach an instance of your behavior to any game object, both in the Unity editor and through the GameObject.AddComponent method. Whenever a game object is active with your behavior attached and enabled, Unity automatically calls its methods (Awake, Start, FixedUpdate, Update, OnEnable, etc.) at the appropriate times.

Returning to our Stretchy target transform question, if we want to be able to tether to any transform, components provide us with all the power we need. We just have to implement our StretchyTarget class a subclass of MonoBehaviour and we'll be able to add a StretchyTarget to any transform that doesn't already have one when we tether it (and remove it later). All in all this sounds like a good way to go. Let's do it!

The StretchyTarget Component

public class StretchyTarget : MonoBehaviour {
  protected Transform T;
  public Vector3 worldOffset = Vector3.zero, localOffset = Vector3.zero;
  public bool useWorldOffset = true, useLocalOffset = false;
  public float defaultMargin = 0;

  public void Awake() { T = transform; }

  public void SetOffsets(Vector3 localOffs, Vector3 worldOffs) {
    localOffset = localOffs;
    worldOffset = worldOffs;
  }
}

Simple! Let's review. Following the same pattern as we did with Stretchy, we'll use the property T to cache the transform. Next, we allocate space for a worldOffset like we implemented in Part 1, and a localOffset that we'll being figuring out shortly. The flags useWorldOffset and useLocalOffset will allow us to turn the offsets on and off without changing them, and we should also be able to use these flags to reduce the amount of processing in StretchyTethered.Update. The defaultMargin property will allow a StretchyTarget to provide an initial value for Stretchy.targetMargin. Next to last, we add code to initialize T in Awake. Finally, the SetOffsets method provides a convenient way to set both offsets at once.

Using StretchyTarget

As I suggested above, we'll replace those two-element arrays with a single array of StretchyTarget objects, and we'll rewrite StretchyTethered to use this new object in place of the old properties. Our single declaration is much cleaner:

public StretchyTarget[] target = new StretchyTarget[2];

As you can see, this will have implications for which objects can be pre-attached in a scene. Only objects with a StretchyTarget behavior will be able to connect within the Unity editor. Personally, I don't think that's a big issue. We can easily add a method for tethering to a transform. Check it out:

// StretchyTethered.TetherEndToTransformWithOffset
public void TetherEndToTransformWithOffset(int end, Transform trans, Vector3 offset, bool isWorld=false) {
  GameObject o = trans.gameObject;
  StretchyTarget t = o.GetComponent<StretchyTarget>();
  if (t == null) t = o.AddComponent<StretchyTarget>();
  TetherEndToTargetWithOffset(end, t, offset, isWorld);
}

As you can see, it's ridiculously easy to check for the existence of a component and to add one when you need it. The TetherEndToTransformWithOffset method is just a thin wrapper for TetherEndToTargetWithOffset, which now becomes the "main" way to attach a target to the end of a stretchy:

// StretchyTethered.TetherEndToTargetWithOffset
public void TetherEndToTargetWithOffset(int end, StretchyTarget t, Vector3 offset, bool isWorld=false) {
  if (end == 0 || end == 1) {
    if (t != null) {
      if (isWorld)
        t.SetOffsets(Vector3.zero, offset);
      else
        t.SetOffsets(offset, Vector3.zero);
    }
    target[end] = t;
  }
}

public void TetherEndToTarget(int end, StretchyTarget t) {
  TetherEndToTargetWithOffset(end, t, Vector3.zero);
}

These methods don't need much explanation. You can pass null as the StretchyTarget to clear the target. The offset argument will be treated as a world or local offset depending on the isWorld argument, which means you can only enable one or the other, not both, using this method. For most applications this will be fine, and we can provide a way to set both if needed.

The Untether method can now call TetherEndToTarget instead of TetherEndToTransform. We might as well shorten it slightly while we're at it:

public void Untether(int end=-1) {
  if (end == -1) {
    TetherEndToTarget(0, null);
    end = 1;
  }
  TetherEndToTarget(end, null);
}

Behavior Callback Updates

Next let's take a look at how the behavior callbacks Start and Update are going to be affected. You may recall from our previous installment that we used the Start callback as our golden opportunity to initialize the target offsets based on the current scene layout. We're still going to do that, and we're also going to initialize the Stretchy.targetMargin values based on the StretchyTarget.defaultMargin property we added earlier:

protected override void Start() {
  base.Start();
  RefreshTargetOffsets();
  ResetTargetMargins();
}

void ResetTargetMargins() {
  for (int i = 0; i < 2; i++)
    if (target[i] != null)
      targetMargin[i] = target[i].defaultMargin;
}

You may remember in our first attempt at StretchyTethered that we decided to initialize our offsets from the world-based distances between the ends of the stretchy object and its targets. Then in the Update method we simply added the offset to each target's position. This caused them to behave as world-based offsets. The code ended up being quite neat:

// StretchyTethered.Update (the old way)
protected override void Update() {
  for (int i = 0; i < 2; i++)
    if (targetObj[i] != null)
      targetPoint[i] = targetObj[i].position + targetOffset[i];
  base.Update();
}

In that older routine we directly access each target's transform, position, and world offset to produce a world-based tether point for each end. That's a fine way to do it, but for our updated scheme it will make more sense to ask the target for this point. Let's add an accessor to StretchyTarget that will give us the target's current tether point in world space:

// StretchyTarget.worldTetherPoint
public Vector3 worldTetherPoint {
  get {
    Vector3 wtp = useLocalOffset ? T.TransformPoint(localOffset) : T.position;
    return useWorldOffset ? wtp + worldOffset : wtp;
  }
}

That couldn't be much simpler, and yet this little piece of code contains the full solution to last week's first homework challenge! If you blink you could miss it, so let's take a closer look. On Line 4 the code checks the useLocalOffset flag. If it's set, it converts the localOffset to a world point based on the position, rotation, and scaling of its own transform. Otherwise it just gets its center point as a world point. Finally, if useWorldOffset is set it adds the worldOffset vector to the returned point.

I thought it would be a lot more difficult to implement localOffset.

So what does our Update handler look like now? Pretty much perfect:

// StretchyTethered.Update (the new way)
protected override void Update() {
  for (int i = 0; i < 2; i++) {
    StretchyTarget t = target[i];
    if (t != null) targetPoint[i] = t.worldTetherPoint;
  }
  base.Update();
}

Spread the Love

At this point we've covered the essentials to make a working StretchyTarget, but there are a few more methods in need of some attention. Before we dive into those, let's add some utility methods to StretchyTarget so we can get useful information about the current position:

  // StretchyTarget utility methods
  public Vector3 position {
    get { return T.position; }
    set { T.position = value; }
  }
  public Vector3 WorldOffsetToPoint(Vector3 point) {
    return point - T.position;
  }
  public Vector3 LocalOffsetToPoint(Vector3 point) {
    return T.InverseTransformPoint(point);
  }
  public float WorldDistanceToPoint(Vector3 point) {
    return WorldOffsetToPoint(point).magnitude;
  }
  public float LocalDistanceToPoint(Vector3 point) {
    return LocalOffsetToPoint(point).magnitude;
  }

Revising RefreshTargetOffsets

The most complicated method in the StretchyTethered class was RefreshTargetOffsets because it had to do some extra logic to ensure the proper ends were being associated with the targets. In our updated implementation, the setup code remains almost exactly the same:

public void RefreshTargetOffsets() {
  int targetCount = 0;
  bool[] hasTarget = new bool[2];
  for (int i = 0; i < 2; i++)
    if (hasTarget[i] = (target[i] != null)) targetCount++;

  if (targetCount == 0) return;

  Vector3[] endPt = endPoints; // Get start and end points of the stretch

The only change here is that targetObj (Transform pointers) has been replaced with target (StretchyTarget pointers).

The code that handles the swapping of targets becomes slightly smaller:

if (targetCount == 1) {
  int targetEnd = hasTarget[0] ? 0 : 1;
  StretchyTarget t = target[targetEnd];
  if (t.WorldDistanceToPoint(endPt[1-targetEnd]) < t.WorldDistanceToPoint(endPt[targetEnd])) {
    SwapTargetPoints();
    endPt.Swap();
  }
}
else {
  if (target[1].WorldDistanceToPoint(endPt[0]) < target[0].WorldDistanceToPoint(endPt[0]))
    endPt.Swap();
}

Those new utility methods have certainly made this code a lot simpler.

The code to update the offsets at the end of this method has probably been changed the most. It now uses the state of the useWorldOffset flag to decide whether to treat the offset as being local or world-based. This allows prefabricated targets in a scene to decide ahead of initialization how the current offset should be treated. Here's how our method finishes up:

  for (int i = 0; i < 2; i++) {
    if (hasTarget[i]) {
      StretchyTarget t = target[i];
      if (t.useWorldOffset)
        t.SetOffsets(Vector3.zero, t.WorldOffsetToPoint(endPt[i]));
      else
        t.SetOffsets(t.LocalOffsetToPoint(endPt[i]), Vector3.zero);
    }
  }

} // end of RefreshTargetOffsets

Other Changes?

There are several other enhancements we can, could, and probably should add, but I won't go over them all here. As I add further enhancements (see the task list at the top of this article) I will post them in future installments. Meanwhile, download the code, visit the Github project page, and have a look at the code, which is quite small. If you've been following along closely, it should be easy to comprehend what's going on in the parts we haven't discussed.

I hope you'll find these classes useful. Let me know if you use Stretchy objects in your own Unity projects, and please share your experiences with the Unity community through the usual sites and social media channels. Please follow me on Twitter and Google+ where I post about Unity, RepRap, and other subjects of interest.