Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 41 additions & 14 deletions crates/rustmotion-core/src/engine/animator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::collections::HashMap<(u64, u64, u64, u64), f64>> =
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.
///
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
38 changes: 38 additions & 0 deletions crates/rustmotion-core/tests/audit_ws_a.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
Loading