diff --git a/Assets/Scripts/PlayerPoseController.cs b/Assets/Scripts/PlayerPoseController.cs
index 25df199..d0cfe55 100644
--- a/Assets/Scripts/PlayerPoseController.cs
+++ b/Assets/Scripts/PlayerPoseController.cs
@@ -13,12 +13,13 @@ using UnityEngine.InputSystem;
/// immediately instead of only once the player comes to a stop. One key cycles to the next pose
/// (wrapping), and sprinting in any state (ground, air, wall, ceiling) snaps the selection back to the default pose.
///
-/// The pose clip is played through a tiny (an
-/// on an ) that overrides
+/// The pose clip is played through a tiny (a two-input
+/// on an ) that overrides
/// the Animator Controller's run/idle animation for as long as a pose is selected (it only hands the
-/// rig back when the list is empty / the clip is null). The per-pose ceiling y-offset is routed
-/// through the controller (the single writer of the model-offset transform) so it composes with the
-/// wall-lean instead of fighting it.
+/// rig back when the list is empty / the clip is null). Switching pose crossfades the outgoing and
+/// incoming clips over instead of snapping. The per-pose ceiling
+/// y-offset is eased toward its target and routed through the controller (the single writer of the
+/// model-offset transform) so it composes with the wall-lean instead of fighting it.
///
[RequireComponent(typeof(PlayerCharacterController))]
[DisallowMultipleComponent]
@@ -44,18 +45,36 @@ public partial class PlayerPoseController : MonoBehaviour
[SerializeField] private KeyCode cycleKey = KeyCode.C;
#endif
+ [Header("Transitions")]
+ [Tooltip("Seconds to crossfade from the previous pose to a newly selected one. 0 = instant snap.")]
+ [Min(0f)]
+ [SerializeField] private float poseBlendDuration = 0.2f;
+
+ [Tooltip("Approach speed (local units/sec) for easing the ceiling y-offset toward its target. 0 = instant.")]
+ [Min(0f)]
+ [SerializeField] private float ceilingOffsetSpeed = 4f;
+
private PlayerCharacterController _player;
private Animator _animator;
private string _graphName; // cached so building the graph allocates no string from the Update path
private int _currentIndex;
- // Playables: a held clip that overrides the Animator Controller while idle.
+ // Playables: a 2-input AnimationMixerPlayable that overrides the Animator Controller. Input 1 holds
+ // the current ("to") pose; input 0 holds the outgoing ("from") pose during a crossfade and is empty
+ // once the blend completes. Blending both inputs over a per-frame rest baseline (see below) makes
+ // poses interpolate instead of snapping; an empty/partial "to" clip still resolves to rest.
private PlayableGraph _graph;
private AnimationPlayableOutput _output;
- private AnimationClipPlayable _clipPlayable;
- private AnimationClip _activeClip;
+ private AnimationMixerPlayable _mixer;
+ private AnimationClipPlayable _fromPlayable;
+ private AnimationClipPlayable _toPlayable;
+ private AnimationClip _toClip; // the clip currently targeted (held on input 1)
private bool _posePlaying;
+ private float _blend; // 0..1 weight of the "to" input; 1 = fully on the target pose
+ private bool _blending;
+
+ private float _ceilingOffset; // eased local-up offset currently fed to the controller
// Rest pose snapshot of the whole Animator subtree, taken in Awake before the Animator first
// evaluates. An AnimationClipPlayable only writes the bones its curves cover, so a clip that
@@ -105,10 +124,14 @@ public partial class PlayerPoseController : MonoBehaviour
// the bound-graph state (both only change here).
_player.AnimatorOverridden = _posePlaying;
- // Ceiling y-offset: only while stuck to the ceiling, routed through the controller so it
- // shares the wall-lean's single write of the model transform.
- float ceilingOffset = (pose && _player.IsOnCeiling) ? pose.CeilingYOffset : 0f;
- _player.SetPoseLocalYOffset(ceilingOffset);
+ // Ceiling y-offset: only while stuck to the ceiling. Ease toward the target instead of
+ // snapping, so entering and leaving the ceiling (and switching ceiling poses) glides. Routed
+ // through the controller so it shares the wall-lean's single write of the model transform.
+ float ceilingTarget = (pose && _player.IsOnCeiling) ? pose.CeilingYOffset : 0f;
+ _ceilingOffset = ceilingOffsetSpeed > 0f
+ ? Mathf.MoveTowards(_ceilingOffset, ceilingTarget, ceilingOffsetSpeed * Time.deltaTime)
+ : ceilingTarget;
+ _player.SetPoseLocalYOffset(_ceilingOffset);
}
void OnDisable()
@@ -116,6 +139,7 @@ public partial class PlayerPoseController : MonoBehaviour
// Hand the rig back to the Animator Controller and never leak a graph; rebuilt lazily on the
// next idle frame. Also clear any ceiling offset we were holding.
ReleasePose();
+ _ceilingOffset = 0f;
if (_player)
{
_player.SetPoseLocalYOffset(0f);
@@ -156,9 +180,11 @@ public partial class PlayerPoseController : MonoBehaviour
}
///
- /// Plays and holds the given clip through the Playables graph, overriding the Animator
- /// Controller. No-op while the same clip is already held, so idle re-apply never restarts it.
- /// A null clip (or no animator) releases back to the controller.
+ /// Plays and holds the given clip, overriding the Animator Controller. Switching to a different
+ /// clip crossfades from the current pose over (the first pose, or a
+ /// zero duration, appears instantly). Called every frame: it starts a transition on a clip change,
+ /// advances any in-flight blend, and re-snaps un-keyed bones to rest before the graph evaluates. A
+ /// null clip (or no animator) releases back to the controller.
///
private void PlayPose(AnimationClip clip)
{
@@ -167,32 +193,104 @@ public partial class PlayerPoseController : MonoBehaviour
ReleasePose();
return;
}
- if (_posePlaying && _activeClip == clip)
- return;
- if (!_graph.IsValid())
- {
- _graph = PlayableGraph.Create(_graphName);
- _graph.SetTimeUpdateMode(DirectorUpdateMode.GameTime);
- _output = AnimationPlayableOutput.Create(_graph, "Pose", _animator);
- }
- if (_clipPlayable.IsValid())
- _graph.DestroyPlayable(_clipPlayable);
+ EnsureGraph();
- // Snap un-keyed bones to rest first, so a partial or empty clip (e.g. the T-pose) defines the
- // whole body instead of inheriting the previous animation's pose. The clip then overrides the
- // bones it does key when the graph evaluates later this frame.
+ if (!(_posePlaying && _toClip == clip))
+ BeginTransition(clip);
+
+ AdvanceBlend(Time.deltaTime);
+
+ // Snap un-keyed bones to rest every frame, so each mixer input blends relative to rest: a
+ // partial or empty clip (e.g. the T-pose) defines the whole body, and a crossfade interpolates
+ // both poses against the same baseline. The graph overrides the keyed bones, weighted, when it
+ // evaluates later this frame.
ResetToRestPose();
- _clipPlayable = AnimationClipPlayable.Create(_graph, clip);
- _output.SetSourcePlayable(_clipPlayable);
if (!_graph.IsPlaying())
_graph.Play();
- _activeClip = clip;
_posePlaying = true;
}
+ /// Builds the graph (a 2-input mixer feeding the Animator output) lazily if it isn't alive yet.
+ private void EnsureGraph()
+ {
+ if (_graph.IsValid())
+ return;
+ _graph = PlayableGraph.Create(_graphName);
+ _graph.SetTimeUpdateMode(DirectorUpdateMode.GameTime);
+ _mixer = AnimationMixerPlayable.Create(_graph, 2);
+ _output = AnimationPlayableOutput.Create(_graph, "Pose", _animator);
+ _output.SetSourcePlayable(_mixer);
+ }
+
+ ///
+ /// Starts a crossfade to : the in-flight target becomes the outgoing pose on
+ /// input 0 (carrying its playback time), the new clip goes on input 1, and the blend ramps up from
+ /// 0. The first pose (or a zero duration) snaps in at full weight with no fade-from-nothing.
+ ///
+ private void BeginTransition(AnimationClip clip)
+ {
+ bool hadPose = _posePlaying && _toPlayable.IsValid();
+
+ // Drop the previous outgoing pose (already faded out, or none) before reusing input 0.
+ if (_fromPlayable.IsValid())
+ {
+ _mixer.DisconnectInput(0);
+ _graph.DestroyPlayable(_fromPlayable);
+ _fromPlayable = default;
+ }
+
+ if (hadPose)
+ {
+ // Move the current target from input 1 to input 0 so it becomes the outgoing pose without a
+ // recreate (its animation time carries over, no jump).
+ _mixer.DisconnectInput(1);
+ _fromPlayable = _toPlayable;
+ _mixer.ConnectInput(0, _fromPlayable, 0);
+ }
+
+ _toPlayable = AnimationClipPlayable.Create(_graph, clip);
+ _mixer.ConnectInput(1, _toPlayable, 0);
+ _toClip = clip;
+
+ _blend = (hadPose && poseBlendDuration > 0f) ? 0f : 1f;
+ _blending = _blend < 1f;
+ ApplyBlendWeights();
+ }
+
+ /// Advances an in-flight crossfade and drops the outgoing pose once it has fully faded.
+ private void AdvanceBlend(float dt)
+ {
+ if (!_blending)
+ return;
+ _blend = poseBlendDuration > 0f ? Mathf.MoveTowards(_blend, 1f, dt / poseBlendDuration) : 1f;
+ if (_blend >= 1f)
+ {
+ _blend = 1f;
+ _blending = false;
+ if (_fromPlayable.IsValid())
+ {
+ _mixer.DisconnectInput(0);
+ _graph.DestroyPlayable(_fromPlayable);
+ _fromPlayable = default;
+ }
+ }
+ ApplyBlendWeights();
+ }
+
+ /// Pushes the current blend onto the mixer inputs (smoothstepped while a "from" pose exists).
+ private void ApplyBlendWeights()
+ {
+ if (!_mixer.IsValid())
+ return;
+ bool hasFrom = _fromPlayable.IsValid();
+ float w = hasFrom ? Mathf.SmoothStep(0f, 1f, _blend) : 1f;
+ _mixer.SetInputWeight(0, hasFrom ? 1f - w : 0f);
+ _mixer.SetInputWeight(1, _toPlayable.IsValid() ? w : 0f);
+ }
+
///
/// Snapshots every transform a pose clip can address (the Animator's whole subtree) in its
/// authored rest pose. Taken in Awake before the Animator first evaluates, so it captures the rig
@@ -239,8 +337,12 @@ public partial class PlayerPoseController : MonoBehaviour
_graph.Destroy();
_graph = default;
_output = default;
- _clipPlayable = default;
- _activeClip = null;
+ _mixer = default;
+ _fromPlayable = default;
+ _toPlayable = default;
+ _toClip = null;
+ _blend = 0f;
+ _blending = false;
_posePlaying = false;
}
}