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.
This commit is contained in:
Kelsi 2026-02-19 16:45:39 -08:00
parent 2bbd0fdc5f
commit d7692ab88e
3 changed files with 50 additions and 12 deletions

View file

@ -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
};
/**

View file

@ -1330,6 +1330,7 @@ private:
std::unordered_map<uint64_t, std::array<uint32_t, 19>> otherPlayerVisibleItemEntries_;
std::unordered_set<uint64_t> otherPlayerVisibleDirty_;
std::unordered_map<uint64_t, uint32_t> otherPlayerMoveTimeMs_;
std::unordered_map<uint64_t, float> otherPlayerSmoothedIntervalMs_; // EMA of packet intervals
// Inspect fallback (when visible item fields are missing/unreliable)
std::unordered_map<uint64_t, std::array<uint32_t, 19>> inspectedPlayerItemEntries_;