Tuesday, March 17, 2009

Animation Spawning

A developer posted an issue on one of the Flex forums last week that the new Gumbo effects had choppy artifacts when running his test case, especially when compared to similar code that used the Tweener animation library. Being of a sensitive and insecure nature, and wanting to fix any bugs in my effects code, I looked into the problem and discovered some things about animation seemed worth this post.

The Test Case

The original test case used a 3D rotation effect. But between different behavior between Tweener and our FxRotate3D effect and my workspace being a tad torn apart at the moment, I simplified the case down to animating a small square to the current mouse position. Specifically, as you move the mouse around you set a new position to which the the square will animate. If the mouse stops moving, the square will animate toward that position and stop when it gets there. Otherwise, the square will keep animating toward whatever the new position is, changing direction a sthe mouse moves around.

The original test case was set up to start a new animation every time the mouse moved, in a mouse movement handler. Here's that code for the Tweener version:

       private function mouseMoveHandler(event:MouseEvent):void
       {
           Tweener.addTween(rect,
               {x:event.stageX,y:event.stageY,time:2});
       }

where this call animates the rectangle's x and y properties from their current values to the new x/y location of the mouse over a duration of 2 seconds.

Here is similar code for the Flex effects version:

       private function mouseMoveHandler(event:MouseEvent):void
       {
           mover.xTo = event.stageX;
           mover.yTo = event.stageY;
           mover.stop();
           mover.play();
       }

where the Flex mover effect is declared like this:

    <Declarations>
       <easing:Exponential id="exponentialEase" easeInFraction="0"/>
       <FxMove id="mover" target="{rect}" easer="{exponentialEase}" duration="2000"
           animationUpdate="updateHandler(event)"/>
   </Declarations>

Note that the Exponential ease is not in the SDK yet - I created one for this example to mimic the behavior of Tweener's default easing. I will be adding that class sometime soon, along with more of the old easing classes for the Flex3 effects.

The Demos

Here's the Tweener version of the application. If you move the mouse constantly in a particular direction, you can see the square hopping to keep up with it. It's not smooth, but it does aggressively try to keep up, and gets smoother when the mouse stops:

And here's the Flex effects version of the application:

Note that the square seems to move much more slowly when the mouse is in constant motion. In fact, inside of browsers on the Mac (which was the test platform for the person that submitted the original issue), sometimes the square doesn't move at all until the mouse stops moving, at which point the animation engine kicks in and we get a smooth animation of the square.

Now notice that the Flex effects test has a checkbox in the upper-right, cunningly labeled "Smooth Moves." If you check that, you may notice that the animations are a bit snappier, and closer to what you are seeing in the Tweener version. (In fact, I see smoother performance in general with this version than the Tweener version, probably because we force a higher frame rate on animations by posting an updateAfterEvent request whenever there is a Timer event, which will force a render event between the usual enterFrame events). And on the Mac, you should notice that you no longer get complete freezing or jarring delays while the mouse is moving around. All in all, the app is much better behaved when that box is checked.

So what was the fix?

The Fix

It turns out that the original method of spawning new animations for every mouse move is not just more overhead than you really want or need in your animation - it's actually bad practice. Depending on the platform you're on, the frame rate you're getting, the event at which Timer events (which are currently the basis for the Flex effects animation updates) are coming in, and the rate at which mouse events are coming in, you're going to get somewhat unpredictable behavior. What's happening is this:

Between actual animation updates, whether they happen because of a Timer (in the case of Flex effects) or the enterFrame event (in the case of Tweener), you may be restarting your animation one or more times. And depending on when those restarts happen relative to the next animation update, the behavior of those animations may not be what you want. In the case of Flex effects, imagine that you restart your animation 4 times between animation updates because mouse move events came in that many times between updates. When the system finally gets to update the animation, it will use the last one started, which may have started just prior to, or even at the same time as, the current update time. So you may get no movement whatsoever from your animation because according to the time elapsed between the time that you started it and the time you measured it, no time has passed, so the object shouldn't move. Then you start getting mouse events again, and spawning new animations, and you eventually reach the next animation update event only to find that, once again, you are processing an animation that hasn't had any time pass since it was begun.

"But, but, but, " you may justifiably be stuttering, "What about Tweener? Why is that system able to handle this situation better and keep that object moving?"

Why, indeed? The answer is that it does not actually record the true start time for any animation, but rather uses the time used at the last animation update, which is the previous enterFrame event. So when you restart an animation just prior to the next enterFrame event, it doesn't matter that very little or no real time passes between starting it and processing it the first time - the system will process it as if it had been started at the previous enterFrame time. So our little square is able to keep moving around on the screen because the constantly restarted animations are using inflated elapsed times for every calculation.

Note: I'm not saying that Tweener should or should not do it this way; it's just the way that that system happens to work, just as Flex effects work differently by assigning a real start time based on when the effect is started.

