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().

Wiggle

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.ofVector(createVector(300, 80))
    .addEffect(new Wiggle(10, 1));

// An amplitude per axis: this one only moves up and down.
vertical = Keyed.ofVector(createVector(300, 320))
    .addEffect(new Wiggle(createVector(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
let gentle;
let jittery;
let vertical;

function setup() {
    createCanvas(400, 400);
    // No duration: the default timeline runs forever, and so does the wiggle.
    Keyed.init();
    textFont('monospace');
    textSize(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.ofVector(createVector(300, 80))
        .addEffect(new Wiggle(10, 1));

    jittery = Keyed.ofVector(createVector(300, 200))
        .addEffect(new Wiggle(30, 3));

    // An amplitude per axis: this one only moves up and down.
    vertical = Keyed.ofVector(createVector(300, 320))
        .addEffect(new Wiggle(createVector(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.
}

function draw() {
    background(255);
    row('Wiggle(10, 1)', 80, gentle);
    row('Wiggle(30, 3)', 200, jittery);
    row('Wiggle(createVector(0, 30), 2)', 320, vertical);
}

function row(label, y, ball) {
    noStroke();
    fill(150);
    text(label, 20, y);

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

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

Orbit

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
let center;
let ball;

function setup() {
    createCanvas(400, 400);
    Keyed.init().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));
}

function motion() {
    return Keyed.ofVector()
        .key(Key.at(0).setEasing(1 / 3), createVector(100, 200))
        .key(Key.at(2).setEasing(1 / 3), createVector(300, 200))
        .key(Key.at(4).setEasing(1 / 3), createVector(100, 200));
}

function draw() {
    background(255);

    // Where the ball has been, from echo().
    const trail = ball.echo(60, 0.015);
    noStroke();
    for (let i = trail.length - 1; i > 0; i--) {
        const p = trail[i];
        fill(0, map(i, 0, trail.length, 120, 0));
        circle(p.x, p.y, 6);
    }

    // The value without the orbit.
    const c = center.value();
    const 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

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
const grid = 20;

let smooth;
let snapped;
let snappedX;

function setup() {
    createCanvas(400, 400);
    Keyed.init().setDuration(3);
    textFont('monospace');
    textSize(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));
}

function motion() {
    return Keyed.ofVector()
        .key(Key.at(0).setEasing(1 / 3), createVector(80, 300))
        .key(Key.at(1).setEasing(1 / 3), createVector(200, 80))
        .key(Key.at(2).setEasing(1 / 3), createVector(320, 300))
        .key(Key.at(3).setEasing(1 / 3), createVector(80, 300));
}

function draw() {
    background(255);

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

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

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

    const 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

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.6));
wobbly = jump().addEffect(new Spring(new FloatLerp(), 2, 0.4));

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

Full sketch: SpringEffect
let plain;
let stiff;
let wobbly;

function setup() {
    createCanvas(400, 400);
    Keyed.init().setDuration(4);
    textFont('monospace');
    textSize(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.6));
    wobbly = jump().addEffect(new Spring(new FloatLerp(), 2, 0.4));

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

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

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

function lane(label, y, 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

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 = squareMotion().addEffect(Effect.lag(0.3, 10));
longLag = squareMotion().addEffect(Effect.lag(1, 30));

Effect.lag() is the shortcut for p5.Vectors; other types use new Lag(lerp, duration, samples).

Full sketch: LagEffect
let corners;

let plain;
let shortLag;
let longLag;

function setup() {
    createCanvas(400, 400);
    Keyed.init().setDuration(4);
    textFont('monospace');
    textSize(14);

    corners = [
        createVector(100, 80),
        createVector(300, 80),
        createVector(300, 280),
        createVector(100, 280),
    ];

    plain = squareMotion();

    // 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 = squareMotion().addEffect(Effect.lag(0.3, 10));
    longLag = squareMotion().addEffect(Effect.lag(1, 30));
}

// One corner per second, at constant speed.
function squareMotion() {
    const k = Keyed.ofVector();
    for (let i = 0; i <= corners.length; i++) {
        k.key(i, corners[i % corners.length]);
    }
    return k;
}

function draw() {
    background(255);

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

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

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

    const 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

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

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

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

Full sketch: StopMotionEffect
let smooth;
let onTwos;
let choppy;

function setup() {
    createCanvas(400, 400);
    Keyed.init().setDuration(2);
    textFont('monospace');
    textSize(14);

    smooth = sweep();

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

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

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

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

function lane(label, y, 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

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
const grid = 20;

let snapLast;
let snapFirst;

function setup() {
    createCanvas(400, 400);
    Keyed.init().setDuration(4);
    textFont('monospace');
    textSize(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));
}

function motion(y) {
    return Keyed.ofVector()
        .key(Key.at(0).setEasing(1 / 3), createVector(100, y))
        .key(Key.at(2).setEasing(1 / 3), createVector(300, y))
        .key(Key.at(4).setEasing(1 / 3), createVector(100, y));
}

function draw() {
    background(255);

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

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

    const 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

Writing your own effect takes one line. In p5.keyed, a custom effect always gets the animation itself as source and the time t, so it can either read the current value with source(t) or sample any other time. Spring and Lag use the same idea.

// Bobs up and down twice per second.
const bob = (source, t) => {
    const p = source(t);
    return createVector(p.x, p.y + 15 * sin(t * TWO_PI * 2));
};
bobbing = sweep(210).addEffect(bob);

// Plays the animation half a second late.
const late = (source, t) => source(t - 0.5);
delayed = sweep(330).addEffect(late);

Effects can also be classes with an apply(source, t) method.

Full sketch: CustomEffect
let plain;
let bobbing;
let delayed;

function setup() {
    createCanvas(400, 400);
    Keyed.init().setDuration(3);
    textFont('monospace');
    textSize(14);

    plain = sweep(90);

    // In JS, custom effects always get source(t) and the time.
    // This one bobs up and down twice per second.
    const bob = (source, t) => {
        const p = source(t);
        return createVector(p.x, p.y + 15 * sin(t * TWO_PI * 2));
    };
    bobbing = sweep(210).addEffect(bob);

    // This one plays the animation half a second late.
    const late = (source, t) => source(t - 0.5);
    delayed = sweep(330).addEffect(late);
}

function sweep(y) {
    return Keyed.ofVector()
        .key(Key.at(0).setEasing(1 / 3), createVector(80, y))
        .key(Key.at(1.5).setEasing(1 / 3), createVector(320, y))
        .key(Key.at(3).setEasing(1 / 3), createVector(80, y));
}

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

function lane(label, y, ball) {
    noStroke();
    fill(150);
    text(label, 40, y - 40);

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

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

Avoid (a custom effect)

A custom effect can also take parameters: write a function 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:

function avoid(center, r, soft) {
    return (source, t) => {
        const p = source(t);
        const away = p5.Vector.sub(p, center);
        const d = away.mag();
        if (d >= r + soft || d === 0) return p;
        const h = max(soft - abs(d - r), 0) / soft;
        const target = max(d, r) + h * h * soft / 4;
        return p5.Vector.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
const points = 6;
const radius = 80;
const softness = 100;

let loop;
let spline;
let pos;
let obstacle;

function setup() {
    createCanvas(400, 400);
    Keyed.init().setDuration(4);
    textFont('monospace');
    textSize(14);

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

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

    obstacle = createVector(width / 2, height / 2);
    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.
function avoid(center, r, soft) {
    return (source, t) => {
        const p = source(t);
        const away = p5.Vector.sub(p, center);
        const 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.
        const h = max(soft - abs(d - r), 0) / soft;
        const target = max(d, r) + h * h * soft / 4;
        return p5.Vector.add(center, away.setMag(target));
    };
}

// Random points, wrapped around like in SplinePath to close the loop.
function createLoop(n) {
    const margin = 50;
    const pts = [];
    for (let i = 0; i < n; i++) {
        pts.push(createVector(random(margin, width - margin), random(margin, height - margin)));
    }
    return [pts[n - 1], ...pts, pts[0], pts[1]];
}

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

function draw() {
    background(255);

    // The path without the effect.
    noFill();
    stroke(220);
    strokeWeight(2);
    beginShape();
    for (let i = 0; i <= 300; i++) {
        const p = spline.value(i / 300);
        vertex(p.x, p.y);
    }
    endShape();

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

    // The motion with the effect, as a trail.
    const trail = pos.echo(200, 0.003);
    noFill();
    stroke(0);
    strokeWeight(5);
    beginShape();
    for (const p of trail) {
        vertex(p.x, p.y);
    }
    endShape();

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

Echo (motion trails)

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:

const trail = pos.echo(12, 0.04);

for (let i = trail.length - 1; i >= 0; i--) {
    const p = trail[i];
    fill(0, map(i, 0, trail.length, 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
let pos;

function setup() {
    createCanvas(400, 400);
    Keyed.init().setDuration(3);

    pos = Keyed.ofVector()
        .key(0, createVector(80, 300))
        .key(1, createVector(200, 80))
        .key(2, createVector(320, 300))
        .key(3, createVector(80, 300));
}

function 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.
    const trail = pos.echo(12, 0.04);

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

Next: Types.