Skip to content

fix(animator): start the underdamped spring from rest - #224

Closed
LeadcodeDev wants to merge 1 commit into
fix/layer-bounds-outset-shadowfrom
fix/spring-step-response
Closed

LeadcodeDev wants to merge 1 commit into
fix/layer-bounds-outset-shadowfrom
fix/spring-step-response

Conversation

@LeadcodeDev

Copy link
Copy Markdown
Owner

Severity Medium, category correctness. Location: crates/rustmotion-core/src/engine/animator.rs:504

Impact

The closed-form underdamped step response is 1 - e^(-zeta*omega*t) * [cos(omega_d*t) + (zeta*omega/omega_d)*sin(omega_d*t)]. The code feeds zeta*omega*t/omega_d into sin() instead of omega_d*t — the t was multiplied into the coefficient instead of the phase. I verified this numerically against a 400k-step ODE integration of m*x'' + c*x' + k*x = k: the correct formula matches the integrator to 5 decimals, the repo formula does not. With the shipped defaults (damping 15, stiffness 100, mass 1, zeta = 0.75), at the first 60fps frame after the start (t = 0.0167 s) the solver reports 0.10416 of the travel where the physics gives 0.01282 — 8x. Initial velocity is 6.21/s instead of 0, so the element visibly snaps on frame 1 rather than easing out of rest, and the overshoot peak is wrong too (1.0194 vs 1.0284). This affects every spring path in the engine: EasingType::Spring keyframes, the bounce_in preset (kf_anim_spring, zeta = 0.6, animator.rs:2092), elastic_in (kf_anim_spring_underdamped, zeta = 0.274, animator.rs:2106), and every author-supplied spring override applied through apply_spring_to_motion. spring_settle_time/spring_rest_time (what rustmotion info reports and what the spring.duration remap divides by) are derived from the same wrong curve.

Fix

Change the sine argument to (omega_d * t).sin(), keeping the (zeta * omega / omega_d) coefficient: 1.0 - decay * ((omega_d * t).sin() * (zeta * omega / omega_d) + (omega_d * t).cos()). Add a regression test asserting the derivative at t=0 is ~0 (a step response starts at rest) and comparing a few samples against a numerically integrated reference.

Evidence the audit read

if zeta < 1.0 {
        // Underdamped
        let omega_d = omega * (1.0 - zeta * zeta).sqrt();
        let decay = (-zeta * omega * t).exp();
        1.0 - decay
            * ((zeta * omega * t / omega_d).sin() * (zeta * omega / omega_d) + (omega_d * t).cos())

Stacked on fix/layer-bounds-outset-shadow, which carries the previous finding of this workstream. GitHub shows only this finding's diff; merge in order.

Part of the September 2026 audit remediation chantier. Refs #220 (RM-09).

@LeadcodeDev LeadcodeDev added the bug Something isn't working label Sep 21, 2026
@LeadcodeDev LeadcodeDev self-assigned this Sep 21, 2026
@LeadcodeDev
LeadcodeDev force-pushed the fix/layer-bounds-outset-shadow branch from baafd53 to 7c9e21a Compare September 21, 2026 23:39
@LeadcodeDev
LeadcodeDev force-pushed the fix/spring-step-response branch from 4c92d97 to 42a83b8 Compare September 21, 2026 23:39
@LeadcodeDev
LeadcodeDev force-pushed the fix/layer-bounds-outset-shadow branch from fcbc3cf to ee0e0e8 Compare September 22, 2026 06:09
@LeadcodeDev
LeadcodeDev force-pushed the fix/spring-step-response branch from 5a808bd to d414df5 Compare September 22, 2026 06:09
@LeadcodeDev
LeadcodeDev force-pushed the fix/layer-bounds-outset-shadow branch from ee0e0e8 to 6890377 Compare September 22, 2026 08:33
@LeadcodeDev
LeadcodeDev force-pushed the fix/spring-step-response branch from d414df5 to bdc887f Compare September 22, 2026 08:33
@LeadcodeDev
LeadcodeDev force-pushed the fix/layer-bounds-outset-shadow branch from 6890377 to 7aa0de5 Compare September 22, 2026 08:43
The closed-form underdamped step response is 1 - e^(-zeta*omega*t) *
[cos(omega_d*t) + (zeta*omega/omega_d)*sin(omega_d*t)]. The code feeds
zeta*omega*t/omega_d into sin() instead of omega_d*t — the t was multiplied
into the coefficient instead of the phase. I verified this numerically
against a 400k-step ODE integration of m*x'' + c*x' + k*x = k: the correct
formula matches the integrator to 5 decimals, the repo formula does not.
With the shipped defaults (damping 15, stiffness 100, mass 1, zeta = 0.75),
at the first 60fps frame after the start (t = 0.0167 s) the solver reports
0.10416 of the travel where the physics gives 0.01282 — 8x. Initial velocity
is 6.21/s instead of 0, so the element visibly snaps on frame 1 rather than
easing out of rest, and the overshoot peak is wrong too (1.0194 vs 1.0284).
This affects every spring path in the engine: EasingType::Spring keyframes,
the bounce_in preset (kf_anim_spring, zeta = 0.6, animator.rs:2092),
elastic_in (kf_anim_spring_underdamped, zeta = 0.274, animator.rs:2106), and
every author-supplied spring override applied through
apply_spring_to_motion. spring_settle_time/spring_rest_time (what rustmotion
info reports and what the spring.duration remap divides by) are derived from
the same wrong curve.

Fix: Change the sine argument to (omega_d * t).sin(), keeping the (zeta *
omega / omega_d) coefficient: 1.0 - decay * ((omega_d * t).sin() * (zeta *
omega / omega_d) + (omega_d * t).cos()). Add a regression test asserting the
derivative at t=0 is ~0 (a step response starts at rest) and comparing a few
samples against a numerically integrated reference.

Refs #220
@LeadcodeDev
LeadcodeDev force-pushed the fix/spring-step-response branch from bdc887f to 1cde354 Compare September 22, 2026 08:43
@LeadcodeDev
LeadcodeDev deleted the branch fix/layer-bounds-outset-shadow September 22, 2026 08:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant