Animation and Easing
Animation and Easing
This tutorial shows how to use different kinds of animation to change a property from one value to another in an interesting way. It progresses from simple lerp() to easing functions, then to animating multiple properties at once.
Use the ◀ ▶ buttons or arrow keys to step through the stages. Click inside the canvas to trigger animations. Drag any number in the code to adjust it live.
This tutorial shows how to use different kinds of animation, to change a property from one value to another in an interesting way.
It first shows a very simple way of adding animation, using just the p5.js lerp() function. Then it demonstrates how to set up a sketch so that it can use different functions that compute the intermediate value as a function of time. This allows you to use easing functions, such as the functions on easings.net.
Any property (position, size, color, rotation) can be animated, and you can animate several properties at once. For simplicity, the first part of this tutorial animates a single value: the horizontal (x) position of a line.
In this first step, there is no animation. Click the mouse to change the line’s horizontal position. It immediately jumps to the new position.
let currentX = 100;
function setup() {
createCanvas(windowWidth, windowHeight);
}
function draw() {
background(100);
strokeWeight(2);
line(currentX, 0, currentX, height);
}
function mousePressed() {
currentX = mouseX;
} lerp(a, b, s) mixes two values – it returns a value between a and b. If s is near 0, lerp() returns a value near a. If s is near 1, lerp() returns a value near b.
Each frame, the new value moves closer to the target value. The amount that it moves is proportional to the distance between them. This has the effect that the value starts out moving quickly, and slows down – it approaches the target asymptotically.
let currentX = 100;
let targetX = currentX;
function setup() {
createCanvas(windowWidth, windowHeight);
}
function draw() {
background(100);
currentX = lerp(currentX, targetX, 0.1);
strokeWeight(2);
line(currentX, 0, currentX, height);
}
function mousePressed() {
targetX = mouseX;
} Pass a larger value to lerp() to move more quickly.
let currentX = 100;
let targetX = currentX;
function setup() {
createCanvas(windowWidth, windowHeight);
}
function draw() {
background(100);
currentX = lerp(currentX, targetX, 0.2);
strokeWeight(2);
line(currentX, 0, currentX, height);
}
function mousePressed() {
targetX = mouseX;
} To perform other kinds of animations, we need to store more information about the animation: the start and end value, and the start and end time.
Now we have exact control over the animation time. Here, it is 0.5 seconds – all animations take the same amount of time, no matter how far the distance between the start and end.
let startX = 100;
let currentX = startX;
let endX = startX;
let startTime = 0, endTime = 0; // when the current animation started and ends
let animationDuration = 500; // animations take this many milliseconds
function setup() {
createCanvas(windowWidth, windowHeight);
}
function draw() {
background(100);
let s = map(millis(), startTime, endTime, 0, 1, true);
currentX = lerp(currentX, endX, s);
strokeWeight(2);
line(currentX, 0, currentX, height);
}
function mousePressed() {
startX = currentX;
endX = mouseX;
startTime = millis();
endTime = startTime + animationDuration;
} We could make the animation time proportional to the distance between the start and end values, so that moving a greater distance takes longer.
let startX = 100;
let endX = startX;
let currentX;
let startTime = 0, endTime = 0; // when the current animation started and ends
function setup() {
createCanvas(windowWidth, windowHeight);
}
function draw() {
background(100);
let s = map(millis(), startTime, endTime, 0, 1, true);
currentX = lerp(startX, endX, s);
strokeWeight(2);
line(currentX, 0, currentX, height);
}
function mousePressed() {
startX = currentX;
endX = mouseX;
startTime = millis();
let animationDuration = abs(startX - endX);
endTime = startTime + animationDuration;
} Keeping track of the start, the end, and the time allows us to use animation easing functions.
This step uses the easeInOutCubic function from easings.net. This function causes the animation to start slowly, speed up, and then slow down again as it reaches the end value.
let startX = 100;
let endX = startX;
let currentX;
let startTime = 0, endTime = 0; // when the current animation started and ends
let animationDuration = 1000; // animations take this many milliseconds
function setup() {
createCanvas(windowWidth, windowHeight);
}
function draw() {
background(100);
let s = map(millis(), startTime, endTime, 0, 1, true);
let t = easeInOutCubic(s);
currentX = lerp(startX, endX, t);
strokeWeight(2);
line(currentX, 0, currentX, height);
}
function mousePressed() {
startX = currentX;
endX = mouseX;
startTime = millis();
endTime = startTime + animationDuration;
}
function easeInOutCubic(x) {
return x < 0.5 ? 4 * x * x * x : 1 - pow(-2 * x + 2, 3) / 2;
} easeOutBounce adds a bounce to the end of the animation.
let startX = 100;
let endX = startX;
let currentX;
let startTime = 0, endTime = 0; // when the current animation started and ends
let animationDuration = 1000; // animations take this many milliseconds
function setup() {
createCanvas(windowWidth, windowHeight);
}
function draw() {
background(100);
let s = map(millis(), startTime, endTime, 0, 1, true);
let t = easeOutBounce(s);
currentX = lerp(startX, endX, t);
strokeWeight(2);
line(currentX, 0, currentX, height);
}
function mousePressed() {
startX = currentX;
endX = mouseX;
startTime = millis();
endTime = startTime + animationDuration;
}
function easeOutBounce(x) {
const n1 = 7.5625;
const d1 = 2.75;
if (x < 1 / d1) {
return n1 * x * x;
} else if (x < 2 / d1) {
return n1 * (x -= 1.5 / d1) * x + 0.75;
} else if (x < 2.5 / d1) {
return n1 * (x -= 2.25 / d1) * x + 0.9375;
} else {
return n1 * (x -= 2.625 / d1) * x + 0.984375;
}
} easeOutElastic adds a different kind of bounce, that overshoots and then bounces back.
let startX = 100;
let endX = startX;
let currentX;
let startTime = 0, endTime = 0; // when the current animation started and ends
let animationDuration = 1500; // animations take this many milliseconds
function setup() {
createCanvas(windowWidth, windowHeight);
}
function draw() {
background(100);
let s = map(millis(), startTime, endTime, 0, 1, true);
let t = easeOutElastic(s);
currentX = lerp(startX, endX, t);
strokeWeight(2);
line(currentX, 0, currentX, height);
}
function mousePressed() {
startX = currentX;
endX = mouseX;
startTime = millis();
endTime = startTime + animationDuration;
}
function easeOutElastic(x) {
const c4 = TWO_PI / 3;
return x === 0 ? 0
: x === 1 ? 1
: pow(2, -10 * x) * sin((x * 10 - 0.75) * c4) + 1;
} Animate two properties (x and y), instead of just x.
For each property, we add variables to store its start, end, and current values.
Since both properties animate during the same interval, we use the same startTime and endTime for both of them.
let startX = 100;
let startY = 100;
let endX = startX;
let endY = startY;
let currentX;
let currentY;
let startTime = 0, endTime = 0; // when the current animation started and ends
let animationDuration = 500; // animations take this many milliseconds
function setup() {
createCanvas(windowWidth, windowHeight);
}
function draw() {
background(100);
let s = map(millis(), startTime, endTime, 0, 1, true);
let t = easeOutCubic(s);
currentX = lerp(startX, endX, t);
circleY = lerp(startY, endY, t);
circle(currentX, circleY, 20);
}
function mousePressed() {
startX = currentX;
startY = circleY;
endX = mouseX;
endY = mouseY;
startTime = millis();
endTime = startTime + animationDuration;
}
function easeOutCubic(x) {
return 1 - pow(1 - x, 3);
} Creating a new trio of variables, and copying values between these sets of variables, is tedious as we add properties.
Here’s an alternative that packages all the properties into a single state Object. Instead of using three variables for each property (start, end, and current), it uses three variables total.
This will pay off when we add another property, in the next step.
let startState = {
x: 100,
y: 100,
};
let endState = startState;
let currentState;
let startTime = 0, endTime = 0; // when the current animation started and ends
let animationDuration = 500; // animations take this many milliseconds
function setup() {
createCanvas(windowWidth, windowHeight);
}
function draw() {
background(100);
let s = map(millis(), startTime, endTime, 0, 1, true);
let t = easeOutCubic(s);
currentState = {
x: lerp(startState.x, endState.x, t),
y: lerp(startState.y, endState.y, t),
};
circle(currentState.x, currentState.y, 20);
}
function mousePressed() {
startTime = millis();
endTime = startTime + animationDuration;
startState = currentState;
endState = {
x: mouseX,
y: mouseY,
};
}
function easeOutCubic(x) {
return 1 - pow(1 - x, 3);
} Now it is easy to add a third animated property, the circle size. Adding the property requires just these changes:
- Giving it an initial value (lines 4 and 38)
- Using the value (line 27)
- Computing the animation state
let startState = {
x: 100,
y: 100,
size: 10,
};
let endState = startState;
let currentState;
let startTime = 0, endTime = 0; // when the current animation started and ends
let animationDuration = 500; // animations take this many milliseconds
function setup() {
createCanvas(windowWidth, windowHeight);
}
function draw() {
background(100);
let s = map(millis(), startTime, endTime, 0, 1, true);
let t = easeOutCubic(s);
currentState = {
x: lerp(startState.x, endState.x, t),
y: lerp(startState.y, endState.y, t),
size: lerp(startState.size, endState.size, t),
};
circle(currentState.x, currentState.y, currentState.size);
}
function mousePressed() {
startTime = millis();
endTime = startTime + animationDuration;
startState = currentState;
endState = {
x: mouseX,
y: mouseY,
size: random(10, 100),
};
}
function easeOutCubic(x) {
return 1 - pow(1 - x, 3);
} Color is special. Because it is not a scalar (a number), you cannot use lerp() to interpolate between two colors. Instead, use lerpColor().
Also, you cannot call color() outside of a function, so the start state is set in setup() instead.
let startState, endState, currentState;
let startTime = 0, endTime = 0; // when the current animation started and ends
let animationDuration = 500; // animations take this many milliseconds
function setup() {
createCanvas(windowWidth, windowHeight);
startState = {
x: 100,
y: 100,
size: 10,
color: color(0, 25, 100),
};
endState = startState;
}
function draw() {
background(100);
let s = map(millis(), startTime, endTime, 0, 1, true);
let t = easeOutCubic(s);
currentState = {
x: lerp(startState.x, endState.x, t),
y: lerp(startState.y, endState.y, t),
size: lerp(startState.size, endState.size, t),
color: lerpColor(startState.color, endState.color, t),
};
fill(currentState.color);
circle(currentState.x, currentState.y, currentState.size);
}
function mousePressed() {
startTime = millis();
endTime = startTime + animationDuration;
startState = currentState;
endState = {
x: mouseX,
y: mouseY,
size: random(10, 100),
color: color(random(256), random(256), random(256)),
};
}
function easeOutCubic(x) {
return 1 - pow(1 - x, 3);
} Often, interpolating between colors looks better in HSB space than RGB space. Using HSB space also allows us to generate random colors that are partly saturated.
Saturation is the second argument to color – the S(aturation) in HSB. Brightness is the third argument – the (B)rightness.
let startState, endState, currentState;
let startTime = 0, endTime = 0; // when the current animation started and ends
let animationDuration = 500; // animations take this many milliseconds
function setup() {
createCanvas(windowWidth, windowHeight);
colorMode(HSB);
startState = {
x: 100,
y: 100,
size: 10,
color: color(0, 25, 100),
};
endState = startState;
}
function draw() {
background(40);
let s = map(millis(), startTime, endTime, 0, 1, true);
let t = easeOutCubic(s);
currentState = {
x: lerp(startState.x, endState.x, t),
y: lerp(startState.y, endState.y, t),
size: lerp(startState.size, endState.size, t),
color: lerpColor(startState.color, endState.color, t),
};
fill(currentState.color);
circle(currentState.x, currentState.y, currentState.size);
}
function mousePressed() {
startTime = millis();
endTime = startTime + animationDuration;
startState = currentState;
endState = {
x: mouseX,
y: mouseY,
size: random(10, 100),
color: color(random(360), 25, 100),
};
}
function easeOutCubic(x) {
return 1 - pow(1 - x, 3);
} (Not covered in class) A p5.Vector packages x and y values into a single object. This is similar to how a color() packages red, green, and blue components, or (hue, saturation and brightness components) into a single color object.
Use p5.Vector.lerp() to interpolate between two vectors.
let startState, endState, currentState;
let startTime = 0, endTime = 0; // when the current animation started and ends
let animationDuration = 500; // animations take this many milliseconds
function setup() {
createCanvas(windowWidth, windowHeight);
colorMode(HSB);
startState = {
position: createVector(100, 100),
size: 10,
color: color(0, 25, 100),
};
endState = startState;
}
function draw() {
background(40);
let s = map(millis(), startTime, endTime, 0, 1, true);
let t = easeOutCubic(s);
currentState = {
position: p5.Vector.lerp(startState.position, endState.position, t),
size: lerp(startState.size, endState.size, t),
color: lerpColor(startState.color, endState.color, t),
};
fill(currentState.color);
circle(currentState.position.x, currentState.position.y, currentState.size);
}
function mousePressed() {
startTime = millis();
endTime = startTime + animationDuration;
startState = currentState;
endState = {
position: createVector(mouseX, mouseY),
size: random(10, 100),
color: color(random(360), 25, 100),
};
}
function easeOutCubic(x) {
return 1 - pow(1 - x, 3);
} If we use a linear interpolation for x and an easing function for y, and leave a trail, we can draw a plot of the easing function.
This step uses several easing functions from easings.net. Each time you click, it randomly selects one of them (line 42).
It makes heavy use of the fact that a function is a value. easingFunctions is an Array of these values. reversed is a function that takes a function value as an argument, and returns a function value as its result.
let startState, endState, currentState;
let startTime = 0, endTime = 0; // when the current animation started and ends
let animationDuration = 500; // animations take this many milliseconds
let animationFunction;
function setup() {
createCanvas(windowWidth, windowHeight);
colorMode(HSB);
background(40);
startState = {
x: 100,
y: 100,
size: 10,
};
endState = startState;
animationFunction = x => x;
animationFunction.displayName = "Click to animate";
}
function draw() {
background(40, 0.05);
textSize(20);
fill(80);
stroke(0);
text(animationFunction.displayName || animationFunction.name, 10, 20);
noStroke();
let s = map(millis(), startTime, endTime, 0, 1, true);
let t = animationFunction(s);
currentState = {
x: lerp(startState.x, endState.x, s),
y: lerp(startState.y, endState.y, t),
};
circle(currentState.x, currentState.y, 10);
if (s >= 1) noLoop();
}
function mousePressed() {
animationFunction = random(easingFunctions);
startTime = millis();
endTime = startTime + animationDuration;
startState = currentState;
endState = {
x: mouseX,
y: mouseY,
};
background(40);
loop();
}
/*
* Easing functions
*/
const linear = x => x;
// given an easing function f,
// return an easing function that's temporally
// reversed
const reversed = fn => {
let r = x => 1 - fn(1 - x);
r.displayName = fn.name.replace("Out", "In");
return r;
}
function easeOutBack(x) {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * pow(x - 1, 3) + c1 * pow(x - 1, 2);
}
function easeInOutBack(x) {
const c1 = 1.70158;
const c2 = c1 * 1.525;
return x < 0.5
? (pow(2 * x, 2) * ((c2 + 1) * 2 * x - c2)) / 2
: (pow(2 * x - 2, 2) * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2;
}
function easeOutBounce(x) {
const n1 = 7.5625;
const d1 = 2.75;
if (x < 1 / d1) {
return n1 * x * x;
} else if (x < 2 / d1) {
return n1 * (x -= 1.5 / d1) * x + 0.75;
} else if (x < 2.5 / d1) {
return n1 * (x -= 2.25 / d1) * x + 0.9375;
} else {
return n1 * (x -= 2.625 / d1) * x + 0.984375;
}
}
const easeOutCubic = x => 1 - pow(1 - x, 3);
const easeInOutCubic = x =>
x < 0.5 ? 4 * x * x * x : 1 - pow(-2 * x + 2, 3) / 2;
function easeOutElastic(x) {
return x === 0 ? 0
: x === 1 ? 1
: pow(2, -10 * x) * sin((x * 10 - 0.75) * TWO_PI / 3) + 1;
}
const easingFunctions = [
easeOutBounce, reversed(easeOutBounce),
easeOutBack, reversed(easeOutBack), easeInOutBack,
easeOutCubic, reversed(easeOutCubic), easeInOutCubic,
easeOutElastic, reversed(easeOutElastic),
]; Try it on OpenProcessing: Animation and Easing