Springs

This article covers the concepts required to create scriptable spring-like motion which has uses in things such as animation or with the use of dynamic targets. It should not be confused with the SpringConstraint object which serves a similar purpose but has some differences.

Prerequisites

  • Algebra
  • Vector math
  • Familiarity with trigonometric functions

Hooke's law[edit]

Hooke's law is a simple equation that can be used to create spring motion between a target and a free moving point. It is a great place to start when learning about springs because it's a relatively simple concept and it works well for the most part. However, Hooke's law is often regarded as a black-sheep in game development because it can be difficult to tune to get the exact spring motion we desire. Once we've mastered the basics of Hooke's law we'll then move onto a more mathematical approach for a spring which will make our spring much easier to adjust.

Hooke’s law is used to calculate the force that would need to be applied on an object attached to a spring in order to get its position, after some number of iterations, to return to the point where the spring is fully compressed and unmoving at the target. The formula is simple and is as follows:

F = kX

Where:
F = force
k = constant
X = distance between the start and end of the spring

Something important worth mentioning is that the variable X is not limited to being a scalar value, rather it can also represent the vector between the start and end of the spring. This makes it possible for Hooke's law to return a force that also has a direction which is important when using Hooke's law to make a spring in two or three dimensions (via Vector2 or Vector3).

F can be thought of as being similar to acceleration. This is really important because it means that it’s the rate of change of velocity which is the speed of something in a given direction. This means that every time we calculate F we have to add it to the current velocity of the object attached to the spring.

k is a bit more difficult to explain. k can be thought of as similar to speed in some sense, but perhaps a better definition might actually be the intensity at which the spring tries to attain its goal. This might seem like a weird way to explain something but hopefully the visuals below will show why.

Let’s work off of an example. Let’s say k is equal to 0.3, we’re starting from a velocity of <0, 0, 0>, and we have the current position of the thing attached to the spring and the target (where we want the spring to be in rest).

ScriptSpring1.png

We'll start by sketching our first iteration.

ScriptSpring2.png

When we calculate our F value it returns a vector which we can treat as acceleration, which is the rate of change of velocity, which as mentioned before means we add it to our current velocity. We also know that velocity is the rate of change of position and therefore to get our new position we add our current velocity to the current position. This is shown as moving our position to the new position! Tada! We’re an iteration closer to reaching our target.

Let’s now check our second iteration:

ScriptSpring3.png

Once again we repeat the same process except this time our X value has changed and we now no longer have an empty current velocity vector. This means two things. For one, our F value's magnitude now smaller than the previous F value's magnitude meaning our acceleration is slowing down. And secondly, we're actually speeding up. These two statements might seem like opposites but all we're really saying is that we're still going faster, but at a slower pace than we were previously.

It might also seem that we've almost reached our target and the spring is about to reach rest. This isn't really the case though because we still have a lot of velocity. All the current situation says is that the force we're going to get returned is going to be tiny, we still add that to the current velocity and then add that velocity to the current position. In reality we're actually going to flying past the target position!

Now here's where the spring really starts acting like a spring. With a new X value now representing the opposite direction we're now getting an F value that represents acceleration in the opposite direction. When we add this F value to the current velocity it starts to slow down and after a couple of iterations even starts to once again travel towards the target again. This process of bouncing around can happen many times but eventually the position will converge with the target.

Some future iteration:

ScriptSpring4.png

This by itself would seem like it would work, but it was thought up of for use the real world, not for computers. As such it doesn’t account for things such as friction, drag, etc. As such we usually add in an extra constant to add in this effect. With all that in mind we now know enough about springs to make a very basic one!

ScriptSpringGif1.gif

-- spring class
 
local spring = {}
 
function spring.new(position, velocity, target)
	local self = setmetatable({}, {__index = spring})
 
	self.position = position
	self.velocity = velocity
	self.target = target
	self.k = 1
	self.d = 1 -- friction constant
 
	return self
end
 
