Tweening
Before CSS transitions, there was a cottage industry of little JavaScript libraries that used the setTimeout
and setInterval
globals to implement animations on the web. I was trying to hack together something fast in three.js this week and I learned a bunch of cool stuff about its animation system. However, that system is really about playing key-framed animations that are baked into 3D assets by an animation tool like Blender or Maya. To be clear, for most conventional uses, CSS transitions would be vastly superior to a hand-coded JavaScript animation, but CSS does not apply to three.js.
Since I don’t really want to choose and learn a JavaScript library just to smooth a one-off animation, I thought this could be a fun ten-minute side project. JavaScript, or at least ECMAScript 6, has come so far since “ye olde days” that I was surprised at how well it worked. What started as a ten-minute diversion has turned into a longer process of revising and refining this into a little ES6 module that I might actually reuse.
I didn’t save the first draft of my function, but I’ve tried to reconstruct it the best I can. I just wanted a function that would move a parameter from 0 to 1 over a specified time interval.
const tween = (duration, fn) => {
var start = Date.now();
var update = () => {
var delta = Date.now() - start;
if(delta <= duration){
window.requestAnimationFrame(update);
fn(delta/duration);
}
}
update();
}
We can test this in a browser console by just passing console.log
as the fn
argument, e.g. tween(500, console.log)
. We can see it spools out a bunch of numbers that range from almost zero to almost 1. It would be annoying if our tweens didn’t reach exactly 1 because we might be relying on the completed transform leaving objects in perfect alignment. So it’s easy enough to force the last frame to always land exactly on 1, even if it means it’s not exactly following wall-clock time.
const tween = (duration, fn) => {
var start = Date.now();
var update = () => {
var delta = Date.now() - start;
if(delta < duration){
window.requestAnimationFrame(update);
fn(delta/duration);
} else {
fn(1);
}
}
update();
}
It should also be possible to start exactly at zero, but I think far less likely to be useful. Since a zero value will usually imply no change, why waste a frame of the tween on staying in the starting state?
In my little demo, I ended up using it like this:
startRot = obj.rotation.y;
tween(500, t => {
var s = 0.9 + 0.6*t;
var y = Math.PI * t;
obj.scale.set(s, s, s);
obj.rotation.y = startRot + y;
obj.opacity = 1-t;
}
Lerping
Clearly it needs some friends. For example, I wanted the scale to range from 0.9 to 1.5 as t
ranged from 0 to 1. This operation is often called lerp or in GLSL it’s called mix
. Wikipedia offers this helpful implementation hint that avoids floating-point arithmetic error and guarantees that the value is exactly a
when t = 0
and exactly b
when t = 1
.
const lerp = (a, b, t) => {
return (1-t)*a + t*b;
}
Here’s how lerp
could improve my previous iteration of the code:
var startRot = obj.rotation.y;
var endRot = startRot + Math.PI;
tween(500, t => {
var s = lerp(0.9, 1.5, t);
obj.scale.set(s, s, s);
obj.rotation.y = lerp(startRot, endRot, t);
obj.opacity = lerp(1, 0, t);
});
Although I’m undecided whether lerp(1, 0, t)
is any clearer than 1 - t
.
Easing
Although I haven’t actually done anything with this yet, another easy improvement would be to add some easing functions. The cheat sheet at easings.net currently has a lovely catalog of easing functions, including animated graphs and TypeScript implementations. These functions are ready-made to work with our t
value ranging from 0 to 1.
Sequencing
What about sequencing two animations? A lot of those JavaScript tweening libraries provided method chaining to implement effect sequences. We can do it easily with promises. I’ve never loved the odd syntax of the Promise constructor, so this implementation works with a promise
factory function.
const tween = (duration, fn) => {
var [p, resolve] = promise();
var start = Date.now();
var update = () => {
var delta = Date.now() - start;
if(delta < duration){
window.requestAnimationFrame(update);
fn(delta/duration);
} else {
fn(1);
resolve();
}
}
update();
return p;
}
const promise = () => {
var resolve, reject;
var p = new Promise((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return [p, resolve, reject];
}
Now we have options for how we can sequence our tweens. We can either use the promise’s then
method, or we can use async/await. The then
method has a disadvantage in that sequences must be nested rather than linearly chained; so a third item in the following sequence would require yet another level of indentation.
tween(1000, t => {
document.body.style.opacity = 1 - t;
}).then(() => {
tween(1000, t => {
document.body.style.opacity = t;
});
});
However, the await syntax is much, much nicer:
const doit = async () => {
await tween(1000, t => {
document.body.style.opacity = 1 - t;
});
await tween(1000, t => {
document.body.style.opacity = t;
});
}
doit();
We can add some utilities to make it a little cleaner and make things more reusable. The fadeIn
function takes the parameter elt
and returns a function that changes the opacity of elt
in response to the changing value of the argument t
.
const fadeIn = elt => {
return t => {
elt.style.opacity = t;
}
}
const fadeOut = elt => {
return t => {
elt.style.opacity = 1 - t;
}
}
const doit = async () => {
await tween(1000, fadeOut(document.body));
await tween(1000, fadeIn(document.body));
}
doit();
Again, I just want to repeat that even though I used a DOM example above, this example would almost certainly be better addressed with a CSS transition.
Canceling
Finally, we might want to be able to cancel our animation midway. For example, a mouseover
event causes an object’s scale to increase while a mouseout
event causes the scale to return to normal. If the mouseout
event were unable to cancel the animation started by the mouseover
, then there could be visual glitches as the two animations alternately set the scale property.
I’ve addressed this by extending Promise
to add a cancel
method. The tween
function must check the promise and reject it when its canceled flag is set. Since rejecting the promise raises an exception, it also interrupts sequenced animations. I’ll end with a complete code listing that includes the cancellation code.
export class TweenPromise extends Promise {
constructor(...args) {
super(...args);
this._tweencanceled = false;
}
cancel() {
this._tweencanceled = true;
}
get canceled() {
return this._tweencanceled
}
}
export class TweenCanceled extends Error {
constructor() {
super('Tween Canceled');
}
}
export const tween = (duration, fn) => {
var [p, resolve, reject] = promise();
var start = Date.now();
var update = () => {
if(p.canceled) {
reject(new TweenCanceled());
return;
}
var delta = Date.now() - start;
if(delta < duration){
window.requestAnimationFrame(update);
fn(delta/duration);
} else {
fn(1);
resolve();
}
}
update();
return p;
}
const promise = () => {
var resolve, reject;
var p = new TweenPromise((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return [p, resolve, reject];
}
export const lerp = (a, b, t) => {
return (1-t)*a + t*b;
}