Update spring solver

This commit is contained in:
Aaron Smith 2023-08-24 18:09:40 +01:00
parent fdc72a17f0
commit 8ef2d0b190
5 changed files with 280 additions and 120 deletions

View file

@ -1,4 +1,5 @@
if not game then script = require "test/relative-string" end
local Vector3 = game and Vector3 or require "test/mock".Vector3 :: never
--[[
@ -10,88 +11,122 @@ Supported datatypes:
- UDim2
- Vector2
- Vector3
- Rect
Unsupported datatypes:
- bool
- Rect
- Vector2int16
- Vector3int16
- EnumItem
]]
local throw = require(script.Parent.throw)
local graph = require(script.Parent.graph)
type Node<T> = graph.Node<T>
local create = graph.create
local get = graph.get
local set = graph.set
local set_effect = graph.set_effect
local capture = graph.capture
local TOLERANCE = 0.0001
local UPDATE_RATE = 120
local TOLERANCE = 0.00001
type Vec3 = Vector3
local function Vec3(x: number?, y: number?, z: number?)
return Vector3.new(x, y, z)
end
type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3
type SpringData<T> = {
time: number,
period: number,
damping_ratio: number,
k: number, -- spring constant
c: number, -- damping coeff (from k)
alpha: number,
velocity: number,
initial_velocity: number,
initial_position: T,
target_position: T,
-- dimensions 1-3
x0_123: Vec3,
x1_123: Vec3,
v_123: Vec3,
target_updated: boolean,
target: () -> T
-- dimensions 4-6
x0_456: Vec3,
x1_456: Vec3,
v_456: Vec3,
source_value: T
}
type Lerp<T> = (initial: T, target: T, alpha: number) -> T
type TypeToVec6<T> = (T) -> (Vec3, Vec3)
type Vec6ToType<T> = (Vec3, Vec3) -> T
-- period, damping ratio, initial velocity, total time
local function solve(T: number, z: number, u: number, t: number): number -- alpha
local wn = 2*math.pi / T
local wd = wn * math.sqrt(1 - z^2)
local a = z * wn
local type_to_vec6 = {
number = function(v)
return Vec3(v, 0, 0), Vec3()
end :: TypeToVec6<number>,
local s = math.exp(-a*t) * math.cos(wd*t)
local v = (u/wn) * math.exp(-a*t) * math.sin(wn*t)
return (1-s) + v
end
CFrame = function(v)
-- todo: proper rotation tween
return v.Position, Vec3(v:ToEulerAnglesXYZ())
end :: TypeToVec6<CFrame>,
local lerpable: { [string]: Lerp<any> } = {
number = function(v1, v2, a)
return v1 + (v2 - v1)*a
end :: Lerp<number>,
Color3 = function(v)
-- todo: hsv
return Vec3(v.R, v.G, v.B), Vec3()
end :: TypeToVec6<Color3>,
CFrame = function(v1, v2, a)
return v1:Lerp(v2, a)
end :: Lerp<CFrame>,
UDim = function(v)
return Vec3(v.Scale, v.Offset, 0), Vec3()
end :: TypeToVec6<UDim>,
UDim2 = function(v)
return Vec3(v.X.Scale, v.X.Offset, v.Y.Scale), Vec3(v.Y.Offset, 0, 0)
end :: TypeToVec6<UDim2>,
Color3 = function(v1, v2, a)
return v1:Lerp(v2, a)
end :: Lerp<Color3>,
Vector2 = function(v)
return Vec3(v.X, v.Y, 0), Vec3()
end :: TypeToVec6<Vector2>,
UDim = function(v1, v2, a)
return UDim.new(
v1.Scale + (v2.Scale - v1.Scale)*a,
v1.Offset + (v2.Offset - v1.Offset)*a
)
end :: Lerp<UDim>,
Vector3 = function(v)
return v, Vec3()
end :: TypeToVec6<Vector3>,
UDim2 = function(v1, v2, a)
return v1:Lerp(v2, a)
end :: Lerp<UDim2>,
Rect = function(v)
return Vec3(v.Min.X, v.Min.Y, v.Max.X), Vec3(v.Max.Y, 0, 0)
end :: TypeToVec6<Rect>
}
Vector2 = function(v1, v2, a)
return v1:Lerp(v2, a)
end :: Lerp<Vector2>,
local vec6_to_type = {
number = function(a, b)
return a.X
end :: Vec6ToType<number>,
Vector3 = function(v1, v2, a)
return v1:Lerp(v2, a)
end :: Lerp<Vector3>,
CFrame = function(a, b)
return CFrame.new(a) * CFrame.fromEulerAnglesXYZ(b.X, b.Y, b.Z)
end :: Vec6ToType<CFrame>,
Color3 = function(v)
return Color3.new(v.X, v.Y, v.Z)
end :: Vec6ToType<Color3>,
UDim = function(v)
return UDim.new(v.X, v.Y)
end :: Vec6ToType<UDim>,
UDim2 = function(a, b)
return UDim2.new(a.X, a.Y, a.Z, b.X)
end :: Vec6ToType<UDim2>,
Vector2 = function(v)
return Vector2.new(v.X, v.Y)
end :: Vec6ToType<Vector2>,
Vector3 = function(v)
return v
end :: Vec6ToType<Vector3>,
Rect = function(a, b)
return Rect.new(a.X, a.Y, a.Z, b.X)
end :: Vec6ToType<Rect>
}
-- maps spring data to its corresponding output node
@ -99,33 +134,46 @@ local lerpable: { [string]: Lerp<any> } = {
local springs: { [SpringData<any>]: Node<any> } = {}
setmetatable(springs, { __mode = "vs" })
local function spring<T>(target: () -> T, period: number?, damping_ratio: number?): () -> T
if damping_ratio and damping_ratio > 1 then
throw "damping ratio cannot be greater than 1"
end
local inputs, initial_position = capture(target)
local function spring<T>(source: () -> T, period: number?, damping_ratio: number?): () -> T
local inputs, initial_position = capture(source)
local output, output_get = create(initial_position)
local source_value = source()
local vtype = typeof(source_value)
local x1_123, x1_456 = type_to_vec6[vtype](source_value)
-- https://en.wikipedia.org/wiki/Damping
-- todo: calculate damped freq at 10tau instead of natural freq
local w_n = 2*math.pi / (period or 1)
local z = damping_ratio or 1
local k = w_n^2
local c_c = 2*w_n
local c = z * c_c
local data: SpringData<T> = {
time = 0,
period = period or 1,
damping_ratio = damping_ratio or 1,
k = k,
c = c,
alpha = 0,
velocity = 0,
initial_velocity = 0,
initial_position = initial_position,
target_position = initial_position,
x0_123 = x1_123,
x1_123 = x1_123,
v_123 = Vec3(),
target_updated = false,
target = target
x0_456 = x1_456,
x1_456 = x1_456,
v_456 = Vec3(),
source_value = source_value,
_ = source -- prevent gc of source while data exists
}
-- reschedule spring for simulation on input update
local function input_updated(node)
data.target_updated = true
data.target_position = target()
local v = source()
data.x1_123, data.x1_456 = type_to_vec6[type(v)](v)
data.source_value = v
springs[data] = node
end
@ -139,60 +187,54 @@ local function spring<T>(target: () -> T, period: number?, damping_ratio: number
return output_get
end
-- `springs` is a hashmap, use array to queue indexes-to-remove to avoid
-- iterator invalidation of `springs`
local function step_springs(dt: number)
for data, output in next, springs do
local k, c, x0_123, x1_123, u_123, x0_456, x1_456, u_456 = data.k, data.c, data.x0_123, data.x1_123, data.v_123, data.x0_456, data.x1_456, data.v_456
-- calculate displacement from target
local dx_123 = x0_123 - x1_123
local dx_456 = x0_456 - x1_456
-- calculate spring force
local fs_123 = dx_123*-k
local fs_456 = dx_456*-k
-- calculate friction force
local ff_123 = u_123*-c
local ff_456 = u_456*-c
-- calculate acceleration step
local dv_123 = (fs_123 + ff_123)*dt
local dv_456 = (fs_456 + ff_456)*dt
-- apply acceleration step
local v_123 = u_123 + dv_123
local v_456 = u_456 + dv_456
-- calculate new position
local x_123 = x0_123 + v_123*dt
local x_456 = x0_456 + v_456*dt
data.x0_123, data.x0_456 = x_123, x_456
data.v_123, data.v_456 = v_123, v_456
end
end
local remove_queue = {}
local function update_springs(dt: number)
local function update_spring_sources()
for data, output in next, springs do
if data.target_updated then
data.target_updated = false
data.time = 0
data.alpha = 0
data.initial_velocity = data.velocity
data.initial_position = get(output)
data.target_position = data.target()
end
local x0_123, x1_123, v_123, x0_456, x1_456, v_456 = data.x0_123, data.x1_123, data.v_123, data.x0_456, data.x1_456, data.v_456
local dx_123, dx_456 = x0_123 - x1_123, x0_456 - x1_456
local initial_position = data.initial_position
local target_position = data.target_position
local target_type = typeof(target_position)
if target_type ~= typeof(initial_position) then
springs[data] = nil
warn(string.format(
"Mismatched state value types, cancelling state update (initial value: %s, target value: %s)",
typeof(initial_position),
target_type
))
throw(`cannot tween type { typeof(initial_position) } and { target_type }`)
continue
end
local lerp: Lerp<Animatable> = lerpable[target_type]
if lerp == nil then
springs[data] = nil
throw(`cannot animate type { target_type }`)
continue
end
local new_time = data.time + dt
local new_alpha = solve(data.period, data.damping_ratio, data.initial_velocity, new_time)
local new_velocity = -(new_alpha - data.alpha)/dt
data.time = new_time
data.velocity = new_velocity
data.alpha = new_alpha
local value = lerp(initial_position, target_position, new_alpha)
if math.abs(1 - new_alpha) < TOLERANCE and math.abs(new_velocity) < TOLERANCE then
-- todo: can this false positive?
if (v_123 + v_456 + dx_123 + dx_456).Magnitude < TOLERANCE then
-- close enough to target, unshedule spring and set value to target
table.insert(remove_queue, data)
set(output, target_position)
set(output, data.source_value)
else
set(output, value)
set(output, vec6_to_type[typeof(data.source_value)](x0_123, x0_456))
end
end
@ -203,4 +245,17 @@ local function update_springs(dt: number)
table.clear(remove_queue)
end
return function() return spring, update_springs end
return function()
local time_elapsed = 0
return spring, function(dt: number)
time_elapsed += dt
while time_elapsed > 1 / UPDATE_RATE do
time_elapsed -= 1 / UPDATE_RATE
step_springs(1 / UPDATE_RATE)
end
update_spring_sources()
end
end