• Unity
  • Rotational Root Motion?

Has anyone set up root motion that uses rotation?

I'm working on a boss that is a pure top-down character (as though you're looking directly down on the top of their head), and want to set them up such that we can make an animation that includes rotating the entire character, and for that rotation to be used as a sort of 'rotational root motion'. For example, walking forward a couple steps, then turning 90 degrees to the right (clockwise), then taking another couple steps, then turning again, etc (through multiple animations).

Was curious if anyone has set this up before, or how difficult it would potentially be to convert/extend the existing Root Motion to work with rotation.

Thanks for any help!

Related Discussions
...

You basically need to create a copy of the existing translation root motion code, adjusted to read RotateTimeline and also apply the gathered rotation. You could alternatively modify the existing code to return both translation and rotation (instead of only translation) in any method, this is not recommended for obvious reasons however.

So you would create an adapted copy of the GetAnimationRootMotion method:
https://github.com/EsotericSoftware/spine-runtimes/blob/4.1-beta/spine-unity/Assets/Spine/Runtime/spine-unity/Components/RootMotion/SkeletonRootMotionBase.cs#L231
You would then read animation.FindTimelineForBone<RotateTimeline>(), and add a RotateTimeline version of TimelineExtensions.Evaluate()
https://github.com/EsotericSoftware/spine-runtimes/blob/4.1-beta/spine-unity/Assets/Spine/Runtime/spine-unity/Utility/TimelineExtensions.cs#L41

An adapted copy of the SkeletonAnimation-specific CalculateAnimationsMovementDelta method:
https://github.com/EsotericSoftware/spine-runtimes/blob/4.1-beta/spine-unity/Assets/Spine/Runtime/spine-unity/Components/RootMotion/SkeletonRootMotion.cs#L101

To then apply the gathered rotation animation, you need to create a copy of the HandleUpdateLocal method, which would also be hooked up to the UpdateLocal callback.
https://github.com/EsotericSoftware/spine-runtimes/blob/4.1-beta/spine-unity/Assets/Spine/Runtime/spine-unity/Components/RootMotion/SkeletonRootMotionBase.cs#L374
You then need to apply the rotation in a rotation version of ApplyRootMotion:
https://github.com/EsotericSoftware/spine-runtimes/blob/4.1-beta/spine-unity/Assets/Spine/Runtime/spine-unity/Components/RootMotion/SkeletonRootMotionBase.cs#L384

I wonder however if you really want rotational root motion, as rotating the GameObject in increments will look wrong when the top-down perspective is not perfectly orthogonal. You could also use a separate animation track for blended cumulated rotation animation (could be additive animation, or also procedurally set bone rotation values), and a SkeletonBoneFollower component to copy the total rotation over to the GameObject Transform.

We could officially add rotation root motion support to the components, however we need to understand if there are really some valid common use cases that other users will also benefit from.

Thanks a ton Harald! I was able to piece it together in about an hour with your help, and it even worked on the first try :rofl:

Harald a écrit

I wonder however if you really want rotational root motion, as rotating the GameObject in increments will look wrong when the top-down perspective is not perfectly orthogonal. You could also use a separate animation track for blended cumulated rotation animation (could be additive animation, or also procedurally set bone rotation values), and a SkeletonBoneFollower component to copy the total rotation over to the GameObject Transform.

Yeah this is actually a kinda weird, ultra specific use-case for this sort of thing. This is for a horror style boss that is in a pitch black cave area. It has a body that is amorphous with a bunch of random appendages sticking out, and then has two eyes on the front of its "head" that are like spotlights that look around for the player 😃 So since its super dark in the cave, and its body is amorphous and vaguely 'circular', we can get away with treating it as a perfectly orthogonal character, and have the entire thing simply rotate as it moves around, lol (even though you're correct that our normal top-down perspective doesn't allow that)

Harald a écrit

