Skip to content

7. Effects

Effects change a value on top of its keys: shake it, circle it, snap it to a grid, or let it spring after the keyed motion. Add them with addEffect(). Most effects live in their own package:

import ch.domizai.keyed.effect.*;

Wiggle

WiggleEffect

Wiggle(amplitude, frequency) adds a smooth random offset of up to ±amplitude, changing direction about frequency times per second, inspired by After Effects' wiggle(). Effects work even without keys: the value is just its default, and the effect still moves it.

gentle = Keyed.of(new PVector(300, 80))
    .addEffect(new Wiggle(10, 1));

// An amplitude per axis: this one only moves up and down.
vertical = Keyed.of(new PVector(300, 320))
    .addEffect(new Wiggle(new PVector(0, 30), 2));

Every Wiggle moves differently. Pass a seed as a third argument, and wiggles with the same seed and settings move the same way. On a looping timeline, Wiggle loops seamlessly when duration × frequency is a whole number.

Full sketch: WiggleEffect
import ch.domizai.keyed.*;
import ch.domizai.keyed.effect.*;

Keyed<PVector> gentle, jittery, vertical;

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

void setup() {
    // No duration: the default timeline runs forever, and so does the wiggle.
    Keyed.init(this);
    textFont(createFont("Courier", 14));
    textAlign(LEFT, CENTER);

    // Effects change the value on top of the keys. Without any keys
    // the value is just the default, and the effect still moves it.

    // Wiggle(amplitude, frequency): a smooth random offset of up to
    // ±amplitude, changing direction about frequency times per second.
    gentle = Keyed.of(new PVector(300, 80))
        .addEffect(new Wiggle(10, 1));

    jittery = Keyed.of(new PVector(300, 200))
        .addEffect(new Wiggle(30, 3));

    // An amplitude per axis: this one only moves up and down.
    vertical = Keyed.of(new PVector(300, 320))
        .addEffect(new Wiggle(new PVector(0, 30), 2));

    // Every Wiggle moves differently. Pass a seed as a third argument,
    // and Wiggles with the same seed and settings move the same way.
    // On a looping timeline, Wiggle loops seamlessly when
    // duration * frequency is a whole number.
}

void draw() {
    background(255);
    row("Wiggle(10, 1)", 80, gentle);
    row("Wiggle(30, 3)", 200, jittery);
    row("Wiggle(new PVector(0, 30), 2)", 320, vertical);
}

void row(String label, float y, Keyed<PVector> ball) {
    noStroke();
    fill(150);
    text(label, 20, y);

    // The value without the wiggle.
    fill(220);
    circle(300, y, 8);

    PVector p = ball.value();
    fill(0);
    circle(p.x, p.y, 24);
}

Orbit

OrbitEffect

Orbit(radius, frequency) circles around the value, frequency times per second. Orbit(radius, frequency, axis) circles around any 3D axis instead.

ball = motion().addEffect(new Orbit(30, 2));

2 circles per second in a 4 second loop is a whole number of circles, so the loop is seamless. The trail comes from echo(), see Echo at the end of this chapter.

Full sketch: OrbitEffect
import java.util.List;

import ch.domizai.keyed.*;
import ch.domizai.keyed.effect.*;

Keyed<PVector> center, ball;

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

void setup() {
    Keyed.init(this).setDuration(4);

    center = motion();

    // Orbit(radius, frequency) circles around the value, frequency times
    // per second. 2 per second in a 4 second loop is a whole number of
    // circles, so the loop is seamless.
    // Orbit(radius, frequency, axis) circles around any 3D axis instead.
    ball = motion().addEffect(new Orbit(30, 2));
}

Keyed<PVector> motion() {
    return Keyed.ofPVector()
        .key(Key.at(0).setEasing(1 / 3f), new PVector(100, 200))
        .key(Key.at(2).setEasing(1 / 3f), new PVector(300, 200))
        .key(Key.at(4).setEasing(1 / 3f), new PVector(100, 200));
}

void draw() {
    background(255);

    // Where the ball has been, from echo().
    List<PVector> trail = ball.echo(60, 0.015f);
    noStroke();
    for (int i = trail.size() - 1; i > 0; i--) {
        PVector p = trail.get(i);
        fill(0, map(i, 0, trail.size(), 120, 0));
        circle(p.x, p.y, 6);
    }

    // The value without the orbit.
    PVector c = center.value();
    PVector p = ball.value();
    stroke(220);
    line(c.x, c.y, p.x, p.y);
    noStroke();
    fill(200);
    circle(c.x, c.y, 8);

    fill(0);
    circle(p.x, p.y, 24);
}

