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; } } }