We could officially add rotation root motion support to the components, however we need to understand if there are really some valid common use cases that other users will also benefit from.

Yeah, I'm not too sure if it's something that ought to be added - my reason for wanting it in particular is somewhat due to the fact that I want this boss character to be able to just 'wander around' a bit (just triggering animations of him moving around a bit, rotating 90 degrees, turning around, all triggered semi randomly, etc), which is easy to do with this Rotational Root Motion setup. But if my entire game was the perfect-orthogonal perspective, I'd probably have a completely different system for controlling how characters look around and rotate.

I'm hesitant to post my code since its pretty much frankenstein-ed together from the existing code, but I guess I might as well. I also probably could have stripped out a bunch of stuff related to the Translation root motion (it ONLY applies the Rotational root motion), but I'm only using this script on a single character in the entire game lol. Also, I didn't set it up to allow for the rotation to apply to Rigidbody, I only set it up to apply directly to a Transform.

Add this to the TimelineExtentions.cs:

/// <summary>Evaluates the resulting value of a RotateTimeline at a given time.
/// SkeletonData can be accessed from Skeleton.Data or from SkeletonDataAsset.GetSkeletonData.
/// If no SkeletonData is given, values are returned as difference to setup pose
/// instead of absolute values.</summary>
public static float Evaluate(this RotateTimeline timeline, float time, SkeletonData skeletonData = null) {
   if (time < timeline.Frames[0]) return 0f;

   float rotation = timeline.GetCurveValue(time);

   if (skeletonData == null) {
      return rotation;
   } else {
      BoneData boneData = skeletonData.Bones.Items[timeline.BoneIndex];
      return (boneData.Rotation + rotation);
   }
}

The new script: SpineRotationalRootMotion.cs

using Spine;
using Spine.Unity;
using Spine.Unity.AnimationTools;
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Animation = Spine.Animation;
using AnimationState = Spine.AnimationState;

public class SpineRotationalRootMotion : MonoBehaviour
{

   ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
   ///////////////////////////////////////////////////////From: SkeletonRootMotionBase.cs//////////////////////////////////////////////////////////////////////
   ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

   #region Inspector

   [SpineBone]
   [SerializeField]
   protected string rootMotionBoneName = "root";
   public bool transformPositionX = true;
   public bool transformPositionY = true;

   public float rootMotionScaleX = 1;
   public float rootMotionScaleY = 1;
   /// <summary>Skeleton space X translation per skeleton space Y translation root motion.</summary>
   public float rootMotionTranslateXPerY = 0;
   /// <summary>Skeleton space Y translation per skeleton space X translation root motion.</summary>
   public float rootMotionTranslateYPerX = 0;

   [Header("Optional")]
   public Rigidbody2D rigidBody2D;
   public bool applyRigidbody2DGravity = false;
   public Rigidbody rigidBody;

   public bool UsesRigidbody {
      get { return rigidBody != null || rigidBody2D != null; }
   }
   #endregion

   protected ISkeletonComponent skeletonComponent;
   protected Bone rootMotionBone;
   protected int rootMotionBoneIndex;
   protected List<Bone> topLevelBones = new List<Bone>();
   protected Vector2 initialOffset = Vector2.zero;
   protected Vector2 tempSkeletonDisplacement;
   protected Vector2 rigidbodyDisplacement;

   protected virtual void Reset() {
      FindRigidbodyComponent();

  //From SkeletonRootMotion
  animationTrackFlags = DefaultAnimationTrackFlags;
   }