function spring:update()
	local x = self.target - self.position
	local f = x * self.k
	self.velocity = (self.velocity * (1 - self.d)) + f
	self.position = self.position + self.velocity
end
 
-- using the spring
 
local part = Instance.new("Part")
part.Anchored = true
part.BrickColor = BrickColor.Green()
part.Parent = game.Workspace
 
local mouse = game.Players.LocalPlayer:GetMouse()
mouse.TargetFilter = part
 
-- creating the spring
local springy = spring.new(part.CFrame.p, Vector3.new(), mouse.Hit.p)
 
-- tuning the spring
springy.k = 0.05
springy.d = 0.1
 
-- update the spring every frame (do an iteration)
game:GetService("RunService").RenderStepped:connect(function()
	springy.target = mouse.Hit.p
	springy:update()
	-- set the cframe to the spring's position
	part.CFrame = CFrame.new(springy.position)
end)

Expanding Hooke's law[edit]

so we’ve now got the basics of Hooke’s law down. That means it’s time to flesh out some of its hidden secrets with a little imagination and some simple math.

To start off, let’s try to answer the question, what if the rest position of the spring was simply defined as a length offset from the target as opposed to the target position specifically? There’s no specific direction, simply a distance. This means our real target could be anywhere on the surface of a sphere with some given radius.

ScriptSpring5.png

Turns out that this is actually pretty easy to do. We already have the direction in the form X, so all we have to do then is get its distance between the two points is, normalize the vector, subtract the radius from the initial length, and then multiply the normalized vector by the new length we calculated. This moves our “real” target to somewhere around the edge of the sphere!

ScriptSpringGif2.gif

local spring = {}
 
function spring.new(position, velocity, target)
	local self = setmetatable({}, {__index = spring})
 
	self.position = position
	self.velocity = velocity
	self.target = target
	self.k = 1
	self.d = 1
 
	self.dist = 5 -- The radius from the target
 
	return self
end
 
function isNan(v)
	return tostring(v) == "NAN, NAN, NAN"
end
 
function spring:update()
	local x = (self.target - self.position)
	x = x.unit * (x.magnitude - self.dist)
	if not isNan(x) then -- bit of error handling
		local f = x * self.k
		self.velocity = (self.velocity * (1 - self.d)) + f
		self.position = self.position + self.velocity
	end
end


Now, this might seem a bit arbitrary, after all the spring could literally end up anywhere around the edge of the sphere. This tends to look a bit odd because in real life springs tend to have some gravitational force applied to them meaning the free point would tend to converge to the bottom of the sphere like it's hanging. Once again, this is pretty easy to apply to our spring because all we have to do is add in a downward force to our velocity. Just adding any old downward force would work, but it’s not exactly accurate as the force we apply might affect the radius of our target. So now we can use our masterful knowledge of physics to come up with a method that allows our spring to gravitate towards a position!

Since we’re working in the realm of forces we know that we can use Newton’s law of gravitation to calculate the forced needed to get to the point we want it to. The law is as follows:

ScriptSpring6.png

For simplicity sake we’ll assume G*m1*m2 = 1. However, you’re of course free to fiddle around with these values until you get something that works for you.

The only problem is that this formula is for a scalar value. So to bring it over to a vector form we’re going to get the vector between our current position and our gravitational target, normalize it, and then multiply it by the force scalar. When all is said and done we’ll be left with a way to gravitate towards a target without changing our radius distance!

ScriptSpringGif3.gif

local spring = {}
 
function spring.new(position, velocity, target)
	local self = setmetatable({}, {__index = spring})
 
	self.position = position
	self.velocity = velocity
	self.target = target
	self.k = 1
	self.d = 1
 
	self.dist = 5
	self.mass = 1 -- G * m1 * m2
 
	return self
end
 
function spring:gravTo(p)
	local v = (self.position - p)
	local dist = v.magnitude
	-- multiply the direction by the scalar calculated
	local grav = v.unit * (self.mass/dist^2)
	return grav
end
 
function isNan(v)
	return tostring(v) == "NAN, NAN, NAN"
end
 
