notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 3fa22479527ed7dd4a1476f2e147f2de95d50958
parent 7ac7dc20993ddf89e6c3e0408eaf55a8ed959c14
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 23 Feb 2026 13:26:16 -0800

nostrverse: add velocity-based dead reckoning for presence interpolation

Include velocity in presence events (kind 10555) so remote user positions
can be extrapolated between updates. Publisher computes velocity from
position deltas and triggers on start/stop and direction changes (>30°).
Receiver extrapolates with capped dead reckoning (3s/10m max) and smooth
lerp correction, replacing the previous snap-to-position behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_nostrverse/src/lib.rs | 31+++++++++++++++++++++++++++++--
Mcrates/notedeck_nostrverse/src/nostr_events.rs | 31+++++++++++++++++++++++++++++--
Mcrates/notedeck_nostrverse/src/presence.rs | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcrates/notedeck_nostrverse/src/room_state.rs | 10++++++++++
4 files changed, 198 insertions(+), 27 deletions(-)

diff --git a/crates/notedeck_nostrverse/src/lib.rs b/crates/notedeck_nostrverse/src/lib.rs @@ -40,6 +40,12 @@ fn demo_pubkey() -> Pubkey { const AVATAR_SCALE: f32 = 7.0; /// How fast the avatar yaw lerps toward the target (higher = faster) const AVATAR_YAW_LERP_SPEED: f32 = 10.0; +/// How fast remote avatar position lerps toward extrapolated target +const AVATAR_POS_LERP_SPEED: f32 = 8.0; +/// Maximum extrapolation time (seconds) before clamping dead reckoning +const MAX_EXTRAPOLATION_TIME: f64 = 3.0; +/// Maximum extrapolation distance from last known position +const MAX_EXTRAPOLATION_DISTANCE: f32 = 10.0; /// Demo room in protoverse .space format const DEMO_SPACE: &str = r#"(room (name "Demo Room") (shape rectangle) (width 20) (height 15) (depth 10) @@ -456,6 +462,7 @@ impl NostrverseApp { && let Some(self_user) = self.state.users.iter_mut().find(|u| u.is_self) { self_user.position = pos; + self_user.display_position = pos; } // Sync all user avatars to the scene @@ -464,6 +471,8 @@ impl NostrverseApp { .map(|b| (b.max.y - b.min.y) * 0.5) .unwrap_or(0.0); let avatar_y_offset = avatar_half_h * AVATAR_SCALE; + let now = self.start_time.elapsed().as_secs_f64(); + let dt = 1.0 / 60.0_f32; // Smoothly lerp avatar yaw toward target if let Some(target_yaw) = avatar_yaw { @@ -471,12 +480,30 @@ impl NostrverseApp { let mut diff = target_yaw - current; diff = (diff + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU) - std::f32::consts::PI; - let dt = 1.0 / 60.0; let t = (AVATAR_YAW_LERP_SPEED * dt).min(1.0); self.state.smooth_avatar_yaw = current + diff * t; } for user in &mut self.state.users { + // Dead reckoning for remote users + if !user.is_self { + let time_since_update = (now - user.update_time).min(MAX_EXTRAPOLATION_TIME) as f32; + let extrapolated = user.position + user.velocity * time_since_update; + + // Clamp extrapolation distance to prevent runaway drift + let offset = extrapolated - user.position; + let target = if offset.length() > MAX_EXTRAPOLATION_DISTANCE { + user.position + offset.normalize() * MAX_EXTRAPOLATION_DISTANCE + } else { + extrapolated + }; + + // Smooth lerp display_position toward the extrapolated target + let t = (AVATAR_POS_LERP_SPEED * dt).min(1.0); + user.display_position = user.display_position.lerp(target, t); + } + + let render_pos = user.display_position; let yaw = if user.is_self { self.state.smooth_avatar_yaw } else { @@ -484,7 +511,7 @@ impl NostrverseApp { }; let transform = Transform { - translation: user.position + Vec3::new(0.0, avatar_y_offset, 0.0), + translation: render_pos + Vec3::new(0.0, avatar_y_offset, 0.0), rotation: glam::Quat::from_rotation_y(yaw), scale: Vec3::splat(AVATAR_SCALE), }; diff --git a/crates/notedeck_nostrverse/src/nostr_events.rs b/crates/notedeck_nostrverse/src/nostr_events.rs @@ -71,8 +71,13 @@ fn get_tag_value<'a>(note: &'a Note<'a>, tag_name: &str) -> Option<&'a str> { /// /// The expiration tag (NIP-40) tells relays/nostrdb to discard the event /// after 90 seconds, matching the client-side stale timeout. -pub fn build_presence_event<'a>(room_naddr: &str, position: glam::Vec3) -> NoteBuilder<'a> { +pub fn build_presence_event<'a>( + room_naddr: &str, + position: glam::Vec3, + velocity: glam::Vec3, +) -> NoteBuilder<'a> { let pos_str = format!("{} {} {}", position.x, position.y, position.z); + let vel_str = format!("{} {} {}", velocity.x, velocity.y, velocity.z); let expiration = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -91,6 +96,9 @@ pub fn build_presence_event<'a>(room_naddr: &str, position: glam::Vec3) -> NoteB .tag_str("position") .tag_str(&pos_str) .start_tag() + .tag_str("velocity") + .tag_str(&vel_str) + .start_tag() .tag_str("expiration") .tag_str(&exp_str) } @@ -105,6 +113,19 @@ pub fn parse_presence_position(note: &Note<'_>) -> Option<glam::Vec3> { Some(glam::Vec3::new(x, y, z)) } +/// Parse a presence event's velocity tag into a Vec3. +/// Returns Vec3::ZERO if no velocity tag (backward compatible with old events). +pub fn parse_presence_velocity(note: &Note<'_>) -> glam::Vec3 { + let Some(vel_str) = get_tag_value(note, "velocity") else { + return glam::Vec3::ZERO; + }; + let mut parts = vel_str.split_whitespace(); + let x: f32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0); + let y: f32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0); + let z: f32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0); + glam::Vec3::new(x, y, z) +} + /// Extract the "a" tag (room naddr) from a presence note. pub fn get_presence_room<'a>(note: &'a Note<'a>) -> Option<&'a str> { get_tag_value(note, "a") @@ -209,7 +230,8 @@ mod tests { #[test] fn test_build_presence_event() { let pos = glam::Vec3::new(1.5, 0.0, -3.2); - let mut builder = build_presence_event("37555:abc123:my-room", pos); + let vel = glam::Vec3::new(2.0, 0.0, -1.0); + let mut builder = build_presence_event("37555:abc123:my-room", pos, vel); let note = builder.build().expect("build note"); assert_eq!(note.content(), ""); @@ -220,6 +242,11 @@ mod tests { assert!((parsed_pos.y - 0.0).abs() < 0.01); assert!((parsed_pos.z - (-3.2)).abs() < 0.01); + let parsed_vel = parse_presence_velocity(&note); + assert!((parsed_vel.x - 2.0).abs() < 0.01); + assert!((parsed_vel.y - 0.0).abs() < 0.01); + assert!((parsed_vel.z - (-1.0)).abs() < 0.01); + // Should have an expiration tag (NIP-40) let exp = get_tag_value(&note, "expiration").expect("missing expiration tag"); let exp_ts: u64 = exp.parse().expect("expiration should be a number"); diff --git a/crates/notedeck_nostrverse/src/presence.rs b/crates/notedeck_nostrverse/src/presence.rs @@ -25,15 +25,28 @@ const STALE_TIMEOUT: f64 = 90.0; /// How often to check for stale users (seconds). const EXPIRY_CHECK_INTERVAL: f64 = 10.0; +/// Minimum speed to consider "moving" (units/s). Below this, velocity is zeroed. +const MIN_SPEED: f32 = 0.1; + +/// Direction change threshold (dot product). cos(30°) ≈ 0.866. +/// If the normalized velocity direction changes by more than ~30°, publish. +const DIRECTION_CHANGE_THRESHOLD: f32 = 0.866; + /// Publishes local user presence as kind 10555 events. /// -/// Only publishes when position changes meaningfully, plus periodic -/// keep-alive to maintain room presence. Does not spam on idle. +/// Only publishes when position or velocity changes meaningfully, plus periodic +/// keep-alive to maintain room presence. Includes velocity for dead reckoning. pub struct PresencePublisher { /// Last position we published last_position: Vec3, + /// Last velocity we published + last_velocity: Vec3, /// Monotonic time of last publish last_publish_time: f64, + /// Previous position sample (for computing velocity) + prev_position: Vec3, + /// Time of previous position sample + prev_position_time: f64, /// Whether we've published at least once published_once: bool, } @@ -42,14 +55,30 @@ impl PresencePublisher { pub fn new() -> Self { Self { last_position: Vec3::ZERO, + last_velocity: Vec3::ZERO, last_publish_time: 0.0, + prev_position: Vec3::ZERO, + prev_position_time: 0.0, published_once: false, } } + /// Compute instantaneous velocity from position samples. + fn compute_velocity(&self, position: Vec3, now: f64) -> Vec3 { + let dt = now - self.prev_position_time; + if dt < 0.01 { + return self.last_velocity; + } + let vel = (position - self.prev_position) / dt as f32; + if vel.length() < MIN_SPEED { + Vec3::ZERO + } else { + vel + } + } + /// Check whether a publish should happen (without side effects). - /// Used for both the real publish path and tests. - fn should_publish(&self, position: Vec3, now: f64) -> bool { + fn should_publish(&self, position: Vec3, velocity: Vec3, now: f64) -> bool { // Always publish the first time if !self.published_once { return true; @@ -63,18 +92,34 @@ impl PresencePublisher { } // Publish if position changed meaningfully - let moved = self.last_position.distance(position) > POSITION_THRESHOLD; - if moved { + if self.last_position.distance(position) > POSITION_THRESHOLD { + return true; + } + + // Publish on start/stop transitions + let was_moving = self.last_velocity.length() > MIN_SPEED; + let is_moving = velocity.length() > MIN_SPEED; + if was_moving != is_moving { return true; } + // Publish on significant direction change while moving + if was_moving && is_moving { + let old_dir = self.last_velocity.normalize(); + let new_dir = velocity.normalize(); + if old_dir.dot(new_dir) < DIRECTION_CHANGE_THRESHOLD { + return true; + } + } + // Keep-alive: publish periodically even when idle elapsed >= KEEPALIVE_INTERVAL } /// Record that a publish happened (update internal state). - fn record_publish(&mut self, position: Vec3, now: f64) { + fn record_publish(&mut self, position: Vec3, velocity: Vec3, now: f64) { self.last_position = position; + self.last_velocity = velocity; self.last_publish_time = now; self.published_once = true; } @@ -88,14 +133,20 @@ impl PresencePublisher { position: Vec3, now: f64, ) -> bool { - if !self.should_publish(position, now) { + let velocity = self.compute_velocity(position, now); + + // Always update position sample for velocity computation + self.prev_position = position; + self.prev_position_time = now; + + if !self.should_publish(position, velocity, now) { return false; } - let builder = nostr_events::build_presence_event(room_naddr, position); + let builder = nostr_events::build_presence_event(room_naddr, position, velocity); nostr_events::ingest_event(builder, ndb, kp); - self.record_publish(position, now); + self.record_publish(position, velocity, now); true } } @@ -135,12 +186,20 @@ pub fn poll_presence( continue; } + let velocity = nostr_events::parse_presence_velocity(note); + // Update or insert user if let Some(user) = users.iter_mut().find(|u| u.pubkey == pubkey) { + // Update authoritative state; preserve display_position for smooth lerp user.position = position; + user.velocity = velocity; + user.update_time = now; user.last_seen = now; } else { let mut user = RoomUser::new(pubkey, "anon".to_string(), position); + user.velocity = velocity; + user.display_position = position; // snap on first appearance + user.update_time = now; user.last_seen = now; users.push(user); } @@ -223,40 +282,88 @@ mod tests { fn test_publisher_first_publish() { let pub_ = PresencePublisher::new(); // First publish should always happen - assert!(pub_.should_publish(Vec3::ZERO, 0.0)); + assert!(pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 0.0)); } #[test] fn test_publisher_no_spam_when_idle() { let mut pub_ = PresencePublisher::new(); - pub_.record_publish(Vec3::ZERO, 0.0); + pub_.record_publish(Vec3::ZERO, Vec3::ZERO, 0.0); // Idle at same position — should NOT publish at 1s, 5s, 10s, 30s - assert!(!pub_.should_publish(Vec3::ZERO, 1.0)); - assert!(!pub_.should_publish(Vec3::ZERO, 5.0)); - assert!(!pub_.should_publish(Vec3::ZERO, 10.0)); - assert!(!pub_.should_publish(Vec3::ZERO, 30.0)); + assert!(!pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 1.0)); + assert!(!pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 5.0)); + assert!(!pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 10.0)); + assert!(!pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 30.0)); // Keep-alive triggers at 60s - assert!(pub_.should_publish(Vec3::ZERO, 60.1)); + assert!(pub_.should_publish(Vec3::ZERO, Vec3::ZERO, 60.1)); } #[test] fn test_publisher_on_movement() { let mut pub_ = PresencePublisher::new(); - pub_.record_publish(Vec3::ZERO, 0.0); + pub_.record_publish(Vec3::ZERO, Vec3::ZERO, 0.0); // Small movement below threshold — no publish - assert!(!pub_.should_publish(Vec3::new(0.1, 0.0, 0.0), 2.0)); + assert!(!pub_.should_publish(Vec3::new(0.1, 0.0, 0.0), Vec3::ZERO, 2.0)); // Significant movement — publish - assert!(pub_.should_publish(Vec3::new(5.0, 0.0, 0.0), 2.0)); + assert!(pub_.should_publish(Vec3::new(5.0, 0.0, 0.0), Vec3::ZERO, 2.0)); // But rate limited: can't publish again within 1s - pub_.record_publish(Vec3::new(5.0, 0.0, 0.0), 2.0); - assert!(!pub_.should_publish(Vec3::new(10.0, 0.0, 0.0), 2.5)); + pub_.record_publish(Vec3::new(5.0, 0.0, 0.0), Vec3::ZERO, 2.0); + assert!(!pub_.should_publish(Vec3::new(10.0, 0.0, 0.0), Vec3::ZERO, 2.5)); // After 1s gap, can publish again - assert!(pub_.should_publish(Vec3::new(10.0, 0.0, 0.0), 3.1)); + assert!(pub_.should_publish(Vec3::new(10.0, 0.0, 0.0), Vec3::ZERO, 3.1)); + } + + #[test] + fn test_publisher_velocity_start_stop() { + let mut pub_ = PresencePublisher::new(); + let pos = Vec3::new(1.0, 0.0, 0.0); + pub_.record_publish(pos, Vec3::ZERO, 0.0); + + // Start moving — should trigger (velocity went from zero to non-zero) + let vel = Vec3::new(3.0, 0.0, 0.0); + assert!(pub_.should_publish(pos, vel, 2.0)); + pub_.record_publish(pos, vel, 2.0); + + // Stop moving — should trigger (velocity went from non-zero to zero) + assert!(pub_.should_publish(pos, Vec3::ZERO, 3.5)); + } + + #[test] + fn test_publisher_velocity_direction_change() { + let mut pub_ = PresencePublisher::new(); + let pos = Vec3::new(1.0, 0.0, 0.0); + let vel_east = Vec3::new(3.0, 0.0, 0.0); + pub_.record_publish(pos, vel_east, 0.0); + + // Small direction change (still mostly east) — no publish + let vel_slight = Vec3::new(3.0, 0.0, 0.5); + assert!(!pub_.should_publish(pos, vel_slight, 2.0)); + + // Large direction change (east → north, 90 degrees) — should publish + let vel_north = Vec3::new(0.0, 0.0, 3.0); + assert!(pub_.should_publish(pos, vel_north, 2.0)); + } + + #[test] + fn test_compute_velocity() { + let mut pub_ = PresencePublisher::new(); + pub_.prev_position = Vec3::ZERO; + pub_.prev_position_time = 0.0; + + // 5 units in 1 second = 5 units/s + let vel = pub_.compute_velocity(Vec3::new(5.0, 0.0, 0.0), 1.0); + assert!((vel.x - 5.0).abs() < 0.01); + + // Very small movement → zeroed (below MIN_SPEED) + pub_.prev_position = Vec3::ZERO; + pub_.prev_position_time = 0.0; + let vel = pub_.compute_velocity(Vec3::new(0.01, 0.0, 0.0), 1.0); + assert_eq!(vel, Vec3::ZERO); } } diff --git a/crates/notedeck_nostrverse/src/room_state.rs b/crates/notedeck_nostrverse/src/room_state.rs @@ -161,7 +161,14 @@ impl RoomObject { pub struct RoomUser { pub pubkey: Pubkey, pub display_name: String, + /// Authoritative position from last presence event pub position: Vec3, + /// Velocity from last presence event (units/second) + pub velocity: Vec3, + /// Smoothed display position (interpolated for remote users, direct for self) + pub display_position: Vec3, + /// Monotonic time when last presence update was received (extrapolation base) + pub update_time: f64, /// Whether this is the current user pub is_self: bool, /// Monotonic timestamp (seconds) of last presence update @@ -178,6 +185,9 @@ impl RoomUser { pubkey, display_name, position, + velocity: Vec3::ZERO, + display_position: position, + update_time: 0.0, is_self: false, last_seen: 0.0, scene_object_id: None,