But when I realized that that was the key that was causing the stutter I was seeing on Mac browsers for the Flex effects version, I realized that the problem was not in the way that we were handling animations (because I do want Flex effects to reflect the real elapsed time, regardless of when they are started), but rather in how and when these animations were being launched. In particular, it doesn't make sense to launch several animations in between times when we're actually updating the animation. Instead, it makes more sense, both because of the issues here and from the "don't do so freaking much work" standpoint, to track the mouse movement but only cause it to restart animations at other, less frequent intervals.

The "fix" embedded in the "Smooth Moves" variation in the application above does just that: it tracks the mouse movement constantly and then uses the last known position to launch a new animation in the next enterFrame handler (if the position has changed since we started the last animation). In this way, we can be assured that we're letting our animation run at least a little bit before we clobber and restart it with new values, potentially stalling out because we're not allowing things to move at all before we restart a new animation in place of the older one.

Here's the complete code for the Flex effects version - you can see how we handle the animation-spawning logic differently based on the setting of the checkbox. By default, we restart the effect every time the mouse moves. But when the checkbox is selected (smoothMoves.selected == true), then we only record the last mouse position in mouseMoveHandler(), and we defer spawning a new animation until the next enterFrame() call.

<?xml version="1.0" encoding="utf-8"?>
<FxApplication xmlns="http://ns.adobe.com/mxml/2009" width="100%" height="100%"
   enterFrame="enterFrame(event)" mouseMove="mouseMoveHandler(event)"
   xmlns:easing="easing.*">
   <Script>
       <![CDATA[
       import mx.events.AnimationEvent;
      
       private var lastMouseX:int;
       private var lastMouseY:int;
       private var mouseMoved:Boolean;
      
       private function mouseMoveHandler(event:MouseEvent):void
       {
           if (smoothMoves.selected)
           {
               lastMouseX = event.stageX;
               lastMouseY = event.stageY;
               mouseMoved = true;
               if (!mover.isPlaying)
                   mover.play();
           }
           else
           {
               mover.xTo = event.stageX;
               mover.yTo = event.stageY;
               mover.stop();
               mover.play();
           }
       }
       private function enterFrame(event:Event):void
       {
           if (mouseMoved)
           {
               mover.xTo = lastMouseX;
               mover.yTo = lastMouseY;
               mover.stop();
               mover.play();
               mouseMoved = false;
           }
       }
       ]]>
   </Script>
   <Declarations>
       <Power id="powerEase" easeInFraction="0" exponent="7"/>
       <easing:Exponential id="exponentialEase" easeInFraction="0"/>
       <FxMove id="mover" target="{rect}" easer="{exponentialEase}" duration="2000"/>
   </Declarations>
   <Rect id="rect" width="20" height="20">
       <fill>
           <SolidColor color="0xff0000"/>
       </fill>
   </Rect>
   <HBox top="10" right="10">
       <Label text="Smooth Moves"/>
       <CheckBox id="smoothMoves"/>
   </HBox>
</FxApplication>

So: don't spawn animations too aggressively; you may not get the effect you're after from the effect. And note that this approach should work just as well for Tweener (or any other animation engine) as for Flex effects; Tweener only happened to look better in this situation because of its inherent timing behavior, but there would be no reason not to back off and have it spawn less animations too.

p.s.

As a side note, I should point out that another artifact that the original poster ran into was caused by playing the Flex effect multiple times without stopping the previous one(s). You should always, at least in the current Flex effects system, stop() currently playing effects on the same target/property, because otherwise you're effectively giving that property multiple, conflicting instructions at every animation update, as all of the active animations on that property may try to assign different values to it. Tweener does not have this issue because it auto-stops any animations running on that target/property, but Flex effects require you to stop the effect itself. Note that this doesn't mean that you need to stop all effects in the system when you play a new one, or even all effects running on a particular target; the only time that it's an issue is when there are multiple effects running on the same property of the same target(s), as is the case in the example here, where we are always changing the same target's x and y properties.

If you're interested in the full source code, which includes the simple examples above plus the supporting Exponential and Tweener classes used by the examples, here it is. Note that building the Flex effects example requires some recent build of the Gumbo SDK, as it uses classs that only exist in that version of the platform. If you really want to, you should be able to run similar tests with Flex 3 effects (using components instead of the new Gumbo Rect object), since they use the same underlying Timer facility. But the main point of the article was in the demos and explanations; the code is just provided to give a sense of closure.

2 comments:

Unknown said...

Chet,

This was extremely useful and has helped redefine how I look at the problem entirely. I'm going to have to start visiting your blog more often!!

I do have one concern though. When starting and stopping so many animations how do you get the start velocity of one animation to match the end velocity of the last animation? I suppose, in this particular case, what would be most natural (assuming a lot of movement), would be to use an easing object that has an easeInFraction of 0? Thanks,

- e

(btw, I know easeInFraction might not exist in your class, but I think you understand what I mean)

Unknown said...

duh. and there in your code, easeInFraction="0"

- e