use bevy::prelude::*;
use std::collections::VecDeque;
#[derive(Clone, Copy, Debug)]
pub struct TransformSample {
pub position: Vec3,
pub rotation: Quat,
pub timestamp: f64,
}
#[derive(Clone, Copy, Debug)]
pub struct SmootherConfig {
pub buffer_capacity: usize,
pub expected_send_interval_secs: f64,
pub max_jitter_drift_secs: f64,
pub render_delay_secs: f64,
pub max_coord_abs: f32,
}
impl Default for SmootherConfig {
fn default() -> Self {
Self {
buffer_capacity: 32,
expected_send_interval_secs: 1.0 / 60.0,
max_jitter_drift_secs: 0.5,
render_delay_secs: 0.1,
max_coord_abs: 1.0e6,
}
}
}
#[derive(Component, Default, Debug)]
pub struct TransformBuffer {
pub samples: VecDeque<TransformSample>,
}
impl TransformBuffer {
pub fn push_sample(
&mut self,
position: Vec3,
rotation: Quat,
now: f64,
cfg: &SmootherConfig,
) -> bool {
if !position.is_finite() {
return false;
}
if position.abs().max_element() > cfg.max_coord_abs {
return false;
}
let rotation = if rotation.is_finite() && rotation.length_squared() > 1e-6 {
rotation.normalize()
} else {
Quat::IDENTITY
};
let raw_next = match self.samples.back() {
Some(last) => (last.timestamp + cfg.expected_send_interval_secs).max(now),
None => now,
};
let ceiling = now + cfg.max_jitter_drift_secs;
let timestamp = raw_next.min(ceiling);
self.samples.push_back(TransformSample {
position,
rotation,
timestamp,
});
while self.samples.len() > cfg.buffer_capacity {
self.samples.pop_front();
}
true
}
pub fn smoothed_at(&mut self, now: f64, cfg: &SmootherConfig) -> Option<(Vec3, Quat)> {
if self.samples.is_empty() {
return None;
}
let render_time = now - cfg.render_delay_secs;
let prune_cutoff = render_time - 2.0 * cfg.render_delay_secs.max(0.05);
while self.samples.len() > 2
&& self.samples.get(1).map(|s| s.timestamp).unwrap_or(f64::MAX) < prune_cutoff
{
self.samples.pop_front();
}
let samples = &self.samples;
if samples.len() == 1 || render_time <= samples.front().unwrap().timestamp {
let s = samples.front().unwrap();
return Some((s.position, s.rotation));
}
if render_time >= samples.back().unwrap().timestamp {
let s = samples.back().unwrap();
return Some((s.position, s.rotation));
}
let mut i = 0;
while i + 1 < samples.len() && samples[i + 1].timestamp < render_time {
i += 1;
}
let a = samples[i];
let b = samples[i + 1];
let dt = (b.timestamp - a.timestamp).max(1e-6);
let t = ((render_time - a.timestamp) / dt).clamp(0.0, 1.0) as f32;
let dt_f = dt as f32;
let tangent_a = if i > 0 {
let prev = samples[i - 1];
let total = (b.timestamp - prev.timestamp).max(1e-6) as f32;
(b.position - prev.position) / total * dt_f
} else {
b.position - a.position
};
let tangent_b = if i + 2 < samples.len() {
let next = samples[i + 2];
let total = (next.timestamp - a.timestamp).max(1e-6) as f32;
(next.position - a.position) / total * dt_f
} else {
b.position - a.position
};
let t2 = t * t;
let t3 = t2 * t;
let h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
let h10 = t3 - 2.0 * t2 + t;
let h01 = -2.0 * t3 + 3.0 * t2;
let h11 = t3 - t2;
let position = a.position * h00 + tangent_a * h10 + b.position * h01 + tangent_b * h11;
let rotation = a.rotation.slerp(b.rotation, t);
Some((position, rotation))
}
pub fn latest_snap(&mut self) -> Option<(Vec3, Quat)> {
let last = self.samples.back().copied()?;
while self.samples.len() > 1 {
self.samples.pop_front();
}
Some((last.position, last.rotation))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> SmootherConfig {
SmootherConfig::default()
}
#[test]
fn rejects_non_finite_position() {
let mut buf = TransformBuffer::default();
assert!(!buf.push_sample(Vec3::new(f32::NAN, 0.0, 0.0), Quat::IDENTITY, 0.0, &cfg()));
assert!(!buf.push_sample(
Vec3::new(f32::INFINITY, 0.0, 0.0),
Quat::IDENTITY,
0.0,
&cfg()
));
assert!(buf.samples.is_empty());
}
#[test]
fn rejects_oversized_position() {
let mut buf = TransformBuffer::default();
let huge = Vec3::splat(2.0e6);
assert!(!buf.push_sample(huge, Quat::IDENTITY, 0.0, &cfg()));
assert!(buf.samples.is_empty());
}
#[test]
fn normalises_unnormal_quat() {
let mut buf = TransformBuffer::default();
let q = Quat::from_xyzw(2.0, 0.0, 0.0, 0.0);
assert!(buf.push_sample(Vec3::ZERO, q, 0.0, &cfg()));
let stored = buf.samples.back().unwrap().rotation;
assert!((stored.length() - 1.0).abs() < 1e-5);
}
#[test]
fn substitutes_identity_for_degenerate_quat() {
let mut buf = TransformBuffer::default();
let zero = Quat::from_xyzw(0.0, 0.0, 0.0, 0.0);
assert!(buf.push_sample(Vec3::ZERO, zero, 0.0, &cfg()));
assert_eq!(buf.samples.back().unwrap().rotation, Quat::IDENTITY);
}
#[test]
fn same_frame_burst_does_not_collapse_dt() {
let mut buf = TransformBuffer::default();
let cfg = cfg();
for i in 0..3 {
buf.push_sample(Vec3::new(i as f32, 0.0, 0.0), Quat::IDENTITY, 1.0, &cfg);
}
let stamps: Vec<f64> = buf.samples.iter().map(|s| s.timestamp).collect();
assert!(stamps[1] > stamps[0]);
assert!(stamps[2] > stamps[1]);
}
#[test]
fn timestamp_clamps_to_max_drift() {
let mut buf = TransformBuffer::default();
let cfg = SmootherConfig {
max_jitter_drift_secs: 0.1,
..cfg()
};
buf.samples.push_back(TransformSample {
position: Vec3::ZERO,
rotation: Quat::IDENTITY,
timestamp: 100.0,
});
buf.push_sample(Vec3::ONE, Quat::IDENTITY, 10.0, &cfg);
assert!(buf.samples.back().unwrap().timestamp <= 10.1 + 1e-9);
}
#[test]
fn evicts_oldest_at_capacity() {
let mut buf = TransformBuffer::default();
let cfg = SmootherConfig {
buffer_capacity: 4,
..cfg()
};
for i in 0..10 {
buf.push_sample(
Vec3::new(i as f32, 0.0, 0.0),
Quat::IDENTITY,
i as f64,
&cfg,
);
}
assert_eq!(buf.samples.len(), 4);
assert!(buf.samples.front().unwrap().position.x >= 6.0);
}
#[test]
fn smoothed_at_returns_none_when_empty() {
let mut buf = TransformBuffer::default();
assert_eq!(buf.smoothed_at(1.0, &cfg()), None);
}
#[test]
fn smoothed_at_snaps_before_first_and_after_last() {
let mut buf = TransformBuffer::default();
let cfg = cfg();
buf.samples.push_back(TransformSample {
position: Vec3::new(1.0, 0.0, 0.0),
rotation: Quat::IDENTITY,
timestamp: 1.0,
});
buf.samples.push_back(TransformSample {
position: Vec3::new(2.0, 0.0, 0.0),
rotation: Quat::IDENTITY,
timestamp: 2.0,
});
let (pos, _) = buf.smoothed_at(0.0, &cfg).unwrap();
assert_eq!(pos, Vec3::new(1.0, 0.0, 0.0));
let (pos, _) = buf.smoothed_at(5.0, &cfg).unwrap();
assert_eq!(pos, Vec3::new(2.0, 0.0, 0.0));
}
#[test]
fn smoothed_at_interpolates_between_samples() {
let mut buf = TransformBuffer::default();
let cfg = SmootherConfig {
render_delay_secs: 0.0,
..cfg()
};
buf.samples.push_back(TransformSample {
position: Vec3::new(0.0, 0.0, 0.0),
rotation: Quat::IDENTITY,
timestamp: 0.0,
});
buf.samples.push_back(TransformSample {
position: Vec3::new(10.0, 0.0, 0.0),
rotation: Quat::IDENTITY,
timestamp: 1.0,
});
let (pos, _) = buf.smoothed_at(0.5, &cfg).unwrap();
assert!((pos.x - 5.0).abs() < 1e-4, "expected ~5, got {pos}");
}
#[test]
fn latest_snap_drops_history() {
let mut buf = TransformBuffer::default();
for i in 0..5 {
buf.samples.push_back(TransformSample {
position: Vec3::new(i as f32, 0.0, 0.0),
rotation: Quat::IDENTITY,
timestamp: i as f64,
});
}
let (pos, _) = buf.latest_snap().unwrap();
assert_eq!(pos, Vec3::new(4.0, 0.0, 0.0));
assert_eq!(buf.samples.len(), 1);
}
}