Skip to content

6. Paths

Blending two p5.Vectors moves in a straight line. To move along a curve, a key can follow a path instead of holding a fixed value.

Bézier curves

CubicBezier takes the same four points as p5's bezier(), QuadraticBezier takes three:

cubic = new CubicBezier(c[0], c[1], c[2], c[3]);
quad = new QuadraticBezier(q[0], q[1], q[2]);

Two keys on the same path travel along all of it, start to end:

a = Keyed.ofVector()
    .key(0, cubic)
    .key(3, cubic);

at() pins a key to one position on the path: 0 is the start, 1 the end. From at(0) to at(1) and back to at(0) goes there and back again. Easing works as usual, shaping the progress along the path:

b = Keyed.ofVector()
    .key(Key.at(0).setEasing(1 / 3), quad.at(0))
    .key(Key.at(1.5).setEasing(1 / 3), quad.at(1))
    .key(Key.at(3).setEasing(1 / 3), quad.at(0));

A path is a Tween<p5.Vector>: value(d) is the point at d, from 0 to 1. Paths are traveled at constant speed, so even steps in d are evenly spaced, as the dots in the sketch show.

Full sketch: Bezier
let c;
let q;

let cubic;
let quad;
let a;
let b;

const drawPath = (path) => {
    // A path is a Tween<p5.Vector>: value(d) is the point at d, from 0 to 1.
    noFill();
    stroke(200);
    beginShape();
    for (let i = 0; i <= 100; i++) {
        const v = path.value(i / 100);
        vertex(v.x, v.y);
    }
    endShape();

    // Paths are traveled at constant speed, so even steps in d are evenly spaced.
    noStroke();
    fill(200);
    for (let i = 0; i <= 10; i++) {
        const v = path.value(i / 10);
        circle(v.x, v.y, 6);
    }
};

const handle = (anchor, control) => {
    stroke(230);
    line(anchor.x, anchor.y, control.x, control.y);
    noStroke();
    fill(230);
    rectMode(CENTER);
    rect(control.x, control.y, 8, 8);
    rectMode(CORNER);
};

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

    c = [
        createVector(60, 140), createVector(120, 50),
        createVector(280, 190), createVector(340, 100),
    ];
    q = [
        createVector(60, 340), createVector(200, 190), createVector(340, 340),
    ];

    cubic = new CubicBezier(c[0], c[1], c[2], c[3]);
    quad = new QuadraticBezier(q[0], q[1], q[2]);

    // A key can follow a path instead of holding a fixed value.
    // Two keys on the same path travel along all of it, start to end.
    a = Keyed.ofVector()
        .key(0, cubic)
        .key(3, cubic);

    // at() pins a key to one position on the path: 0 is the start, 1 the end.
    // From at(0) to at(1) and back to at(0) goes there and back again.
    // Easing works as usual, shaping the progress along the path.
    b = Keyed.ofVector()
        .key(Key.at(0).setEasing(1 / 3), quad.at(0))
        .key(Key.at(1.5).setEasing(1 / 3), quad.at(1))
        .key(Key.at(3).setEasing(1 / 3), quad.at(0));
};

window.draw = () => {
    background(255);

    handle(c[0], c[1]);
    handle(c[3], c[2]);
    handle(q[0], q[1]);
    handle(q[2], q[1]);

    drawPath(cubic);
    drawPath(quad);

    noStroke();
    fill(0);
    const pa = a.value();
    circle(pa.x, pa.y, 24);
    const pb = b.value();
    circle(pb.x, pb.y, 24);

    fill(150);
    text('CubicBezier', 40, 30);
    text('QuadraticBezier with at()', 40, 380);
};

Splines

Spline works like curveVertex(): the curve passes through every point except the first and last, which only shape its ends. Wrapping the points around closes the loop:

spline = new Spline(pts[4], pts[0], pts[1], pts[2], pts[3], pts[4], pts[0], pts[1]);

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

setTightness() is the same as curveTightness(): 0 is round, 1 gives straight lines. In the sketch, the mouse controls it.

Full sketch: SplinePath
let pts;

let spline;
let pos;
let tightness = 0;

