From 00ae7d38939e3e0c3414009f49d1dabb3a6345bd Mon Sep 17 00:00:00 2001 From: Vlad Zahorodnii Date: Tue, 3 May 2022 23:00:47 +0300 Subject: [PATCH] effects/slide: Use mass-spring-damper model for animation If you lift fingers but not swipe them enough to switch to another virtual desktop, the slide effect will play an animation to move from the current position in the virtual desktop grid to the current desktop. However, that animation doesn't feel right, there's something missing. The slide effect uses a TimeLine to animate switching between virtual desktops, it's great if the amount of sliding is constant. This change makes the slide effect use the mass-spring-damper model to simulate the motion of a spring in order to animate switching between virtual desktops. The mass-spring-damper equation is integrated using RK4. If the delta interval is not multiple of the integration step precisely, the SpringMotion will perform integration as many times as the integration step fits into the delta. The leftover will be used for LERP between the previous and the next integration results. With the spring animation, the slide animation feels more natural when you lift fingers. If you switch between virtual desktops without using a gesture, the slide animation should look almost the same as if it were implemented with the TimeLine. --- src/effects/slide/CMakeLists.txt | 1 + src/effects/slide/slide.cpp | 37 +++---- src/effects/slide/slide.h | 13 +-- src/effects/slide/slide.kcfg | 3 - src/effects/slide/slide_config.ui | 33 ------- src/effects/slide/springmotion.cpp | 154 +++++++++++++++++++++++++++++ src/effects/slide/springmotion.h | 103 +++++++++++++++++++ 7 files changed, 278 insertions(+), 66 deletions(-) create mode 100644 src/effects/slide/springmotion.cpp create mode 100644 src/effects/slide/springmotion.h diff --git a/src/effects/slide/CMakeLists.txt b/src/effects/slide/CMakeLists.txt index 8a3656e822..57e77b657e 100644 --- a/src/effects/slide/CMakeLists.txt +++ b/src/effects/slide/CMakeLists.txt @@ -4,6 +4,7 @@ set(slide_SOURCES main.cpp slide.cpp + springmotion.cpp ) kconfig_add_kcfg_files(slide_SOURCES diff --git a/src/effects/slide/slide.cpp b/src/effects/slide/slide.cpp index c797ff5ea8..07448081f2 100644 --- a/src/effects/slide/slide.cpp +++ b/src/effects/slide/slide.cpp @@ -25,8 +25,6 @@ SlideEffect::SlideEffect() initConfig(); reconfigure(ReconfigureAll); - m_timeLine.setEasingCurve(QEasingCurve::OutCubic); - connect(effects, QOverload::of(&EffectsHandler::desktopChanged), this, &SlideEffect::desktopChanged); connect(effects, QOverload::of(&EffectsHandler::desktopChanging), @@ -61,8 +59,11 @@ void SlideEffect::reconfigure(ReconfigureFlags) { SlideConfig::self()->read(); - m_fullAnimationDuration = animationTime(500); - m_timeLine.setDuration(std::chrono::milliseconds(m_fullAnimationDuration)); + const qreal springConstant = 200.0 / effects->animationTimeFactor(); + const qreal dampingRatio = 1.1; + + m_motionX = SpringMotion(springConstant, dampingRatio); + m_motionY = SpringMotion(springConstant, dampingRatio); m_hGap = SlideConfig::horizontalGap(); m_vGap = SlideConfig::verticalGap(); @@ -96,10 +97,13 @@ void SlideEffect::prePaintScreen(ScreenPrePaintData &data, std::chrono::millisec timeDelta = presentTime - m_lastPresentTime; } m_lastPresentTime = presentTime; - m_timeLine.update(timeDelta); if (m_state == State::ActiveAnimation) { - m_currentPosition = m_startPos + (m_endPos - m_startPos) * m_timeLine.value(); + m_motionX.advance(timeDelta); + m_motionY.advance(timeDelta); + const QSize virtualSpaceSize = effects->virtualScreenSize(); + m_currentPosition.setX(m_motionX.position() / virtualSpaceSize.width()); + m_currentPosition.setY(m_motionY.position() / virtualSpaceSize.height()); } const int w = effects->desktopGridWidth(); @@ -306,7 +310,7 @@ void SlideEffect::paintWindow(EffectWindow *w, int mask, QRegion region, WindowP void SlideEffect::postPaintScreen() { - if (m_state == State::ActiveAnimation && m_timeLine.done()) { + if (m_state == State::ActiveAnimation && !m_motionX.isMoving() && !m_motionY.isMoving()) { finishedSwitching(); } @@ -351,7 +355,6 @@ void SlideEffect::startAnimation(int old, int current, EffectWindow *movingWindo m_state = State::ActiveAnimation; m_movingWindow = movingWindow; - m_timeLine.reset(); m_startPos = m_currentPosition; m_endPos = effects->desktopGridCoords(current); @@ -359,19 +362,11 @@ void SlideEffect::startAnimation(int old, int current, EffectWindow *movingWindo optimizePath(); } - // Find an apropriate duration - QPointF distance = m_startPos - m_endPos; - distance.setX(std::abs(distance.x())); - distance.setY(std::abs(distance.y())); - if (distance.x() < 1 && distance.y() < 1) { - if (distance.x() > distance.y()) { - m_timeLine.setDuration(std::chrono::milliseconds(std::max(1, (int)(m_fullAnimationDuration * distance.x())))); - } else { - m_timeLine.setDuration(std::chrono::milliseconds(std::max(1, (int)(m_fullAnimationDuration * distance.y())))); - } - } else { - m_timeLine.setDuration(std::chrono::milliseconds(m_fullAnimationDuration)); - } + const QSize virtualSpaceSize = effects->virtualScreenSize(); + m_motionX.setAnchor(m_endPos.x() * virtualSpaceSize.width()); + m_motionX.setPosition(m_startPos.x() * virtualSpaceSize.width()); + m_motionY.setAnchor(m_endPos.y() * virtualSpaceSize.height()); + m_motionY.setPosition(m_startPos.y() * virtualSpaceSize.height()); effects->setActiveFullScreenEffect(this); effects->addRepaintFull(); diff --git a/src/effects/slide/slide.h b/src/effects/slide/slide.h index 71831d2a96..ea81fefb75 100644 --- a/src/effects/slide/slide.h +++ b/src/effects/slide/slide.h @@ -15,6 +15,8 @@ // kwineffects #include +#include "springmotion.h" + namespace KWin { @@ -45,7 +47,6 @@ namespace KWin class SlideEffect : public Effect { Q_OBJECT - Q_PROPERTY(int duration READ duration) Q_PROPERTY(int horizontalGap READ horizontalGap) Q_PROPERTY(int verticalGap READ verticalGap) Q_PROPERTY(bool slideDocks READ slideDocks) @@ -69,7 +70,6 @@ public: static bool supported(); - int duration() const; int horizontalGap() const; int verticalGap() const; bool slideDocks() const; @@ -101,7 +101,6 @@ private: int m_vGap; bool m_slideDocks; bool m_slideBackground; - int m_fullAnimationDuration; // Miliseconds for 1 complete desktop switch enum class State { Inactive, @@ -110,7 +109,8 @@ private: }; State m_state = State::Inactive; - TimeLine m_timeLine; + SpringMotion m_motionX; + SpringMotion m_motionY; // When the desktop isn't desktopChanging(), these two variables are used to control the animation path. // They use desktops as a unit. @@ -136,11 +136,6 @@ private: EffectWindowList m_elevatedWindows; }; -inline int SlideEffect::duration() const -{ - return m_fullAnimationDuration; -} - inline int SlideEffect::horizontalGap() const { return m_hGap; diff --git a/src/effects/slide/slide.kcfg b/src/effects/slide/slide.kcfg index 183c7930fb..42de60a304 100644 --- a/src/effects/slide/slide.kcfg +++ b/src/effects/slide/slide.kcfg @@ -6,9 +6,6 @@ http://www.kde.org/standards/kcfg/1.0/kcfg.xsd" > - - 0 - 45 diff --git a/src/effects/slide/slide_config.ui b/src/effects/slide/slide_config.ui index 4c9cb31ed6..1fc7b63103 100644 --- a/src/effects/slide/slide_config.ui +++ b/src/effects/slide/slide_config.ui @@ -11,39 +11,6 @@ - - - - - - Duration: - - - - - - - - 0 - 0 - - - - Default - - - milliseconds - - - 9999 - - - 10 - - - - - diff --git a/src/effects/slide/springmotion.cpp b/src/effects/slide/springmotion.cpp new file mode 100644 index 0000000000..5007d29ff2 --- /dev/null +++ b/src/effects/slide/springmotion.cpp @@ -0,0 +1,154 @@ +/* + SPDX-FileCopyrightText: 2022 Vlad Zahorodnii + + SPDX-License-Identifier: GPL-2.0-or-later +*/ + +#include "springmotion.h" + +#include + +namespace KWin +{ + +static qreal lerp(qreal a, qreal b, qreal t) +{ + return a * (1 - t) + b * t; +} + +SpringMotion::SpringMotion() + : SpringMotion(200.0, 1.1) +{ +} + +SpringMotion::SpringMotion(qreal springConstant, qreal dampingRatio) + : m_prev({0, 0}) + , m_next({0, 0}) + , m_t(1.0) + , m_timestep(1.0 / 100.0) + , m_anchor(0) + , m_springConstant(springConstant) + , m_dampingRatio(dampingRatio) + , m_dampingCoefficient(2 * std::sqrt(m_springConstant) * m_dampingRatio) + , m_epsilon(1.0) +{ +} + +bool SpringMotion::isMoving() const +{ + return std::fabs(position() - anchor()) > m_epsilon || std::fabs(velocity()) > m_epsilon; +} + +qreal SpringMotion::springConstant() const +{ + return m_springConstant; +} + +qreal SpringMotion::dampingRatio() const +{ + return m_dampingRatio; +} + +qreal SpringMotion::velocity() const +{ + return lerp(m_prev.velocity, m_next.velocity, m_t); +} + +void SpringMotion::setVelocity(qreal velocity) +{ + m_next = State{ + .position = position(), + .velocity = velocity, + }; + m_t = 1.0; +} + +qreal SpringMotion::position() const +{ + return lerp(m_prev.position, m_next.position, m_t); +} + +void SpringMotion::setPosition(qreal position) +{ + m_next = State{ + .position = position, + .velocity = velocity(), + }; + m_t = 1.0; +} + +qreal SpringMotion::epsilon() const +{ + return m_epsilon; +} + +void SpringMotion::setEpsilon(qreal epsilon) +{ + m_epsilon = epsilon; +} + +qreal SpringMotion::anchor() const +{ + return m_anchor; +} + +void SpringMotion::setAnchor(qreal anchor) +{ + m_anchor = anchor; +} + +SpringMotion::Slope SpringMotion::evaluate(const State &state, qreal dt, const Slope &slope) +{ + const State next{ + .position = state.position + slope.dp * dt, + .velocity = state.velocity + slope.dv * dt, + }; + + // The math here follows from the mass-spring-damper model equation. + const qreal springForce = (m_anchor - next.position) * m_springConstant; + const qreal dampingForce = -next.velocity * m_dampingCoefficient; + const qreal acceleration = springForce + dampingForce; + + return Slope{ + .dp = state.velocity, + .dv = acceleration, + }; +} + +SpringMotion::State SpringMotion::integrate(const State &state, qreal dt) +{ + // Use Runge-Kutta method (RK4) to integrate the mass-spring-damper equation. + const Slope initial{ + .dp = 0, + .dv = 0, + }; + const Slope k1 = evaluate(state, 0.0, initial); + const Slope k2 = evaluate(state, 0.5 * dt, k1); + const Slope k3 = evaluate(state, 0.5 * dt, k2); + const Slope k4 = evaluate(state, dt, k3); + + const qreal dpdt = 1.0 / 6.0 * (k1.dp + 2 * k2.dp + 2 * k3.dp + k4.dp); + const qreal dvdt = 1.0 / 6.0 * (k1.dv + 2 * k2.dv + 2 * k3.dv + k4.dv); + + return State{ + .position = state.position + dpdt * dt, + .velocity = state.velocity + dvdt * dt, + }; +} + +void SpringMotion::advance(std::chrono::milliseconds delta) +{ + if (!isMoving()) { + return; + } + + // If the delta interval is not multiple of m_timestep precisely, the previous and + // the next samples will be linearly interpolated to get current position and velocity. + const qreal steps = (delta.count() / 1000.0) / m_timestep; + for (m_t += steps; m_t > 1.0; m_t -= 1.0) { + m_prev = m_next; + m_next = integrate(m_next, m_timestep); + } +} + +} // namespace KWin diff --git a/src/effects/slide/springmotion.h b/src/effects/slide/springmotion.h new file mode 100644 index 0000000000..cd580a6fde --- /dev/null +++ b/src/effects/slide/springmotion.h @@ -0,0 +1,103 @@ +/* + SPDX-FileCopyrightText: 2022 Vlad Zahorodnii + + SPDX-License-Identifier: GPL-2.0-or-later +*/ + +#pragma once + +#include + +#include + +namespace KWin +{ + +/** + * The SpringMotion class simulates the motion of a spring along one dimension using the + * mass-spring-damper model. The sping constant parameter controls the acceleration of the + * spring. The damping ratio controls the oscillation of the spring. + */ +class SpringMotion +{ +public: + SpringMotion(); + SpringMotion(qreal springConstant, qreal dampingRatio); + + /** + * Advance the simulation by the given @a delta milliseconds. + */ + void advance(std::chrono::milliseconds delta); + bool isMoving() const; + + /** + * Returns the current velocity. + */ + qreal velocity() const; + void setVelocity(qreal velocity); + + /** + * Returns the current position. + */ + qreal position() const; + void setPosition(qreal position); + + /** + * Returns the anchor position. It's the position that the spring is pulled towards. + */ + qreal anchor() const; + void setAnchor(qreal anchor); + + /** + * Returns the spring constant. It controls the acceleration of the spring. + */ + qreal springConstant() const; + + /** + * Returns the damping ratio. It controls the oscillation of the spring. Potential values: + * + * - 0 or undamped: the spring will oscillate indefinitely + * - less than 1 or underdamped: the mass tends to overshoot its starting position, but with + * every oscillation some energy is dissipated and the oscillation dies away + * - 1 or critically damped: the mass will fail to overshoot and make a single oscillation + * - greater than 1 or overdamped: the mass slowly returns to the anchor position without + * overshooting + */ + qreal dampingRatio() const; + + /** + * If the distance of the mass between two consecutive simulations is smaller than the epsilon + * value, consider that the mass has stopped moving. + */ + qreal epsilon() const; + void setEpsilon(qreal epsilon); + +private: + struct State + { + qreal position; + qreal velocity; + }; + + struct Slope + { + qreal dp; + qreal dv; + }; + + State integrate(const State &state, qreal dt); + Slope evaluate(const State &state, qreal dt, const Slope &slope); + + State m_prev; + State m_next; + qreal m_t; + qreal m_timestep; + + qreal m_anchor; + qreal m_springConstant; + qreal m_dampingRatio; + qreal m_dampingCoefficient; + qreal m_epsilon; +}; + +} // namespace KWin