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;
18use std::rc::Rc;
19
20use cranpose_core::with_current_composer;
21use cranpose_core::RuntimeHandle;
22use cranpose_macros::composable;
23
24use crate::material::GlassDeformation;
25
26/// Stretch gained per dp/s of travel speed. The target redistributes volume
27/// visibly but remains a landscape lens through launch and cruise.
28const STRETCH_PER_SPEED: f32 = 3.2e-4;
29/// Stretch removed per dp/s² of forward acceleration (and added back per
30/// dp/s² of braking): launch blunts the bubble, arrival elongates it.
31const STRETCH_PER_ACCEL: f32 = 1.1e-5;
32/// The droplet never deforms past these bounds, however violent the fling.
33/// Public so hosting nodes can budget layout headroom for the extremes.
34pub const STRETCH_MIN: f32 = 0.78;
35pub const STRETCH_MAX: f32 = 1.50;
36/// Leading-edge swell per dp/s² of braking, and its cap in dp (public for
37/// the same headroom budgeting).
38const BULGE_PER_DECEL: f32 = 4.5e-4;
39pub const BULGE_MAX: f32 = 8.0;
40/// Low-pass time constants (s): the fluid's visual inertia is asymmetric —
41/// excitation grabs the droplet fast, but it relaxes back viscously (the
42/// reference arrival swell stays visible for a beat before rounding off).
43const ATTACK_TAU: f32 = 0.03;
44const RELEASE_TAU: f32 = 0.11;
45/// Direct input arrives independently from rendering. Preserve the last
46/// observed finger velocity across short gaps instead of interpreting every
47/// intervening render as an instantaneous stop.
48const POINTER_VELOCITY_TAU: f32 = 0.045;
49const POINTER_STOP_HORIZON_NANOS: u64 = 40_000_000;
50const POINTER_COAST_TAU: f32 = 0.10;
51/// Below this speed (dp/s) the motion axis holds its last direction, so a
52/// settling bubble relaxes in place instead of flipping its axis on noise.
53const AXIS_MIN_SPEED: f32 = 60.0;
54/// Frame deltas are clamped here (s): a paused scene must not integrate one
55/// giant step, and a double-pumped frame must not divide by ~zero.
56const DT_MIN: f32 = 1.0 / 1000.0;
57const DT_MAX: f32 = 1.0 / 15.0;
58/// A position step implying more than this speed (dp/s) is a teleport
59/// (window relayout, snap), not motion: state re-anchors without energy.
60const TELEPORT_SPEED: f32 = 30_000.0;
61
62/// Motion-derived deformation of a liquid lens for one frame.
63#[derive(Clone, Copy, Debug, PartialEq)]
64pub struct LiquidPose {
65    /// Scale along the motion axis (>1 = elongated, <1 = compressed).
66    pub stretch: f32,
67    /// Scale across the motion axis; always `1/stretch` (area conserved).
68    pub ortho: f32,
69    /// Unit motion direction (math convention, +x right / +y up in caller
70    /// space — callers feed whatever space they draw in).
71    pub axis: (f32, f32),
72    /// Leading-edge swell amplitude (dp) toward `bulge_direction` (radians).
73    pub bulge_amplitude: f32,
74    pub bulge_direction: f32,
75    /// Smoothed travel speed (dp/s) for auxiliary channels (surface depth,
76    /// wobble, chromatic fringe).
77    pub speed: f32,
78}
79
80impl Default for LiquidPose {
81    fn default() -> Self {
82        Self {
83            stretch: 1.0,
84            ortho: 1.0,
85            axis: (1.0, 0.0),
86            bulge_amplitude: 0.0,
87            bulge_direction: 0.0,
88            speed: 0.0,
89        }
90    }
91}
92
93impl LiquidPose {
94    /// The full rotating strain tensor for the shader. Applying this to the
95    /// signed-distance field preserves area for horizontal, vertical and
96    /// diagonal travel; projecting it into an axis-aligned width/height pair
97    /// cannot preserve that invariant away from the cardinal axes.
98    pub fn deformation(&self) -> GlassDeformation {
99        GlassDeformation::incompressible(self.axis, self.stretch)
100    }
101
102    /// Normalized motion energy in 0..1 (`speed` against a reference fling
103    /// of ~1100 dp/s) — the shared ramp for speed-driven side channels.
104    pub fn energy(&self) -> f32 {
105        (self.speed / 1100.0).clamp(0.0, 1.0)
106    }
107}
108
109/// Per-lens frame integrator. Interior-mutable so per-frame draw closures
110/// (plain `Fn`) can advance it without a `RefCell` dance.
111pub struct LiquidDynamics {
112    runtime: RuntimeHandle,
113    last_nanos: Cell<Option<u64>>,
114    last_pos: Cell<Option<(f32, f32)>>,
115    velocity: Cell<(f32, f32)>,
116    stretch: Cell<f32>,
117    bulge: Cell<f32>,
118    speed: Cell<f32>,
119    axis: Cell<(f32, f32)>,
120    pose: Cell<LiquidPose>,
121    pointer_pose_pending: Cell<bool>,
122    pointer_active: Cell<bool>,
123    last_pointer_nanos: Cell<Option<u64>>,
124}
125
126impl LiquidDynamics {
127    pub fn new(runtime: RuntimeHandle) -> Self {
128        Self {
129            runtime,
130            last_nanos: Cell::new(None),
131            last_pos: Cell::new(None),
132            velocity: Cell::new((0.0, 0.0)),
133            stretch: Cell::new(1.0),
134            bulge: Cell::new(0.0),
135            speed: Cell::new(0.0),
136            axis: Cell::new((1.0, 0.0)),
137            pose: Cell::new(LiquidPose::default()),
138            pointer_pose_pending: Cell::new(false),
139            pointer_active: Cell::new(false),
140            last_pointer_nanos: Cell::new(None),
141        }
142    }
143
144    /// Forget all motion state (fresh grab, teleporting relayout): the next
145    /// update re-anchors at rest.
146    pub fn reset(&self) {
147        self.last_nanos.set(None);
148        self.last_pos.set(None);
149        self.velocity.set((0.0, 0.0));
150        self.stretch.set(1.0);
151        self.bulge.set(0.0);
152        self.speed.set(0.0);
153        self.pose.set(LiquidPose {
154            axis: self.axis.get(),
155            ..LiquidPose::default()
156        });
157        self.pointer_pose_pending.set(false);
158        self.pointer_active.set(false);
159        self.last_pointer_nanos.set(None);
160    }
161
162    pub(crate) fn anchor_pointer(&self, pos: (f32, f32)) {
163        self.reset();
164        self.last_pos.set(Some(pos));
165        let now = self.runtime.last_frame_time_nanos();
166        self.last_nanos.set(now);
167        self.last_pointer_nanos.set(now);
168        self.pointer_active.set(true);
169    }
170
171    pub(crate) fn advance_pointer(&self, pos: (f32, f32), dt: f32) -> LiquidPose {
172        let Some(last_pos) = self.last_pos.get() else {
173            self.last_pos.set(Some(pos));
174            return self.pose.get();
175        };
176        let dt = dt.clamp(DT_MIN, DT_MAX);
177        let raw_velocity = ((pos.0 - last_pos.0) / dt, (pos.1 - last_pos.1) / dt);
178        self.last_pos.set(Some(pos));
179        let previous = self.velocity.get();
180        let follow = 1.0 - (-dt / POINTER_VELOCITY_TAU).exp();
181        let filtered_velocity = (
182            previous.0 + (raw_velocity.0 - previous.0) * follow,
183            previous.1 + (raw_velocity.1 - previous.1) * follow,
184        );
185        let pose = self.advance_velocity(filtered_velocity, dt);
186        let now = self.runtime.last_frame_time_nanos();
187        self.last_nanos.set(now);
188        self.last_pointer_nanos.set(now);
189        self.pointer_active.set(true);
190        self.pointer_pose_pending.set(true);
191        pose
192    }
193
194    pub(crate) fn release_pointer(&self) {
195        self.pointer_active.set(false);
196    }
197
198    pub(crate) fn update_pointer(&self, pos: (f32, f32)) -> LiquidPose {
199        if self.pointer_pose_pending.replace(false) {
200            self.last_pos.set(Some(pos));
201            self.last_nanos.set(self.runtime.last_frame_time_nanos());
202            return self.pose.get();
203        }
204        let Some(now) = self.runtime.last_frame_time_nanos() else {
205            self.last_pos.set(Some(pos));
206            return self.pose.get();
207        };
208        let Some(last) = self.last_nanos.get() else {
209            self.last_nanos.set(Some(now));
210            self.last_pos.set(Some(pos));
211            return self.pose.get();
212        };
213        if last == now {
214            return self.pose.get();
215        }
216        let dt = (now.saturating_sub(last)) as f32 / 1_000_000_000.0;
217        self.last_nanos.set(Some(now));
218        let stationary = self.last_pos.get().is_some_and(|last_pos| {
219            (last_pos.0 - pos.0).abs() < 0.001 && (last_pos.1 - pos.1).abs() < 0.001
220        });
221        if !stationary {
222            return self.advance(pos, dt);
223        }
224        let within_pointer_horizon = self.pointer_active.get()
225            && self
226                .last_pointer_nanos
227                .get()
228                .is_some_and(|sample| now.saturating_sub(sample) <= POINTER_STOP_HORIZON_NANOS);
229        if within_pointer_horizon {
230            return self.pose.get();
231        }
232        let dt = dt.clamp(DT_MIN, DT_MAX);
233        let decay = (-dt / POINTER_COAST_TAU).exp();
234        let velocity = self.velocity.get();
235        self.advance_velocity((velocity.0 * decay, velocity.1 * decay), dt)
236    }
237
238    /// Advance with the lens ride position using the runtime's animation
239    /// clock. Multiple reads within one frame return the same pose.
240    pub fn update(&self, pos: (f32, f32)) -> LiquidPose {
241        let Some(now) = self.runtime.last_frame_time_nanos() else {
242            self.last_pos.set(Some(pos));
243            return self.pose.get();
244        };
245        match self.last_nanos.get() {
246            Some(last) if last == now => {
247                // Same frame (second glass closure read): no time passed.
248                self.last_pos.set(Some(pos));
249                self.pose.get()
250            }
251            Some(last) => {
252                let dt = (now.saturating_sub(last)) as f32 / 1_000_000_000.0;
253                self.last_nanos.set(Some(now));
254                self.advance(pos, dt)
255            }
256            None => {
257                self.last_nanos.set(Some(now));
258                self.last_pos.set(Some(pos));
259                self.pose.get()
260            }
261        }
262    }
263
264    /// Pure integration step (exposed for tests and custom clocks): advance
265    /// by `dt` seconds toward `pos`.
266    pub fn advance(&self, pos: (f32, f32), dt: f32) -> LiquidPose {
267        let Some(last_pos) = self.last_pos.get() else {
268            self.last_pos.set(Some(pos));
269            return self.pose.get();
270        };
271        let dt = dt.clamp(DT_MIN, DT_MAX);
272        let delta = (pos.0 - last_pos.0, pos.1 - last_pos.1);
273        self.last_pos.set(Some(pos));
274
275        let velocity = (delta.0 / dt, delta.1 / dt);
276        let raw_speed = (velocity.0 * velocity.0 + velocity.1 * velocity.1).sqrt();
277        if raw_speed > TELEPORT_SPEED {
278            self.velocity.set((0.0, 0.0));
279            return self.pose.get();
280        }
281
282        self.advance_velocity(velocity, dt)
283    }
284
285    fn advance_velocity(&self, velocity: (f32, f32), dt: f32) -> LiquidPose {
286        let dt = dt.clamp(DT_MIN, DT_MAX);
287        let raw_speed = (velocity.0 * velocity.0 + velocity.1 * velocity.1).sqrt();
288        let previous_velocity = self.velocity.get();
289        self.velocity.set(velocity);
290        let accel = (
291            (velocity.0 - previous_velocity.0) / dt,
292            (velocity.1 - previous_velocity.1) / dt,
293        );
294
295        let mut axis = self.axis.get();
296        if raw_speed > AXIS_MIN_SPEED {
297            axis = (velocity.0 / raw_speed, velocity.1 / raw_speed);
298            self.axis.set(axis);
299        }
300        // Signed acceleration along travel: positive while gaining speed.
301        let accel_along = accel.0 * axis.0 + accel.1 * axis.1;
302
303        let target_stretch = (1.0 + STRETCH_PER_SPEED * raw_speed
304            - STRETCH_PER_ACCEL * accel_along)
305            .clamp(STRETCH_MIN, STRETCH_MAX);
306        let target_bulge = (BULGE_PER_DECEL * (-accel_along).max(0.0)).min(BULGE_MAX);
307
308        // Excitation (moving away from neutral) is fast; relaxation back is
309        // viscous, so a brake swell lingers a beat like the reference.
310        let follow = |current: f32, target: f32, neutral: f32| {
311            let tau = if (target - neutral).abs() > (current - neutral).abs() {
312                ATTACK_TAU
313            } else {
314                RELEASE_TAU
315            };
316            current + (target - current) * (1.0 - (-dt / tau).exp())
317        };
318        let stretch = follow(self.stretch.get(), target_stretch, 1.0);
319        let bulge = follow(self.bulge.get(), target_bulge, 0.0);
320        let speed = follow(self.speed.get(), raw_speed, 0.0);
321        self.stretch.set(stretch);
322        self.bulge.set(bulge);
323        self.speed.set(speed);
324
325        let pose = LiquidPose {
326            stretch,
327            ortho: 1.0 / stretch,
328            axis,
329            bulge_amplitude: bulge,
330            bulge_direction: axis.1.atan2(axis.0),
331            speed,
332        };
333        self.pose.set(pose);
334        pose
335    }
336
337    /// Latest pose without advancing.
338    pub fn pose(&self) -> LiquidPose {
339        self.pose.get()
340    }
341}
342
343/// Remember one [`LiquidDynamics`] for the calling composition site.
344#[composable]
345pub fn remember_liquid_dynamics() -> Rc<LiquidDynamics> {
346    with_current_composer(|composer| {
347        let runtime = composer.runtime_handle();
348        composer
349            .remember(move || Rc::new(LiquidDynamics::new(runtime)))
350            .with(Rc::clone)
351    })
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    fn dynamics() -> LiquidDynamics {
359        let runtime =
360            cranpose_core::Runtime::new(std::sync::Arc::new(cranpose_core::DefaultScheduler));
361        LiquidDynamics::new(runtime.handle())
362    }
363
364    fn settle(d: &LiquidDynamics, pos: (f32, f32), frames: usize) -> LiquidPose {
365        let mut pose = d.pose();
366        for _ in 0..frames {
367            pose = d.advance(pos, 1.0 / 60.0);
368        }
369        pose
370    }
371
372    /// Drive at constant velocity and return the steady pose.
373    fn cruise(d: &LiquidDynamics, speed_dp_s: f32, frames: usize) -> LiquidPose {
374        let dt = 1.0 / 60.0;
375        let mut x = 0.0;
376        let mut pose = d.pose();
377        for _ in 0..frames {
378            x += speed_dp_s * dt;
379            pose = d.advance((x, 0.0), dt);
380        }
381        pose
382    }
383
384    #[test]
385    fn constant_speed_elongates_along_axis_and_conserves_area() {
386        let d = dynamics();
387        let pose = cruise(&d, 1200.0, 40);
388        assert!((1.36..1.40).contains(&pose.stretch));
389        assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
390        assert!((pose.axis.0 - 1.0).abs() < 1e-4);
391    }
392
393    #[test]
394    fn subpixel_pointer_jitter_cannot_invent_extreme_strain() {
395        let d = dynamics();
396        d.anchor_pointer((0.0, 0.0));
397        let mut pose = d.pose();
398        for sample in 1..=12 {
399            pose = d.advance_pointer((sample as f32 * 0.20, 0.0), 1.0 / 60.0);
400        }
401        assert!(
402            (pose.stretch - 1.0).abs() < 0.025,
403            "subpixel travel must stay near equilibrium: {pose:?}"
404        );
405        assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
406    }
407
408    #[test]
409    fn launch_compresses_before_cruise_stretch_wins() {
410        let d = dynamics();
411        d.advance((0.0, 0.0), 1.0 / 60.0);
412        // Hard launch: 0 → 1500 dp/s in one frame (a = 90k dp/s²) — the
413        // instantaneous target is compression-dominated.
414        let dt = 1.0 / 60.0;
415        let pose = d.advance((1500.0 * dt, 0.0), dt);
416        assert!(pose.stretch < 1.0, "launch stretch {}", pose.stretch);
417        assert!(pose.ortho > 1.0, "launch ortho {}", pose.ortho);
418    }
419
420    #[test]
421    fn braking_decompresses_past_cruise_and_swells_leading_edge() {
422        let d = dynamics();
423        let cruise_pose = cruise(&d, 1200.0, 40);
424        // Brake to a stop over two frames.
425        let dt = 1.0 / 60.0;
426        let x = d.last_pos.get().unwrap().0;
427        let brake = d.advance((x + 300.0 * dt, 0.0), dt);
428        assert!(
429            brake.stretch > cruise_pose.stretch + 0.04,
430            "brake {} vs cruise {}",
431            brake.stretch,
432            cruise_pose.stretch
433        );
434        assert!(
435            brake.bulge_amplitude > 0.5,
436            "bulge {}",
437            brake.bulge_amplitude
438        );
439        assert!(
440            brake.ortho < cruise_pose.ortho,
441            "brake ortho {}",
442            brake.ortho
443        );
444        // Leading edge = travel direction (+x).
445        assert!(brake.bulge_direction.abs() < 1e-3);
446    }
447
448    #[test]
449    fn rest_decays_to_identity() {
450        let d = dynamics();
451        cruise(&d, 1200.0, 40);
452        let pose = settle(&d, d.last_pos.get().unwrap(), 60);
453        assert!(
454            (pose.stretch - 1.0).abs() < 0.02,
455            "stretch {}",
456            pose.stretch
457        );
458        assert!(pose.bulge_amplitude < 0.2);
459        assert!(pose.speed < 15.0);
460    }
461
462    #[test]
463    fn axis_follows_motion_direction_and_holds_at_rest() {
464        let d = dynamics();
465        let dt = 1.0 / 60.0;
466        d.advance((0.0, 0.0), dt);
467        let mut x = 0.0;
468        let mut pose = d.pose();
469        for _ in 0..10 {
470            x -= 900.0 * dt;
471            pose = d.advance((x, 0.0), dt);
472        }
473        assert!((pose.axis.0 + 1.0).abs() < 1e-4, "axis {:?}", pose.axis);
474        assert!((pose.bulge_direction.abs() - std::f32::consts::PI).abs() < 1e-3);
475        // Stopping keeps the last axis instead of flipping on noise.
476        let held = settle(&d, (x, 0.0), 30);
477        assert!((held.axis.0 + 1.0).abs() < 1e-4);
478    }
479
480    #[test]
481    fn frame_rate_independent_cruise() {
482        let d60 = dynamics();
483        let d120 = dynamics();
484        let cruise60 = cruise(&d60, 1000.0, 30);
485        let dt = 1.0 / 120.0;
486        let mut x = 0.0;
487        let mut cruise120 = d120.pose();
488        for _ in 0..60 {
489            x += 1000.0 * dt;
490            cruise120 = d120.advance((x, 0.0), dt);
491        }
492        assert!(
493            (cruise60.stretch - cruise120.stretch).abs() < 0.02,
494            "60Hz {} vs 120Hz {}",
495            cruise60.stretch,
496            cruise120.stretch
497        );
498    }
499
500    #[test]
501    fn teleport_re_anchors_without_energy() {
502        let d = dynamics();
503        d.advance((0.0, 0.0), 1.0 / 60.0);
504        let pose = d.advance((4000.0, 0.0), 1.0 / 60.0);
505        assert_eq!(pose, d.pose());
506        assert!(
507            (pose.stretch - 1.0).abs() < 1e-4,
508            "teleport {}",
509            pose.stretch
510        );
511        // Motion resumes cleanly from the new anchor.
512        let resumed = cruise(&d, 800.0, 30);
513        assert!(resumed.stretch > 1.01);
514    }
515
516    #[test]
517    fn vertical_travel_stretches_height_not_width() {
518        let d = dynamics();
519        let dt = 1.0 / 60.0;
520        let mut y = 0.0;
521        d.advance((0.0, 0.0), dt);
522        let mut pose = d.pose();
523        for _ in 0..30 {
524            y += 1000.0 * dt;
525            pose = d.advance((0.0, y), dt);
526        }
527        assert!(
528            (1.30..1.34).contains(&pose.stretch),
529            "stretch {}",
530            pose.stretch
531        );
532        assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
533    }
534
535    #[test]
536    fn reset_forgets_motion() {
537        let d = dynamics();
538        cruise(&d, 1200.0, 40);
539        d.reset();
540        assert_eq!(d.pose().stretch, 1.0);
541        let pose = d.advance((500.0, 0.0), 1.0 / 60.0);
542        assert!((pose.stretch - 1.0).abs() < 1e-4);
543    }
544}