use std::{cell::Cell, rc::Rc};
use cranpose_core::{RuntimeHandle, with_current_composer};
use cranpose_macros::composable;
use crate::material::GlassDeformation;
const STRETCH_PER_SPEED: f32 = 3.2e-4;
const STRETCH_PER_ACCEL: f32 = 3.5e-5;
pub const STRETCH_MIN: f32 = 0.78;
pub const STRETCH_MAX: f32 = 1.50;
const BULGE_PER_ACCELERATION: f32 = 4.5e-4;
pub const BULGE_MAX: f32 = 8.0;
const ATTACK_TAU: f32 = 0.03;
const RELEASE_TAU: f32 = 0.11;
const AXIS_MIN_SPEED: f32 = 60.0;
const REST_SPEED: f32 = 1.0;
const REST_STRETCH: f32 = 5.0e-4;
const REST_BULGE: f32 = 1.0e-2;
const DT_MIN: f32 = 1.0 / 1000.0;
const DT_MAX: f32 = 1.0 / 15.0;
const TELEPORT_SPEED: f32 = 30_000.0;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LiquidPose {
pub stretch: f32,
pub ortho: f32,
pub axis: (f32, f32),
pub bulge_amplitude: f32,
pub bulge_direction: f32,
pub speed: f32,
}
impl Default for LiquidPose {
fn default() -> Self {
Self {
stretch: 1.0,
ortho: 1.0,
axis: (1.0, 0.0),
bulge_amplitude: 0.0,
bulge_direction: 0.0,
speed: 0.0,
}
}
}
impl LiquidPose {
pub fn deformation(&self) -> GlassDeformation {
GlassDeformation::incompressible(self.axis, self.stretch)
}
pub fn energy(&self) -> f32 {
(self.speed / 1100.0).clamp(0.0, 1.0)
}
}
pub struct LiquidDynamics {
runtime: RuntimeHandle,
last_nanos: Cell<Option<u64>>,
last_pos: Cell<Option<(f32, f32)>>,
velocity: Cell<(f32, f32)>,
stretch: Cell<f32>,
bulge_vector: Cell<(f32, f32)>,
speed: Cell<f32>,
axis: Cell<(f32, f32)>,
pose: Cell<LiquidPose>,
}
impl LiquidDynamics {
pub fn new(runtime: RuntimeHandle) -> Self {
Self {
runtime,
last_nanos: Cell::new(None),
last_pos: Cell::new(None),
velocity: Cell::new((0.0, 0.0)),
stretch: Cell::new(1.0),
bulge_vector: Cell::new((0.0, 0.0)),
speed: Cell::new(0.0),
axis: Cell::new((1.0, 0.0)),
pose: Cell::new(LiquidPose::default()),
}
}
pub fn reset(&self) {
self.last_nanos.set(None);
self.last_pos.set(None);
self.velocity.set((0.0, 0.0));
self.stretch.set(1.0);
self.bulge_vector.set((0.0, 0.0));
self.speed.set(0.0);
self.pose.set(LiquidPose {
axis: self.axis.get(),
..LiquidPose::default()
});
}
pub fn update(&self, pos: (f32, f32)) -> LiquidPose {
redraw_until_rest(self.integrate(pos))
}
fn integrate(&self, pos: (f32, f32)) -> LiquidPose {
let Some(now) = self.runtime.last_frame_time_nanos() else {
self.last_pos.set(Some(pos));
return self.pose.get();
};
match self.last_nanos.get() {
Some(last) if last == now => {
self.last_pos.set(Some(pos));
self.pose.get()
}
Some(last) => {
let dt = (now.saturating_sub(last)) as f32 / 1_000_000_000.0;
self.last_nanos.set(Some(now));
self.advance(pos, dt)
}
None => {
self.last_nanos.set(Some(now));
self.last_pos.set(Some(pos));
self.pose.get()
}
}
}
pub fn advance(&self, pos: (f32, f32), dt: f32) -> LiquidPose {
let Some(last_pos) = self.last_pos.get() else {
self.last_pos.set(Some(pos));
return self.pose.get();
};
let dt = dt.clamp(DT_MIN, DT_MAX);
let delta = (pos.0 - last_pos.0, pos.1 - last_pos.1);
self.last_pos.set(Some(pos));
let velocity = (delta.0 / dt, delta.1 / dt);
let raw_speed = (velocity.0 * velocity.0 + velocity.1 * velocity.1).sqrt();
if raw_speed > TELEPORT_SPEED {
self.velocity.set((0.0, 0.0));
return self.pose.get();
}
self.advance_velocity(velocity, dt)
}
fn advance_velocity(&self, velocity: (f32, f32), dt: f32) -> LiquidPose {
let dt = dt.clamp(DT_MIN, DT_MAX);
let raw_speed = (velocity.0 * velocity.0 + velocity.1 * velocity.1).sqrt();
let previous_velocity = self.velocity.get();
self.velocity.set(velocity);
let accel = (
(velocity.0 - previous_velocity.0) / dt,
(velocity.1 - previous_velocity.1) / dt,
);
let mut axis = self.axis.get();
if raw_speed > AXIS_MIN_SPEED {
axis = (velocity.0 / raw_speed, velocity.1 / raw_speed);
self.axis.set(axis);
}
let accel_along = accel.0 * axis.0 + accel.1 * axis.1;
let target_stretch = (1.0 + STRETCH_PER_SPEED * raw_speed
- STRETCH_PER_ACCEL * accel_along)
.clamp(STRETCH_MIN, STRETCH_MAX);
let signed_bulge = (-BULGE_PER_ACCELERATION * accel_along).clamp(-BULGE_MAX, BULGE_MAX);
let target_bulge = (axis.0 * signed_bulge, axis.1 * signed_bulge);
let follow = |current: f32, target: f32, neutral: f32| {
let tau = if (target - neutral).abs() > (current - neutral).abs() {
ATTACK_TAU
} else {
RELEASE_TAU
};
current + (target - current) * (1.0 - (-dt / tau).exp())
};
let stretch = follow(self.stretch.get(), target_stretch, 1.0);
let current_bulge = self.bulge_vector.get();
let current_bulge_length = current_bulge.0.hypot(current_bulge.1);
let target_bulge_length = target_bulge.0.hypot(target_bulge.1);
let bulge_tau = if target_bulge_length > current_bulge_length {
ATTACK_TAU
} else {
RELEASE_TAU
};
let bulge_follow = 1.0 - (-dt / bulge_tau).exp();
let bulge_vector = (
current_bulge.0 + (target_bulge.0 - current_bulge.0) * bulge_follow,
current_bulge.1 + (target_bulge.1 - current_bulge.1) * bulge_follow,
);
let speed = follow(self.speed.get(), raw_speed, 0.0);
let at_rest = raw_speed <= REST_SPEED
&& speed <= REST_SPEED
&& (stretch - 1.0).abs() <= REST_STRETCH
&& bulge_vector.0.hypot(bulge_vector.1) <= REST_BULGE;
let (stretch, bulge_vector, speed) = if at_rest {
(1.0, (0.0, 0.0), 0.0)
} else {
(stretch, bulge_vector, speed)
};
let bulge = bulge_vector.0.hypot(bulge_vector.1);
self.stretch.set(stretch);
self.bulge_vector.set(bulge_vector);
self.speed.set(speed);
let bulge_direction = if bulge > 1.0e-4 {
bulge_vector.1.atan2(bulge_vector.0)
} else {
axis.1.atan2(axis.0)
};
let pose = LiquidPose {
stretch,
ortho: 1.0 / stretch,
axis,
bulge_amplitude: bulge,
bulge_direction,
speed,
};
self.pose.set(pose);
pose
}
pub fn pose(&self) -> LiquidPose {
self.pose.get()
}
}
fn redraw_until_rest(pose: LiquidPose) -> LiquidPose {
if pose.stretch != 1.0 || pose.bulge_amplitude != 0.0 || pose.speed != 0.0 {
cranpose_ui::request_current_draw_redraw();
}
pose
}
#[composable]
#[track_caller]
pub fn rememberLiquidDynamics() -> Rc<LiquidDynamics> {
let caller = cranpose_core::caller_location_key();
with_current_composer(|composer| {
let runtime = composer.runtime_handle();
composer
.remember_at(caller, move || Rc::new(LiquidDynamics::new(runtime)))
.with(Rc::clone)
})
}
#[cfg(test)]
#[path = "tests/dynamics_tests.rs"]
mod tests;