From 8ef2d0b190e6d5190f8cf997fa9e52e651131102 Mon Sep 17 00:00:00 2001 From: Aaron Smith <83140718+centau@users.noreply.github.com> Date: Thu, 24 Aug 2023 18:09:40 +0100 Subject: [PATCH] Update spring solver --- docs/api/animation.md | 2 +- src/spring.luau | 285 +++++++++++++++++++++++++----------------- test/mock.luau | 36 +++++- test/spring-test.luau | 71 +++++++++++ test/tests.luau | 6 +- 5 files changed, 280 insertions(+), 120 deletions(-) create mode 100644 test/spring-test.luau diff --git a/docs/api/animation.md b/docs/api/animation.md index 539f6ba..1e6803b 100644 --- a/docs/api/animation.md +++ b/docs/api/animation.md @@ -29,7 +29,7 @@ Returns a new source with a dynamically animated value of the input source. `damping_ratio` is the amount of resistance applied to the spring. - - \>1 = Overdamped (not currently supported). + - \>1 = Overdamped - slowly reaches target without any overshoot. - 1 = Critically damped - reaches target without any overshoot. - <1 = Underdamped - reaches target with some overshoot. - 0 = Undamped - never stabilizes, oscillates forever. diff --git a/src/spring.luau b/src/spring.luau index 5f97325..6d8437b 100644 --- a/src/spring.luau +++ b/src/spring.luau @@ -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 = graph.Node 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 = { - 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 = (initial: T, target: T, alpha: number) -> T +type TypeToVec6 = (T) -> (Vec3, Vec3) +type Vec6ToType = (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, - 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, -local lerpable: { [string]: Lerp } = { - number = function(v1, v2, a) - return v1 + (v2 - v1)*a - end :: Lerp, + Color3 = function(v) + -- todo: hsv + return Vec3(v.R, v.G, v.B), Vec3() + end :: TypeToVec6, - CFrame = function(v1, v2, a) - return v1:Lerp(v2, a) - end :: Lerp, + UDim = function(v) + return Vec3(v.Scale, v.Offset, 0), Vec3() + end :: TypeToVec6, + + UDim2 = function(v) + return Vec3(v.X.Scale, v.X.Offset, v.Y.Scale), Vec3(v.Y.Offset, 0, 0) + end :: TypeToVec6, - Color3 = function(v1, v2, a) - return v1:Lerp(v2, a) - end :: Lerp, + Vector2 = function(v) + return Vec3(v.X, v.Y, 0), Vec3() + end :: TypeToVec6, - UDim = function(v1, v2, a) - return UDim.new( - v1.Scale + (v2.Scale - v1.Scale)*a, - v1.Offset + (v2.Offset - v1.Offset)*a - ) - end :: Lerp, + Vector3 = function(v) + return v, Vec3() + end :: TypeToVec6, - UDim2 = function(v1, v2, a) - return v1:Lerp(v2, a) - end :: Lerp, + Rect = function(v) + return Vec3(v.Min.X, v.Min.Y, v.Max.X), Vec3(v.Max.Y, 0, 0) + end :: TypeToVec6 +} - Vector2 = function(v1, v2, a) - return v1:Lerp(v2, a) - end :: Lerp, +local vec6_to_type = { + number = function(a, b) + return a.X + end :: Vec6ToType, - Vector3 = function(v1, v2, a) - return v1:Lerp(v2, a) - end :: Lerp, + CFrame = function(a, b) + return CFrame.new(a) * CFrame.fromEulerAnglesXYZ(b.X, b.Y, b.Z) + end :: Vec6ToType, + + Color3 = function(v) + return Color3.new(v.X, v.Y, v.Z) + end :: Vec6ToType, + + UDim = function(v) + return UDim.new(v.X, v.Y) + end :: Vec6ToType, + + UDim2 = function(a, b) + return UDim2.new(a.X, a.Y, a.Z, b.X) + end :: Vec6ToType, + + Vector2 = function(v) + return Vector2.new(v.X, v.Y) + end :: Vec6ToType, + + Vector3 = function(v) + return v + end :: Vec6ToType, + + Rect = function(a, b) + return Rect.new(a.X, a.Y, a.Z, b.X) + end :: Vec6ToType } -- maps spring data to its corresponding output node @@ -99,33 +134,46 @@ local lerpable: { [string]: Lerp } = { local springs: { [SpringData]: Node } = {} setmetatable(springs, { __mode = "vs" }) -local function spring(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(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 = { - 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(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 = 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 diff --git a/test/mock.luau b/test/mock.luau index b7cf634..0c76dcb 100644 --- a/test/mock.luau +++ b/test/mock.luau @@ -256,6 +256,40 @@ local Vector2 = { __type = "Vector2" } :: any do end end +local Vector3 = { __type = "Vector3" } :: any do + local function new(x, y, z) + return setmetatable({ X = x, Y = y, Z = z }, Vector3) + end + + function Vector3.new(x, y, z) + return new(x or 0, y or 0, z or 0) + end + + function Vector3.__add(a, b) + return new(a.X + b.X, a.Y + b.Y, a.Z + b.Z) + end + + function Vector3.__sub(a, b) + return new(a.X - b.X, a.Y - b.Y, a.Z - b.Z) + end + + function Vector3.__mul(a, b) + return new(a.X * b, a.Y * b, a.Z * b) + end + + function Vector3.__unm(v) + return new(-v.X, -v.Y, -v.Z) + end + + function Vector3.__eq(a, b) + return a.X == b.X and a.Y == b.Y + end + + function Vector3.__index(v) + return (v.X^2 + v.Y^2 + v.Z^2)^0.5 + end +end + local UDim2 = { __type = "UDim2" } :: any do function UDim2.new(sx, ox, sy, oy) return table_to_proxy(setmetatable({ x = { scale = sx, offset = ox }, y = { scale = sy, offset = oy } }, UDim2)) @@ -294,8 +328,8 @@ return { Signal = Signal, Instance = Instance :: typeof(Instance), Color3 = Color3 :: typeof(Color3), - Vector3 = Vector3 :: typeof(Vector3), Vector2 = Vector2 :: typeof(Vector2), + Vector3 = Vector3 :: typeof(Vector3), UDim2 = UDim2 :: typeof(UDim2), Enum = Enum :: typeof(Enum), typeof = typeof :: typeof(typeof) diff --git a/test/spring-test.luau b/test/spring-test.luau new file mode 100644 index 0000000..691e256 --- /dev/null +++ b/test/spring-test.luau @@ -0,0 +1,71 @@ +local vide = require "src/init" +local testkit = require("test/testkit") + +local program_time = os.clock() + +local function step(): number + local FPS = 60 + local DT = 1/FPS + + repeat until os.clock() - program_time >= DT + program_time += DT + return DT +end + +local function main() + local source = vide.source + local spring = vide.spring + local watch = vide.watch + + local TERMINAL_HEIGHT = 73 --* REDUCE IF BAR DOES NOT FIT IN TERMINAL + local MIN_ALPHA = 0.3 + local MAX_ALPHA = 0.7 + + local MIN = TERMINAL_HEIGHT * MIN_ALPHA + local MAX = TERMINAL_HEIGHT * MAX_ALPHA + local OFFSET = TERMINAL_HEIGHT - MAX + + local BLOCK = "█" + + local function remainder_to_block(x) + return + if x > 7/8 then "█" + elseif x > 6/8 then "▇" + elseif x > 5/8 then "▆" + elseif x > 4/8 then "▅" + elseif x > 3/8 then "▄" + elseif x > 2/8 then "▃" + elseif x > 1/8 then "▂" + else "▁" + end + + local value = source(MAX) + local sprung = spring(value, 1, 0.3) + + watch(function() + local v = sprung() + local fv = math.floor(v) + local reset = "\27[H\27[2J" -- ANSI clear terminal + local offset = string.rep("\n", MAX - fv + OFFSET) + local bar = testkit.color.gray(remainder_to_block(v - fv) .. "\n" .. string.rep(BLOCK .. "\n", fv)) + print(reset .. offset .. bar) + end) + + local elapsed = 0 + repeat local dt = step() + vide.step(dt) + + local T = 3 + elapsed += dt + while elapsed >= T do + elapsed -= T + value(value() == MAX and MIN or MAX) + end + + until false +end + +main() + + + diff --git a/test/tests.luau b/test/tests.luau index 699d262..6cb5bab 100644 --- a/test/tests.luau +++ b/test/tests.luau @@ -1240,7 +1240,7 @@ TEST("spring()", function() input(1) vide.step(0.05) CHECK(output() ~= input()) -- check spring is moving - vide.step(6) -- spring finished, should be internally removed from queue + vide.step(10) -- spring finished, should be internally removed from queue CHECK(output() == input()) -- check spring is at target local count = -1 @@ -1250,13 +1250,13 @@ TEST("spring()", function() end) vide.step(1) -- attempt to cause another spring update - CHECK(count == 1) -- check no update occurs as spring is finished + CHECK(count == 0) -- check no update occurs as spring is finished -- gc() -- perform full gc input(2) -- spring should be re-added to spring queue vide.step(0) -- process spring queue - CHECK(count == 2) -- check spring was rescheduled correctly + CHECK(count == 1) -- check spring was rescheduled correctly end end)