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
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.
- Frame rate independent lerp?
- How to actually create frame independant lerping!
- FPS independent lerp?
- Lerp problems on low framerate
- Part movement noise with high framerates
- How to make RenderStepped Independent of Frame Rate?
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!!!
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
LerpHelper ModuleScript
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
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
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
LerpHelper.rbxl (70.8 KB)