clock

Log #5

Some Notes on Linear Interpolation

I've recently struggled a bit with how to properly use linear interpolation in one of my games. I needed to make a quick note of it, so that it can hopefully benefit me in the future when I need to refresh my memory.

My goal: I wanted to ease a position in the update loop of the game.

Rather than just immediately changing the position, I wanted to have the node animate or ease into the target position. I figured that the lerp method was a perfect tool for this, but I made the mistake of trusting various discussions around the web on how to use it without understanding the tradeoffs.

The debate that I'm seeing throughout my research boils down to: which argument of the lerp() function should be variable?

// Example Lerp Function
lerp(from, to, percentage)

One group states that the percentage argument should be variable, while the other group argues that the from argument should be variable.

Let's quickly break down these two approaches and try to understand why each party finds them favorable.

Using a Variable "from"

var target_position = Vector2(100, 100)

func _process():
    if (self.get_position().distance_to(self.target_position) < 0.2):
        return

    self.set_position(lerp(self.get_position(), self.target_position, 0.1))

This is nice and short, which is incredibly appealing. However, because the from value is changing, the returned value from lerp ends up just dividing the changing from value over-and-over again - sometimes never reaching the to value. Because of this, we would have to implement a "threshold" check as seen in the example above to know when the value is close enough to be deemed "complete".

In addition to the succinct amount of code, another benefit of this is that the continuously divided number does provide the easing result that we were looking for. While there are use cases that are perfectly serviceable using this, the inaccuracy of this personally bothers me.

Using a Variable "percentage"

var start_position = Vector2.ZERO
var target_position = Vector2(100, 100)

var frames = 0
var max_frames = 30
var ease_curve = 0.25

func _process(delta):
    var interpolation_ratio = float(self.frames) / float(self.max_frames)
    self.set_position(lerp(self.start_position, self.target_position, ease(interpolation_ratio, ease_curve)))
    self.frames = (self.frames + 1) % (self.max_frames + 1)

Compared to the previous example, this requires much more code. But, with this additional code we're given a lot more control and more importantly: accuracy. First, you'll notice that we have stored a start_position in a variable because we expect it not to change. We also have some additional variables for frames and max_frames which are used to calculate the percentage of the lerp function.

Inside of the function, we do some simple division to determine the value we will pass as the percentage argument in the lerp function. We wrap that with an ease function and give it a easing curve before passing it into the lerp function. With that return value, we can update the position and because we have used the ease function, we get the same easing functionality that the first example demonstrated except with a far more predictable and accurate result.

Finally, we update the frames value to reflect how many frames of the interpolation have completed so that we can accurately calculate the interpolation_ratio on each subsequent loop and also know when the interpolation has completed.

While this solution contains a lot more code (and math) it does feel like the far better solution to me. It's precise and we have enabled more flexibility by introducing the max_frames and ease_curve variables that we can use to tweak the easing.


So, in the end - does this really matter? Is one solution better than the other?

I think it's ultimately up to the specific usage of lerp and how important accuracy is in each implementation. In my case, my logic depended on the value reaching the to value precisely and consistently. This was only achievable with the percentage usage described above.

With that being said, I've seen the from usage all over the web - especially when dealing with KinematicBody player movement. It's an extremely common solution that serves most use cases, and that is difficult to argue with. The simplicity of the code and the "close enough" nature of it makes it an incredibly powerful tool.

Ultimately, it's all about understanding the tools we use, how to best wield them, and being mindful of the tradeoffs when using them unconventionally.

Logged on November, 21st 2021