From e7031a821b2bfe73e5c2167f47eb5d5db1977d9d Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Wed, 19 Aug 2026 10:01:10 -0500 Subject: [PATCH 1/8] 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. --- ...rkTransformInterpolationRenderTimeTests.cs | 239 ++++++++++++++++++ ...nsformInterpolationRenderTimeTests.cs.meta | 2 + 2 files changed, 241 insertions(+) create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformInterpolationRenderTimeTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformInterpolationRenderTimeTests.cs.meta diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformInterpolationRenderTimeTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformInterpolationRenderTimeTests.cs new file mode 100644 index 0000000000..2b163487b2 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformInterpolationRenderTimeTests.cs @@ -0,0 +1,239 @@ +using System.Collections; +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Validates that the render time a non-authority instance interpolates towards is derived from the same + /// clock that the state updates it is interpolating between are stamped on. + /// + /// + /// A state's SentTime is derived from its NetworkTick, which is a server + /// tick, so the render time has to be measured from ServerTime. Measuring it from LocalTime mixes two + /// clocks: LocalTime leads ServerTime, so subtracting the tick latency from LocalTime lands the render time + /// back at approximately ServerTime rather than a whole tick latency behind it. The interpolator is then + /// asked to render a point in time at (or ahead of) the newest state that can possibly exist, so it has + /// nothing left to interpolate towards. + /// + /// What this test measures is how far behind ServerTime the state currently being interpolated towards was + /// sent. Because the target is selected against the render time, this has to be at least the tick latency: + /// the render time is ServerTime minus the tick latency, and only states sent at or before the render time + /// are eligible. Deriving the render time from LocalTime instead eats into that margin by however far the + /// two clocks are apart, and can push the target past ServerTime entirely (a negative value below, meaning + /// the interpolator is chasing a state that the server clock says has not happened yet). + /// + [TestFixture(HostOrServer.Host, NetworkTransform.InterpolationTypes.Lerp)] + [TestFixture(HostOrServer.Host, NetworkTransform.InterpolationTypes.SmoothDampening)] + internal class NetworkTransformInterpolationRenderTimeTests : IntegrationTestWithApproximation + { + protected override int NumberOfClients => 1; + + // How far LocalTime is pushed ahead of ServerTime, in ticks. An in-process integration test has + // effectively no round trip time and the separation between the two clocks is + // (half RTT + LocalBufferSec + ServerBufferSec), so without widening the local buffer the two clocks + // sit close enough together that which one is used barely shows. This is deliberately large enough to + // exceed NetworkTimeSystem's hard reset threshold (0.2s) so the offset snaps rather than converging at + // the default adjustment ratio of 0.01s per second, which would take over ten seconds. + private const int k_LocalBufferTicks = 12; + + // The separation the clocks must actually reach before any measurement is taken. + private const double k_RequiredLeadTicks = 8.0d; + + // Ticks of authority motion after the clocks have separated, so the interpolator reaches steady state. + private const int k_WarmUpTicks = 20; + + // The number of rendered frames sampled once the warm up has completed. + private const int k_SampledFrames = 90; + + // The distance the authority moves each tick. Large enough that every tick produces a state update + // rather than being filtered out by the position threshold. + private const float k_DistancePerTick = 1.37f; + + private readonly NetworkTransform.InterpolationTypes m_InterpolationType; + + private GameObject m_TestPrefab; + private NetworkManager m_AuthorityNetworkManager; + private NetworkTransform m_AuthorityInstance; + private Vector3 m_Direction; + private int m_TickCount; + + public NetworkTransformInterpolationRenderTimeTests(HostOrServer hostOrServer, NetworkTransform.InterpolationTypes interpolationType) : base(hostOrServer) + { + m_InterpolationType = interpolationType; + } + + // TODO: [CmbServiceTests] ServerTime's meaning under a CMB service session has not been verified. + protected override bool UseCMBService() + { + return false; + } + + protected override void OnServerAndClientsCreated() + { + m_TestPrefab = CreateNetworkObjectPrefab("RenderTimeTestObj"); + var networkTransform = m_TestPrefab.AddComponent(); + networkTransform.PositionInterpolationType = m_InterpolationType; + base.OnServerAndClientsCreated(); + } + + private static double GetTickInterval(NetworkManager networkManager) + { + return 1.0d / networkManager.NetworkTickSystem.TickRate; + } + + /// + /// How far LocalTime currently leads ServerTime, expressed in ticks. + /// + private static double GetClockLeadInTicks(NetworkManager networkManager) + { + return (networkManager.LocalTime.Time - networkManager.ServerTime.Time) / GetTickInterval(networkManager); + } + + /// + /// Moves the authority instance once per tick so that a state update is generated every tick. + /// + private void OnNetworkTick() + { + m_TickCount++; + m_AuthorityInstance.transform.position += m_Direction * k_DistancePerTick; + } + + private bool AllClientsSpawnedInstance() + { + foreach (var networkManager in m_NetworkManagers) + { + if (networkManager == m_AuthorityNetworkManager) + { + continue; + } + + if (!networkManager.SpawnManager.SpawnedObjects.ContainsKey(m_AuthorityInstance.NetworkObject.NetworkObjectId)) + { + return false; + } + } + return true; + } + + private List GetNonAuthorityInstances() + { + var instances = new List(); + foreach (var networkManager in m_NetworkManagers) + { + if (networkManager == m_AuthorityNetworkManager) + { + continue; + } + + var spawnedObject = networkManager.SpawnManager.SpawnedObjects[m_AuthorityInstance.NetworkObject.NetworkObjectId]; + instances.Add(spawnedObject.GetComponent()); + } + return instances; + } + + [UnityTest] + public IEnumerator RenderTimeTrailsTheServerClock() + { + m_AuthorityNetworkManager = GetAuthorityNetworkManager(); + m_AuthorityInstance = SpawnObject(m_TestPrefab, m_AuthorityNetworkManager).GetComponent(); + + yield return WaitForConditionOrTimeOut(AllClientsSpawnedInstance); + AssertOnTimeout($"Not all clients spawned {m_AuthorityInstance.name}!"); + + var nonAuthorityInstances = GetNonAuthorityInstances(); + Assert.IsNotEmpty(nonAuthorityInstances, "There were no non-authority instances to measure!"); + + // Separate the two clocks by a known amount so that which one the render time is derived from is + // actually distinguishable. + foreach (var instance in nonAuthorityInstances) + { + var networkManager = instance.NetworkManager; + networkManager.NetworkTimeSystem.LocalBufferSec = k_LocalBufferTicks * GetTickInterval(networkManager); + } + + // Start continuous motion on the authority. + m_Direction = GetRandomVector3(-10, 10).normalized; + m_TickCount = 0; + m_AuthorityNetworkManager.NetworkTickSystem.Tick += OnNetworkTick; + + // The offset only moves when the client next receives a time sync, so wait for the separation to + // actually take hold rather than assuming it has. + yield return WaitForConditionOrTimeOut(() => + { + foreach (var instance in nonAuthorityInstances) + { + if (GetClockLeadInTicks(instance.NetworkManager) < k_RequiredLeadTicks) + { + return false; + } + } + return true; + }); + AssertOnTimeout($"The client clocks never separated by {k_RequiredLeadTicks} ticks, so this test " + + $"cannot tell the two clocks apart and would pass regardless of which one is used."); + + // Let the interpolator settle at the new separation before measuring. + var warmUpTarget = m_TickCount + k_WarmUpTicks; + yield return WaitForConditionOrTimeOut(() => m_TickCount >= warmUpTarget); + AssertOnTimeout("Timed out waiting for the authority to keep moving!"); + + // Sample how far behind ServerTime the state being interpolated towards was sent. + var totalTargetLagTicks = new Dictionary(); + var totalBuffered = new Dictionary(); + var samples = new Dictionary(); + foreach (var instance in nonAuthorityInstances) + { + totalTargetLagTicks.Add(instance, 0.0d); + totalBuffered.Add(instance, 0); + samples.Add(instance, 0); + } + + for (int frame = 0; frame < k_SampledFrames; frame++) + { + foreach (var instance in nonAuthorityInstances) + { + var interpolator = instance.GetPositionInterpolator(); + if (!interpolator.InterpolateState.Target.HasValue) + { + continue; + } + + var networkManager = instance.NetworkManager; + var targetLag = networkManager.ServerTime.Time - interpolator.InterpolateState.Target.Value.TimeSent; + totalTargetLagTicks[instance] += targetLag / GetTickInterval(networkManager); + totalBuffered[instance] += interpolator.m_BufferQueue.Count; + samples[instance]++; + } + yield return null; + } + + m_AuthorityNetworkManager.NetworkTickSystem.Tick -= OnNetworkTick; + + foreach (var instance in nonAuthorityInstances) + { + Assert.Greater(samples[instance], 0, $"{instance.name} never had a state to interpolate towards!"); + + var networkManager = instance.NetworkManager; + var meanTargetLagTicks = totalTargetLagTicks[instance] / samples[instance]; + var meanBuffered = totalBuffered[instance] / (float)samples[instance]; + var tickLatency = networkManager.NetworkTimeSystem.TickLatency; + + // 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 the target can never be newer than + // that. Anything less means the render time was taken from a clock that runs ahead of the one + // the states are stamped on. + Assert.GreaterOrEqual(meanTargetLagTicks, tickLatency, + $"[{m_InterpolationType}] {instance.name} was interpolating towards a state sent " + + $"{meanTargetLagTicks:F3} ticks behind the server clock, but the render time is the server " + + $"clock minus a tick latency of {tickLatency}, so it should never be less than that. " + + $"(clock lead {GetClockLeadInTicks(networkManager):F3} ticks, mean buffered {meanBuffered:F3}). " + + $"The render time is being derived from a clock that leads the one state updates are stamped on."); + } + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformInterpolationRenderTimeTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformInterpolationRenderTimeTests.cs.meta new file mode 100644 index 0000000000..2ddeccd2ba --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformInterpolationRenderTimeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: acef3f08e52a1e143bbffdddb16cdfc5 \ No newline at end of file From 6bf1ba90cfe6e354b695d47fc27e93f7701d2368 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Wed, 19 Aug 2026 10:02:11 -0500 Subject: [PATCH 2/8] 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. --- com.unity.netcode.gameobjects/CHANGELOG.md | 9 ++---- .../Runtime/Components/NetworkTransform.cs | 31 +++++++++++++------ 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 85157fe510..a7d6d42f5d 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -19,27 +19,22 @@ Additional documentation and release notes are available at [Multiplayer Documen - `Unity.Netcode.Editor.PackageChecker` → `Unity.Netcode.GameObjects.Editor.PackageChecker` - `Unity.Netcode.Editor.Tests` → `Unity.Netcode.GameObjects.Editor.Tests` - - ### Deprecated - ### Removed - ### Fixed +- Issue where non-authority `NetworkTransform` instances derived their interpolation time from the local clock instead of the server clock that state updates are stamped on, which starved the interpolator and reduced interpolation to snapping between state updates. (#TBD) +- Issue where `NetworkTransform.GetTickLatencyInSeconds` returned a time derived from the local clock, which did not match the time the interpolators actually use. (#TBD) - Issue with not being able to spawn initially disabled in-scene placed objects. (#4093) - Issue with pre-instantiated network prefab instances being marked as in-scene placed. Now pre-instantiated network prefabs are dynamically spawned. (#4093) - Issue where a user could spawn runtime created `NetworkObject` that has a GlobalObjectIdHash of zero. These are not valid instances and will no longer be allowed to spawn. (#4093) - ### Security - ### Obsolete - ## [2.13.1] - 2026-07-19 ### Added diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs index de4e86999d..dd4dc41dc4 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs @@ -4259,14 +4259,27 @@ internal BufferedLinearInterpolatorQuaternion GetRotationInterpolator() // Non-Authority private void UpdateInterpolation() { - // Use the local time because: - // Client-Server: - // Local time is server time on a host or server. - // Local time on clients takes latency into consideration. - // Distributed authority: - // Local time is used by the authority. - // Local time on non-authority takes latency into consid]eration. - var timeSystem = m_CachedNetworkManager.LocalTime; + // Use the server time, because that is the clock the measurements being interpolated between are + // stamped on: a state's SentTime is derived from its NetworkTick, which is a server tick. + // + // Deriving the render time from LocalTime instead mixes two clocks. LocalTime leads ServerTime by + // roughly the tick latency, so subtracting the tick latency from it lands the render time back at + // (approximately) ServerTime rather than behind it. "Approximately" is the problem: the lead is + // fractional while the subtraction is a whole number of ticks, and a state's SentTime is floored to + // a tick boundary on top of that. The render time therefore ends up at or slightly ahead of the + // newest state that can exist, leaving the interpolator with nothing to interpolate towards. A + // measured session had the render time ahead of ServerTime on 100% of frames, with the interpolator + // never holding more than one measurement. + // + // Measuring from ServerTime instead makes the offset the whole tick latency rather than whatever is + // left of it, which is self correcting: as the round trip time grows, NetworkTimeSystem.TickLatency + // grows and the render time moves further back with it. + // + // Note this is a no-op on a host or server, where LocalTime and ServerTime are the same. + // TODO-JIRA-TICKET: + // Confirm the distributed authority case. Authority instances interpolate nothing, so this should + // not reach them, but ServerTime's meaning under a CMB service session should be verified. + var timeSystem = m_CachedNetworkManager.ServerTime; var currentTime = timeSystem.Time; #if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D var cachedDeltaTime = m_UseRigidbodyForMotion ? m_CachedNetworkManager.RealTimeProvider.FixedDeltaTime : m_CachedNetworkManager.RealTimeProvider.DeltaTime; @@ -4730,7 +4743,7 @@ internal static float GetTickLatencyInSeconds(NetworkManager networkManager) { if (networkManager.IsListening) { - return (float)networkManager.LocalTime.TimeTicksAgo(networkManager.NetworkTimeSystem.TickLatency + InterpolationBufferTickOffset).Time; + return (float)networkManager.ServerTime.TimeTicksAgo(networkManager.NetworkTimeSystem.TickLatency + InterpolationBufferTickOffset).Time; } return 0f; } From 2b383b87c116b48e79796ede72536216611e0e7f Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Wed, 19 Aug 2026 11:24:01 -0500 Subject: [PATCH 3/8] 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. --- com.unity.netcode.gameobjects/CHANGELOG.md | 1 - .../Runtime/Components/NetworkTransform.cs | 26 ++++---------- ...rkTransformInterpolationRenderTimeTests.cs | 35 ++++++------------- 3 files changed, 16 insertions(+), 46 deletions(-) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index a7d6d42f5d..b7fa98d94e 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -10,7 +10,6 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Added - ### Changed - All editor assembly definitions are renamed with `Unity.Netcode.GameObjects.x` variants diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs index dd4dc41dc4..8a183c8dd0 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs @@ -4259,26 +4259,12 @@ internal BufferedLinearInterpolatorQuaternion GetRotationInterpolator() // Non-Authority private void UpdateInterpolation() { - // Use the server time, because that is the clock the measurements being interpolated between are - // stamped on: a state's SentTime is derived from its NetworkTick, which is a server tick. - // - // Deriving the render time from LocalTime instead mixes two clocks. LocalTime leads ServerTime by - // roughly the tick latency, so subtracting the tick latency from it lands the render time back at - // (approximately) ServerTime rather than behind it. "Approximately" is the problem: the lead is - // fractional while the subtraction is a whole number of ticks, and a state's SentTime is floored to - // a tick boundary on top of that. The render time therefore ends up at or slightly ahead of the - // newest state that can exist, leaving the interpolator with nothing to interpolate towards. A - // measured session had the render time ahead of ServerTime on 100% of frames, with the interpolator - // never holding more than one measurement. - // - // Measuring from ServerTime instead makes the offset the whole tick latency rather than whatever is - // left of it, which is self correcting: as the round trip time grows, NetworkTimeSystem.TickLatency - // grows and the render time moves further back with it. - // - // Note this is a no-op on a host or server, where LocalTime and ServerTime are the same. - // TODO-JIRA-TICKET: - // Confirm the distributed authority case. Authority instances interpolate nothing, so this should - // not reach them, but ServerTime's meaning under a CMB service session should be verified. + // Use the server time, since that is the clock the states being interpolated between are stamped on + // (a state's SentTime is derived from its NetworkTick). Deriving the render time from LocalTime + // subtracts the tick latency from a clock that already leads ServerTime by roughly that much, which + // leaves the render time at or ahead of the newest state that can exist and starves the interpolator. + // Measuring from ServerTime is also self correcting, as the tick latency grows with the round trip + // time. This is a no-op on a host or server, where both clocks are the same. var timeSystem = m_CachedNetworkManager.ServerTime; var currentTime = timeSystem.Time; #if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformInterpolationRenderTimeTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformInterpolationRenderTimeTests.cs index 2b163487b2..dceaa1875c 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformInterpolationRenderTimeTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformInterpolationRenderTimeTests.cs @@ -13,19 +13,10 @@ namespace Unity.Netcode.RuntimeTests /// clock that the state updates it is interpolating between are stamped on. /// /// - /// A state's SentTime is derived from its NetworkTick, which is a server - /// tick, so the render time has to be measured from ServerTime. Measuring it from LocalTime mixes two - /// clocks: LocalTime leads ServerTime, so subtracting the tick latency from LocalTime lands the render time - /// back at approximately ServerTime rather than a whole tick latency behind it. The interpolator is then - /// asked to render a point in time at (or ahead of) the newest state that can possibly exist, so it has - /// nothing left to interpolate towards. - /// - /// What this test measures is how far behind ServerTime the state currently being interpolated towards was - /// sent. Because the target is selected against the render time, this has to be at least the tick latency: - /// the render time is ServerTime minus the tick latency, and only states sent at or before the render time - /// are eligible. Deriving the render time from LocalTime instead eats into that margin by however far the - /// two clocks are apart, and can push the target past ServerTime entirely (a negative value below, meaning - /// the interpolator is chasing a state that the server clock says has not happened yet). + /// Measures how far behind ServerTime the state being interpolated towards was sent. The render time is + /// ServerTime minus the tick latency and only states sent at or before it are eligible, so that measurement + /// can never be less than the tick latency. Deriving the render time from LocalTime eats into that margin by + /// however far the two clocks are apart, and can push the target past ServerTime entirely. /// [TestFixture(HostOrServer.Host, NetworkTransform.InterpolationTypes.Lerp)] [TestFixture(HostOrServer.Host, NetworkTransform.InterpolationTypes.SmoothDampening)] @@ -33,12 +24,9 @@ internal class NetworkTransformInterpolationRenderTimeTests : IntegrationTestWit { protected override int NumberOfClients => 1; - // How far LocalTime is pushed ahead of ServerTime, in ticks. An in-process integration test has - // effectively no round trip time and the separation between the two clocks is - // (half RTT + LocalBufferSec + ServerBufferSec), so without widening the local buffer the two clocks - // sit close enough together that which one is used barely shows. This is deliberately large enough to - // exceed NetworkTimeSystem's hard reset threshold (0.2s) so the offset snaps rather than converging at - // the default adjustment ratio of 0.01s per second, which would take over ten seconds. + // How far LocalTime is pushed ahead of ServerTime, in ticks. An in-process test has no round trip time + // to separate the two clocks, and this is large enough to exceed NetworkTimeSystem's hard reset + // threshold so the offset snaps instead of converging at its default adjustment ratio. private const int k_LocalBufferTicks = 12; // The separation the clocks must actually reach before any measurement is taken. @@ -47,11 +35,10 @@ internal class NetworkTransformInterpolationRenderTimeTests : IntegrationTestWit // Ticks of authority motion after the clocks have separated, so the interpolator reaches steady state. private const int k_WarmUpTicks = 20; - // The number of rendered frames sampled once the warm up has completed. private const int k_SampledFrames = 90; - // The distance the authority moves each tick. Large enough that every tick produces a state update - // rather than being filtered out by the position threshold. + // Far enough each tick that every tick produces a state update rather than being filtered out by the + // position threshold. private const float k_DistancePerTick = 1.37f; private readonly NetworkTransform.InterpolationTypes m_InterpolationType; @@ -223,9 +210,7 @@ public IEnumerator RenderTimeTrailsTheServerClock() var meanBuffered = totalBuffered[instance] / (float)samples[instance]; var tickLatency = networkManager.NetworkTimeSystem.TickLatency; - // 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 the target can never be newer than - // that. Anything less means the render time was taken from a clock that runs ahead of the one + // Anything less than the tick latency means the render time came from a clock that leads the one // the states are stamped on. Assert.GreaterOrEqual(meanTargetLagTicks, tickLatency, $"[{m_InterpolationType}] {instance.name} was interpolating towards a state sent " + From e945333426f8e06c33b9925cf10316a976bd7dc4 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Wed, 19 Aug 2026 11:30:50 -0500 Subject: [PATCH 4/8] update Adding PR number to changelog entries. --- com.unity.netcode.gameobjects/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index b7fa98d94e..2bc19bcd26 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -24,8 +24,8 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed -- Issue where non-authority `NetworkTransform` instances derived their interpolation time from the local clock instead of the server clock that state updates are stamped on, which starved the interpolator and reduced interpolation to snapping between state updates. (#TBD) -- Issue where `NetworkTransform.GetTickLatencyInSeconds` returned a time derived from the local clock, which did not match the time the interpolators actually use. (#TBD) +- 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) +- Issue where `NetworkTransform.GetTickLatencyInSeconds` returned a time based on the local clock instead of the server clock used for interpolation. (#4133) - Issue with not being able to spawn initially disabled in-scene placed objects. (#4093) - Issue with pre-instantiated network prefab instances being marked as in-scene placed. Now pre-instantiated network prefabs are dynamically spawned. (#4093) - Issue where a user could spawn runtime created `NetworkObject` that has a GlobalObjectIdHash of zero. These are not valid instances and will no longer be allowed to spawn. (#4093) From dad9cd85943688ba10fcc37ad1a1b5dd0fb38e2e Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Wed, 19 Aug 2026 11:47:50 -0500 Subject: [PATCH 5/8] update Adding PR number to changelog entries. --- com.unity.netcode.gameobjects/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 2bc19bcd26..01f9f6ab42 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -24,8 +24,8 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed -- 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) -- Issue where `NetworkTransform.GetTickLatencyInSeconds` returned a time based on the local clock instead of the server clock used for interpolation. (#4133) +- 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. (#4135) +- Issue where `NetworkTransform.GetTickLatencyInSeconds` returned a time based on the local clock instead of the server clock used for interpolation. (#4135) - Issue with not being able to spawn initially disabled in-scene placed objects. (#4093) - Issue with pre-instantiated network prefab instances being marked as in-scene placed. Now pre-instantiated network prefabs are dynamically spawned. (#4093) - Issue where a user could spawn runtime created `NetworkObject` that has a GlobalObjectIdHash of zero. These are not valid instances and will no longer be allowed to spawn. (#4093) From 43ce4d95389aed3b41c8bef93c61f0082ab1146b Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Sun, 23 Aug 2026 16:58:56 -0500 Subject: [PATCH 6/8] 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. --- com.unity.netcode.gameobjects/CHANGELOG.md | 2 +- .../Runtime/Components/NetworkTransform.cs | 5 +- .../NetworkTransformTickLatencyTests.cs | 96 +++++++++++++++++++ .../NetworkTransformTickLatencyTests.cs.meta | 2 + 4 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformTickLatencyTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformTickLatencyTests.cs.meta diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 01f9f6ab42..db252b17dc 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -25,7 +25,7 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed - 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. (#4135) -- Issue where `NetworkTransform.GetTickLatencyInSeconds` returned a time based on the local clock instead of the server clock used for interpolation. (#4135) +- 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. (#4135) - Issue with not being able to spawn initially disabled in-scene placed objects. (#4093) - Issue with pre-instantiated network prefab instances being marked as in-scene placed. Now pre-instantiated network prefabs are dynamically spawned. (#4093) - Issue where a user could spawn runtime created `NetworkObject` that has a GlobalObjectIdHash of zero. These are not valid instances and will no longer be allowed to spawn. (#4093) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs index 8a183c8dd0..0d85dc89b0 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs @@ -4729,7 +4729,10 @@ internal static float GetTickLatencyInSeconds(NetworkManager networkManager) { if (networkManager.IsListening) { - return (float)networkManager.ServerTime.TimeTicksAgo(networkManager.NetworkTimeSystem.TickLatency + InterpolationBufferTickOffset).Time; + // The number of ticks the interpolators run behind, as a duration. This is not a point in time: + // it does not grow as the session runs. + var ticksBehind = networkManager.NetworkTimeSystem.TickLatency + InterpolationBufferTickOffset; + return (float)(ticksBehind * networkManager.ServerTime.FixedDeltaTimeAsDouble); } return 0f; } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformTickLatencyTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformTickLatencyTests.cs new file mode 100644 index 0000000000..6f3b48031e --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformTickLatencyTests.cs @@ -0,0 +1,96 @@ +using System.Collections; +using NUnit.Framework; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Validates that returns what it is documented to + /// return: the tick latency as a duration in seconds. + /// + /// + /// It previously returned TimeTicksAgo(...).Time, which is an absolute network timestamp rather than a + /// duration, so the value grew for as long as the session ran. + /// + internal class NetworkTransformTickLatencyTests : NetcodeIntegrationTest + { + protected override int NumberOfClients => 1; + + // Ticks of additional buffering applied part way through the test to confirm the returned duration + // tracks the tick latency it is derived from. + private const int k_AddedBufferTicks = 3; + + // Seconds of tolerance when comparing against the expected duration. + private const float k_Tolerance = 0.0005f; + + // The number of samples taken while the session runs, to confirm the value does not drift with time. + private const int k_Samples = 30; + + private int m_OriginalBufferTickOffset; + + protected override IEnumerator OnSetup() + { + m_OriginalBufferTickOffset = NetworkTransform.InterpolationBufferTickOffset; + return base.OnSetup(); + } + + protected override IEnumerator OnTearDown() + { + // This is static, so leaving it modified would leak into every test that runs afterwards. + NetworkTransform.InterpolationBufferTickOffset = m_OriginalBufferTickOffset; + return base.OnTearDown(); + } + + private static float GetExpectedLatencyInSeconds(NetworkManager networkManager) + { + var ticksBehind = networkManager.NetworkTimeSystem.TickLatency + NetworkTransform.InterpolationBufferTickOffset; + return (float)(ticksBehind * networkManager.ServerTime.FixedDeltaTimeAsDouble); + } + + [UnityTest] + public IEnumerator GetTickLatencyInSecondsReturnsADuration() + { + var client = m_ClientNetworkManagers[0]; + + // Sample repeatedly while the session clock advances. A duration tracks the tick latency and stays + // put, where an absolute timestamp would climb by roughly one second per second. + var firstSample = NetworkTransform.GetTickLatencyInSeconds(client); + for (int i = 0; i < k_Samples; i++) + { + var expected = GetExpectedLatencyInSeconds(client); + var actual = NetworkTransform.GetTickLatencyInSeconds(client); + Assert.AreEqual(expected, actual, k_Tolerance, + $"Expected the tick latency to be {expected}s but it was {actual}s."); + yield return null; + } + + var lastSample = NetworkTransform.GetTickLatencyInSeconds(client); + Assert.AreEqual(firstSample, lastSample, k_Tolerance, + $"The tick latency changed from {firstSample}s to {lastSample}s while the session ran without " + + $"the tick latency itself changing, so it is tracking elapsed time rather than latency."); + } + + [UnityTest] + public IEnumerator GetTickLatencyInSecondsTracksTheBufferTickOffset() + { + var client = m_ClientNetworkManagers[0]; + var tickInterval = (float)client.ServerTime.FixedDeltaTimeAsDouble; + + var before = NetworkTransform.GetTickLatencyInSeconds(client); + + // Buffering more ticks has to lengthen the reported duration by exactly those ticks. + NetworkTransform.InterpolationBufferTickOffset = m_OriginalBufferTickOffset + k_AddedBufferTicks; + yield return null; + + var after = NetworkTransform.GetTickLatencyInSeconds(client); + var expectedIncrease = k_AddedBufferTicks * tickInterval; + Assert.AreEqual(expectedIncrease, after - before, k_Tolerance, + $"Adding {k_AddedBufferTicks} ticks of buffering changed the reported latency by " + + $"{after - before}s when a tick is {tickInterval}s, so it should have changed by " + + $"{expectedIncrease}s."); + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformTickLatencyTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformTickLatencyTests.cs.meta new file mode 100644 index 0000000000..51de85178d --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformTickLatencyTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 36f3fd2271d60504cbf04455d7ad3908 \ No newline at end of file From e0a019158c08159d11983625f6af8a66d47b5964 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Sun, 23 Aug 2026 17:19:44 -0500 Subject: [PATCH 7/8] style up-porting style fix --- .../Runtime/NetworkTransform/NetworkTransformTickLatencyTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformTickLatencyTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformTickLatencyTests.cs index 6f3b48031e..cae4e58bf7 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformTickLatencyTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformTickLatencyTests.cs @@ -2,7 +2,6 @@ using NUnit.Framework; using Unity.Netcode.Components; using Unity.Netcode.TestHelpers.Runtime; -using UnityEngine; using UnityEngine.TestTools; namespace Unity.Netcode.RuntimeTests From d06eead1d5892248d69e16531ec5e08d0434215b Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Sun, 23 Aug 2026 18:02:21 -0500 Subject: [PATCH 8/8] 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. --- .../NetworkTransformTickLatencyTests.cs | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformTickLatencyTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformTickLatencyTests.cs index cae4e58bf7..02dc787dfb 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformTickLatencyTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformTickLatencyTests.cs @@ -54,22 +54,33 @@ public IEnumerator GetTickLatencyInSecondsReturnsADuration() { var client = m_ClientNetworkManagers[0]; - // Sample repeatedly while the session clock advances. A duration tracks the tick latency and stays - // put, where an absolute timestamp would climb by roughly one second per second. - var firstSample = NetworkTransform.GetTickLatencyInSeconds(client); + // Sample repeatedly while the session clock advances. A duration tracks the tick latency, where an + // absolute timestamp would climb by roughly one second per second. + // + // NetworkTimeSystem.TickLatency is adaptive and can legitimately change mid-run, so the value is + // only held to being unchanged across samples where the tick latency itself did not change. + var previousTicksBehind = -1; + var previousValue = 0f; for (int i = 0; i < k_Samples; i++) { + var ticksBehind = client.NetworkTimeSystem.TickLatency + NetworkTransform.InterpolationBufferTickOffset; var expected = GetExpectedLatencyInSeconds(client); var actual = NetworkTransform.GetTickLatencyInSeconds(client); + Assert.AreEqual(expected, actual, k_Tolerance, $"Expected the tick latency to be {expected}s but it was {actual}s."); + + if (ticksBehind == previousTicksBehind) + { + Assert.AreEqual(previousValue, actual, k_Tolerance, + $"The reported latency moved from {previousValue}s to {actual}s while the tick latency " + + $"stayed at {ticksBehind} ticks, so it is tracking elapsed time rather than latency."); + } + + previousTicksBehind = ticksBehind; + previousValue = actual; yield return null; } - - var lastSample = NetworkTransform.GetTickLatencyInSeconds(client); - Assert.AreEqual(firstSample, lastSample, k_Tolerance, - $"The tick latency changed from {firstSample}s to {lastSample}s while the session ran without " + - $"the tick latency itself changing, so it is tracking elapsed time rather than latency."); } [UnityTest] @@ -78,18 +89,23 @@ public IEnumerator GetTickLatencyInSecondsTracksTheBufferTickOffset() var client = m_ClientNetworkManagers[0]; var tickInterval = (float)client.ServerTime.FixedDeltaTimeAsDouble; + var latencyBefore = client.NetworkTimeSystem.TickLatency; var before = NetworkTransform.GetTickLatencyInSeconds(client); // Buffering more ticks has to lengthen the reported duration by exactly those ticks. NetworkTransform.InterpolationBufferTickOffset = m_OriginalBufferTickOffset + k_AddedBufferTicks; yield return null; + var latencyAfter = client.NetworkTimeSystem.TickLatency; var after = NetworkTransform.GetTickLatencyInSeconds(client); - var expectedIncrease = k_AddedBufferTicks * tickInterval; + + // The adaptive tick latency may also have moved in between, so only the buffering is held to an + // exact figure. + var expectedIncrease = (k_AddedBufferTicks + (latencyAfter - latencyBefore)) * tickInterval; Assert.AreEqual(expectedIncrease, after - before, k_Tolerance, $"Adding {k_AddedBufferTicks} ticks of buffering changed the reported latency by " + $"{after - before}s when a tick is {tickInterval}s, so it should have changed by " + - $"{expectedIncrease}s."); + $"{expectedIncrease}s (tick latency went from {latencyBefore} to {latencyAfter})."); } } }