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 yazdı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 yazdı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);
}
}
}