function spring:update()
	local x = (self.target - self.position)
	x = x.unit * (x.magnitude - self.dist)
	if not isNan(x) then -- bit of error handling
		local f = x * self.k
		-- add the grav velocity
		self.velocity = self:gravTo(self.target + Vector3.new(0, self.dist, 0)) + (self.velocity * (1 - self.d)) + f
		self.position = self.position + self.velocity
	end
end


The last thing we’re going to try doing regarding Hooke’s law is to try to create a spring between two moving targets. This is actually easier to do than you might think because we already talked about offsets. If we set the target of a spring to an offset of another moving part then we'll get a spring motion that always tries to converge to that offset. If we have two springs and continuously set their targets to each others free moving position with a offset we'll get an effect that looks a like a spring connecting two moving targets.

ScriptSpringGif4.gif

local part1 = Instance.new("Part")
part1.Anchored = true
part1.BrickColor = BrickColor.Green()
part1.Parent = game.Workspace
 
local part2 = Instance.new("Part")
part2.Anchored = true
part2.BrickColor = BrickColor.Blue()
part2.Parent = game.Workspace
 
-- two seperate springs
local spring1 = spring.new(part1.CFrame.p, Vector3.new(), Vector3.new())
local spring2 = spring.new(part2.CFrame.p, Vector3.new(), Vector3.new())
 
spring1.k = 0.05
spring1.d = 0.2
spring1.dist = 0
 
spring2.k = 0.05
spring2.d = 0.2
spring2.dist = 0
 
local offset = -5 -- the distance between the two
 
game:GetService("RunService").RenderStepped:connect(function()
	-- get the offset vector
	local v1 = (spring2.position - spring1.position).unit * offset
	local v2 = (spring1.position - spring2.position).unit * offset
 
	-- set the target
	spring1.target = spring2.position + v1
	spring2.target = spring1.position + v2
 
	-- update
	spring1:update()
	spring2:update()
 
	part1.CFrame = CFrame.new(spring1.position)
	part2.CFrame = CFrame.new(spring2.position)
end)

A more mathematical approach: Decaying waves[edit]

Hooke’s law is great and all, but it most certainly has flaws. It can be used to create a spring that’s pretty dynamic, but it lacks easy to tune variables and it can’t be used as an easing style making it difficult to work with in animation. Our next step is to now clear the drawing board and start anew.

To start, let’s lay down what our goal is. We by all means want the same type of motion that Hooke’s law provides, but we want to be able to get any point of motion as a function of time. So let’s take our first steps by actually graphing the distance from the target as time passes with Hooke’s law. That way we can get an idea of what our new function should look like.

ScriptSpringGif5.gif

From this graph we can see a general trend. As time passes waves are created and those waves eventually decay until the it simply converges at a fixed distance, zero.

So that’s a start! We have an image that shows how our distance from the target changes over time. We can still assume that we’ll be able to handle the direction through vectors so if we’re able to replicate this decaying wave we’ll have a more mathematically based spring!

Let’s first start by figuring out how to mathematically represent a wave. One simple way is to use cosine or sine so let’s try that:

ScriptSpring7.png

That’s not a bad start, but in our current form the time it takes to complete a full wave (the time it takes to get from one maximum to the next) is pretty irrational (2π to be exact). So our next goal is going to be to try and get the full time it takes to complete a wave down to one second. Turns out this is pretty easy since we know in radians a full circle is 2π so we just apply that and bam! 1 second waves!

ScriptSpring8.png

Let’s now bring our focus to decaying the wave. To start let’s see what happens when we multiply our previous function by different numbers. Let’s try 0.5 and 2:

ScriptSpring9.png

As we can hopefully see, we’re directly controlling the height of our waves by multiplying our wave by some constant. That’s a good start, but we aren’t quite there yet, we ideally want to multiply our wave by a diminishing value that starts at one and converges to zero.

Luckily we hopefully know an easy way to do this is just to take some base to a negative number:

ScriptSpring10.png