window.setup = () => {
    createCanvas(400, 400);
    Keyed.init().setDuration(5);
    textFont('monospace');
    textSize(14);

    pts = [
        createVector(200, 60),
        createVector(340, 170),
        createVector(290, 330),
        createVector(110, 330),
        createVector(60, 170),
    ];

    // Like curveVertex(): the curve passes through every point except
    // the first and last, which only shape its ends. Wrapping the points
    // around like this closes the loop.
    spline = new Spline(pts[4], pts[0], pts[1], pts[2], pts[3], pts[4], pts[0], pts[1]);

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

window.mouseMoved = () => {
    // Same as curveTightness(): 0 is round, 1 gives straight lines.
    tightness = constrain(map(mouseX, 40, 360, 0, 1), 0, 1);
    spline.setTightness(tightness);
};

window.draw = () => {
    background(255);

    noFill();
    stroke(200);
    beginShape();
    for (let i = 0; i <= 200; i++) {
        const v = spline.value(i / 200);
        vertex(v.x, v.y);
    }
    endShape();

    noStroke();
    fill(200);
    for (const p of pts) {
        circle(p.x, p.y, 10);
    }

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

    fill(150);
    text(`tightness ${nf(tightness, 1, 2)}   move the mouse`, 40, 380);
};

Chaining and slicing

add() chains paths into a Curve, played one after another at even speed. Each path should start where the previous one ends:

curve = new CubicBezier(...)
    .add(new QuadraticBezier(...))
    .add(new CubicBezier(...));

slice(t0, t1) is the part of a path between t0 and t1. Here the black line is drawn on with curve.slice(0, progress). With t1 < t0 the slice runs backwards, which is how the red ball travels the curve in reverse:

reversed = curve.slice(1, 0);
back = Keyed.ofVector()
    .key(0, reversed)
    .key(4, reversed);
Full sketch: CurveSlice
let curve;
let reversed;
let progress;
let back;

const drawPath = (path, col, weight) => {
    noFill();
    stroke(col);
    strokeWeight(weight);
    beginShape();
    for (let i = 0; i <= 150; i++) {
        const v = path.value(i / 150);
        vertex(v.x, v.y);
    }
    endShape();
    strokeWeight(1);
};

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

    // add() chains paths into a Curve, played one after another at even speed.
    // Each path starts where the previous one ends.
    curve = new CubicBezier(
        createVector(60, 330), createVector(60, 200),
        createVector(180, 220), createVector(180, 140),
    ).add(new QuadraticBezier(
        createVector(180, 140), createVector(180, 50), createVector(260, 60),
    )).add(new CubicBezier(
        createVector(260, 60), createVector(360, 70),
        createVector(370, 220), createVector(300, 300),
    ));

    // Draws the curve on in 3 seconds, then holds for 1.
    progress = Keyed.ofFloat()
        .key(Key.at(0).setEasing(1 / 3), 0)
        .key(Key.at(3).setEasing(1 / 3), 1);

    // slice(t0, t1) is the part of a path between t0 and t1.
    // With t1 < t0 it runs backwards.
    reversed = curve.slice(1, 0);
    back = Keyed.ofVector()
        .key(0, reversed)
        .key(4, reversed);
};

window.draw = () => {
    background(255);

    drawPath(curve, 220, 1);

    // Only the first part of the curve, growing with progress.
    const p = progress.value();
    drawPath(curve.slice(0, p), 0, 4);
    const tip = curve.value(p);
    noStroke();
    fill(0);
    circle(tip.x, tip.y, 12);

    const b = back.value();
    fill(230, 60, 60);
    circle(b.x, b.y, 24);

    fill(0);
    text('slice(0, progress)', 40, 360);
    fill(230, 60, 60);
    text('slice(1, 0)', 40, 380);
};

Tweens

Paths are one kind of Tween: a value as a function of d from 0 to 1. Tweens can be transformed:

  • x() and y() turn a path into a Tween<number> of one coordinate. The shadow follows the ball along the ground with hop.x().
  • map(f) turns a tween's values into something else. The higher the ball, the narrower its shadow.
const hopX = hop.x();
shadowX = Keyed.ofFloat()
    .key(0, hopX)
    .key(duration, hopX);

const hopWidth = hop.map((p) => map(p.y, ground - r, 140, 60, 20));

And since a Tween is just a function, an arrow function works too. This one squashes the ball as it lands:

const landing = (d) => 1 - 0.3 * pow(abs(cos(d * TWO_PI)), 12);
squash = Keyed.ofFloat()
    .key(0, landing)
    .key(duration, landing);
Full sketch: TweenMap
const duration = 2;
const ground = 300;
const r = 15;

