Last active
January 18, 2024 07:17
-
-
Save delphic/42e7ade45edb60df418954ef09287337 to your computer and use it in GitHub Desktop.
Unity3D Playables API Based Character Animation Controller
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This is the animation controller code as talked about in this blog post https://delphic.me.uk/blog/seekers_animation_system