Skip to content

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):

beginShape();
shape.value().vertices();
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):

quat.value().apply();
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.