   protected virtual void Start() {
      skeletonComponent = GetComponent<ISkeletonComponent>();
      GatherTopLevelBones();
      SetRootMotionBone(rootMotionBoneName);
      if (rootMotionBone != null)
         initialOffset = new Vector2(rootMotionBone.X, rootMotionBone.Y);

  var skeletonAnimation = skeletonComponent as ISkeletonAnimation;
  if (skeletonAnimation != null) {
     skeletonAnimation.UpdateLocal -= HandleUpdateLocal;
     skeletonAnimation.UpdateLocal += HandleUpdateLocal;
  }

  //From SkeletonRootMotion
  var animstateComponent = skeletonComponent as IAnimationStateComponent;
  this.animationState = (animstateComponent != null) ? animstateComponent.AnimationState : null;

  if (this.GetComponent<CanvasRenderer>() != null) {
     canvas = this.GetComponentInParent<Canvas>();
  }
   }

   protected virtual void FixedUpdate() {
      if (!this.isActiveAndEnabled)
         return; // Root motion is only applied when component is enabled.

  if (rigidBody2D != null) {

     Vector2 gravityAndVelocityMovement = Vector2.zero;
     if (applyRigidbody2DGravity) {
        float deltaTime = Time.fixedDeltaTime;
        float deltaTimeSquared = (deltaTime * deltaTime);

        rigidBody2D.velocity += rigidBody2D.gravityScale * Physics2D.gravity * deltaTime;
        gravityAndVelocityMovement = 0.5f * rigidBody2D.gravityScale * Physics2D.gravity * deltaTimeSquared +
           rigidBody2D.velocity * deltaTime;
     }

     rigidBody2D.MovePosition(gravityAndVelocityMovement + new Vector2(transform.position.x, transform.position.y)
        + rigidbodyDisplacement);
  }
  if (rigidBody != null) {
     rigidBody.MovePosition(transform.position
        + new Vector3(rigidbodyDisplacement.x, rigidbodyDisplacement.y, 0));
  }
  Vector2 parentBoneScale;
  GetScaleAffectingRootMotion(out parentBoneScale);
  ClearEffectiveBoneOffsets(parentBoneScale);
  rigidbodyDisplacement = Vector2.zero;
  tempSkeletonDisplacement = Vector2.zero;
   }

   protected virtual void OnDisable() {
      rigidbodyDisplacement = Vector2.zero;
      tempSkeletonDisplacement = Vector2.zero;
   }

   protected void FindRigidbodyComponent() {
      rigidBody2D = this.GetComponent<Rigidbody2D>();
      if (!rigidBody2D)
         rigidBody = this.GetComponent<Rigidbody>();

  if (!rigidBody2D && !rigidBody) {
     rigidBody2D = this.GetComponentInParent<Rigidbody2D>();
     if (!rigidBody2D)
        rigidBody = this.GetComponentInParent<Rigidbody>();
  }
   }

   protected virtual float AdditionalScale { get { return 1.0f; } }

   public struct RootMotionInfo
   {
      public Vector2 start;
      public Vector2 current;
      public Vector2 mid;
      public Vector2 end;
      public bool timeIsPastMid;
   };

   public void SetRootMotionBone(string name) {
      var skeleton = skeletonComponent.Skeleton;
      Bone bone = skeleton.FindBone(name);
      if (bone != null) {
         this.rootMotionBoneIndex = bone.Data.Index;
         this.rootMotionBone = bone;
      } else {
         Debug.Log("Bone named \"" + name + "\" could not be found.");
         this.rootMotionBoneIndex = 0;
         this.rootMotionBone = skeleton.RootBone;
      }
   }

