Skip to content

Instantly share code, notes, and snippets.

@delphic
Last active January 18, 2024 07:17
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save delphic/42e7ade45edb60df418954ef09287337 to your computer and use it in GitHub Desktop.
Save delphic/42e7ade45edb60df418954ef09287337 to your computer and use it in GitHub Desktop.
Unity3D Playables API Based Character Animation Controller
using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.Playables;
[RequireComponent(typeof(Animator))]
public class AnimationController : MonoBehaviour
{
const string ANIMATION = "Animation";
PlayableGraph _playableGraph;
PlayableOutput _playableOutput;
AnimationMixerPlayable _mixerPlayable;
AnimationClipPlayable[] _clipPlayables;
void OnDestroy()
{
_playableGraph.Destroy();
}
public void Init(AnimationClip[] clips)
{
_playableGraph = PlayableGraph.Create();
_playableGraph.SetTimeUpdateMode(DirectorUpdateMode.GameTime);
_playableOutput = AnimationPlayableOutput.Create(_playableGraph, ANIMATION, GetComponent<Animator>());
_mixerPlayable = AnimationMixerPlayable.Create(_playableGraph, clips.Length);
_playableOutput.SetSourcePlayable(_mixerPlayable);
_clipPlayables = new AnimationClipPlayable[clips.Length];
for (int i = 0, l = _clipPlayables.Length; i < l; i++)
{
_clipPlayables[i] = AnimationClipPlayable.Create(_playableGraph, clips[i]);
_playableGraph.Connect(_clipPlayables[i], 0, _mixerPlayable, i);
}
_playableGraph.Play();
}
public bool IsComplete(int index)
{
return _clipPlayables[index].GetTime() > _clipPlayables[index].GetAnimationClip().length;
}
public float GetNormalizedTime(int index)
{
return (float)_clipPlayables[index].GetTime() / _clipPlayables[index].GetAnimationClip().length;
}
public void SetTime(int index, float time)
{
_clipPlayables[index].SetTime(time);
}
public void SetWeight(int index, float weight)
{
_mixerPlayable.SetInputWeight(index, weight);
}
public void SetSpeed(int index, float speed)
{
_clipPlayables[index].SetSpeed(speed);
}
public void SetDuration(int index, float duration)
{
SetSpeed(index, _clipPlayables[index].GetAnimationClip().length / duration);
}
}
using UnityEngine;
using System.Collections.Generic;
public interface IEntityAnimationController
{
void PlayNamedClip(string name, bool clamp = false, float? duration = null);
}
public class CharacterAnimationController : AnimationController, IEntityAnimationController
{
[System.Serializable]
public struct NamedAnimationClip
{
public string Name;
public AnimationClip Clip;
}
[System.Serializable]
public struct MovementBlendSettings
{
[UnityEngine.Serialization.FormerlySerializedAs("UseBlendSettings")]
public bool SyncMovementClips;
public float DesiredClipLength;
public float NormalizedLeftFootfallTime;
}
private struct MovementBlendValues
{
public float PlaybackSpeed;
public float Offset;
}
// ^^ Previous two structs allow clips of different lengths and footfall
// positions to be adjusted dynamically to blend well by this controller
// ideally the animations would be such that this was not necessary
[System.Serializable]
public struct MovementClip
{
public MovementAnimationDef Def;
public float SpeedRangeMin;
public float SpeedRangeMax;
}
[System.Serializable]
public struct IdleClip
{
public AnimationClip Clip;
public float Weighting;
}
public event System.Action OnLeftFootfall;
public event System.Action OnRightFootfall;
[SerializeField]
bool _blendMovementClips;
[SerializeField]
IdleClip[] _idleClips;
[SerializeField]
MovementClip[] _moveClips;
[SerializeField]
MovementBlendSettings _moveBlendSettings;
[SerializeField]
NamedAnimationClip[] _clips;
[SerializeField, Range(0,10)]
float _speed = 0;
MovementController _movementController;
// ^^ Not included in gist, just need something that exposes current character speed.
Transform _transform;
// ^^ Assumes this component is on visual root
int[] _idleClipPlayables;
int[] _movementClipPlayables;
Dictionary<string, int> _namedClipPlayables;
int _idleClipIndex = 0;
int _movementClipIndex = 0;
string _namedClipName = null;
bool _clampNamedClip;
float _previousNormalizedMovementTime;
MovementBlendValues[] _movementBlendValues;
public void Init(MovementController movementController)
{
_movementController = movementController;
}
void Awake()
{
_transform = transform;
int clipCount = _idleClips.Length + _moveClips.Length + _clips.Length;
var clips = new List<AnimationClip>(clipCount);
_idleClipPlayables = new int[_idleClips.Length];
_namedClipPlayables = new Dictionary<string, int>(_clips.Length);
for (int i = 0, l = _idleClipPlayables.Length; i < l; i++)
{
_idleClipPlayables[i] = clips.Count;
clips.Add(_idleClips[i].Clip);
}
_movementBlendValues = new MovementBlendValues[_moveClips.Length];
_movementClipPlayables = new int[_moveClips.Length];
for (int i = 0, l = _moveClips.Length; i < l; i++)
{
CalculateMovementBlendValues(i);
_movementClipPlayables[i] = clips.Count;
clips.Add(_moveClips[i].Def.Clip);
}
for (int i = 0, l = _clips.Length; i < l; i++)
{
string name = _clips[i].Name;
_namedClipPlayables[name] = clips.Count;
clips.Add(_clips[i].Clip);
}
base.Init(clips.ToArray());
SetMovementClipPlaybackSpeeds();
}
void Update()
{
if (_movementController != null)
{
// Some Really Basic Smoothing
_speed = Mathf.Lerp(_speed, _movementController.Speed, 0.5f);
}
if (_namedClipName != null && !_clampNamedClip && IsComplete(_namedClipPlayables[_namedClipName]))
{
SetWeight(_namedClipPlayables[_namedClipName], 0);
_namedClipName = null;
}
if (_namedClipName == null)
{
if (_speed < 0.05f && _idleClipPlayables.Length > 0)
{
SetIdleWeights();
}
else if (_movementClipPlayables.Length > 0)
{
if (_blendMovementClips)
{
SetBlendedMovementWeights();
CheckForFootfalls();
// ^^ Assumes either using the blend settings to enforce same footfall positions
// or that they are at approximately the same point in each clip
// In this mode foot falls do not vary by how fast you're moving
}
else
{
SetMovementWeights();
CheckForFootfalls();
}
}
}
}
public void Idle()
{
if (_namedClipName != null)
{
SetWeight(_namedClipPlayables[_namedClipName], 0);
}
_clampNamedClip = false;
_namedClipName = null;
SetIdleWeights();
}
public void PlayNamedClip(string name, bool clamp = false, float? duration = null)
{
if (_namedClipPlayables.ContainsKey(name))
{
_clampNamedClip = clamp;
_namedClipName = name;
SetTime(_namedClipPlayables[name], 0);
if (duration.HasValue)
{
SetDuration(_namedClipPlayables[name], duration.Value);
}
else
{
SetSpeed(_namedClipPlayables[name], 1);
}
int index = _namedClipPlayables[name];
for (int i = 0; i < _totalClipCount; i++)
{
if (i == index)
{
SetWeight(i, 1);
}
else
{
SetWeight(i, 0);
}
}
}
}
void CalculateMovementBlendValues(int i)
{
_movementBlendValues[i] = new MovementBlendValues();
if (_moveBlendSettings.SyncMovementClips)
{
int cycleCount = _moveClips[i].Def.LeftFootfallNormalizedTimes.Length;
_movementBlendValues[i].PlaybackSpeed = _moveClips[i].Def.Clip.length / (cycleCount * _moveBlendSettings.DesiredClipLength);
_movementBlendValues[i].Offset = _moveClips[i].Def.LeftFootfallNormalizedTimes[0] - _moveBlendSettings.NormalizedLeftFootfallTime;
if (_movementBlendValues[i].Offset < 0)
{
_movementBlendValues[i].Offset = 1 - _movementBlendValues[i].Offset;
}
}
else
{
_movementBlendValues[i].PlaybackSpeed = 1;
_movementBlendValues[i].Offset = 0;
}
}
void SetMovementClipPlaybackSpeeds()
{
if (_blendMovementClips)
{
for (int i = 0, l = _movementClipPlayables.Length; i < l; i++)
{
SetSpeed(_movementClipPlayables[i], _movementBlendValues[i].PlaybackSpeed);
}
}
}
void SetIdleWeights()
{
int mixerIndexOffset = _idleClipPlayables.Length;
for (int i = 0, l = _movementClipPlayables.Length; i < l; i++)
{
SetWeight(_movementClipPlayables[i], 0);
if (_blendMovementClips)
{
SetTime(_movementClipPlayables[i], _movementBlendValues[i].Offset);
}
else
{
SetTime(_movementClipPlayables[i], 0);
}
}
if (IsComplete(_idleClipPlayables[_idleClipIndex]))
{
_idleClipIndex = PickIdleIndex();
SetTime(_idleClipPlayables[_idleClipIndex], 0);
}
// Would be nice to blend these in out - more state transition blends!
for(int i = 0, l = _idleClipPlayables.Length; i < l; i++)
{
SetWeight(_idleClipPlayables[i], i == _idleClipIndex ? 1 : 0);
}
}
void SetMovementWeights()
{
// Uses each animation for a range of speeds changing the speed of play back to match.
// Scales to overspeed - but transitions instantly between animations
for (int i = 0, l = _idleClipPlayables.Length; i < l; i++)
{
SetWeight(_idleClipPlayables[i], 0);
}
_movementClipIndex = GetClosestMovementClipIndex(_speed);
for (int i = 0, l = _moveClips.Length; i < l; i++)
{
if (i == _movementClipIndex)
{
SetSpeed(_movementClipPlayables[i], _speed / GetTravelSpeed(i));
SetWeight(_movementClipPlayables[i], 1);
}
else
{
SetWeight(_movementClipPlayables[i], 0);
}
}
if (IsComplete(_movementClipPlayables[_movementClipIndex]))
{
SetTime(_movementClipPlayables[_movementClipIndex], 0);
}
}
void SetBlendedMovementWeights()
{
for(int i = 0, l = _idleClipPlayables.Length; i < l; i++)
{
SetWeight(_idleClipPlayables[i], 0);
}
_movementClipIndex = GetClosestMovementClipIndex(_speed);
// This method compares the travel speed of the clip to the speed of the agent
// and lerps between movement clips to pick a combination, does not alter playback speed
for (int i = 0, l = _moveClips.Length; i < l; i++)
{
float travelSpeed = GetTravelSpeed(i);
if (_speed >= travelSpeed)
{
float weight = 1;
if (i + 1 < l)
{
float upperTravelSpeed = GetTravelSpeed(i + 1);
weight = Mathf.Clamp01(1 - (_speed - travelSpeed) / (upperTravelSpeed - travelSpeed));
}
SetWeight(_movementClipPlayables[i], weight);
}
else
{
float weight = 1;
if (i > 0)
{
float lowerTravelSpeed = GetTravelSpeed(i - 1);
weight = Mathf.Clamp01((_speed - lowerTravelSpeed) / (travelSpeed - lowerTravelSpeed));
}
if (i == 0)
{
float lowerTravelSpeed = 0;
weight = Mathf.Clamp01((_speed - lowerTravelSpeed) / (travelSpeed - lowerTravelSpeed));
SetWeight(_idleClipPlayables[0], 1 - weight);
}
SetWeight(_movementClipPlayables[i], weight);
}
// Loop clips as necessary
if (IsComplete(_movementClipPlayables[i]))
{
SetTime(_movementClipPlayables[i], 0);
}
}
// Loop idle clip as necessary
if (IsComplete(_idleClipPlayables[0]))
{
SetTime(_idleClipPlayables[0], 0);
}
}
void CheckForFootfalls()
{
float normalizedMovementTime = GetNormalizedTime(_movementClipPlayables[_movementClipIndex]);
float[] footfalls;
if (OnLeftFootfall != null && _moveClips[_movementClipIndex].Def.LeftFootfallNormalizedTimes != null)
{
footfalls = _moveClips[_movementClipIndex].Def.LeftFootfallNormalizedTimes;
for (int i = 0, l = footfalls.Length; i < l; i++)
{
if (normalizedMovementTime >= footfalls[i]
&& (_previousNormalizedMovementTime < footfalls[i] || _previousNormalizedMovementTime > normalizedMovementTime))
{
OnLeftFootfall();
}
}
}
if (OnRightFootfall != null && _moveClips[_movementClipIndex].Def.RightFootfallNormalizedTimes != null)
{
footfalls = _moveClips[_movementClipIndex].Def.RightFootfallNormalizedTimes;
for (int i = 0, l = footfalls.Length; i < l; i++)
{
if (normalizedMovementTime >= footfalls[i]
&& (_previousNormalizedMovementTime < footfalls[i] || _previousNormalizedMovementTime > normalizedMovementTime))
{
OnRightFootfall();
}
}
}
_previousNormalizedMovementTime = normalizedMovementTime;
}
#region Helpers
float GetTravelSpeed(int moveClipIndex)
{
float travelSpeed = _moveClips[moveClipIndex].Def.TravelSpeed;
if (_blendMovementClips)
{
travelSpeed *= _movementBlendValues[moveClipIndex].PlaybackSpeed;
}
return travelSpeed * _transform.localScale.z; // Assumption of forward motion only
}
bool InSpeedRange(MovementClip movementClip, float speed)
{
return movementClip.SpeedRangeMin * _transform.localScale.z <= _speed && movementClip.SpeedRangeMax * _transform.localScale.z >= speed;
}
int GetClosestMovementClipIndex(float speed)
{
if (_moveClips == null || _moveClips.Length == 0)
{
return -1;
}
// Prioritise current clip if there are overlaps
// NOTE: on player characters want overlap ranges to prevent back
// and forth between clips around the boundary due to input drift
if (InSpeedRange(_moveClips[_movementClipIndex], speed))
{
return _movementClipIndex;
}
int result = 0;
float minDistance = Mathf.Infinity;
for(int i = 0, l = _moveClips.Length; i < l; i++)
{
if (InSpeedRange(_moveClips[i], speed))
{
result = i;
break;
}
float distance = Mathf.Min(
Mathf.Abs(_moveClips[i].SpeedRangeMin * _transform.localScale.z - speed),
Mathf.Abs(_moveClips[i].SpeedRangeMax * _transform.localScale.z - speed));
if (distance < minDistance)
{
minDistance = distance;
result = i;
}
}
return result;
}
int PickIdleIndex()
{
if (_idleClips.Length == 1)
{
return 0;
}
float totalWeight = 0;
for(int i = 0, l = _idleClips.Length; i < l; i++)
{
totalWeight += _idleClips[i].Weighting;
}
float random = Random.value * totalWeight;
float evaluatedWeight = 0;
for (int i = 0, l = _idleClips.Length; i < l; i++)
{
if (random <= evaluatedWeight + _idleClips[i].Weighting)
{
return i;
}
evaluatedWeight += _idleClips[i].Weighting;
}
// Technically will never be reached but C# doesn't know that
return 0;
}
#endregion
}
Copyright (c) 2019 Harry Jones
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.
@delphic
Copy link
Author

delphic commented Apr 11, 2019

This is the animation controller code as talked about in this blog post https://delphic.me.uk/blog/seekers_animation_system

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment