From 99a94967b776b0ace94728c6e2d3f547067782fe Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Tue, 22 Sep 2026 11:04:14 +0200 Subject: [PATCH] perf(animator): cache the spring settle time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `spring_value` is reached per animated property, per node, per frame (`box_builder.rs:538` -> `resolve_props_for_effects` -> `resolve_animation_value_full`, animator.rs:997). When `SpringConfig::duration` is set, each of those calls runs a full coarse scan plus 40 bisection steps, with `steps` clamped to [2000, 20000]. For the shipped defaults (stiffness 100, mass 1 -> omega 10, period 0.628 s) `desired_steps` is 2292, so one animated property costs ~2333 `exp`/`sin`/`cos` evaluations per frame to recompute a value that depends only on (damping, stiffness, mass, rest_threshold) — completely invariant across frames. A 30 s/60 fps render with 20 nodes carrying two spring-duration properties each burns roughly 165 million redundant solver evaluations. The function's own doc comment acknowledges it is "called on every `spring_value` sample when `duration` is set". Refs #220 --- crates/rustmotion-core/src/engine/animator.rs | 55 ++++++++++++++----- crates/rustmotion-core/tests/audit_ws_a.rs | 38 +++++++++++++ 2 files changed, 79 insertions(+), 14 deletions(-) diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index 42c42787..be2dce5e 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -434,6 +434,45 @@ pub const DEFAULT_SPRING_REST_THRESHOLD: f64 = 0.005; /// rather than an unbounded loop. pub const MAX_SPRING_SEARCH_SECONDS: f64 = 30.0; +thread_local! { + /// Cache for [`spring_settle_time_cached`], keyed on the exact bit + /// pattern of its four inputs. One `SpringConfig` is sampled once per + /// animated property per node per frame, always with the same + /// (floored) `damping`/`stiffness`/`mass`/`threshold` — the scan result + /// is frame-invariant, so a thread-local map turns the whole render + /// into one real scan per distinct spring plus O(1) lookups instead of + /// one scan per sample. + static SPRING_SETTLE_TIME_CACHE: std::cell::RefCell> = + std::cell::RefCell::new(std::collections::HashMap::new()); +} + +/// Memoized [`spring_settle_time`]: identical inputs always produce the +/// identical scan result, so a cache hit skips the coarse-then-bisect +/// search entirely. `max_t` is not part of the key because both call sites +/// below always pass [`MAX_SPRING_SEARCH_SECONDS`]. +fn spring_settle_time_cached(damping: f64, stiffness: f64, mass: f64, threshold: f64) -> f64 { + let key = ( + damping.to_bits(), + stiffness.to_bits(), + mass.to_bits(), + threshold.to_bits(), + ); + SPRING_SETTLE_TIME_CACHE.with(|cache| { + if let Some(&cached) = cache.borrow().get(&key) { + return cached; + } + let settled = spring_settle_time( + damping, + stiffness, + mass, + threshold, + MAX_SPRING_SEARCH_SECONDS, + ); + cache.borrow_mut().insert(key, settled); + settled + }) +} + /// Solve spring animation at time t (seconds). /// Returns a value between 0.0 and 1.0 representing progress. /// @@ -467,13 +506,7 @@ pub fn spring_value(t: f64, config: &SpringConfig) -> f64 { match config.duration { Some(duration) if duration > 0.0 => { let threshold = spring_rest_threshold(config); - let natural_rest = spring_settle_time( - damping, - stiffness, - mass, - threshold, - MAX_SPRING_SEARCH_SECONDS, - ); + let natural_rest = spring_settle_time_cached(damping, stiffness, mass, threshold); if natural_rest < 1e-9 { // Degenerate: the spring starts at distance 1.0 from its // target, so in practice `natural_rest` is never this @@ -623,13 +656,7 @@ pub fn spring_rest_time(config: &SpringConfig) -> f64 { let stiffness = config.stiffness.max(1e-6); let mass = config.mass.max(1e-6); let threshold = spring_rest_threshold(config); - spring_settle_time( - damping, - stiffness, - mass, - threshold, - MAX_SPRING_SEARCH_SECONDS, - ) + spring_settle_time_cached(damping, stiffness, mass, threshold) } } } diff --git a/crates/rustmotion-core/tests/audit_ws_a.rs b/crates/rustmotion-core/tests/audit_ws_a.rs index dbf4160d..d264fbae 100644 --- a/crates/rustmotion-core/tests/audit_ws_a.rs +++ b/crates/rustmotion-core/tests/audit_ws_a.rs @@ -242,3 +242,41 @@ fn linear_gradient_180deg_puts_the_first_stop_at_the_top() { "angle: 180 must put the black last stop at the bottom, got {bottom:?}" ); } + +// ---- `spring_settle_time` must not be rescanned on every `spring_value` call ---- + +#[test] +fn spring_settle_time_is_memoized_not_rescanned_every_call() { + // `SpringConfig::duration` routes every `spring_value` sample through + // `spring_settle_time`'s 2k-20k-step coarse-then-bisect scan (see + // animator.rs). The scan result depends only on the spring's own + // (damping, stiffness, mass, threshold) — invariant across every frame + // an animation is sampled at — so repeating it per call is pure waste. + // Measured uncached on this parameter set: 2000 calls take ~530ms in a + // debug build; memoized, the same 2000 calls (one real scan, the rest + // cache hits) complete in well under a tenth of that. + let config = SpringConfig { + damping: 37.0, + stiffness: 733.0, + mass: 1.0, + duration: Some(0.42), + rest_threshold: None, + }; + let start = std::time::Instant::now(); + let mut acc = 0.0; + for i in 0..2000 { + let t = (i as f64) * 1e-4; + acc += spring_value(t, &config); + } + let elapsed = start.elapsed(); + assert!( + acc.is_finite(), + "sanity: accumulated spring values must be finite" + ); + assert!( + elapsed.as_millis() < 150, + "2000 spring_value calls with the same spring parameters took {elapsed:?}; \ + spring_settle_time must be memoized on (damping, stiffness, mass, threshold) rather \ + than re-scanned on every call" + ); +}