123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347 |
- // snapshot interpolation V2 by mischa
- //
- // Unity independent to be engine agnostic & easy to test.
- // boxing: in C#, uses <T> does not box! passing the interface would box!
- //
- // credits:
- // glenn fiedler: https://gafferongames.com/post/snapshot_interpolation/
- // fholm: netcode streams
- // fakebyte: standard deviation for dynamic adjustment
- // ninjakicka: math & debugging
- using System.Collections.Generic;
- using System.Runtime.CompilerServices;
- namespace Mirror
- {
- public static class SortedListExtensions
- {
- // removes the first 'amount' elements from the sorted list
- public static void RemoveRange<T, U>(this SortedList<T, U> list, int amount)
- {
- // remove the first element 'amount' times.
- // handles -1 and > count safely.
- for (int i = 0; i < amount && i < list.Count; ++i)
- list.RemoveAt(0);
- }
- }
- public static class SnapshotInterpolation
- {
- // calculate timescale for catch-up / slow-down
- // note that negative threshold should be <0.
- // caller should verify (i.e. Unity OnValidate).
- // improves branch prediction.
- public static double Timescale(
- double drift, // how far we are off from bufferTime
- double catchupSpeed, // in % [0,1]
- double slowdownSpeed, // in % [0,1]
- double catchupNegativeThreshold, // in % of sendInteral (careful, we may run out of snapshots)
- double catchupPositiveThreshold) // in % of sendInterval)
- {
- // if the drift time is too large, it means we are behind more time.
- // so we need to speed up the timescale.
- // note the threshold should be sendInterval * catchupThreshold.
- if (drift > catchupPositiveThreshold)
- {
- // localTimeline += 0.001; // too simple, this would ping pong
- return 1 + catchupSpeed; // n% faster
- }
- // if the drift time is too small, it means we are ahead of time.
- // so we need to slow down the timescale.
- // note the threshold should be sendInterval * catchupThreshold.
- if (drift < catchupNegativeThreshold)
- {
- // localTimeline -= 0.001; // too simple, this would ping pong
- return 1 - slowdownSpeed; // n% slower
- }
- // keep constant timescale while within threshold.
- // this way we have perfectly smooth speed most of the time.
- return 1;
- }
- // calculate dynamic buffer time adjustment
- public static double DynamicAdjustment(
- double sendInterval,
- double jitterStandardDeviation,
- double dynamicAdjustmentTolerance)
- {
- // jitter is equal to delivery time standard variation.
- // delivery time is made up of 'sendInterval+jitter'.
- // .Average would be dampened by the constant sendInterval
- // .StandardDeviation is the changes in 'jitter' that we want
- // so add it to send interval again.
- double intervalWithJitter = sendInterval + jitterStandardDeviation;
- // how many multiples of sendInterval is that?
- // we want to convert to bufferTimeMultiplier later.
- double multiples = intervalWithJitter / sendInterval;
- // add the tolerance
- double safezone = multiples + dynamicAdjustmentTolerance;
- // UnityEngine.Debug.Log($"sendInterval={sendInterval:F3} jitter std={jitterStandardDeviation:F3} => that is ~{multiples:F1} x sendInterval + {dynamicAdjustmentTolerance} => dynamic bufferTimeMultiplier={safezone}");
- return safezone;
- }
- // helper function to insert a snapshot if it doesn't exist yet.
- // extra function so we can use it for both cases:
- // NetworkClient global timeline insertions & adjustments via Insert<T>.
- // NetworkBehaviour local insertion without any time adjustments.
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static bool InsertIfNotExists<T>(
- SortedList<double, T> buffer, // snapshot buffer
- T snapshot) // the newly received snapshot
- where T : Snapshot
- {
- // SortedList does not allow duplicates.
- // we don't need to check ContainsKey (which is expensive).
- // simply add and compare count before/after for the return value.
- //if (buffer.ContainsKey(snapshot.remoteTime)) return false; // too expensive
- // buffer.Add(snapshot.remoteTime, snapshot); // throws if key exists
- int before = buffer.Count;
- buffer[snapshot.remoteTime] = snapshot; // overwrites if key exists
- return buffer.Count > before;
- }
- // call this for every received snapshot.
- // adds / inserts it to the list & initializes local time if needed.
- public static void InsertAndAdjust<T>(
- SortedList<double, T> buffer, // snapshot buffer
- T snapshot, // the newly received snapshot
- ref double localTimeline, // local interpolation time based on server time
- ref double localTimescale, // timeline multiplier to apply catchup / slowdown over time
- float sendInterval, // for debugging
- double bufferTime, // offset for buffering
- double catchupSpeed, // in % [0,1]
- double slowdownSpeed, // in % [0,1]
- ref ExponentialMovingAverage driftEma, // for catchup / slowdown
- float catchupNegativeThreshold, // in % of sendInteral (careful, we may run out of snapshots)
- float catchupPositiveThreshold, // in % of sendInterval
- ref ExponentialMovingAverage deliveryTimeEma) // for dynamic buffer time adjustment
- where T : Snapshot
- {
- // first snapshot?
- // initialize local timeline.
- // we want it to be behind by 'offset'.
- //
- // note that the first snapshot may be a lagging packet.
- // so we would always be behind by that lag.
- // this requires catchup later.
- if (buffer.Count == 0)
- localTimeline = snapshot.remoteTime - bufferTime;
- // insert into the buffer.
- //
- // note that we might insert it between our current interpolation
- // which is fine, it adds another data point for accuracy.
- //
- // note that insert may be called twice for the same key.
- // by default, this would throw.
- // need to handle it silently.
- if (InsertIfNotExists(buffer, snapshot))
- {
- // dynamic buffer adjustment needs delivery interval jitter
- if (buffer.Count >= 2)
- {
- // note that this is not entirely accurate for scrambled inserts.
- //
- // we always use the last two, not what we just inserted
- // even if we were to use the diff for what we just inserted,
- // a scrambled insert would still not be 100% accurate:
- // => assume a buffer of AC, with delivery time C-A
- // => we then insert B, with delivery time B-A
- // => but then technically the first C-A wasn't correct,
- // as it would have to be C-B
- //
- // in practice, scramble is rare and won't make much difference
- double previousLocalTime = buffer.Values[buffer.Count - 2].localTime;
- double lastestLocalTime = buffer.Values[buffer.Count - 1].localTime;
- // this is the delivery time since last snapshot
- double localDeliveryTime = lastestLocalTime - previousLocalTime;
- // feed the local delivery time to the EMA.
- // this is what the original stream did too.
- // our final dynamic buffer adjustment is different though.
- // we use standard deviation instead of average.
- deliveryTimeEma.Add(localDeliveryTime);
- }
- // adjust timescale to catch up / slow down after each insertion
- // because that is when we add new values to our EMA.
- // we want localTimeline to be about 'bufferTime' behind.
- // for that, we need the delivery time EMA.
- // snapshots may arrive out of order, we can not use last-timeline.
- // we need to use the inserted snapshot's time - timeline.
- double latestRemoteTime = snapshot.remoteTime;
- double timeDiff = latestRemoteTime - localTimeline;
- // next, calculate average of a few seconds worth of timediffs.
- // this gives smoother results.
- //
- // to calculate the average, we could simply loop through the
- // last 'n' seconds worth of timediffs, but:
- // - our buffer may only store a few snapshots (bufferTime)
- // - looping through seconds worth of snapshots every time is
- // expensive
- //
- // to solve this, we use an exponential moving average.
- // https://en.wikipedia.org/wiki/Moving_average#Exponential_moving_average
- // which is basically fancy math to do the same but faster.
- // additionally, it allows us to look at more timeDiff values
- // than we sould have access to in our buffer :)
- driftEma.Add(timeDiff);
- // next up, calculate how far we are currently away from bufferTime
- double drift = driftEma.Value - bufferTime;
- // convert relative thresholds to absolute values based on sendInterval
- double absoluteNegativeThreshold = sendInterval * catchupNegativeThreshold;
- double absolutePositiveThreshold = sendInterval * catchupPositiveThreshold;
- // next, set localTimescale to catchup consistently in Update().
- // we quantize between default/catchup/slowdown,
- // this way we have 'default' speed most of the time(!).
- // and only catch up / slow down for a little bit occasionally.
- // a consistent multiplier would never be exactly 1.0.
- localTimescale = Timescale(drift, catchupSpeed, slowdownSpeed, absoluteNegativeThreshold, absolutePositiveThreshold);
- // debug logging
- // UnityEngine.Debug.Log($"sendInterval={sendInterval:F3} bufferTime={bufferTime:F3} drift={drift:F3} driftEma={driftEma.Value:F3} timescale={localTimescale:F3} deliveryIntervalEma={deliveryTimeEma.Value:F3}");
- }
- }
- // sample snapshot buffer to find the pair around the given time.
- // returns indices so we can use it with RemoveRange to clear old snaps.
- // make sure to use use buffer.Values[from/to], not buffer[from/to].
- // make sure to only call this is we have > 0 snapshots.
- public static void Sample<T>(
- SortedList<double, T> buffer, // snapshot buffer
- double localTimeline, // local interpolation time based on server time
- out int from, // the snapshot <= time
- out int to, // the snapshot >= time
- out double t) // interpolation factor
- where T : Snapshot
- {
- from = -1;
- to = -1;
- t = 0;
- // sample from [0,count-1] so we always have two at 'i' and 'i+1'.
- for (int i = 0; i < buffer.Count - 1; ++i)
- {
- // is local time between these two?
- T first = buffer.Values[i];
- T second = buffer.Values[i + 1];
- if (localTimeline >= first.remoteTime &&
- localTimeline <= second.remoteTime)
- {
- // use these two snapshots
- from = i;
- to = i + 1;
- t = Mathd.InverseLerp(first.remoteTime, second.remoteTime, localTimeline);
- return;
- }
- }
- // didn't find two snapshots around local time.
- // so pick either the first or last, depending on which is closer.
- // oldest snapshot ahead of local time?
- if (buffer.Values[0].remoteTime > localTimeline)
- {
- from = to = 0;
- t = 0;
- }
- // otherwise initialize both to the last one
- else
- {
- from = to = buffer.Count - 1;
- t = 0;
- }
- }
- // progress local timeline every update.
- //
- // ONLY CALL IF SNAPSHOTS.COUNT > 0!
- //
- // decoupled from Step<T> for easier testing and so we can progress
- // time only once in NetworkClient, while stepping for each component.
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static void StepTime(
- double deltaTime, // engine delta time (unscaled)
- ref double localTimeline, // local interpolation time based on server time
- double localTimescale) // catchup / slowdown is applied to time every update)
- {
- // move local forward in time, scaled with catchup / slowdown applied
- localTimeline += deltaTime * localTimescale;
- }
- // sample, clear old.
- // call this every update.
- //
- // ONLY CALL IF SNAPSHOTS.COUNT > 0!
- //
- // returns true if there is anything to apply (requires at least 1 snap)
- // from/to/t are out parameters instead of an interpolated 'computed'.
- // this allows us to store from/to/t globally (i.e. in NetworkClient)
- // and have each component apply the interpolation manually.
- // besides, passing "Func Interpolate" would allocate anyway.
- public static void StepInterpolation<T>(
- SortedList<double, T> buffer, // snapshot buffer
- double localTimeline, // local interpolation time based on server time
- out T fromSnapshot, // we interpolate 'from' this snapshot
- out T toSnapshot, // 'to' this snapshot
- out double t) // at ratio 't' [0,1]
- where T : Snapshot
- {
- // check this in caller:
- // nothing to do if there are no snapshots at all yet
- // if (buffer.Count == 0) return false;
- // sample snapshot buffer at local interpolation time
- Sample(buffer, localTimeline, out int from, out int to, out t);
- // save from/to
- fromSnapshot = buffer.Values[from];
- toSnapshot = buffer.Values[to];
- // remove older snapshots that we definitely don't need anymore.
- // after(!) using the indices.
- //
- // if we have 3 snapshots and we are between 2nd and 3rd:
- // from = 1, to = 2
- // then we need to remove the first one, which is exactly 'from'.
- // because 'from-1' = 0 would remove none.
- buffer.RemoveRange(from);
- }
- // update time, sample, clear old.
- // call this every update.
- //
- // ONLY CALL IF SNAPSHOTS.COUNT > 0!
- //
- // returns true if there is anything to apply (requires at least 1 snap)
- // from/to/t are out parameters instead of an interpolated 'computed'.
- // this allows us to store from/to/t globally (i.e. in NetworkClient)
- // and have each component apply the interpolation manually.
- // besides, passing "Func Interpolate" would allocate anyway.
- public static void Step<T>(
- SortedList<double, T> buffer, // snapshot buffer
- double deltaTime, // engine delta time (unscaled)
- ref double localTimeline, // local interpolation time based on server time
- double localTimescale, // catchup / slowdown is applied to time every update
- out T fromSnapshot, // we interpolate 'from' this snapshot
- out T toSnapshot, // 'to' this snapshot
- out double t) // at ratio 't' [0,1]
- where T : Snapshot
- {
- StepTime(deltaTime, ref localTimeline, localTimescale);
- StepInterpolation(buffer, localTimeline, out fromSnapshot, out toSnapshot, out t);
- }
- }
- }
|