   public RootMotionInfo GetAnimationRootMotionInfo(Animation animation, float currentTime) {
      RootMotionInfo rootMotion = new RootMotionInfo();
      float duration = animation.Duration;
      float mid = duration * 0.5f;
      rootMotion.timeIsPastMid = currentTime > mid;
      TranslateTimeline timeline = animation.FindTranslateTimelineForBone(rootMotionBoneIndex);
      if (timeline != null) {
         rootMotion.start = timeline.Evaluate(0);
         rootMotion.current = timeline.Evaluate(currentTime);
         rootMotion.mid = timeline.Evaluate(mid);
         rootMotion.end = timeline.Evaluate(duration);
         return rootMotion;
      }
      TranslateXTimeline xTimeline = animation.FindTimelineForBone<TranslateXTimeline>(rootMotionBoneIndex);
      TranslateYTimeline yTimeline = animation.FindTimelineForBone<TranslateYTimeline>(rootMotionBoneIndex);
      if (xTimeline != null || yTimeline != null) {
         rootMotion.start = TimelineExtensions.Evaluate(xTimeline, yTimeline, 0);
         rootMotion.current = TimelineExtensions.Evaluate(xTimeline, yTimeline, currentTime);
         rootMotion.mid = TimelineExtensions.Evaluate(xTimeline, yTimeline, mid);
         rootMotion.end = TimelineExtensions.Evaluate(xTimeline, yTimeline, duration);
         return rootMotion;
      }
      return rootMotion;
   }

   Vector2 GetTimelineMovementDelta(float startTime, float endTime,
      TranslateTimeline timeline, Animation animation) {

  Vector2 currentDelta;
  if (startTime > endTime) // Looped
     currentDelta = (timeline.Evaluate(animation.Duration) - timeline.Evaluate(startTime))
        + (timeline.Evaluate(endTime) - timeline.Evaluate(0));
  else if (startTime != endTime) // Non-looped
     currentDelta = timeline.Evaluate(endTime) - timeline.Evaluate(startTime);
  else
     currentDelta = Vector2.zero;
  return currentDelta;
   }

   Vector2 GetTimelineMovementDelta(float startTime, float endTime,
      TranslateXTimeline xTimeline, TranslateYTimeline yTimeline, Animation animation) {

  Vector2 currentDelta;
  if (startTime > endTime) // Looped
     currentDelta =
        (TimelineExtensions.Evaluate(xTimeline, yTimeline, animation.Duration)
        - TimelineExtensions.Evaluate(xTimeline, yTimeline, startTime))
        + (TimelineExtensions.Evaluate(xTimeline, yTimeline, endTime)
        - TimelineExtensions.Evaluate(xTimeline, yTimeline, 0));
  else if (startTime != endTime) // Non-looped
     currentDelta = TimelineExtensions.Evaluate(xTimeline, yTimeline, endTime)
        - TimelineExtensions.Evaluate(xTimeline, yTimeline, startTime);
  else
     currentDelta = Vector2.zero;
  return currentDelta;
   }

   void GatherTopLevelBones() {
      topLevelBones.Clear();
      var skeleton = skeletonComponent.Skeleton;
      foreach (var bone in skeleton.Bones) {
         if (bone.Parent == null)
            topLevelBones.Add(bone);
      }
   }

   Vector2 GetScaleAffectingRootMotion() {
      Vector2 parentBoneScale;
      return GetScaleAffectingRootMotion(out parentBoneScale);
   }

   Vector2 GetScaleAffectingRootMotion(out Vector2 parentBoneScale) {
      var skeleton = skeletonComponent.Skeleton;
      Vector2 totalScale = Vector2.one;
      totalScale.x *= skeleton.ScaleX;
      totalScale.y *= skeleton.ScaleY;

  parentBoneScale = Vector2.one;
  Bone scaleBone = rootMotionBone;
  while ((scaleBone = scaleBone.Parent) != null) {
     parentBoneScale.x *= scaleBone.ScaleX;
     parentBoneScale.y *= scaleBone.ScaleY;
  }
  totalScale = Vector2.Scale(totalScale, parentBoneScale);
  totalScale *= AdditionalScale;
  return totalScale;
   }