let ball;
let shadowX;
let shadowWidth;
let squash;

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

    // An arc, then the same arc backwards: a hop there and back.
    const arc = new QuadraticBezier(
        createVector(80, ground - r),
        createVector(200, 0),
        createVector(320, ground - r)
    );
    const hop = arc.add(arc.slice(1, 0));

    ball = Keyed.ofVector()
        .key(0, hop)
        .key(duration, hop);

    // x() turns a path into a Tween<Float> of its x coordinate (y() works too),
    // so the shadow follows the ball along the ground.
    const hopX = hop.x();
    shadowX = Keyed.ofFloat()
        .key(0, hopX)
        .key(duration, hopX);

    // map() turns a tween's values into something else:
    // the higher the ball, the narrower its shadow.
    const hopWidth = hop.map((p) => map(p.y, ground - r, 140, 60, 20));
    shadowWidth = Keyed.ofFloat()
        .key(0, hopWidth)
        .key(duration, hopWidth);

    // A Tween is just a function of d from 0 to 1, so a lambda works too.
    // This one squashes the ball as it lands, at d = 0, 0.5 and 1.
    const landing = (d) => 1 - 0.3 * pow(abs(cos(d * TWO_PI)), 12);
    squash = Keyed.ofFloat()
        .key(0, landing)
        .key(duration, landing);
}

function draw() {
    background(255);

    stroke(220);
    line(40, ground, 360, ground);

    noStroke();
    fill(220);
    ellipse(shadowX.value(), ground, shadowWidth.value(), 8);

    // Squashed flat and wide, with the bottom kept on the path.
    const p = ball.value();
    const s = squash.value();
    fill(0);
    ellipse(p.x, p.y + r * (1 - s), 2 * r * (2 - s), 2 * r * s);

    fill(150);
    text('x()     shadow position', 40, 340);
    text('map()   shadow width', 40, 360);
    text('d -> .. squash on landing', 40, 380);
}

Paths in 3D

Paths use x, y and z, so everything above works in WEBGL too. The track is a closed Spline through six points, and the shadow is the same track flattened onto the ground with map():

track = new Spline(pts[n - 1], pts[0], pts[1], pts[2], pts[3], pts[4], pts[5], pts[0], pts[1]);
shadowPath = track.map((p) => createVector(p.x, ground, p.z));
Full sketch: Path3D
const duration = 6;
const ground = 60;

let pts;
let track;
let shadowPath;
let ball;
let shadow;
let spin;
let overlay;

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

    overlay = createGraphics(400, 400);
    overlay.textFont('monospace');
    overlay.textSize(14);
    overlay.fill(150);
    overlay.noStroke();
    overlay.text('Spline in P3D', 40, 380);

    // Six points around a circle on the ground (x and z),
    // alternating high and low (y points down in Processing).
    const n = 6;
    pts = [];
    for (let i = 0; i < n; i++) {
        const a = i * TWO_PI / n;
        const y = i % 2 === 0 ? -80 : 30;
        pts.push(createVector(cos(a) * 130, y, sin(a) * 130));
    }

    // Paths use x, y and z, so they work in 3D just like in 2D.
    // Wrapping the points around closes the loop, as in SplinePath.
    track = new Spline(pts[n - 1], pts[0], pts[1], pts[2], pts[3], pts[4], pts[5], pts[0], pts[1]);

    ball = Keyed.ofVector()
        .key(0, track)
        .key(duration, track);

    // map() flattens the track onto the ground, for the shadow.
    shadowPath = track.map((p) => createVector(p.x, ground, p.z));
    shadow = Keyed.ofVector()
        .key(0, shadowPath)
        .key(duration, shadowPath);

    // Half a turn per loop, to see the depth.
    spin = Keyed.ofFloat()
        .key(0, 0)
        .key(duration, PI);
}

function draw() {
    background(255);

    lights();
    rotateX(-0.45);
    rotateY(spin.value());

    // The track's shadow on the ground.
    noFill();
    stroke(230);
    drawPath(shadowPath);

    // The track.
    stroke(0);
    strokeWeight(2);
    drawPath((d) => track.value(d));
    strokeWeight(1);

    // Posts from the ground up to each point.
    stroke(220);
    for (const p of pts) {
        line(p.x, ground, p.z, p.x, p.y, p.z);
    }

    const s = shadow.value();
    const b = ball.value();
    stroke(230, 60, 60, 120);
    line(s.x, s.y, s.z, b.x, b.y, b.z);

    noStroke();
    fill(200);
    push();
    translate(s.x, s.y, s.z);
    rotateX(HALF_PI);
    circle(0, 0, 20);
    pop();

    fill(230, 60, 60);
    push();
    translate(b.x, b.y, b.z);
    sphere(12);
    pop();

    push();
    resetMatrix();
    translate(-width / 2, -height / 2);
    image(overlay, 0, 0);
    pop();
}

const drawPath = (path) => {
    beginShape();
    for (let i = 0; i <= 200; i++) {
        const v = path(i / 200);
        vertex(v.x, v.y, v.z);
    }
    endShape();
};

Next: Effects.