Lerping

Smoothing Motion with Linear Interpolation

Your friend is working on a fun sketch and needs a bit of help adding some polish to it. They need to make the mouse cursor move a bit more smoothly around the canvas and add some cursor trails. Right now, they know how to make a trail which follows the mouse around by making a list of positions and drawing circles at each one. And your friend can't quite put their finger on it, but they want to figure out how to make the movements a bit smoother.

Looking at the example below, we can see that the trail is a little jittery. Whenever we move the mouse quickly, the trail jumps around a bit. It also doesn't seem to shrink in a very satisfying way. And whenever we move the mouse with a bit of jitter or there's some choppy frames, the trail also reflects that jitter.

So ultimately, they want to go from the sketch above, to something more like this:

Let's try to break down what we need to get there. We want to:

  • make the trail of circles follow the mouse cursor smoothly, not just plot along the mouse's path
  • have the circles of this trail follow the mouse, but not instantaneously
  • make a trail of additional circles which follow the first circle

To do this, we'll have to drill down into a few topics:

  • Using a function which smoothly updates a position each frame to get closer to an arbitrary target position
  • Making something that keeps track of where each circle is and how slow it should move
  • Making a way of animating the circles where they get a little closer to the mouse or the circle in front of them each frame

So let's start with making one circle follow the mouse, then we can scale that solution up to manage a whole list of circles.

Linear Interpolation (Lerp)

Linear Interpolation is a way of taking two numbers (commonly referred to as A and B or Start and Stop) and plotting a progression between them. In the example below, we have a start and stop point on the left and right side of the canvas and a point between them labeled "lerp." This lerp point is computed based on the start and stop points and the value of the progress slider. This way, when the slider is at 0.0, the lerp point is the same as the start point, at 0.5 the lerp point is halfway between the two, and at 1.0 the lerp point is the same as the stop point. Try playing around with the sketch below to get an idea of how it works.

This will also work if the two points are moving around the canvas. Whatever the progress is, the lerp value will always be proportional to the distance between the start and stop points.

How Linear Interpolation Works

Now that we have had a chance to play around with lerp(), let's dig a bit deeper into how it works under the hood. If we go to the p5.js reference (opens in a new tab), we can follow a link to the p5.js source code for lerp() (opens in a new tab). There, we can see how the function is actually written.

p5.prototype.lerp = function(start, stop, amt) {
  p5._validateParameters('lerp', arguments);
  return amt * (stop - start) + start;
};

Some of this code we can ignore for now, since it's just validation, error handling, and attaching the function to the p5 prototype. This is the important part of the code:

function(start, stop, amt) {
  return amt * (stop - start) + start;
};

So let's break down what this code is doing. The arguments should map pretty closely to the start, stop, and progress values we've been using in the earlier examples.

  • start and stop are the two points we are interpolating between.
  • amt is the amount of interpolation to apply. This is the part derived from the "progress" slider value in the earlier examples.

The really dense part is the body of the function. Even though it's a single line of code, it's a pretty dense expression. So let's break it down:

return amt * (stop - start) + start;
  • The return at the beginning of the line means that the function is returning a value. In this case, we can determine based on the p5 documentation that its meant to return a number. The rest of the line is computing the value which is being returned.
  • Following the order of operations, lets break down the rest of the line:
    • (stop - start) is getting us the difference between the stop and start points. This is the distance we need to interpolate over.
    • amt * is then multiplying this distance by the amount of interpolation to apply.
    • + start is added to the interpolated value to shift it to the correct position.

So for a simple example, if we're getting a lerped value between 0 and 100, with amt set to 0.5, the result would be 50.

function(start = 0, stop = 100, amt = 0.5) {
  return amt * (stop - start) + start;
  // 0.5 * (100 - 0) + 0 = 50
  // 100-0 = 100
  // 0.5 * 100 = 50
  // 50 + 0 = 50
  // 50
};

And if we want 50% of the distance between 50 and 100, we'll get 75.

function(start = 50, stop = 100, amt = 0.5) {
  return amt * (stop - start) + start;
  // 0.5 * (100 - 50) + 50
  // 100-50 = 50
  // 0.5 * 50 = 25
  // 25 + 50 = 75
  // 75
};

And this also works even when the stop value is less than the start value. The difference stop - start becomes negative, which flips the direction of the step. The result still falls between the two values.

function(start = 100, stop = 0, amt = 0.5) {
  return amt * (stop - start) + start;
  // 0.5 * (0 - 100) + 100
  // 0-100 = -100
  // 0.5 * -100 = -50
  // -50 + 100 = 50
  // 50
};

Feel free to experiment with different values for start, stop, and amt to see how the function behaves. Once you're satisfied, we can move on to the next step.

Lerping and Zeno's Paradox

From playing around with the values, did you happen to notice something interesting about the function's behavior? If we keep the amt value constant and vary start and stop, we can see that the value yielded by amt * (stop - start) part of the equation will vary. As the distance between the start and stop values gets smaller, the value of amt * (stop - start) gets smaller. Looking back at when we did the math by hand, we can see that the return value of 50% of the distance between 0 and 100 is 50, and the return value of 50% of the distance between 50 and 100 is 75. So the smaller the distance, the smaller the return value of our lerp function will be. This point is important because this is how we can start to use the lerp function to animate values over time and smooth out the animation.