   Vector2 GetSkeletonSpaceMovementDelta(Vector2 boneLocalDelta, out Vector2 parentBoneScale) {
      Vector2 skeletonDelta = boneLocalDelta;
      Vector2 totalScale = GetScaleAffectingRootMotion(out parentBoneScale);
      skeletonDelta.Scale(totalScale);

  Vector2 rootMotionTranslation = new Vector2(
     rootMotionTranslateXPerY * skeletonDelta.y,
     rootMotionTranslateYPerX * skeletonDelta.x);

  skeletonDelta.x *= rootMotionScaleX;
  skeletonDelta.y *= rootMotionScaleY;
  skeletonDelta.x += rootMotionTranslation.x;
  skeletonDelta.y += rootMotionTranslation.y;

  if (!transformPositionX) skeletonDelta.x = 0f;
  if (!transformPositionY) skeletonDelta.y = 0f;
  return skeletonDelta;
   }

   void SetEffectiveBoneOffsetsTo(Vector2 displacementSkeletonSpace, Vector2 parentBoneScale) {
      // Move top level bones in opposite direction of the root motion bone
      var skeleton = skeletonComponent.Skeleton;
      foreach (var topLevelBone in topLevelBones) {
         if (topLevelBone == rootMotionBone) {
            if (transformPositionX) topLevelBone.X = displacementSkeletonSpace.x / skeleton.ScaleX;
            if (transformPositionY) topLevelBone.Y = displacementSkeletonSpace.y / skeleton.ScaleY;
         } else {
            float offsetX = (initialOffset.x - rootMotionBone.X) * parentBoneScale.x;
            float offsetY = (initialOffset.y - rootMotionBone.Y) * parentBoneScale.y;
            if (transformPositionX) topLevelBone.X = (displacementSkeletonSpace.x / skeleton.ScaleX) + offsetX;
            if (transformPositionY) topLevelBone.Y = (displacementSkeletonSpace.y / skeleton.ScaleY) + offsetY;
         }
      }
   }

   public void ClearEffectiveBoneOffsets(Vector2 parentBoneScale) {
      SetEffectiveBoneOffsetsTo(Vector2.zero, parentBoneScale);
   }


   ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
   /////////////////////////////////////////////////////////From: SkeletonRootMotion.cs////////////////////////////////////////////////////////////////////////
   ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

   #region Inspector
   const int DefaultAnimationTrackFlags = -1;
   public int animationTrackFlags = DefaultAnimationTrackFlags;
   #endregion

   AnimationState animationState;
   Canvas canvas;

   public RootMotionInfo GetRootMotionInfo(int trackIndex) {
      TrackEntry track = animationState.GetCurrent(trackIndex);
      if (track == null)
         return new RootMotionInfo();

  var animation = track.Animation;
  float time = track.AnimationTime;
  return GetAnimationRootMotionInfo(track.Animation, time);
   }

   void ApplyMixAlphaToDelta(ref float currentDelta, TrackEntry next, TrackEntry track) {
      // Apply mix alpha to the delta position (based on AnimationState.cs).
      float mix;
      if (next != null) {
         if (next.MixDuration == 0) { // Single frame mix to undo mixingFrom changes.
            mix = 1;
         } else {
            mix = next.MixTime / next.MixDuration;
            if (mix > 1) mix = 1;
         }
         float mixAndAlpha = track.Alpha * next.InterruptAlpha * (1 - mix);
         currentDelta *= mixAndAlpha;
      } else {
         if (track.MixDuration == 0) {
            mix = 1;
         } else {
            mix = track.Alpha * (track.MixTime / track.MixDuration);
            if (mix > 1) mix = 1;
         }
         currentDelta *= mix;
      }
   }


   ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
   /////////////////////////////////////////////////////////////Replaced Functions/////////////////////////////////////////////////////////////////////////////
   ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

   public Transform applyRotationalRootMotionToTransform;

   public float GetAnimationRootMotion(Animation animation) {
      return GetAnimationRootMotion(0, animation.Duration, animation);
   }

