Short form guide to solving physics-based spring jitter!

Hey guys, seen a lot of confusion around camera springs as it relates to physics jitter, just want to clear up this issue.

The main problem:

This is an infographic of the roblox render pipeline, most configurations of camera springs use .RenderStepped which updates earlier than physics, creating a jitter effect you can see in this video: unknown_2021.11.21-17.41

The fix:

If you update camera movement on .Stepped instead, it will be in sync with the physics updates.
(don’t worry if you don’t understand the math jargon which I borrowed from several kind and smart robloxians mentioned below)
Hope this helps!

	modified by SaltyPotter to include orientation	
	borrowed math and ideas from: @fractality, @Validark, @Quenty, @XAXA, @EgoMoose
	use cases:
		Camera following anything physics-based (without jitter issues):
			---- I'm using dictionaries to send CFrame information ----
			local position={RightVector=startCF.RightVector, UpVector=startCF.UpVector, Position=startCF.Position}
			local velocity={,,}
			local goal={RightVector=goalCF.RightVector, UpVector=goalCF.UpVector, Position=goalCF.Position}
			local, velocity, goal)
			---- you can adjust the frequency and dampness to your liking, i found a 2:1 profile is a nice smooth effect ----
			runservice.Stepped:Connect(function(t, dt)
				---- update the goal every step ----
				spring.goal={RightVector=goalCF.RightVector, UpVector=goalCF.UpVector, Position=goalCF.Position}
				---- call :update(), it returns a CFrame ----
			same as above, but you can use .RenderStepped since there's no physics involved

local spring={}

local tau = math.pi * 2
local exp = math.exp
local sin = math.sin
local cos = math.cos
local sqrt = math.sqrt

local EPSILON = 1e-4

	return spring

function spring:adjust(key:string,dt:number)
	local dampingRatio = self.dampener
	local angularFrequency = self.frequency * tau
	local goal = self.goal[key]
	local p0 = self.position[key]
	local v0 = self.velocity[key]

	local offset = p0 - goal
	local decay = exp(-dampingRatio * angularFrequency * dt)
	local position

	if dampingRatio == 1 then -- Critically damped
		position = (offset * (1 + angularFrequency * dt) + v0 * dt) * decay + goal
		self.velocity[key] = (v0 * (1 - angularFrequency * dt) - offset * (angularFrequency * angularFrequency * dt)) * decay
	elseif dampingRatio < 1 then -- Underdamped
		local e = 1 - dampingRatio * dampingRatio
		local c = sqrt(e)
		local y = angularFrequency * c
		local i = cos(y * dt)
		local j = sin(y * dt)

		-- Damping ratios approaching 1 can cause division by small numbers.
		-- To fix that, group terms around z=j/c and find an approximation for z.
		-- Start with the definition of z:
		--    z = sin(dt*angularFrequency*c)/c
		-- Substitute a=dt*angularFrequency:
		--    z = sin(a*c)/c
		-- Take the Maclaurin expansion of z with respect to c:
		--    z = a - (a^3*c^2)/6 + (a^5*c^4)/120 + O(c^6)
		--    z ≈ a - (a^3*c^2)/6 + (a^5*c^4)/120
		-- Rewrite in Horner form:
		--    z ≈ a + ((a*a)*(c*c)*(c*c)/20 - c*c)*(a*a*a)/6

		local z
		if c > EPSILON then
			z = j / c
			local a = dt * angularFrequency
			local a_2 = a * a
			z = a * (((e*e * a_2 - 20*e) / 120) * a_2 + 1)

		-- Frequencies approaching 0 present a similar problem.
		-- We want an approximation for y as angularFrequency approaches 0, where:
		--    y = sin(dt*angularFrequency*c)/(angularFrequency*c)
		-- Substitute b=dt*c:
		--    y = sin(b*c)/b
		-- Now reapply the process from z.

		if y > EPSILON then
			y = j / y
			local b = y * y
			local dd = dt * dt
			y = dt * (dd * (b*b*dd / 20 - b) / 6 + 1)

		local ze = z * dampingRatio
		position = (offset * (i + ze) + v0 * y) * decay + goal
		self.velocity[key] = (v0 * (i - ze) - offset * (z * angularFrequency)) * decay
	else -- Overdamped
		local x = -angularFrequency * dampingRatio
		local y = angularFrequency * sqrt(dampingRatio * dampingRatio - 1)
		local r1 = x + y
		local r2 = x - y

		local co2 = (v0 - offset * r1) / (2 * y)
		local co1 = offset - co2

		local e1 = co1 * exp(r1 * dt)
		local e2 = co2 * exp(r2 * dt)

		position = e1 + e2 + goal
		self.velocity[key] = e1 * r1 + e2 * r2

	self.position[key] = position

function spring:update(dt:number)
	local t={}
	for key,_ in self.goal do 
	return CFrame.fromMatrix(t.Position,t.RightVector,t.UpVector):Orthonormalize()

return spring

Can you also provide a video that compares the common implementation versus the fix to the jitter?

1 Like

Sure, so commonly it’s a critically damped spring but the issue is with using RenderStepped dt or heartbeat dt. (post was meant to be more of a copy paste fix, didn’t want to go too in depth or post a lot of vids)

but here’s some test vids (i’m using gyazo which is running at 30fps so might be kinda hard to tell but i’ll point out the issues)

Heartbeat test (character jitters)

RenderStepped test (part jitters)

Stepped test (neither jitter)


literally needed this yesterday I even made my own post! Thanks for this because I’ve seen a lot of people asking about this.

Btw if anyone still has jitter problems after using stepped make sure adaptive time stepping is off that seems to make it jitter.

1 Like

There’s… zero explanation here. “The problem is springs using RenderStepped” and the fix is just a giant code block. What? That’s not a real solution. Could you explain exactly, in the OP, what the fix is to the problem and how it fixes it instead of just dumping code - or if the explanation is somewhere in the OP, could you make it more clear?


So there’s no code explanation needed here, it’s just a switch of events. The critically damped spring modules are open-sourced people can figure that out on their own, not going to go in-depth explaining Hooke’s law here, this was to be a short form guide on the issue and a copy-paste solution. Just wanted to help others. Hope I did.

I think what he was asking is why does updating the spring before physics but after rendering remove the jittering, not all the nitty gritty mathematics.


I’m sorry if it was confusing, I intended for it to be fast and easy for everyone, I edited the OP to include the basic idea. :+1:

This solved the issue for me!, great information.