Dynamic Animation Part 1

Unity has some pretty decent animation abilities built into the engine with which you can keyframe the position of entire hierarchies of objects. If you were animating a walk cycle or a bouncing ball, it works great. Other common use cases, like animating the move of a UI element when supporting multiple screen aspect ratios, are another issue. When you don’t know what the size of your screen will be, you can’t keyframe an animation for it. There are plenty of plugins on the asset store which solve this problem, but if you are like me and want to know how it all works, or you simply don’t want to spend money, read along and we will create our own solution.

Introduction to Easing Equations

Our dynamic animation system won’t be based on keyframe curves like the one Unity has built-in. Instead, we will make a system based off of math- easing equations. For a quick example of how an easing equation can be used, Create a new scene, add a cube placed at XYZ (-5, 0, 0) and attach the following script to it:

public class Temp : MonoBehaviour 
{
	void Start ()
	{
		StartCoroutine("Tween");
	}

	IEnumerator Tween ()
	{
		float start = -5;
		float end = 5;
		float duration = 3;
		float time = 0;

		while (time < duration)
		{
			yield return new WaitForEndOfFrame();
			time = Mathf.Clamp(time + Time.deltaTime, 0, duration);
			float value = (end - start) * (time / duration) + start;
			transform.localPosition = new Vector3( value, 0, 0 );
		}
	}
}

If you run this sample, you will see the cube move from the left side of the screen toward the right over the course of three seconds. The “Easing” of this animation is “Linear” because it moves at the same rate for the duration of the animation. Different math functions can produce variations that feel more natural for this purpose. For example, it would be nice if the cube started slowly, ramped up to its full speed, and then slowed back down to a gentle stop. A sample easing equation which fits this description is “EaseInOutQuad”. The math for that is much more complex, but luckily, the formulas are found freely on the internet. I’ve been using a bunch of them for years, converting them from one programming language to another.

// Copyright (c) 2011 Bob Berkebile (pixelplacment)
// Please direct any bugs/comments/suggestions to http://pixelplacement.com
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

