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 lambda, or teach your own classes to blend themselves. Keyed also comes with two types for shapes and 3D rotations.

Lerps

Lerps

Pass a Lerp and a default value to the constructor to choose how values blend:

shortWay = new Keyed<>(new AngleLerp(), 0f)
    .key(Key.at(0).setEasing(1 / 3f), radians(330))
    .key(Key.at(2).setEasing(1 / 3f), radians(30))
    .key(Key.at(4).setEasing(1 / 3f), radians(330));

The built-in lerps are in ch.domizai.keyed.lerps:

Lerp Blends
FloatLerp, IntLerp, PVectorLerp numbers and vectors
ColorLerp colors in RGB
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

Lerp is a functional interface, so a lambda works too. This one blends colors in HSB, passing through the hues instead of gray:

Lerp<Integer> hsbLerp = (a, b, d) -> lerpColor(a, b, d, HSB);
hsb = new Keyed<>(hsbLerp, 0)
    .key(0, color(255, 0, 0))
    .key(2, color(0, 255, 255))
    .key(4, color(255, 0, 0));
Full sketch: Lerps
import ch.domizai.keyed.*;
import ch.domizai.keyed.lerps.*;

Keyed<Float> longWay, shortWay;
Keyed<Integer> rgb, hsb;
Keyed<String> morph, step;

void settings() {
    size(400, 400);
}

void setup() {
    Keyed.init(this).setDuration(4);
    textFont(createFont("Courier", 24));
    textSize(14);
    textAlign(CENTER, CENTER);

    // A Lerp decides how two values blend. Keyed.ofFloat() and friends pick one,
    // new Keyed<>(lerp, default) 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 / 3f), radians(330))
        .key(Key.at(2).setEasing(1 / 3f), radians(30))
        .key(Key.at(4).setEasing(1 / 3f), radians(330));

    // AngleLerp takes the short way round: 60° forward, across 0°.
    shortWay = new Keyed<>(new AngleLerp(), 0f)
        .key(Key.at(0).setEasing(1 / 3f), radians(330))
        .key(Key.at(2).setEasing(1 / 3f), radians(30))
        .key(Key.at(4).setEasing(1 / 3f), radians(330));

    // Keyed.ofColor() uses ColorLerp, which blends red, green and blue:
    // red to cyan passes through gray.
    rgb = Keyed.ofColor()
        .key(0, color(255, 0, 0))
        .key(2, color(0, 255, 255))
        .key(4, color(255, 0, 0));

    // Lerp is a functional interface, so a lambda works too.
    // This one blends in HSB, passing through the hues instead.
    Lerp<Integer> hsbLerp = (a, b, d) -> lerpColor(a, b, d, HSB);
    hsb = new Keyed<>(hsbLerp, 0)
        .key(0, color(255, 0, 0))
        .key(2, color(0, 255, 255))
        .key(4, color(255, 0, 0));

    // 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<String>(0.5f), "")
        .key(0, "keyed")
        .key(2, "lerps")
        .key(4, "keyed");
}

void draw() {
    background(255);

    dial("FloatLerp", 110, 90, longWay.value());
    dial("AngleLerp", 290, 90, shortWay.value());

    noStroke();
    fill(rgb.value());
    rect(40, 190, 140, 40);
    fill(hsb.value());
    rect(220, 190, 140, 40);
    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.5f)", 290, 330);
}

void dial(String label, float x, float y, float 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);
    float deg = (degrees(angle) % 360 + 360) % 360;
    text(label + "  " + round(deg) + "°", x, y + 70);
}

Your own types

CustomType

A class that implements Lerpable knows how to blend itself, so Keyed.of() needs no Lerp. This Transform animates position, rotation and scale together, in one value:

import ch.domizai.keyed.types.Lerpable;