Grid snap

GridSnapEffect

GridSnap(size) rounds x and y to multiples of size, so the motion jumps from cell to cell. GridSnap(sizeX, sizeY) uses a size per axis, and 0 leaves that axis alone:

snapped = motion().addEffect(new GridSnap(grid));
snappedX = motion().addEffect(new GridSnap(grid, 0));
Full sketch: GridSnapEffect
import ch.domizai.keyed.*;
import ch.domizai.keyed.effect.*;

int grid = 20;
Keyed<PVector> smooth, snapped, snappedX;

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

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

    smooth = motion();

    // GridSnap(size) rounds x and y to multiples of size,
    // so the motion jumps from cell to cell.
    snapped = motion().addEffect(new GridSnap(grid));

    // GridSnap(sizeX, sizeY) uses a size per axis; 0 leaves that axis alone.
    // This one steps sideways but moves smoothly up and down.
    snappedX = motion().addEffect(new GridSnap(grid, 0));
}

Keyed<PVector> motion() {
    return Keyed.ofPVector()
        .key(Key.at(0).setEasing(1 / 3f), new PVector(80, 300))
        .key(Key.at(1).setEasing(1 / 3f), new PVector(200, 80))
        .key(Key.at(2).setEasing(1 / 3f), new PVector(320, 300))
        .key(Key.at(3).setEasing(1 / 3f), new PVector(80, 300));
}

void draw() {
    background(255);

    // Grid lines between the snapped positions, so each one is a cell.
    stroke(240);
    for (int i = grid / 2; i < width; i += grid) {
        line(i, 0, i, height);
        line(0, i, width, i);
    }

    noStroke();
    PVector s = snapped.value();
    fill(0);
    rect(s.x, s.y, grid, grid);

    PVector x = snappedX.value();
    fill(230, 60, 60);
    circle(x.x, x.y, grid * 0.8f);

    PVector p = smooth.value();
    noFill();
    stroke(150);
    circle(p.x, p.y, grid);

    noStroke();
    fill(150);
    text("no effect", 20, 345);
    fill(0);
    text("new GridSnap(grid)", 20, 365);
    fill(230, 60, 60);
    text("new GridSnap(grid, 0)", 20, 385);
}

Spring

SpringEffect

Spring(lerp, frequency, damping) follows the value like a spring: it lags behind, overshoots and settles. frequency is in wobbles per second; damping goes from 0 (wobbles forever) to 1 (no wobble).

stiff = jump().addEffect(new Spring<>(new FloatLerp(), 4, 0.6f));
wobbly = jump().addEffect(new Spring<>(new FloatLerp(), 2, 0.4f));

Spring needs a Lerp to work with any type. For PVectors there is a shortcut: Effect.spring(2, 0.4f).

Full sketch: SpringEffect
import ch.domizai.keyed.*;
import ch.domizai.keyed.effect.*;
import ch.domizai.keyed.lerps.*;

Keyed<Float> plain, stiff, wobbly;

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

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

    plain = jump();

    // Spring(lerp, frequency, damping) follows the value like a spring:
    // it lags behind, overshoots and settles. frequency is in wobbles per
    // second; damping goes from 0 (wobbles forever) to 1 (no wobble).
    stiff = jump().addEffect(new Spring<>(new FloatLerp(), 4, 0.6f));
    wobbly = jump().addEffect(new Spring<>(new FloatLerp(), 2, 0.4f));

    // For PVectors there is a shortcut: Effect.spring(2, 0.4f).
}

// Jumps right at 0.5 seconds and back at 2.5, with no motion in between.
Keyed<Float> jump() {
    return Keyed.ofFloat()
        .key(Key.at(0).hold(), 100f)
        .key(Key.at(0.5f).hold(), 300f)
        .key(2.5f, 100f);
}

void draw() {
    background(255);
    lane("hold()", 90, plain);
    lane("Spring(4, 0.6)", 210, stiff);
    lane("Spring(2, 0.4)", 330, wobbly);
}

void lane(String label, float y, Keyed<Float> x) {
    noStroke();
    fill(150);
    text(label, 40, y - 30);

    stroke(220);
    line(100, y, 300, y);

    noStroke();
    fill(0);
    circle(x.value(), y, 24);
}

Lag

LagEffect

Lag averages the value over the last duration seconds: it trails behind and rounds off sharp corners, without overshooting. More samples is smoother, but each one costs an evaluation.

shortLag = square().addEffect(Effect.lag(0.3f, 10));
longLag = square().addEffect(Effect.lag(1, 30));

