Animating with Coroutines
Published on Thursday, June 2nd, 2022
Using an concept known as coroutines you can produce some really nice looking imperative animation code! I was inspired by two great YouTube videos recently to try a new approach to building animations in games.
// Look at this nice imperative animation code 😎
coroutine(function* animation() {
// scale the entity 2x over 1000 ms duration
yield* scaleTo(entity, 2, 1000);
// delay animation 1000 ms duration
yield* delay(1000);
// move the entity to (200, 100) 500 ms duration
yield* moveTo(entity, {x: 200, y: 100}, 500);
// rotate the entity to 4 PI over 1000 ms duration
yield* rotateTo(entity, 4 * Math.PI, 1000);
});
TLDR show me the code
What is a coroutine anyway?
The word "coroutine" definitely sounds fancy! Coroutines are a type of suspendable execution. They are a useful way of simulating concurrent tasks or sequences over time without blocking the main thread. They are made possible by a new(ish) feature in JavaScript called generators!
Why are they called "coroutines"? Well it's because they cooperatively share execution. In our example below the coroutine passes control into the generator, and it yield
's control back up to the coroutine.
In our example, we are working with animation's over time, so each yield
can be thought of as a single frame that takes an "elapsed" amount of time.
function coroutine(generatorFunction: () => Generator<any, void, number>) {
const generator = generatorFunction(); // instantiate the coroutine
generator.next(); // execute until the first yield to start the generator
return (elapsed: number) => {
return generator.next(elapsed);
}
}
coroutine(function* myAnimation() {
let elapsed: number;
// each yield represents a frame
elapsed = yield; // frame 1
elapsed = yield; // frame 2
elapsed = yield; // frame 3
// ...
});
Implementing Animations with requestAnimationFrame
We need something to drive updating each frame for our animations, we can use a classic game update approach with requestAnimationFrame
.
let lastTime = performance.now();
let running = true;
const mainloop = (time: number) => {
try {
if (!running) return;
requestAnimationFrame(mainloop);
const now = time;
const elapsed = now - lastTime;
// update coroutines
for (let animation of animations) {
animation(elapsed);
}
// draw stuff
clear(ctx);
draw(ctx, elapsed)
lastTime = now;
} catch(e) {
running = false;
throw e;
}
}
mainloop(performance.now());
animations.push(coroutine(function* myAnimation(){ ... }))
Now we can use our fancy coroutines to make some fancy animations!
MoveTo Generator with Linear Interpolation
We can build some animation primitives. One of the first things you might want to do is move things around, something like moveTo
which will animate an entity to a set of coordinates over a duration.
First things first we'll need to define a "Linearly Interpolate" function, or as they say in the biz "lerp". Traditionally easing functions like lerp
specify a numeric start
and end
value, and a time
between 0 and 1.
This means that when time is 0, we return the start, and when time is 1 we return the end, else we mix them. Looking at our example below that math checks out. You can think of this as a weighted average between the start and end over time 😎
const lerp = (start: number, end: number, time: number) => {
return start * (1-time) + end * time;
}
Another useful helper is clamp
, which is useful for keep a value between an upper and lower bound. And it is important that our time
passed to lerp
stays in between 0 and 1.
const clamp = (val: number, min: number, max: number) => {
return Math.min(max, Math.max(val, min));
}
Now we have those utilities explained, let's start with the basic structure of our animation generator:
function* moveTo(entity: Entity, destination: {x: number, y: number}, durationMs: number){
let totalTime = 0;
while (true) {
let elapsed = yield;
totalTime += elapsed;
// TODO implement the rest
}
}
-
The first thing you might notice above is the apparent infinite loop! Believe it or not this is totally okay in a generator, the
yield
will pause execution and "yield" it back up to our main loop for the next animation. -
The second thing you might notice is
let elapsed = yield;
this is sort of wild, but theyield
acts like a two way return! Things can be passed down into the generator and they fall out left hand side, but also things can be passed back out of the generator if on the right, for exampleyield 42;
will "return" 42 up to the caller. This in/out duality in generators makes them ideal for coroutines.
Next we can use JavaScript scope to capture the entities original x/y position. Notice that it is above the yield.
function* moveTo(entity: Entity, destination: {x: number, y: number}, durationMs: number) {
let totalTime = 0;
// Capture the original position
const { x: originalX, y: originalY } = entity;
while(true) {
let elapsed = yield;
totalTime += elapsed;
// TODO implement the rest
}
}
Then Convert our current totalTime to something between 0 and 1 for our lerp
.
const currentTime = clamp(totalTime / durationMs, 0, 1);
Then the meat of our moveTo
is calculating the lerp
and setting the properties on the entity we are animating!
const currentX = lerp(originalX, destination.x, currentTime);
const currentY = lerp(originalY, destination.y, currentTime);
entity.x = currentX;
entity.y = currentY;
if (totalTime >= durationMs) {
console.log('moveTo complete', currentX, currentY);
return;
}
Finally, putting the whole thing together it looks like this
function* moveTo(entity: Entity, destination: {x: number, y: number}, durationMs: number) {
let totalTime = 0;
const { x: originalX, y: originalY} = entity;
while(true) {
let elapsed = yield;
totalTime += elapsed;
const currentTime = clamp(totalTime / durationMs, 0, 1);
const currentX = lerp(originalX, destination.x, currentTime);
const currentY = lerp(originalY, destination.y, currentTime);
entity.x = currentX;
entity.y = currentY;
if (totalTime >= durationMs) {
console.log('moveTo complete', currentX, currentY);
return;
}
}
}
Here is an example of our moveTo
animations in action, each animation runs in sequence on after the other!
The yeild*
is a new piece of syntax but it basically means that if the thing to the right is a generator, dig into and run that generator! Cool right?
coroutine(function* boxAnimation() {
yield* moveTo(entity, {x: 50, y: 550}, 1000);
yield* moveTo(entity, {x: 750, y: 550}, 1000);
yield* moveTo(entity, {x: 750, y: 50}, 1000);
yield* moveTo(entity, {x: 50, y: 50}, 1000);
});
Concurrency with Coroutines
There are more cool tricks we can play, like simulating concurrent animations with coroutines!
Let's say in addition to moveTo
I also create a rotateTo
and a scaleTo
animation, AND I want to do all three at once! Well now that we have generators helping us out we can drive multiple generators at the same time.
In this example we grab a list of our animations, and drive them until they are all done:
function* parallel(...animations: Generator[]) {
while (true) {
let elapsed = yield;
let results: IteratorResult<any, any>[] = [];
// Drive each animation 1 frame
for (let anim of animations) {
results.push(anim.next(elapsed));
}
// Wait to all the child animations are done
if (results.every(f => f.done)){
return;
}
}
}
See how it reads in usage:
coroutine(function* boxAnimation() {
// animations run in parallel
yield* parallel(
moveTo(entity, {x: 400, y: 400}, 1000),
rotateTo(entity, 2 * Math.PI, 1000),
scaleTo(entity, 2, 1000),
)
});
Wrapping Sequences with Coroutines
Sometimes it's useful to specify different groups of animations that you want to run.
This collects up the animations and runs each of them!
function sequence(...animations: Generator[]) {
return function* generatorSequence() {
for(let generator of animations) {
yield* generator;
}
}();
}
Now we can run 2 separate sequential animations AT THE SAME TIME! 🤯
coroutine(function* manyBoxesAnimation() {
yield* parallel(
sequence(
moveTo(yellowEntity, {x: 50, y: 550}, 1000),
moveTo(yellowEntity, {x: 750, y: 550}, 1000),
moveTo(yellowEntity, {x: 750, y: 50}, 1000),
moveTo(yellowEntity, {x: 50, y: 50}, 1000),
),
sequence(
moveTo(greenEntity, {x: 750, y: 50}, 1000),
moveTo(greenEntity, {x: 750, y: 550}, 1000),
moveTo(greenEntity, {x: 50, y: 550}, 1000),
moveTo(greenEntity, {x: 50, y: 50}, 1000),
)
)
})
I hope this helps you aspiring game devs out there who are trying to build some animations!
Check out the code and play around
-Erik
Help support me on Github Sponsors or Patreon
Become a Patron!