public class Transform implements Lerpable<Transform> {
    public final float x, y, rotation, scale;
    ...
    @Override
    public Transform lerp(Transform b, float d) {
        float turn = ((b.rotation - rotation) % TWO_PI + TWO_PI + PI) % TWO_PI - PI;
        return new Transform(
            PApplet.lerp(x, b.x, d),
            PApplet.lerp(y, b.y, d),
            rotation + turn * d,
            PApplet.lerp(scale, b.scale, d));
    }
}
transform = Keyed.of(new Transform(200, 200))
    .key(Key.at(0).setEasing(1 / 3f), new Transform(100, 120, 0, 1))
    .key(Key.at(1.3f).setEasing(1 / 3f), new Transform(300, 120, radians(120), 1.6f))
    ...

Tip

Make your type immutable, like Transform with its final fields. Then lerp() can safely return shared instances.

Full sketch: CustomType
import ch.domizai.keyed.*;

Keyed<Transform> transform;

void settings() {
    size(400, 400);
}

void setup() {
    Keyed.init(this).setDuration(4);
    textFont(createFont("Courier", 14));
    rectMode(CENTER);

    // Transform (see its tab) implements Lerpable: 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 / 3f), new Transform(100, 120, 0, 1))
        .key(Key.at(1.3f).setEasing(1 / 3f), new Transform(300, 120, radians(120), 1.6f))
        .key(Key.at(2.6f).setEasing(1 / 3f), new Transform(200, 290, radians(240), 0.6f))
        // Transform.lerp() turns the short way: from 240° to 0° it turns
        // forward 120°, not back 240°.
        .key(Key.at(4).setEasing(1 / 3f), new Transform(100, 120, 0, 1));
}

void draw() {
    background(255);

    pushMatrix();

    Transform t = transform.value();
    translate(t.x, t.y);
    rotate(t.rotation);
    scale(t.scale);
    // or simply:
    // transform.value().apply(this);

    noStroke();
    fill(0);
    rect(0, 0, 60, 60);
    // A notch, to see the rotation.
    fill(255);
    circle(0, -20, 10);
    popMatrix();

    fill(150);
    text("Keyed.of(new Transform(...))", 40, 380);
}
import ch.domizai.keyed.types.Lerpable;

/** Immutable position, rotation and scale, so lerp() can safely share instances. */
public class Transform implements Lerpable<Transform> {
    public final float x, y, rotation, scale;

    /** Rotation in radians. */
    public Transform(float x, float y, float rotation, float scale) {
        this.x = x;
        this.y = y;
        this.rotation = rotation;
        this.scale = scale;
    }

    /** Position only, no rotation, scale 1. */
    public Transform(float x, float y) {
        this(x, y, 0, 1);
    }

    /** Rotation takes the shorter way round, e.g. 350° to 10° turns 20°, not 340°. */
    @Override
    public Transform lerp(Transform b, float d) {
        float turn = ((b.rotation - rotation) % TWO_PI + TWO_PI + PI) % TWO_PI - PI;
        return new Transform(
            PApplet.lerp(x, b.x, d),
            PApplet.lerp(y, b.y, d),
            rotation + turn * d,
            PApplet.lerp(scale, b.scale, d));
    }

    /** Translates, rotates and scales g; call inside pushMatrix()/popMatrix(), e.g. transform.value().apply(this). */
    public void apply(PApplet g) {
        g.translate(x, y);
        g.rotate(rotation);
        g.scale(scale);
    }
}

Shape morphing

Morphing

ShapeMorph (in ch.domizai.keyed.types) 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.6f), polygon(3, 130))
    .key(Key.at(1).setEasing(0.6f), polygon(4, 120))
    .key(Key.at(2).setEasing(0.6f), star(5, 140, 60))
    .key(Key.at(3).setEasing(0.6f), polygon(40, 120))
    .key(Key.at(4).setEasing(0.6f), polygon(3, 130));

Create one from a list of PVectors with new ShapeMorph(points). To draw it, call vertices() between beginShape() and endShape():