Effect.lag() is the shortcut for PVectors; other types use new Lag<>(lerp, duration, samples).

Full sketch: LagEffect
import ch.domizai.keyed.*;
import ch.domizai.keyed.effect.*;

PVector[] corners = {
    new PVector(100, 80),
    new PVector(300, 80),
    new PVector(300, 280),
    new PVector(100, 280)
};

Keyed<PVector> plain, shortLag, longLag;

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

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

    plain = square();

    // Lag averages the value over the last duration seconds: it trails
    // behind and rounds off sharp corners, without overshooting.
    // More samples is smoother, but each one costs an evaluation.
    shortLag = square().addEffect(Effect.lag(0.3f, 10));
    longLag = square().addEffect(Effect.lag(1, 30));

    // Effect.lag() is the shortcut for PVectors;
    // other types use new Lag<>(lerp, duration, samples).
}

// One corner per second, at constant speed.
Keyed<PVector> square() {
    Keyed<PVector> k = Keyed.ofPVector();
    for (int i = 0; i <= corners.length; i++) {
        k.key(i, corners[i % corners.length]);
    }
    return k;
}

void draw() {
    background(255);

    noFill();
    stroke(230);
    rect(100, 80, 200, 200);

    noStroke();
    PVector p = plain.value();
    fill(200);
    circle(p.x, p.y, 12);

    PVector a = shortLag.value();
    fill(0);
    circle(a.x, a.y, 24);

    PVector b = longLag.value();
    fill(230, 60, 60);
    circle(b.x, b.y, 24);

    fill(200);
    text("no effect", 40, 340);
    fill(0);
    text("Effect.lag(0.3f, 10)", 40, 360);
    fill(230, 60, 60);
    text("Effect.lag(1, 30)", 40, 380);
}

Stop motion

StopMotionEffect

StopMotion(step) holds each pose for step seconds, whatever the keys. 2f / 24 is "on twos" at 24 fps, common in hand-drawn animation.

onTwos = sweep().addEffect(new StopMotion<>(2f / 24));
choppy = sweep().addEffect(new StopMotion<>(0.25f));

Unlike Key.hold(), which holds single keys, StopMotion samples the whole animation at a fixed rate.

Full sketch: StopMotionEffect
import ch.domizai.keyed.*;
import ch.domizai.keyed.effect.*;

Keyed<Float> smooth, onTwos, choppy;

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

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

    smooth = sweep();

    // StopMotion(step) holds each pose for step seconds, whatever the keys.
    // 2f / 24 is "on twos" at 24 fps, common in hand-drawn animation.
    onTwos = sweep().addEffect(new StopMotion<>(2f / 24));
    choppy = sweep().addEffect(new StopMotion<>(0.25f));

    // Unlike Key.hold(), which holds single keys,
    // StopMotion samples the whole animation at a fixed rate.
}

Keyed<Float> sweep() {
    return Keyed.ofFloat()
        .key(Key.at(0).setEasing(1 / 3f), 80f)
        .key(Key.at(1).setEasing(1 / 3f), 320f)
        .key(Key.at(2).setEasing(1 / 3f), 80f);
}

void draw() {
    background(255);
    lane("no effect", 90, smooth);
    lane("StopMotion(2f / 24)", 210, onTwos);
    lane("StopMotion(0.25f)", 330, choppy);
}

void lane(String label, float y, Keyed<Float> x) {
    noStroke();
    fill(150);
    text(label, 40, y - 30);

    stroke(220);
    line(80, y, 320, y);

    noStroke();
    fill(0);
    circle(x.value(), y, 24);
}

Stacking effects

Stacking

addEffect() can be called several times. Effects apply in the order they are added, each one to the result of the one before, so the order matters:

// Orbit, then GridSnap: the whole circle snaps to the grid.
snapLast = motion(130)
    .addEffect(new Orbit(40, 1))
    .addEffect(new GridSnap(grid));

// GridSnap, then Orbit: only the center snaps, the circle stays smooth.
snapFirst = motion(290)
    .addEffect(new GridSnap(grid))
    .addEffect(new Orbit(40, 1));
Full sketch: Stacking
import ch.domizai.keyed.*;
import ch.domizai.keyed.effect.*;

int grid = 20;
Keyed<PVector> snapLast, snapFirst;

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

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

    // addEffect() can be called several times. Effects apply in the order
    // they are added, each one to the result of the one before.

    // Orbit, then GridSnap: the whole circle snaps to the grid.
    snapLast = motion(130)
        .addEffect(new Orbit(40, 1))
        .addEffect(new GridSnap(grid));

    // GridSnap, then Orbit: only the center snaps, the circle stays smooth.
    snapFirst = motion(290)
        .addEffect(new GridSnap(grid))
        .addEffect(new Orbit(40, 1));
}

