Add crossfade pose transitions and eased ceiling Y-offset
Replace the hard pose snap with a 2-input AnimationMixerPlayable crossfade: the running pose moves to input 0 (keeping its play time), the new pose to input 1, weight ramps over poseBlendDuration (smoothstep). Per-frame rest reset stays, so empty/partial clips still resolve to rest during and after the blend. Ceiling Y-offset now eases via MoveTowards (ceilingOffsetSpeed) on enter/exit/pose-change instead of snapping. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
c41fb398e4
commit
def10541ab
1 file changed
+135
-33
@@ -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
|
/// 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.
|
/// (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 <see cref="PlayableGraph"/> (an
|
/// The pose clip is played through a tiny <see cref="PlayableGraph"/> (a two-input
|
||||||
/// <see cref="AnimationClipPlayable"/> on an <see cref="AnimationPlayableOutput"/>) that overrides
|
/// <see cref="AnimationMixerPlayable"/> on an <see cref="AnimationPlayableOutput"/>) that overrides
|
||||||
/// the Animator Controller's run/idle animation for as long as a pose is selected (it only hands the
|
/// 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
|
/// rig back when the list is empty / the clip is null). Switching pose crossfades the outgoing and
|
||||||
/// through the controller (the single writer of the model-offset transform) so it composes with the
|
/// incoming clips over <see cref="poseBlendDuration"/> instead of snapping. The per-pose ceiling
|
||||||
/// wall-lean instead of fighting it.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[RequireComponent(typeof(PlayerCharacterController))]
|
[RequireComponent(typeof(PlayerCharacterController))]
|
||||||
[DisallowMultipleComponent]
|
[DisallowMultipleComponent]
|
||||||
@@ -44,18 +45,36 @@ public partial class PlayerPoseController : MonoBehaviour
|
|||||||
[SerializeField] private KeyCode cycleKey = KeyCode.C;
|
[SerializeField] private KeyCode cycleKey = KeyCode.C;
|
||||||
#endif
|
#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 PlayerCharacterController _player;
|
||||||
private Animator _animator;
|
private Animator _animator;
|
||||||
private string _graphName; // cached so building the graph allocates no string from the Update path
|
private string _graphName; // cached so building the graph allocates no string from the Update path
|
||||||
|
|
||||||
private int _currentIndex;
|
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 PlayableGraph _graph;
|
||||||
private AnimationPlayableOutput _output;
|
private AnimationPlayableOutput _output;
|
||||||
private AnimationClipPlayable _clipPlayable;
|
private AnimationMixerPlayable _mixer;
|
||||||
private AnimationClip _activeClip;
|
private AnimationClipPlayable _fromPlayable;
|
||||||
|
private AnimationClipPlayable _toPlayable;
|
||||||
|
private AnimationClip _toClip; // the clip currently targeted (held on input 1)
|
||||||
private bool _posePlaying;
|
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
|
// 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
|
// 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).
|
// the bound-graph state (both only change here).
|
||||||
_player.AnimatorOverridden = _posePlaying;
|
_player.AnimatorOverridden = _posePlaying;
|
||||||
|
|
||||||
// Ceiling y-offset: only while stuck to the ceiling, routed through the controller so it
|
// Ceiling y-offset: only while stuck to the ceiling. Ease toward the target instead of
|
||||||
// shares the wall-lean's single write of the model transform.
|
// snapping, so entering and leaving the ceiling (and switching ceiling poses) glides. Routed
|
||||||
float ceilingOffset = (pose && _player.IsOnCeiling) ? pose.CeilingYOffset : 0f;
|
// through the controller so it shares the wall-lean's single write of the model transform.
|
||||||
_player.SetPoseLocalYOffset(ceilingOffset);
|
float ceilingTarget = (pose && _player.IsOnCeiling) ? pose.CeilingYOffset : 0f;
|
||||||
|
_ceilingOffset = ceilingOffsetSpeed > 0f
|
||||||
|
? Mathf.MoveTowards(_ceilingOffset, ceilingTarget, ceilingOffsetSpeed * Time.deltaTime)
|
||||||
|
: ceilingTarget;
|
||||||
|
_player.SetPoseLocalYOffset(_ceilingOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
void OnDisable()
|
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
|
// 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.
|
// next idle frame. Also clear any ceiling offset we were holding.
|
||||||
ReleasePose();
|
ReleasePose();
|
||||||
|
_ceilingOffset = 0f;
|
||||||
if (_player)
|
if (_player)
|
||||||
{
|
{
|
||||||
_player.SetPoseLocalYOffset(0f);
|
_player.SetPoseLocalYOffset(0f);
|
||||||
@@ -156,9 +180,11 @@ public partial class PlayerPoseController : MonoBehaviour
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Plays and holds the given clip through the Playables graph, overriding the Animator
|
/// Plays and holds the given clip, overriding the Animator Controller. Switching to a different
|
||||||
/// Controller. No-op while the same clip is already held, so idle re-apply never restarts it.
|
/// clip crossfades from the current pose over <see cref="poseBlendDuration"/> (the first pose, or a
|
||||||
/// A null clip (or no animator) releases back to the controller.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void PlayPose(AnimationClip clip)
|
private void PlayPose(AnimationClip clip)
|
||||||
{
|
{
|
||||||
@@ -167,32 +193,104 @@ public partial class PlayerPoseController : MonoBehaviour
|
|||||||
ReleasePose();
|
ReleasePose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_posePlaying && _activeClip == clip)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (!_graph.IsValid())
|
EnsureGraph();
|
||||||
{
|
|
||||||
_graph = PlayableGraph.Create(_graphName);
|
|
||||||
_graph.SetTimeUpdateMode(DirectorUpdateMode.GameTime);
|
|
||||||
_output = AnimationPlayableOutput.Create(_graph, "Pose", _animator);
|
|
||||||
}
|
|
||||||
if (_clipPlayable.IsValid())
|
|
||||||
_graph.DestroyPlayable(_clipPlayable);
|
|
||||||
|
|
||||||
// Snap un-keyed bones to rest first, so a partial or empty clip (e.g. the T-pose) defines the
|
if (!(_posePlaying && _toClip == clip))
|
||||||
// whole body instead of inheriting the previous animation's pose. The clip then overrides the
|
BeginTransition(clip);
|
||||||
// bones it does key when the graph evaluates later this frame.
|
|
||||||
|
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();
|
ResetToRestPose();
|
||||||
|
|
||||||
_clipPlayable = AnimationClipPlayable.Create(_graph, clip);
|
|
||||||
_output.SetSourcePlayable(_clipPlayable);
|
|
||||||
if (!_graph.IsPlaying())
|
if (!_graph.IsPlaying())
|
||||||
_graph.Play();
|
_graph.Play();
|
||||||
|
|
||||||
_activeClip = clip;
|
|
||||||
_posePlaying = true;
|
_posePlaying = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the graph (a 2-input mixer feeding the Animator output) lazily if it isn't alive yet.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts a crossfade to <paramref name="clip"/>: 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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Advances an in-flight crossfade and drops the outgoing pose once it has fully faded.</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pushes the current blend onto the mixer inputs (smoothstepped while a "from" pose exists).</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Snapshots every transform a pose clip can address (the Animator's whole subtree) in its
|
/// 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
|
/// 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.Destroy();
|
||||||
_graph = default;
|
_graph = default;
|
||||||
_output = default;
|
_output = default;
|
||||||
_clipPlayable = default;
|
_mixer = default;
|
||||||
_activeClip = null;
|
_fromPlayable = default;
|
||||||
|
_toPlayable = default;
|
||||||
|
_toClip = null;
|
||||||
|
_blend = 0f;
|
||||||
|
_blending = false;
|
||||||
_posePlaying = false;
|
_posePlaying = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user