beginShape();
shape.value().vertices(this);
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
import java.util.ArrayList;

import ch.domizai.keyed.*;
import ch.domizai.keyed.types.*;

Keyed<ShapeMorph> shape;

void settings() {
    size(400, 400);
}

void setup() {
    Keyed.init(this).setDuration(4);
    textFont(createFont("Courier", 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.6f), polygon(3, 130))
        .key(Key.at(1).setEasing(0.6f), polygon(4, 120))
        .key(Key.at(2).setEasing(0.6f), star(5, 140, 60))
        .key(Key.at(3).setEasing(0.6f), polygon(40, 120))
        .key(Key.at(4).setEasing(0.6f), polygon(3, 130));
}

// All shapes start at the top, so they don't twist while morphing.
ShapeMorph polygon(int n, float r) {
    ArrayList<PVector> pts = new ArrayList<>();
    for (int i = 0; i < n; i++) {
        float a = -HALF_PI + i * TWO_PI / n;
        pts.add(new PVector(200 + cos(a) * r, 190 + sin(a) * r));
    }
    return new ShapeMorph(pts);
}

ShapeMorph star(int n, float outer, float inner) {
    ArrayList<PVector> pts = new ArrayList<>();
    for (int i = 0; i < n * 2; i++) {
        float a = -HALF_PI + i * PI / n;
        float r = i % 2 == 0 ? outer : inner;
        pts.add(new PVector(200 + cos(a) * r, 190 + sin(a) * r));
    }
    return new ShapeMorph(pts);
}

void draw() {
    background(255);

    ShapeMorph s = shape.value();

    noStroke();
    fill(0);
    beginShape();
    s.vertices(this);
    endShape(CLOSE);

    // The points it is made of.
    fill(230, 60, 60);
    for (PVector p : s.points) {
        circle(p.x, p.y, 6);
    }

    fill(150);
    text(s.points.size() + " points", 40, 380);
}

Shape morphing in 3D

Morphing3D

ShapeMorph uses x, y and z, so outlines can morph in 3D too. With a 3D renderer, vertices() includes z. Here a circle turns into a wave, a trefoil knot and a triangle with only 3 points.

Full sketch: Morphing3D
import java.util.ArrayList;

import ch.domizai.keyed.*;
import ch.domizai.keyed.types.*;

int n = 60;
Keyed<ShapeMorph> shape;
Keyed<Float> spin;

void settings() {
    size(400, 400, P3D);
}

void setup() {
    Keyed.init(this).setDuration(8);
    textFont(createFont("Courier", 14));

    // ShapeMorph uses x, y and z, so outlines can morph in 3D too.
    shape = Keyed.of(circle())
        .key(Key.at(0).setEasing(0.6f), circle())
        .key(Key.at(2).setEasing(0.6f), wave())
        .key(Key.at(4).setEasing(0.6f), knot())
        .key(Key.at(6).setEasing(0.6f), triangle())
        .key(Key.at(8).setEasing(0.6f), circle());

    // One turn per loop, to see the depth.
    spin = Keyed.ofFloat()
        .key(0, 0f)
        .key(8, TWO_PI);
}

// All shapes start at the top, so they don't twist while morphing.

// A flat circle.
ShapeMorph circle() {
    ArrayList<PVector> pts = new ArrayList<>();
    for (int i = 0; i < n; i++) {
        float a = -HALF_PI + i * TWO_PI / n;
        pts.add(new PVector(cos(a) * 120, sin(a) * 120, 0));
    }
    return new ShapeMorph(pts);
}

// A circle that goes up and down three times.
ShapeMorph wave() {
    ArrayList<PVector> pts = new ArrayList<>();
    for (int i = 0; i < n; i++) {
        float a = -HALF_PI + i * TWO_PI / n;
        pts.add(new PVector(cos(a) * 110, sin(a) * 110, sin(a * 3) * 50));
    }
    return new ShapeMorph(pts);
}

