From d7692ab88ee2ab3881a26cbc81b11640c75054d3 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 19 Feb 2026 16:45:39 -0800 Subject: [PATCH] Smooth other-player movement with velocity dead reckoning Previously other players jittered because the entity sat frozen at its destination between movement packets, then snapped to the new start position on the next packet (stop-pop-stop-pop at ~10 Hz). Entity interpolation now tracks a smoothed velocity and dead-reckons past the end of each packet window, so the entity keeps gliding at the estimated speed until the next server update arrives. Movement stops only after two consecutive intervals with no new packet (entity has genuinely stopped). Also replaced the raw packet-delta duration with an exponential moving average (EMA) per player. A single slow or fast packet no longer spikes the playback speed; the EMA converges on the actual send rate (~100 ms) and absorbs jitter without adding a fixed input-latency penalty. --- include/game/entity.hpp | 39 ++++++++++++++++++++++++++++++----- include/game/game_handler.hpp | 1 + src/game/game_handler.cpp | 22 +++++++++++++------- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/include/game/entity.hpp b/include/game/entity.hpp index 0b9fb647..702bf7a0 100644 --- a/include/game/entity.hpp +++ b/include/game/entity.hpp @@ -83,6 +83,21 @@ public: setPosition(destX, destY, destZ, destO); return; } + // Derive velocity from the displacement this packet implies. + // Use the previous destination (not current lerped pos) as the "from" so + // variable network timing doesn't inflate/shrink the implied speed. + float fromX = isMoving_ ? moveEndX_ : x; + float fromY = isMoving_ ? moveEndY_ : y; + float fromZ = isMoving_ ? moveEndZ_ : z; + float impliedVX = (destX - fromX) / durationSec; + float impliedVY = (destY - fromY) / durationSec; + float impliedVZ = (destZ - fromZ) / durationSec; + // Exponentially smooth velocity so jittery packet timing doesn't snap speed. + const float alpha = 0.65f; + velX_ = alpha * impliedVX + (1.0f - alpha) * velX_; + velY_ = alpha * impliedVY + (1.0f - alpha) * velY_; + velZ_ = alpha * impliedVZ + (1.0f - alpha) * velZ_; + moveStartX_ = x; moveStartY_ = y; moveStartZ_ = z; moveEndX_ = destX; moveEndY_ = destY; moveEndZ_ = destZ; moveDuration_ = durationSec; @@ -94,14 +109,27 @@ public: void updateMovement(float deltaTime) { if (!isMoving_) return; moveElapsed_ += deltaTime; - float t = moveElapsed_ / moveDuration_; - if (t >= 1.0f) { - x = moveEndX_; y = moveEndY_; z = moveEndZ_; - isMoving_ = false; - } else { + if (moveElapsed_ < moveDuration_) { + // Linear interpolation within the packet window + float t = moveElapsed_ / moveDuration_; x = moveStartX_ + (moveEndX_ - moveStartX_) * t; y = moveStartY_ + (moveEndY_ - moveStartY_) * t; z = moveStartZ_ + (moveEndZ_ - moveStartZ_) * t; + } else { + // Past the interpolation window: dead-reckon at the smoothed velocity + // rather than freezing in place. Cap to one extra interval so we don't + // drift endlessly if the entity stops sending packets. + float overrun = moveElapsed_ - moveDuration_; + if (overrun < moveDuration_) { + x = moveEndX_ + velX_ * overrun; + y = moveEndY_ + velY_ * overrun; + z = moveEndZ_ + velZ_ * overrun; + } else { + // Two intervals with no update — entity has probably stopped. + x = moveEndX_; y = moveEndY_; z = moveEndZ_; + velX_ = 0.0f; velY_ = 0.0f; velZ_ = 0.0f; + isMoving_ = false; + } } } @@ -155,6 +183,7 @@ protected: float moveEndX_ = 0, moveEndY_ = 0, moveEndZ_ = 0; float moveDuration_ = 0; float moveElapsed_ = 0; + float velX_ = 0, velY_ = 0, velZ_ = 0; // Smoothed velocity for dead reckoning }; /** diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 61e7661d..f1b92b5f 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1330,6 +1330,7 @@ private: std::unordered_map> otherPlayerVisibleItemEntries_; std::unordered_set otherPlayerVisibleDirty_; std::unordered_map otherPlayerMoveTimeMs_; + std::unordered_map otherPlayerSmoothedIntervalMs_; // EMA of packet intervals // Inspect fallback (when visible item fields are missing/unreliable) std::unordered_map> inspectedPlayerItemEntries_; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9ce45d01..cb81e4cc 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -7641,15 +7641,23 @@ void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { // Convert server coords to canonical glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(info.x, info.y, info.z)); float canYaw = core::coords::serverToCanonicalYaw(info.orientation); - // Smooth movement between client-relayed snapshots so animations can play. - uint32_t durationMs = 100; + // Compute a smoothed interpolation window for this player. + // Using a raw packet delta causes jitter when timing spikes (e.g. 50ms then 300ms). + // An exponential moving average of intervals gives a stable playback speed that + // dead-reckoning in Entity::updateMovement() can bridge without a visible freeze. + uint32_t durationMs = 120; auto itPrev = otherPlayerMoveTimeMs_.find(moverGuid); if (itPrev != otherPlayerMoveTimeMs_.end()) { - uint32_t dt = info.time - itPrev->second; // handles wrap - if (dt >= 30 && dt <= 1000) { - if (dt < 50) dt = 50; - if (dt > 350) dt = 350; - durationMs = dt; + uint32_t rawDt = info.time - itPrev->second; // wraps naturally on uint32_t + if (rawDt >= 20 && rawDt <= 2000) { + float fDt = static_cast(rawDt); + // EMA: smooth the interval so single spike packets don't stutter playback. + auto& smoothed = otherPlayerSmoothedIntervalMs_[moverGuid]; + if (smoothed < 1.0f) smoothed = fDt; // first observation — seed directly + smoothed = 0.7f * smoothed + 0.3f * fDt; + // Clamp to sane WoW movement rates: ~10 Hz (100ms) normal, up to 2Hz (500ms) slow + float clamped = std::max(60.0f, std::min(500.0f, smoothed)); + durationMs = static_cast(clamped); } } otherPlayerMoveTimeMs_[moverGuid] = info.time;