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:
Two keys on the same path travel along all of it, start to end:
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:
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:
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()andy()turn a path into aTween<number>of one coordinate. The shadow follows the ball along the ground withhop.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.