Creating Framerate-Independent/Discretized Iterative Lerp + Slerp

Before

Lerp factor = 0.05


After

Lerp demo (0.05 factor, varying FPS):

Slerp demo (0.05 factor, varying + noisy FPS):


Problem

Doing interpolation per frame/heartbeat/loop is a common technique for smooth movement for player movement, cameras, etc. For example, you’re doing a custom physics engine and you want the player to slow down at a point.

for i = 1,100 do
	part.Position = part.Position * 0.75 + newPosition * 0.25 
	task.wait()
end
local targetVelocity = Vector3.new()
local currentVelocity = Vector3.new(10,0,0)
game["Run Service"].Stepped:Connect(function(dt)
	currentVelocity = currentVelocity * 0.75 + targetVelocity * 0.25 
	part.Position += currentVelocity * dt
end)

The current issue is that most solutions assume that the step rate is fixed, meaning that Heartbeat/FPS stays perfectly 60 ticks per second during the whole gameplay. This is not true in real games.

This ESPECIALLY does not hold true when hooked to RenderStepped, where user framerate makes it hard to have consistent effects. For example, simulating swimming in water slowing you down or air drag using Lerp, and simulating the camera rotation with Slerp.

The inconsistent FPS behavior (especially when linked to movement) gives certain players advantages.


Related Work :pen:

Show

There have been plenty of responses regarding this issue. But it’s only been for Lerping and not other interpolation techniques such as Slerping. They are also sometimes inaccurate.

There is also a solution given by @EmesOfficial after noting from a game development post:

I have also seen solutions that attempt to call the function multiple times per frame to allow for higher FPS to still function. Or implement the opposite where it’s always computing at 60 FPS regardless of framerate. These methods incur additional computation costs or look jittery because of reduced framerate calculations.

The proposed method will only need to be computed once per frame. I will contribute to this problem by extending it to Slerping and providing a demo place file + library to manage it.


Boring Math!!! :nerd_face:

Show

Essentially, the iterative lerp algorithm is just code running the same calculations repeatedly.

let a = 1 <- initial value
let b = 10 <- target value
let p = 0.25 <- original lerping factor

This is the generic lerping function:

F(x) = x*(1-p) + b*p

Passing each value gives the next value

F(a) = A0 <- first lerp iteration
F(F(a)) = F(A0) = A1 <- second lerp iteration
F(F(F(a))) = F(A1) = A2 <- third lerp iteration
F(F(F(F(a)))) = F(A2) = A3 <- fourth lerp iteration

This is nice, we are stepping forward by 1 every iteration.
What if we want to pass crazy numbers such as 0.5, 2, or 0.15815?
What does it mean to go half an iteration? Double an iteration? 0.15815 of an iteration?
We want to turn this into a nicer continuous function.

G(x) where:
G(0) = a
G(1) = F(a)
G(2) = F(F(a))
G(3) = F(F(F(a)))
G(4) = F(F(F(F(a))))

G(0.5) = ???
G(0.15815) = ???

If it’s a continuous function, we can put in weird non integers and get values back that make sense!
Interestingly, the function to represent this iterative chain exists as an exponential function!

G(x) = p^(x)

If we flip this function around, we can set it up in a where G(0) is 0 and G(infinity) = 1; we can find the exponent that matches the exact discrete step above!

G(x) = 1 - p^(1-x)


You can see the green line in the graph perfectly line up with the purple/black/red/blue points!

Why is this useful? This lets us put in crazy numbers like 0.5 and 2 as steps and compute the adjusted percentage p that respects time! Essentially, 0.5 step combined with p will produce a new p that lerps correctly! This is known as discretization!

Instead of doing this:

F(F(F(F(a)))) = F(A2) = A3

We can now do:

p1 = G(4)
A3 = a * (1-p1) + b*p1

We just went from a to A3 without chaining the same interpolation!

Since this new function is continous, we can put in weird numbers such as 0.5 and get something out!

p1 = G(0.5)
A_05 = a * (1-p1) + b*p1

An interesting property is that the stepping spacing is still preserved!
If we stepped by 0.5, then stepped again by 0.5, we would have the same value as stepping 1 whole step!

p1 = G(0.5)
A_05 = a * (1-p1) + b*p1
A_1 = A_05 * (1-p1) + b*p1

This is equivalent to:

p1 = G(1)
A_1 = A_05 * (1-p1) + b*p1