Now one final thing, we could use a base of 2 like in the example above, but instead let's use Euler’s number. The number actually pops up in nature quite a lot (to be honest it’s kind of eerie) and as such gives us an exponential equation that has a relation to nature in some sense or another. Keep in mind you can realistically use whatever base you want, but for the purpose of this explanation e it shall be!

Alright, so this leaves us with:

ScriptSpring11.png

We’ve now got ourselves a replication of the graph we got earlier with Hooke’s law. Now let’s see what we can add to make this equation something we can easily manipulate for all our spring needs.

Let’s first try multiplying our 2πx by some variable ω (omega):

Pretty neat, as we increase ω we can see we don’t change how long it takes to get to the end of our spring, but we do change how many waves there are between the start and finish.

Next let’s see what happens when we add a variable, k, to the exponential part of our equation:

As you can see k somewhat changes how fast the cosine waves decay. With this equation you would have yourself a pretty dope, but basic spring.

ScriptSpringGif6.gif

local spring = {}
local e = 2.7182818284590452353602875 -- euler's number
 
function spring.new(initial, target)
	local self = setmetatable({}, {__index = spring})
 
	self.initial = initial
	self.position = initial
	self.target = target
 
	self.t = tick() -- start time
	self.w = 1
	self.k = 1
 
	return self
end
 
function spring:update()
	local x = tick() - self.t
	-- vector away from target b/c percent goes from 1 to 0
	local v = (self.initial - self.target)
	local percent = e^(-self.k*x) * math.cos(2*math.pi*x*self.w)
	self.position = self.target + (v * percent)
end


Linear damping[edit]

There’s actually a little more we can add to the spring through a process called linear damping in which Wikipedia provides us a few useful equations on the topic:

ScriptSpring12.png

Let’s see what the differences are from where we left off. To start we no longer have a k value, rather it was replaced by -ζ2πω. In addition, we now have a wave that’s created by both cosine and sine together and has the newly added prefix of √(1-ζ^2 ). Aside from A and B what can we already say about this new function? Well, as far as variables go, ω now acts as value that’s pretty much directly correlated to the time it takes to complete the entire spring. The higher ω is the faster the spring reaches its target and vice versa for a low ω. ζ (zeta) on the other hand is controls how much the spring oscillates. The lower ζ is the most oscillations there are, the higher the value the less there are.

ScriptSpring13.png

These are already some pretty decent advancements, compared to our previous simple equation. However, we still have a bit more to add in the form of A and B. The A value is simple enough, it’s just the y-position we start at. The thing that gets a bit more confusing is B. Not only are we dealing with the initial position (f(0)), but we’re also dealing with the initial velocity (f'(0), which is the first derivative of f(x)). Adding both these in mainly allow us to set initial velocity which is useful if your spring doesn't start from rest position.

With all that in mind we can write out our full equation then:

ScriptSpring14.png

Awesome we’ve now got our final spring equation! It’s pretty easy to apply in game, works well with animations, and is a lot easier to tune!

ScriptSpringGif7.gif

local spring = {}
local e = 2.7182818284590452353602875 -- euler's number
 
function spring.new(initial, velocity, target)
	local self = setmetatable({}, {__index = spring})
 
	self.initial = initial
	self.position = initial
	self.velocity = velocity
	self.target = target
 
	self.t = tick() -- start time
	self.w = 1
	self.z = 0.2
 
	return self
end
 
function spring:update()
	local x = tick() - self.t
	local v = (self.initial - self.target)
	local nv = math.pow(e, -2 * math.pi * self.w * self.z * x) 
			* (v * math.cos(math.sqrt(1 - math.pow(self.z, 2)) * 2 * math.pi * self.w * x)
			+ ((self.z * self.w * v + self.velocity) / (self.w * math.sqrt(1 - math.pow(self.z, 2))))
			* math.sin(math.sqrt(1 - math.pow(self.z, 2)) * 2 * math.pi * self.w * x))
	self.position = self.target + nv
end

Further reading[edit]

To see more in regards to how the linear damping equation is found check out this awesome article!