1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
//! Utilities for client-side extrapolation of remote entity states.
//!
//! Extrapolation is used to predict an entity's state for a short duration beyond
//! the last received authoritative server update, helping to mask network latency.
//! It typically relies on the last known state and velocities.
use std::cell::Cell;
use std::fmt::Debug;
use crate::types::ClientTimeMs;
/// Trait for types whose state can be extrapolated forward given a velocity and a time delta.
///
/// This is implemented by the application's `StateType` or `RenderStateType` for entities
/// whose movement can be reasonably predicted for short periods.
///
/// - `VelocityType`: The application-defined type representing the entity's velocity (e.g., a struct containing linear and angular velocity).
/// - `TimeDelta`: The type representing the duration to extrapolate over (e.g., `f32` seconds, `std::time::Duration`).
pub trait Extrapolatable<VelocityType, TimeDelta>
where
Self: Sized + Clone,
VelocityType: Debug,
TimeDelta: Copy + Debug,
{
/// Extrapolates the current state (`self`) forward by `delta_time` using the given `velocity`.
/// Returns the new, extrapolated state.
fn extrapolate_with_velocity(&self, velocity: &VelocityType, delta_time: TimeDelta) -> Self;
}
/// Stores the last known authoritative state and velocity for an entity,
/// used as a basis for extrapolation.
#[derive(Debug, Clone)]
pub struct ExtrapolationBase<StateType, VelocityType, ServerTimestamp>
where
StateType: Clone + Debug,
VelocityType: Clone + Debug,
ServerTimestamp: Copy + Debug + PartialOrd, // PartialOrd for comparing timestamps
{
/// The last authoritative state received from the server.
pub state: StateType,
/// The last authoritative velocity received from the server.
pub velocity: VelocityType,
/// The server's timestamp when this state and velocity were authoritative.
pub server_timestamp: ServerTimestamp,
/// The client's local time when this server update was processed.
/// Used to help estimate how "old" this data is relative to current client time.
pub client_receipt_time_ms: ClientTimeMs,
/// How many extrapolations ran past the cap. `Cell` because the method that
/// counts is a read: making it `&mut self` would force every caller holding a
/// base across frames to hold it mutably to draw.
over_extrapolations: Cell<u64>,
}
impl<StateType, VelocityType, ServerTimestamp> ExtrapolationBase<StateType, VelocityType, ServerTimestamp>
where
StateType: Clone + Debug,
VelocityType: Clone + Debug,
ServerTimestamp: Copy + Debug + PartialOrd,
{
pub fn new(
state: StateType,
velocity: VelocityType,
server_timestamp: ServerTimestamp,
client_receipt_time_ms: ClientTimeMs,
) -> Self {
Self {
state,
velocity,
server_timestamp,
client_receipt_time_ms,
over_extrapolations: Cell::new(0),
}
}
/// Attempts to get an extrapolated state for the `target_client_render_time_ms`.
///
/// - `target_client_render_time_ms`: The client's current rendering time.
/// - `max_extrapolation_duration_ms`: The longest extrapolation allowed, measured from
/// `client_receipt_time_ms`. A target past it is held at the cap and counted in
/// [`over_extrapolations`](Self::over_extrapolations).
/// - `convert_ms_to_time_delta`: A function to convert a millisecond duration (u64)
/// into the `TimeDelta` type required by `StateType::extrapolate_with_velocity`.
///
/// Always returns `Some`. A target before `client_receipt_time_ms` gets the base
/// state unchanged.
pub fn get_extrapolated_state<TimeDelta>(
&self,
target_client_render_time_ms: ClientTimeMs,
max_extrapolation_duration_ms: u64,
convert_ms_to_time_delta: impl Fn(u64) -> TimeDelta,
) -> Option<StateType>
where
StateType: Extrapolatable<VelocityType, TimeDelta>,
VelocityType: Debug,
TimeDelta: Copy + Debug,
{
if target_client_render_time_ms < self.client_receipt_time_ms {
// Target render time is in the past relative to when we received this base state.
// Extrapolation is for predicting the future from this base state.
// For past states, interpolation should be used.
tracing::trace!(
target_render_ms = target_client_render_time_ms,
receipt_ms = self.client_receipt_time_ms,
"Target render time is before last update receipt; extrapolation not applicable. Returning last auth state."
);
return Some(self.state.clone());
}
let time_since_receipt_ms: u64 = target_client_render_time_ms - self.client_receipt_time_ms;
// Cap the *duration* rather than discarding the extrapolation.
//
// Returning the un-extrapolated state past the limit causes a
// discontinuity: at the limit the entity has coasted `velocity * max_ms`
// forward and one millisecond later it is drawn back at the raw sample.
// That jumps the whole extrapolation window backwards and jitter around the
// boundary makes it flicker. With the duration capped the entity coasts to
// the limit and stops there.
let capped_ms = time_since_receipt_ms.min(max_extrapolation_duration_ms);
if time_since_receipt_ms > max_extrapolation_duration_ms {
self.over_extrapolations.set(self.over_extrapolations.get() + 1);
// A `warn` even though holding is a legitimate outcome, because reaching
// this branch *steadily* almost always means a **render target computed
// the wrong way** rather than a starved link. A target derived from an
// absolute clock estimate sits ahead of the newest sample by the whole
// link delay, so the view never interpolates at all and every entity is
// drawn held or dead reckoned. On screen remote entities stutter or
// overshoot and this line is the only direct report of it.
//
// Steer the render clock toward the stream instead (see
// [`InterpolationClock::resync`]) so the target trails the newest sample
// by a couple of send intervals. Then this fires only on real starvation,
// which is bursty and rare and worth hearing about. For remote entities
// the standard approach is to not extrapolate at all: render in the past
// far enough that two real snapshots always bracket the target, which is
// Gambetta's entity interpolation and what
// `RenderOpts { extrapolate: false }` selects.
//
// [`InterpolationClock::resync`]: crate::interpolation::InterpolationClock::resync
tracing::warn!(
elapsed_ms = time_since_receipt_ms,
max_ms = max_extrapolation_duration_ms,
"Extrapolation window exceeded, holding at the limit. Steady occurrences usually mean the render target is ahead of the newest snapshot rather than trailing it."
);
}
let extrapolation_delta: TimeDelta = convert_ms_to_time_delta(capped_ms);
let extrapolated_state = self
.state
.extrapolate_with_velocity(&self.velocity, extrapolation_delta);
tracing::trace!(
target_render_ms = target_client_render_time_ms,
extrap_dur_ms = time_since_receipt_ms,
"Extrapolated state generated."
);
Some(extrapolated_state)
}
/// How many extrapolations were asked to reach further past receipt than the cap
/// allowed, and were held at the cap instead.
///
/// Holding is a legitimate outcome, so read this as a rate rather than an
/// error count: climbing steadily means the render target is being computed
/// ahead of the newest sample rather than trailing it, so the entity is being
/// dead reckoned every frame instead of interpolated. See the note in
/// [`get_extrapolated_state`](Self::get_extrapolated_state) for the fix.
pub fn over_extrapolations(&self) -> u64 {
self.over_extrapolations.get()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ClientTimeMs;
/// A one-dimensional position, so the boundary arithmetic is readable.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Pos(f32);
impl Extrapolatable<f32, f32> for Pos {
fn extrapolate_with_velocity(&self, velocity: &f32, dt: f32) -> Self {
Pos(self.0 + velocity * dt)
}
}
#[test]
fn crossing_the_extrapolation_limit_does_not_move_the_entity_backwards() {
// Returning the *un-extrapolated* state past the limit would draw an entity
// coasted `velocity * max_ms` forward back at the raw sample one millisecond
// later. That jumps the whole window backwards and jitter around the
// boundary makes it flicker. With the duration capped it coasts to the
// limit and stops there.
let base = ExtrapolationBase::new(Pos(0.0), 100.0, 0u64, 0);
let max_ms = 120;
let at = |t: ClientTimeMs| base.get_extrapolated_state(t, max_ms, |ms| ms as f32 / 1000.0).unwrap();
let inside = at(119);
let outside = at(121);
assert!(
(outside.0 - inside.0).abs() < 1.0,
"crossing the limit jumped from {inside:?} to {outside:?}"
);
assert!(inside.0 > 11.0, "it really was extrapolating up to the limit: {inside:?}");
}
#[test]
fn holding_at_the_limit_is_counted_rather_than_only_logged() {
// Holding is a legitimate outcome, so this is a rate and not an error count.
// Climbing steadily means the render target is computed ahead of the newest
// sample instead of trailing it, and the entity is dead reckoned every frame.
let base = ExtrapolationBase::new(Pos(0.0), 100.0, 0u64, 0);
assert_eq!(base.over_extrapolations(), 0);
let _ = base.get_extrapolated_state(50, 120, |ms| ms as f32 / 1000.0);
assert_eq!(base.over_extrapolations(), 0, "inside the cap");
let _ = base.get_extrapolated_state(500, 120, |ms| ms as f32 / 1000.0);
let _ = base.get_extrapolated_state(900, 120, |ms| ms as f32 / 1000.0);
assert_eq!(base.over_extrapolations(), 2);
}
#[test]
fn past_the_limit_the_entity_holds_where_it_stopped() {
// Held at the limit, not at the sample, and held *steadily* however far the
// target runs on.
let base = ExtrapolationBase::new(Pos(0.0), 100.0, 0u64, 0);
let max_ms = 120;
let at = |t: ClientTimeMs| base.get_extrapolated_state(t, max_ms, |ms| ms as f32 / 1000.0).unwrap();
let limit = at(120);
assert_eq!(at(500), limit);
assert_eq!(at(5000), limit);
assert!((limit.0 - 12.0).abs() < 1e-4, "held at the limit's position: {limit:?}");
}
use std::time::Duration;
#[derive(Debug, Clone, PartialEq)]
struct TestExtrapState {
position: f32,
}
#[derive(Debug, Clone, PartialEq)]
struct TestExtrapVelocity {
speed: f32,
}
impl Extrapolatable<TestExtrapVelocity, Duration> for TestExtrapState {
fn extrapolate_with_velocity(&self, velocity: &TestExtrapVelocity, delta_time: Duration) -> Self {
TestExtrapState {
position: self.position + velocity.speed * delta_time.as_secs_f32(),
}
}
}
fn ms_to_duration(ms: u64) -> Duration {
Duration::from_millis(ms)
}
#[test]
fn basic_extrapolation() {
let base_state = TestExtrapState { position: 10.0 };
let base_velocity = TestExtrapVelocity { speed: 5.0 }; // 5 units/sec
let server_ts: ClientTimeMs = 1000; // Some server time unit
let client_receipt_ts: ClientTimeMs = 5000; // Client received this at its 5000ms mark
let extrap_base = ExtrapolationBase::new(base_state.clone(), base_velocity.clone(), server_ts, client_receipt_ts);
// Target render time is 100ms after receipt (client time 5100ms)
let target_render_time = client_receipt_ts + 100; // 5100ms
let max_extrap_ms = 200;
let extrapolated = extrap_base
.get_extrapolated_state(
target_render_time,
max_extrap_ms,
ms_to_duration, // Convert 100ms to Duration for the trait
)
.unwrap();
// Expected position: 10.0 (base) + 5.0 units/sec * 0.1 sec = 10.0 + 0.5 = 10.5
assert!((extrapolated.position - 10.5).abs() < f32::EPSILON);
}
#[test]
fn extrapolation_exceeds_max_duration() {
let base_state = TestExtrapState { position: 10.0 };
let base_velocity = TestExtrapVelocity { speed: 5.0 };
let server_ts: ClientTimeMs = 1000;
let client_receipt_ts: ClientTimeMs = 5000;
let extrap_base = ExtrapolationBase::new(base_state.clone(), base_velocity.clone(), server_ts, client_receipt_ts);
// Target render time is 300ms after receipt (client time 5300ms)
let target_render_time = client_receipt_ts + 300;
let max_extrap_ms = 200; // Max is 200ms
let extrapolated = extrap_base
.get_extrapolated_state(target_render_time, max_extrap_ms, ms_to_duration)
.unwrap();
// Capped at 200ms of travel, *not* rewound to the base state. Rewinding is
// the discontinuity: at 200ms the entity has moved 200ms of velocity and at
// 201ms it would be drawn back where it started.
let capped = base_state.position + base_velocity.speed * (max_extrap_ms as f32 / 1000.0);
assert!(
(extrapolated.position - capped).abs() < 1e-4,
"expected the position at the cap ({capped}), got {}",
extrapolated.position
);
assert_ne!(extrapolated, base_state, "past the cap must not rewind to the raw sample");
}
#[test]
fn extrapolation_target_time_in_past() {
let base_state = TestExtrapState { position: 10.0 };
let base_velocity = TestExtrapVelocity { speed: 5.0 };
let server_ts: ClientTimeMs = 1000;
let client_receipt_ts: ClientTimeMs = 5000; // Received at 5000ms
let extrap_base = ExtrapolationBase::new(base_state.clone(), base_velocity.clone(), server_ts, client_receipt_ts);
// Target render time is before the receipt time (client time 4900ms)
let target_render_time = client_receipt_ts - 100;
let max_extrap_ms = 200;
let extrapolated = extrap_base
.get_extrapolated_state(target_render_time, max_extrap_ms, ms_to_duration)
.unwrap();
// Should return the base state (clamped) as extrapolation is for future predictions from base.
assert_eq!(extrapolated, base_state);
}
#[test]
fn extrapolation_at_receipt_time() {
let base_state = TestExtrapState { position: 10.0 };
let base_velocity = TestExtrapVelocity { speed: 5.0 };
let server_ts: ClientTimeMs = 1000;
let client_receipt_ts: ClientTimeMs = 5000;
let extrap_base = ExtrapolationBase::new(base_state.clone(), base_velocity.clone(), server_ts, client_receipt_ts);
// Target render time is exactly the receipt time
let target_render_time = client_receipt_ts; // 5000ms
let max_extrap_ms = 200;
let extrapolated = extrap_base
.get_extrapolated_state(target_render_time, max_extrap_ms, ms_to_duration)
.unwrap();
// Extrapolation duration is 0, so should return the base state.
assert_eq!(extrapolated, base_state);
}
}