8. Types¶
How two values blend is decided by a Lerp. Keyed.ofFloat(), ofColor() and the other factories pick one for you, but you can choose your own, write one as a function, or teach your own classes to blend themselves. Keyed also comes with two types for shapes and 3D rotations.
Lerps¶
Pass a Lerp and a default value to the constructor to choose how values blend:
shortWay = new Keyed(new AngleLerp(), 0)
.key(Key.at(0).setEasing(1 / 3), radians(330))
.key(Key.at(2).setEasing(1 / 3), radians(30))
.key(Key.at(4).setEasing(1 / 3), radians(330));
The built-in lerps are:
| Lerp | Blends |
|---|---|
FloatLerp, IntLerp, VectorLerp |
numbers and vectors |
ColorLerp |
colors in the color space of the colors themselves |
AngleLerp |
angles the short way round: from 330° to 30° it turns 60° forward, where FloatLerp turns 300° back |
StringLerp |
text, one character edit at a time |
BooleanLerp |
switches when the next key is reached |
StepLerp(threshold) |
any type; switches once the blend passes the threshold |
A Lerp can be a function too. This one blends colors in HSB, passing through the hues instead of gray:
colorMode(HSB, 360, 100, 100, 1);
const hsbBlack = color(0, 0, 0);
const hsbRed = color(0, 100, 100);
const hsbCyan = color(180, 100, 100);
colorMode(RGB, 255);
hsb = new Keyed((a, b, d) => a.lerp(b, d, 'hsb'), hsbBlack)
.key(0, hsbRed)
.key(2, hsbCyan)
.key(4, hsbRed);
Full sketch: Lerps
let longWay;
let shortWay;
let rgb;
let hsb;
let morph;
let step;
function setup() {
createCanvas(400, 400);
Keyed.init().setDuration(4);
textFont('monospace');
textSize(14);
textAlign(CENTER, CENTER);
// A Lerp decides how two values blend. Keyed.ofFloat() and friends pick one,
// new Keyed(lerp, defaultValue) lets you choose.
// Keyed.ofFloat() uses FloatLerp, which blends the numbers:
// from 330° to 30° it turns back 300°.
longWay = Keyed.ofFloat()
.key(Key.at(0).setEasing(1 / 3), radians(330))
.key(Key.at(2).setEasing(1 / 3), radians(30))
.key(Key.at(4).setEasing(1 / 3), radians(330));
// AngleLerp takes the short way round: 60° forward, across 0°.
shortWay = new Keyed(new AngleLerp(), 0)
.key(Key.at(0).setEasing(1 / 3), radians(330))
.key(Key.at(2).setEasing(1 / 3), radians(30))
.key(Key.at(4).setEasing(1 / 3), radians(330));
// Keyed.ofColor() uses ColorLerp, which blends red, green and blue:
// red to cyan passes through gray.
const rgbRed = color(255, 0, 0);
const rgbCyan = color(0, 255, 255);
rgb = Keyed.ofColor()
.key(0, rgbRed)
.key(2, rgbCyan)
.key(4, rgbRed);
// Lerp is a function too.
// This one blends in HSB, passing through the hues instead.
colorMode(HSB, 360, 100, 100, 1);
const hsbBlack = color(0, 0, 0);
const hsbRed = color(0, 100, 100);
const hsbCyan = color(180, 100, 100);
colorMode(RGB, 255);
hsb = new Keyed((a, b, d) => a.lerp(b, d, 'hsb'), hsbBlack)
.key(0, hsbRed)
.key(2, hsbCyan)
.key(4, hsbRed);
// Keyed.ofString() uses StringLerp, which morphs one character at a time.
morph = Keyed.ofString()
.key(0, 'keyed')
.key(2, 'lerps')
.key(4, 'keyed');
// StepLerp doesn't blend: it switches once the blend passes a threshold,
// here halfway between keys. It works for any type.
step = new Keyed(new StepLerp(0.5), '')
.key(0, 'keyed')
.key(2, 'lerps')
.key(4, 'keyed');
}
function draw() {
background(255);
dial('FloatLerp', 110, 90, longWay.value());
dial('AngleLerp', 290, 90, shortWay.value());
noStroke();
colorMode(RGB, 255);
fill(rgb.value());
rect(40, 190, 140, 40);
colorMode(HSB, 360, 100, 100, 1);
fill(hsb.value());
rect(220, 190, 140, 40);
colorMode(RGB, 255);
fill(150);
text('ColorLerp', 110, 250);
text('lambda, HSB', 290, 250);
fill(0);
textSize(24);
text(morph.value(), 110, 300);
text(step.value(), 290, 300);
fill(150);
textSize(14);
text('StringLerp', 110, 330);
text('StepLerp(0.5)', 290, 330);
}
function dial(label, x, y, angle) {
noFill();
stroke(220);
circle(x, y, 100);
// 0° points up.
stroke(0);
strokeWeight(3);
line(x, y, x + sin(angle) * 40, y - cos(angle) * 40);
strokeWeight(1);
noStroke();
fill(150);
const deg = ((degrees(angle) % 360) + 360) % 360;
text(`${label} ${round(deg)}°`, x, y + 70);
}
Plain objects¶
A lerp function is all it takes to animate your own data. This one blends plain objects with x and y, so no class is needed, and the default value is optional:
const pointLerp = (a, b, d) => ({
x: (1 - d) * a.x + d * b.x,
y: (1 - d) * a.y + d * b.y,
});
point = new Keyed(pointLerp)
.key(4, { x: 340, y: 100 })
.key(1, { x: 60, y: 300 });
point.value(2.5); // { x: 200, y: 200 }
Keys can be added in any order. Without a default value, value() is undefined until the first key. Keys are copied through the lerp, so changing your original objects later doesn't change them.
This sketch has no animation: it draws value(t) at a few fixed times, a row of dots between the two keys (red).
Full sketch: PlainObjects
// A lerp is just a function (a, b, d) => value: a at d = 0, b at d = 1.
// This one blends plain objects with x and y, so no class is needed.
const pointLerp = (a, b, d) => ({
x: (1 - d) * a.x + d * b.x,
y: (1 - d) * a.y + d * b.y,
});
let point;
function setup() {
createCanvas(400, 400);
textFont('monospace');
textSize(14);
// The default value is optional: value() is undefined until the first key.
// Keys can be added in any order.
point = new Keyed(pointLerp)
.key(4, { x: 340, y: 100 })
.key(1, { x: 60, y: 300 });
// Nothing moves, so draw once.
noLoop();
}
function draw() {
background(255);
noStroke();
// value(t) reads the animation at any time, here every half second from 1 to 4.
for (let t = 1; t <= 4; t += 0.5) {
const p = point.value(t);
fill(0, 60);
circle(p.x, p.y, 20);
}
// The two keys.
fill(230, 60, 60);
for (const key of point.keys()) {
const p = point.value(key.t());
circle(p.x, p.y, 12);
}
fill(150);
text('new Keyed(pointLerp).key(...)', 40, 380);
}
Your own types¶
Any object with a lerp(b, d) method can blend itself, so Keyed.of() needs no separate Lerp. It should return a new object instead of mutating either input. This Transform animates position, rotation and scale together, in one value:
class Transform {
constructor(x, y, rotation = 0, scale = 1) {
this.x = x;
this.y = y;
this.rotation = rotation;
this.scale = scale;
}
lerp(b, d) {
const turn = ((((b.rotation - this.rotation) % TWO_PI) + TWO_PI + PI) % TWO_PI) - PI;
return new Transform(
lerp(this.x, b.x, d),
lerp(this.y, b.y, d),
this.rotation + turn * d,
lerp(this.scale, b.scale, d),
);
}
}
transform = Keyed.of(new Transform(200, 200))
.key(Key.at(0).setEasing(1 / 3), new Transform(100, 120, 0, 1))
.key(Key.at(1.3).setEasing(1 / 3), new Transform(300, 120, radians(120), 1.6))
.key(Key.at(2.6).setEasing(1 / 3), new Transform(200, 290, radians(240), 0.6))
.key(Key.at(4).setEasing(1 / 3), new Transform(100, 120, 0, 1));
Tip
Make your type immutable, like Transform. Then lerp() can safely return shared instances or fresh ones without stored keys being changed from the outside.
Full sketch: CustomType
let transform;
function setup() {
createCanvas(400, 400);
Keyed.init().setDuration(4);
textFont('monospace');
textSize(14);
rectMode(CENTER);
// Transform (see its file) implements lerp(): it knows how to blend
// itself, so Keyed.of() needs no Lerp. Any class can do this.
// Position, rotation and scale are animated together, in one value.
transform = Keyed.of(new Transform(200, 200))
.key(Key.at(0).setEasing(1 / 3), new Transform(100, 120, 0, 1))
.key(Key.at(1.3).setEasing(1 / 3), new Transform(300, 120, radians(120), 1.6))
.key(Key.at(2.6).setEasing(1 / 3), new Transform(200, 290, radians(240), 0.6))
// Transform.lerp() turns the short way: from 240° to 0° it turns
// forward 120°, not back 240°.
.key(Key.at(4).setEasing(1 / 3), new Transform(100, 120, 0, 1));
}
function draw() {
background(255);
push();
const t = transform.value();
translate(t.x, t.y);
rotate(t.rotation);
scale(t.scale);
// or simply:
// transform.value().apply();
noStroke();
fill(0);
rect(0, 0, 60, 60);
// A notch, to see the rotation.
fill(255);
circle(0, -20, 10);
pop();
// text() is outlined by the current stroke in p5.
noStroke();
fill(150);
text('Keyed.of(new Transform(...))', 40, 380);
}
/** Immutable position, rotation and scale, so lerp() can safely share instances. */
class Transform {
/** Rotation in radians. */
constructor(x, y, rotation = 0, scale = 1) {
this.x = x;
this.y = y;
this.rotation = rotation;
this.scale = scale;
}
/** Rotation takes the shorter way round, e.g. 350° to 10° turns 20°, not 340°. */
lerp(b, d) {
const turn = ((((b.rotation - this.rotation) % TWO_PI) + TWO_PI + PI) % TWO_PI) - PI;
return new Transform(
lerp(this.x, b.x, d),
lerp(this.y, b.y, d),
this.rotation + turn * d,
lerp(this.scale, b.scale, d),
);
}
/** Translates, rotates and scales g; call inside push()/pop(), e.g. transform.value().apply(). */
apply(g = window) {
g.translate(this.x, this.y);
g.rotate(this.rotation);
g.scale(this.scale);
}
}
Shape morphing¶
ShapeMorph is a closed polygon that blends point by point. Shapes may have different point counts: the shape with fewer points gets extra points on its longest edges, so outlines and corners stay sharp while it morphs.
shape = Keyed.of(polygon(3, 130))
.key(Key.at(0).setEasing(0.6), polygon(3, 130))
.key(Key.at(1).setEasing(0.6), polygon(4, 120))
.key(Key.at(2).setEasing(0.6), star(5, 140, 60))
.key(Key.at(3).setEasing(0.6), polygon(40, 120))
.key(Key.at(4).setEasing(0.6), polygon(3, 130));
Create one from a list of p5.Vectors with new ShapeMorph(points). To draw it, call vertices() between beginShape() and endShape(CLOSE):
Tip
Start all shapes at the same angle, e.g. at the top, so they don't twist while morphing.
Full sketch: Morphing
let shape;
function setup() {
createCanvas(400, 400);
Keyed.init().setDuration(4);
textFont('monospace');
textSize(14);
// ShapeMorph is a closed polygon that blends point by point.
// The shape with fewer points gets extra points on its longest edges,
// so outlines and corners stay sharp while it morphs.
shape = Keyed.of(polygon(3, 130))
.key(Key.at(0).setEasing(0.6), polygon(3, 130))
.key(Key.at(1).setEasing(0.6), polygon(4, 120))
.key(Key.at(2).setEasing(0.6), star(5, 140, 60))
.key(Key.at(3).setEasing(0.6), polygon(40, 120))
.key(Key.at(4).setEasing(0.6), polygon(3, 130));
}
// All shapes start at the top, so they don't twist while morphing.
function polygon(n, r) {
const pts = [];
for (let i = 0; i < n; i++) {
const a = -HALF_PI + (i * TWO_PI) / n;
pts.push(createVector(200 + cos(a) * r, 190 + sin(a) * r));
}
return new ShapeMorph(pts);
}
function star(n, outer, inner) {
const pts = [];
for (let i = 0; i < n * 2; i++) {
const a = -HALF_PI + (i * PI) / n;
const r = i % 2 === 0 ? outer : inner;
pts.push(createVector(200 + cos(a) * r, 190 + sin(a) * r));
}
return new ShapeMorph(pts);
}
function draw() {
background(255);
const s = shape.value();
noStroke();
fill(0);
beginShape();
s.vertices();
endShape(CLOSE);
// The points it is made of.
fill(230, 60, 60);
for (const p of s.points) {
circle(p.x, p.y, 6);
}
fill(150);
text(`${s.points.length} points`, 40, 380);
}
Shape morphing in 3D¶
ShapeMorph uses x, y and z, so outlines can morph in 3D too. On a WEBGL canvas, vertices() emits z as well. Here a circle turns into a wave, a trefoil knot and a triangle with only 3 points.
Full sketch: Morphing3D
const n = 60;
let shape;
let spin;
let overlay;
function setup() {
createCanvas(400, 400, WEBGL);
Keyed.init().setDuration(8);
overlay = createGraphics(400, 400);
overlay.textFont('monospace');
overlay.textSize(14);
overlay.fill(150);
overlay.noStroke();
overlay.text('ShapeMorph in P3D', 40, 380);
// ShapeMorph uses x, y and z, so outlines can morph in 3D too.
shape = Keyed.of(makeCircle())
.key(Key.at(0).setEasing(0.6), makeCircle())
.key(Key.at(2).setEasing(0.6), makeWave())
.key(Key.at(4).setEasing(0.6), makeKnot())
.key(Key.at(6).setEasing(0.6), makeTriangle())
.key(Key.at(8).setEasing(0.6), makeCircle());
// One turn per loop, to see the depth.
spin = Keyed.ofFloat()
.key(0, 0)
.key(8, TWO_PI);
}
// All shapes start at the top, so they don't twist while morphing.
// A flat circle.
function makeCircle() {
const pts = [];
for (let i = 0; i < n; i++) {
const a = -HALF_PI + i * TWO_PI / n;
pts.push(createVector(cos(a) * 120, sin(a) * 120, 0));
}
return new ShapeMorph(pts);
}
// A circle that goes up and down three times.
function makeWave() {
const pts = [];
for (let i = 0; i < n; i++) {
const a = -HALF_PI + i * TWO_PI / n;
pts.push(createVector(cos(a) * 110, sin(a) * 110, sin(a * 3) * 50));
}
return new ShapeMorph(pts);
}
// A trefoil knot.
function makeKnot() {
const pts = [];
for (let i = 0; i < n; i++) {
const t = i * TWO_PI / n;
pts.push(createVector(
(sin(t) + 2 * sin(2 * t)) * 40,
(cos(t) - 2 * cos(2 * t)) * 40,
-sin(3 * t) * 40,
));
}
return new ShapeMorph(pts);
}
// Only 3 points: ShapeMorph adds the missing ones along its edges.
function makeTriangle() {
const pts = [];
for (let i = 0; i < 3; i++) {
const a = -HALF_PI + i * TWO_PI / 3;
pts.push(createVector(cos(a) * 130, sin(a) * 130, 0));
}
return new ShapeMorph(pts);
}
function draw() {
background(255);
push();
translate(-width / 2, -height / 2);
translate(width / 2, height / 2 - 10);
rotateX(-0.5);
rotateY(spin.value());
const s = shape.value();
// vertices() includes z with a 3D renderer.
noFill();
stroke(0);
strokeWeight(1.5);
beginShape();
s.vertices();
endShape(CLOSE);
stroke(230, 60, 60);
strokeWeight(7);
for (const p of s.points) {
point(p.x, p.y, p.z);
}
strokeWeight(1);
pop();
push();
resetMatrix();
translate(-width / 2, -height / 2);
image(overlay, 0, 0);
pop();
}
3D rotation¶
Both boxes turn between the same two orientations. Blending three Euler angles one by one makes the left box tumble along the way. A Quaternion is a 3D rotation that blends along the shortest arc, so the right box turns straight there:
quat = Keyed.of(Quaternion.identity())
.key(Key.at(0).setEasing(1 / 3), Quaternion.identity())
.key(Key.at(1.5).setEasing(1 / 3), fromEuler(PI, 0, PI))
.key(Key.at(3).setEasing(1 / 3), Quaternion.identity());
Build quaternions with Quaternion.fromAxisAngle(axis, angle), where axis can be a p5.Vector or [x, y, z], and combine them with mult(). apply() rotates the sketch's WEBGL matrix, like rotate(angle, axis):
Full sketch: Rotation3D
let euler;
let quat;
let overlay;
function setup() {
createCanvas(400, 400, WEBGL);
Keyed.init().setDuration(3);
overlay = createGraphics(400, 400);
overlay.textFont('monospace');
overlay.textSize(14);
overlay.fill(150);
overlay.noStroke();
overlay.textAlign(CENTER, CENTER);
overlay.text('Euler angles', 110, 330);
overlay.text('Quaternion', 290, 330);
// Both boxes turn between the same two orientations:
// no rotation, and rotateX(PI) plus rotateZ(PI).
// Blending the three angles one by one tumbles along the way.
euler = Keyed.ofVector()
.key(Key.at(0).setEasing(1 / 3), createVector(0, 0, 0))
.key(Key.at(1.5).setEasing(1 / 3), createVector(PI, 0, PI))
.key(Key.at(3).setEasing(1 / 3), createVector(0, 0, 0));
// A Quaternion is a 3D rotation that blends along the shortest arc.
// Here it turns straight around the y axis, which is the same end result.
quat = Keyed.of(Quaternion.identity())
.key(Key.at(0).setEasing(1 / 3), Quaternion.identity())
.key(Key.at(1.5).setEasing(1 / 3), fromEuler(PI, 0, PI))
.key(Key.at(3).setEasing(1 / 3), Quaternion.identity());
}
// The same rotation as rotateX(x), rotateY(y), rotateZ(z).
function fromEuler(x, y, z) {
const qx = Quaternion.fromAxisAngle(createVector(1, 0, 0), x);
const qy = Quaternion.fromAxisAngle(createVector(0, 1, 0), y);
const qz = Quaternion.fromAxisAngle(createVector(0, 0, 1), z);
return qx.mult(qy).mult(qz);
}
function draw() {
background(255);
push();
lights();
translate(-width / 2, -height / 2);
const e = euler.value();
push();
translate(110, 190);
rotateX(e.x);
rotateY(e.y);
rotateZ(e.z);
drawShape3D();
pop();
push();
translate(290, 190);
// Rotates the matrix, like rotate(angle, x, y, z).
quat.value().apply();
drawShape3D();
pop();
pop();
push();
resetMatrix();
translate(-width / 2, -height / 2);
image(overlay, 0, 0);
pop();
}
// A box with a red nose, to see which way it faces.
function drawShape3D() {
noStroke();
fill(240);
box(70, 40, 40);
fill(230, 60, 60);
translate(45, 0, 0);
box(20, 20, 20);
}
Next: Composition.