feat(net): add lag compensation with history rewind and interpolation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 16:27:58 +09:00
parent 28b24226e7
commit 6beafc6949
2 changed files with 142 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
use std::collections::VecDeque;
/// A timestamped snapshot of entity positions for lag compensation.
#[derive(Debug, Clone)]
pub struct HistoryEntry {
pub tick: u64,
pub timestamp: f32, // seconds
pub positions: Vec<([f32; 3], u32)>, // (position, entity_id)
}
/// Stores recent world state history for server-side lag compensation.
pub struct LagCompensation {
history: VecDeque<HistoryEntry>,
pub max_history_ms: f32, // max history duration (e.g., 200ms)
}
impl LagCompensation {
pub fn new(max_history_ms: f32) -> Self {
LagCompensation { history: VecDeque::new(), max_history_ms }
}
/// Record current world state.
pub fn record(&mut self, entry: HistoryEntry) {
self.history.push_back(entry);
// Prune old entries
let cutoff = self.history.back().map(|e| e.timestamp - self.max_history_ms / 1000.0).unwrap_or(0.0);
while let Some(front) = self.history.front() {
if front.timestamp < cutoff {
self.history.pop_front();
} else {
break;
}
}
}
/// Find the closest history entry to the given timestamp.
pub fn rewind(&self, timestamp: f32) -> Option<&HistoryEntry> {
let mut best: Option<&HistoryEntry> = None;
let mut best_diff = f32::MAX;
for entry in &self.history {
let diff = (entry.timestamp - timestamp).abs();
if diff < best_diff {
best_diff = diff;
best = Some(entry);
}
}
best
}
/// Interpolate between two closest entries at the given timestamp.
pub fn rewind_interpolated(&self, timestamp: f32) -> Option<Vec<([f32; 3], u32)>> {
if self.history.len() < 2 { return self.rewind(timestamp).map(|e| e.positions.clone()); }
// Find the two entries bracketing the timestamp
let mut before: Option<&HistoryEntry> = None;
let mut after: Option<&HistoryEntry> = None;
for entry in &self.history {
if entry.timestamp <= timestamp {
before = Some(entry);
} else {
after = Some(entry);
break;
}
}
match (before, after) {
(Some(b), Some(a)) => {
let t = if (a.timestamp - b.timestamp).abs() > 1e-6 {
(timestamp - b.timestamp) / (a.timestamp - b.timestamp)
} else { 0.0 };
// Interpolate positions
let mut result = Vec::new();
for (i, (pos_b, id)) in b.positions.iter().enumerate() {
if let Some((pos_a, _)) = a.positions.get(i) {
let lerped = [
pos_b[0] + (pos_a[0] - pos_b[0]) * t,
pos_b[1] + (pos_a[1] - pos_b[1]) * t,
pos_b[2] + (pos_a[2] - pos_b[2]) * t,
];
result.push((lerped, *id));
}
}
Some(result)
}
_ => self.rewind(timestamp).map(|e| e.positions.clone()),
}
}
pub fn history_len(&self) -> usize { self.history.len() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_record_and_rewind() {
let mut lc = LagCompensation::new(200.0);
lc.record(HistoryEntry { tick: 1, timestamp: 0.0, positions: vec![([1.0, 0.0, 0.0], 0)] });
lc.record(HistoryEntry { tick: 2, timestamp: 0.016, positions: vec![([2.0, 0.0, 0.0], 0)] });
let entry = lc.rewind(0.0).unwrap();
assert_eq!(entry.tick, 1);
}
#[test]
fn test_prune_old() {
let mut lc = LagCompensation::new(100.0); // 100ms
for i in 0..20 {
lc.record(HistoryEntry { tick: i, timestamp: i as f32 * 0.016, positions: vec![] });
}
// At t=0.304, cutoff = 0.304 - 0.1 = 0.204
// Entries before t=0.204 should be pruned
assert!(lc.history_len() < 20);
}
#[test]
fn test_rewind_closest() {
let mut lc = LagCompensation::new(500.0);
lc.record(HistoryEntry { tick: 1, timestamp: 0.0, positions: vec![] });
lc.record(HistoryEntry { tick: 2, timestamp: 0.1, positions: vec![] });
lc.record(HistoryEntry { tick: 3, timestamp: 0.2, positions: vec![] });
let entry = lc.rewind(0.09).unwrap();
assert_eq!(entry.tick, 2); // closest to 0.1
}
#[test]
fn test_rewind_interpolated() {
let mut lc = LagCompensation::new(500.0);
lc.record(HistoryEntry { tick: 1, timestamp: 0.0, positions: vec![([0.0, 0.0, 0.0], 0)] });
lc.record(HistoryEntry { tick: 2, timestamp: 0.1, positions: vec![([10.0, 0.0, 0.0], 0)] });
let interp = lc.rewind_interpolated(0.05).unwrap();
assert!((interp[0].0[0] - 5.0).abs() < 0.1); // midpoint
}
#[test]
fn test_empty_history() {
let lc = LagCompensation::new(200.0);
assert!(lc.rewind(0.0).is_none());
}
}

View File

@@ -5,6 +5,7 @@ pub mod client;
pub mod reliable;
pub mod snapshot;
pub mod interpolation;
pub mod lag_compensation;
pub use packet::Packet;
pub use socket::NetSocket;
@@ -13,3 +14,4 @@ pub use client::{NetClient, ClientEvent};
pub use reliable::{ReliableChannel, OrderedChannel};
pub use snapshot::{Snapshot, EntityState, serialize_snapshot, deserialize_snapshot, diff_snapshots, apply_diff};
pub use interpolation::InterpolationBuffer;
pub use lag_compensation::LagCompensation;