Time update issues with Keyframe-based animation
Some time ago I joined to some project developed since 3 months before by a 3-4 person team. After next 4 months I made a refactor request to modify whole engine and game to make game frame update based on the time, rather than device power. Yeah, there was a constant constraint set - maximum 30 Frames Per Second.
Many of world-existing mobile devices couldn’t achieve those constant 30 FPS-es. On the other side some devices could show 60 without a problem but couldn’t because of constraint. It seemed crucial to me to change that. I have seen a game in previous company where 60 FPS were desired and some devices didn’t make 30-40. Those games felt totally too slow.
I was developing a whole animation system then but I’d like to describe one element of it all - keyframe-based animation which was a huge part, actually most crucial. I tried to look for solutions, even tried to look into Spine (which is a great software, I recommend it!) libgdx “runtime” which is open-sourced. Unfortunately, our needs were too wide.
Needed features
We already had some implementation of such keyframe-based animation class, which was called Clip. Implementation of a time-based update for keyframe-based animation happened to be a challenge.
Nonetheless, I needed such features:
- speed up and slow down time (“time factor”)
- play backward
- call custom functions on specific moments (“events”), like specific frame numbers, frame change, overloop or animation finish
- pause animations
- repeat animation given number of times (or infinitely)
- use externally passed “deltaTime” so I could do things like a slow motion or pause animations in whole game without interacting with animation objects
- programmatically added children which were updated due to some animated rectangle (called it “meta child” but in Spine nomenclature it’s called “attachment”)
- optional rewind after animation finishes all it’s repeats. For example, if it was turned off and some animation has finished, it had to stop on the last frame, no matter what. Otherwise it hat to be set on the first frame.
Needs vs problems
It happened to be a lot of requests to myself and also a lot of problems. I don’t wanna always reinvent the wheel but couldn’t find any information on the Internet. So I had to list a problems from first implementation:
- “deltaTime” and “timeFactor” had to work indepedently. Tiny issue.
- backward playing had to work with deltaTime and timeFactor. Little issue.
- speeding up omitted events. Not a little isue.
- lowering speed called same event for same frame more than one time on slow clips. Not a little issue…
- proper overloop. Repeating has to be done smoothly, e.g. jump from last frame to frame based on speed and time, not just jump to first frame. Bigger issue.
- overloop with backward playing. Medium big issue.
- many overloops per update. If animation was short and time speeded up too much, then it should overloop multiple times and call all events listeners for it. Big issue.
- optional rewinding had to work properly with overloops. Not a little issue.
- support Object Pooling. Clip had to implement IPoolable interface. Calling events could dispose current Clip so it end up in exception. Little issue.
- update meta children positions properly after solving all above problems. Little issue but it’s really engine-based thing so I won’t describe it.
- support Clip usage in events. For example if animation is finished and we call event listener for it and this listener calls isPlaying() it should return false, not true. Same with calling “currentFrame” getter in some specific-frame events listener. Issue connected to issues from #2 to #8. In my implementation it was also connected to #10. Huge issue.
So looking at those problems it looked like former Clip implementation couldn’t be just extended. It had to be redesigned and implemented once again. Of course all those problems were not obvious at first but when I made some experiments and a list of them on paper, I started thinking on implementation.
Implementation, rather than Solution
I would imagine that I exposed a step by step implentation to solve all those problems. But originally, looking at problem #11 I didn’t really find a way to do it in such way. I can’t really tell that those issues could be resolved separetely. Instead, job had to be be done a whole at once and then thoroughly tested.
There’s a full implementation of my Clip. Reading all needed features and problems with those it will be easier to remove unneeded parts and fit to custom needs. And again, it’s more like an implementation than show of algorithm, that’s why it may look dirty at some places (especially with “dispose”).
public function act(deltaTime:Number):void {
if (deltaTime == 0) {
if (_metaChildrenPositionsInvalidated) {
updateMetaChildrenPositions();
}
return;
}
var i:int;
if (isPlaying()) {
var direction:int = playForward ? 1 : -1;
var deltaFrame:Number = direction * this.fps * this.playSpeedFactor * deltaTime;
var previousFrame:uint = this.currentFrame;
// Add deltaFrame, then we will find overloops count based on that, and later frame progress will be correted to be in range [0, lastFrame]
var finalFrameProgress:Number = playForward
? (this.currentFrameProgress + deltaFrame) % totalFrames
: totalFrames - ( -(this.currentFrameProgress + deltaFrame) % totalFrames);
this.currentFrameProgress += deltaFrame;
if (_wasDisposed) {
return;
}
// Overloops are the moments when current frame reaches begin/end while playing forward/backward.
var willOverloop:Boolean = this.currentFrameProgress >= totalFrames || this.currentFrameProgress < 0; var overloopsCount:int = 0; if (willOverloop) { overloopsCount = uint(Math.floor(Math.abs(deltaFrame))) / totalFrames; if (playForward && this.currentFrameProgress >= totalFrames && finalFrameProgress < currentFrameProgress) {
++overloopsCount;
}
else if (!playForward && this.currentFrameProgress < 0 && finalFrameProgress > currentFrameProgress) {
++overloopsCount;
}
}
var hasChangedFrame:Boolean = this.currentFrame != previousFrame || willOverloop;
if (hasChangedFrame) {
if (willOverloop) {
var loopIndex:uint = 0;
for (i = previousFrame; loopIndex < overloopsCount && _repeats != 0 && isPlaying(); ++i) { this.currentFrameProgress += direction; if (_wasDisposed) { return; } var reachedForwardEnd:Boolean = this.currentFrameProgress >= totalFrames;
var reachedBackwardEnd:Boolean = this.currentFrameProgress < 0.0; var hasOverlooped:Boolean = reachedForwardEnd || reachedBackwardEnd; if (hasOverlooped && _repeats > 0) {
--_repeats;
}
while (this.currentFrameProgress >= totalFrames) {
this.currentFrameProgress -= totalFrames;
if (_wasDisposed) {
return;
}
}
while (this.currentFrameProgress < 0.0) {
this.currentFrameProgress += totalFrames;
if (_wasDisposed) {
return;
}
}
updateMetaChildrenPositions();
callEventIfCan(this.currentFrame);
if (_wasDisposed) {
return;
}
if (hasOverlooped) {
onLoopFinishedSignal.fire();
if (_wasDisposed) {
return;
}
++loopIndex;
}
}
var hasFinishedAnimation:Boolean = _repeats == 0;
if (hasFinishedAnimation) {
if (_rewindAtEnd) {
_currentFrame = playForward ? 0 : lastFrame + 0.98666;
}
else {
_currentFrame = playForward ? lastFrame : 0;
}
updateMetaChildrenPositions();
onAnimationFinishedSignal.fire();
if (_wasDisposed) {
return;
}
}
else {
_metaChildrenPositionsInvalidated = true;
}
}
else {
// there may be a jump between two far frames so... call omitted events!
var targetFrame:uint = this.currentFrame;
for (i = previousFrame + direction; i != targetFrame + direction; i += direction) {
updateMetaChildrenPositions();
callEventIfCan(i);
if (_wasDisposed) {
return;
}
}
_metaChildrenPositionsInvalidated = true;
}
}
}
if (_metaChildrenPositionsInvalidated) {
updateMetaChildrenPositions();
}
}
I hope it is self-commenting somehow. Below are some last explanations:
- this.fps tells how many frames should be played for second. It was set by animator in our team.
- _repeats tells how many repeats are left. If we set to value lower than 0, animation will be played infinitely. Otherwise, if it’s positive on start, it will be called a given number of times.
- there are many variables. I didn’t know ActionScript (3) that well back then. But today I can tell that it will not be optimized by default compiler.
- to start animation: set repeats to needed value, set paused = false, currentFrame to 0 or anything to start with. It can be paused just by setting paused = true and stopped just by setting repeats = 0.
- signal is object to which listeners are connected. Should self-explain.
Conclusion
Trivially looking problems are sometimes so big that they need to be posted on a blog.