This commit is contained in:
mischa 2023-07-26 11:24:59 +08:00
parent 0dd504de11
commit f4650eff64
2 changed files with 104 additions and 56 deletions

View File

@ -10,29 +10,45 @@ namespace Mirror
{ {
public class HistoryBounds public class HistoryBounds
{ {
// history of bounds // FakeByte: gather bounds in smaller buckets.
readonly Queue<Bounds> history; // for example, bucket(t0,t1,t2), bucket(t3,t4,t5), ...
public int Count => history.Count; // instead of removing old bounds t0, t1, ...
// we remove a whole bucket every 3 times: bucket(t0,t1,t2)
// and when building total bounds, we encapsulate a few larger buckets
// instead of many smaller bounds.
//
// => a bucket is encapsulate(bounds0, bounds1, bounds2) so we don't
// need a custom struct, simply reuse bounds but remember that each
// entry includes N timestamps.
//
// => note that simply reducing capture interval is _not_ the same.
// we want to capture in detail in case players run in zig-zag.
// but still grow larger buckets internally.
readonly int boundsPerBucket;
readonly Queue<Bounds> fullBuckets;
Bounds? currentBucket;
int currentBucketSize; // 0..boundsPerBucket
// history limit. oldest bounds will be removed. // history limit. oldest bounds will be removed.
public readonly int limit; public readonly int boundsLimit;
readonly int bucketLimit;
// only remove old entries every n-th insertion. // amount of total bounds, including bounds in full buckets + current
// new entries are still encapsulated on every insertion. public int boundsCount { get; private set; }
// for example, every 2nd insertion is enough, and 2x as fast.
public readonly int recalculateEveryNth;
int recalculateCounter = 0;
// total bounds encapsulating all of the bounds history // total bounds encapsulating all of the bounds history
public Bounds total; public Bounds total;
public HistoryBounds(int limit, int recalculateEveryNth) public HistoryBounds(int boundsLimit, int boundsPerBucket)
{ {
// initialize queue with maximum capacity to avoid runtime resizing // initialize queue with maximum capacity to avoid runtime resizing
// +1 because it makes the code easier if we insert first, and then remove. this.boundsPerBucket = boundsPerBucket;
this.limit = limit; this.boundsLimit = boundsLimit;
this.recalculateEveryNth = recalculateEveryNth; this.bucketLimit = (boundsLimit / boundsPerBucket);
history = new Queue<Bounds>(limit + 1);
// capacity +1 because it makes the code easier if we insert first, and then remove.
fullBuckets = new Queue<Bounds>(bucketLimit + 1);
} }
// insert new bounds into history. calculates new total bounds. // insert new bounds into history. calculates new total bounds.
@ -41,38 +57,62 @@ public void Insert(Bounds bounds)
{ {
// initialize 'total' if not initialized yet. // initialize 'total' if not initialized yet.
// we don't want to call (0,0).Encapsulate(bounds). // we don't want to call (0,0).Encapsulate(bounds).
if (history.Count == 0) if (boundsCount == 0)
{
total = bounds; total = bounds;
}
// insert and encapsulate the new bounds // add to current bucket:
history.Enqueue(bounds); // either initialize new one, or encapsulate into existing one
if (currentBucket == null)
{
currentBucket = bounds;
}
else
{
currentBucket.Value.Encapsulate(bounds);
}
// current bucket has one more bounds.
// total bounds increased as well.
currentBucketSize += 1;
boundsCount += 1;
// always encapsulate into total immediately.
// this is free.
total.Encapsulate(bounds); total.Encapsulate(bounds);
// ensure history stays within limit // current bucket full?
if (history.Count > limit) if (currentBucketSize == boundsPerBucket)
{ {
// remove oldest // move it to full buckets
history.Dequeue(); fullBuckets.Enqueue(currentBucket.Value);
currentBucket = null;
currentBucketSize = 0;
// optimization: only recalculate every n-th removal. // full bucket capacity reached?
// accurate enough, and N times faster. if (fullBuckets.Count > bucketLimit)
if (++recalculateCounter < recalculateEveryNth) {
return; // remove oldest bucket
fullBuckets.Dequeue();
boundsCount -= boundsPerBucket;
// reset counter // recompute total bounds
recalculateCounter = 0; // instead of iterating N buckets, we iterate N / boundsPerBucket buckets.
// TODO technically we could reuse 'currentBucket' before clearing instead of encapsulating again
// recalculate total bounds
// (only needed after removing the oldest)
total = bounds; total = bounds;
foreach (Bounds b in history) foreach (Bounds bucket in fullBuckets)
total.Encapsulate(b); total.Encapsulate(bucket);
}
} }
} }
public void Reset() public void Reset()
{ {
history.Clear(); fullBuckets.Clear();
currentBucket = null;
currentBucketSize = 0;
boundsCount = 0;
total = new Bounds(); total = new Bounds();
} }
} }

View File

@ -1,5 +1,7 @@
using System;
using NUnit.Framework; using NUnit.Framework;
using UnityEngine; using UnityEngine;
using Random = UnityEngine.Random;
namespace Mirror.Tests.LagCompensationTests namespace Mirror.Tests.LagCompensationTests
{ {
@ -25,16 +27,18 @@ public static Bounds MinMax(float min, float max) =>
// Unity 2021.3 LTS, release mode: 100k; limit=8 // Unity 2021.3 LTS, release mode: 100k; limit=8
// O(N) Queue<Bounds> implementation: 183 ms // O(N) Queue<Bounds> implementation: 183 ms
// O(N) Queue and recalculate every 2nd: 108 ms // O(N) Queue and recalculate every 2nd: 108 ms
// Buckets of 2: 79 ms
// Buckets of 4: 54 ms
[Test] [Test]
[TestCase(100_000, 8, 1)] [TestCase(100_000, 8, 1)]
[TestCase(100_000, 8, 2)] [TestCase(100_000, 8, 2)]
[TestCase(100_000, 8, 4)] [TestCase(100_000, 8, 4)]
public void Benchmark(int iterations, int limit, int recalculate) public void Benchmark(int iterations, int limit, int boundsPerBucket)
{ {
// always use the same seed so we get the same test. // always use the same seed so we get the same test.
Random.InitState(0); Random.InitState(0);
HistoryBounds history = new HistoryBounds(limit, recalculate); HistoryBounds history = new HistoryBounds(limit, boundsPerBucket);
for (int i = 0; i < iterations; ++i) for (int i = 0; i < iterations; ++i)
{ {
float min = Random.Range(-1, 1); float min = Random.Range(-1, 1);
@ -53,32 +57,32 @@ public void Benchmark(int iterations, int limit, int recalculate)
public void Insert_Basic() public void Insert_Basic()
{ {
const int limit = 3; const int limit = 3;
HistoryBounds history = new HistoryBounds(limit, recalculateEveryNth: 1); HistoryBounds history = new HistoryBounds(limit, boundsPerBucket: 1);
// insert initial [-1, 1]. // insert initial [-1, 1].
// should calculate new bounds == initial. // should calculate new bounds == initial.
history.Insert(MinMax(-1, 1)); history.Insert(MinMax(-1, 1));
Assert.That(history.Count, Is.EqualTo(1)); Assert.That(history.boundsCount, Is.EqualTo(1));
Assert.That(history.total, Is.EqualTo(MinMax(-1, 1))); Assert.That(history.total, Is.EqualTo(MinMax(-1, 1)));
// insert [0, 2] // insert [0, 2]
// should calculate new bounds == [-1, 2]. // should calculate new bounds == [-1, 2].
history.Insert(MinMax(0, 2)); history.Insert(MinMax(0, 2));
Assert.That(history.Count, Is.EqualTo(2)); Assert.That(history.boundsCount, Is.EqualTo(2));
Assert.That(history.total, Is.EqualTo(MinMax(-1, 2))); Assert.That(history.total, Is.EqualTo(MinMax(-1, 2)));
// insert one that's smaller than current bounds [-.5, 0] // insert one that's smaller than current bounds [-.5, 0]
// history needs to contain it even if smaller, because once the oldest // history needs to contain it even if smaller, because once the oldest
// largest one gets removed, this one matters too. // largest one gets removed, this one matters too.
history.Insert(MinMax(-0.5f, 0)); history.Insert(MinMax(-0.5f, 0));
Assert.That(history.Count, Is.EqualTo(3)); Assert.That(history.boundsCount, Is.EqualTo(3));
Assert.That(history.total, Is.EqualTo(MinMax(-1, 2))); Assert.That(history.total, Is.EqualTo(MinMax(-1, 2)));
// insert more than 'limit': [0, 0] // insert more than 'limit': [0, 0]
// the oldest one [-1, 1] should be discarded. // the oldest one [-1, 1] should be discarded.
// new bounds should be [-0.5, 2] // new bounds should be [-0.5, 2]
history.Insert(MinMax(0, 0)); history.Insert(MinMax(0, 0));
Assert.That(history.Count, Is.EqualTo(3)); Assert.That(history.boundsCount, Is.EqualTo(3));
Assert.That(history.total, Is.EqualTo(MinMax(-0.5f, 2))); Assert.That(history.total, Is.EqualTo(MinMax(-0.5f, 2)));
} }
@ -88,30 +92,30 @@ public void Insert_Basic()
public void Insert_Revisit() public void Insert_Revisit()
{ {
const int limit = 3; const int limit = 3;
HistoryBounds history = new HistoryBounds(limit, recalculateEveryNth: 1); HistoryBounds history = new HistoryBounds(limit, boundsPerBucket: 1);
// insert initial [-1, 1]. // insert initial [-1, 1].
// should calculate new bounds == initial. // should calculate new bounds == initial.
history.Insert(MinMax(-1, 1)); history.Insert(MinMax(-1, 1));
Assert.That(history.Count, Is.EqualTo(1)); Assert.That(history.boundsCount, Is.EqualTo(1));
Assert.That(history.total, Is.EqualTo(MinMax(-1, 1))); Assert.That(history.total, Is.EqualTo(MinMax(-1, 1)));
// insert [0, 2] // insert [0, 2]
// should calculate new bounds == [-1, 2]. // should calculate new bounds == [-1, 2].
history.Insert(MinMax(0, 2)); history.Insert(MinMax(0, 2));
Assert.That(history.Count, Is.EqualTo(2)); Assert.That(history.boundsCount, Is.EqualTo(2));
Assert.That(history.total, Is.EqualTo(MinMax(-1, 2))); Assert.That(history.total, Is.EqualTo(MinMax(-1, 2)));
// visit [-1, 1] again // visit [-1, 1] again
history.Insert(MinMax(-1, 1)); history.Insert(MinMax(-1, 1));
Assert.That(history.Count, Is.EqualTo(3)); Assert.That(history.boundsCount, Is.EqualTo(3));
Assert.That(history.total, Is.EqualTo(MinMax(-1, 2))); Assert.That(history.total, Is.EqualTo(MinMax(-1, 2)));
// insert beyond limit. // insert beyond limit.
// oldest one [-1, 1] should be removed. // oldest one [-1, 1] should be removed.
// total should still include it because we revisited [1, 1]. // total should still include it because we revisited [1, 1].
history.Insert(MinMax(0, 0)); history.Insert(MinMax(0, 0));
Assert.That(history.Count, Is.EqualTo(3)); Assert.That(history.boundsCount, Is.EqualTo(3));
Assert.That(history.total, Is.EqualTo(MinMax(-1, 2))); Assert.That(history.total, Is.EqualTo(MinMax(-1, 2)));
} }
@ -121,40 +125,43 @@ public void Insert_Revisit()
public void Insert_Far() public void Insert_Far()
{ {
const int limit = 3; const int limit = 3;
HistoryBounds history = new HistoryBounds(limit, recalculateEveryNth: 1); HistoryBounds history = new HistoryBounds(limit, boundsPerBucket: 1);
// insert initial [2, 3]. // insert initial [2, 3].
// should calculate new bounds == initial. // should calculate new bounds == initial.
history.Insert(MinMax(2, 3)); history.Insert(MinMax(2, 3));
Assert.That(history.Count, Is.EqualTo(1)); Assert.That(history.boundsCount, Is.EqualTo(1));
Assert.That(history.total, Is.EqualTo(MinMax(2, 3))); Assert.That(history.total, Is.EqualTo(MinMax(2, 3)));
// insert [3, 4] // insert [3, 4]
// should calculate new bounds == [2, 4]. // should calculate new bounds == [2, 4].
history.Insert(MinMax(3, 4)); history.Insert(MinMax(3, 4));
Assert.That(history.Count, Is.EqualTo(2)); Assert.That(history.boundsCount, Is.EqualTo(2));
Assert.That(history.total, Is.EqualTo(MinMax(2, 4))); Assert.That(history.total, Is.EqualTo(MinMax(2, 4)));
// insert one that's smaller than current bounds [0.5, 1] // insert one that's smaller than current bounds [0.5, 1]
// history needs to contain it even if smaller, because once the oldest // history needs to contain it even if smaller, because once the oldest
// largest one gets removed, this one matters too. // largest one gets removed, this one matters too.
history.Insert(MinMax(0.5f, 1)); history.Insert(MinMax(0.5f, 1));
Assert.That(history.Count, Is.EqualTo(3)); Assert.That(history.boundsCount, Is.EqualTo(3));
Assert.That(history.total, Is.EqualTo(MinMax(0.5f, 4))); Assert.That(history.total, Is.EqualTo(MinMax(0.5f, 4)));
// insert more than 'limit' // insert more than 'limit'
// the oldest one [-1, 1] should be discarded. // the oldest one [-1, 1] should be discarded.
// new bounds should be [-0.5, 2] // new bounds should be [-0.5, 2]
history.Insert(MinMax(2, 2)); history.Insert(MinMax(2, 2));
Assert.That(history.Count, Is.EqualTo(3)); Assert.That(history.boundsCount, Is.EqualTo(3));
Assert.That(history.total, Is.EqualTo(MinMax(0.5f, 4))); Assert.That(history.total, Is.EqualTo(MinMax(0.5f, 4)));
} }
// test to check if recalculate works as expected
// test to check if bounds per bucket works as expected
[Test] [Test]
public void Insert_Recalculate() public void Insert_MultipleBoundsPerBUcket()
{ {
throw new NotImplementedException();
/*
const int limit = 3; const int limit = 3;
HistoryBounds history = new HistoryBounds(limit, recalculateEveryNth: 2); HistoryBounds history = new HistoryBounds(limit, boundsPerBucket: 2);
// insert initial [-1, 1]. // insert initial [-1, 1].
// should calculate new bounds == initial. // should calculate new bounds == initial.
@ -207,20 +214,21 @@ public void Insert_Recalculate()
history.Insert(MinMax(0, 0)); history.Insert(MinMax(0, 0));
Assert.That(history.Count, Is.EqualTo(3)); Assert.That(history.Count, Is.EqualTo(3));
Assert.That(history.total, Is.EqualTo(MinMax(0, 0))); Assert.That(history.total, Is.EqualTo(MinMax(0, 0)));
*/
} }
[Test] [Test]
public void Reset() public void Reset()
{ {
const int limit = 3; const int limit = 3;
HistoryBounds history = new HistoryBounds(limit, recalculateEveryNth: 1); HistoryBounds history = new HistoryBounds(limit, boundsPerBucket: 1);
history.Insert(MinMax(1, 2)); history.Insert(MinMax(1, 2));
history.Insert(MinMax(2, 3)); history.Insert(MinMax(2, 3));
history.Insert(MinMax(3, 4)); history.Insert(MinMax(3, 4));
history.Reset(); history.Reset();
Assert.That(history.Count, Is.EqualTo(0)); Assert.That(history.boundsCount, Is.EqualTo(0));
Assert.That(history.total, Is.EqualTo(MinMax(0, 0))); Assert.That(history.total, Is.EqualTo(MinMax(0, 0)));
} }
} }