To help visualize this, we can use a paradox made by Zeno of Elea. In his thought experiment, Zeno rather cheekily makes the claim that if you try to walk across a room from one side to the other, you'll never reach the other side. Why is this?

Well, if you're walking to a destination, you have to go half of the distance to get there, right? Then, once you're half way there, you then have to go half of the remaining distance to the destination. And since you're going halfway of halfway, your speed will be half of what you started with. And then, once you've got only a half way of a half way to go, you still need to go half the remaining distance to the destination, which again means progressing half as fast as you did in the last step.

This process repeats indefinitely, so you'll never reach the other side, according to Zeno. Of course, this is not how real life works; Zeno is just trying to troll us with a not so clever paradox. This reasoning wouldn't hold up to something like Newtonian physics. It's also worth noting since JavaScript floating point numbers don't have infinite precision, they will eventually round up to the stop value, even if they follow the steps described by Zeno's paradox. But in our p5 sketch where we don't necessarily need to follow real-world physics, we can use this paradox to our advantage.

Let's take the example we were looking at before, and instead of just applying the interpolation once, let's use the new position to interpolate again. This way, we can have the ball move the step size amount towards the stop position, and then loop back to the start position. We'll also slow down the animation so we can see the effect more clearly.

As you might notice, when we make the step size smaller, we increase the number of steps required to reach the target, making the ball move slower and smoother. The slow-down of the ball as it approaches the target is also more pronounced.

Now we can take this approach to animating the ball towards an arbitrary target, and have the ball continuously move towards the mouse position. Each frame, we can update the target position, then use p5.Vector.lerp to move the ball towards the target.

stop = createVector(mouseX, mouseY)
ballPos = p5.Vector.lerp(ballPos, stop, pct)

As you move the mouse around, pay attention to how the ball follows it. When you tug the mouse away quickly, the ball will also speed up to follow and then slow down as it approaches the cursor. You might also see that once the step size gets to about 0.4 and above, the lag between the ball and the mouse gets a lot shorter and harder to notice.

Making a Trail

Now that we have a single circle following the mouse, let's scale that up. The goal is a trail: a series of circles where each one follows the one ahead of it, forming a smooth chain behind the cursor.

Before writing anything, let's think through what a trail actually needs:

  • A list of circles, each with its own position
  • Each circle should lerp toward a target every frame
  • Each circle should draw itself at its current position

The last two points sound a lot like the ball from the previous section. Since we'll need a whole list of them, it makes sense to wrap that behavior into a class.

The TrailNode class

A TrailNode needs to know where it is, how big it is, and how quickly it moves toward its target:

class TrailNode {
  constructor(_x, _y, _size, _speed) {
    this.position = createVector(_x, _y)
    this.size = _size
    this.speed = _speed
  }
  update(target) {
    this.position.lerp(target, this.speed)
  }
  display() {
    ellipse(this.position.x, this.position.y, this.size, this.size)
  }
}

The speed property is just the amt argument for lerp. It's a value from 0 to 1 controlling how quickly the node closes in on its target. Let's make a small array of them and have them all follow the mouse:

Move the mouse around. The circles all chase the cursor independently. Faster ones hug it closely, slower ones lag behind. But they're not connected to each other. To make a real trail, each node needs to follow the one ahead of it, not the mouse directly.

Chaining the nodes

The only change is what we pass as target to each node's update(). The first node follows the mouse, and every node after that follows the one before it:

for (let i = 0; i < trail.length; i++) {
  let tar = (i === 0) ? createVector(mouseX, mouseY) : trail[i-1].position
  trail[i].update(tar)
}

One other thing to sort out: draw order. Right now we draw from first to last, so the tail ends up on top of the head visually. Reversing the draw loop puts the head on top where it belongs:

trail.slice().reverse().forEach(node => node.display())

Why slice() first? Array.reverse() modifies the array in place. Without slice(), we'd also flip the update order next frame, so nodes would try to follow themselves backward and the whole chain would collapse. slice() makes a shallow copy so we can safely reverse just the drawing.

New to these array methods? push(), slice(), reverse(), and forEach() are standard JavaScript, not p5-specific. A quick guide to the ones used here →

Adding more nodes and a gradient

With the structure in place, scaling to 200 nodes is just a matter of changing the trailSize variable. For some extra polish, we can add a color gradient by setting the fill inside the draw loop, blending from the background color at the tail to the full pink at the head based on each node's index:

trail.slice().reverse().map((node, i) => {
  fill(105 + i/trailSize*100, 0, 64 + i/trailSize*64)
  node.display()
})

One last detail worth noticing: the first node gets a much lower speed than the rest (0.12 instead of 0.88). The head moves lazily toward the mouse, while every node behind it snaps quickly into position. That slow head is what gives the whole trail its smooth, organic feel.

Further Refinement

Something you might have noticed about the lerping though is how its affected by frame rate. Because we're taking a step every frame, the speed of our frames directly influences the speed of our cursor trail.

As an exercise, how would you make the animation of the cursor trail frame-independent? Feel free to look to the Frame vs Time tutorial for inspiration. There's also an excellent talk by Freya Holmer about this exact topic: