New modules with examples!

This commit is contained in:
Ukendio 2026-01-26 04:28:14 +01:00
parent 2f2ce3541d
commit a2e6c0aafa
13 changed files with 1039 additions and 1 deletions

View file

@ -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,

44
modules/BT/module.luau Executable file
View file

@ -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

View file

@ -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)

108
modules/GetRect/module.luau Executable file
View file

@ -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<T>(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<T>(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

View file

@ -0,0 +1,69 @@
local jecs = require("@jecs")
local OB = require("@modules/OB/module")
local ct = {
Transform = jecs.component() :: jecs.Id<CFrame>,
PivotTo = jecs.component() :: jecs.Id<CFrame>,
TRANSFORM_PREDICTED = jecs.tag(),
Renderable = jecs.component() :: jecs.Id<Instance>
}
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)

187
modules/Spring/cframe.luau Executable file
View file

@ -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
}

110
modules/Spring/color3.luau Executable file
View file

@ -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
}

View file

@ -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)

55
modules/Spring/generic.luau Executable file
View file

@ -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,
}

136
modules/Spring/number.luau Executable file
View file

@ -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,
}

149
modules/Spring/vector2.luau Executable file
View file

@ -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,
}

133
modules/Spring/vector3.luau Executable file
View file

@ -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,
}