OR

F(a)

This mechanism allows us to do variable stepping, which is common in framerate related lerping.
The additive property lets us reach the same goal at the same time even when framerates are different!

*Additional note! This can be generalized to other interpolation algorithms that use an interpolation factor! You can use this for CFrames:Lerp(), Vector3:Lerp(), etc.

Thanks for reading
:nerd_face:


LerpHelper ModuleScript :scroll:

Show
--!strict
--[[
	This library was created by treebee63 
	Last modified: February 7th 2025
--]]

local module = {}

function module:DiscretizeStep(percent: number, step: number)
	return 1 - math.pow(1 - percent, step)
end

function module:Lerp(a: number, b: number, percent: number)
	return a * (1 - percent) + b * percent
end

function module:ContinuousLerp(a: number, b: number, percent: number, step: number)
	local newPercent = module:DiscretizeStep(percent, step)
	return module:Lerp(a, b, newPercent)
end

function module:SphericalLerp(a: CFrame, b: CFrame, percent: number)
	local relative = a:ToObjectSpace(b)
	local axis, angle = relative:ToAxisAngle()
	local newAngle = angle * percent
	local interpolated = CFrame.fromAxisAngle(axis, newAngle)

	return a * interpolated
end

function module:ContinuousSphericalLerp(a: CFrame, b: CFrame, percent: number, step: number)
	local newPercent = module:DiscretizeStep(percent, step)
	return module:SphericalLerp(a, b, newPercent)
end

return module

Lerp Demo Code :scroll:

Show

local LERP = require(game.ReplicatedStorage.LerpHelper) -- this is the LerpHelper ModuleScript above
local model = script.Parent

local framerate = 5

model.MovingPiece.Position = model.Start.Position
task.wait(2)

local dt = 0
for i = 1,100 do
	local timeOffset = ((math.random() - 0.5) * 2) * 0.1 -- we simulate an inconsistent FPS [-0.1, 0.1)
	dt = task.wait(1 / framerate + timeOffset)
	model.MovingPiece.Position = LERP:ContinuousLerp(model.MovingPiece.Position, model.End.Position, 0.05, 60 * dt) -- 60 here means that we are doing 60 steps per second, which is the 60 fps rate. always leave this at 60 unless you want to experiment!
end

Slerp Demo Code :scroll:

Show

local LERP = require(game.ReplicatedStorage.LerpHelper) -- this is the LerpHelper ModuleScript above
local model = script.Parent

local framerate = 5

model.MovingPiece.Position = model.Start.Position
task.wait(2)

-- in my demo, I used a spinning ball for spherical lerp
local targetCFrame = CFrame.new(model.Center.Position, model.End.Position)
local currentCFrame = CFrame.new(model.Center.Position, model.Start.Position)
model.MovingPiece.Position = (currentCFrame * CFrame.new(0,0,-8)).Position -- the ball itself is only used to denote orientation, the actual slerping occurs with the cframe stored

local dt = 0
for i = 1,100 do
	local timeOffset = ((math.random() - 0.5) * 2) * 0.1 -- we simulate an inconsistent FPS [-0.1, 0.1)
	dt = task.wait(1 / framerate + timeOffset)
	
	currentCFrame = LERP:ContinuousSphericalLerp(currentCFrame, targetCFrame, 0.05, 60 * dt) -- 60 here means that we are doing 60 steps per second, which is the 60 fps rate. always leave this at 60 unless you want to experiment!
	model.MovingPiece.Position = (currentCFrame * CFrame.new(0,0,-8)).Position
end

Demo Place :house:

LerpHelper.rbxl (70.8 KB)

11 Likes

or instead you could just do this:

local start = os.clock()
while true do
    local timeElapsed = (os.clock() - start)
    local progress = math.min((timeElapsed / duration), 1)
    model.MovingPiece.Position = model.MovingPiece.Position:Lerp(model.End.Position, progress)

    if progress == 1 then break end
    heartbeat:Wait()
end

and now you’re not relying on a set amount of iterations or unexpected step count, this still yields the thread just like what you have above but now the behavior is explicit and directly related to time rather than iterations and the related.

You can also easily add a frame-rate limiter here as well for no extra charge. Simply by checking if we’ve passed enough time since the last update.

1 Like

The method proposed is an approximation that guarantees MovingPiece will reach the target by the duration. I want to mention that it is inaccurate when we want fixed lerping and values to be consistent. I think it could work for certain use cases where accuracy doesn’t matter and you only care about position lerping.

