Skip to main content

cranpose_liquid/
dynamics.rs

1//! Water-droplet motion physics shared by every travelling liquid lens.
2//!
3//! The reference lens (example/iphone17_records, example/target/tab-swipe)
4//! deforms with its motion, not with its position: cruising speed stretches
5//! the bubble along the travel axis, acceleration compresses it against the
6//! push, deceleration releases that compression past neutral and swells the
7//! leading edge — and whatever one axis does, the orthogonal axis does in
8//! reverse so the droplet keeps its area (an incompressible bubble).
9//!
10//! [`LiquidDynamics`] is the one frame integrator implementing that law.
11//! Widgets feed it the lens ride position once per drawn frame (from inside
12//! their `glass_effect_with` closure) and map the returned [`LiquidPose`]
13//! onto the morph uniforms. Time comes from the runtime's animation clock —
14//! never wall time — so poses stay exact under robot keyframe captures and
15//! on wasm.
16
17use std::{cell::Cell, rc::Rc};
18
19use cranpose_core::{RuntimeHandle, with_current_composer};
20use cranpose_macros::composable;
21
22use crate::material::GlassDeformation;
23
24const STRETCH_PER_SPEED: f32 = 3.2e-4;
25const STRETCH_PER_ACCEL: f32 = 3.5e-5;
26/// The droplet never deforms past these bounds, however violent the fling.
27/// Public so hosting nodes can budget layout headroom for the extremes.
28pub const STRETCH_MIN: f32 = 0.78;
29pub const STRETCH_MAX: f32 = 1.50;
30const BULGE_PER_ACCELERATION: f32 = 4.5e-4;
31pub const BULGE_MAX: f32 = 8.0;
32const ATTACK_TAU: f32 = 0.03;
33const RELEASE_TAU: f32 = 0.11;
34const POINTER_VELOCITY_TAU: f32 = 0.045;
35const POINTER_STOP_HORIZON_NANOS: u64 = 40_000_000;
36const POINTER_COAST_TAU: f32 = 0.10;
37const AXIS_MIN_SPEED: f32 = 60.0;
38const DT_MIN: f32 = 1.0 / 1000.0;
39const DT_MAX: f32 = 1.0 / 15.0;
40const TELEPORT_SPEED: f32 = 30_000.0;
41
42/// Motion-derived deformation of a liquid lens for one frame.
43#[derive(Clone, Copy, Debug, PartialEq)]
44pub struct LiquidPose {
45    /// Scale along the motion axis (>1 = elongated, <1 = compressed).
46    pub stretch: f32,
47    /// Scale across the motion axis; always `1/stretch` (area conserved).
48    pub ortho: f32,
49    /// Unit motion direction (math convention, +x right / +y up in caller
50    /// space — callers feed whatever space they draw in).
51    pub axis: (f32, f32),
52    /// Leading-edge swell amplitude (dp) toward `bulge_direction` (radians).
53    pub bulge_amplitude: f32,
54    pub bulge_direction: f32,
55    /// Smoothed travel speed (dp/s) for auxiliary channels (surface depth,
56    /// wobble, chromatic fringe).
57    pub speed: f32,
58}
59
60impl Default for LiquidPose {
61    fn default() -> Self {
62        Self {
63            stretch: 1.0,
64            ortho: 1.0,
65            axis: (1.0, 0.0),
66            bulge_amplitude: 0.0,
67            bulge_direction: 0.0,
68            speed: 0.0,
69        }
70    }
71}
72
73impl LiquidPose {
74    /// The full rotating strain tensor for the shader. Applying this to the
75    /// signed-distance field preserves area for horizontal, vertical and
76    /// diagonal travel; projecting it into an axis-aligned width/height pair
77    /// cannot preserve that invariant away from the cardinal axes.
78    pub fn deformation(&self) -> GlassDeformation {
79        GlassDeformation::incompressible(self.axis, self.stretch)
80    }
81
82    /// Normalized motion energy in 0..1 (`speed` against a reference fling
83    /// of ~1100 dp/s) — the shared ramp for speed-driven side channels.
84    pub fn energy(&self) -> f32 {
85        (self.speed / 1100.0).clamp(0.0, 1.0)
86    }
87}
88
89/// Per-lens frame integrator. Interior-mutable so per-frame draw closures
90/// (plain `Fn`) can advance it without a `RefCell` dance.
91pub struct LiquidDynamics {
92    runtime: RuntimeHandle,
93    last_nanos: Cell<Option<u64>>,
94    last_pos: Cell<Option<(f32, f32)>>,
95    velocity: Cell<(f32, f32)>,
96    stretch: Cell<f32>,
97    bulge_vector: Cell<(f32, f32)>,
98    speed: Cell<f32>,
99    axis: Cell<(f32, f32)>,
100    pose: Cell<LiquidPose>,
101    pointer_pose_pending: Cell<bool>,
102    pointer_active: Cell<bool>,
103    last_pointer_nanos: Cell<Option<u64>>,
104}
105
106impl LiquidDynamics {
107    pub fn new(runtime: RuntimeHandle) -> Self {
108        Self {
109            runtime,
110            last_nanos: Cell::new(None),
111            last_pos: Cell::new(None),
112            velocity: Cell::new((0.0, 0.0)),
113            stretch: Cell::new(1.0),
114            bulge_vector: Cell::new((0.0, 0.0)),
115            speed: Cell::new(0.0),
116            axis: Cell::new((1.0, 0.0)),
117            pose: Cell::new(LiquidPose::default()),
118            pointer_pose_pending: Cell::new(false),
119            pointer_active: Cell::new(false),
120            last_pointer_nanos: Cell::new(None),
121        }
122    }
123
124    /// Forget all motion state (fresh grab, teleporting relayout): the next
125    /// update re-anchors at rest.
126    pub fn reset(&self) {
127        self.last_nanos.set(None);
128        self.last_pos.set(None);
129        self.velocity.set((0.0, 0.0));
130        self.stretch.set(1.0);
131        self.bulge_vector.set((0.0, 0.0));
132        self.speed.set(0.0);
133        self.pose.set(LiquidPose {
134            axis: self.axis.get(),
135            ..LiquidPose::default()
136        });
137        self.pointer_pose_pending.set(false);
138        self.pointer_active.set(false);
139        self.last_pointer_nanos.set(None);
140    }
141
142    pub(crate) fn anchor_pointer(&self, pos: (f32, f32)) {
143        self.reset();
144        self.last_pos.set(Some(pos));
145        let now = self.runtime.last_frame_time_nanos();
146        self.last_nanos.set(now);
147        self.last_pointer_nanos.set(now);
148        self.pointer_active.set(true);
149    }
150
151    pub(crate) fn advance_pointer(&self, pos: (f32, f32), dt: f32) -> LiquidPose {
152        let Some(last_pos) = self.last_pos.get() else {
153            self.last_pos.set(Some(pos));
154            return self.pose.get();
155        };
156        let dt = dt.clamp(DT_MIN, DT_MAX);
157        let raw_velocity = ((pos.0 - last_pos.0) / dt, (pos.1 - last_pos.1) / dt);
158        self.last_pos.set(Some(pos));
159        let previous = self.velocity.get();
160        let follow = 1.0 - (-dt / POINTER_VELOCITY_TAU).exp();
161        let filtered_velocity = (
162            previous.0 + (raw_velocity.0 - previous.0) * follow,
163            previous.1 + (raw_velocity.1 - previous.1) * follow,
164        );
165        let pose = self.advance_velocity(filtered_velocity, dt);
166        let now = self.runtime.last_frame_time_nanos();
167        self.last_nanos.set(now);
168        self.last_pointer_nanos.set(now);
169        self.pointer_active.set(true);
170        self.pointer_pose_pending.set(true);
171        pose
172    }
173
174    pub(crate) fn release_pointer(&self) {
175        self.pointer_active.set(false);
176    }
177
178    pub(crate) fn update_pointer(&self, pos: (f32, f32)) -> LiquidPose {
179        if self.pointer_pose_pending.replace(false) {
180            self.last_pos.set(Some(pos));
181            self.last_nanos.set(self.runtime.last_frame_time_nanos());
182            return self.pose.get();
183        }
184        let Some(now) = self.runtime.last_frame_time_nanos() else {
185            self.last_pos.set(Some(pos));
186            return self.pose.get();
187        };
188        let Some(last) = self.last_nanos.get() else {
189            self.last_nanos.set(Some(now));
190            self.last_pos.set(Some(pos));
191            return self.pose.get();
192        };
193        if last == now {
194            return self.pose.get();
195        }
196        let dt = (now.saturating_sub(last)) as f32 / 1_000_000_000.0;
197        self.last_nanos.set(Some(now));
198        let stationary = self.last_pos.get().is_some_and(|last_pos| {
199            (last_pos.0 - pos.0).abs() < 0.001 && (last_pos.1 - pos.1).abs() < 0.001
200        });
201        if !stationary {
202            return self.advance(pos, dt);
203        }
204        let within_pointer_horizon = self.pointer_active.get()
205            && self
206                .last_pointer_nanos
207                .get()
208                .is_some_and(|sample| now.saturating_sub(sample) <= POINTER_STOP_HORIZON_NANOS);
209        if within_pointer_horizon {
210            return self.pose.get();
211        }
212        let dt = dt.clamp(DT_MIN, DT_MAX);
213        let decay = (-dt / POINTER_COAST_TAU).exp();
214        let velocity = self.velocity.get();
215        self.advance_velocity((velocity.0 * decay, velocity.1 * decay), dt)
216    }
217
218    /// Advance with the lens ride position using the runtime's animation
219    /// clock. Multiple reads within one frame return the same pose.
220    pub fn update(&self, pos: (f32, f32)) -> LiquidPose {
221        let Some(now) = self.runtime.last_frame_time_nanos() else {
222            self.last_pos.set(Some(pos));
223            return self.pose.get();
224        };
225        match self.last_nanos.get() {
226            Some(last) if last == now => {
227                self.last_pos.set(Some(pos));
228                self.pose.get()
229            }
230            Some(last) => {
231                let dt = (now.saturating_sub(last)) as f32 / 1_000_000_000.0;
232                self.last_nanos.set(Some(now));
233                self.advance(pos, dt)
234            }
235            None => {
236                self.last_nanos.set(Some(now));
237                self.last_pos.set(Some(pos));
238                self.pose.get()
239            }
240        }
241    }
242
243    /// Pure integration step (exposed for tests and custom clocks): advance
244    /// by `dt` seconds toward `pos`.
245    pub fn advance(&self, pos: (f32, f32), dt: f32) -> LiquidPose {
246        let Some(last_pos) = self.last_pos.get() else {
247            self.last_pos.set(Some(pos));
248            return self.pose.get();
249        };
250        let dt = dt.clamp(DT_MIN, DT_MAX);
251        let delta = (pos.0 - last_pos.0, pos.1 - last_pos.1);
252        self.last_pos.set(Some(pos));
253
254        let velocity = (delta.0 / dt, delta.1 / dt);
255        let raw_speed = (velocity.0 * velocity.0 + velocity.1 * velocity.1).sqrt();
256        if raw_speed > TELEPORT_SPEED {
257            self.velocity.set((0.0, 0.0));
258            return self.pose.get();
259        }
260
261        self.advance_velocity(velocity, dt)
262    }
263
264    fn advance_velocity(&self, velocity: (f32, f32), dt: f32) -> LiquidPose {
265        let dt = dt.clamp(DT_MIN, DT_MAX);
266        let raw_speed = (velocity.0 * velocity.0 + velocity.1 * velocity.1).sqrt();
267        let previous_velocity = self.velocity.get();
268        self.velocity.set(velocity);
269        let accel = (
270            (velocity.0 - previous_velocity.0) / dt,
271            (velocity.1 - previous_velocity.1) / dt,
272        );
273
274        let mut axis = self.axis.get();
275        if raw_speed > AXIS_MIN_SPEED {
276            axis = (velocity.0 / raw_speed, velocity.1 / raw_speed);
277            self.axis.set(axis);
278        }
279        let accel_along = accel.0 * axis.0 + accel.1 * axis.1;
280
281        let target_stretch = (1.0 + STRETCH_PER_SPEED * raw_speed
282            - STRETCH_PER_ACCEL * accel_along)
283            .clamp(STRETCH_MIN, STRETCH_MAX);
284        let signed_bulge = (-BULGE_PER_ACCELERATION * accel_along).clamp(-BULGE_MAX, BULGE_MAX);
285        let target_bulge = (axis.0 * signed_bulge, axis.1 * signed_bulge);
286
287        let follow = |current: f32, target: f32, neutral: f32| {
288            let tau = if (target - neutral).abs() > (current - neutral).abs() {
289                ATTACK_TAU
290            } else {
291                RELEASE_TAU
292            };
293            current + (target - current) * (1.0 - (-dt / tau).exp())
294        };
295        let stretch = follow(self.stretch.get(), target_stretch, 1.0);
296        let current_bulge = self.bulge_vector.get();
297        let current_bulge_length = current_bulge.0.hypot(current_bulge.1);
298        let target_bulge_length = target_bulge.0.hypot(target_bulge.1);
299        let bulge_tau = if target_bulge_length > current_bulge_length {
300            ATTACK_TAU
301        } else {
302            RELEASE_TAU
303        };
304        let bulge_follow = 1.0 - (-dt / bulge_tau).exp();
305        let bulge_vector = (
306            current_bulge.0 + (target_bulge.0 - current_bulge.0) * bulge_follow,
307            current_bulge.1 + (target_bulge.1 - current_bulge.1) * bulge_follow,
308        );
309        let bulge = bulge_vector.0.hypot(bulge_vector.1);
310        let speed = follow(self.speed.get(), raw_speed, 0.0);
311        self.stretch.set(stretch);
312        self.bulge_vector.set(bulge_vector);
313        self.speed.set(speed);
314
315        let bulge_direction = if bulge > 1.0e-4 {
316            bulge_vector.1.atan2(bulge_vector.0)
317        } else {
318            axis.1.atan2(axis.0)
319        };
320
321        let pose = LiquidPose {
322            stretch,
323            ortho: 1.0 / stretch,
324            axis,
325            bulge_amplitude: bulge,
326            bulge_direction,
327            speed,
328        };
329        self.pose.set(pose);
330        pose
331    }
332
333    /// Latest pose without advancing.
334    pub fn pose(&self) -> LiquidPose {
335        self.pose.get()
336    }
337}
338
339/// Remember one [`LiquidDynamics`] for the calling composition site.
340#[composable]
341#[track_caller]
342pub fn rememberLiquidDynamics() -> Rc<LiquidDynamics> {
343    let caller = cranpose_core::caller_location_key();
344    with_current_composer(|composer| {
345        let runtime = composer.runtime_handle();
346        composer
347            .remember_at(caller, move || Rc::new(LiquidDynamics::new(runtime)))
348            .with(Rc::clone)
349    })
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    fn dynamics() -> LiquidDynamics {
357        let runtime =
358            cranpose_core::Runtime::new(std::sync::Arc::new(cranpose_core::DefaultScheduler));
359        LiquidDynamics::new(runtime.handle())
360    }
361
362    fn settle(d: &LiquidDynamics, pos: (f32, f32), frames: usize) -> LiquidPose {
363        let mut pose = d.pose();
364        for _ in 0..frames {
365            pose = d.advance(pos, 1.0 / 60.0);
366        }
367        pose
368    }
369
370    fn cruise(d: &LiquidDynamics, speed_dp_s: f32, frames: usize) -> LiquidPose {
371        let dt = 1.0 / 60.0;
372        let mut x = 0.0;
373        let mut pose = d.pose();
374        for _ in 0..frames {
375            x += speed_dp_s * dt;
376            pose = d.advance((x, 0.0), dt);
377        }
378        pose
379    }
380
381    #[test]
382    fn constant_speed_elongates_along_axis_and_conserves_area() {
383        let d = dynamics();
384        let pose = cruise(&d, 1200.0, 40);
385        assert!((1.36..1.40).contains(&pose.stretch));
386        assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
387        assert!((pose.axis.0 - 1.0).abs() < 1e-4);
388    }
389
390    #[test]
391    fn subpixel_pointer_jitter_cannot_invent_extreme_strain() {
392        let d = dynamics();
393        d.anchor_pointer((0.0, 0.0));
394        let mut pose = d.pose();
395        for sample in 1..=12 {
396            pose = d.advance_pointer((sample as f32 * 0.20, 0.0), 1.0 / 60.0);
397        }
398        assert!(
399            (pose.stretch - 1.0).abs() < 0.025,
400            "subpixel travel must stay near equilibrium: {pose:?}"
401        );
402        assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
403    }
404
405    #[test]
406    fn launch_compresses_before_cruise_stretch_wins() {
407        let d = dynamics();
408        d.advance((0.0, 0.0), 1.0 / 60.0);
409        let dt = 1.0 / 60.0;
410        let pose = d.advance((1500.0 * dt, 0.0), dt);
411        assert!(pose.stretch < 1.0, "launch stretch {}", pose.stretch);
412        assert!(pose.ortho > 1.0, "launch ortho {}", pose.ortho);
413    }
414
415    #[test]
416    fn launch_acceleration_leaves_a_persistent_trailing_material_wake() {
417        let d = dynamics();
418        let dt = 1.0 / 60.0;
419        d.advance((0.0, 0.0), dt);
420        let launch = d.advance((1200.0 * dt, 0.0), dt);
421        assert!(
422            launch.bulge_amplitude > 0.5,
423            "launch must displace liquid toward the trailing edge: {launch:?}"
424        );
425        assert!(
426            (launch.bulge_direction.abs() - std::f32::consts::PI).abs() < 0.1,
427            "rightward acceleration must trail to the left: {launch:?}"
428        );
429
430        let cruise = d.advance((2400.0 * dt, 0.0), dt);
431        assert!(
432            cruise.bulge_amplitude > 0.25,
433            "the material wake must survive beyond one pointer sample: {launch:?} -> {cruise:?}"
434        );
435        assert!(
436            (cruise.bulge_direction.abs() - std::f32::consts::PI).abs() < 0.2,
437            "the remembered wake cannot flip on the next sample: {cruise:?}"
438        );
439    }
440
441    #[test]
442    fn direct_drag_cadence_remains_launch_compressed() {
443        let d = dynamics();
444        d.anchor_pointer((0.0, 0.0));
445        d.advance_pointer((-20.0, 0.0), 0.08);
446        let pose = d.advance_pointer((-40.0, 0.0), 0.03);
447        assert!(
448            pose.stretch <= 0.92,
449            "the target's two-event launch must compress along travel: {pose:?}"
450        );
451        assert!(
452            pose.ortho >= 1.08,
453            "launch must expand across travel: {pose:?}"
454        );
455        assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
456    }
457
458    #[test]
459    fn braking_decompresses_past_cruise_and_swells_leading_edge() {
460        let d = dynamics();
461        let cruise_pose = cruise(&d, 1200.0, 40);
462        let dt = 1.0 / 60.0;
463        let x = d.last_pos.get().unwrap().0;
464        let brake = d.advance((x + 300.0 * dt, 0.0), dt);
465        assert!(
466            brake.stretch > cruise_pose.stretch + 0.04,
467            "brake {} vs cruise {}",
468            brake.stretch,
469            cruise_pose.stretch
470        );
471        assert!(
472            brake.bulge_amplitude > 0.5,
473            "bulge {}",
474            brake.bulge_amplitude
475        );
476        assert!(
477            brake.ortho < cruise_pose.ortho,
478            "brake ortho {}",
479            brake.ortho
480        );
481        assert!(brake.bulge_direction.abs() < 1e-3);
482    }
483
484    #[test]
485    fn rest_decays_to_identity() {
486        let d = dynamics();
487        cruise(&d, 1200.0, 40);
488        let pose = settle(&d, d.last_pos.get().unwrap(), 60);
489        assert!(
490            (pose.stretch - 1.0).abs() < 0.02,
491            "stretch {}",
492            pose.stretch
493        );
494        assert!(pose.bulge_amplitude < 0.2);
495        assert!(pose.speed < 15.0);
496    }
497
498    #[test]
499    fn axis_follows_motion_direction_and_holds_at_rest() {
500        let d = dynamics();
501        let dt = 1.0 / 60.0;
502        d.advance((0.0, 0.0), dt);
503        let mut x = 0.0;
504        let mut pose = d.pose();
505        for _ in 0..10 {
506            x -= 900.0 * dt;
507            pose = d.advance((x, 0.0), dt);
508        }
509        assert!((pose.axis.0 + 1.0).abs() < 1e-4, "axis {:?}", pose.axis);
510        assert!(
511            pose.bulge_direction.abs() < 0.1,
512            "leftward launch inertia must trail toward +x: {pose:?}"
513        );
514        let held = settle(&d, (x, 0.0), 30);
515        assert!((held.axis.0 + 1.0).abs() < 1e-4);
516    }
517
518    #[test]
519    fn frame_rate_independent_cruise() {
520        let d60 = dynamics();
521        let d120 = dynamics();
522        let cruise60 = cruise(&d60, 1000.0, 30);
523        let dt = 1.0 / 120.0;
524        let mut x = 0.0;
525        let mut cruise120 = d120.pose();
526        for _ in 0..60 {
527            x += 1000.0 * dt;
528            cruise120 = d120.advance((x, 0.0), dt);
529        }
530        assert!(
531            (cruise60.stretch - cruise120.stretch).abs() < 0.02,
532            "60Hz {} vs 120Hz {}",
533            cruise60.stretch,
534            cruise120.stretch
535        );
536    }
537
538    #[test]
539    fn teleport_re_anchors_without_energy() {
540        let d = dynamics();
541        d.advance((0.0, 0.0), 1.0 / 60.0);
542        let pose = d.advance((4000.0, 0.0), 1.0 / 60.0);
543        assert_eq!(pose, d.pose());
544        assert!(
545            (pose.stretch - 1.0).abs() < 1e-4,
546            "teleport {}",
547            pose.stretch
548        );
549        let resumed = cruise(&d, 800.0, 30);
550        assert!(resumed.stretch > 1.01);
551    }
552
553    #[test]
554    fn vertical_travel_stretches_height_not_width() {
555        let d = dynamics();
556        let dt = 1.0 / 60.0;
557        let mut y = 0.0;
558        d.advance((0.0, 0.0), dt);
559        let mut pose = d.pose();
560        for _ in 0..30 {
561            y += 1000.0 * dt;
562            pose = d.advance((0.0, y), dt);
563        }
564        assert!(
565            (1.30..1.34).contains(&pose.stretch),
566            "stretch {}",
567            pose.stretch
568        );
569        assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
570    }
571
572    #[test]
573    fn reset_forgets_motion() {
574        let d = dynamics();
575        cruise(&d, 1200.0, 40);
576        d.reset();
577        assert_eq!(d.pose().stretch, 1.0);
578        let pose = d.advance((500.0, 0.0), 1.0 / 60.0);
579        assert!((pose.stretch - 1.0).abs() < 1e-4);
580    }
581}