   public float GetAnimationRootMotion(float startTime, float endTime, Animation animation) {

  if (startTime == endTime)
     return 0f;

  RotateTimeline rotationTimeline = animation.FindTimelineForBone<RotateTimeline>(rootMotionBoneIndex);

  // Non-looped base
  float endRotation = 0f;
  float startRotation = 0f;

  if (rotationTimeline != null) {
     endRotation = rotationTimeline.Evaluate(endTime);
     startRotation = rotationTimeline.Evaluate(startTime);
  }

  float currentDelta = endRotation - startRotation;

  // Looped additions
  if (startTime > endTime) {
     float loopRotation = 0f;
     float zeroRotation = 0f;

     if (rotationTimeline != null) {
        loopRotation = rotationTimeline.Evaluate(animation.Duration);
        zeroRotation = rotationTimeline.Evaluate(0);
     } 

     currentDelta += loopRotation - zeroRotation;
  }

  return currentDelta;
   }


   protected float CalculateAnimationsRotationDelta() {
      float localDelta = 0f;
      int trackCount = animationState.Tracks.Count;

  for (int trackIndex = 0; trackIndex < trackCount; ++trackIndex) {
     // note: animationTrackFlags != -1 below covers trackIndex >= 32,
     // with -1 corresponding to entry "everything" of the dropdown list.
     if (animationTrackFlags != -1 && (animationTrackFlags & 1 << trackIndex) == 0)
        continue;

     TrackEntry track = animationState.GetCurrent(trackIndex);
     TrackEntry next = null;
     while (track != null) {
        var animation = track.Animation;
        float start = track.AnimationLast;
        float end = track.AnimationTime;
        var currentDelta = GetAnimationRootMotion(start, end, animation);
        if (currentDelta != 0f) {
           ApplyMixAlphaToDelta(ref currentDelta, next, track);
           localDelta += currentDelta;
        }

        // Traverse mixingFrom chain.
        next = track;
        track = track.MixingFrom;
     }
  }
  return localDelta;
   }


   void HandleUpdateLocal(ISkeletonAnimation animatedSkeletonComponent) {
      if (!this.isActiveAndEnabled)
         return; // Root motion is only applied when component is enabled.

  //James: Removed scale-related things
  float boneLocalDelta = CalculateAnimationsRotationDelta();
  ApplyRootMotion(boneLocalDelta);
   }


   void ApplyRootMotion(float skeletonDelta) {
      // Apply root motion to Transform or RigidBody;
      if (UsesRigidbody) {

     // Accumulated displacement is applied on the next Physics update in FixedUpdate.
     // Until the next Physics update, tempBoneDisplacement is offsetting bone locations
     // to prevent stutter which would otherwise occur if we don't move every Update.

     //James: Not using Rigidbody, so removed the following lines:
     //tempSkeletonDisplacement += skeletonDelta;
     //SetEffectiveBoneOffsetsTo(tempSkeletonDisplacement, parentBoneScale);
     Debug.LogError("Rotational Root Motion not configured for Rigidbody");

  } else {
     applyRotationalRootMotionToTransform.Rotate(0f, 0f, skeletonDelta);
  }
   }

}
9 jours plus tard
Jamez0r a écrit

Thanks a ton Harald! I was able to piece it together in about an hour with your help, and it even worked on the first try :rofl:

Awesome, very glad to hear. :nerd:

Jamez0r a écrit

I'm hesitant to post my code since its pretty much frankenstein-ed together from the existing code, but I guess I might as well.

Thanks very much for sharing, always much appreciated! I will give it some thought in the next few days and then decide whether I'll integrate the feature officially into the runtimes. Since it should not come at any performance cost when inactive, I'm currently biased towards integrating it.


I have just added a commit to the 4.1-beta branch that adds rotational root motion to the SkeletonRootMotion components. It also supports rigidbodies and should work with different skeleton scale (flip X / Y) combinations as well. @Jamez0r You might want to check whether you would like to use this official implementation. If you give it a test run, please as always let me know if you find anything that does not work as intended.

A new 4.1-beta spine-unity unitypackage is available for download here as usual:
Spine Unity Download: spine unity 4.1 beta