I should clarify that the primary goal of using framerate-independent lerping is to make sure laggy players reach the same destination at the same time as other players with different framerates. The value itself does not need to reach the target. In a real-world use case, the target will actually be constantly moving! Think about tracking a moving object or simulating deceleration in user input.

The set amount of iterations is simply used for demonstration purposes. In a real game, we’d use .RenderStepped or an event medium to control lerping.


Suppose you’re making a racing game where it takes time to reach top speed and time to slow down. A simple way to do it without acceleration is to repeatedly lerp up to speed, then lerp down to 0 speed. This now involves integration of speed to create position.

I implemented your algorithm in purple. Blue is the framerate-independent method. Yellow is the original lerping. For 120 FPS, I had to simulate time stepping without the use of task.wait() or heartbeat just to make it more accurate.
image

You can see that yellow at 5 FPS struggles to reach the end before the other framerates, and purple accelerates/decelerates inaccurately (in fact, you have a clear ADVANTAGE from having higher FPS). You can see that blue’s distances are more consistent and also allows all FPS settings to reach the goal at the same time.*

*Corrrection posted here


Discretized Code
local LERP = require(game.ReplicatedStorage.LerpHelper)
local model = script.Parent

local framerate = 5

model.MovingPiece.Position = model.Start.Position
task.wait(2)

local targetSpeed = Vector3.new()
local currentSpeed = Vector3.new()
local function speedOn()
	targetSpeed = Vector3.new(0,0,3)
end
local function speedOff()
	targetSpeed = Vector3.new()
end

local start = os.clock()
local timeElapsed = os.clock() - start
local dt = 0
while timeElapsed < 15 do
	dt = task.wait(1/framerate)
	timeElapsed = os.clock() - start
	
	if timeElapsed > 3 then
		speedOff()
	else
		speedOn()
	end
	
	currentSpeed = LERP:ContinuousLerp(currentSpeed, targetSpeed, 0.05, dt * 60)
	model.MovingPiece.Position += currentSpeed * dt
	model.MovingPiece.BillboardGui.TextLabel.Text = math.round((model.MovingPiece.Position - model.Start.Position).Magnitude*100)/100
end

Approximation Code
local LERP = require(game.ReplicatedStorage.LerpHelper)
local model = script.Parent

local framerate = 5

model.MovingPiece.Position = model.Start.Position
task.wait(2)

local targetSpeed = Vector3.new()
local currentSpeed = Vector3.new()
local function speedOn()
	targetSpeed = Vector3.new(0,0,3)
end
local function speedOff()
	targetSpeed = Vector3.new()
end

local start = os.clock()
local timeElapsed = os.clock() - start
local dt = 0
local duration = 15
while timeElapsed < 15 do
	dt = task.wait(1/framerate)
	timeElapsed = os.clock() - start
	local progress = math.min((timeElapsed / duration), 1)
	
	if timeElapsed > 3 then
		speedOff()
	else
		speedOn()
	end

	currentSpeed = currentSpeed * (1 - progress) + targetSpeed * progress
	model.MovingPiece.Position += currentSpeed * dt
	model.MovingPiece.BillboardGui.TextLabel.Text = math.round((model.MovingPiece.Position - model.Start.Position).Magnitude*100)/100
	
	if progress == 1 then break end
end

I updated the place here:
LerpHelper.rbxl (70.8 KB)

1 Like

Great Job, a lot of people will definitely find this interesting!

1 Like

I would like to post a correction to this.

My implementation of your code was not correct. I did not specify the duration correctly. I’ve updated it to do 3 seconds of acceleration/deceleration instead of 3 acceleration and 12 deceleration.

If you pause the video, you can see the difference is better compared to the naive lerping method, but still worse than the discretized method.

I think your method lets people specify the duration and it still does better than the naive method.
The only issue is that it doesn’t give direct control of the damping factor and it still suffers from the FPS difference (I understand frame limiters are a thing but you’re essentially still taking multiple big fixed steps to self correct).

Your algorithm happens to really align with 0.05 damping in the video above. In this video I used 0.01 for velocity damping to show that it’s doesn’t replace the original naive method because damping factor is not configurable. I tested a race where each point goes at 3 speed, then 7 speed, then 0 speed.

Place file

LerpHelper.rbxl (70.8 KB)