/*
TERMS OF USE - EASING EQUATIONS
Open source under the BSD License.
Copyright (c)2001 Robert Penner
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
Neither the name of the author nor the names of contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
using UnityEngine;
using System;

public static class EasingEquations
{
	public static float Linear (float start, float end, float value)
	{
		return (end - start) * value + start;
	}

	public static float Spring (float start, float end, float value)
	{
		value = Mathf.Clamp01 (value);
		value = (Mathf.Sin (value * Mathf.PI * (0.2f + 2.5f * value * value * value)) * Mathf.Pow (1f - value, 2.2f) + value) * (1f + (1.2f * (1f - value)));
		return start + (end - start) * value;
	}

	public static float EaseInQuad (float start, float end, float value)
	{
		end -= start;
		return end * value * value + start;
	}

	public static float EaseOutQuad (float start, float end, float value)
	{
		end -= start;
		return -end * value * (value - 2) + start;
	}

	public static float EaseInOutQuad (float start, float end, float value)
	{
		value /= .5f;
		end -= start;
		if (value < 1)
			return end / 2 * value * value + start;
		value--;
		return -end / 2 * (value * (value - 2) - 1) + start;
	}

	public static float EaseInCubic (float start, float end, float value)
	{
		end -= start;
		return end * value * value * value + start;
	}

	public static float EaseOutCubic (float start, float end, float value)
	{
		value--;
		end -= start;
		return end * (value * value * value + 1) + start;
	}

	public static float EaseInOutCubic (float start, float end, float value)
	{
		value /= .5f;
		end -= start;
		if (value < 1)
			return end / 2 * value * value * value + start;
		value -= 2;
		return end / 2 * (value * value * value + 2) + start;
	}

	public static float EaseInQuart (float start, float end, float value)
	{
		end -= start;
		return end * value * value * value * value + start;
	}

	public static float EaseOutQuart (float start, float end, float value)
	{
		value--;
		end -= start;
		return -end * (value * value * value * value - 1) + start;
	}

	public static float EaseInOutQuart (float start, float end, float value)
	{
		value /= .5f;
		end -= start;
		if (value < 1)
			return end / 2 * value * value * value * value + start;
		value -= 2;
		return -end / 2 * (value * value * value * value - 2) + start;
	}

	public static float EaseInQuint (float start, float end, float value)
	{
		end -= start;
		return end * value * value * value * value * value + start;
	}

	public static float EaseOutQuint (float start, float end, float value)
	{
		value--;
		end -= start;
		return end * (value * value * value * value * value + 1) + start;
	}

	public static float EaseInOutQuint (float start, float end, float value)
	{
		value /= .5f;
		end -= start;
		if (value < 1)
			return end / 2 * value * value * value * value * value + start;
		value -= 2;
		return end / 2 * (value * value * value * value * value + 2) + start;
	}

	public static float EaseInSine (float start, float end, float value)
	{
		end -= start;
		return -end * Mathf.Cos (value / 1 * (Mathf.PI / 2)) + end + start;
	}

	public static float EaseOutSine (float start, float end, float value)
	{
		end -= start;
		return end * Mathf.Sin (value / 1 * (Mathf.PI / 2)) + start;
	}

	public static float EaseInOutSine (float start, float end, float value)
	{
		end -= start;
		return -end / 2 * (Mathf.Cos (Mathf.PI * value / 1) - 1) + start;
	}

	public static float EaseInExpo (float start, float end, float value)
	{
		end -= start;
		return end * Mathf.Pow (2, 10 * (value / 1 - 1)) + start;
	}

	public static float EaseOutExpo (float start, float end, float value)
	{
		end -= start;
		return end * (-Mathf.Pow (2, -10 * value / 1) + 1) + start;
	}

	public static float EaseInOutExpo (float start, float end, float value)
	{
		value /= .5f;
		end -= start;
		if (value < 1)
			return end / 2 * Mathf.Pow (2, 10 * (value - 1)) + start;
		value--;
		return end / 2 * (-Mathf.Pow (2, -10 * value) + 2) + start;
	}

	public static float EaseInCirc (float start, float end, float value)
	{
		end -= start;
		return -end * (Mathf.Sqrt (1 - value * value) - 1) + start;
	}

	public static float EaseOutCirc (float start, float end, float value)
	{
		value--;
		end -= start;
		return end * Mathf.Sqrt (1 - value * value) + start;
	}

	public static float EaseInOutCirc (float start, float end, float value)
	{
		value /= .5f;
		end -= start;
		if (value < 1)
			return -end / 2 * (Mathf.Sqrt (1 - value * value) - 1) + start;
		value -= 2;
		return end / 2 * (Mathf.Sqrt (1 - value * value) + 1) + start;
	}

	public static float EaseInBounce (float start, float end, float value)
	{
		end -= start;
		float d = 1f;
		return end - EaseOutBounce (0, end, d - value) + start;
	}

	public static float EaseOutBounce (float start, float end, float value)
	{
		value /= 1f;
		end -= start;
		if (value < (1 / 2.75f)) {
			return end * (7.5625f * value * value) + start;
		} else if (value < (2 / 2.75f)) {
			value -= (1.5f / 2.75f);
			return end * (7.5625f * (value) * value + .75f) + start;
		} else if (value < (2.5 / 2.75)) {
			value -= (2.25f / 2.75f);
			return end * (7.5625f * (value) * value + .9375f) + start;
		} else {
			value -= (2.625f / 2.75f);
			return end * (7.5625f * (value) * value + .984375f) + start;
		}
	}

	public static float EaseInOutBounce (float start, float end, float value)
	{
		end -= start;
		float d = 1f;
		if (value < d / 2)
			return EaseInBounce (0, end, value * 2) * 0.5f + start;
		else
			return EaseOutBounce (0, end, value * 2 - d) * 0.5f + end * 0.5f + start;
	}

	public static float EaseInBack (float start, float end, float value)
	{
		end -= start;
		value /= 1;
		float s = 1.70158f;
		return end * (value) * value * ((s + 1) * value - s) + start;
	}

	public static float EaseOutBack (float start, float end, float value)
	{
		float s = 1.70158f;
		end -= start;
		value = (value / 1) - 1;
		return end * ((value) * value * ((s + 1) * value + s) + 1) + start;
	}

	public static float EaseInOutBack (float start, float end, float value)
	{
		float s = 1.70158f;
		end -= start;
		value /= .5f;
		if ((value) < 1) {
			s *= (1.525f);
			return end / 2 * (value * value * (((s) + 1) * value - s)) + start;
		}
		value -= 2;
		s *= (1.525f);
		return end / 2 * ((value) * value * (((s) + 1) * value + s) + 2) + start;
	}

	public static float Punch (float amplitude, float value)
	{
		float s = 9;
		if (value == 0) {
			return 0;
		}
		if (value == 1) {
			return 0;
		}
		float period = 1 * 0.3f;
		s = period / (2 * Mathf.PI) * Mathf.Asin (0);
		return (amplitude * Mathf.Pow (2, -10 * value) * Mathf.Sin ((value * 1 - s) * (2 * Mathf.PI) / period));
	}

	public static float EaseInElastic (float start, float end, float value)
	{
		end -= start;
	
		float d = 1f;
		float p = d * .3f;
		float s = 0;
		float a = 0;
	
		if (value == 0)
			return start;
	
		if ((value /= d) == 1)
			return start + end;
	
		if (a == 0f || a < Mathf.Abs (end)) {
			a = end;
			s = p / 4;
		} else {
			s = p / (2 * Mathf.PI) * Mathf.Asin (end / a);
		}
	
		return -(a * Mathf.Pow (2, 10 * (value -= 1)) * Mathf.Sin ((value * d - s) * (2 * Mathf.PI) / p)) + start;
	}

	public static float EaseOutElastic (float start, float end, float value)
	{
		end -= start;
	
		float d = 1f;
		float p = d * .3f;
		float s = 0;
		float a = 0;
	
		if (value == 0)
			return start;
	
		if ((value /= d) == 1)
			return start + end;
	
		if (a == 0f || a < Mathf.Abs (end)) {
			a = end;
			s = p / 4;
		} else {
			s = p / (2 * Mathf.PI) * Mathf.Asin (end / a);
		}
	
		return (a * Mathf.Pow (2, -10 * value) * Mathf.Sin ((value * d - s) * (2 * Mathf.PI) / p) + end + start);
	}

	public static float EaseInOutElastic (float start, float end, float value)
	{
		end -= start;
	
		float d = 1f;
		float p = d * .3f;
		float s = 0;
		float a = 0;
	
		if (value == 0)
			return start;
	
		if ((value /= d / 2) == 2)
			return start + end;
	
		if (a == 0f || a < Mathf.Abs (end)) {
			a = end;
			s = p / 4;
		} else {
			s = p / (2 * Mathf.PI) * Mathf.Asin (end / a);
		}
	
		if (value < 1)
			return -0.5f * (a * Mathf.Pow (2, 10 * (value -= 1)) * Mathf.Sin ((value * d - s) * (2 * Mathf.PI) / p)) + start;
		return a * Mathf.Pow (2, -10 * (value -= 1)) * Mathf.Sin ((value * d - s) * (2 * Mathf.PI) / p) * 0.5f + end + start;
	}
}

With that library of easing equations added to your project, change line 19 of the first demo script to the following:

float value = EasingEquations.EaseInOutQuad(start, end, (time / duration));

Play the scene again. The cube goes from the same place, to the same place, in the same amount of time, but because of the easing it looks much better. Take some time to try several of the other equations as well, like “EaseOutBounce” or “EaseOutBack” – the variety of options is quite nice.

Making a Reusable Animation Control

Even though the Easing Equations themselves are reusable, it would be nice to wrap up a lot of the common needs we might encounter, like specifying start and end values, duration, etc and adding the ability to do things like loop the animation, pause and resume, etc. into a reusable component. Let’s do that now. Create a new script called “EasingControl.cs”

I don’t want to hardcode “what” this control actually modifies. It will simply tween a value over an easing curve and then I will apply it to something else after the fact. In order to keep it reusable like this, I will need to expose some events so that I can take action at certain times. We will have an event for every “Update Tick” of the animation so that we can update the positions of things on screen, I may want to know when the state of the control changes (like from pause to play or vice versa), I will frequently want to be notified when an animation has completed, and might want to know when it has looped. Make sure you include the System namespace. These events are defined as follows:

public event EventHandler updateEvent;
public event EventHandler stateChangeEvent;
public event EventHandler completedEvent;
public event EventHandler loopedEvent;

I will define some enums to help clarify the intent of several features the control will support. The first, “TimeType” defines how the control will update itself. “Normal” will be a normal update loop, just waiting for the end of each frame and incrementing at the speed that Unity’s Time.deltaTime returns (this means you could pause all animations by setting the time scale to zero). Sometimes you want animations to play even while time scale is zero, such as animation of UI elements, so we will add another mode called “Real”. Finally we might want the update loop to be tied to the Physics engine, so for that we will specify “Fixed”.

public enum TimeType
{
	Normal,
	Real,
	Fixed,
};

There may be scenarios where you need to query the state of our control, so those will be defined by the “PlayState” which hopefully is self documenting.

public enum PlayState
{
	Stopped,
	Paused,
	Playing,
	Reversing,
};

Whenever the animation completes, we might want to allow it to remain in place, or reset to its starting position:

public enum EndBehaviour
{
	Constant,
	Reset,
};

And if we have enabled looping, we might want it to reset for each loop, or reverse back to the beginning instead:

public enum LoopType
{
	Repeat,
	PingPong,
};

We will need a field to store the current value for each of the enums we just defined. I use two for the “PlayState” so that I can know what state to restore to when I “Resume” a “Paused” animation. I will also create a convenience property to let me know if the control is “playing” which can include playing forward or reversed.

public TimeType timeType = TimeType.Normal;
public PlayState playState { get; private set; }
public PlayState previousPlayState { get; private set; }
public EndBehaviour endBehaviour = EndBehaviour.Constant;
public LoopType loopType = LoopType.Repeat;
public bool IsPlaying { get { return playState == PlayState.Playing || playState == PlayState.Reversing; }}

Just like the initial demo script, we will need a value to animate from, a value to animate to, and a duration for the animation. In addition, I will add a loopCount field to allow us to specify how many times the animation will play. A loopCount of zero plays once – no looping. A loopCount of 1 or more plays that many times again. If you want an animation to play infinitely, pass it a negative value such as -1. I will use a “Func” delegate to determine what kind of easing equation to interpolate over. This delegate will take a “from” value, “to” value, “time” value, and return the easing value at that time. Any of the static methods in the EasingEquations class are compatible, and I will default to one of them. These fields are defined as follows:

public float startValue = 0.0f;
public float endValue = 1.0f;
public float duration = 1.0f;
public int loopCount = 0;
public Func<float, float, float, float> equation = EasingEquations.Linear;

Finally, we need the fields which store the current animation values of the control. I will want to be able to see those values in order to drive the implementation on some other object, but I don’t want to allow other scripts to modify the values directly. We will expose the:

  • Current Time, which will range from zero to the duration specified
  • Current Value, which is the value calculated by the Easing Equation at any moment of time
  • Current Offset, which is the amount of change in value since the last frame (you may not need this in many use cases)
  • And the number of times the animation has looped.
public float currentTime { get; private set; }
public float currentValue { get; private set; }
public float currentOffset { get; private set; }
public int loops { get; private set; }

Since this control inherits from MonoBehaviour (in order to tie into the update loop) we will also take advantage of a few of its methods. Any GameObject using an this control should pause when disabled and resume when enabled. The Resume and Pause methods will be shown next.

void OnEnable ()
{
	Resume();
}

void OnDisable ()
{
	Pause();
}

We will need a few public methods for this control to enable it to begin playing on command, pause, resume, stop, or skip to any point in the animation.

public void Play ()
{
	SetPlayState(PlayState.Playing);
}

public void Reverse ()
{
	SetPlayState(PlayState.Reversing);
}

public void Pause ()
{
	SetPlayState(PlayState.Paused);
}

public void Resume ()
{
	SetPlayState(previousPlayState);
}

public void Stop ()
{
	SetPlayState(PlayState.Stopped);
	loops = 0;
	if (endBehaviour == EndBehaviour.Reset)
		SeekToBeginning ();
}

public void SeekToTime (float time)
{
	currentTime = Mathf.Clamp01(time / duration);
	float newValue = (endValue - startValue) * currentTime + startValue;
	currentOffset = newValue - currentValue;
	currentValue = newValue;

	if (updateEvent != null)
		updateEvent(this, EventArgs.Empty);
}

public void SeekToBeginning ()
{
	SeekToTime(0.0f);
}

public void SeekToEnd ()
{
	SeekToTime(duration);
}

I forced my script to implement the change of state in a single method, so that it is easy to make sure all state changes post an event. Also, we can conveniently start or stop a Coroutine which will be driving the update loop based on the state we change to.

void SetPlayState (PlayState target)
{
	if (playState == target)
		return;

	previousPlayState = playState;
	playState = target;

	if (stateChangeEvent != null)
		stateChangeEvent(this, EventArgs.Empty);

	StopCoroutine("Ticker");
	if (IsPlaying)
		StartCoroutine("Ticker");
}

The update loop itself is in a Coroutine, instead of the Update method. This prevents unneeded overhead at the times that animations are not playing, as well as providing the option of updating in different types of loops such as the physics loop.

IEnumerator Ticker ()
{
	while (true)
	{
		switch (timeType)
		{
		case TimeType.Normal:
			yield return new WaitForEndOfFrame();
			Tick(Time.deltaTime);
			break;
		case TimeType.Real:
			yield return new WaitForEndOfFrame();
			Tick(Time.unscaledDeltaTime);
			break;
		default: // Fixed
			yield return new WaitForFixedUpdate();
			Tick(Time.fixedDeltaTime);
			break;
		}
	}
}

Finally, the real “meat” of the script is the actual “Tick” method, which handles the update of values over time according to the easing equation and handles looping and completion events.

void Tick (float time)
{
	bool finished = false;
	if (playState == PlayState.Playing)
	{
		currentTime = Mathf.Clamp01( currentTime + (time / duration));
		finished = Mathf.Approximately(currentTime, 1.0f);
	}
	else // Reversing
	{
		currentTime = Mathf.Clamp01( currentTime - (time / duration));
		finished = Mathf.Approximately(currentTime, 0.0f);
	}

	float frameValue = (endValue - startValue) * equation (0.0f, 1.0f, currentTime) + startValue;
	currentOffset = frameValue - currentValue;
	currentValue = frameValue;

	if (updateEvent != null)
		updateEvent(this, EventArgs.Empty);

	if (finished)
	{
		++loops;
		if (loopCount < 0 || loopCount >= loops) 
		{
			if (loopType == LoopType.Repeat) 
				SeekToBeginning();
			else // PingPong
				SetPlayState( playState == PlayState.Playing ? PlayState.Reversing : PlayState.Playing );

			if (loopedEvent != null)
				loopedEvent(this, EventArgs.Empty);
		} 
		else 
		{
			if (completedEvent != null)
				completedEvent(this, EventArgs.Empty);

			Stop ();
		}
	}
}

That completes the Easing Control script. Below is an example showing how it could be used to accomplish the demo script we made earlier, to move the cube from one side of the screen to the other. However, I will add a few of our new features, like making it loop infinitely and play back and forth in a ping-pong style.

public class Temp : MonoBehaviour
{
	EasingControl ec;

	void Start ()
	{
		ec = gameObject.AddComponent<EasingControl>();
		ec.startValue = -5;
		ec.endValue = 5;
		ec.duration = 3;
		ec.loopCount = -1; // inifinite looping
		ec.loopType = EasingControl.LoopType.PingPong;
		ec.equation = EasingEquations.EaseInOutQuad;
		ec.updateEvent += OnUpdateEvent;
		ec.Play();
	}

	void OnUpdateEvent (object sender, EventArgs e)
	{
		transform.localPosition = new Vector3( ec.currentValue, 0, 0 );
	}
}

It doesn’t necessarily look like a whole lot less code than the first example, but it is far more reusable, has many more options, and a lot of that code could have been specified in the inspector.

In the next post, we will make use of some extensions to reduce even more of this code and make it feel much more pleasant to use all over the place. For example, imagine the following snippet, which could move an object from its current location to another, in a specified amount of time, and with a specified curve, all with a single line of code!

transform.MoveTo( new Vector3(5, 0, 0), 3f, EasingEquations.EaseInOutQuad );

3 thoughts on “Dynamic Animation Part 1

  1. Hey, I liked your animation system so much I tweaked it so it worked entirely through the inspector on a single component (depending on what property you want to drive). You can drive properties of game objects using UnityEvents by deriving from your EasingControl and overriding OnUpdate. You can change the easing equation through the inspector as well.

    Demo: http://gfycat.com/AntiqueDazzlingAfricanhornbill

    Code: https://github.com/sarkahn/UnityEasingAnimation

Leave a Reply

Your email address will not be published. Required fields are marked *