pub const MOTION_RESPONSE: f32 = 0.15;
pub const MAX_TRACKED_SPEED: f32 = 40.0;
pub const MIN_TRACKED_SPEED: f32 = 0.01;
pub fn smooth_velocity(previous: [f32; 2], step: [f32; 2], dt: f32, response: f32) -> [f32; 2] {
let finite = previous.iter().chain(&step).chain([&dt, &response]).all(|x| x.is_finite());
if !finite || dt <= 0.0 {
return previous;
}
let raw = [step[0] / dt, step[1] / dt];
let raw_speed = raw[0].hypot(raw[1]);
if raw_speed > MAX_TRACKED_SPEED {
return previous;
}
let k = 1.0 - (-dt / response.max(1.0e-3)).exp();
let v = [previous[0] + (raw[0] - previous[0]) * k, previous[1] + (raw[1] - previous[1]) * k];
if v[0].hypot(v[1]) < MIN_TRACKED_SPEED && raw_speed < MIN_TRACKED_SPEED { [0.0, 0.0] } else { v }
}
pub fn relative_air(wind: [f32; 2], velocity: [f32; 2], gain: f32) -> [f32; 2] {
let gain = gain.max(0.0);
[wind[0] - gain * velocity[0], wind[1] - gain * velocity[1]]
}
#[cfg(test)]
mod tests {
use super::*;
const STEP: f32 = 1.0 / 60.0;
fn run(v: [f32; 2], speed: f32, frames: usize, dt: f32) -> [f32; 2] {
(0..frames).fold(v, |v, _| smooth_velocity(v, [speed * dt, 0.0], dt, MOTION_RESPONSE))
}
#[test]
fn a_constant_step_reaches_the_true_velocity_in_about_three_time_constants() {
let frames = (3.0 * MOTION_RESPONSE / STEP) as usize;
let v = run([0.0, 0.0], 2.0, frames, STEP);
assert!((v[0] / 2.0 - 0.95).abs() < 0.02, "{v:?} after {frames} frames");
let v = run([0.0, 0.0], 2.0, 600, STEP);
assert!((v[0] - 2.0).abs() < 1e-3);
}
#[test]
fn the_curve_is_the_same_at_any_frame_rate() {
let at = |dt: f32| run([0.0, 0.0], 1.5, (0.3 / dt).round() as usize, dt)[0];
let (slow, mid, fast) = (at(1.0 / 30.0), at(1.0 / 60.0), at(1.0 / 120.0));
assert!((slow - mid).abs() < 0.02 && (mid - fast).abs() < 0.02, "{slow} {mid} {fast}");
}
#[test]
fn a_paused_world_holds_the_velocity() {
let v = [0.7, -0.2];
assert_eq!(smooth_velocity(v, [3.0, 3.0], 0.0, MOTION_RESPONSE), v);
assert_eq!(smooth_velocity(v, [3.0, 3.0], -1.0, MOTION_RESPONSE), v);
}
#[test]
fn a_teleport_carries_no_velocity() {
let v = [0.7, 0.0];
assert_eq!(smooth_velocity(v, [3.0, 0.0], STEP, MOTION_RESPONSE), v);
assert!(smooth_velocity(v, [30.0 * STEP, 0.0], STEP, MOTION_RESPONSE)[0] > v[0]);
}
#[test]
fn a_fire_that_stops_settles_to_exactly_zero_and_a_creeping_one_is_not_rounded_away() {
let v = run([0.0, 0.0], 1.4, 120, STEP);
let settled = (0..300).fold(v, |v, _| smooth_velocity(v, [0.0, 0.0], STEP, MOTION_RESPONSE));
assert_eq!(settled, [0.0, 0.0], "a stopped fire is a still fire");
let creep = run([0.0, 0.0], 0.05, 300, STEP);
assert!((creep[0] - 0.05).abs() < 1e-3, "{creep:?}");
assert_eq!(run([0.0, 0.0], 0.005, 300, STEP), [0.0, 0.0]);
}
#[test]
fn a_swinging_arm_reverses_the_velocity_and_the_filter_follows_it_with_a_lag() {
let mut v = [0.0, 0.0];
let mut peak: f32 = 0.0;
for i in 0..600 {
let t = i as f32 * STEP;
let raw = (core::f32::consts::TAU * t).sin();
v = smooth_velocity(v, [raw * STEP, 0.0], STEP, MOTION_RESPONSE);
if i > 300 {
peak = peak.max(v[0].abs());
}
}
assert!((0.6..0.85).contains(&peak), "{peak}");
}
#[test]
fn a_non_finite_input_returns_the_previous_velocity() {
let v = [0.3, 0.1];
for bad in [f32::NAN, f32::INFINITY] {
assert_eq!(smooth_velocity(v, [bad, 0.0], STEP, MOTION_RESPONSE), v);
assert_eq!(smooth_velocity(v, [0.0, 0.0], bad, MOTION_RESPONSE), v);
assert_eq!(smooth_velocity(v, [0.0, 0.0], STEP, bad), v);
}
}
#[test]
fn relative_air_is_the_wind_at_rest_and_for_gain_zero_and_a_bad_gain() {
assert_eq!(relative_air([1.0, -2.0], [0.0, 0.0], 1.0), [1.0, -2.0]);
assert_eq!(relative_air([1.0, -2.0], [5.0, 5.0], 0.0), [1.0, -2.0]);
assert_eq!(relative_air([1.0, -2.0], [5.0, 5.0], -3.0), [1.0, -2.0]);
assert_eq!(relative_air([1.0, -2.0], [5.0, 5.0], f32::NAN), [1.0, -2.0]);
assert_eq!(relative_air([0.0, 0.0], [2.0, 0.0], 0.5), [-1.0, 0.0]);
}
}