Files
Unlucky-Launcher/crates/design/src/motion/spring.rs
T
AskaEth 1105a86448
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Release / 选槽位(默认两个 Windows) (push) Canceled after 0s
Release / build (${{ matrix.slot }}) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Unlucky Launcher v0.2.2
2026-11-07 08:46:11 +08:00

381 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 弹簧参数(M3 Expressive 动效的物理表达)与动效时长缩放。
//!
//! **来源 A 级**:`androidx.compose.material3.tokens.ExpressiveMotionTokens`(v0_14_0)给出六组
//! `Spring{Damping, Stiffness}`——注意 Compose 的 `Damping` 是**阻尼比 ζ**(不是绝对阻尼系数),
//! 所以本模块的 [`Spring`] 也按 (stiffness, damping_ratio, mass) 存,两者可直接对照。
//! 阶跃响应用标准二阶系统闭式解(欠阻尼 / 临界 / 过阻尼三分支),是纯数学、无状态。
/// 弹簧参数(Compose 口径:刚度 + 阻尼比 + 质量)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Spring {
/// 刚度 k(越大越快)。
pub stiffness: f32,
/// 阻尼比 ζ(1.0 = 临界阻尼,< 1 会轻微弹过)。
pub damping_ratio: f32,
/// 质量 m。
pub mass: f32,
}
impl Spring {
/// 按 M3 令牌口径构造(刚度 + 阻尼比 + 质量)。
pub const fn from_ratio(stiffness: f32, damping_ratio: f32, mass: f32) -> Self {
Self {
stiffness,
damping_ratio,
mass,
}
}
/// 自然角频率 ωn(rad/s)。
pub fn natural_frequency(self) -> f32 {
(self.stiffness / self.mass).sqrt()
}
/// 绝对阻尼系数 c = 2·ζ·√(k·m)(需要喂给「阻尼系数」式积分器时用)。
pub fn damping_coefficient(self) -> f32 {
2.0 * self.damping_ratio * (self.stiffness * self.mass).sqrt()
}
/// 阶跃响应:`t_ms` 毫秒后的进度(0..1,欠阻尼时会轻微超过 1 = 回弹)。
pub fn sample(self, t_ms: f32) -> f32 {
if t_ms <= 0.0 {
return 0.0;
}
let t = t_ms / 1000.0;
let wn = self.natural_frequency();
let zeta = self.damping_ratio;
if wn <= 0.0 {
return 1.0;
}
if zeta < 1.0 {
let wd = wn * (1.0 - zeta * zeta).sqrt();
let decay = (-zeta * wn * t).exp();
1.0 - decay * ((wd * t).cos() + (zeta * wn / wd) * (wd * t).sin())
} else if (zeta - 1.0).abs() < 1.0e-4 {
let decay = (-wn * t).exp();
1.0 - decay * (1.0 + wn * t)
} else {
let root = (zeta * zeta - 1.0).sqrt();
let r1 = -wn * (zeta - root);
let r2 = -wn * (zeta + root);
let denom = r2 - r1;
if denom.abs() < f32::EPSILON {
return 1.0;
}
1.0 - (r2 * (r1 * t).exp() - r1 * (r2 * t).exp()) / denom
}
}
/// 收敛到 `threshold`(0.1% 是常用的「停下来」判定)所需毫秒数。
pub fn settle_ms(self, threshold: f32) -> f32 {
let wn = self.natural_frequency();
let zeta = self.damping_ratio;
if wn <= 0.0 {
return 0.0;
}
let thr = threshold.clamp(1.0e-6, 0.5);
let seconds = if zeta < 1.0 {
let wd = (1.0 - zeta * zeta).sqrt().max(1.0e-4);
-(thr * wd).ln() / (zeta * wn)
} else if (zeta - 1.0).abs() < 1.0e-4 {
-(thr).ln() / wn
} else {
let root = (zeta * zeta - 1.0).sqrt();
let slow = wn * (zeta - root);
-(thr).ln() / slow
};
(seconds * 1000.0).max(0.0)
}
}
/// 动效方案(Expressive 把弹簧打包成 standard / expressive **两套**,不是一组)。
///
/// - [`MotionSchemeKind::Standard`]:保守、几乎不弹(spatial ζ=0.9);
/// - [`MotionSchemeKind::Expressive`]:D26 的视觉基线,回弹明显(spatial ζ=0.6~0.8)。
///
/// `effects`(透明度/颜色类)在两套方案里数值相同——效应类不该有可见回弹。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MotionSchemeKind {
/// 标准方案(保守)。
Standard,
/// Expressive 方案(本项目默认)。
Expressive,
}
/// 弹簧类别:空间类管**位移/尺寸**,效应类管**颜色/透明度**。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SpringCategory {
/// 位移/尺寸(会产生可见回弹)。
Spatial,
/// 颜色/透明度(不允许过冲)。
Effects,
}
/// 弹簧速度档。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SpringSpeed {
/// 快速:hover/press、小元素。
Fast,
/// 默认:面板、卡片、按下形变。
Default,
/// 慢速:大位移(>120px 的面板/抽屉)。
Slow,
}
/// 二维表查询:`方案 × 类别 × 速度 → 弹簧`(A 级:`StandardMotionTokens` 与
/// `ExpressiveMotionTokens`,经 `MotionScheme.standard()/expressive()` 组装)。
pub const fn spring(
scheme: MotionSchemeKind,
category: SpringCategory,
speed: SpringSpeed,
) -> Spring {
use MotionSchemeKind::{Expressive, Standard};
use SpringCategory::{Effects, Spatial};
use SpringSpeed::{Default, Fast, Slow};
match (scheme, category, speed) {
(Standard, Spatial, Fast) => Spring::from_ratio(1400.0, 0.9, 1.0),
(Standard, Spatial, Default) => Spring::from_ratio(700.0, 0.9, 1.0),
(Standard, Spatial, Slow) => Spring::from_ratio(300.0, 0.9, 1.0),
(Expressive, Spatial, Fast) => Spring::from_ratio(800.0, 0.6, 1.0),
(Expressive, Spatial, Default) => Spring::from_ratio(380.0, 0.8, 1.0),
(Expressive, Spatial, Slow) => Spring::from_ratio(200.0, 0.8, 1.0),
// effects 两套同值:ζ=1.0(临界阻尼,不过冲)。
(_, Effects, Fast) => Spring::from_ratio(3800.0, 1.0, 1.0),
(_, Effects, Default) => Spring::from_ratio(1600.0, 1.0, 1.0),
(_, Effects, Slow) => Spring::from_ratio(800.0, 1.0, 1.0),
}
}
/// 标准方案 · 空间 · 快速。
pub const SPRING_STANDARD_SPATIAL_FAST: Spring = spring(
MotionSchemeKind::Standard,
SpringCategory::Spatial,
SpringSpeed::Fast,
);
/// 标准方案 · 空间 · 默认。
pub const SPRING_STANDARD_SPATIAL_DEFAULT: Spring = spring(
MotionSchemeKind::Standard,
SpringCategory::Spatial,
SpringSpeed::Default,
);
/// 标准方案 · 空间 · 慢速。
pub const SPRING_STANDARD_SPATIAL_SLOW: Spring = spring(
MotionSchemeKind::Standard,
SpringCategory::Spatial,
SpringSpeed::Slow,
);
/// Expressive 方案 · 空间 · 快速(D26 基线的默认选择)。
pub const SPRING_EXPRESSIVE_SPATIAL_FAST: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Spatial,
SpringSpeed::Fast,
);
/// Expressive 方案 · 空间 · 默认:面板、卡片、按下形变。
pub const SPRING_EXPRESSIVE_SPATIAL_DEFAULT: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Spatial,
SpringSpeed::Default,
);
/// Expressive 方案 · 空间 · 慢速:大位移(>120px)。
pub const SPRING_EXPRESSIVE_SPATIAL_SLOW: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Spatial,
SpringSpeed::Slow,
);
/// 效应类 · 快速(两套方案同值):hover/press 颜色。
pub const SPRING_EFFECTS_FAST: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Effects,
SpringSpeed::Fast,
);
/// 效应类 · 默认(两套方案同值)。
pub const SPRING_EFFECTS_DEFAULT: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Effects,
SpringSpeed::Default,
);
/// 效应类 · 慢速(两套方案同值)。
pub const SPRING_EFFECTS_SLOW: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Effects,
SpringSpeed::Slow,
);
/// 动效时长的全局缩放(用户「减少动效」/CPU 路径关动画时用)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MotionScale {
factor: f32,
}
impl MotionScale {
/// 标准(1.0)。
pub const STANDARD: MotionScale = MotionScale { factor: 1.0 };
/// 关闭动效(时长归零;适配层应直接落终态)。
pub const NONE: MotionScale = MotionScale { factor: 0.0 };
/// 半速(弱动效机器/低帧率兜底)。
pub const HALF: MotionScale = MotionScale { factor: 0.5 };
/// 自定义缩放(夹取到 0.0..=1.0;不放大,避免慢到不可用)。
pub fn new(factor: f32) -> Self {
Self {
factor: factor.clamp(0.0, 1.0),
}
}
/// 缩放系数。
pub fn factor(self) -> f32 {
self.factor
}
/// 是否完全关闭(适配层据此跳过动画)。
pub fn is_off(self) -> bool {
self.factor <= 0.0
}
/// 把某一档时长换算成实际毫秒。
pub fn ms(self, token: super::DurationToken) -> f32 {
token.ms() as f32 * self.factor
}
/// 把弹簧的收敛时间换算成实际毫秒。
pub fn spring_ms(self, spring: Spring) -> f32 {
spring.settle_ms(0.001) * self.factor
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::motion::DurationToken;
const PRESETS: [Spring; 9] = [
SPRING_STANDARD_SPATIAL_FAST,
SPRING_STANDARD_SPATIAL_DEFAULT,
SPRING_STANDARD_SPATIAL_SLOW,
SPRING_EXPRESSIVE_SPATIAL_FAST,
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
SPRING_EXPRESSIVE_SPATIAL_SLOW,
SPRING_EFFECTS_FAST,
SPRING_EFFECTS_DEFAULT,
SPRING_EFFECTS_SLOW,
];
#[test]
fn every_cell_of_the_two_dimensional_table_resolves() {
for scheme in [MotionSchemeKind::Standard, MotionSchemeKind::Expressive] {
for category in [SpringCategory::Spatial, SpringCategory::Effects] {
for speed in [SpringSpeed::Fast, SpringSpeed::Default, SpringSpeed::Slow] {
let spring = spring(scheme, category, speed);
assert!(spring.stiffness > 0.0);
assert!(spring.damping_ratio > 0.0);
}
}
}
// effects 两套方案同值(效应类不该有可见回弹差异)。
for speed in [SpringSpeed::Fast, SpringSpeed::Default, SpringSpeed::Slow] {
assert_eq!(
spring(MotionSchemeKind::Standard, SpringCategory::Effects, speed),
spring(MotionSchemeKind::Expressive, SpringCategory::Effects, speed)
);
}
// expressive 的空间类更弹(阻尼比更低)。
for speed in [SpringSpeed::Fast, SpringSpeed::Default, SpringSpeed::Slow] {
assert!(
spring(MotionSchemeKind::Expressive, SpringCategory::Spatial, speed).damping_ratio
< spring(MotionSchemeKind::Standard, SpringCategory::Spatial, speed)
.damping_ratio
);
}
}
#[test]
fn spring_presets_behave() {
for spring in PRESETS {
assert_eq!(spring.sample(0.0), 0.0);
let settle = spring.settle_ms(0.001);
assert!(
(60.0..1500.0).contains(&settle),
"收敛时间 {settle}ms 不合理(k={} ζ={})",
spring.stiffness,
spring.damping_ratio
);
let end = spring.sample(settle);
assert!((end - 1.0).abs() < 0.01, "收敛后应回到 1.0,实测 {end}");
}
}
#[test]
fn effects_presets_are_critically_damped_and_never_overshoot() {
for spring in [
SPRING_EFFECTS_FAST,
SPRING_EFFECTS_DEFAULT,
SPRING_EFFECTS_SLOW,
] {
assert!((spring.damping_ratio - 1.0).abs() < 1.0e-6);
for step in 1..=60 {
let v = spring.sample(step as f32 * 5.0);
assert!(v <= 1.0 + 1.0e-3, "效应类弹簧不应过冲:{v}");
}
}
}
#[test]
fn spatial_presets_overdamp_into_settle_without_wild_overshoot() {
for spring in [
SPRING_EXPRESSIVE_SPATIAL_FAST,
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
SPRING_EXPRESSIVE_SPATIAL_SLOW,
] {
assert!(
spring.damping_ratio < 1.0,
"空间类弹簧欠阻尼(允许轻微过冲)"
);
let mut peak: f32 = 0.0;
for step in 1..=200 {
peak = peak.max(spring.sample(step as f32 * 2.0));
}
assert!(peak < 1.15, "过冲 {peak} 太大");
}
}
#[test]
fn critically_damped_spring_does_not_overshoot() {
let wn = SPRING_EXPRESSIVE_SPATIAL_DEFAULT.natural_frequency();
let critical = Spring::from_ratio(wn * wn, 1.0, 1.0);
for step in 1..=40 {
let v = critical.sample(step as f32 * 5.0);
assert!(v <= 1.0 + 1.0e-3, "临界阻尼不应过冲:{v}");
}
}
#[test]
fn overdamped_spring_converges_slowly_without_overshoot() {
let wn = SPRING_EXPRESSIVE_SPATIAL_DEFAULT.natural_frequency();
let over = Spring::from_ratio(wn * wn, 2.0, 1.0);
let settle = over.settle_ms(0.001);
assert!((over.sample(settle) - 1.0).abs() < 0.01);
for step in 1..=40 {
assert!(over.sample(step as f32 * 10.0) <= 1.0 + 1.0e-3);
}
}
#[test]
fn damping_coefficient_matches_ratio() {
let spring = SPRING_EXPRESSIVE_SPATIAL_DEFAULT;
let c = spring.damping_coefficient();
let zeta = c / (2.0 * (spring.stiffness * spring.mass).sqrt());
assert!((zeta - spring.damping_ratio).abs() < 1.0e-5);
}
#[test]
fn motion_scale_scales_or_kills_duration() {
assert_eq!(MotionScale::STANDARD.ms(DurationToken::Medium2), 300.0);
assert_eq!(MotionScale::HALF.ms(DurationToken::Medium2), 150.0);
assert_eq!(MotionScale::NONE.ms(DurationToken::Medium2), 0.0);
assert!(MotionScale::NONE.is_off());
assert_eq!(MotionScale::new(5.0).factor(), 1.0);
assert_eq!(MotionScale::new(-1.0).factor(), 0.0);
assert!(MotionScale::STANDARD.spring_ms(SPRING_EXPRESSIVE_SPATIAL_DEFAULT) > 0.0);
}
}