using System;
using Unity.Mathematics;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace UnityEngine.Splines
{
///
/// A component to animate an object along a spline.
///
[AddComponentMenu("Splines/Spline Animate")]
[ExecuteInEditMode]
public class SplineAnimate : SplineComponent
{
///
/// Describes the different methods that can be used to animated an object along a spline.
///
public enum Method
{
/// Spline will be traversed in the given amount of seconds.
Time,
/// Spline will be traversed at a given maximum speed.
Speed
}
///
/// Describes the different ways the object's animation along the Spline can be looped.
///
public enum LoopMode
{
/// Traverse the spline once and stop at the end.
[InspectorName("Once")]
Once,
/// Traverse the spline continuously without stopping.
[InspectorName("Loop Continuous")]
Loop,
/// Traverse the spline continuously without stopping. If is set to or
/// then easing is only applied to the first loop of the animation. Otherwise, no easing is applied with this loop mode.
///
[InspectorName("Ease In Then Continuous")]
LoopEaseInOnce,
/// Traverse the spline and then reverse direction at the end of the spline. The animation plays repeatedly.
[InspectorName("Ping Pong")]
PingPong
}
///
/// Describes the different ways the object's animation along the spline can be eased.
///
public enum EasingMode
{
/// Apply no easing. The animation speed is linear.
[InspectorName("None")]
None,
/// Apply easing to the beginning of animation.
[InspectorName("Ease In Only")]
EaseIn,
/// Apply easing to the end of animation.
[InspectorName("Ease Out Only")]
EaseOut,
/// Apply easing to the beginning and end of animation.
[InspectorName("Ease In-Out")]
EaseInOut
}
///
/// Describes the ways the object can be aligned when animating along the spline.
///
public enum AlignmentMode
{
/// No aligment is done and object's rotation is unaffected.
[InspectorName("None")]
None,
/// The object's forward and up axes align to the spline's tangent and up vectors.
[InspectorName("Spline Element")]
SplineElement,
/// The object's forward and up axes align to the spline tranform's z-axis and y-axis.
[InspectorName("Spline Object")]
SplineObject,
/// The object's forward and up axes align to to the world's z-axis and y-axis.
[InspectorName("World Space")]
World
}
[SerializeField, Tooltip("The target spline to follow.")]
SplineContainer m_Target;
[SerializeField, Tooltip("Enable to have the animation start when the GameObject first loads.")]
bool m_PlayOnAwake = true;
[SerializeField, Tooltip("The loop mode that the animation uses. Loop modes cause the animation to repeat after it finishes. The following loop modes are available:.\n" +
"Once - Traverse the spline once and stop at the end.\n" +
"Loop Continuous - Traverse the spline continuously without stopping.\n" +
"Ease In Then Continuous - Traverse the spline repeatedly without stopping. If Ease In easing is enabled, apply easing to the first loop only.\n" +
"Ping Pong - Traverse the spline continuously without stopping and reverse direction after an end of the spline is reached.\n")]
LoopMode m_LoopMode = LoopMode.Loop;
[SerializeField, Tooltip("The method used to animate the GameObject along the spline.\n" +
"Time - The spline is traversed in a given amount of seconds.\n" +
"Speed - The spline is traversed at a given maximum speed.")]
Method m_Method = Method.Time;
[SerializeField, Tooltip("The period of time that it takes for the GameObject to complete its animation along the spline.")]
float m_Duration = 1f;
[SerializeField, Tooltip("The speed in meters/second that the GameObject animates along the spline at.")]
float m_MaxSpeed = 10f;
[SerializeField, Tooltip("The easing mode used when the GameObject animates along the spline.\n" +
"None - Apply no easing to the animation. The animation speed is linear.\n" +
"Ease In Only - Apply easing to the beginning of animation.\n" +
"Ease Out Only - Apply easing to the end of animation.\n" +
"Ease In-Out - Apply easing to the beginning and end of animation.\n")]
EasingMode m_EasingMode = EasingMode.None;
[SerializeField, Tooltip("The coordinate space that the GameObject's up and forward axes align to.")]
AlignmentMode m_AlignmentMode = AlignmentMode.SplineElement;
[SerializeField, Tooltip("Which axis of the GameObject is treated as the forward axis.")]
AlignAxis m_ObjectForwardAxis = AlignAxis.ZAxis;
[SerializeField, Tooltip("Which axis of the GameObject is treated as the up axis.")]
AlignAxis m_ObjectUpAxis = AlignAxis.YAxis;
[SerializeField, Tooltip("Normalized distance [0;1] offset along the spline at which the GameObject should be placed when the animation begins.")]
float m_StartOffset;
[NonSerialized]
float m_StartOffsetT;
float m_SplineLength = -1;
bool m_Playing;
float m_NormalizedTime;
float m_ElapsedTime;
#if UNITY_EDITOR
double m_LastEditorUpdateTime;
#endif
SplinePath m_SplinePath;
/// The target container of the splines to follow.
[Obsolete("Use Container instead.", false)]
public SplineContainer splineContainer => Container;
/// The target container of the splines to follow.
public SplineContainer Container
{
get => m_Target;
set
{
m_Target = value;
if (enabled && m_Target != null && m_Target.Splines != null)
{
for (int i = 0; i < m_Target.Splines.Count; i++)
OnSplineChange(m_Target.Splines[i], -1, SplineModification.Default);
}
UpdateStartOffsetT();
}
}
/// If true, transform will automatically start following the target Spline on awake.
[Obsolete("Use PlayOnAwake instead.", false)]
public bool playOnAwake => PlayOnAwake;
/// If true, transform will automatically start following the target Spline on awake.
public bool PlayOnAwake
{
get => m_PlayOnAwake;
set => m_PlayOnAwake = value;
}
/// The way the Spline should be looped. See for details.
[Obsolete("Use Loop instead.", false)]
public LoopMode loopMode => Loop;
/// The way the Spline should be looped. See for details.
public LoopMode Loop
{
get => m_LoopMode;
set => m_LoopMode = value;
}
/// The method used to traverse the Spline. See for details.
[Obsolete("Use AnimationMethod instead.", false)]
public Method method => AnimationMethod;
/// The method used to traverse the Spline. See for details.
public Method AnimationMethod
{
get => m_Method;
set => m_Method = value;
}
/// The time (in seconds) it takes to traverse the Spline once.
///
/// When animation method is set to this setter will set the value and automatically recalculate ,
/// otherwise, it will have no effect.
///
[Obsolete("Use Duration instead.", false)]
public float duration => Duration;
/// The time (in seconds) it takes to traverse the Spline once.
///
/// When animation method is set to this setter will set the value and automatically recalculate ,
/// otherwise, it will have no effect.
///
public float Duration
{
get => m_Duration;
set
{
if (m_Method == Method.Time)
{
m_Duration = Mathf.Max(0f, value);
CalculateMaxSpeed();
}
}
}
/// The maxSpeed speed (in Unity units/second) that the Spline traversal will advance in.
///
/// If is to then the Spline will be traversed at MaxSpeed throughout its length.
/// Otherwise, the traversal speed will range from 0 to MaxSpeed throughout the Spline's length depending on the easing mode set.
/// When animation method is set to this setter will set the value and automatically recalculate ,
/// otherwise, it will have no effect.
///
[Obsolete("Use MaxSpeed instead.", false)]
public float maxSpeed => MaxSpeed;
/// The maxSpeed speed (in Unity units/second) that the Spline traversal will advance in.
///
/// If is to then the Spline will be traversed at MaxSpeed throughout its length.
/// Otherwise, the traversal speed will range from 0 to MaxSpeed throughout the Spline's length depending on the easing mode set.
/// When animation method is set to this setter will set the value and automatically recalculate ,
/// otherwise, it will have no effect.
///
public float MaxSpeed
{
get => m_MaxSpeed;
set
{
if (m_Method == Method.Speed)
{
m_MaxSpeed = Mathf.Max(0f, value);
CalculateDuration();
}
}
}
/// Easing mode used when animating the object along the Spline. See for details.
[Obsolete("Use Easing instead.", false)]
public EasingMode easingMode => Easing;
/// Easing mode used when animating the object along the Spline. See for details.
public EasingMode Easing
{
get => m_EasingMode;
set => m_EasingMode = value;
}
/// The way the object should align when animating along the Spline. See for details.
[Obsolete("Use Alignment instead.", false)]
public AlignmentMode alignmentMode => Alignment;
/// The way the object should align when animating along the Spline. See for details.
public AlignmentMode Alignment
{
get => m_AlignmentMode;
set => m_AlignmentMode = value;
}
/// Object space axis that should be considered as the object's forward vector.
[Obsolete("Use ObjectForwardAxis instead.", false)]
public AlignAxis objectForwardAxis => ObjectForwardAxis;
/// Object space axis that should be considered as the object's forward vector.
public AlignAxis ObjectForwardAxis
{
get => m_ObjectForwardAxis;
set => m_ObjectUpAxis = SetObjectAlignAxis(value, ref m_ObjectForwardAxis, m_ObjectUpAxis);
}
/// Object space axis that should be considered as the object's up vector.
[Obsolete("Use ObjectUpAxis instead.", false)]
public AlignAxis objectUpAxis => ObjectUpAxis;
/// Object space axis that should be considered as the object's up vector.
public AlignAxis ObjectUpAxis
{
get => m_ObjectUpAxis;
set => m_ObjectForwardAxis = SetObjectAlignAxis(value, ref m_ObjectUpAxis, m_ObjectForwardAxis);
}
///
/// Normalized time of the Spline's traversal. The integer part is the number of times the Spline has been traversed.
/// The fractional part is the % (0-1) of progress in the current loop.
///
[Obsolete("Use NormalizedTime instead.", false)]
public float normalizedTime => NormalizedTime;
///
/// Normalized time of the Spline's traversal. The integer part is the number of times the Spline has been traversed.
/// The fractional part is the % (0-1) of progress in the current loop.
///
public float NormalizedTime
{
get => m_NormalizedTime;
set
{
m_NormalizedTime = value;
if (m_LoopMode == LoopMode.PingPong)
{
var currentDirection = (int)(m_ElapsedTime / m_Duration);
m_ElapsedTime = m_Duration * m_NormalizedTime + ((currentDirection % 2 == 1) ? m_Duration : 0f);
}
else
m_ElapsedTime = m_Duration * m_NormalizedTime;
UpdateTransform();
}
}
/// Total time (in seconds) since the start of Spline's traversal.
[Obsolete("Use ElapsedTime instead.", false)]
public float elapsedTime => ElapsedTime;
/// Total time (in seconds) since the start of Spline's traversal.
public float ElapsedTime
{
get => m_ElapsedTime;
set
{
m_ElapsedTime = value;
CalculateNormalizedTime(0f);
UpdateTransform();
}
}
/// Normalized distance [0;1] offset along the spline at which the object should be placed when the animation begins.
public float StartOffset
{
get => m_StartOffset;
set
{
if (m_SplineLength < 0f)
RebuildSplinePath();
m_StartOffset = Mathf.Clamp01(value);
UpdateStartOffsetT();
}
}
internal float StartOffsetT => m_StartOffsetT;
/// Returns true if object is currently animating along the Spline.
[Obsolete("Use IsPlaying instead.", false)]
public bool isPlaying => IsPlaying;
/// Returns true if object is currently animating along the Spline.
public bool IsPlaying => m_Playing;
/// Invoked each time object's animation along the Spline is updated.
[Obsolete("Use Updated instead.", false)]
public event Action onUpdated;
/// Invoked each time object's animation along the Spline is updated.
public event Action Updated;
private bool m_EndReached = false;
/// Invoked every time the object's animation reaches the end of the Spline.
/// In case the animation loops, this event is called at the end of each loop.
public event Action Completed;
void Awake()
{
RecalculateAnimationParameters();
#if UNITY_EDITOR
if(EditorApplication.isPlaying)
#endif
Restart(m_PlayOnAwake);
#if UNITY_EDITOR
else // Place the animated object back at the animation start position.
Restart(false);
#endif
}
void OnEnable()
{
RecalculateAnimationParameters();
Spline.Changed += OnSplineChange;
}
void OnDisable()
{
Spline.Changed -= OnSplineChange;
}
void OnValidate()
{
m_Duration = Mathf.Max(0f, m_Duration);
m_MaxSpeed = Mathf.Max(0f, m_MaxSpeed);
RecalculateAnimationParameters();
}
internal void RecalculateAnimationParameters()
{
RebuildSplinePath();
switch (m_Method)
{
case Method.Time:
CalculateMaxSpeed();
break;
case Method.Speed:
CalculateDuration();
break;
default:
Debug.Log($"{m_Method} animation method is not supported!", this);
break;
}
}
internal static readonly string k_EmptyContainerError = "SplineAnimate does not have a valid SplineContainer set.";
bool IsNullOrEmptyContainer()
{
if (m_Target == null || m_Target.Splines.Count == 0)
{
if(Application.isPlaying)
Debug.LogError(k_EmptyContainerError, this);
return true;
}
return false;
}
/// Begin animating object along the Spline.
public void Play()
{
if (IsNullOrEmptyContainer())
return;
m_Playing = true;
#if UNITY_EDITOR
m_LastEditorUpdateTime = EditorApplication.timeSinceStartup;
#endif
}
/// Pause object's animation along the Spline.
public void Pause()
{
m_Playing = false;
}
/// Stop the animation and place the object at the beginning of the Spline.
/// If true, the animation along the Spline will start over again.
public void Restart(bool autoplay)
{
// [SPLB-269]: Early exit if the container is null to remove log error when initializing the spline animate object from code
if (Container == null)
return;
if(IsNullOrEmptyContainer())
return;
m_Playing = false;
m_ElapsedTime = 0f;
NormalizedTime = 0f;
switch (m_Method)
{
case Method.Time:
CalculateMaxSpeed();
break;
case Method.Speed:
CalculateDuration();
break;
default:
Debug.Log($"{m_Method} animation method is not supported!", this);
break;
}
UpdateTransform();
UpdateStartOffsetT();
if (autoplay)
Play();
}
///
/// Evaluates the animation along the Spline based on deltaTime.
///
public void Update()
{
if (!m_Playing || (m_LoopMode == LoopMode.Once && m_NormalizedTime >= 1f))
return;
var dt = Time.deltaTime;
#if UNITY_EDITOR
if (!EditorApplication.isPlaying)
{
dt = (float)(EditorApplication.timeSinceStartup - m_LastEditorUpdateTime);
m_LastEditorUpdateTime = EditorApplication.timeSinceStartup;
}
#endif
CalculateNormalizedTime(dt);
UpdateTransform();
}
void CalculateNormalizedTime(float deltaTime)
{
var previousElapsedTime = m_ElapsedTime;
m_ElapsedTime += deltaTime;
var currentDuration = m_Duration;
var t = 0f;
switch (m_LoopMode)
{
case LoopMode.Once:
t = Mathf.Min(m_ElapsedTime, currentDuration);
break;
case LoopMode.Loop:
t = m_ElapsedTime % currentDuration;
UpdateEndReached(previousElapsedTime, currentDuration);
break;
case LoopMode.LoopEaseInOnce:
/* If the first loop had an ease in, then our velocity is double that of linear traversal.
Therefore time to traverse subsequent loops should be half of the first loop. */
if ((m_EasingMode == EasingMode.EaseIn || m_EasingMode == EasingMode.EaseInOut) &&
m_ElapsedTime >= currentDuration)
currentDuration *= 0.5f;
t = m_ElapsedTime % currentDuration;
UpdateEndReached(previousElapsedTime, currentDuration);
break;
case LoopMode.PingPong:
t = Mathf.PingPong(m_ElapsedTime, currentDuration);
UpdateEndReached(previousElapsedTime, currentDuration);
break;
default:
Debug.Log($"{m_LoopMode} animation loop mode is not supported!", this);
break;
}
t /= currentDuration;
if (m_LoopMode == LoopMode.LoopEaseInOnce)
{
// Apply ease in for the first loop and continue linearly for remaining loops
if ((m_EasingMode == EasingMode.EaseIn || m_EasingMode == EasingMode.EaseInOut) &&
m_ElapsedTime < currentDuration)
t = EaseInQuadratic(t);
}
else
{
switch (m_EasingMode)
{
case EasingMode.EaseIn:
t = EaseInQuadratic(t);
break;
case EasingMode.EaseOut:
t = EaseOutQuadratic(t);
break;
case EasingMode.EaseInOut:
t = EaseInOutQuadratic(t);
break;
}
}
// forcing reset to 0 if the m_NormalizedTime reach the end of the spline previously (1).
m_NormalizedTime = t == 0 ? 0f : Mathf.Floor(m_NormalizedTime) + t;
if (m_NormalizedTime >= 1f && m_LoopMode == LoopMode.Once)
{
m_EndReached = true;
m_Playing = false;
}
}
void UpdateEndReached(float previousTime, float currentDuration)
{
m_EndReached = Mathf.FloorToInt(previousTime/currentDuration) < Mathf.FloorToInt(m_ElapsedTime/currentDuration);
}
void UpdateStartOffsetT()
{
if (m_SplinePath != null)
m_StartOffsetT = m_SplinePath.ConvertIndexUnit(m_StartOffset * m_SplineLength, PathIndexUnit.Distance, PathIndexUnit.Normalized);
}
void UpdateTransform()
{
if (m_Target == null)
return;
EvaluatePositionAndRotation(out var position, out var rotation);
#if UNITY_EDITOR
if (EditorApplication.isPlaying)
{
#endif
transform.position = position;
if (m_AlignmentMode != AlignmentMode.None)
transform.rotation = rotation;
#if UNITY_EDITOR
}
#endif
onUpdated?.Invoke(position, rotation);
Updated?.Invoke(position, rotation);
if (m_EndReached)
{
m_EndReached = false;
Completed?.Invoke();
}
}
void EvaluatePositionAndRotation(out Vector3 position, out Quaternion rotation)
{
var t = GetLoopInterpolation(true);
position = m_Target.EvaluatePosition(m_SplinePath, t);
rotation = Quaternion.identity;
// Correct forward and up vectors based on axis remapping parameters
var remappedForward = GetAxis(m_ObjectForwardAxis);
var remappedUp = GetAxis(m_ObjectUpAxis);
var axisRemapRotation = Quaternion.Inverse(Quaternion.LookRotation(remappedForward, remappedUp));
if (m_AlignmentMode != AlignmentMode.None)
{
var forward = Vector3.forward;
var up = Vector3.up;
switch (m_AlignmentMode)
{
case AlignmentMode.SplineElement:
forward = m_Target.EvaluateTangent(m_SplinePath, t);
if (Vector3.Magnitude(forward) <= Mathf.Epsilon)
{
if (t < 1f)
forward = m_Target.EvaluateTangent(m_SplinePath, Mathf.Min(1f, t + 0.01f));
else
forward = m_Target.EvaluateTangent(m_SplinePath, t - 0.01f);
}
forward.Normalize();
up = m_Target.EvaluateUpVector(m_SplinePath, t);
break;
case AlignmentMode.SplineObject:
var objectRotation = m_Target.transform.rotation;
forward = objectRotation * forward;
up = objectRotation * up;
break;
default:
Debug.Log($"{m_AlignmentMode} animation alignment mode is not supported!", this);
break;
}
var valid = math.isfinite(forward) & math.isfinite(up);
if (math.all(valid))
rotation = Quaternion.LookRotation(forward, up) * axisRemapRotation;
else
Debug.LogError("Trying to EvaluatePositionAndRotation with invalid parameters. Please check the SplineAnimate component.", this);
}
else
rotation = transform.rotation;
}
void CalculateDuration()
{
if (m_SplineLength < 0f)
RebuildSplinePath();
if (m_SplineLength >= 0f)
{
switch (m_EasingMode)
{
case EasingMode.None:
m_Duration = m_SplineLength / m_MaxSpeed;
break;
case EasingMode.EaseIn:
case EasingMode.EaseOut:
case EasingMode.EaseInOut:
m_Duration = (2f * m_SplineLength) / m_MaxSpeed;
break;
default:
Debug.Log($"{m_EasingMode} animation easing mode is not supported!", this);
break;
}
}
}
void CalculateMaxSpeed()
{
if (m_SplineLength < 0f)
RebuildSplinePath();
if (m_SplineLength >= 0f)
{
switch (m_EasingMode)
{
case EasingMode.None:
m_MaxSpeed = m_SplineLength / m_Duration;
break;
case EasingMode.EaseIn:
case EasingMode.EaseOut:
case EasingMode.EaseInOut:
m_MaxSpeed = (2f * m_SplineLength) / m_Duration;
break;
default:
Debug.Log($"{m_EasingMode} animation easing mode is not supported!", this);
break;
}
}
}
void RebuildSplinePath()
{
if (m_Target != null)
{
m_SplinePath = new SplinePath(m_Target.Splines);
m_SplineLength = m_SplinePath != null ? m_SplinePath.GetLength() : 0f;
}
}
AlignAxis SetObjectAlignAxis(AlignAxis newValue, ref AlignAxis targetAxis, AlignAxis otherAxis)
{
// Swap axes if the new value matches that of the other axis
if (newValue == otherAxis)
{
otherAxis = targetAxis;
targetAxis = newValue;
}
// Do not allow configuring object's forward and up axes as opposite
else if ((int) newValue % 3 != (int) otherAxis % 3)
targetAxis = newValue;
return otherAxis;
}
void OnSplineChange(Spline spline, int knotIndex, SplineModification modificationType)
{
RecalculateAnimationParameters();
}
internal float GetLoopInterpolation(bool offset)
{
var t = 0f;
var normalizedTimeWithOffset = NormalizedTime + (offset ? m_StartOffsetT : 0f);
if (Mathf.Floor(normalizedTimeWithOffset) == normalizedTimeWithOffset)
t = Mathf.Clamp01(normalizedTimeWithOffset);
else
t = normalizedTimeWithOffset % 1f;
return t;
}
float EaseInQuadratic(float t)
{
return t * t;
}
float EaseOutQuadratic(float t)
{
return t * (2f - t);
}
float EaseInOutQuadratic(float t)
{
var eased = 2f * t * t;
if (t > 0.5f)
eased = 4f * t - eased - 1f;
return eased;
}
}
}