Keyed<PVector> motion(float y) {
    return Keyed.ofPVector()
        .key(Key.at(0).setEasing(1 / 3f), new PVector(100, y))
        .key(Key.at(2).setEasing(1 / 3f), new PVector(300, y))
        .key(Key.at(4).setEasing(1 / 3f), new PVector(100, y));
}

void draw() {
    background(255);

    stroke(240);
    for (int i = grid / 2; i < width; i += grid) {
        line(i, 0, i, height);
        line(0, i, width, i);
    }

    noStroke();
    PVector a = snapLast.value();
    fill(0);
    rect(a.x, a.y, grid, grid);

    PVector b = snapFirst.value();
    fill(230, 60, 60);
    circle(b.x, b.y, grid);

    fill(0);
    text("Orbit, then GridSnap", 40, 60);
    fill(230, 60, 60);
    text("GridSnap, then Orbit", 40, 220);
}

Custom effects

CustomEffect

Writing your own effect takes one line. There are two kinds:

  • An Effect gets the value and the time, and returns a new value.
  • A TimeEffect gets the animation itself as a Tween, so it can read the value at any other time. Spring and Lag are time effects.
// Bobs up and down twice per second.
Effect<PVector> bob = (p, t) -> new PVector(p.x, p.y + 15 * sin(t * TWO_PI * 2));
bobbing = sweep(210).addEffect(bob);

// Plays the animation half a second late.
TimeEffect<PVector> late = (source, t) -> source.value(t - 0.5f);
delayed = sweep(330).addEffect(late);

Declaring the type tells Java which kind of effect the lambda is. Both can also be classes that implement Effect or TimeEffect.

Full sketch: CustomEffect
import ch.domizai.keyed.*;
import ch.domizai.keyed.effect.*;

Keyed<PVector> plain, bobbing, delayed;

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

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

    plain = sweep(90);

    // An Effect gets the value and the time, and returns a new value.
    // This one bobs up and down twice per second.
    Effect<PVector> bob = (p, t) -> new PVector(p.x, p.y + 15 * sin(t * TWO_PI * 2));
    bobbing = sweep(210).addEffect(bob);

    // A TimeEffect gets the animation itself as a Tween,
    // so it can read the value at any other time.
    // This one plays the animation half a second late.
    TimeEffect<PVector> late = (source, t) -> source.value(t - 0.5f);
    delayed = sweep(330).addEffect(late);

    // Declaring the type tells Java which kind of effect the lambda is.
    // Both can also be classes that implement Effect or TimeEffect.
}

Keyed<PVector> sweep(float y) {
    return Keyed.ofPVector()
        .key(Key.at(0).setEasing(1 / 3f), new PVector(80, y))
        .key(Key.at(1.5f).setEasing(1 / 3f), new PVector(320, y))
        .key(Key.at(3).setEasing(1 / 3f), new PVector(80, y));
}

void draw() {
    background(255);
    lane("no effect", 90, plain);
    lane("Effect      (p, t) -> ...", 210, bobbing);
    lane("TimeEffect  (source, t) -> ...", 330, delayed);
}

void lane(String label, float y, Keyed<PVector> ball) {
    noStroke();
    fill(150);
    text(label, 40, y - 40);

    stroke(220);
    line(80, y, 320, y);

    PVector p = ball.value();
    noStroke();
    fill(0);
    circle(p.x, p.y, 24);
}

Avoid (a custom effect)

Avoid

A custom effect can also take parameters: write a method that returns it. This one keeps a path out of a circle. Points inside are pushed onto its edge, so the motion walks around the obstacle instead of passing through it:

Effect<PVector> avoid(PVector center, float r, float soft) {
    return (PVector p, float t) -> {
        PVector away = PVector.sub(p, center);
        float d = away.mag();
        if (d >= r + soft || d == 0) return p;
        float h = max(soft - abs(d - r), 0) / soft;
        float target = max(d, r) + h * h * soft / 4;
        return PVector.add(center, away.setMag(target));
    };
}

pos.addEffect(avoid(obstacle, radius, softness));

target is a smooth max(d, r): the distance never drops below r, and soft rounds off the corners where the path meets the circle. The effect keeps a reference to obstacle, so changing it with obstacle.set() moves the obstacle. If the path runs right through the center, the motion swaps sides quickly there.

