From d53463f1d2151f81f4e1bb5b780988998ffcfae9 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Wed, 26 Aug 2026 22:47:40 -0500 Subject: [PATCH] test: Rewrite TransformInterpolationTests to remove its random failures Overhauling this test since it has been problematic for a long time. The root cause is that the values applied ended forming a discontinuously-linear/saw-tooth motion which makes it very problematic to distinguish a fault from an actual valid interpolation. The actual solution is to use a smoothed sinusoidal wave that forms a continuous motion that is discernable between a fault and the motion being applied. There were additional order of operation issues that --- .../Runtime/TransformInterpolationTests.cs | 311 +++++++++--------- 1 file changed, 161 insertions(+), 150 deletions(-) diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/TransformInterpolationTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/TransformInterpolationTests.cs index 4e29237a82..53fd7a48f6 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/TransformInterpolationTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/TransformInterpolationTests.cs @@ -5,230 +5,241 @@ using UnityEngine; using UnityEngine.TestTools; - namespace Unity.Netcode.RuntimeTests { + /// + /// Drives a continuous world space motion on the authority and samples the resulting world + /// space position on the non-authority. + /// + /// + /// The moving instance is parented to an object held at , + /// so the local and world space representations of the same point differ by ~1000 units. A + /// non-authority that applies a local space value as a world space value (or the reverse) lands + /// roughly that far from where it belongs, which is what the sampling below looks for. + /// internal class TransformInterpolationObject : NetworkTransform { - public static bool TestComplete = false; - // Set the minimum threshold which we will use as our margin of error -#if UNITY_EDITOR - public const float MinThreshold = 0.00555555f; -#else - // Add additional room for error on console tests - public const float MinThreshold = 0.009999f; -#endif - - private const int k_TargetLocalSpaceToggles = 10; + // The motion is a sine wave, which keeps the authority's position continuous. A + // discontinuous motion (i.e. a saw tooth) cannot be told apart from an interpolation fault, + // since both show up on the non-authority as a large single frame delta. + public const float Amplitude = 1.0f; + public const float MotionPeriod = 2.0f; + + // How far outside of the motion's band the non-authority is allowed to sit. Budget: + // - PositionThreshold, since the authority suppresses updates until it has moved that far. + // - Interpolation lag, as the non-authority renders several ticks behind the authority and + // so trails it by up to (peak speed * that time), the peak speed of the motion above + // being Amplitude * 2*pi / MotionPeriod. + // - A hitched frame under CI load, which adds another frame of trailing distance. + // A world/local space fault puts the instance ~1000 units out, so this stays three orders + // of magnitude below a real failure. + public const float PositionTolerance = 0.25f; + + // Deliberately not a factor of MotionPeriod, so the transitions land on a different point + // of the motion each time instead of repeatedly hitting the same phase. + private const float k_ToggleInterval = 0.35f; - public bool CheckPosition; public bool IsMoving; public bool IsFixed; + public bool CheckPosition; - private float m_FrameRateFractional; - private bool m_CurrentLocalSpace; - - private int m_LocalSpaceToggles; - private int m_LastFrameCount; - - public bool ReachedTargetLocalSpaceTransitionCount() - { - TestComplete = m_LocalSpaceToggles >= k_TargetLocalSpaceToggles; - return TestComplete; - } + public int LocalSpaceToggles; + public float MinimumSampled; + public float MaximumSampled; + public float WorstDeviation; + public int SamplesTaken; - protected override void OnInitialize(ref NetworkTransformState replicatedState) - { - m_LocalSpaceToggles = 0; - m_FrameRateFractional = 1.0f / Application.targetFrameRate; - PositionThreshold = MinThreshold; - SetMaxInterpolationBound(1.0f); - base.OnInitialize(ref replicatedState); - } - - private int m_StartFrameCount; + private bool m_IsToggling; + private double m_MotionStartTime; + private double m_NextToggleTime; public void StartMoving() { - m_StartFrameCount = Time.frameCount; + m_MotionStartTime = NetworkManager.LocalTime.Time; IsMoving = true; } - public void StopMoving() + /// + /// Kept separate from so that no transition happens while the + /// non-authority is still converging on the motion. + /// + public void StartToggling() { - IsMoving = false; + m_NextToggleTime = NetworkManager.LocalTime.Time + k_ToggleInterval; + m_IsToggling = true; } - private const int k_MaxThresholdFailures = 4; - private int m_ExceededThresholdCount; + public void StartSampling() + { + MinimumSampled = float.MaxValue; + MaximumSampled = float.MinValue; + WorstDeviation = 0.0f; + SamplesTaken = 0; + CheckPosition = true; + } + /// + /// Invoked by during + /// and only for non-authority instances, so the interpolated position for this frame has + /// already been applied by the time the sampling below runs. + /// public override void OnUpdate() { base.OnUpdate(); - - // Check the position of the nested object on the client - if (CheckPosition) + if (!CheckPosition) { - if (transform.position.y < -MinThreshold || transform.position.y > Application.targetFrameRate + MinThreshold) - { - // Temporary work around for this test. - // Really, this test needs to be completely re-written. - m_ExceededThresholdCount++; - // If we haven't corrected ourselves within the maximum number of updates then throw an error. - if (m_ExceededThresholdCount > k_MaxThresholdFailures) - { - Debug.LogError($"Interpolation failure. transform.position.y is {transform.position.y}. Should be between 0.0 and 100.0. Current threshold is [+/- {MinThreshold}]."); - } - } - else - { - // If corrected, then reset our count - m_ExceededThresholdCount = 0; - } + return; } + + var positionY = transform.position.y; + MinimumSampled = Mathf.Min(MinimumSampled, positionY); + MaximumSampled = Mathf.Max(MaximumSampled, positionY); + WorstDeviation = Mathf.Max(WorstDeviation, Mathf.Abs(positionY) - Amplitude); + SamplesTaken++; } + /// + /// Authority side motion. Authority instances are excluded from the update loop's + /// NetworkTransform registration, so this cannot live in . + /// + /// + /// Never invoke from here. It advances the interpolators by + /// , which the update loop already does once per frame for a + /// non-authority instance. + /// private void Update() { - base.OnUpdate(); - - if (!IsSpawned || !CanCommitToTransform || TestComplete) + if (!IsSpawned || !CanCommitToTransform) { return; } + if (IsFixed) + { + transform.position = TransformInterpolationTests.ParentPosition; + return; + } - // Move the nested object on the server - if (IsMoving) + if (!IsMoving) { - Assert.True(CanCommitToTransform, $"Using non-authority instance to update transform!"); - - if (m_LastFrameCount == Time.frameCount) - { - Debug.Log($"Detected duplicate frame update count {Time.frameCount}. Ignoring this update."); - return; - } - - m_LastFrameCount = Time.frameCount; - - // Leaving this here for reference. - // If a system is running at a slower frame rate than expected, then the below code could toggle - // the local to world space value at a higher frequency which might not provide enough updates to - // handle interpolating between the transitions. - //var y = Time.realtimeSinceStartup % 10.0f; - //// change the space between local and global every second - //GetComponent().InLocalSpace = ((int)y % 2 == 0); - - // Reduce the total frame count down to the frame rate - var y = (Time.frameCount - m_StartFrameCount) % Application.targetFrameRate; - - // change the space between local and global every time we hit the expected number of frames - // (or every second if running at the target frame rate) - InLocalSpace = y == 0 ? !InLocalSpace : InLocalSpace; - - if (m_CurrentLocalSpace != InLocalSpace) - { - m_LocalSpaceToggles++; - m_CurrentLocalSpace = InLocalSpace; - } - - transform.position = new Vector3(0.0f, (y * m_FrameRateFractional), 0.0f); + return; } - // On the server, make sure to keep the parent object at a fixed position - if (IsFixed) + var localTime = NetworkManager.LocalTime.Time; + + if (m_IsToggling && localTime >= m_NextToggleTime) { - Assert.True(CanCommitToTransform, $"Using non-authority instance to update transform!"); - transform.position = new Vector3(1000.0f, 1000.0f, 1000.0f); + InLocalSpace = !InLocalSpace; + LocalSpaceToggles++; + m_NextToggleTime += k_ToggleInterval; } + + var elapsed = (float)(localTime - m_MotionStartTime); + transform.position = new Vector3(0.0f, Amplitude * Mathf.Sin(elapsed * 2.0f * Mathf.PI / MotionPeriod), 0.0f); } } - internal class TransformInterpolationTests : NetcodeIntegrationTest + [TestFixture(HostOrServer.Host)] + [TestFixture(HostOrServer.DAHost)] + internal class TransformInterpolationTests : IntegrationTestWithApproximation { + internal static readonly Vector3 ParentPosition = new Vector3(1000.0f, 1000.0f, 1000.0f); + + private const int k_TargetLocalSpaceToggles = 10; + + // The non-authority has to cover most of the motion's range for the samples to mean + // anything. An instance that stopped updating sits at a single value and would otherwise + // satisfy every bounds check in the test. + private const float k_MinimumRangeCovered = 0.5f; + protected override int NumberOfClients => 1; private GameObject m_PrefabToSpawn; - private NetworkObject m_SpawnedAsNetworkObject; - private NetworkObject m_SpawnedObjectOnClient; - - private NetworkObject m_BaseAsNetworkObject; - private NetworkObject m_BaseOnClient; + private TransformInterpolationObject m_AuthorityParent; + private TransformInterpolationObject m_AuthorityChild; + private TransformInterpolationObject m_NonAuthorityChild; + public TransformInterpolationTests(HostOrServer hostOrServer) : base(hostOrServer) + { + } protected override void OnServerAndClientsCreated() { m_PrefabToSpawn = CreateNetworkObjectPrefab("InterpTestObject"); - var networkTransform = m_PrefabToSpawn.AddComponent(); + m_PrefabToSpawn.AddComponent(); } - private IEnumerator RefreshNetworkObjects() + private IEnumerator SpawnAndParent() { - var clientId = m_ClientNetworkManagers[0].LocalClientId; + var authority = GetAuthorityNetworkManager(); + var nonAuthority = GetNonAuthorityNetworkManager(); + + m_AuthorityParent = SpawnObject(m_PrefabToSpawn, authority).GetComponent(); + m_AuthorityChild = SpawnObject(m_PrefabToSpawn, authority).GetComponent(); + + var parentId = m_AuthorityParent.NetworkObject.NetworkObjectId; + var childId = m_AuthorityChild.NetworkObject.NetworkObjectId; + var clientId = nonAuthority.LocalClientId; + yield return WaitForConditionOrTimeOut(() => s_GlobalNetworkObjects.ContainsKey(clientId) && - s_GlobalNetworkObjects[clientId].ContainsKey(m_BaseAsNetworkObject.NetworkObjectId) && - s_GlobalNetworkObjects[clientId].ContainsKey(m_SpawnedAsNetworkObject.NetworkObjectId)); + s_GlobalNetworkObjects[clientId].ContainsKey(parentId) && + s_GlobalNetworkObjects[clientId].ContainsKey(childId)); + AssertOnTimeout($"Timed out waiting for the non-authority to spawn both {nameof(NetworkObject)}s!"); - Assert.False(s_GlobalTimeoutHelper.TimedOut, $"Timed out waiting for client side {nameof(NetworkObject)} ID of {m_SpawnedAsNetworkObject.NetworkObjectId}"); + m_NonAuthorityChild = s_GlobalNetworkObjects[clientId][childId].GetComponent(); - m_BaseOnClient = s_GlobalNetworkObjects[clientId][m_BaseAsNetworkObject.NetworkObjectId]; - // make sure the objects are set with the right network manager - m_BaseOnClient.NetworkManagerOwner = m_ClientNetworkManagers[0]; + Assert.True(m_AuthorityChild.NetworkObject.TrySetParent(m_AuthorityParent.NetworkObject), "Failed to parent the moving instance!"); - m_SpawnedObjectOnClient = s_GlobalNetworkObjects[clientId][m_SpawnedAsNetworkObject.NetworkObjectId]; - // make sure the objects are set with the right network manager - m_SpawnedObjectOnClient.NetworkManagerOwner = m_ClientNetworkManagers[0]; + yield return WaitForConditionOrTimeOut(() => m_NonAuthorityChild.transform.parent != null); + AssertOnTimeout("Timed out waiting for the non-authority instance to be parented!"); } [UnityTest] public IEnumerator TransformInterpolationTest() { - TransformInterpolationObject.TestComplete = false; - // create an object - var spawnedObject = Object.Instantiate(m_PrefabToSpawn); - var baseObject = Object.Instantiate(m_PrefabToSpawn); - baseObject.GetComponent().NetworkManagerOwner = m_ServerNetworkManager; - baseObject.GetComponent().Spawn(); + yield return SpawnAndParent(); - m_SpawnedAsNetworkObject = spawnedObject.GetComponent(); - m_SpawnedAsNetworkObject.NetworkManagerOwner = m_ServerNetworkManager; + m_AuthorityParent.IsFixed = true; - m_BaseAsNetworkObject = baseObject.GetComponent(); - m_BaseAsNetworkObject.NetworkManagerOwner = m_ServerNetworkManager; + // The child's world space position only means anything once the parent has settled on + // both instances, since the non-authority reconstructs it through its own parent. + var nonAuthorityParent = m_NonAuthorityChild.transform.parent; + yield return WaitForConditionOrTimeOut(() => Approximately(m_AuthorityParent.transform.position, ParentPosition) && + Approximately(nonAuthorityParent.position, ParentPosition)); + AssertOnTimeout($"Timed out waiting for the parent instances to settle at {ParentPosition}!"); - m_SpawnedAsNetworkObject.TrySetParent(baseObject); + m_AuthorityChild.StartMoving(); - m_SpawnedAsNetworkObject.Spawn(); + // The child rides its parent out at ~1000 until the motion starts, so wait for the + // non-authority to close that distance before sampling. Its approach to the first + // authoritative position is not what this test measures. + yield return WaitForConditionOrTimeOut(() => Mathf.Abs(m_NonAuthorityChild.transform.position.y) <= TransformInterpolationObject.Amplitude); + AssertOnTimeout("Timed out waiting for the non-authority instance to converge on the authority's motion!"); - yield return RefreshNetworkObjects(); + m_NonAuthorityChild.StartSampling(); + m_AuthorityChild.StartToggling(); - m_SpawnedAsNetworkObject.TrySetParent(baseObject); - var spawnedObjectNetworkTransform = spawnedObject.GetComponent(); - baseObject.GetComponent().IsFixed = true; - spawnedObject.GetComponent().StartMoving(); + var timeOutHelper = new TimeoutFrameCountHelper(10); + yield return WaitForConditionOrTimeOut(() => m_AuthorityChild.LocalSpaceToggles >= k_TargetLocalSpaceToggles, timeOutHelper); + m_NonAuthorityChild.CheckPosition = false; + AssertOnTimeout($"Failed to reach {k_TargetLocalSpaceToggles} local to world space transitions in the given time!", timeOutHelper); - const float maxPlacementError = 0.01f; + VerboseDebug($"[{nameof(TransformInterpolationTest)}] Toggles ({m_AuthorityChild.LocalSpaceToggles}) | Samples ({m_NonAuthorityChild.SamplesTaken}) | " + + $"Range ({m_NonAuthorityChild.MinimumSampled} to {m_NonAuthorityChild.MaximumSampled}) | Worst deviation ({m_NonAuthorityChild.WorstDeviation}) | " + + $"Frames ({timeOutHelper.GetFrameCount()}) | Elapsed ({timeOutHelper.GetTimeElapsed()})"); - // Wait for the base object to place itself on both instances - while (m_BaseOnClient.transform.position.y < 1000 - maxPlacementError || - m_BaseOnClient.transform.position.y > 1000 + maxPlacementError || - baseObject.transform.position.y < 1000 - maxPlacementError || - baseObject.transform.position.y > 1000 + maxPlacementError) - { - yield return new WaitForSeconds(0.01f); - } + Assert.Greater(m_NonAuthorityChild.SamplesTaken, 0, "The non-authority instance was never updated!"); - m_SpawnedObjectOnClient.GetComponent().CheckPosition = true; + Assert.LessOrEqual(m_NonAuthorityChild.WorstDeviation, TransformInterpolationObject.PositionTolerance, + $"The non-authority instance left the expected world space band of [+/- {TransformInterpolationObject.Amplitude}] by " + + $"{m_NonAuthorityChild.WorstDeviation}, which exceeds the tolerance of {TransformInterpolationObject.PositionTolerance}. " + + $"Sampled range was {m_NonAuthorityChild.MinimumSampled} to {m_NonAuthorityChild.MaximumSampled}."); - // Test that interpolation works correctly for ~10 seconds or 10 local to world space transitions while moving - // Increasing this duration gives you the opportunity to go check in the Editor how the objects are setup - // and how they move - var timeOutHelper = new TimeoutFrameCountHelper(10); - yield return WaitForConditionOrTimeOut(spawnedObjectNetworkTransform.ReachedTargetLocalSpaceTransitionCount, timeOutHelper); - VerboseDebug($"[TransformInterpolationTest] Wait condition reached or timed out. Frame Count ({timeOutHelper.GetFrameCount()}) | Time Elapsed ({timeOutHelper.GetTimeElapsed()})"); - AssertOnTimeout($"Failed to reach desired local to world space transitions in the given time!", timeOutHelper); + var rangeCovered = (m_NonAuthorityChild.MaximumSampled - m_NonAuthorityChild.MinimumSampled) / (2.0f * TransformInterpolationObject.Amplitude); + Assert.GreaterOrEqual(rangeCovered, k_MinimumRangeCovered, + $"The non-authority instance only covered {rangeCovered:P0} of the authority's motion, so it was not tracking it."); } } }