Browse Source

Support for multiple mixing without dipping.

The multiple mixing setting has been removed. Multiple mixing is always done and dipping is avoided for adjacent track entries.

This is pretty close to complete. Mixing `a -> b` where both key property `x` avoids dipping. If the mix is interrupted by `c`, the dipping is properly mixed out. However, if `c` *also* keys `x`, a dip is seen.

This is good test JSON data:
http://n4te.com/x/1948-6b1G.txt
The problem can be seen by doing `m1 -> m1-dup` then interrupting the mix with `m1`.

Related issues: #621, #815, #899, #900
NathanSweet 8 years ago
parent
commit
b882fb8c51

+ 9 - 75
spine-libgdx/spine-libgdx-tests/src/com/esotericsoftware/spine/AnimationStateTests.java

@@ -619,7 +619,7 @@ public class AnimationStateTests {
 			}
 		});
 
-		setup("setAnimation twice with mixing", // 22
+		setup("setAnimation twice with multiple mixing", // 22
 			expect(0, "start", 0, 0), //
 			expect(0, "interrupt", 0, 0), //
 			expect(0, "end", 0, 0), //
@@ -651,85 +651,20 @@ public class AnimationStateTests {
 
 			expect(0, "start", 0, 0.4f), //
 			expect(0, "event 0", 0.1f, 0.5f), //
-
-			expect(2, "end", 0.3f, 0.6f), //
-			expect(2, "dispose", 0.3f, 0.6f), //
-
-			expect(0, "event 14", 0.5f, 0.9f), //
-
-			expect(1, "complete", 1, 1), //
-			expect(1, "end", 1, 1.1f), //
-			expect(1, "dispose", 1, 1.1f), //
-
-			expect(0, "event 30", 1, 1.4f), //
-			expect(0, "complete", 1, 1.4f), //
-			expect(0, "end", 1, 1.5f), //
-			expect(0, "dispose", 1, 1.5f) //
-		);
-		stateData.setDefaultMix(0.6f);
-		state.setAnimation(0, "events0", false); // First should be ignored.
-		state.setAnimation(0, "events1", false);
-		run(0.1f, 1000, new TestListener() {
-			public void frame (float time) {
-				if (MathUtils.isEqual(time, 0.2f)) {
-					state.setAnimation(0, "events0", false); // First should be ignored.
-					state.setAnimation(0, "events2", false);
-				}
-				if (MathUtils.isEqual(time, 0.4f)) {
-					state.setAnimation(0, "events1", false); // First should be ignored.
-					state.setAnimation(0, "events0", false).setTrackEnd(1);
-				}
-			}
-		});
-
-		setup("setAnimation twice with multiple mixing", // 23
-			expect(0, "start", 0, 0), //
-			expect(0, "interrupt", 0, 0), //
-			expect(0, "end", 0, 0), //
-			expect(0, "dispose", 0, 0), //
-
-			expect(1, "start", 0, 0), //
-			expect(1, "event 0", 0, 0), //
-
-			note("First 2 setAnimation calls are done."),
-
-			expect(1, "interrupt", 0.2f, 0.2f), //
-
-			expect(0, "start", 0, 0.2f), //
-			expect(0, "interrupt", 0, 0.2f), //
-			expect(0, "end", 0, 0.2f), //
-			expect(0, "dispose", 0, 0.2f), //
-
-			expect(2, "start", 0, 0.2f), //
-			expect(2, "event 0", 0.1f, 0.3f), //
-
-			note("Second 2 setAnimation calls are done."),
-
-			expect(2, "interrupt", 0.2f, 0.4f), //
-
-			expect(1, "start", 0, 0.4f), //
-			expect(1, "interrupt", 0, 0.4f), //
-			expect(1, "end", 0, 0.4f), //
-			expect(1, "dispose", 0, 0.4f), //
-
-			expect(0, "start", 0, 0.4f), //
-			expect(0, "event 0", 0.1f, 0.5f), //
-
-			expect(1, "end", 0.8f, 0.9f), //
-			expect(1, "dispose", 0.8f, 0.9f), //
-
 			expect(0, "event 14", 0.5f, 0.9f), //
 
 			expect(2, "end", 0.8f, 1.1f), //
 			expect(2, "dispose", 0.8f, 1.1f), //
 
+			expect(1, "end", 0.8f, 1.1f), //
+			expect(1, "dispose", 0.8f, 1.1f), //
+
 			expect(0, "event 30", 1, 1.4f), //
 			expect(0, "complete", 1, 1.4f), //
 			expect(0, "end", 1, 1.5f), //
 			expect(0, "dispose", 1, 1.5f) //
 		);
 		stateData.setDefaultMix(0.6f);
-		state.setMultipleMixing(true);
 		state.setAnimation(0, "events0", false); // First should be ignored.
 		state.setAnimation(0, "events1", false);
 		run(0.1f, 1000, new TestListener() {
@@ -744,9 +679,8 @@ public class AnimationStateTests {
 				}
 			}
 		});
-		state.setMultipleMixing(false);
 
-		setup("addAnimation with delay on empty track", // 24
+		setup("addAnimation with delay on empty track", // 23
 			expect(0, "start", 0, 0), //
 			expect(0, "event 0", 0, 5), //
 			expect(0, "event 14", 0.5f, 5.5f), //
@@ -758,7 +692,7 @@ public class AnimationStateTests {
 		state.addAnimation(0, "events0", false, 5).setTrackEnd(1);
 		run(0.1f, 10, null);
 
-		setup("setAnimation during AnimationStateListener"); // 25
+		setup("setAnimation during AnimationStateListener"); // 24
 		state.addListener(new AnimationStateListener() {
 			public void start (TrackEntry entry) {
 				if (entry.getAnimation().getName().equals("events0")) state.setAnimation(1, "events1", false);
@@ -789,7 +723,7 @@ public class AnimationStateTests {
 		state.setAnimation(1, "events1", false).setTrackEnd(1);
 		run(0.1f, 10, null);
 
-		setup("clearTrack", // 26
+		setup("clearTrack", // 25
 			expect(0, "start", 0, 0), //
 			expect(0, "event 0", 0, 0), //
 			expect(0, "event 14", 0.5f, 0.5f), //
@@ -803,7 +737,7 @@ public class AnimationStateTests {
 			}
 		});
 
-		setup("setEmptyAnimation", // 27
+		setup("setEmptyAnimation", // 26
 			expect(0, "start", 0, 0), //
 			expect(0, "event 0", 0, 0), //
 			expect(0, "event 14", 0.5f, 0.5f), //
@@ -825,7 +759,7 @@ public class AnimationStateTests {
 			}
 		});
 
-		setup("TrackEntry listener"); // 28
+		setup("TrackEntry listener"); // 27
 		final AtomicInteger counter = new AtomicInteger();
 		state.addAnimation(0, "events0", false, 0).setListener(new AnimationStateListener() {
 			public void start (TrackEntry entry) {

+ 71 - 148
spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/AnimationState.java

@@ -33,8 +33,8 @@ package com.esotericsoftware.spine;
 import static com.esotericsoftware.spine.Animation.RotateTimeline.*;
 
 import com.badlogic.gdx.utils.Array;
-import com.badlogic.gdx.utils.BooleanArray;
 import com.badlogic.gdx.utils.FloatArray;
+import com.badlogic.gdx.utils.IntArray;
 import com.badlogic.gdx.utils.IntSet;
 import com.badlogic.gdx.utils.Pool;
 import com.badlogic.gdx.utils.Pool.Poolable;
@@ -49,6 +49,7 @@ import com.esotericsoftware.spine.Animation.Timeline;
  * See <a href='http://esotericsoftware.com/spine-applying-animations/'>Applying Animations</a> in the Spine Runtimes Guide. */
 public class AnimationState {
 	static private final Animation emptyAnimation = new Animation("<empty>", new Array(0), 0);
+	static private final int SUBSEQUENT = 0, FIRST = 1, DIP = 2;
 
 	private AnimationStateData data;
 	final Array<TrackEntry> tracks = new Array();
@@ -56,7 +57,7 @@ public class AnimationState {
 	final Array<AnimationStateListener> listeners = new Array();
 	private final EventQueue queue = new EventQueue();
 	private final IntSet propertyIDs = new IntSet();
-	boolean animationsChanged, multipleMixing;
+	boolean animationsChanged;
 	private float timeScale = 1;
 
 	Pool<TrackEntry> trackEntryPool = new Pool() {
@@ -93,7 +94,7 @@ public class AnimationState {
 				current.delay = 0;
 			}
 
-			TrackEntry next = current.next;
+			TrackEntry from = current.mixingFrom, next = current.next;
 			if (next != null) {
 				// When the next entry's delay is passed, change to the next entry, preserving leftover time.
 				float nextTime = current.trackLast - next.delay;
@@ -108,14 +109,21 @@ public class AnimationState {
 					}
 					continue;
 				}
-			} else if (current.trackLast >= current.trackEnd && current.mixingFrom == null) {
+			} else if (current.trackLast >= current.trackEnd && from == null) {
 				// Clear the track when there is no next entry, the track end time is reached, and there is no mixingFrom.
 				tracks.set(i, null);
 				queue.end(current);
 				disposeNext(current);
 				continue;
 			}
-			updateMixingFrom(current, delta);
+			if (from != null && updateMixingFrom(current, delta)) {
+				// End mixing from entries once all have completed.
+				do {
+					queue.end(from);
+					from = from.mixingFrom;
+				} while (from != null);
+				current.mixingFrom = null;
+			}
 
 			current.trackTime += currentDelta;
 		}
@@ -123,22 +131,19 @@ public class AnimationState {
 		queue.drain();
 	}
 
-	private void updateMixingFrom (TrackEntry entry, float delta) {
+	/** Returns true when all mixing from entries are complete. */
+	private boolean updateMixingFrom (TrackEntry entry, float delta) {
 		TrackEntry from = entry.mixingFrom;
-		if (from == null) return;
+		if (from == null) return true;
 
-		updateMixingFrom(from, delta);
-
-		if (entry.mixTime >= entry.mixDuration && from.mixingFrom == null && entry.mixTime > 0) {
-			entry.mixingFrom = null;
-			queue.end(from);
-			return;
-		}
+		boolean finished = updateMixingFrom(from, delta);
+		if (entry.mixTime >= entry.mixDuration && entry.mixTime > 0) return finished;
 
 		from.animationLast = from.nextAnimationLast;
 		from.trackLast = from.nextTrackLast;
 		from.trackTime += delta * from.timeScale;
 		entry.mixTime += delta * entry.timeScale;
+		return false;
 	}
 
 	/** Poses the skeleton using the track entry animations. There are no side effects other than invoking listeners, so the
@@ -156,7 +161,7 @@ public class AnimationState {
 			// Apply mixing from entries first.
 			float mix = current.alpha;
 			if (current.mixingFrom != null)
-				mix *= applyMixingFrom(current, skeleton);
+				mix *= applyMixingFrom(current, skeleton, 0);
 			else if (current.trackTime >= current.trackEnd && current.next == null) //
 				mix = 0; // Set to setup pose the last time the entry will be applied.
 
@@ -172,14 +177,14 @@ public class AnimationState {
 				if (firstFrame) current.timelinesRotation.setSize(timelineCount << 1);
 				float[] timelinesRotation = current.timelinesRotation.items;
 
-				boolean[] timelinesFirst = current.timelinesFirst.items;
+				int[] timelineData = current.timelineData.items;
 				for (int ii = 0; ii < timelineCount; ii++) {
 					Timeline timeline = (Timeline)timelines[ii];
 					if (timeline instanceof RotateTimeline) {
-						applyRotateTimeline(timeline, skeleton, animationTime, mix, timelinesFirst[ii], timelinesRotation, ii << 1,
+						applyRotateTimeline(timeline, skeleton, animationTime, mix, timelineData[ii] > 0, timelinesRotation, ii << 1,
 							firstFrame);
 					} else
-						timeline.apply(skeleton, animationLast, animationTime, events, mix, timelinesFirst[ii], false);
+						timeline.apply(skeleton, animationLast, animationTime, events, mix, timelineData[ii] > 0, false);
 				}
 			}
 			queueEvents(current, animationTime);
@@ -191,25 +196,25 @@ public class AnimationState {
 		queue.drain();
 	}
 
-	private float applyMixingFrom (TrackEntry entry, Skeleton skeleton) {
-		TrackEntry from = entry.mixingFrom;
-		if (from.mixingFrom != null) applyMixingFrom(from, skeleton);
-
+	private float applyMixingFrom (TrackEntry to, Skeleton skeleton, float parentMix) {
 		float mix;
-		if (entry.mixDuration == 0) // Single frame mix to undo mixingFrom changes.
+		if (to.mixDuration == 0) // Single frame mix to undo mixingFrom changes.
 			mix = 1;
 		else {
-			mix = entry.mixTime / entry.mixDuration;
+			mix = to.mixTime / to.mixDuration;
 			if (mix > 1) mix = 1;
 		}
 
+		TrackEntry from = to.mixingFrom;
+		if (from.mixingFrom != null) applyMixingFrom(from, skeleton, mix);
+
 		Array<Event> events = mix < from.eventThreshold ? this.events : null;
 		boolean attachments = mix < from.attachmentThreshold, drawOrder = mix < from.drawOrderThreshold;
 		float animationLast = from.animationLast, animationTime = from.getAnimationTime();
 		int timelineCount = from.animation.timelines.size;
 		Object[] timelines = from.animation.timelines.items;
-		boolean[] timelinesFirst = from.timelinesFirst.items, timelinesLast = multipleMixing ? null : from.timelinesLast.items;
-		float alphaBase = from.alpha * entry.mixAlpha, alphaMix = alphaBase * (1 - mix);
+		int[] timelineData = from.timelineData.items;
+		float alphaMix = from.alpha * to.mixAlpha * (1 - mix), alphaDip = from.alpha * to.mixAlpha * (1 - parentMix);
 
 		boolean firstFrame = from.timelinesRotation.size == 0;
 		if (firstFrame) from.timelinesRotation.setSize(timelineCount << 1);
@@ -217,20 +222,21 @@ public class AnimationState {
 
 		for (int i = 0; i < timelineCount; i++) {
 			Timeline timeline = (Timeline)timelines[i];
-			boolean setupPose = timelinesFirst[i];
-			float alpha = timelinesLast != null && setupPose && !timelinesLast[i] ? alphaBase : alphaMix;
+			int data = timelineData[i];
+			boolean first = data > 0;
+			float alpha = data == DIP ? alphaDip : alphaMix;
 			if (timeline instanceof RotateTimeline)
-				applyRotateTimeline(timeline, skeleton, animationTime, alpha, setupPose, timelinesRotation, i << 1, firstFrame);
+				applyRotateTimeline(timeline, skeleton, animationTime, alpha, first, timelinesRotation, i << 1, firstFrame);
 			else {
-				if (!setupPose) {
+				if (!first) {
 					if (!attachments && timeline instanceof AttachmentTimeline) continue;
 					if (!drawOrder && timeline instanceof DrawOrderTimeline) continue;
 				}
-				timeline.apply(skeleton, animationLast, animationTime, events, alpha, setupPose, true);
+				timeline.apply(skeleton, animationLast, animationTime, events, alpha, first, true);
 			}
 		}
 
-		if (entry.mixDuration > 0) queueEvents(from, animationTime);
+		if (to.mixDuration > 0) queueEvents(from, animationTime);
 		this.events.clear();
 		from.nextAnimationLast = animationTime;
 		from.nextTrackLast = from.trackTime;
@@ -382,29 +388,7 @@ public class AnimationState {
 			if (interrupt) queue.interrupt(from);
 			current.mixingFrom = from;
 			current.mixTime = 0;
-
-			TrackEntry mixingFrom = from.mixingFrom;
-			if (mixingFrom != null && from.mixDuration > 0) {
-				if (multipleMixing) {
-					// The interrupted mix will mix out from its current percentage to zero.
-					current.mixAlpha *= Math.min(from.mixTime / from.mixDuration, 1);
-				} else {
-					// A mix was interrupted, mix from the closest animation.
-					if (from.mixTime / from.mixDuration < 0.5f && mixingFrom.animation != emptyAnimation) {
-						current.mixingFrom = mixingFrom;
-						mixingFrom.mixingFrom = from;
-						mixingFrom.mixTime = from.mixDuration - from.mixTime;
-						mixingFrom.mixDuration = from.mixDuration;
-						from.mixingFrom = null;
-						from = mixingFrom;
-					}
-
-					// End the other animation after it is applied one last time.
-					from.mixAlpha = 0;
-					from.mixTime = 0;
-					from.mixDuration = 0;
-				}
-			}
+			current.mixAlpha *= Math.min(from.mixTime / from.mixDuration, 1); // Store interrupted mix percentage.
 
 			from.timelinesRotation.clear(); // Reset rotation for mixing out, in case entry was mixed in.
 		}
@@ -583,83 +567,19 @@ public class AnimationState {
 	private void animationsChanged () {
 		animationsChanged = false;
 
+		// Set timelinesData for all entries, from lowest track to highest.
 		IntSet propertyIDs = this.propertyIDs;
-
-		// Set timelinesFirst for all entries, from lowest track to highest.
-		int i = 0, n = tracks.size;
 		propertyIDs.clear();
-		for (; i < n; i++) { // Find first non-null entry.
-			TrackEntry entry = tracks.get(i);
-			if (entry == null) continue;
-			setTimelinesFirst(entry);
-			i++;
-			break;
-		}
-		for (; i < n; i++) { // Rest of entries.
-			TrackEntry entry = tracks.get(i);
-			if (entry != null) checkTimelinesFirst(entry);
-		}
-
-		if (multipleMixing) return;
-
-		// Set timelinesLast for mixingFrom entries, from highest track to lowest that has mixingFrom.
-		propertyIDs.clear();
-		int lowestMixingFrom = n;
-		for (i = 0; i < n; i++) { // Find lowest track with a mixingFrom entry.
-			TrackEntry entry = tracks.get(i);
-			if (entry == null || entry.mixingFrom == null) continue;
-			lowestMixingFrom = i;
-			break;
-		}
-		for (i = n - 1; i >= lowestMixingFrom; i--) { // Find first non-null entry.
+		TrackEntry lastEntry = null;
+		for (int i = 0, n = tracks.size; i < n; i++) {
 			TrackEntry entry = tracks.get(i);
-			if (entry == null) continue;
-
-			// Store properties for non-mixingFrom entry but don't set timelinesLast, which is only used for mixingFrom entries.
-			Object[] timelines = entry.animation.timelines.items;
-			for (int ii = 0, nn = entry.animation.timelines.size; ii < nn; ii++)
-				propertyIDs.add(((Timeline)timelines[ii]).getPropertyId());
-
-			entry = entry.mixingFrom;
-			while (entry != null) {
-				checkTimelinesUsage(entry, entry.timelinesLast);
-				entry = entry.mixingFrom;
+			if (entry != null) {
+				entry.setTimelineData(lastEntry, propertyIDs);
+				lastEntry = entry;
 			}
 		}
 	}
 
-	/** From last to first mixingFrom entries, sets timelinesFirst to true on last, calls checkTimelineUsage on rest. */
-	private void setTimelinesFirst (TrackEntry entry) {
-		if (entry.mixingFrom != null) {
-			setTimelinesFirst(entry.mixingFrom);
-			checkTimelinesUsage(entry, entry.timelinesFirst);
-			return;
-		}
-		IntSet propertyIDs = this.propertyIDs;
-		int n = entry.animation.timelines.size;
-		Object[] timelines = entry.animation.timelines.items;
-		boolean[] usage = entry.timelinesFirst.setSize(n);
-		for (int i = 0; i < n; i++) {
-			propertyIDs.add(((Timeline)timelines[i]).getPropertyId());
-			usage[i] = true;
-		}
-	}
-
-	/** From last to first mixingFrom entries, calls checkTimelineUsage. */
-	private void checkTimelinesFirst (TrackEntry entry) {
-		if (entry.mixingFrom != null) checkTimelinesFirst(entry.mixingFrom);
-		checkTimelinesUsage(entry, entry.timelinesFirst);
-	}
-
-	private void checkTimelinesUsage (TrackEntry entry, BooleanArray usageArray) {
-		IntSet propertyIDs = this.propertyIDs;
-		int n = entry.animation.timelines.size;
-		Object[] timelines = entry.animation.timelines.items;
-		boolean[] usage = usageArray.setSize(n);
-		for (int i = 0; i < n; i++)
-			usage[i] = propertyIDs.add(((Timeline)timelines[i]).getPropertyId());
-	}
-
 	/** Returns the track entry for the animation currently playing on the track, or null if no animation is currently playing. */
 	public TrackEntry getCurrent (int trackIndex) {
 		if (trackIndex >= tracks.size) return null;
@@ -701,25 +621,6 @@ public class AnimationState {
 		this.timeScale = timeScale;
 	}
 
-	/** When false, only two animations can be mixed at once. Interrupting a mix by setting a new animation will discard one of the
-	 * two old animations, keeping the one closest to being fully mixed in. Discarding an animation in this way may cause keyed
-	 * values to jump.
-	 * <p>
-	 * When true, any number of animations can be mixed at once without causing keyed values to jump. Mixing is done by mixing out
-	 * one or more old animations while mixing in the newest one. When animations key the same value, this may cause "dipping",
-	 * where the value moves toward the setup pose as the old animations mix out, then back to the keyed value as the new animation
-	 * mixes in.
-	 * <p>
-	 * Defaults to false. */
-	public boolean getMultipleMixing () {
-		return multipleMixing;
-	}
-
-	public void setMultipleMixing (boolean multipleMixing) {
-		this.multipleMixing = multipleMixing;
-		animationsChanged = true;
-	}
-
 	/** The AnimationStateData to look up mix durations. */
 	public AnimationStateData getData () {
 		return data;
@@ -760,7 +661,7 @@ public class AnimationState {
 		float animationStart, animationEnd, animationLast, nextAnimationLast;
 		float delay, trackTime, trackLast, nextTrackLast, trackEnd, timeScale;
 		float alpha, mixTime, mixDuration, mixAlpha;
-		final BooleanArray timelinesFirst = new BooleanArray(), timelinesLast = new BooleanArray();
+		final IntArray timelineData = new IntArray();
 		final FloatArray timelinesRotation = new FloatArray();
 
 		public void reset () {
@@ -768,11 +669,33 @@ public class AnimationState {
 			mixingFrom = null;
 			animation = null;
 			listener = null;
-			timelinesFirst.clear();
-			timelinesLast.clear();
+			timelineData.clear();
 			timelinesRotation.clear();
 		}
 
+		TrackEntry setTimelineData (TrackEntry parent, IntSet propertyIDs) {
+			TrackEntry lastEntry = mixingFrom != null ? mixingFrom.setTimelineData(this, propertyIDs) : this;
+			int n = animation.timelines.size;
+			Object[] timelines = animation.timelines.items;
+			int[] timelineData = this.timelineData.setSize(n << 1);
+			for (int i = 0; i < n; i++) {
+				int id = ((Timeline)timelines[i]).getPropertyId();
+				boolean first = propertyIDs.add(id);
+				if (first && parent != null && parent.hasTimeline(id))
+					timelineData[i] = DIP;
+				else
+					timelineData[i] = first ? FIRST : SUBSEQUENT;
+			}
+			return lastEntry;
+		}
+
+		private boolean hasTimeline (int id) {
+			Object[] timelines = animation.timelines.items;
+			for (int i = 0, n = animation.timelines.size; i < n; i++)
+				if (((Timeline)timelines[i]).getPropertyId() == id) return true;
+			return false;
+		}
+
 		/** The index of the track where this track entry is either current or queued.
 		 * <p>
 		 * See {@link AnimationState#getCurrent(int)}. */
@@ -994,7 +917,7 @@ public class AnimationState {
 		}
 
 		/** The track entry for the previous animation when mixing from the previous animation to this animation, or null if no
-		 * mixing is currently occuring. If mixing from multiple animations, <code>mixingFrom</code> makes up a linked list. */
+		 * mixing is currently occuring. When mixing from multiple animations, <code>mixingFrom</code> makes up a linked list. */
 		public TrackEntry getMixingFrom () {
 			return mixingFrom;
 		}

+ 0 - 13
spine-libgdx/spine-skeletonviewer/src/com/esotericsoftware/spine/SkeletonViewer.java

@@ -187,7 +187,6 @@ public class SkeletonViewer extends ApplicationAdapter {
 		skeleton.updateWorldTransform();
 
 		state = new AnimationState(new AnimationStateData(skeletonData));
-		state.setMultipleMixing(ui.multipleMixingCheckbox.isChecked());
 		state.addListener(new AnimationStateAdapter() {
 			public void event (TrackEntry entry, Event event) {
 				ui.toast(event.getData().getName());
@@ -384,7 +383,6 @@ public class SkeletonViewer extends ApplicationAdapter {
 		List<String> skinList = new List(skin);
 		ScrollPane skinScroll = new ScrollPane(skinList, skin, "bg");
 		CheckBox loopCheckbox = new CheckBox("Loop", skin);
-		CheckBox multipleMixingCheckbox = new CheckBox("Multiple mixing", skin);
 		CheckBox premultipliedCheckbox = new CheckBox("Premultiplied", skin);
 		Slider mixSlider = new Slider(0, 4, 0.01f, false, skin);
 		Label mixLabel = new Label("0.3", skin);
@@ -539,8 +537,6 @@ public class SkeletonViewer extends ApplicationAdapter {
 				table.add(mixSlider).fillX().expandX();
 				root.add(table).fill().row();
 			}
-			root.add();
-			root.add(multipleMixingCheckbox).row();
 
 			window.add(root).expand().fill();
 			window.pack();
@@ -706,12 +702,6 @@ public class SkeletonViewer extends ApplicationAdapter {
 				}
 			});
 
-			multipleMixingCheckbox.addListener(new ChangeListener() {
-				public void changed (ChangeEvent event, Actor actor) {
-					if (state != null) state.setMultipleMixing(multipleMixingCheckbox.isChecked());
-				}
-			});
-
 			skinList.addListener(new ChangeListener() {
 				public void changed (ChangeEvent event, Actor actor) {
 					if (skeleton != null) {
@@ -792,7 +782,6 @@ public class SkeletonViewer extends ApplicationAdapter {
 			debugClippingCheckbox.addListener(savePrefsListener);
 			premultipliedCheckbox.addListener(savePrefsListener);
 			loopCheckbox.addListener(savePrefsListener);
-			multipleMixingCheckbox.addListener(savePrefsListener);
 			speedSlider.addListener(savePrefsListener);
 			speedResetButton.addListener(savePrefsListener);
 			mixSlider.addListener(savePrefsListener);
@@ -857,7 +846,6 @@ public class SkeletonViewer extends ApplicationAdapter {
 			prefs.putBoolean("debugClipping", debugClippingCheckbox.isChecked());
 			prefs.putBoolean("premultiplied", premultipliedCheckbox.isChecked());
 			prefs.putBoolean("loop", loopCheckbox.isChecked());
-			prefs.putBoolean("multipleMixing", multipleMixingCheckbox.isChecked());
 			prefs.putFloat("speed", speedSlider.getValue());
 			prefs.putFloat("mix", mixSlider.getValue());
 			prefs.putFloat("scale", scaleSlider.getValue());
@@ -886,7 +874,6 @@ public class SkeletonViewer extends ApplicationAdapter {
 			debugClippingCheckbox.setChecked(prefs.getBoolean("debugClipping", true));
 			premultipliedCheckbox.setChecked(prefs.getBoolean("premultiplied", true));
 			loopCheckbox.setChecked(prefs.getBoolean("loop", false));
-			multipleMixingCheckbox.setChecked(prefs.getBoolean("multipleMixing", false));
 			speedSlider.setValue(prefs.getFloat("speed", 0.3f));
 			mixSlider.setValue(prefs.getFloat("mix", 0.3f));