Full sketch: Avoid
import java.util.List;

import ch.domizai.keyed.*;
import ch.domizai.keyed.effect.*;
import ch.domizai.keyed.tween.*;

int points = 6;
float radius = 80;
float softness = 100;

List<PVector> loop;
Spline spline;
Keyed<PVector> pos;
PVector obstacle;

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

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

    // The same path on every run. Click for a new one.
    randomSeed(4);
    loop = createLoop(points);
    spline = new Spline(loop);

    pos = Keyed.ofPVector()
        .key(0, spline)
        .key(4, spline);

    obstacle = new PVector(width / 2f, height / 2f);
    pos.addEffect(avoid(obstacle, radius, softness));
}

// A custom effect that keeps the value out of a circle.
// Points inside are pushed onto its edge, so the motion walks around it.
// softness rounds off the corners where the path meets the circle.
Effect<PVector> avoid(PVector center, float r, float soft) {
    return (PVector p, float t) -> {
        PVector away = PVector.sub(p, center);
        float d = away.mag();
        if (d >= r + soft || d == 0) return p;
        // A smooth max(d, r): the distance never drops below r,
        // and blends in over the softness band around the edge.
        float h = max(soft - abs(d - r), 0) / soft;
        float target = max(d, r) + h * h * soft / 4;
        return PVector.add(center, away.setMag(target));
    };
}

// Random points, wrapped around like in SplinePath to close the loop.
List<PVector> createLoop(int n) {
    float margin = 50;
    List<PVector> pts = new ArrayList<>();
    for (int i = 0; i < n; i++) {
        pts.add(new PVector(random(margin, width - margin), random(margin, height - margin)));
    }
    List<PVector> wrapped = new ArrayList<>();
    wrapped.add(pts.get(n - 1));
    wrapped.addAll(pts);
    wrapped.add(pts.get(0));
    wrapped.add(pts.get(1));
    return wrapped;
}

void mousePressed() {
    loop = createLoop(points);
    spline.setPoints(loop);
}

void draw() {
    background(255);

    // The path without the effect. curveVertex() draws the same curve as
    // Spline, so the points can be passed straight in.
    noFill();
    stroke(220);
    strokeWeight(2);
    beginShape();
    for (PVector p : loop) {
        curveVertex(p.x, p.y);
    }
    endShape();

    stroke(255, 0, 0);
    circle(obstacle.x, obstacle.y, radius * 2);

    // The motion with the effect, as a trail. curveVertex() skips the
    // first and last points, so they are added twice.
    List<PVector> trail = pos.echo(200, 0.003f);
    PVector first = trail.get(0);
    PVector last = trail.get(trail.size() - 1);
    stroke(0);
    strokeWeight(5);
    beginShape();
    curveVertex(first.x, first.y);
    for (PVector p : trail) {
        curveVertex(p.x, p.y);
    }
    curveVertex(last.x, last.y);
    endShape();

    noStroke();
    fill(150);
    text("click for a new path", 40, 380);
}

Echo (motion trails)

Echo

Not an effect per se, but a related tool: echo(samples, delay) returns the value now, delay seconds ago, 2 * delay seconds ago, and so on. That's a ready-made motion trail:

List<PVector> trail = pos.echo(12, 0.04f);

for (int i = trail.size() - 1; i >= 0; i--) {
    PVector p = trail.get(i);
    fill(0, map(i, 0, trail.size(), 255, 20));
    circle(p.x, p.y, 40 - i * 2);
}

Drawing oldest first puts the current position on top. A negative delay samples the future instead.

Full sketch: Echo
import java.util.List;

import ch.domizai.keyed.*;

Keyed<PVector> pos;

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

void setup() {
    Keyed.init(this).setDuration(3);

    pos = Keyed.ofPVector()
        .key(0, new PVector(80, 300))
        .key(1, new PVector(200, 80))
        .key(2, new PVector(320, 300))
        .key(3, new PVector(80, 300));
}

void draw() {
    background(255);

    // echo(samples, delay) returns the value now, delay seconds ago,
    // 2 * delay seconds ago, and so on: a ready-made motion trail.
    // A negative delay samples the future instead.
    List<PVector> trail = pos.echo(12, 0.04f);

    // Oldest first, so the current position is drawn on top.
    noStroke();
    for (int i = trail.size() - 1; i >= 0; i--) {
        PVector p = trail.get(i);
        fill(0, map(i, 0, trail.size(), 255, 20));
        circle(p.x, p.y, 40 - i * 2);
    }
}

Next: Types.