From a2e6c0aafa31d70fd208cd534d506c64d6c01f75 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Mon, 26 Jan 2026 04:28:14 +0100 Subject: [PATCH] New modules with examples! --- examples/networking/networking_send.luau | 2 +- modules/BT/module.luau | 44 ++++++ modules/GetRect/examples/button.luau | 32 ++++ modules/GetRect/module.luau | 108 +++++++++++++ modules/OB/examples/example.luau | 69 +++++++++ modules/{ob.luau => OB/module.luau} | 0 modules/Spring/cframe.luau | 187 +++++++++++++++++++++++ modules/Spring/color3.luau | 110 +++++++++++++ modules/Spring/examples/example.luau | 15 ++ modules/Spring/generic.luau | 55 +++++++ modules/Spring/number.luau | 136 +++++++++++++++++ modules/Spring/vector2.luau | 149 ++++++++++++++++++ modules/Spring/vector3.luau | 133 ++++++++++++++++ 13 files changed, 1039 insertions(+), 1 deletion(-) create mode 100755 modules/BT/module.luau create mode 100755 modules/GetRect/examples/button.luau create mode 100755 modules/GetRect/module.luau create mode 100755 modules/OB/examples/example.luau rename modules/{ob.luau => OB/module.luau} (100%) create mode 100755 modules/Spring/cframe.luau create mode 100755 modules/Spring/color3.luau create mode 100755 modules/Spring/examples/example.luau create mode 100755 modules/Spring/generic.luau create mode 100755 modules/Spring/number.luau create mode 100755 modules/Spring/vector2.luau create mode 100755 modules/Spring/vector3.luau diff --git a/examples/networking/networking_send.luau b/examples/networking/networking_send.luau index 20593d1..99219bd 100755 --- a/examples/networking/networking_send.luau +++ b/examples/networking/networking_send.luau @@ -134,7 +134,7 @@ local function networking_send(world: jecs.World) set_n += entities_len end - local set = table.move(set_ids_lazy, 1, set_n, 1, {}) + local set = table.move(set_ids_lazy, 1, set_n, 1, table.create(set_n)) local map = { set = if set_n > 0 then set else nil, diff --git a/modules/BT/module.luau b/modules/BT/module.luau new file mode 100755 index 0000000..9407dbe --- /dev/null +++ b/modules/BT/module.luau @@ -0,0 +1,44 @@ +-- original author @centau + +local SUCCESS = true +local FAILURE = false +local RUNNING = newproxy(false) + +local function SEQUENCE(nodes: { (...any) -> boolean}) + return function(...) + for _, node in nodes do + local status = node(...) + if not status or status == RUNNING then + return status + end + end + return SUCCESS + end +end + +local function FALLBACK(nodes: { (...any) -> boolean }) + return function(...) + for _, node in nodes do + local status = node(...) + if status or status == RUNNING then + return status + end + end + return FAILURE + end +end + +local function NOT(f) + return function(...): boolean + return not f(...) + end +end + +local bt = { + SEQUENCE = SEQUENCE, + FALLBACK = FALLBACK, + RUNNING = RUNNING, + NOT = NOT, +} + +return bt \ No newline at end of file diff --git a/modules/GetRect/examples/button.luau b/modules/GetRect/examples/button.luau new file mode 100755 index 0000000..425250d --- /dev/null +++ b/modules/GetRect/examples/button.luau @@ -0,0 +1,32 @@ +local ReplicatedStorage = require("@game/ReplicatedStorage") +local RunService = require("@game/RunService") +local UI_Context = require(ReplicatedStorage.UI_Context) +local GetRect = require("@modules/GetRect/module") + +RunService.RenderStepped:Connect(fúnction(dt) + local pressed, button_state, released = GetRect.button(UI_Context.button.shop) + + local my_window = UI_Context.window.shop + local state = UI_Context.state + if released then + my_window.Visible = not my_window.shop.Visible + state.pop_effect_t = 0 + state.pop_duration += dt + end + + state.pop_effect_t = GetRect.animate(state.pop_effect_t, my_window.shop_visible, dt * 10) + local base = 0.6 + local blend_factor = math.sin(math.pi/2*state.pop_duration * 0.5) + blend_factor += 1 + blend_factor *= 0.5 + state.pop_factor = 0.6 + state.pop_effect_t * blend_factor + + local color3 = my_window.Title.TextColor3 + local r, g, b = color3.R, color3.G, color3.B + r = math.lerp(r, UI_Context.theme.text_color_pop, state.pop_factor) + g = math.lerp(g, UI_Context.theme.text_color_pop, state.pop_factor) + b = math.lerp(b, UI_Context.theme.text_color_pop, state.pop_factor) + + my_window.UIScale = state.pop_effect_t + my_window.Title.TextColor3 = Color3.new(r, g, b) +end) \ No newline at end of file diff --git a/modules/GetRect/module.luau b/modules/GetRect/module.luau new file mode 100755 index 0000000..42b45f8 --- /dev/null +++ b/modules/GetRect/module.luau @@ -0,0 +1,108 @@ +local state_table = {} :: {[GuiObject]: Active_Widget } +type Active_Widget = { + last_update_time: number, + is_on_state_stack: boolean, + last_getrect_frame: number, + instance: GuiObject, + parent: Instance +} + + +local function orphan(template: T): T + -- NOTE(marcus): Right now this does nothing... + local instance = template :: Instance + if instance then + return template + end + instance.Parent = nil + return instance:Clone() :: T +end + +local function find_or_create_state(rect: GuiObject): (T, boolean) + local state = state_table[rect] + if state then + return state, false + end + + state = { + last_getrect_frame = -1, + last_update_time = -1, + is_on_state_stack = false, + instance = orphan(rect) -- NOTE(marcus): In the future we could just as easily treat rect as a theme and then just clone the instance by orphaning it + } :: Active_Widget + state_table[rect] = state + + return state, true +end + +type Button_State = { + pressed: boolean?, + prev: boolean?, + down: boolean?, + released: boolean?, + status: Enum.GuiState, +} & Active_Widget + +local function button(rect: GuiObject): (boolean, Button_State, boolean) + + local state = find_or_create_state(rect) :: Button_State + local status = state.instance.GuiState + state.status = status + + local released = false + local result = false + + if state.pressed then + if not (status == Enum.GuiState.Press) then + released = true + state.pressed = false + end + else + if status == Enum.GuiState.Press then + state.pressed = true + result = true + end + end + + if state.pressed then + state.status = Enum.GuiState.Hover + end + + return result, state, released +end + +local function move_toward(a: number, b: number, amount_increasing: number, amount_decreasing: number?) + amount_decreasing = amount_decreasing or -1 + if a > b then + if amount_decreasing == -1 then + amount_decreasing = amount_increasing + end + a -= amount_decreasing + if a < b then + a = b + end + else + a += amount_increasing + if a > b then + a = b + end + end + return a +end + +local function animate(f: number, condition: boolean, dt: number, up_rate: number, down_rate: number) + if condition then + return move_toward(f, 1, dt * up_rate) + else + return move_toward(f, 0, dt * down_rate) + end +end + +local GetRect = { + button = button, + move_toward = move_toward, + animate = animate, + orphan = orphan +} + +return GetRect \ No newline at end of file diff --git a/modules/OB/examples/example.luau b/modules/OB/examples/example.luau new file mode 100755 index 0000000..f4361e5 --- /dev/null +++ b/modules/OB/examples/example.luau @@ -0,0 +1,69 @@ +local jecs = require("@jecs") +local OB = require("@modules/OB/module") + +local ct = { + Transform = jecs.component() :: jecs.Id, + PivotTo = jecs.component() :: jecs.Id, + TRANSFORM_PREDICTED = jecs.tag(), + Renderable = jecs.component() :: jecs.Id +} + +local function main(world: jecs.World) + local function pivots_added(entity: jecs.Entity) + local dst: CFrame?, model: Instance? = world:get(entity, ct.PivotTo, ct.Renderable) + if dst == nil then + return + end + + world:set(entity, ct.Transform, dst) + world:remove(entity, ct.PivotTo) + ;(model :: Model):PivotTo(dst) + end + + local authoritative_transforms = world + :query(ct.Transform, ct.Renderable) + :without(ct.TRANSFORM_PREDICTED) + :cached() + + OB.observer(world:query(ct.PivotTo, ct.Renderable), pivots_added) + + return function() + for entity, transform, renderable in authoritative_transforms do + local model = renderable :: Model + local primarypart = model.PrimaryPart + if primarypart == nil or primarypart.CFrame == transform then + continue + end + + primarypart.CFrame = transform + end + + for entity, transform, model in predicted_transforms do + local primarypart = (model :: Model).PrimaryPart + if primarypart == nil then + continue + end + + local cframe = primarypart.CFrame + + if (transform.Position - cframe.Position).Magnitude < 0.1 then + continue + end + + world:set(entity, ct.Transform, cframe) + end + end +end + +local w = jecs.world() + +local entity = world:entity() +local part = Instance.new("Part") +part.Anchored = true +part.Parent = workspace +world:set(entity, ct.Renderable, part) +world:set(entity, ct.PivotTo, CFrame.new()) + +local system = main(w) +system(1/60) + diff --git a/modules/ob.luau b/modules/OB/module.luau similarity index 100% rename from modules/ob.luau rename to modules/OB/module.luau diff --git a/modules/Spring/cframe.luau b/modules/Spring/cframe.luau new file mode 100755 index 0000000..b68c2c3 --- /dev/null +++ b/modules/Spring/cframe.luau @@ -0,0 +1,187 @@ +--!native +--!optimize 2 +--!strict + +local Spring_Vector3 = require("./vector3") + +export type Spring = { + type: "CFrame", + position: Spring_Vector3.Spring, + rotation: Spring_Rotation, + d: number, + f: number, + g: CFrame, + p: CFrame +} + +type Spring_Rotation = { + d: number, + f: number, + g: CFrame, + p: CFrame, + v: Vector3 +} + +-- evaluate dot products in high precision +local function dot(v0: Vector3, v1: Vector3) + return v0.X*v1.X + v0.Y*v1.Y + v0.Z*v1.Z +end + +local function angleDiff(c0: CFrame, c1: CFrame) + local x = dot(c0.XVector, c1.XVector) + local y = dot(c0.YVector, c1.YVector) + local z = dot(c0.ZVector, c1.ZVector) + local w = x + y + z - 1 + return math.atan2(math.sqrt(math.max(0, 1 - w*w*0.25)), w*0.5) +end + +-- gives approx. 21% accuracy improvement over CFrame.fromAxisAngle near poles +local function fromAxisAngle(axis: Vector3, angle: number) + local c = math.cos(angle) + local s = math.sin(angle) + local x, y, z = axis.X, axis.Y, axis.Z + + local mxy = x*y*(1 - c) + local myz = y*z*(1 - c) + local mzx = z*x*(1 - c) + + local rx = Vector3.new(x*x*(1 - c) + c, mxy + z*s, mzx - y*s) + local ry = Vector3.new(mxy - z*s, y*y*(1 - c) + c, myz + x*s) + local rz = Vector3.new(mzx + y*s, myz - x*s, z*z*(1 - c) + c) + + return CFrame.fromMatrix(Vector3.zero, rx, ry, rz):Orthonormalize() +end + +local function rotateAxis(r0: Vector3, c1: CFrame) + local c0 = CFrame.identity + local mag = r0.Magnitude + if mag > 1e-6 then + c0 = fromAxisAngle(r0.Unit, mag) + end + return c0 * c1 +end + +-- axis*angle difference between two cframes +local function axisAngleDiff(c0: CFrame, c1: CFrame) + -- use native axis (stable enough) + local axis = (c0*c1:Inverse()):ToAxisAngle() + + -- use full-precision angle calculation to minimize truncation + local angle = angleDiff(c0, c1) + return axis.Unit*angle +end + +local function rotation_step(spring: Spring_Rotation, dt: number): CFrame + debug.profilebegin("rotation") + local d = spring.d + local f = spring.f*(2*math.pi) + local g = spring.g + local p0 = spring.p + local v0 = spring.v + + local offset = axisAngleDiff(p0, g) + local decay = math.exp(-d*f*dt) + + local pt: CFrame + local vt: Vector3 + + if d == 1 then -- critically damped + pt = rotateAxis((offset*(1 + f*dt) + v0*dt)*decay, g) + vt = (v0*(1 - dt*f) - offset*(dt*f*f))*decay + + elseif d < 1 then -- underdamped + local c = math.sqrt(1 - d*d) + + local i = math.cos(dt*f*c) + local j = math.sin(dt*f*c) + + local y = j/(f*c) + local z = j/c + + pt = rotateAxis((offset*(i + z*d) + v0*y)*decay, g) + vt = (v0*(i - z*d) - offset*(z*f))*decay + else -- overdamped + local c = math.sqrt(d*d - 1) + + local r1 = -f*(d + c) + local r2 = -f*(d - c) + + local co2 = (v0 - offset*r1)/(2*f*c) + local co1 = offset - co2 + + local e1 = co1*math.exp(r1*dt) + local e2 = co2*math.exp(r2*dt) + + pt = rotateAxis(e1 + e2, g) + vt = e1*r1 + e2*r2 + end + + spring.p = pt + spring.v = vt + + debug.profileend() + + return pt +end + +local SLEEP_ROTATION_DIFF = math.rad(0.01) -- rad +local SLEEP_ROTATION_VELOCITY = math.rad(0.1) -- rad/s + +local function areRotationsClose(c0: CFrame, c1: CFrame) + local rx = dot(c0.XVector, c1.XVector) + local ry = dot(c0.YVector, c1.YVector) + local rz = dot(c0.ZVector, c1.ZVector) + local trace = rx + ry + rz + return trace > 1 + 2*math.cos(SLEEP_ROTATION_DIFF) +end + +local function rotation_can_sleep(spring: Spring_Rotation): boolean + local sleepP = areRotationsClose(spring.p, spring.g) + local sleepV = spring.v.Magnitude < SLEEP_ROTATION_VELOCITY + return sleepP and sleepV +end + +-- Very important to remember that the origo and the goal's Rotation are +-- orthonormalized! +local function create(d: number, f: number, origo: CFrame, goal: CFrame): Spring + return { + type = "CFrame", + position = { + type = "Vector3", + d = d, + f = f, + g = goal.Position, + p = origo.Position, + v = Vector3.zero, + }, + rotation = { + d = d, + f = f, + g = goal.Rotation:Orthonormalize(), + p = origo.Rotation:Orthonormalize(), + v = Vector3.zero + }, + d = d, + f = f, + p = origo, + g = goal + } +end + +local function step(spring: Spring, dt: number): CFrame + local p = Spring_Vector3.step(spring.position, dt) + local r = rotation_step(spring.rotation, dt) + local cframe = r + p + spring.p = cframe + return cframe +end + +local function can_sleep(spring: Spring) + return Spring_Vector3.can_sleep(spring.position) and rotation_can_sleep(spring.rotation) +end + +return { + create = create, + step = step, + can_sleep = can_sleep +} diff --git a/modules/Spring/color3.luau b/modules/Spring/color3.luau new file mode 100755 index 0000000..4d2f843 --- /dev/null +++ b/modules/Spring/color3.luau @@ -0,0 +1,110 @@ +--!native +--!optimize 2 +--!strict +local Spring_Vector3 = require("./vector3") + +export type Spring = { + type: "Color3", + d: number, + f: number, + g: Vector3, + p: Vector3, + v: Vector3 +} + +local function create(d: number, f: number, origo: Color3, goal: Color3): Spring + return { + type = "Color3", + d = d, + f = f, + g = Vector3.new(), + p = Vector3.new(), + v = Vector3.new(), + } +end + +local function inverseGammaCorrectD65(c) + return c < 0.0404482362771076 and c/12.92 or 0.87941546140213*(c + 0.055)^2.4 +end + +local function gammaCorrectD65(c) + return c < 3.1306684425e-3 and 12.92*c or 1.055*c^(1/2.4) - 0.055 +end + +local function rgbToLuv(value: Vector3): Color3 + -- convert RGB to a variant of cieluv space + local r, g, b = value.X, value.Y, value.Z + + -- D65 sRGB inverse gamma correction + r = inverseGammaCorrectD65(r) + g = inverseGammaCorrectD65(g) + b = inverseGammaCorrectD65(b) + + -- sRGB -> xyz + local x = 0.9257063972951867*r - 0.8333736323779866*g - 0.09209820666085898*b + local y = 0.2125862307855956*r + 0.71517030370341085*g + 0.0722004986433362*b + local z = 3.6590806972265883*r + 11.4426895800574232*g + 4.1149915024264843*b + + -- xyz -> scaled cieluv + local l = y > 0.008856451679035631 and 116*y^(1/3) - 16 or 903.296296296296*y + + local u, v + if z > 1e-14 then + u = l*x/z + v = l*(9*y/z - 0.46832) + else + u = -0.19783*l + v = -0.46832*l + end + + return Color3.new(l,u,v) +end +local function luvToRgb(value: Vector3): Color3 + -- convert back from modified cieluv to rgb space + local l = value.X + if l < 0.0197955 then + return Color3.new(0, 0, 0) + end + local u = value.Y/l + 0.19783 + local v = value.Z/l + 0.46832 + + -- cieluv -> xyz + local y = (l + 16)/116 + y = y > 0.206896551724137931 and y*y*y or 0.12841854934601665*y - 0.01771290335807126 + local x = y*u/v + local z = y*((3 - 0.75*u)/v - 5) + + -- xyz -> D65 sRGB + local r = 7.2914074*x - 1.5372080*y - 0.4986286*z + local g = -2.1800940*x + 1.8757561*y + 0.0415175*z + local b = 0.1253477*x - 0.2040211*y + 1.0569959*z + + -- clamp minimum sRGB component + if r < 0 and r < g and r < b then + r, g, b = 0, g - r, b - r + elseif g < 0 and g < b then + r, g, b = r - g, 0, b - g + elseif b < 0 then + r, g, b = r - b, g - b, 0 + end + + -- gamma correction from D65 + -- clamp to avoid undesirable overflow wrapping behavior on certain properties (e.g. BasePart.Color) + return Color3.new( + math.min(gammaCorrectD65(r), 1), + math.min(gammaCorrectD65(g), 1), + math.min(gammaCorrectD65(b), 1) + ) +end + +local function step(spring: Spring, dt: number): Color3 + Spring_Vector3.step((spring::any), dt) + return rgbToLuv(spring.g) +end + +return { + create = create, + step = step, + can_sleep = Spring_Vector3.can_sleep, + rgbToLuv = rgbToLuv +} diff --git a/modules/Spring/examples/example.luau b/modules/Spring/examples/example.luau new file mode 100755 index 0000000..3fba704 --- /dev/null +++ b/modules/Spring/examples/example.luau @@ -0,0 +1,15 @@ +local Spring_Vector3 = require("@modules/Spring/vector3") +local Spring_Generic = require("@modules/Spring/generic") + +local spring = Spring_Vector3.create(1, 1, vector.create(0, 0, 0), vector.create(1, 1, 1)) + +local p = Spring_Vector3.step(spring, 1/60) -- You can also supplement 0 for the deltatime and it will just give you the value it is on right now +print(p) +print(Spring_Vector3.can_sleep(spring)) + +-- This generic spring interface allows you to step any spring which can be +-- useful if you would like a single homogenous system to update all scheduled +-- springs +Spring_Generic.step(spring, 1/60) + + diff --git a/modules/Spring/generic.luau b/modules/Spring/generic.luau new file mode 100755 index 0000000..58918bb --- /dev/null +++ b/modules/Spring/generic.luau @@ -0,0 +1,55 @@ +local cframe = require("./cframe") +local color3 = require("./color3") +local number = require("./number") +local vector2 = require("./vector2") +local vector3 = require("./vector3") + +export type Spring = + | cframe.Spring + | color3.Spring + | number.Spring + | vector2.Spring + | vector3.Spring + +local function exhaustive(x: never) + error(x) +end + + +local function step(spring: Spring, dt: number) + if spring.type == "CFrame" then + cframe.step(spring, dt) + elseif spring.type == "Color3" then + color3.step(spring, dt) + elseif spring.type == "Vector2" then + vector2.step(spring, dt) + elseif spring.type == "Vector3" then + vector3.step(spring, dt) + elseif spring.type == "number" then + number.step(spring, dt) + else + exhaustive(spring.type) + end +end + + +local function can_sleep(spring: Spring) + if spring.type == "CFrame" then + cframe.can_sleep(spring) + elseif spring.type == "Color3" then + color3.can_sleep((spring::any)::vector3.Spring) + elseif spring.type == "Vector2" then + vector2.can_sleep(spring) + elseif spring.type == "Vector3" then + vector3.can_sleep(spring) + elseif spring.type == "number" then + number.can_sleep(spring) + else + exhaustive(spring.type) + end +end + +return { + step = step, + can_sleep = can_sleep, +} diff --git a/modules/Spring/number.luau b/modules/Spring/number.luau new file mode 100755 index 0000000..2ea28eb --- /dev/null +++ b/modules/Spring/number.luau @@ -0,0 +1,136 @@ +--!native +--!optimize 2 +--!strict + +export type Spring = { + type: "number", + d: number, + f: number, + g: number, + p: number, + v: number +} + +local EPS = 1e-5 -- epsilon for stability checks around pathological frequency/damping values + +local function create(d: number, f: number, origo: number, goal: number): Spring + return { + type = "number", + d = d, + f = f, + g = goal, + p = origo, + v = 0, + } +end + +local function step(spring: Spring, dt: number): number + debug.profilebegin("Vector3 Linear Spring") + + local f = spring.f + local d = spring.d + local g = spring.g + local v = spring.v + local p = spring.p + + if d == 1 then -- critically damped + local q = math.exp(-f*dt) + local w = dt*q + + local c0 = q + w*f + local c2 = q - w*f + local c3 = w*f*f + + local o = p - g + + p = o*c0+v*w+g + v = v * c2-o*c3 + elseif d < 1 then -- underdamped + local q = math.exp(-d*f*dt) + local c = math.sqrt(1 - d*d) + + local i = math.cos(dt*f*c) + local j = math.sin(dt*f*c) + + -- Damping ratios approaching 1 can cause division by very small numbers. + -- To mitigate that, group terms around z=j/c and find an approximation for z. + -- Start with the definition of z: + -- z = sin(dt*f*c)/c + -- Substitute a=dt*f: + -- 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 > EPS then + z = j/c + else + local a = dt*f + z = a + ((a*a)*(c*c)*(c*c)/20 - c*c)*(a*a*a)/6 + end + + -- Frequencies approaching 0 present a similar problem. + -- We want an approximation for y as f approaches 0, where: + -- y = sin(dt*f*c)/(f*c) + -- Substitute b=dt*c: + -- y = sin(b*c)/b + -- Now reapply the process from z. + + local y + if f*c > EPS then + y = j/(f*c) + else + local b = f*c + y = dt + ((dt*dt)*(b*b)*(b*b)/20 - b*b)*(dt*dt*dt)/6 + end + + local o = p - g + + p = o*(i+z*d)+v*y+g + v = (v*(i-z*d)-o*(z*f))*q + + else -- overdamped + local c = math.sqrt(d*d - 1) + + local r1 = -f*(d + c) + local r2 = -f*(d - c) + + local ec1 = math.exp(r1*dt) + local ec2 = math.exp(r2*dt) + + local o = p - g + local co2 = (v - o*r1)/(2*f*c) + local co1 = ec1*(o - co2) + p = co1 + co2*ec2 + g + v = co1*r1+co2*ec2*r2 + end + + spring.p = p + spring.v = v + debug.profileend() + return p +end + +local SLEEP_OFFSET_SQ_LIMIT = (1/3840)^2 -- square of the offset sleep limit +local SLEEP_VELOCITY_SQ_LIMIT = 1e-2^2 -- square of the velocity sleep limit + +local function can_sleep(spring: Spring): boolean + if spring.v^2 > SLEEP_VELOCITY_SQ_LIMIT then + return false + end + + if (spring.p - spring.g)^2 > SLEEP_OFFSET_SQ_LIMIT then + return false + end + + return true +end + +return { + create = create, + step = step, + can_sleep = can_sleep, +} diff --git a/modules/Spring/vector2.luau b/modules/Spring/vector2.luau new file mode 100755 index 0000000..5f02872 --- /dev/null +++ b/modules/Spring/vector2.luau @@ -0,0 +1,149 @@ +--!native +--!optimize 2 +--!strict + +local Spring_Vector3 = require("./vector3") + +export type Spring = { + type: "Vector2", + d: number, + f: number, + g: Vector2, + p: Vector3, + v: Vector3 +} + +local EPS = 1e-5 -- epsilon for stability checks around pathological frequency/damping values + +local function create(d: number, f: number, origo: Vector2, goal: Vector2): Spring + return { + type = "Vector2", + d = d, + f = f, + g = goal, + p = Vector3.new(origo.X, origo.Y, 0), + v = Vector3.zero, + } +end + +local function step(spring: Spring, dt: number): Vector3 + debug.profilebegin("Vector2 Linear Spring") + + local f = spring.f + local d = spring.d + local g = spring.g + local v = spring.v + local p = spring.p + + if d == 1 then -- critically damped + local q = math.exp(-f*dt) + local w = dt*q + + local c0 = q + w*f + local c2 = q - w*f + local c3 = w*f*f + + local ox = p.X - g.X + local oy = p.Y - g.Y + + p = Vector3.new( + ox*c0+v.X*w+g.X, + oy*c0+v.Y*w+g.Y + ) + v = Vector3.new( + v.X*c2-ox*c3, + v.Y*c2-oy*c3 + ) + elseif d < 1 then -- underdamped + local q = math.exp(-d*f*dt) + local c = math.sqrt(1 - d*d) + + local i = math.cos(dt*f*c) + local j = math.sin(dt*f*c) + + -- Damping ratios approaching 1 can cause division by very small numbers. + -- To mitigate that, group terms around z=j/c and find an approximation for z. + -- Start with the definition of z: + -- z = sin(dt*f*c)/c + -- Substitute a=dt*f: + -- 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 > EPS then + z = j/c + else + local a = dt*f + z = a + ((a*a)*(c*c)*(c*c)/20 - c*c)*(a*a*a)/6 + end + + -- Frequencies approaching 0 present a similar problem. + -- We want an approximation for y as f approaches 0, where: + -- y = sin(dt*f*c)/(f*c) + -- Substitute b=dt*c: + -- y = sin(b*c)/b + -- Now reapply the process from z. + + local y + if f*c > EPS then + y = j/(f*c) + else + local b = f*c + y = dt + ((dt*dt)*(b*b)*(b*b)/20 - b*b)*(dt*dt*dt)/6 + end + + local ox = p.X - g.X + local oy = p.Y - g.Y + + p = Vector3.new( + (ox*(i + z*d) + v.X*y)*q + g.X, + (oy*(i + z*d) + v.Y*y)*q + g.Y + ) + v = Vector3.new( + (v.X*(i - z*d) - ox*(z*f))*q, + (v.Y*(i - z*d) - oy*(z*f))*q + ) + + else -- overdamped + local c = math.sqrt(d*d - 1) + + local r1 = -f*(d + c) + local r2 = -f*(d - c) + + local ec1 = math.exp(r1*dt) + local ec2 = math.exp(r2*dt) + + local ox = p.X - g.X + local oy = p.Y - g.Y + + local co2x = (v.X - ox*r1)/(2*f*c) + local co2y = (v.Y - oy*r1)/(2*f*c) + + local co1x = ec1*(ox - co2x) + local co1y = ec1*(oy - co2y) + + p = Vector3.new( + co1x + co2x*ec2 + g.X, + co1y + co2y*ec2 + g.Y + ) + v = Vector3.new( + co1x*r1 + co2x*ec2*r2, + co1y*r1 + co2y*ec2*r2 + ) + end + + spring.p = p + spring.v = v + debug.profileend() + return p +end + +return { + create = create, + step = step, + can_sleep = Vector3_Spring.can_sleep, +} diff --git a/modules/Spring/vector3.luau b/modules/Spring/vector3.luau new file mode 100755 index 0000000..968a690 --- /dev/null +++ b/modules/Spring/vector3.luau @@ -0,0 +1,133 @@ +--!native +--!optimize 2 +--!strict + +export type Spring = { + type: "Vector3", + d: number, + f: number, + g: Vector3, + p: Vector3, + v: vector +} + +local EPS = 1e-5 -- epsilon for stability checks around pathological frequency/damping values + +local function create(d: number, f: number, origo: Vector3, goal: Vector3): Spring + return { + type = "Vector3", + d = d, + f = f, + g = goal, + p = origo, + v = vector.create(0, 0, 0), + } +end + +local function step(spring: Spring, dt: number): Vector3 + local f = spring.f + local d = spring.d + local g = (spring.g :: any)::vector + local v = spring.v + local p = (spring.p :: any)::vector + + if d == 1 then -- critically damped + local q = math.exp(-f*dt) + local w = dt*q + + local c0 = q + w*f + local c2 = q - w*f + local c3 = w*f*f + + local o = p - g + p = o * c0 + v * w + g + v = v * c2 - o * c3 + elseif d < 1 then -- underdamped + local q = math.exp(-d*f*dt) + local c = math.sqrt(1 - d*d) + + local i = math.cos(dt*f*c) + local j = math.sin(dt*f*c) + + -- Damping ratios approaching 1 can cause division by very small numbers. + -- To mitigate that, group terms around z=j/c and find an approximation for z. + -- Start with the definition of z: + -- z = sin(dt*f*c)/c + -- Substitute a=dt*f: + -- 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 > EPS then + z = j/c + else + local a = dt*f + z = a + ((a*a)*(c*c)*(c*c)/20 - c*c)*(a*a*a)/6 + end + + -- Frequencies approaching 0 present a similar problem. + -- We want an approximation for y as f approaches 0, where: + -- y = sin(dt*f*c)/(f*c) + -- Substitute b=dt*c: + -- y = sin(b*c)/b + -- Now reapply the process from z. + + local y + if f*c > EPS then + y = j/(f*c) + else + local b = f*c + y = dt + ((dt*dt)*(b*b)*(b*b)/20 - b*b)*(dt*dt*dt)/6 + end + + local o = p - g + + p = (o * (i + z*d) + v * y) * q + g + v = (v * (i - z*d) - o * (z*f)) * q + else -- overdamped + local c = math.sqrt(d*d - 1) + + local r1 = -f*(d + c) + local r2 = -f*(d - c) + + local ec1 = math.exp(r1*dt) + local ec2 = math.exp(r2*dt) + + local o = p - g + + local co2 = (v - o*r1)/(2*f*c) + local co1 = ec1*(o - co2) + + p = co1 + co2*ec2 + g + v = co1*r1 + co2*ec2*r2 + end + + spring.p = (p :: any) :: Vector3 + spring.v = (v :: any) :: Vector3 + return (p :: any) :: Vector3 +end + +local SLEEP_OFFSET_SQ_LIMIT = (1/3840)^2 -- square of the offset sleep limit +local SLEEP_VELOCITY_SQ_LIMIT = 1e-2^2 -- square of the velocity sleep limit + +local function can_sleep(spring: Spring): boolean + if vector.magnitude(spring.v)^2 > SLEEP_VELOCITY_SQ_LIMIT then + return false + end + + if (spring.p - spring.g).Magnitude^2 > SLEEP_OFFSET_SQ_LIMIT then + return false + end + + return true +end + +return { + create = create, + step = step, + can_sleep = can_sleep, +}