Skip to main content

thread_engine/
traveler.rs

1//! Travelers — the people sharing a world, and where they are *now*.
2//!
3//! The client half of [presence-wire-v0.1]: a transport feeds
4//! timestamped [`PoseSample`]s in per the presence-wire spec; this interpolates
5//! each traveler ~100 ms in the past (lerp position, slerp rotation, extrapolate
6//! with velocity across gaps) and produces [`EntityInstance`]s for any renderer.
7//! Coordinate frame matches the manifest: right-handed, +Y up, metres, rotation
8//! as a unit quaternion.
9//!
10//! [presence-wire-v0.1]: https://github.com/Pixygon/thread-spec/blob/main/specs/presence-wire-v0.1.md
11
12use std::collections::HashMap;
13
14use glam::{Mat4, Quat, Vec3};
15
16/// One traveler, ready to draw: a world transform and a tint.
17///
18/// This is the entire surface presence needed from a renderer, and it is why
19/// this module could leave the engine it grew up in — two fields, owned here,
20/// so the Thread's presence implementation depends on no renderer at all. A
21/// host converts it to whatever its own instance buffer wants.
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct Instance {
24    pub model: Mat4,
25    pub color: [f32; 4],
26}
27
28/// Render this far behind the newest pose, to hide network jitter (ms).
29const INTERP_DELAY_MS: u64 = 100;
30/// Cap velocity extrapolation across a gap before freezing (ms).
31const MAX_EXTRAP_MS: u64 = 250;
32/// Poses retained per traveler (a little over the interpolation window).
33const MAX_BUFFER: usize = 16;
34
35/// One sampled pose of a traveler. `t_ms` is a monotonic local arrival time —
36/// the transport stamps it on receipt so interpolation shares one clock.
37#[derive(Debug, Clone, Copy)]
38pub struct PoseSample {
39    pub t_ms: u64,
40    pub pos: Vec3,
41    pub rot: Quat,
42    pub vel: Vec3,
43    /// Locomotion/animation state (idle/walk/run/…) — used once avatars animate.
44    pub anim: u8,
45}
46
47/// One traveler's render-ready pose this frame: interpolated transform, look,
48/// portable body (0 = placeholder), and horizontal speed (drives walk vs idle).
49pub struct TravelerPose {
50    pub id: u32,
51    /// Interpolated world position (feet), before any capsule lift.
52    pub position: Vec3,
53    pub model: Mat4,
54    pub color: [f32; 4],
55    pub body: u32,
56    pub speed: f32,
57}
58
59/// A remote traveler: identity + a short history of poses to interpolate.
60pub struct Traveler {
61    pub id: u32,
62    pub name: String,
63    pub color: [f32; 4],
64    /// The traveler's portable avatar (the shared `AvatarSpec` from their
65    /// Passport descriptor) — the renderer swaps the placeholder capsule for
66    /// their assembled body when this is present.
67    pub avatar: Option<infinite_avatar::AvatarSpec>,
68    buffer: Vec<PoseSample>,
69}
70
71impl Traveler {
72    fn new(id: u32, name: String, color: [f32; 4]) -> Self {
73        Self {
74            id,
75            name,
76            color,
77            avatar: None,
78            buffer: Vec::new(),
79        }
80    }
81
82    fn push(&mut self, sample: PoseSample) {
83        // Poses arrive in order (relay-stamped); drop the odd out-of-order one.
84        if self.buffer.last().is_some_and(|l| sample.t_ms < l.t_ms) {
85            return;
86        }
87        self.buffer.push(sample);
88        if self.buffer.len() > MAX_BUFFER {
89            self.buffer.remove(0);
90        }
91    }
92
93    /// Interpolated (position, rotation) at render time `render_t` (ms), or
94    /// `None` if there's no pose yet.
95    fn sample_at(&self, render_t: u64) -> Option<(Vec3, Quat)> {
96        let first = self.buffer.first()?;
97        let last = self.buffer.last()?;
98        if render_t <= first.t_ms {
99            return Some((first.pos, first.rot));
100        }
101        if render_t >= last.t_ms {
102            // Extrapolate along the last velocity, capped, then freeze.
103            let gap = (render_t - last.t_ms).min(MAX_EXTRAP_MS);
104            return Some((last.pos + last.vel * (gap as f32 / 1000.0), last.rot));
105        }
106        // Interpolate within the bracketing pair.
107        for pair in self.buffer.windows(2) {
108            let (a, b) = (&pair[0], &pair[1]);
109            if a.t_ms <= render_t && render_t < b.t_ms {
110                let span = (b.t_ms - a.t_ms).max(1) as f32;
111                let f = (render_t - a.t_ms) as f32 / span;
112                return Some((a.pos.lerp(b.pos, f), a.rot.slerp(b.rot, f)));
113            }
114        }
115        Some((last.pos, last.rot))
116    }
117}
118
119/// All travelers currently present in the world.
120#[derive(Default)]
121pub struct PresenceState {
122    travelers: HashMap<u32, Traveler>,
123}
124
125impl PresenceState {
126    pub fn new() -> Self {
127        Self::default()
128    }
129
130    pub fn count(&self) -> usize {
131        self.travelers.len()
132    }
133
134    pub fn is_empty(&self) -> bool {
135        self.travelers.is_empty()
136    }
137
138    /// A traveler joined (or re-announced). Idempotent.
139    pub fn join(&mut self, id: u32, name: impl Into<String>, color: [f32; 4]) {
140        self.travelers
141            .entry(id)
142            .or_insert_with(|| Traveler::new(id, name.into(), color));
143    }
144
145    /// A traveler left.
146    pub fn leave(&mut self, id: u32) {
147        self.travelers.remove(&id);
148    }
149
150    /// Upgrade a traveler's look once their identity resolves (e.g. a Passport
151    /// descriptor arrived): each `Some` replaces the placeholder. No-op for
152    /// unknown ids.
153    pub fn restyle(&mut self, id: u32, name: Option<String>, color: Option<[f32; 4]>) {
154        if let Some(t) = self.travelers.get_mut(&id) {
155            if let Some(n) = name {
156                t.name = n;
157            }
158            if let Some(c) = color {
159                t.color = c;
160            }
161        }
162    }
163
164    /// Attach a traveler's portable avatar (from their Passport descriptor).
165    pub fn set_avatar(&mut self, id: u32, avatar: Option<infinite_avatar::AvatarSpec>) {
166        if let Some(t) = self.travelers.get_mut(&id) {
167            t.avatar = avatar;
168        }
169    }
170
171    /// Record a pose for a traveler (auto-joins with a default look if unknown).
172    pub fn pose(&mut self, id: u32, sample: PoseSample) {
173        self.travelers
174            .entry(id)
175            .or_insert_with(|| Traveler::new(id, format!("traveler {id}"), default_color(id)))
176            .push(sample);
177    }
178
179    /// Every traveler as a drawable [`Instance`], interpolated at `now_ms`.
180    /// `foot_lift` raises a centered capsule so its feet meet the ground
181    /// (match the player).
182    pub fn entities(&self, now_ms: u64, foot_lift: f32) -> Vec<Instance> {
183        self.poses(now_ms, foot_lift)
184            .into_iter()
185            .map(|p| Instance { model: p.model, color: p.color })
186            .collect()
187    }
188
189    /// Every traveler's interpolated pose at `now_ms` — what the skinned
190    /// character path consumes; [`Self::entities`] is the flat capsule view.
191    pub fn poses(&self, now_ms: u64, foot_lift: f32) -> Vec<TravelerPose> {
192        let render_t = now_ms.saturating_sub(INTERP_DELAY_MS);
193        self.travelers
194            .values()
195            .filter_map(|t| {
196                let (pos, rot) = t.sample_at(render_t)?;
197                let model =
198                    Mat4::from_rotation_translation(rot, pos + Vec3::new(0.0, foot_lift, 0.0));
199                let body = t
200                    .avatar
201                    .as_ref()
202                    .map(|s| s.get(infinite_avatar::AvatarSlot::Body))
203                    .unwrap_or(0);
204                let speed = t
205                    .buffer
206                    .last()
207                    .map(|s| Vec3::new(s.vel.x, 0.0, s.vel.z).length())
208                    .unwrap_or(0.0);
209                Some(TravelerPose {
210                    id: t.id,
211                    position: pos,
212                    model,
213                    color: t.color,
214                    body,
215                    speed,
216                })
217            })
218            .collect()
219    }
220}
221
222/// A stable, distinct-ish colour per traveler id (until avatars carry real looks).
223fn default_color(id: u32) -> [f32; 4] {
224    let h = id.wrapping_mul(2654435761);
225    [
226        0.4 + ((h & 0xFF) as f32 / 255.0) * 0.5,
227        0.4 + (((h >> 8) & 0xFF) as f32 / 255.0) * 0.5,
228        0.4 + (((h >> 16) & 0xFF) as f32 / 255.0) * 0.5,
229        1.0,
230    ]
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    fn s(t_ms: u64, pos: Vec3, vel: Vec3) -> PoseSample {
238        PoseSample {
239            t_ms,
240            pos,
241            rot: Quat::IDENTITY,
242            vel,
243            anim: 0,
244        }
245    }
246
247    #[test]
248    fn interpolates_between_poses() {
249        let mut t = Traveler::new(1, "x".into(), [1.0; 4]);
250        t.push(s(0, Vec3::ZERO, Vec3::ZERO));
251        t.push(s(100, Vec3::new(10.0, 0.0, 0.0), Vec3::ZERO));
252        let (p, _) = t.sample_at(50).unwrap();
253        assert!((p.x - 5.0).abs() < 1e-4, "midpoint lerp, got {}", p.x);
254        assert!((t.sample_at(0).unwrap().0.x - 0.0).abs() < 1e-4);
255        assert!((t.sample_at(100).unwrap().0.x - 10.0).abs() < 1e-4);
256    }
257
258    #[test]
259    fn extrapolates_along_velocity_then_freezes() {
260        let mut t = Traveler::new(1, "x".into(), [1.0; 4]);
261        t.push(s(0, Vec3::ZERO, Vec3::ZERO));
262        t.push(s(100, Vec3::ZERO, Vec3::new(10.0, 0.0, 0.0)));
263        // 50 ms past the last pose → 10 m/s * 0.05 s = 0.5 m.
264        assert!((t.sample_at(150).unwrap().0.x - 0.5).abs() < 1e-4);
265        // Far past → frozen at the cap (250 ms → 2.5 m).
266        assert!((t.sample_at(5000).unwrap().0.x - 2.5).abs() < 1e-4);
267    }
268
269    #[test]
270    fn restyle_upgrades_the_placeholder_look() {
271        let mut p = PresenceState::new();
272        p.pose(5, s(0, Vec3::ZERO, Vec3::ZERO)); // auto-joined with defaults
273        p.restyle(5, Some("Anders".into()), Some([0.1, 0.2, 0.3, 1.0]));
274        let t = p.travelers.get(&5).unwrap();
275        assert_eq!(t.name, "Anders");
276        assert_eq!(t.color, [0.1, 0.2, 0.3, 1.0]);
277        // Unknown ids are ignored, partial restyles keep the rest.
278        p.restyle(99, Some("ghost".into()), None);
279        p.restyle(5, None, Some([1.0; 4]));
280        assert_eq!(p.travelers.get(&5).unwrap().name, "Anders");
281        assert_eq!(p.travelers.get(&5).unwrap().color, [1.0; 4]);
282    }
283
284    #[test]
285    fn produces_one_entity_per_traveler() {
286        let mut p = PresenceState::new();
287        p.pose(1, s(1000, Vec3::new(3.0, 0.0, 0.0), Vec3::ZERO));
288        p.pose(2, s(1000, Vec3::new(-3.0, 0.0, 0.0), Vec3::ZERO));
289        assert_eq!(p.count(), 2);
290        // now well past the poses → both render (frozen).
291        let ents = p.entities(1100, 0.9);
292        assert_eq!(ents.len(), 2);
293        p.leave(1);
294        assert_eq!(p.entities(1100, 0.9).len(), 1);
295    }
296}