From f68e4981544342752bd07d44e8659e3bb76677fe Mon Sep 17 00:00:00 2001 From: Nathan Sweet Date: Mon, 23 Mar 2026 14:07:56 -0400 Subject: [PATCH] [libgdx] Improved AnimationState hold system. Removed holdPrevious, interruptAlpha. --- .../spine/utils/SkeletonSerializer.java | 3 - .../com/esotericsoftware/spine/Animation.java | 2 +- .../spine/AnimationState.java | 183 ++++++------------ .../spine/SkeletonViewer.java | 4 +- .../spine/SkeletonViewerUI.java | 5 +- 5 files changed, 60 insertions(+), 137 deletions(-) diff --git a/spine-libgdx/spine-libgdx-tests/src/com/esotericsoftware/spine/utils/SkeletonSerializer.java b/spine-libgdx/spine-libgdx-tests/src/com/esotericsoftware/spine/utils/SkeletonSerializer.java index 2eed182da..c2d3847f5 100644 --- a/spine-libgdx/spine-libgdx-tests/src/com/esotericsoftware/spine/utils/SkeletonSerializer.java +++ b/spine-libgdx/spine-libgdx-tests/src/com/esotericsoftware/spine/utils/SkeletonSerializer.java @@ -1917,9 +1917,6 @@ public class SkeletonSerializer { writeTrackEntry(obj.getMixingTo()); } - json.writeName("holdPrevious"); - json.writeValue(obj.getHoldPrevious()); - json.writeName("shortestRotation"); json.writeValue(obj.getShortestRotation()); diff --git a/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/Animation.java b/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/Animation.java index 225de8c11..8228e867a 100644 --- a/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/Animation.java +++ b/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/Animation.java @@ -1673,7 +1673,7 @@ public class Animation { } /** Fires events for frames > lastTime and <= time. */ - public void apply (Skeleton skeleton, float lastTime, float time, @Null Array firedEvents, float alpha, + public void apply (@Null Skeleton skeleton, float lastTime, float time, @Null Array firedEvents, float alpha, boolean fromSetup, boolean add, boolean out, boolean appliedPose) { if (firedEvents == null) return; diff --git a/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/AnimationState.java b/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/AnimationState.java index afb8944ae..f9a9e2850 100644 --- a/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/AnimationState.java +++ b/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/AnimationState.java @@ -50,39 +50,7 @@ import com.esotericsoftware.spine.Animation.Timeline; * See Applying Animations in the Spine Runtimes Guide. */ public class AnimationState { static final Animation emptyAnimation = new Animation("", new Array(true, 0, Timeline[]::new), 0); - - /** 1) A previously applied timeline has set this property.
- * Result: Mix from the current pose to the timeline pose. */ - static private final int SUBSEQUENT = 0; - /** 1) This is the first timeline to set this property.
- * 2) The next track entry applied after this one does not have a timeline to set this property.
- * Result: Mix from the setup pose to the timeline pose. */ - static private final int FIRST = 1; - /** 1) A previously applied timeline has set this property.
- * 2) The next track entry to be applied does have a timeline to set this property.
- * 3) The next track entry after that one does not have a timeline to set this property.
- * Result: Mix from the current pose to the timeline pose, but do not mix out. This avoids "dipping" when crossfading - * animations that key the same property. A subsequent timeline will set this property using a mix. */ - static private final int HOLD_SUBSEQUENT = 2; - /** 1) This is the first timeline to set this property.
- * 2) The next track entry to be applied does have a timeline to set this property.
- * 3) The next track entry after that one does not have a timeline to set this property.
- * Result: Mix from the setup pose to the timeline pose, but do not mix out. This avoids "dipping" when crossfading animations - * that key the same property. A subsequent timeline will set this property using a mix. */ - static private final int HOLD_FIRST = 3; - /** 1) This is the first timeline to set this property.
- * 2) The next track entry to be applied does have a timeline to set this property.
- * 3) The next track entry after that one does have a timeline to set this property.
- * 4) timelineHoldMix stores the first subsequent track entry that does not have a timeline to set this property.
- * Result: The same as HOLD except the mix percentage from the timelineHoldMix track entry is used. This handles when more than - * 2 track entries in a row have a timeline that sets the same property.
- * Eg, A -> B -> C -> D where A, B, and C have a timeline setting same property, but D does not. When A is applied, to avoid - * "dipping" A is not mixed out, however D (the first entry that doesn't set the property) mixing in is used to mix out A - * (which affects B and C). Without using D to mix out, A would be applied fully until mixing completes, then snap to the mixed - * out position. */ - static private final int HOLD_MIX = 4; - - static private final int SETUP = 1, CURRENT = 2; + static private final int SUBSEQUENT = 0, FIRST = 1, HOLD = 2, HOLD_FIRST = 3, SETUP = 1, CURRENT = 2; private AnimationStateData data; final Array tracks = new Array(true, 4, TrackEntry[]::new); @@ -184,7 +152,10 @@ public class AnimationState { if (from.totalAlpha == 0 || to.mixDuration == 0) { to.mixingFrom = from.mixingFrom; if (from.mixingFrom != null) from.mixingFrom.mixingTo = to; - to.interruptAlpha = from.interruptAlpha; + if (from.totalAlpha == 0) { + for (TrackEntry next = to; next.mixingTo != null; next = next.mixingTo) + next.keepHold = true; + } queue.end(from); } return finished; @@ -243,7 +214,7 @@ public class AnimationState { : current.timelinesRotation.items; for (int ii = 0; ii < timelineCount; ii++) { Timeline timeline = timelines[ii]; - boolean fromSetup = timelineMode[ii] == FIRST; + boolean fromSetup = (timelineMode[ii] & FIRST) != 0; if (!shortestRotation && timeline instanceof RotateTimeline rotateTimeline) { applyRotateTimeline(rotateTimeline, skeleton, applyTime, alpha, fromSetup, timelinesRotation, ii << 1, firstFrame); @@ -279,20 +250,12 @@ public class AnimationState { private float applyMixingFrom (TrackEntry to, Skeleton skeleton) { TrackEntry from = to.mixingFrom; - if (from.mixingFrom != null) applyMixingFrom(from, skeleton); - - float mix; - if (to.mixDuration == 0) // Single frame mix to undo mixingFrom changes. - mix = 1; - else { - mix = to.mixTime / to.mixDuration; - if (mix > 1) mix = 1; - } - + float fromMix = from.mixingFrom != null ? applyMixingFrom(from, skeleton) : 1; + float mix = to.mixDuration == 0 ? 1 : Math.min(1, to.mixTime / to.mixDuration); boolean attachments = mix < from.mixAttachmentThreshold, drawOrder = mix < from.mixDrawOrderThreshold; int timelineCount = from.animation.timelines.size; Timeline[] timelines = from.animation.timelines.items; - float alphaHold = from.alpha * to.interruptAlpha, alphaMix = alphaHold * (1 - mix); + float alphaMix = from.alpha * fromMix * (1 - mix), keep = 1 - mix * to.alpha, alphaHold = keep > 0 ? alphaMix / keep : 0; float animationLast = from.animationLast, animationTime = from.getAnimationTime(), applyTime = animationTime; Array events = null; if (from.reverse) @@ -307,33 +270,17 @@ public class AnimationState { from.totalAlpha = 0; for (int i = 0; i < timelineCount; i++) { Timeline timeline = timelines[i]; - boolean fromSetup; + int mode = timelineMode[i]; float alpha; - switch (timelineMode[i]) { - case SUBSEQUENT -> { - if (!drawOrder && timeline instanceof DrawOrderTimeline) continue; - fromSetup = false; - alpha = alphaMix; - } - case FIRST -> { - fromSetup = true; - alpha = alphaMix; - } - case HOLD_SUBSEQUENT -> { - fromSetup = false; - alpha = alphaHold; - } - case HOLD_FIRST -> { - fromSetup = true; - alpha = alphaHold; - } - default -> { // HOLD_MIX - fromSetup = true; + if ((mode & HOLD) != 0) { TrackEntry holdMix = timelineHoldMix[i]; - alpha = alphaHold * Math.max(0, 1 - holdMix.mixTime / holdMix.mixDuration); - } + alpha = holdMix == null ? alphaHold : alphaHold * Math.max(0, 1 - holdMix.mixTime / holdMix.mixDuration); + } else { + if (!drawOrder && timeline instanceof DrawOrderTimeline) continue; + alpha = alphaMix; } from.totalAlpha += alpha; + boolean fromSetup = (mode & FIRST) != 0; if (!shortestRotation && timeline instanceof RotateTimeline rotateTimeline) { applyRotateTimeline(rotateTimeline, skeleton, applyTime, alpha, fromSetup, timelinesRotation, i << 1, firstFrame); } else if (timeline instanceof AttachmentTimeline attachmentTimeline) @@ -522,11 +469,6 @@ public class AnimationState { current.mixingFrom = from; from.mixingTo = current; current.mixTime = 0; - - // Store the interrupted mix percentage. - if (from.mixingFrom != null && from.mixDuration > 0) - current.interruptAlpha *= Math.min(1, from.mixTime / from.mixDuration); - from.timelinesRotation.clear(); // Reset rotation for mixing out, in case entry was mixed in. } @@ -623,7 +565,7 @@ public class AnimationState { * {@link #setEmptyAnimations(float)}, or {@link #addEmptyAnimation(int, float, float)}. Mixing to an empty animation causes * the previous animation to be applied less and less over the mix duration. Properties keyed in the previous animation * transition to the value from lower tracks or to the setup pose value if no lower tracks key the property. A mix duration of - * 0 still needs to be applied one more time to mix out, so the the properties it was animating are reverted. + * 0 still needs to be applied one more time to mix out, so the properties it was animating are reverted. *

* Mixing in is done by first setting an empty animation, then adding an animation using * {@link #addAnimation(int, Animation, boolean, float)} with the desired delay (an empty animation has a duration of 0) and on @@ -689,7 +631,6 @@ public class AnimationState { entry.trackIndex = trackIndex; entry.animation = animation; entry.loop = loop; - entry.holdPrevious = false; entry.additive = false; entry.reverse = false; @@ -715,8 +656,8 @@ public class AnimationState { entry.alpha = 1; entry.mixTime = 0; entry.mixDuration = last == null ? 0 : data.getMix(last.animation, animation); - entry.interruptAlpha = 1; entry.totalAlpha = 0; + entry.keepHold = false; return entry; } @@ -756,40 +697,45 @@ public class AnimationState { entry.timelineHoldMix.clear(); TrackEntry[] timelineHoldMix = entry.timelineHoldMix.setSize(timelinesCount); ObjectSet propertyIds = this.propertyIds; - boolean holdPrevious = false, add = entry.additive; + boolean add = entry.additive, keepHold = entry.keepHold; TrackEntry to = entry.mixingTo; - if (to != null) { - if (to.additive) - to = null; - else - holdPrevious = to.holdPrevious; - } + outer: for (int i = 0; i < timelinesCount; i++) { Timeline timeline = timelines[i]; String[] ids = timeline.propertyIds; boolean first = propertyIds.addAll(ids) && !(timeline instanceof DrawOrderFolderTimeline && propertyIds.contains(DrawOrderTimeline.propertyID)); - if (add && timeline.additive) + + if (add && timeline.additive) { timelineMode[i] = first ? FIRST : SUBSEQUENT; - else if (!first) - timelineMode[i] = holdPrevious ? HOLD_SUBSEQUENT : SUBSEQUENT; - else if (holdPrevious) - timelineMode[i] = HOLD_FIRST; - else if (to == null || timeline.instant || !to.animation.hasTimeline(ids)) - timelineMode[i] = FIRST; - else { - for (TrackEntry next = to.mixingTo; next != null; next = next.mixingTo) { - if (next.animation.hasTimeline(ids)) continue; - if (next.mixDuration > 0) { - timelineMode[i] = HOLD_MIX; - timelineHoldMix[i] = next; - continue outer; - } - break; - } - timelineMode[i] = HOLD_FIRST; + continue; } + + for (TrackEntry from = entry.mixingFrom; from != null; from = from.mixingFrom) { + if (from.animation.hasTimeline(ids)) { + // An earlier entry on this track keys this property, isolating it from lower tracks. + timelineMode[i] = SUBSEQUENT; + continue outer; + } + } + + // Hold if the next entry will overwrite this property. + int mode; + if (to == null || timeline.instant || (to.additive && timeline.additive) || !to.animation.hasTimeline(ids)) + mode = first ? FIRST : SUBSEQUENT; + else { + mode = first ? HOLD_FIRST : HOLD; + // Find next entry that doesn't overwrite this property. Its mix fades out the hold, instead of it ending abruptly. + for (TrackEntry next = to.mixingTo; next != null; next = next.mixingTo) { + if ((next.additive && timeline.additive) || !next.animation.hasTimeline(ids)) { + if (next.mixDuration > 0) timelineHoldMix[i] = next; + break; + } + } + } + if (keepHold) mode = (mode & ~HOLD) | (timelineMode[i] & HOLD); + timelineMode[i] = mode; } } @@ -871,12 +817,17 @@ public class AnimationState { @Null TrackEntry previous, next, mixingFrom, mixingTo; @Null AnimationStateListener listener; int trackIndex; - boolean loop, holdPrevious, additive, reverse, shortestRotation; + boolean loop, additive, reverse, shortestRotation, keepHold; float eventThreshold, mixAttachmentThreshold, alphaAttachmentThreshold, mixDrawOrderThreshold; float animationStart, animationEnd, animationLast, nextAnimationLast; float delay, trackTime, trackLast, nextTrackLast, trackEnd, timeScale; - float alpha, mixTime, mixDuration, interruptAlpha, totalAlpha; + float alpha, mixTime, mixDuration, totalAlpha; + /** For each timeline: + *

  • Bit 0, FIRST: 0 = mix from current pose, 1 = mix from setup pose. Timeline is first to set the property. + *
  • Bit 1, HOLD: 0 = mix out using alphaMix, 1 = apply full alpha to prevent dipping. Timeline is first on its track to + * set the property and the next entry (mixingTo) also sets it. When held, timelineHoldMix's mix controls how the hold fades + * out (for 3+ entry chains where the chain eventually stops setting the property). */ final IntArray timelineMode = new IntArray(); final Array timelineHoldMix = new Array(true, 8, TrackEntry[]::new); final FloatArray timelinesRotation = new FloatArray(); @@ -1167,9 +1118,8 @@ public class AnimationState { /** Seconds for mixing from the previous animation to this animation. Defaults to the value provided by * {@link AnimationStateData#getMix(Animation, Animation)} based on the animation before this animation (if any). *

    - * A mix duration of 0 still needs to be applied one more time to mix out, so the the properties it was animating are - * reverted. A mix duration of 0 can be set at any time to end the mix on the next {@link AnimationState#update(float) - * update}. + * A mix duration of 0 still needs to be applied one more time to mix out, so the properties it was animating are reverted. + * A mix duration of 0 can be set at any time to end the mix on the next {@link AnimationState#update(float) update}. *

    * The mixDuration can be set manually rather than use the value from * {@link AnimationStateData#getMix(Animation, Animation)}. In that case, the mixDuration can be set for a new @@ -1232,25 +1182,6 @@ public class AnimationState { return mixingTo; } - public void setHoldPrevious (boolean holdPrevious) { - this.holdPrevious = holdPrevious; - } - - /** If true, when mixing from the previous animation to this animation, the previous animation is applied as normal instead - * of being mixed out. - *

    - * When mixing between animations that key the same property, if a lower track also keys that property then the value will - * briefly dip toward the lower track value during the mix. This happens because the first animation mixes from 100% to 0% - * while the second animation mixes from 0% to 100%. Setting holdPrevious to true applies the first animation - * at 100% during the mix so the lower track value is overwritten. Such dipping does not occur on the lowest track which - * keys the property, only when a higher track also keys the property. - *

    - * Snapping will occur if holdPrevious is true and this animation does not key all the same properties as the - * previous animation. */ - public boolean getHoldPrevious () { - return holdPrevious; - } - public void setShortestRotation (boolean shortestRotation) { this.shortestRotation = shortestRotation; } diff --git a/spine-libgdx/spine-skeletonviewer/src/com/esotericsoftware/spine/SkeletonViewer.java b/spine-libgdx/spine-skeletonviewer/src/com/esotericsoftware/spine/SkeletonViewer.java index 5e2e0775f..c81933e43 100644 --- a/spine-libgdx/spine-skeletonviewer/src/com/esotericsoftware/spine/SkeletonViewer.java +++ b/spine-libgdx/spine-skeletonviewer/src/com/esotericsoftware/spine/SkeletonViewer.java @@ -211,10 +211,8 @@ public class SkeletonViewer extends ApplicationAdapter { state.setEmptyAnimation(track, 0); entry = state.addAnimation(track, ui.animationList.getSelected(), ui.loopCheckbox.isChecked(), 0); entry.setMixDuration(ui.mixSlider.getValue()); - } else { + } else entry = state.setAnimation(track, ui.animationList.getSelected(), ui.loopCheckbox.isChecked()); - entry.setHoldPrevious(track > 0 && ui.holdPrevCheckbox.isChecked()); - } entry.setAdditive(track > 0 && ui.addCheckbox.isChecked()); entry.setReverse(ui.reverseCheckbox.isChecked()); entry.setAlpha(ui.alphaSlider.getValue()); diff --git a/spine-libgdx/spine-skeletonviewer/src/com/esotericsoftware/spine/SkeletonViewerUI.java b/spine-libgdx/spine-skeletonviewer/src/com/esotericsoftware/spine/SkeletonViewerUI.java index 6a098b179..da067aab0 100644 --- a/spine-libgdx/spine-skeletonviewer/src/com/esotericsoftware/spine/SkeletonViewerUI.java +++ b/spine-libgdx/spine-skeletonviewer/src/com/esotericsoftware/spine/SkeletonViewerUI.java @@ -570,10 +570,7 @@ class SkeletonViewerUI { if (current != null) { loopCheckbox.setChecked(current.getLoop()); reverseCheckbox.setChecked(current.getReverse()); - if (track > 0) { - addCheckbox.setChecked(current.getAdditive()); - holdPrevCheckbox.setChecked(current.getHoldPrevious()); - } + if (track > 0) addCheckbox.setChecked(current.getAdditive()); } } };