From 79c4298db91893d883bb6c152a7b5ec0f5113d33 Mon Sep 17 00:00:00 2001 From: mischa Date: Thu, 11 Jan 2024 12:29:22 +0100 Subject: [PATCH] Prediction: InsertCorrection moved into CorrectHistory, with complete step by step test coverage for the algorithm --- .../PredictedRigidbody/PredictedRigidbody.cs | 12 +- .../PredictedRigidbody/RigidbodyState.cs | 8 -- Assets/Mirror/Core/Prediction/Prediction.cs | 46 +++----- .../Editor/Prediction/PredictionTests.cs | 107 ++++++++++++++++++ 4 files changed, 125 insertions(+), 48 deletions(-) diff --git a/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbody.cs b/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbody.cs index 3d6490837..7a67ad5ee 100644 --- a/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbody.cs +++ b/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbody.cs @@ -406,18 +406,14 @@ void OnReceivedState(double timestamp, RigidbodyState state) // helps to compare with the interpolated/applied correction locally. Debug.DrawLine(state.position, state.position + state.velocity * 0.1f, Color.white, lineTime); - // insert the corrected state and adjust the next state's delta. - // this is because deltas are always relative to the previous - // state. and since we inserted another, we need to readjust. - Prediction.InsertCorrection(stateHistory, stateHistoryLimit, state, before, after); - - // insert the corrected state and correct all reapply the deltas after it. - RigidbodyState recomputed = Prediction.CorrectHistory(stateHistory, state, afterIndex); - int correctedAmount = stateHistory.Count - afterIndex; + // insert the correction and correct the history on top of it. + // returns the final recomputed state after rewinding. + RigidbodyState recomputed = Prediction.CorrectHistory(stateHistory, stateHistoryLimit, state, before, after, afterIndex); // log, draw & apply the final position. // always do this here, not when iterating above, in case we aren't iterating. // for example, on same machine with near zero latency. + // int correctedAmount = stateHistory.Count - afterIndex; // Debug.Log($"Correcting {name}: {correctedAmount} / {stateHistory.Count} states to final position from: {rb.position} to: {last.position}"); Debug.DrawLine(rb.position, recomputed.position, Color.green, lineTime); ApplyState(recomputed.position, recomputed.rotation, recomputed.velocity); diff --git a/Assets/Mirror/Components/PredictedRigidbody/RigidbodyState.cs b/Assets/Mirror/Components/PredictedRigidbody/RigidbodyState.cs index 0a544f770..7f5c59ee9 100644 --- a/Assets/Mirror/Components/PredictedRigidbody/RigidbodyState.cs +++ b/Assets/Mirror/Components/PredictedRigidbody/RigidbodyState.cs @@ -31,14 +31,6 @@ public RigidbodyState( this.velocity = velocity; } - // adjust the deltas after inserting a correction between this one and the previous one. - public void AdjustDeltas(float multiplier) - { - positionDelta = Vector3.Lerp(Vector3.zero, positionDelta, multiplier); - // TODO if we have have a rotation delta, then scale it here too - velocityDelta = Vector3.Lerp(Vector3.zero, velocityDelta, multiplier); - } - public static RigidbodyState Interpolate(RigidbodyState a, RigidbodyState b, float t) { return new RigidbodyState diff --git a/Assets/Mirror/Core/Prediction/Prediction.cs b/Assets/Mirror/Core/Prediction/Prediction.cs index a90eef22f..711b9793a 100644 --- a/Assets/Mirror/Core/Prediction/Prediction.cs +++ b/Assets/Mirror/Core/Prediction/Prediction.cs @@ -16,14 +16,6 @@ public interface PredictedState Vector3 velocity { get; set; } Vector3 velocityDelta { get; set; } - - // predicted states should have absolute and delta values, for example: - // Vector3 position; - // Vector3 positionDelta; // from last to here - // when inserting a correction between this one and the one before, - // we need to adjust the delta: - // positionDelta *= multiplier; - void AdjustDeltas(float multiplier); } public static class Prediction @@ -90,17 +82,16 @@ public static bool Sample( return false; } - // when receiving a correction from the server, we want to insert it - // into the client's state history. - // -> if there's already a state at timestamp, replace - // -> otherwise insert and adjust the next state's delta - // TODO test coverage - public static void InsertCorrection( + // inserts a server state into the client's history. + // readjust the deltas of the states after the inserted one. + // returns the corrected final position. + public static T CorrectHistory( SortedList stateHistory, int stateHistoryLimit, - T corrected, // corrected state with timestamp - T before, // state in history before the correction - T after) // state in history after the correction + T corrected, // corrected state with timestamp + T before, // state in history before the correction + T after, // state in history after the correction + int afterIndex) // index of the 'after' value so we don't need to find it again here where T: PredictedState { // respect the limit @@ -142,24 +133,15 @@ public static void InsertCorrection( double multiplier = previousDeltaTime != 0 ? correctedDeltaTime / previousDeltaTime : 0; // 0.5 / 2.0 = 0.25 // recalculate 'after.delta' with the multiplier - after.AdjustDeltas((float)multiplier); + after.positionDelta = Vector3.Lerp(Vector3.zero, after.positionDelta, (float)multiplier); + after.velocityDelta = Vector3.Lerp(Vector3.zero, after.velocityDelta, (float)multiplier); // TODO rotation too? - // write the adjusted 'after' value into the history buffer + // changes aren't saved until we overwrite them in the history stateHistory[after.timestamp] = after; - } - // client may need to correct parts of the history after receiving server state. - // CorrectHistory inserts the entries from [i..n] based on 'corrected'. - // in other words, readjusts the deltas that the client moved since then. - public static T CorrectHistory( - SortedList stateHistory, - T corrected, // corrected state with timestamp - int startIndex) // first state after the inserted correction - where T: PredictedState - { - // start iterating right after the inserted state at startIndex + // second step: readjust all absolute values by rewinding client's delta moves on top of it. T last = corrected; - for (int i = startIndex; i < stateHistory.Count; ++i) + for (int i = afterIndex; i < stateHistory.Count; ++i) { double key = stateHistory.Keys[i]; T entry = stateHistory.Values[i]; @@ -175,7 +157,7 @@ public static T CorrectHistory( last = entry; } - // return the recomputed state after all deltas were applied to the correction + // third step: return the final recomputed state. return last; } } diff --git a/Assets/Mirror/Tests/Editor/Prediction/PredictionTests.cs b/Assets/Mirror/Tests/Editor/Prediction/PredictionTests.cs index 0a2a4a168..c00f46d14 100644 --- a/Assets/Mirror/Tests/Editor/Prediction/PredictionTests.cs +++ b/Assets/Mirror/Tests/Editor/Prediction/PredictionTests.cs @@ -1,10 +1,29 @@ using System.Collections.Generic; using NUnit.Framework; +using UnityEngine; namespace Mirror.Tests { public class PredictionTests { + struct TestState : PredictedState + { + public double timestamp { get; set; } + public Vector3 position { get; set; } + public Vector3 positionDelta { get; set; } + public Vector3 velocity { get; set; } + public Vector3 velocityDelta { get; set; } + + public TestState(double timestamp, Vector3 position, Vector3 positionDelta, Vector3 velocity, Vector3 velocityDelta) + { + this.timestamp = timestamp; + this.position = position; + this.positionDelta = positionDelta; + this.velocity = velocity; + this.velocityDelta = velocityDelta; + } + } + [Test] public void Sample_Empty() { @@ -89,5 +108,93 @@ public void Sample_MultipleEntries() Assert.That(afterIndex, Is.EqualTo(2)); Assert.That(t, Is.EqualTo(0.0)); } + + //////////////////////////////////////////////////////////////////////// + [Test] + public void CorrectHistory() + { + // prepare a straight forward history + SortedList history = new SortedList(); + + // (0,0,0) with delta (0,0,0) from previous: + history.Add(0, new TestState(0, new Vector3(0, 0, 0), new Vector3(0, 0, 0), new Vector3(0, 0, 0), new Vector3(0, 0, 0))); + + // (1,0,0) with delta (1,0,0) from previous: + history.Add(1, new TestState(1, new Vector3(1, 0, 0), new Vector3(1, 0, 0), new Vector3(1, 0, 0), new Vector3(1, 0, 0))); + + // (2,0,0) with delta (1,0,0) from previous: + history.Add(2, new TestState(2, new Vector3(2, 0, 0), new Vector3(1, 0, 0), new Vector3(2, 0, 0), new Vector3(1, 0, 0))); + + // (3,0,0) with delta (1,0,0) from previous: + history.Add(3, new TestState(3, new Vector3(3, 0, 0), new Vector3(1, 0, 0), new Vector3(3, 0, 0), new Vector3(1, 0, 0))); + + // client receives a correction from server between t=1 and t=2. + // exactly t=1.5 where position should be 1.5, server says it's +0.1 = 1.6 + // deltas are zero because that's how PredictedBody.Serialize sends them, alwasy at zero. + TestState correction = new TestState(1.5, new Vector3(1.6f, 0, 0), Vector3.zero, new Vector3(1.6f, 0, 0), Vector3.zero); + + // Sample() will find that the value before correction is at t=1 and after at t=2. + Assert.That(Prediction.Sample(history, correction.timestamp, out TestState before, out TestState after, out int afterIndex, out double t), Is.True); + Assert.That(before.timestamp, Is.EqualTo(1)); + Assert.That(after.timestamp, Is.EqualTo(2)); + Assert.That(afterIndex, Is.EqualTo(2)); + Assert.That(t, Is.EqualTo(0.5)); + + // ... this is where we would interpolate (before, after, 0.5) and + // compare to decide if we need to correct. + // assume we decided that a correction is necessary ... + + // correct history with the received server state + const int historyLimit = 32; + Prediction.CorrectHistory(history, historyLimit, correction, before, after, afterIndex); + + // there should be 4 initial + 1 corrected = 5 entries now + Assert.That(history.Count, Is.EqualTo(5)); + + // first entry at t=0 should be unchanged, since we corrected after that one. + Assert.That(history.Keys[0], Is.EqualTo(0)); + Assert.That(history.Values[0].position.x, Is.EqualTo(0)); + Assert.That(history.Values[0].positionDelta.x, Is.EqualTo(0)); + Assert.That(history.Values[0].velocity.x, Is.EqualTo(0)); + Assert.That(history.Values[0].velocityDelta.x, Is.EqualTo(0)); + + // second entry at t=1 should be unchanged, since we corrected after that one. + Assert.That(history.Keys[1], Is.EqualTo(1)); + Assert.That(history.Values[1].position.x, Is.EqualTo(1)); + Assert.That(history.Values[1].positionDelta.x, Is.EqualTo(1)); + Assert.That(history.Values[1].velocity.x, Is.EqualTo(1)); + Assert.That(history.Values[1].velocityDelta.x, Is.EqualTo(1)); + + // third entry at t=1.5 should be the received state. + // absolute values should be the correction, without any deltas since + // server doesn't send those and we don't need them. + Assert.That(history.Keys[2], Is.EqualTo(1.5)); + Assert.That(history.Values[2].position.x, Is.EqualTo(1.6f).Within(0.001f)); + Assert.That(history.Values[2].positionDelta.x, Is.EqualTo(0)); + Assert.That(history.Values[2].velocity.x, Is.EqualTo(1.6f).Within(0.001f)); + Assert.That(history.Values[2].velocityDelta.x, Is.EqualTo(0)); + + // fourth entry at t=2: + // delta was from t=1.0 @ 1 to t=2.0 @ 2 = 1.0 + // we inserted at t=1.5 which is half way between t=1 and t=2. + // the delta at t=1.5 would've been 0.5. + // => the inserted position is at t=1.6 + // => add the relative delta of 0.5 = 2.1 + Assert.That(history.Keys[3], Is.EqualTo(2.0)); + Assert.That(history.Values[3].position.x, Is.EqualTo(2.1).Within(0.001f)); + Assert.That(history.Values[3].positionDelta.x, Is.EqualTo(0.5).Within(0.001f)); + Assert.That(history.Values[3].velocity.x, Is.EqualTo(2.1).Within(0.001f)); + Assert.That(history.Values[3].velocityDelta.x, Is.EqualTo(0.5).Within(0.001f)); + + // fifth entry at t=3: + // client moved by a delta of 1 here, and that remains unchanged. + // absolute position was 3.0 but if we apply the delta of 1 to the one before at 2.1, + // we get the new position of 3.1 + Assert.That(history.Keys[4], Is.EqualTo(3.0)); + Assert.That(history.Values[4].position.x, Is.EqualTo(3.1).Within(0.001f)); + Assert.That(history.Values[4].positionDelta.x, Is.EqualTo(1.0).Within(0.001f)); + Assert.That(history.Values[4].velocity.x, Is.EqualTo(3.1).Within(0.001f)); + Assert.That(history.Values[4].velocityDelta.x, Is.EqualTo(1.0).Within(0.001f)); + } } }