Skip to content

Commit 57f2ff6

Browse files
fix: NetworkTransform interpolation render time (#4133)
* test: Add NetworkTransform interpolation render time regression test Adds an integration test that measures how far behind the server clock the state a non-authority NetworkTransform is interpolating towards was sent. Only states sent at or before the render time are eligible to be interpolated towards, and the render time is the server clock minus the tick latency, so that measurement can never be less than the tick latency. It currently is, and goes negative, meaning the interpolator is chasing a state that the server clock says has not happened yet. An in-process integration test has effectively no round trip time, so the test first widens the client's local time buffer to separate LocalTime and ServerTime by a known amount and waits for that separation to take hold. Without it the two clocks sit close enough together that the test would pass regardless of which one the render time is derived from. This commit contains the test only, so it can be run against an unfixed tree. * fix: Derive NetworkTransform interpolation time from the server clock A NetworkTransform state's SentTime comes from its NetworkTick, which is a server tick, but the render time the interpolators were given was derived from LocalTime. That mixes two clocks. LocalTime leads ServerTime, so subtracting the tick latency from it lands the render time back at approximately ServerTime rather than a whole tick latency behind it, and a state's SentTime is floored to a tick boundary on top of that. The render time therefore sat at or ahead of the newest state that could exist and the interpolator had nothing to interpolate towards. Measuring from ServerTime makes the offset the whole tick latency instead of whatever is left of it, and is self correcting: as the round trip time grows the tick latency grows and the render time moves further back with it. This also matches the rest of the component, which already resets the interpolators using ServerTime. This is a no-op on a host or server, where the two clocks are the same, so it only affects clients. GetTickLatencyInSeconds returns an absolute time rather than a duration and had the same defect, so it now derives from ServerTime as well. GetTickLatency is left alone because it returns a tick count rather than a point in time. * docs: Condense interpolation render time comments and changelog Comment and changelog wording only, no behavioral or test logic changes. Trims the explanation in UpdateInterpolation from twenty one lines to six and drops the measurement anecdote and the unfilled Jira placeholder, keeping the reason the server clock is the correct one to measure from. Shortens the test's remarks and constant comments to match the density of the surrounding tests. The removed detail, the measurements behind the fix, and the metrics that were tried and rejected while building the test are recorded outside the repository. * update Adding PR number to changelog entries. * fix: Return a duration from NetworkTransform.GetTickLatencyInSeconds GetTickLatencyInSeconds returned TimeTicksAgo(...).Time, which is an absolute network timestamp rather than a duration, so the value grew for as long as the session ran. It is documented as returning the tick latency in seconds, and NetworkTimeSystem.TickLatency points at it as a way to inspect that latency, so the contract was misleading regardless of which clock it was measured from. It now returns the tick count multiplied by the tick interval. This also takes the clock question out of this method entirely, since a duration does not reference LocalTime or ServerTime. The change to derive interpolation render time from ServerTime now applies only to UpdateInterpolation. Adds integration tests covering the documented contract: the value tracks the tick latency rather than elapsed time, and lengthens by exactly the tick interval for each tick of additional buffering. Both fail against the previous implementation, the second regardless of how long the session has run, since buffering more ticks used to make the reported latency smaller. * style Removing using directive for UnityEngine as it is an unused namespace. * test: Tolerate an adaptive tick latency in the tick latency tests NetworkTimeSystem.TickLatency is recomputed from the averaged round trip time and can legitimately change mid-run. Both tests assumed it would not, and one failed on macOS when it moved from two ticks to three, reporting the value as having gone from 0.0666s to 0.1s. The duration is now only held to being unchanged across samples where the tick latency itself did not change, and the buffer offset test accounts for any tick latency movement between its two samples so that only the buffering is held to an exact figure. Both still fail against the previous absolute timestamp implementation. * test: Address review feedback on the render time and tick latency tests - Drop the redundant HostOrServer fixture argument and the UseCMBService override; Host is the default and a client-server fixture never runs under the CMB service. - Use WaitForSpawnedOnAllOrTimeOut, GetNonAuthorityNetworkManager and WaitForTicks instead of hand rolled equivalents. - There is only ever one connected client, so drop the collections and refer to the single non-authority instance directly. - Fold the two tick latency tests into one and drop the assertion that recomputed the implementation's own formula. What is left is what can actually regress: the value does not drift with session time, and it grows by exactly the ticks added to the interpolation buffer.
1 parent 2ea75a3 commit 57f2ff6

6 files changed

Lines changed: 257 additions & 9 deletions

File tree

com.unity.netcode.gameobjects/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ Additional documentation and release notes are available at [Multiplayer Documen
2222

2323
### Fixed
2424

25+
- Issue where `NetworkTransform` interpolated towards a point in time taken from the local clock rather than the server clock that state updates are stamped on, which starved the interpolator on clients and reduced interpolation to snapping between state updates. (#4133)
26+
- Issue where `NetworkTransform.GetTickLatencyInSeconds` returned an absolute network timestamp that grew for as long as the session ran, rather than the tick latency as a duration in seconds that it is documented to return. (#4133)
2527
- Issue where lerp smoothing was applied per frame instead of over time, which caused the `Lerp` and `SmoothDampening` interpolation types to smooth by different amounts at different frame rates. Results at 60fps are unchanged. (#4130)
2628
- Issue where setting a maximum interpolation time of 1.0 would stop a `NetworkTransform` from interpolating at all when using the `Lerp` or `SmoothDampening` interpolation types. (#4130)
2729

com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4242,14 +4242,13 @@ internal BufferedLinearInterpolatorQuaternion GetRotationInterpolator()
42424242
// Non-Authority
42434243
private void UpdateInterpolation()
42444244
{
4245-
// Use the local time because:
4246-
// Client-Server:
4247-
// Local time is server time on a host or server.
4248-
// Local time on clients takes latency into consideration.
4249-
// Distributed authority:
4250-
// Local time is used by the authority.
4251-
// Local time on non-authority takes latency into consid]eration.
4252-
var timeSystem = m_CachedNetworkManager.LocalTime;
4245+
// Use the server time, since that is the clock the states being interpolated between are stamped on
4246+
// (a state's SentTime is derived from its NetworkTick). Deriving the render time from LocalTime
4247+
// subtracts the tick latency from a clock that already leads ServerTime by roughly that much, which
4248+
// leaves the render time at or ahead of the newest state that can exist and starves the interpolator.
4249+
// Measuring from ServerTime is also self correcting, as the tick latency grows with the round trip
4250+
// time. This is a no-op on a host or server, where both clocks are the same.
4251+
var timeSystem = m_CachedNetworkManager.ServerTime;
42534252
var currentTime = timeSystem.Time;
42544253
#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D
42554254
var cachedDeltaTime = m_UseRigidbodyForMotion ? m_CachedNetworkManager.RealTimeProvider.FixedDeltaTime : m_CachedNetworkManager.RealTimeProvider.DeltaTime;
@@ -4713,7 +4712,10 @@ internal static float GetTickLatencyInSeconds(NetworkManager networkManager)
47134712
{
47144713
if (networkManager.IsListening)
47154714
{
4716-
return (float)networkManager.LocalTime.TimeTicksAgo(networkManager.NetworkTimeSystem.TickLatency + InterpolationBufferTickOffset).Time;
4715+
// The number of ticks the interpolators run behind, as a duration. This is not a point in time:
4716+
// it does not grow as the session runs.
4717+
var ticksBehind = networkManager.NetworkTimeSystem.TickLatency + InterpolationBufferTickOffset;
4718+
return (float)(ticksBehind * networkManager.ServerTime.FixedDeltaTimeAsDouble);
47174719
}
47184720
return 0f;
47194721
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
using System.Collections;
2+
using NUnit.Framework;
3+
using Unity.Netcode.Components;
4+
using Unity.Netcode.TestHelpers.Runtime;
5+
using UnityEngine;
6+
using UnityEngine.TestTools;
7+
8+
namespace Unity.Netcode.RuntimeTests
9+
{
10+
/// <summary>
11+
/// Validates that the render time a non-authority instance interpolates towards is derived from the same
12+
/// clock that the state updates it is interpolating between are stamped on.
13+
/// </summary>
14+
/// <remarks>
15+
/// Measures how far behind ServerTime the state being interpolated towards was sent. The render time is
16+
/// ServerTime minus the tick latency and only states sent at or before it are eligible, so that measurement
17+
/// can never be less than the tick latency. Deriving the render time from LocalTime eats into that margin by
18+
/// however far the two clocks are apart, and can push the target past ServerTime entirely.
19+
/// </remarks>
20+
[TestFixture(NetworkTransform.InterpolationTypes.Lerp)]
21+
[TestFixture(NetworkTransform.InterpolationTypes.SmoothDampening)]
22+
internal class NetworkTransformInterpolationRenderTimeTests : IntegrationTestWithApproximation
23+
{
24+
protected override int NumberOfClients => 1;
25+
26+
// How far LocalTime is pushed ahead of ServerTime, in ticks. An in-process test has no round trip time
27+
// to separate the two clocks, and this is large enough to exceed NetworkTimeSystem's hard reset
28+
// threshold so the offset snaps instead of converging at its default adjustment ratio.
29+
private const int k_LocalBufferTicks = 12;
30+
31+
// The separation the clocks must actually reach before any measurement is taken.
32+
private const double k_RequiredLeadTicks = 8.0d;
33+
34+
// Ticks of authority motion after the clocks have separated, so the interpolator reaches steady state.
35+
private const int k_WarmUpTicks = 20;
36+
37+
private const int k_SampledFrames = 90;
38+
39+
// Far enough each tick that every tick produces a state update rather than being filtered out by the
40+
// position threshold.
41+
private const float k_DistancePerTick = 1.37f;
42+
43+
private readonly NetworkTransform.InterpolationTypes m_InterpolationType;
44+
45+
private GameObject m_TestPrefab;
46+
private NetworkManager m_AuthorityNetworkManager;
47+
private NetworkTransform m_AuthorityInstance;
48+
private Vector3 m_Direction;
49+
50+
public NetworkTransformInterpolationRenderTimeTests(NetworkTransform.InterpolationTypes interpolationType)
51+
{
52+
m_InterpolationType = interpolationType;
53+
}
54+
55+
protected override void OnServerAndClientsCreated()
56+
{
57+
m_TestPrefab = CreateNetworkObjectPrefab("RenderTimeTestObj");
58+
var networkTransform = m_TestPrefab.AddComponent<NetworkTransform>();
59+
networkTransform.PositionInterpolationType = m_InterpolationType;
60+
base.OnServerAndClientsCreated();
61+
}
62+
63+
private static double GetTickInterval(NetworkManager networkManager)
64+
{
65+
return 1.0d / networkManager.NetworkTickSystem.TickRate;
66+
}
67+
68+
/// <summary>
69+
/// How far LocalTime currently leads ServerTime, expressed in ticks.
70+
/// </summary>
71+
private static double GetClockLeadInTicks(NetworkManager networkManager)
72+
{
73+
return (networkManager.LocalTime.Time - networkManager.ServerTime.Time) / GetTickInterval(networkManager);
74+
}
75+
76+
/// <summary>
77+
/// Moves the authority instance once per tick so that a state update is generated every tick.
78+
/// </summary>
79+
private void OnNetworkTick()
80+
{
81+
m_AuthorityInstance.transform.position += m_Direction * k_DistancePerTick;
82+
}
83+
84+
[UnityTest]
85+
public IEnumerator RenderTimeTrailsTheServerClock()
86+
{
87+
m_AuthorityNetworkManager = GetAuthorityNetworkManager();
88+
m_AuthorityInstance = SpawnObject(m_TestPrefab, m_AuthorityNetworkManager).GetComponent<NetworkTransform>();
89+
90+
yield return WaitForSpawnedOnAllOrTimeOut(m_AuthorityInstance.NetworkObject);
91+
AssertOnTimeout($"Not all clients spawned {m_AuthorityInstance.name}!");
92+
93+
var nonAuthority = GetNonAuthorityNetworkManager();
94+
var nonAuthorityInstance = nonAuthority.SpawnManager.SpawnedObjects[m_AuthorityInstance.NetworkObject.NetworkObjectId].GetComponent<NetworkTransform>();
95+
96+
// Separate the two clocks by a known amount so that which one the render time is derived from is
97+
// actually distinguishable.
98+
nonAuthority.NetworkTimeSystem.LocalBufferSec = k_LocalBufferTicks * GetTickInterval(nonAuthority);
99+
100+
// Start continuous motion on the authority.
101+
m_Direction = GetRandomVector3(-10, 10).normalized;
102+
m_AuthorityNetworkManager.NetworkTickSystem.Tick += OnNetworkTick;
103+
104+
// The offset only moves when the client next receives a time sync, so wait for the separation to
105+
// actually take hold rather than assuming it has.
106+
yield return WaitForConditionOrTimeOut(() => GetClockLeadInTicks(nonAuthority) >= k_RequiredLeadTicks);
107+
AssertOnTimeout($"The nonAuthority clock never fell {k_RequiredLeadTicks} ticks behind, so this test " +
108+
"cannot tell the two clocks apart and would pass regardless of which one is used.");
109+
110+
// Let the interpolator settle at the new separation before measuring.
111+
yield return WaitForTicks(m_AuthorityNetworkManager, k_WarmUpTicks);
112+
113+
// Sample how far behind ServerTime the state being interpolated towards was sent.
114+
var interpolator = nonAuthorityInstance.GetPositionInterpolator();
115+
var totalTargetLagTicks = 0.0d;
116+
var totalBuffered = 0;
117+
var samples = 0;
118+
for (int frame = 0; frame < k_SampledFrames; frame++)
119+
{
120+
if (interpolator.InterpolateState.Target.HasValue)
121+
{
122+
var targetLag = nonAuthority.ServerTime.Time - interpolator.InterpolateState.Target.Value.TimeSent;
123+
totalTargetLagTicks += targetLag / GetTickInterval(nonAuthority);
124+
totalBuffered += interpolator.m_BufferQueue.Count;
125+
samples++;
126+
}
127+
yield return null;
128+
}
129+
130+
m_AuthorityNetworkManager.NetworkTickSystem.Tick -= OnNetworkTick;
131+
132+
Assert.Greater(samples, 0, $"{nonAuthorityInstance.name} never had a state to interpolate towards!");
133+
134+
var meanTargetLagTicks = totalTargetLagTicks / samples;
135+
var meanBuffered = totalBuffered / (float)samples;
136+
var tickLatency = nonAuthority.NetworkTimeSystem.TickLatency;
137+
138+
// Anything less than the tick latency means the render time came from a clock that leads the one
139+
// the states are stamped on.
140+
Assert.GreaterOrEqual(meanTargetLagTicks, tickLatency,
141+
$"[{m_InterpolationType}] {nonAuthorityInstance.name} was interpolating towards a state sent " +
142+
$"{meanTargetLagTicks:F3} ticks behind the server clock, but the render time is the server " +
143+
$"clock minus a tick latency of {tickLatency}, so it should never be less than that. " +
144+
$"(clock lead {GetClockLeadInTicks(nonAuthority):F3} ticks, mean buffered {meanBuffered:F3})");
145+
}
146+
}
147+
}

com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformInterpolationRenderTimeTests.cs.meta

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
using System.Collections;
2+
using NUnit.Framework;
3+
using Unity.Netcode.Components;
4+
using Unity.Netcode.TestHelpers.Runtime;
5+
using UnityEngine.TestTools;
6+
7+
namespace Unity.Netcode.RuntimeTests
8+
{
9+
/// <summary>
10+
/// Validates that <see cref="NetworkTransform.GetTickLatencyInSeconds()"/> returns what it is documented to
11+
/// return: the tick latency as a duration in seconds.
12+
/// </summary>
13+
/// <remarks>
14+
/// It previously returned <c>TimeTicksAgo(...).Time</c>, which is an absolute network timestamp rather than a
15+
/// duration, so the value grew for as long as the session ran.
16+
/// </remarks>
17+
internal class NetworkTransformTickLatencyTests : NetcodeIntegrationTest
18+
{
19+
protected override int NumberOfClients => 1;
20+
21+
// Ticks of additional buffering applied part way through the test to confirm the returned duration
22+
// tracks the tick latency it is derived from.
23+
private const int k_AddedBufferTicks = 3;
24+
25+
// Seconds of tolerance when comparing against the expected duration.
26+
private const float k_Tolerance = 0.0005f;
27+
28+
// The number of samples taken while the session runs, to confirm the value does not drift with time.
29+
private const int k_Samples = 30;
30+
31+
private int m_OriginalBufferTickOffset;
32+
33+
protected override IEnumerator OnSetup()
34+
{
35+
m_OriginalBufferTickOffset = NetworkTransform.InterpolationBufferTickOffset;
36+
return base.OnSetup();
37+
}
38+
39+
protected override IEnumerator OnTearDown()
40+
{
41+
// This is static, so leaving it modified would leak into every test that runs afterwards.
42+
NetworkTransform.InterpolationBufferTickOffset = m_OriginalBufferTickOffset;
43+
return base.OnTearDown();
44+
}
45+
46+
[UnityTest]
47+
public IEnumerator GetTickLatencyInSecondsReturnsADurationNotATimestamp()
48+
{
49+
var client = GetNonAuthorityNetworkManager();
50+
var tickInterval = (float)client.ServerTime.FixedDeltaTimeAsDouble;
51+
52+
// A timestamp climbs by roughly a second per second, so sample while the session clock advances
53+
// and hold the value to only moving when the tick latency it is derived from moves. That latency
54+
// is adaptive and can legitimately change mid-run.
55+
var previousTicksBehind = -1;
56+
var previousValue = 0f;
57+
for (int i = 0; i < k_Samples; i++)
58+
{
59+
var ticksBehind = client.NetworkTimeSystem.TickLatency + NetworkTransform.InterpolationBufferTickOffset;
60+
var latency = NetworkTransform.GetTickLatencyInSeconds(client);
61+
62+
Assert.Greater(latency, 0f, "A latency of zero or less is not a duration this can be measured against.");
63+
if (ticksBehind == previousTicksBehind)
64+
{
65+
Assert.AreEqual(previousValue, latency, k_Tolerance,
66+
$"The reported latency moved from {previousValue}s to {latency}s while the tick latency " +
67+
$"stayed at {ticksBehind} ticks, so it is tracking elapsed time rather than latency.");
68+
}
69+
70+
previousTicksBehind = ticksBehind;
71+
previousValue = latency;
72+
yield return null;
73+
}
74+
75+
// Buffering more ticks has to lengthen the reported duration by exactly those ticks.
76+
var latencyBefore = client.NetworkTimeSystem.TickLatency;
77+
var before = NetworkTransform.GetTickLatencyInSeconds(client);
78+
NetworkTransform.InterpolationBufferTickOffset = m_OriginalBufferTickOffset + k_AddedBufferTicks;
79+
yield return null;
80+
81+
var latencyAfter = client.NetworkTimeSystem.TickLatency;
82+
var after = NetworkTransform.GetTickLatencyInSeconds(client);
83+
84+
// The adaptive tick latency may also have moved in between, so only the buffering is held to an
85+
// exact figure.
86+
var expectedIncrease = (k_AddedBufferTicks + (latencyAfter - latencyBefore)) * tickInterval;
87+
Assert.AreEqual(expectedIncrease, after - before, k_Tolerance,
88+
$"Adding {k_AddedBufferTicks} ticks of buffering changed the reported latency by " +
89+
$"{after - before}s when a tick is {tickInterval}s, so it should have changed by " +
90+
$"{expectedIncrease}s (tick latency went from {latencyBefore} to {latencyAfter}).");
91+
}
92+
}
93+
}

com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformTickLatencyTests.cs.meta

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)