From 3cd3972b2ee658a14d2610d8494e09259e530124 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 22 May 2026 11:40:41 -0700 Subject: [PATCH] animations: advance springs by elapsed time (#110) * animations: advance springs by elapsed time * animations: do not floor spring step dt * animations: move spring helper body to cpp --- src/animation/AnimatedVariable.cpp | 24 +------ src/animation/Spring.cpp | 55 +++++++++++++++ src/animation/Spring.hpp | 9 +++ tests/animation/Animation.cpp | 104 ++++++++++++++++++----------- 4 files changed, 132 insertions(+), 60 deletions(-) create mode 100644 src/animation/Spring.cpp create mode 100644 src/animation/Spring.hpp diff --git a/src/animation/AnimatedVariable.cpp b/src/animation/AnimatedVariable.cpp index 8a1ff05..2532e3a 100644 --- a/src/animation/AnimatedVariable.cpp +++ b/src/animation/AnimatedVariable.cpp @@ -1,6 +1,7 @@ #include #include #include +#include "animation/Spring.hpp" #include #include @@ -122,29 +123,10 @@ CBaseAnimatedVariable::SCurveStepResult CBaseAnimatedVariable::getCurveStep() { return {.value = 1.F, .finished = true}; const auto NOW = std::chrono::steady_clock::now(); - float dt = std::chrono::duration(NOW - springLastStep).count(); + const auto DT = NOW - springLastStep; springLastStep = NOW; - constexpr float MINDELTA = 1.F / 240.F; - if (dt <= 0.F) - dt = MINDELTA; - else - dt = std::clamp(dt, MINDELTA, 0.05F); - - if (dt > 0.F) { - constexpr const float FIXEDSTEP = 1.F / 240.F; - const int SUBSTEPS = std::clamp(static_cast(std::ceil(dt / FIXEDSTEP)), 1, 16); - const float STEPTIME = dt / SUBSTEPS; - const float MASS = std::max(SPRING->mass, 0.0001f); - - for (int i = 0; i < SUBSTEPS; ++i) { - const float displacement = m_fSpringValue - 1.f; - const float acceleration = ((-SPRING->stiffness * displacement) - (SPRING->damping * m_fSpringVelocity)) / MASS; - - m_fSpringVelocity += acceleration * STEPTIME; - m_fSpringValue += m_fSpringVelocity * STEPTIME; - } - } + Details::advanceSpring(m_fSpringValue, m_fSpringVelocity, *SPRING, DT); const bool FINISHED = std::abs(1.F - m_fSpringValue) <= SPRING->valueEpsilon && std::abs(m_fSpringVelocity) <= SPRING->velocityEpsilon; if (FINISHED) { diff --git a/src/animation/Spring.cpp b/src/animation/Spring.cpp new file mode 100644 index 0000000..888ee48 --- /dev/null +++ b/src/animation/Spring.cpp @@ -0,0 +1,55 @@ +#include "animation/Spring.hpp" + +#include +#include + +using namespace Hyprutils::Animation; + +void Details::advanceSpring(float& value, float& velocity, const SSpringCurve& spring, std::chrono::duration elapsed) { + const float DT = std::max(elapsed.count(), 0.F); + if (DT <= 0.F) + return; + + const float MASS = std::max(spring.mass, 0.0001F); + const float STIFFNESS = std::max(spring.stiffness, 0.0001F); + const float DAMPING = std::max(spring.damping, 0.F); + + const float DISPLACEMENT = value - 1.F; + const float OMEGA0 = std::sqrt(STIFFNESS / MASS); + const float GAMMA = DAMPING / (2.F * MASS); + + if (GAMMA < OMEGA0) { + const float OMEGAD = std::sqrt((OMEGA0 * OMEGA0) - (GAMMA * GAMMA)); + const float EXP = std::exp(-GAMMA * DT); + const float SIN = std::sin(OMEGAD * DT); + const float COS = std::cos(OMEGAD * DT); + + const float NEW_DISPLACEMENT = EXP * ((DISPLACEMENT * COS) + (((velocity + (GAMMA * DISPLACEMENT)) / OMEGAD) * SIN)); + const float NEW_VELOCITY = EXP * ((velocity * COS) - (((GAMMA * velocity) + (OMEGA0 * OMEGA0 * DISPLACEMENT)) / OMEGAD) * SIN); + + value = 1.F + NEW_DISPLACEMENT; + velocity = NEW_VELOCITY; + return; + } + + const float CRITICAL_EPSILON = std::max(OMEGA0, 1.F) * 0.0001F; + if (std::abs(GAMMA - OMEGA0) <= CRITICAL_EPSILON) { + const float EXP = std::exp(-GAMMA * DT); + const float B = velocity + (GAMMA * DISPLACEMENT); + + value = 1.F + (EXP * (DISPLACEMENT + (B * DT))); + velocity = EXP * (velocity - (GAMMA * B * DT)); + return; + } + + const float ROOT = std::sqrt((GAMMA * GAMMA) - (OMEGA0 * OMEGA0)); + const float R1 = -GAMMA + ROOT; + const float R2 = -GAMMA - ROOT; + const float A = (velocity - (R2 * DISPLACEMENT)) / (R1 - R2); + const float B = DISPLACEMENT - A; + const float E1 = std::exp(R1 * DT); + const float E2 = std::exp(R2 * DT); + + value = 1.F + (A * E1) + (B * E2); + velocity = (A * R1 * E1) + (B * R2 * E2); +} diff --git a/src/animation/Spring.hpp b/src/animation/Spring.hpp new file mode 100644 index 0000000..2a66fc4 --- /dev/null +++ b/src/animation/Spring.hpp @@ -0,0 +1,9 @@ +#pragma once + +#include + +#include + +namespace Hyprutils::Animation::Details { + void advanceSpring(float& value, float& velocity, const SSpringCurve& spring, std::chrono::duration elapsed); +} diff --git a/tests/animation/Animation.cpp b/tests/animation/Animation.cpp index 6c471b4..d3bd119 100644 --- a/tests/animation/Animation.cpp +++ b/tests/animation/Animation.cpp @@ -6,6 +6,9 @@ #include #include #include +#include "animation/Spring.hpp" + +#include #define SP CSharedPointer #define WP CWeakPointer @@ -391,49 +394,72 @@ TEST(Animation, animation) { EXPECT_EQ(pAnimationManager.get(), nullptr); } -TEST(Animation, springRetargetPreservesVelocity) { - config(); - - pAnimationManager->addSpringWithName("momentum", - { - .stiffness = 120.f, - .damping = 8.f, - .mass = 1.f, - .valueEpsilon = 0.001f, - .velocityEpsilon = 0.001f, - }); - - animationTree.createNode("spring_global"); - animationTree.createNode("spring_velocity", "spring_global"); - animationTree.setConfigForNode("spring_global", 1, 1.f, "spring:momentum"); - - PANIMVAR av; - pAnimationManager->createAnimation(0, av, "spring_velocity"); - - *av = 100; - - int ticks = 0; - while (av->value() < 40 && ticks++ < 300) { - pAnimationManager->tick(); - } +TEST(Animation, springAdvanceUsesElapsedTime) { + const SSpringCurve SPRING = { + .stiffness = 100.f, + .damping = 20.f, + .mass = 1.f, + .valueEpsilon = 0.001f, + .velocityEpsilon = 0.001f, + }; + + float oneMsValue = 0.F; + float oneMsVelocity = 0.F; + float hundredMsValue = 0.F; + float hundredMsVelocity = 0.F; + float negativeValue = 0.F; + float negativeVelocity = 0.F; + + Details::advanceSpring(oneMsValue, oneMsVelocity, SPRING, std::chrono::milliseconds(1)); + Details::advanceSpring(hundredMsValue, hundredMsVelocity, SPRING, std::chrono::milliseconds(120)); + Details::advanceSpring(negativeValue, negativeVelocity, SPRING, std::chrono::milliseconds(-1)); + + EXPECT_GT(oneMsValue, 0.F); + EXPECT_GT(hundredMsValue, oneMsValue); + EXPECT_EQ(negativeValue, 0.F); + EXPECT_EQ(negativeVelocity, 0.F); +} - const auto RETARGETPOINT = av->value(); - EXPECT_GT(RETARGETPOINT, 20); +TEST(Animation, springAdvancesAcrossLateTicks) { + const SSpringCurve SPRING = { + .stiffness = 100.f, + .damping = 20.f, + .mass = 1.f, + .valueEpsilon = 0.001f, + .velocityEpsilon = 0.001f, + }; + + float lateValue = 0.F; + float lateVelocity = 0.F; + float cappedValue = 0.F; + float cappedVelocity = 0.F; + + Details::advanceSpring(lateValue, lateVelocity, SPRING, std::chrono::milliseconds(120)); + Details::advanceSpring(cappedValue, cappedVelocity, SPRING, std::chrono::milliseconds(50)); + + EXPECT_GT(lateValue, cappedValue); + EXPECT_GT(lateValue, 0.25F); +} - PANIMVAR fromRest; - pAnimationManager->createAnimation(RETARGETPOINT, fromRest, "spring_velocity"); +TEST(Animation, springDoesNotAdvanceFasterThanElapsedTime) { + const SSpringCurve SPRING = { + .stiffness = 38.f, + .damping = 8.f, + .mass = 2.4f, + .valueEpsilon = 0.001f, + .velocityEpsilon = 0.001f, + }; - *av = 0; - *fromRest = 0; - EXPECT_EQ(av->value(), RETARGETPOINT); + float repeatedValue = 0.F; + float repeatedVelocity = 0.F; + float singleValue = 0.F; + float singleVelocity = 0.F; - pAnimationManager->tick(); - EXPECT_GT(av->value(), fromRest->value()); + for (size_t i = 0; i < 132; ++i) + Details::advanceSpring(repeatedValue, repeatedVelocity, SPRING, std::chrono::milliseconds(1)); - while (pAnimationManager->shouldTickForNext() && ticks++ < 2000) { - pAnimationManager->tick(); - } + Details::advanceSpring(singleValue, singleVelocity, SPRING, std::chrono::milliseconds(132)); - EXPECT_EQ(av->value(), 0); - EXPECT_EQ(fromRest->value(), 0); + EXPECT_NEAR(repeatedValue, singleValue, 0.0001F); + EXPECT_LT(repeatedValue, 0.5F); }