Prediction: InsertCorrection moved into CorrectHistory, with complete step by step test coverage for the algorithm

This commit is contained in:
mischa 2024-01-11 12:29:22 +01:00
parent 383aff2698
commit 79c4298db9
4 changed files with 125 additions and 48 deletions

View File

@ -406,18 +406,14 @@ void OnReceivedState(double timestamp, RigidbodyState state)
// helps to compare with the interpolated/applied correction locally. // helps to compare with the interpolated/applied correction locally.
Debug.DrawLine(state.position, state.position + state.velocity * 0.1f, Color.white, lineTime); Debug.DrawLine(state.position, state.position + state.velocity * 0.1f, Color.white, lineTime);
// insert the corrected state and adjust the next state's delta. // insert the correction and correct the history on top of it.
// this is because deltas are always relative to the previous // returns the final recomputed state after rewinding.
// state. and since we inserted another, we need to readjust. RigidbodyState recomputed = Prediction.CorrectHistory(stateHistory, stateHistoryLimit, state, before, after, afterIndex);
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;
// log, draw & apply the final position. // log, draw & apply the final position.
// always do this here, not when iterating above, in case we aren't iterating. // always do this here, not when iterating above, in case we aren't iterating.
// for example, on same machine with near zero latency. // 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.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); Debug.DrawLine(rb.position, recomputed.position, Color.green, lineTime);
ApplyState(recomputed.position, recomputed.rotation, recomputed.velocity); ApplyState(recomputed.position, recomputed.rotation, recomputed.velocity);

View File

@ -31,14 +31,6 @@ public RigidbodyState(
this.velocity = velocity; 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) public static RigidbodyState Interpolate(RigidbodyState a, RigidbodyState b, float t)
{ {
return new RigidbodyState return new RigidbodyState

View File

@ -16,14 +16,6 @@ public interface PredictedState
Vector3 velocity { get; set; } Vector3 velocity { get; set; }
Vector3 velocityDelta { 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 public static class Prediction
@ -90,17 +82,16 @@ public static bool Sample<T>(
return false; return false;
} }
// when receiving a correction from the server, we want to insert it // inserts a server state into the client's history.
// into the client's state history. // readjust the deltas of the states after the inserted one.
// -> if there's already a state at timestamp, replace // returns the corrected final position.
// -> otherwise insert and adjust the next state's delta public static T CorrectHistory<T>(
// TODO test coverage
public static void InsertCorrection<T>(
SortedList<double, T> stateHistory, SortedList<double, T> stateHistory,
int stateHistoryLimit, int stateHistoryLimit,
T corrected, // corrected state with timestamp T corrected, // corrected state with timestamp
T before, // state in history before the correction T before, // state in history before the correction
T after) // state in history after 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 where T: PredictedState
{ {
// respect the limit // respect the limit
@ -142,24 +133,15 @@ public static void InsertCorrection<T>(
double multiplier = previousDeltaTime != 0 ? correctedDeltaTime / previousDeltaTime : 0; // 0.5 / 2.0 = 0.25 double multiplier = previousDeltaTime != 0 ? correctedDeltaTime / previousDeltaTime : 0; // 0.5 / 2.0 = 0.25
// recalculate 'after.delta' with the multiplier // 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; stateHistory[after.timestamp] = after;
}
// client may need to correct parts of the history after receiving server state. // second step: readjust all absolute values by rewinding client's delta moves on top of it.
// 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<T>(
SortedList<double, T> 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
T last = corrected; T last = corrected;
for (int i = startIndex; i < stateHistory.Count; ++i) for (int i = afterIndex; i < stateHistory.Count; ++i)
{ {
double key = stateHistory.Keys[i]; double key = stateHistory.Keys[i];
T entry = stateHistory.Values[i]; T entry = stateHistory.Values[i];
@ -175,7 +157,7 @@ public static T CorrectHistory<T>(
last = entry; last = entry;
} }
// return the recomputed state after all deltas were applied to the correction // third step: return the final recomputed state.
return last; return last;
} }
} }

View File

@ -1,10 +1,29 @@
using System.Collections.Generic; using System.Collections.Generic;
using NUnit.Framework; using NUnit.Framework;
using UnityEngine;
namespace Mirror.Tests namespace Mirror.Tests
{ {
public class PredictionTests 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] [Test]
public void Sample_Empty() public void Sample_Empty()
{ {
@ -89,5 +108,93 @@ public void Sample_MultipleEntries()
Assert.That(afterIndex, Is.EqualTo(2)); Assert.That(afterIndex, Is.EqualTo(2));
Assert.That(t, Is.EqualTo(0.0)); Assert.That(t, Is.EqualTo(0.0));
} }
////////////////////////////////////////////////////////////////////////
[Test]
public void CorrectHistory()
{
// prepare a straight forward history
SortedList<double, TestState> history = new SortedList<double, TestState>();
// (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));
}
} }
} }