Mirror Networking
SnapshotInterpolation.cs
1// snapshot interpolation V2 by mischa
2//
3// Unity independent to be engine agnostic & easy to test.
4// boxing: in C#, uses <T> does not box! passing the interface would box!
5//
6// credits:
7// glenn fiedler: https://gafferongames.com/post/snapshot_interpolation/
8// fholm: netcode streams
9// fakebyte: standard deviation for dynamic adjustment
10// ninjakicka: math & debugging
11using System;
12using System.Collections.Generic;
13
14namespace Mirror
15{
16 public static class SortedListExtensions
17 {
18 // removes the first 'amount' elements from the sorted list
19 public static void RemoveRange<T, U>(this SortedList<T, U> list, int amount)
20 {
21 // remove the first element 'amount' times.
22 // handles -1 and > count safely.
23 for (int i = 0; i < amount && i < list.Count; ++i)
24 list.RemoveAt(0);
25 }
26 }
27
28 public static class SnapshotInterpolation
29 {
30 // calculate timescale for catch-up / slow-down
31 // note that negative threshold should be <0.
32 // caller should verify (i.e. Unity OnValidate).
33 // improves branch prediction.
34 public static double Timescale(
35 double drift, // how far we are off from bufferTime
36 double catchupSpeed, // in % [0,1]
37 double slowdownSpeed, // in % [0,1]
38 double catchupNegativeThreshold, // in % of sendInteral (careful, we may run out of snapshots)
39 double catchupPositiveThreshold) // in % of sendInterval)
40 {
41 // if the drift time is too large, it means we are behind more time.
42 // so we need to speed up the timescale.
43 // note the threshold should be sendInterval * catchupThreshold.
44 if (drift > catchupPositiveThreshold)
45 {
46 // localTimeline += 0.001; // too simple, this would ping pong
47 return 1 + catchupSpeed; // n% faster
48 }
49
50 // if the drift time is too small, it means we are ahead of time.
51 // so we need to slow down the timescale.
52 // note the threshold should be sendInterval * catchupThreshold.
53 if (drift < catchupNegativeThreshold)
54 {
55 // localTimeline -= 0.001; // too simple, this would ping pong
56 return 1 - slowdownSpeed; // n% slower
57 }
58
59 // keep constant timescale while within threshold.
60 // this way we have perfectly smooth speed most of the time.
61 return 1;
62 }
63
64 // calculate dynamic buffer time adjustment
65 public static double DynamicAdjustment(
66 double sendInterval,
67 double jitterStandardDeviation,
68 double dynamicAdjustmentTolerance)
69 {
70 // jitter is equal to delivery time standard variation.
71 // delivery time is made up of 'sendInterval+jitter'.
72 // .Average would be dampened by the constant sendInterval
73 // .StandardDeviation is the changes in 'jitter' that we want
74 // so add it to send interval again.
75 double intervalWithJitter = sendInterval + jitterStandardDeviation;
76
77 // how many multiples of sendInterval is that?
78 // we want to convert to bufferTimeMultiplier later.
79 double multiples = intervalWithJitter / sendInterval;
80
81 // add the tolerance
82 double safezone = multiples + dynamicAdjustmentTolerance;
83 // UnityEngine.Debug.Log($"sendInterval={sendInterval:F3} jitter std={jitterStandardDeviation:F3} => that is ~{multiples:F1} x sendInterval + {dynamicAdjustmentTolerance} => dynamic bufferTimeMultiplier={safezone}");
84 return safezone;
85 }
86
87 // call this for every received snapshot.
88 // adds / inserts it to the list & initializes local time if needed.
89 public static void Insert<T>(
90 SortedList<double, T> buffer, // snapshot buffer
91 T snapshot, // the newly received snapshot
92 ref double localTimeline, // local interpolation time based on server time
93 ref double localTimescale, // timeline multiplier to apply catchup / slowdown over time
94 float sendInterval, // for debugging
95 double bufferTime, // offset for buffering
96 double catchupSpeed, // in % [0,1]
97 double slowdownSpeed, // in % [0,1]
98 ref ExponentialMovingAverage driftEma, // for catchup / slowdown
99 float catchupNegativeThreshold, // in % of sendInteral (careful, we may run out of snapshots)
100 float catchupPositiveThreshold, // in % of sendInterval
101 ref ExponentialMovingAverage deliveryTimeEma) // for dynamic buffer time adjustment
102 where T : Snapshot
103 {
104 // first snapshot?
105 // initialize local timeline.
106 // we want it to be behind by 'offset'.
107 //
108 // note that the first snapshot may be a lagging packet.
109 // so we would always be behind by that lag.
110 // this requires catchup later.
111 if (buffer.Count == 0)
112 localTimeline = snapshot.remoteTime - bufferTime;
113
114 // insert into the buffer.
115 //
116 // note that we might insert it between our current interpolation
117 // which is fine, it adds another data point for accuracy.
118 //
119 // note that insert may be called twice for the same key.
120 // by default, this would throw.
121 // need to handle it silently.
122 if (!buffer.ContainsKey(snapshot.remoteTime))
123 {
124 buffer.Add(snapshot.remoteTime, snapshot);
125
126 // dynamic buffer adjustment needs delivery interval jitter
127 if (buffer.Count >= 2)
128 {
129 // note that this is not entirely accurate for scrambled inserts.
130 //
131 // we always use the last two, not what we just inserted
132 // even if we were to use the diff for what we just inserted,
133 // a scrambled insert would still not be 100% accurate:
134 // => assume a buffer of AC, with delivery time C-A
135 // => we then insert B, with delivery time B-A
136 // => but then technically the first C-A wasn't correct,
137 // as it would have to be C-B
138 //
139 // in practice, scramble is rare and won't make much difference
140 double previousLocalTime = buffer.Values[buffer.Count - 2].localTime;
141 double lastestLocalTime = buffer.Values[buffer.Count - 1].localTime;
142
143 // this is the delivery time since last snapshot
144 double localDeliveryTime = lastestLocalTime - previousLocalTime;
145
146 // feed the local delivery time to the EMA.
147 // this is what the original stream did too.
148 // our final dynamic buffer adjustment is different though.
149 // we use standard deviation instead of average.
150 deliveryTimeEma.Add(localDeliveryTime);
151 }
152
153 // adjust timescale to catch up / slow down after each insertion
154 // because that is when we add new values to our EMA.
155
156 // we want localTimeline to be about 'bufferTime' behind.
157 // for that, we need the delivery time EMA.
158 // snapshots may arrive out of order, we can not use last-timeline.
159 // we need to use the inserted snapshot's time - timeline.
160 double latestRemoteTime = snapshot.remoteTime;
161 double timeDiff = latestRemoteTime - localTimeline;
162
163 // next, calculate average of a few seconds worth of timediffs.
164 // this gives smoother results.
165 //
166 // to calculate the average, we could simply loop through the
167 // last 'n' seconds worth of timediffs, but:
168 // - our buffer may only store a few snapshots (bufferTime)
169 // - looping through seconds worth of snapshots every time is
170 // expensive
171 //
172 // to solve this, we use an exponential moving average.
173 // https://en.wikipedia.org/wiki/Moving_average#Exponential_moving_average
174 // which is basically fancy math to do the same but faster.
175 // additionally, it allows us to look at more timeDiff values
176 // than we sould have access to in our buffer :)
177 driftEma.Add(timeDiff);
178
179 // next up, calculate how far we are currently away from bufferTime
180 double drift = driftEma.Value - bufferTime;
181
182 // convert relative thresholds to absolute values based on sendInterval
183 double absoluteNegativeThreshold = sendInterval * catchupNegativeThreshold;
184 double absolutePositiveThreshold = sendInterval * catchupPositiveThreshold;
185
186 // next, set localTimescale to catchup consistently in Update().
187 // we quantize between default/catchup/slowdown,
188 // this way we have 'default' speed most of the time(!).
189 // and only catch up / slow down for a little bit occasionally.
190 // a consistent multiplier would never be exactly 1.0.
191 localTimescale = Timescale(drift, catchupSpeed, slowdownSpeed, absoluteNegativeThreshold, absolutePositiveThreshold);
192
193 // debug logging
194 // UnityEngine.Debug.Log($"sendInterval={sendInterval:F3} bufferTime={bufferTime:F3} drift={drift:F3} driftEma={driftEma.Value:F3} timescale={localTimescale:F3} deliveryIntervalEma={deliveryTimeEma.Value:F3}");
195 }
196 }
197
198 // sample snapshot buffer to find the pair around the given time.
199 // returns indices so we can use it with RemoveRange to clear old snaps.
200 // make sure to use use buffer.Values[from/to], not buffer[from/to].
201 // make sure to only call this is we have > 0 snapshots.
202 public static void Sample<T>(
203 SortedList<double, T> buffer, // snapshot buffer
204 double localTimeline, // local interpolation time based on server time
205 out int from, // the snapshot <= time
206 out int to, // the snapshot >= time
207 out double t) // interpolation factor
208 where T : Snapshot
209 {
210 from = -1;
211 to = -1;
212 t = 0;
213
214 // sample from [0,count-1] so we always have two at 'i' and 'i+1'.
215 for (int i = 0; i < buffer.Count - 1; ++i)
216 {
217 // is local time between these two?
218 T first = buffer.Values[i];
219 T second = buffer.Values[i + 1];
220 if (localTimeline >= first.remoteTime &&
221 localTimeline <= second.remoteTime)
222 {
223 // use these two snapshots
224 from = i;
225 to = i + 1;
226 t = Mathd.InverseLerp(first.remoteTime, second.remoteTime, localTimeline);
227 return;
228 }
229 }
230
231 // didn't find two snapshots around local time.
232 // so pick either the first or last, depending on which is closer.
233
234 // oldest snapshot ahead of local time?
235 if (buffer.Values[0].remoteTime > localTimeline)
236 {
237 from = to = 0;
238 t = 0;
239 }
240 // otherwise initialize both to the last one
241 else
242 {
243 from = to = buffer.Count - 1;
244 t = 0;
245 }
246 }
247
248 // update time, sample, clear old.
249 // call this every update.
250 // returns true if there is anything to apply (requires at least 1 snap)
251 public static bool Step<T>(
252 SortedList<double, T> buffer, // snapshot buffer
253 double deltaTime, // engine delta time (unscaled)
254 ref double localTimeline, // local interpolation time based on server time
255 double localTimescale, // catchup / slowdown is applied to time every update
256 Func<T, T, double, T> Interpolate, // interpolates snapshot between two snapshots
257 out T computed)
258 where T : Snapshot
259 {
260 computed = default;
261
262 // nothing to do if there are no snapshots at all yet
263 if (buffer.Count == 0)
264 return false;
265
266 // move local forward in time, scaled with catchup / slowdown applied
267 localTimeline += deltaTime * localTimescale;
268
269 // sample snapshot buffer at local interpolation time
270 Sample(buffer, localTimeline, out int from, out int to, out double t);
271
272 // now interpolate between from & to (clamped)
273 T fromSnap = buffer.Values[from];
274 T toSnap = buffer.Values[to];
275 computed = Interpolate(fromSnap, toSnap, t);
276 // UnityEngine.Debug.Log($"step from: {from} to {to}");
277
278 // remove older snapshots that we definitely don't need anymore.
279 // after(!) using the indices.
280 //
281 // if we have 3 snapshots and we are between 2nd and 3rd:
282 // from = 1, to = 2
283 // then we need to remove the first one, which is exactly 'from'.
284 // because 'from-1' = 0 would remove none.
285 buffer.RemoveRange(from);
286
287 // return the interpolated snapshot
288 return true;
289 }
290 }
291}
static double InverseLerp(double a, double b, double value)
Calculates the linear parameter t that produces the interpolant value within the range [a,...