// A trefoil knot.
ShapeMorph knot() {
    ArrayList<PVector> pts = new ArrayList<>();
    for (int i = 0; i < n; i++) {
        float t = i * TWO_PI / n;
        pts.add(new PVector(
            (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.
ShapeMorph triangle() {
    ArrayList<PVector> pts = new ArrayList<>();
    for (int i = 0; i < 3; i++) {
        float a = -HALF_PI + i * TWO_PI / 3;
        pts.add(new PVector(cos(a) * 130, sin(a) * 130, 0));
    }
    return new ShapeMorph(pts);
}

void draw() {
    background(255);

    fill(150);
    text("ShapeMorph in P3D", 40, 380);

    translate(width / 2, height / 2 - 10);
    rotateX(-0.5f);
    rotateY(spin.value());

    ShapeMorph s = shape.value();

    // vertices() includes z with a 3D renderer.
    noFill();
    stroke(0);
    strokeWeight(1.5f);
    beginShape();
    s.vertices(this);
    endShape(CLOSE);

    stroke(230, 60, 60);
    strokeWeight(7);
    for (PVector p : s.points) {
        point(p.x, p.y, p.z);
    }
    strokeWeight(1);
}

3D rotation

Rotation3D

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 / 3f), Quaternion.identity())
    .key(Key.at(1.5f).setEasing(1 / 3f), fromEuler(PI, 0, PI))
    .key(Key.at(3).setEasing(1 / 3f), Quaternion.identity());

Build quaternions with Quaternion.fromAxisAngle(axis, angle) and combine them with mult(). apply(this) rotates the matrix, like rotate(angle, x, y, z):

quat.value().apply(this);
Full sketch: Rotation3D
import ch.domizai.keyed.*;
import ch.domizai.keyed.types.*;

Keyed<PVector> euler;
Keyed<Quaternion> quat;

void settings() {
    size(400, 400, P3D);
}

void setup() {
    Keyed.init(this).setDuration(3);
    textFont(createFont("Courier", 14));
    textAlign(CENTER, CENTER);

    // 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.ofPVector()
        .key(Key.at(0).setEasing(1 / 3f), new PVector(0, 0, 0))
        .key(Key.at(1.5f).setEasing(1 / 3f), new PVector(PI, 0, PI))
        .key(Key.at(3).setEasing(1 / 3f), new PVector(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 / 3f), Quaternion.identity())
        .key(Key.at(1.5f).setEasing(1 / 3f), fromEuler(PI, 0, PI))
        .key(Key.at(3).setEasing(1 / 3f), Quaternion.identity());
}

// The same rotation as rotateX(x), rotateY(y), rotateZ(z).
Quaternion fromEuler(float x, float y, float z) {
    Quaternion qx = Quaternion.fromAxisAngle(new PVector(1, 0, 0), x);
    Quaternion qy = Quaternion.fromAxisAngle(new PVector(0, 1, 0), y);
    Quaternion qz = Quaternion.fromAxisAngle(new PVector(0, 0, 1), z);
    return qx.mult(qy).mult(qz);
}

void draw() {
    background(255);
    lights();

    PVector e = euler.value();
    pushMatrix();
    translate(110, 190);
    rotateX(e.x);
    rotateY(e.y);
    rotateZ(e.z);
    shape3D();
    popMatrix();

    pushMatrix();
    translate(290, 190);
    // Rotates the matrix, like rotate(angle, x, y, z).
    quat.value().apply(this);
    shape3D();
    popMatrix();

    fill(150);
    text("Euler angles", 110, 330);
    text("Quaternion", 290, 330);
}

// A box with a red nose, to see which way it faces.
void shape3D() {
    noStroke();
    fill(240);
    box(70, 40, 40);
    fill(230, 60, 60);
    translate(45, 0, 0);
    box(20, 20, 20);
}

Next: Composition.