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//!
17//! Every channel relaxes exponentially, so none of them ever reaches
18//! neutral on its own. Once travel, strain and swell are all within a
19//! fraction of a device pixel of rest, the integrator snaps them to exactly
20//! [`LiquidPose::default`] and stops producing new poses, so a settled lens
21//! costs nothing per frame. Until then a pose changes with the clock alone,
22//! so each update that returns one away from rest asks for the next frame's
23//! re-record of the node being drawn
24//! ([`cranpose_ui::request_current_draw_redraw`]): a lens keeps relaxing
25//! after its input stops, and only for as long as it has to.
26
27use std::{cell::Cell, rc::Rc};
28
29use cranpose_core::{RuntimeHandle, with_current_composer};
30use cranpose_macros::composable;
31
32use crate::material::GlassDeformation;
33
34const STRETCH_PER_SPEED: f32 = 3.2e-4;
35const STRETCH_PER_ACCEL: f32 = 3.5e-5;
36/// The droplet never deforms past these bounds, however violent the fling.
37/// Public so hosting nodes can budget layout headroom for the extremes.
38pub const STRETCH_MIN: f32 = 0.78;
39/// Largest scale along the motion axis.
40pub const STRETCH_MAX: f32 = 1.50;
41const BULGE_PER_ACCELERATION: f32 = 4.5e-4;
42/// Largest leading-edge swell amplitude, in dp.
43pub const BULGE_MAX: f32 = 8.0;
44const ATTACK_TAU: f32 = 0.03;
45const RELEASE_TAU: f32 = 0.11;
46const AXIS_MIN_SPEED: f32 = 60.0;
47const REST_SPEED: f32 = 1.0;
48const REST_STRETCH: f32 = 5.0e-4;
49const REST_BULGE: f32 = 1.0e-2;
50const DT_MIN: f32 = 1.0 / 1000.0;
51const DT_MAX: f32 = 1.0 / 15.0;
52const TELEPORT_SPEED: f32 = 30_000.0;
53
54/// Motion-derived deformation of a liquid lens for one frame.
55#[derive(Clone, Copy, Debug, PartialEq)]
56pub struct LiquidPose {
57    /// Scale along the motion axis (>1 = elongated, <1 = compressed).
58    pub stretch: f32,
59    /// Scale across the motion axis; always `1/stretch` (area conserved).
60    pub ortho: f32,
61    /// Unit motion direction (math convention, +x right / +y up in caller
62    /// space — callers feed whatever space they draw in).
63    pub axis: (f32, f32),
64    /// Leading-edge swell amplitude (dp) toward `bulge_direction` (radians).
65    pub bulge_amplitude: f32,
66    /// Direction of the leading-edge swell, in radians, in the space of `axis`.
67    pub bulge_direction: f32,
68    /// Smoothed travel speed (dp/s) for auxiliary channels (surface depth,
69    /// wobble, chromatic fringe).
70    pub speed: f32,
71}
72
73impl Default for LiquidPose {
74    fn default() -> Self {
75        Self {
76            stretch: 1.0,
77            ortho: 1.0,
78            axis: (1.0, 0.0),
79            bulge_amplitude: 0.0,
80            bulge_direction: 0.0,
81            speed: 0.0,
82        }
83    }
84}
85
86impl LiquidPose {
87    /// The full rotating strain tensor for the shader. Applying this to the
88    /// signed-distance field preserves area for horizontal, vertical and
89    /// diagonal travel; projecting it into an axis-aligned width/height pair
90    /// cannot preserve that invariant away from the cardinal axes.
91    pub fn deformation(&self) -> GlassDeformation {
92        GlassDeformation::incompressible(self.axis, self.stretch)
93    }
94
95    /// Normalized motion energy in 0..1 (`speed` against a reference fling
96    /// of ~1100 dp/s) — the shared ramp for speed-driven side channels.
97    pub fn energy(&self) -> f32 {
98        (self.speed / 1100.0).clamp(0.0, 1.0)
99    }
100}
101
102/// Per-lens frame integrator. Interior-mutable so per-frame draw closures
103/// (plain `Fn`) can advance it without a `RefCell` dance.
104pub struct LiquidDynamics {
105    runtime: RuntimeHandle,
106    last_nanos: Cell<Option<u64>>,
107    last_pos: Cell<Option<(f32, f32)>>,
108    velocity: Cell<(f32, f32)>,
109    stretch: Cell<f32>,
110    bulge_vector: Cell<(f32, f32)>,
111    speed: Cell<f32>,
112    axis: Cell<(f32, f32)>,
113    pose: Cell<LiquidPose>,
114}
115
116impl LiquidDynamics {
117    /// Creates an integrator at rest that reads the animation clock of `runtime`.
118    pub fn new(runtime: RuntimeHandle) -> Self {
119        Self {
120            runtime,
121            last_nanos: Cell::new(None),
122            last_pos: Cell::new(None),
123            velocity: Cell::new((0.0, 0.0)),
124            stretch: Cell::new(1.0),
125            bulge_vector: Cell::new((0.0, 0.0)),
126            speed: Cell::new(0.0),
127            axis: Cell::new((1.0, 0.0)),
128            pose: Cell::new(LiquidPose::default()),
129        }
130    }
131
132    /// Forget all motion state (fresh grab, teleporting relayout): the next
133    /// update re-anchors at rest.
134    pub fn reset(&self) {
135        self.last_nanos.set(None);
136        self.last_pos.set(None);
137        self.velocity.set((0.0, 0.0));
138        self.stretch.set(1.0);
139        self.bulge_vector.set((0.0, 0.0));
140        self.speed.set(0.0);
141        self.pose.set(LiquidPose {
142            axis: self.axis.get(),
143            ..LiquidPose::default()
144        });
145    }
146
147    /// Advance with the lens ride position using the runtime's animation
148    /// clock. Multiple reads within one frame return the same pose. Read from
149    /// a draw closure: while the pose is away from rest it asks for that
150    /// node's re-record on the next frame.
151    pub fn update(&self, pos: (f32, f32)) -> LiquidPose {
152        redraw_until_rest(self.integrate(pos))
153    }
154
155    fn integrate(&self, pos: (f32, f32)) -> LiquidPose {
156        let Some(now) = self.runtime.last_frame_time_nanos() else {
157            self.last_pos.set(Some(pos));
158            return self.pose.get();
159        };
160        match self.last_nanos.get() {
161            Some(last) if last == now => {
162                self.last_pos.set(Some(pos));
163                self.pose.get()
164            }
165            Some(last) => {
166                let dt = (now.saturating_sub(last)) as f32 / 1_000_000_000.0;
167                self.last_nanos.set(Some(now));
168                self.advance(pos, dt)
169            }
170            None => {
171                self.last_nanos.set(Some(now));
172                self.last_pos.set(Some(pos));
173                self.pose.get()
174            }
175        }
176    }
177
178    /// Pure integration step (exposed for tests and custom clocks): advance
179    /// by `dt` seconds toward `pos`.
180    pub fn advance(&self, pos: (f32, f32), dt: f32) -> LiquidPose {
181        let Some(last_pos) = self.last_pos.get() else {
182            self.last_pos.set(Some(pos));
183            return self.pose.get();
184        };
185        let dt = dt.clamp(DT_MIN, DT_MAX);
186        let delta = (pos.0 - last_pos.0, pos.1 - last_pos.1);
187        self.last_pos.set(Some(pos));
188
189        let velocity = (delta.0 / dt, delta.1 / dt);
190        let raw_speed = (velocity.0 * velocity.0 + velocity.1 * velocity.1).sqrt();
191        if raw_speed > TELEPORT_SPEED {
192            self.velocity.set((0.0, 0.0));
193            return self.pose.get();
194        }
195
196        self.advance_velocity(velocity, dt)
197    }
198
199    fn advance_velocity(&self, velocity: (f32, f32), dt: f32) -> LiquidPose {
200        let dt = dt.clamp(DT_MIN, DT_MAX);
201        let raw_speed = (velocity.0 * velocity.0 + velocity.1 * velocity.1).sqrt();
202        let previous_velocity = self.velocity.get();
203        self.velocity.set(velocity);
204        let accel = (
205            (velocity.0 - previous_velocity.0) / dt,
206            (velocity.1 - previous_velocity.1) / dt,
207        );
208
209        let mut axis = self.axis.get();
210        if raw_speed > AXIS_MIN_SPEED {
211            axis = (velocity.0 / raw_speed, velocity.1 / raw_speed);
212            self.axis.set(axis);
213        }
214        let accel_along = accel.0 * axis.0 + accel.1 * axis.1;
215
216        let target_stretch = (1.0 + STRETCH_PER_SPEED * raw_speed
217            - STRETCH_PER_ACCEL * accel_along)
218            .clamp(STRETCH_MIN, STRETCH_MAX);
219        let signed_bulge = (-BULGE_PER_ACCELERATION * accel_along).clamp(-BULGE_MAX, BULGE_MAX);
220        let target_bulge = (axis.0 * signed_bulge, axis.1 * signed_bulge);
221
222        let follow = |current: f32, target: f32, neutral: f32| {
223            let tau = if (target - neutral).abs() > (current - neutral).abs() {
224                ATTACK_TAU
225            } else {
226                RELEASE_TAU
227            };
228            current + (target - current) * (1.0 - (-dt / tau).exp())
229        };
230        let stretch = follow(self.stretch.get(), target_stretch, 1.0);
231        let current_bulge = self.bulge_vector.get();
232        let current_bulge_length = current_bulge.0.hypot(current_bulge.1);
233        let target_bulge_length = target_bulge.0.hypot(target_bulge.1);
234        let bulge_tau = if target_bulge_length > current_bulge_length {
235            ATTACK_TAU
236        } else {
237            RELEASE_TAU
238        };
239        let bulge_follow = 1.0 - (-dt / bulge_tau).exp();
240        let bulge_vector = (
241            current_bulge.0 + (target_bulge.0 - current_bulge.0) * bulge_follow,
242            current_bulge.1 + (target_bulge.1 - current_bulge.1) * bulge_follow,
243        );
244        let speed = follow(self.speed.get(), raw_speed, 0.0);
245        let at_rest = raw_speed <= REST_SPEED
246            && speed <= REST_SPEED
247            && (stretch - 1.0).abs() <= REST_STRETCH
248            && bulge_vector.0.hypot(bulge_vector.1) <= REST_BULGE;
249        let (stretch, bulge_vector, speed) = if at_rest {
250            (1.0, (0.0, 0.0), 0.0)
251        } else {
252            (stretch, bulge_vector, speed)
253        };
254        let bulge = bulge_vector.0.hypot(bulge_vector.1);
255        self.stretch.set(stretch);
256        self.bulge_vector.set(bulge_vector);
257        self.speed.set(speed);
258
259        let bulge_direction = if bulge > 1.0e-4 {
260            bulge_vector.1.atan2(bulge_vector.0)
261        } else {
262            axis.1.atan2(axis.0)
263        };
264
265        let pose = LiquidPose {
266            stretch,
267            ortho: 1.0 / stretch,
268            axis,
269            bulge_amplitude: bulge,
270            bulge_direction,
271            speed,
272        };
273        self.pose.set(pose);
274        pose
275    }
276
277    /// Latest pose without advancing.
278    pub fn pose(&self) -> LiquidPose {
279        self.pose.get()
280    }
281}
282
283fn redraw_until_rest(pose: LiquidPose) -> LiquidPose {
284    if pose.stretch != 1.0 || pose.bulge_amplitude != 0.0 || pose.speed != 0.0 {
285        cranpose_ui::request_current_draw_redraw();
286    }
287    pose
288}
289
290/// Remember one [`LiquidDynamics`] for the calling composition site.
291#[composable]
292#[track_caller]
293pub fn rememberLiquidDynamics() -> Rc<LiquidDynamics> {
294    let caller = cranpose_core::caller_location_key();
295    with_current_composer(|composer| {
296        let runtime = composer.runtime_handle();
297        composer
298            .remember_at(caller, move || Rc::new(LiquidDynamics::new(runtime)))
299            .with(Rc::clone)
300    })
301}
302
303#[cfg(test)]
304#[path = "tests/dynamics_tests.rs"]
305mod tests;