|
| 1 | +using System.Collections; |
| 2 | +using System.Collections.Generic; |
| 3 | +using NUnit.Framework; |
| 4 | +using Unity.Netcode.Components; |
| 5 | +using Unity.Netcode.TestHelpers.Runtime; |
| 6 | +using UnityEngine; |
| 7 | +using UnityEngine.TestTools; |
| 8 | + |
| 9 | +namespace Unity.Netcode.RuntimeTests |
| 10 | +{ |
| 11 | + /// <summary> |
| 12 | + /// Validates that <see cref="NetworkTransform.UseHalfFloatPrecision"/> does not introduce motion of its own. |
| 13 | + /// </summary> |
| 14 | + /// <remarks> |
| 15 | + /// Both tests move the authority in one direction only and require non-authority instances to follow without |
| 16 | + /// ever moving backwards. Interpolation cannot overshoot, so any movement opposite to the authority's has to |
| 17 | + /// have come from how the position was encoded rather than from the authority. |
| 18 | + /// <br /><br /> |
| 19 | + /// These do not use the time travel harness because the behavior only appears over multiple real state update |
| 20 | + /// and interpolation cycles. |
| 21 | + /// </remarks> |
| 22 | + [TestFixture(HostOrServer.Host)] |
| 23 | + [TestFixture(HostOrServer.DAHost)] |
| 24 | + internal class NetworkTransformHalfFloatPrecisionTests : IntegrationTestWithApproximation |
| 25 | + { |
| 26 | + protected override int NumberOfClients => 1; |
| 27 | + |
| 28 | + /// <summary> |
| 29 | + /// How far the object travels before the position is checked. |
| 30 | + /// </summary> |
| 31 | + /// <remarks> |
| 32 | + /// Half float resolution gets coarser the further the object is from the base position established when it |
| 33 | + /// spawned, so the object has to travel away from that base for the resolution to be worth testing. |
| 34 | + /// </remarks> |
| 35 | + private const float k_TravelDistance = 30.0f; |
| 36 | + |
| 37 | + private const float k_TravelStep = 1.5f; |
| 38 | + |
| 39 | + // Moves the object off a position that a half float can represent exactly, which is a position that leaves |
| 40 | + // no rounding loss behind and so cannot show the problem being tested for. |
| 41 | + private const float k_UnrepresentableOffset = 0.0007f; |
| 42 | + |
| 43 | + // Small enough per update that the encoding cannot represent the change on its own. |
| 44 | + private const float k_CreepStep = 0.0005f; |
| 45 | + |
| 46 | + private const int k_CreepTicks = 60; |
| 47 | + |
| 48 | + // Tolerated backwards movement, which is float noise only. Well below the roughly 1mm resolution. |
| 49 | + private const float k_MonotonicEpsilon = 1e-5f; |
| 50 | + |
| 51 | + private GameObject m_TestPrefab; |
| 52 | + private NetworkManager m_AuthorityNetworkManager; |
| 53 | + private NetworkTransform m_AuthorityInstance; |
| 54 | + private readonly List<NetworkTransform> m_NonAuthorityInstances = new List<NetworkTransform>(); |
| 55 | + |
| 56 | + private readonly Dictionary<NetworkTransform, float> m_WorstRegression = new Dictionary<NetworkTransform, float>(); |
| 57 | + private readonly Dictionary<NetworkTransform, float> m_LastObserved = new Dictionary<NetworkTransform, float>(); |
| 58 | + |
| 59 | + private int m_TicksApplied; |
| 60 | + private float m_StepThisPhase; |
| 61 | + |
| 62 | + public NetworkTransformHalfFloatPrecisionTests(HostOrServer hostOrServer) : base(hostOrServer) |
| 63 | + { |
| 64 | + } |
| 65 | + |
| 66 | + // TODO: [CmbServiceTests] Validate this against the service once half float precision is covered there. |
| 67 | + protected override bool UseCMBService() |
| 68 | + { |
| 69 | + return false; |
| 70 | + } |
| 71 | + |
| 72 | + protected override void OnServerAndClientsCreated() |
| 73 | + { |
| 74 | + m_TestPrefab = CreateNetworkObjectPrefab("HalfFloatObj"); |
| 75 | + var networkTransform = m_TestPrefab.AddComponent<NetworkTransform>(); |
| 76 | + |
| 77 | + networkTransform.UseHalfFloatPrecision = true; |
| 78 | + networkTransform.Interpolate = true; |
| 79 | + |
| 80 | + // Lerp smoothing would filter out the movement being tested for. |
| 81 | + networkTransform.PositionInterpolationType = NetworkTransform.InterpolationTypes.Lerp; |
| 82 | + networkTransform.PositionLerpSmoothing = false; |
| 83 | + |
| 84 | + // No threshold, so the very small movements used below are actually sent. |
| 85 | + networkTransform.PositionThreshold = 0.0f; |
| 86 | + |
| 87 | + networkTransform.SyncRotAngleX = false; |
| 88 | + networkTransform.SyncRotAngleY = false; |
| 89 | + networkTransform.SyncRotAngleZ = false; |
| 90 | + networkTransform.SyncScaleX = false; |
| 91 | + networkTransform.SyncScaleY = false; |
| 92 | + networkTransform.SyncScaleZ = false; |
| 93 | + |
| 94 | + base.OnServerAndClientsCreated(); |
| 95 | + } |
| 96 | + |
| 97 | + private bool AllInstancesSpawned() |
| 98 | + { |
| 99 | + m_NonAuthorityInstances.Clear(); |
| 100 | + foreach (var networkManager in m_NetworkManagers) |
| 101 | + { |
| 102 | + if (networkManager == m_AuthorityNetworkManager) |
| 103 | + { |
| 104 | + continue; |
| 105 | + } |
| 106 | + |
| 107 | + if (!networkManager.SpawnManager.SpawnedObjects.ContainsKey(m_AuthorityInstance.NetworkObjectId)) |
| 108 | + { |
| 109 | + return false; |
| 110 | + } |
| 111 | + |
| 112 | + m_NonAuthorityInstances.Add(networkManager.SpawnManager.SpawnedObjects[m_AuthorityInstance.NetworkObjectId].GetComponent<NetworkTransform>()); |
| 113 | + } |
| 114 | + return m_NonAuthorityInstances.Count > 0; |
| 115 | + } |
| 116 | + |
| 117 | + private bool AllInstancesCaughtUp() |
| 118 | + { |
| 119 | + foreach (var nonAuthority in m_NonAuthorityInstances) |
| 120 | + { |
| 121 | + if (!Approximately(nonAuthority.transform.position, m_AuthorityInstance.transform.position)) |
| 122 | + { |
| 123 | + return false; |
| 124 | + } |
| 125 | + } |
| 126 | + return true; |
| 127 | + } |
| 128 | + |
| 129 | + /// <summary> |
| 130 | + /// Records any movement opposite to the direction the authority is moving. |
| 131 | + /// </summary> |
| 132 | + /// <remarks> |
| 133 | + /// Sampled once per frame rather than once per tick, since the position applied to the transform is what |
| 134 | + /// needs to be checked. |
| 135 | + /// </remarks> |
| 136 | + private void SampleForRegression() |
| 137 | + { |
| 138 | + foreach (var nonAuthority in m_NonAuthorityInstances) |
| 139 | + { |
| 140 | + var current = nonAuthority.transform.position.x; |
| 141 | + if (m_LastObserved.TryGetValue(nonAuthority, out var previous)) |
| 142 | + { |
| 143 | + var regression = previous - current; |
| 144 | + if (regression > m_WorstRegression[nonAuthority]) |
| 145 | + { |
| 146 | + m_WorstRegression[nonAuthority] = regression; |
| 147 | + } |
| 148 | + } |
| 149 | + m_LastObserved[nonAuthority] = current; |
| 150 | + } |
| 151 | + } |
| 152 | + |
| 153 | + private void BeginSampling() |
| 154 | + { |
| 155 | + m_WorstRegression.Clear(); |
| 156 | + m_LastObserved.Clear(); |
| 157 | + foreach (var nonAuthority in m_NonAuthorityInstances) |
| 158 | + { |
| 159 | + m_WorstRegression.Add(nonAuthority, 0.0f); |
| 160 | + m_LastObserved.Add(nonAuthority, nonAuthority.transform.position.x); |
| 161 | + } |
| 162 | + } |
| 163 | + |
| 164 | + private void AssertNoRegression(string phase) |
| 165 | + { |
| 166 | + foreach (var entry in m_WorstRegression) |
| 167 | + { |
| 168 | + Assert.LessOrEqual(entry.Value, k_MonotonicEpsilon, |
| 169 | + $"[{phase}] {entry.Key.NetworkManager.name} moved {entry.Value} backwards along X while the " + |
| 170 | + $"authority only ever moved forwards. Interpolation cannot overshoot, so this motion was " + |
| 171 | + $"introduced by the half float position encoding rather than reproduced from the authority."); |
| 172 | + } |
| 173 | + } |
| 174 | + |
| 175 | + /// <summary> |
| 176 | + /// Advances the authority one step per tick along +X. |
| 177 | + /// </summary> |
| 178 | + /// <remarks> |
| 179 | + /// Driven from the tick event so the position written is the one captured for that same tick. |
| 180 | + /// </remarks> |
| 181 | + private void OnNetworkTick() |
| 182 | + { |
| 183 | + m_TicksApplied++; |
| 184 | + var position = m_AuthorityInstance.transform.position; |
| 185 | + position.x += m_StepThisPhase; |
| 186 | + m_AuthorityInstance.transform.position = position; |
| 187 | + } |
| 188 | + |
| 189 | + private IEnumerator DriveAuthority(float stepPerTick, int ticks) |
| 190 | + { |
| 191 | + m_TicksApplied = 0; |
| 192 | + m_StepThisPhase = stepPerTick; |
| 193 | + m_AuthorityNetworkManager.NetworkTickSystem.Tick += OnNetworkTick; |
| 194 | + yield return WaitForConditionOrTimeOut(() => m_TicksApplied >= ticks); |
| 195 | + m_AuthorityNetworkManager.NetworkTickSystem.Tick -= OnNetworkTick; |
| 196 | + AssertOnTimeout($"Timed out waiting for {ticks} authority updates (applied {m_TicksApplied})."); |
| 197 | + } |
| 198 | + |
| 199 | + /// <summary> |
| 200 | + /// Moves an object away from its base position and then moves it forward in very small steps, requiring |
| 201 | + /// every non-authority instance to follow without ever moving backwards. |
| 202 | + /// </summary> |
| 203 | + /// <returns>An <see cref="IEnumerator"/> for the test coroutine.</returns> |
| 204 | + [UnityTest] |
| 205 | + public IEnumerator HalfFloatPrecisionDoesNotInvertMotion() |
| 206 | + { |
| 207 | + m_AuthorityNetworkManager = GetAuthorityNetworkManager(); |
| 208 | + m_AuthorityInstance = SpawnObject(m_TestPrefab, m_AuthorityNetworkManager).GetComponent<NetworkTransform>(); |
| 209 | + |
| 210 | + yield return WaitForConditionOrTimeOut(AllInstancesSpawned); |
| 211 | + AssertOnTimeout($"Not all clients spawned {m_AuthorityInstance.name}!"); |
| 212 | + |
| 213 | + var travelTicks = (int)(k_TravelDistance / k_TravelStep); |
| 214 | + yield return DriveAuthority(k_TravelStep, travelTicks); |
| 215 | + |
| 216 | + yield return WaitForConditionOrTimeOut(AllInstancesCaughtUp); |
| 217 | + AssertOnTimeout("Non-authority instances did not catch up to the authority after the travel phase."); |
| 218 | + |
| 219 | + BeginSampling(); |
| 220 | + m_TicksApplied = 0; |
| 221 | + m_StepThisPhase = k_CreepStep; |
| 222 | + m_AuthorityNetworkManager.NetworkTickSystem.Tick += OnNetworkTick; |
| 223 | + while (m_TicksApplied < k_CreepTicks) |
| 224 | + { |
| 225 | + SampleForRegression(); |
| 226 | + yield return null; |
| 227 | + } |
| 228 | + m_AuthorityNetworkManager.NetworkTickSystem.Tick -= OnNetworkTick; |
| 229 | + |
| 230 | + // Keep sampling while the last sent states are still being interpolated. |
| 231 | + for (var i = 0; i < 30; i++) |
| 232 | + { |
| 233 | + SampleForRegression(); |
| 234 | + yield return null; |
| 235 | + } |
| 236 | + |
| 237 | + AssertNoRegression("creep"); |
| 238 | + |
| 239 | + // Small movements still have to arrive rather than be discarded. |
| 240 | + yield return WaitForConditionOrTimeOut(AllInstancesCaughtUp); |
| 241 | + AssertOnTimeout($"Non-authority instances did not converge on the authority position " + |
| 242 | + $"{m_AuthorityInstance.transform.position} after creeping, which means slow motion is being " + |
| 243 | + $"discarded rather than transmitted."); |
| 244 | + } |
| 245 | + |
| 246 | + /// <summary> |
| 247 | + /// Requires a stationary authority to produce a stationary non-authority. |
| 248 | + /// </summary> |
| 249 | + /// <returns>An <see cref="IEnumerator"/> for the test coroutine.</returns> |
| 250 | + [UnityTest] |
| 251 | + public IEnumerator HalfFloatPrecisionHoldsStillWhenStationary() |
| 252 | + { |
| 253 | + m_AuthorityNetworkManager = GetAuthorityNetworkManager(); |
| 254 | + m_AuthorityInstance = SpawnObject(m_TestPrefab, m_AuthorityNetworkManager).GetComponent<NetworkTransform>(); |
| 255 | + |
| 256 | + yield return WaitForConditionOrTimeOut(AllInstancesSpawned); |
| 257 | + AssertOnTimeout($"Not all clients spawned {m_AuthorityInstance.name}!"); |
| 258 | + |
| 259 | + var travelTicks = (int)(k_TravelDistance / k_TravelStep); |
| 260 | + yield return DriveAuthority(k_TravelStep, travelTicks); |
| 261 | + |
| 262 | + // A position that a half float happens to represent exactly leaves no rounding loss behind, and with |
| 263 | + // no rounding loss there is nothing that could move the object. Offsetting by less than the encoding |
| 264 | + // can represent guarantees there is some, which is the state a settling object is normally left in. |
| 265 | + yield return DriveAuthority(k_UnrepresentableOffset, 1); |
| 266 | + |
| 267 | + yield return WaitForConditionOrTimeOut(AllInstancesCaughtUp); |
| 268 | + AssertOnTimeout("Non-authority instances did not catch up to the authority after the travel phase."); |
| 269 | + |
| 270 | + // Nothing moves for the rest of the test, so the authority's last direction was forwards. Checking for |
| 271 | + // backwards movement rather than for drift from a starting point means the instances are still free to |
| 272 | + // finish interpolating towards the authority without that counting against them. |
| 273 | + BeginSampling(); |
| 274 | + for (var i = 0; i < 120; i++) |
| 275 | + { |
| 276 | + SampleForRegression(); |
| 277 | + yield return null; |
| 278 | + } |
| 279 | + |
| 280 | + AssertNoRegression("stationary"); |
| 281 | + } |
| 282 | + } |
| 283 | +} |
0 commit comments