From 1854d2d473f055dcf1fa02c6ec5c3a029249befe Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 18 Aug 2026 15:18:36 -0500 Subject: [PATCH 1/5] fix: half float position encoding manufactures motion on resting objects NetworkDeltaPosition carries the half float rounding loss of each update into the next one, which keeps the average transmitted position accurate while a value is moving. The loss alone is enough to change the encoded delta, so once the value stops moving that mechanism keeps changing what is sent even though the position has not moved. The encoded value alternates between neighbouring representable values and a stationary object is transmitted as one that oscillates. The rounding loss is now only carried forward while the value moves by at least one representable step. MaxDeltaBeforeAdjustment also determined the transmitted resolution, since a half float's step size grows with its magnitude. At 64 the coarsest step was 31.25mm, so objects away from their base position were reproduced in ~3cm increments. At 2 it is 0.977mm. Folding the delta into the base more often costs no bandwidth with reliable deltas because both sides apply the same rule to the same value, and the reconstructed position is unchanged by the fold. UseUnreliableDeltas forces a full precision base synchronization per fold, so those projects will send those more often. Measured on 10 settling physics objects with half float enabled: 28-42mm of oscillation before, none after, matching the same scene with half float disabled. Objects in motion improve as well, peak error dropping from 12.5mm to 0.587mm. Sender and receiver must agree on MaxDeltaBeforeAdjustment, so this is not compatible across builds. NetworkConstants.PROTOCOL_VERSION already participates in the connection config hash, so mismatched versions cannot connect. Co-Authored-By: Claude Opus 5 --- com.unity.netcode.gameobjects/CHANGELOG.md | 7 ++- .../Components/NetworkDeltaPosition.cs | 48 +++++++++++++++++-- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 85157fe510..012778e72e 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -13,22 +13,21 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Changed +- Changed `NetworkTransform.UseHalfFloatPrecision` to synchronize position with a resolution of approximately 1mm regardless of how far an object has travelled. Previously the resolution could degrade to approximately 3cm. This does not increase bandwidth, but projects using `NetworkTransform.UseUnreliableDeltas` will send full precision position updates more often. + - All editor assembly definitions are renamed with `Unity.Netcode.GameObjects.x` variants - `Unity.Netcode.Editor` → `Unity.Netcode.GameObjects.Editor` - `Unity.Netcode.Editor.CodeGen` → `Unity.Netcode.GameObjects.Editor.CodeGen` - `Unity.Netcode.Editor.PackageChecker` → `Unity.Netcode.GameObjects.Editor.PackageChecker` - `Unity.Netcode.Editor.Tests` → `Unity.Netcode.GameObjects.Editor.Tests` - - ### Deprecated - ### Removed - ### Fixed +- Issue where objects using `NetworkTransform.UseHalfFloatPrecision` appeared to jitter on non-authority instances while they were stationary or coming to rest, even though the authority was not moving them. - 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/NetworkDeltaPosition.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkDeltaPosition.cs index a780a07230..3bae7eded3 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkDeltaPosition.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkDeltaPosition.cs @@ -11,7 +11,14 @@ namespace Unity.Netcode.Components [Serializable] public struct NetworkDeltaPosition : INetworkSerializable { - internal const float MaxDeltaBeforeAdjustment = 64f; + /// + /// How far the delta may grow before it is folded into the base position. + /// + /// + /// This determines the transmitted position resolution, since a half float's step size grows with its + /// magnitude. Keeping the delta small keeps that step small: at 2 the coarsest step is roughly 1mm. + /// + internal const float MaxDeltaBeforeAdjustment = 2f; /// /// The HalfVector3 used to synchronize the delta in position @@ -138,14 +145,29 @@ public void UpdateFrom(ref Vector3 vector3, int networkTick) { CollapsedDeltaIntoBase = false; NetworkTick = networkTick; - DeltaPosition = (vector3 + PrecisionLossDelta) - CurrentBasePosition; for (int i = 0; i < HalfVector3.Length; i++) { if (HalfVector3.AxisToSynchronize[i]) { + var rawDelta = vector3[i] - CurrentBasePosition[i]; + + // Adding the previous rounding loss back in keeps the average position accurate while the + // value is moving, but it also changes the value being sent. Once the value stops moving + // that is all it does, which makes a stationary object appear to oscillate. + var movedSinceLastSend = Mathf.Abs(vector3[i] - PreviousPosition[i]); + var applyPrecisionLoss = movedSinceLastSend >= HalfPrecisionQuantum(rawDelta); + + DeltaPosition[i] = applyPrecisionLoss ? rawDelta + PrecisionLossDelta[i] : rawDelta; + HalfVector3.Axis[i] = math.half(DeltaPosition[i]); HalfDeltaConvertedBack[i] = Mathf.HalfToFloat(HalfVector3.Axis[i].value); - PrecisionLossDelta[i] = DeltaPosition[i] - HalfDeltaConvertedBack[i]; + + // Left unchanged when skipped so it is still applied once movement resumes. + if (applyPrecisionLoss) + { + PrecisionLossDelta[i] = DeltaPosition[i] - HalfDeltaConvertedBack[i]; + } + if (Mathf.Abs(HalfDeltaConvertedBack[i]) >= MaxDeltaBeforeAdjustment) { CurrentBasePosition[i] += HalfDeltaConvertedBack[i]; @@ -165,6 +187,26 @@ public void UpdateFrom(ref Vector3 vector3, int networkTick) } } + /// + /// The smallest change a half float can represent at the magnitude of the value passed in. + /// + /// The value to get the step size for. + /// The distance to the next representable half float value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static float HalfPrecisionQuantum(float value) + { + // The step size is symmetric about zero, so the sign is dropped. + var magnitude = (ushort)(math.half(value).value & 0x7FFF); + + // Guard only: stepping past the largest finite half float would give infinity. + if (magnitude >= 0x7BFF) + { + return MaxDeltaBeforeAdjustment; + } + + return Mathf.HalfToFloat((ushort)(magnitude + 1)) - Mathf.HalfToFloat(magnitude); + } + /// /// Constructor /// From 20d9ccddae33400ab60d3544974d40d38f404486 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 18 Aug 2026 15:18:53 -0500 Subject: [PATCH 2/5] test: integration coverage for half float position encoding Two NetcodeIntegrationTest cases, one for an object moving in steps too small for the encoding to represent and one for an object at rest. Both move the authority forwards only and require non-authority instances to follow without ever moving backwards. Interpolation cannot overshoot, so movement opposite to the authority's has to have come from the encoding. That also avoids a tolerance that would need revisiting whenever the resolution changes. Two setup details are needed for these to detect anything. The object has to travel away from the base position established when it spawned, since resolution is fine near the base. It then has to step by an amount the encoding cannot represent before coming to rest, because a position a half float represents exactly leaves no rounding loss and so cannot exhibit the problem: resting on 30.0 produces no backwards movement at all while resting on 30.0007 produces 15.6mm. Verified in both directions. Without the fix all four cases fail on the intended assertion, reporting 7.9mm to 10.1mm of backwards movement. With the fix all four pass. These do not use the time travel harness because the behavior only appears over multiple real state update and interpolation cycles. Co-Authored-By: Claude Opus 5 --- ...NetworkTransformHalfFloatPrecisionTests.cs | 283 ++++++++++++++++++ ...rkTransformHalfFloatPrecisionTests.cs.meta | 11 + 2 files changed, 294 insertions(+) create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatPrecisionTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatPrecisionTests.cs.meta diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatPrecisionTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatPrecisionTests.cs new file mode 100644 index 0000000000..5f7adc2319 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatPrecisionTests.cs @@ -0,0 +1,283 @@ +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 does not introduce motion of its own. + /// + /// + /// Both tests move the authority in one direction only and require non-authority instances to follow without + /// ever moving backwards. Interpolation cannot overshoot, so any movement opposite to the authority's has to + /// have come from how the position was encoded rather than from the authority. + ///

+ /// These do not use the time travel harness because the behavior only appears over multiple real state update + /// and interpolation cycles. + ///
+ [TestFixture(HostOrServer.Host)] + [TestFixture(HostOrServer.DAHost)] + internal class NetworkTransformHalfFloatPrecisionTests : IntegrationTestWithApproximation + { + protected override int NumberOfClients => 1; + + /// + /// How far the object travels before the position is checked. + /// + /// + /// Half float resolution gets coarser the further the object is from the base position established when it + /// spawned, so the object has to travel away from that base for the resolution to be worth testing. + /// + private const float k_TravelDistance = 30.0f; + + private const float k_TravelStep = 1.5f; + + // Moves the object off a position that a half float can represent exactly, which is a position that leaves + // no rounding loss behind and so cannot show the problem being tested for. + private const float k_UnrepresentableOffset = 0.0007f; + + // Small enough per update that the encoding cannot represent the change on its own. + private const float k_CreepStep = 0.0005f; + + private const int k_CreepTicks = 60; + + // Tolerated backwards movement, which is float noise only. Well below the roughly 1mm resolution. + private const float k_MonotonicEpsilon = 1e-5f; + + private GameObject m_TestPrefab; + private NetworkManager m_AuthorityNetworkManager; + private NetworkTransform m_AuthorityInstance; + private readonly List m_NonAuthorityInstances = new List(); + + private readonly Dictionary m_WorstRegression = new Dictionary(); + private readonly Dictionary m_LastObserved = new Dictionary(); + + private int m_TicksApplied; + private float m_StepThisPhase; + + public NetworkTransformHalfFloatPrecisionTests(HostOrServer hostOrServer) : base(hostOrServer) + { + } + + // TODO: [CmbServiceTests] Validate this against the service once half float precision is covered there. + protected override bool UseCMBService() + { + return false; + } + + protected override void OnServerAndClientsCreated() + { + m_TestPrefab = CreateNetworkObjectPrefab("HalfFloatObj"); + var networkTransform = m_TestPrefab.AddComponent(); + + networkTransform.UseHalfFloatPrecision = true; + networkTransform.Interpolate = true; + + // Lerp smoothing would filter out the movement being tested for. + networkTransform.PositionInterpolationType = NetworkTransform.InterpolationTypes.Lerp; + networkTransform.PositionLerpSmoothing = false; + + // No threshold, so the very small movements used below are actually sent. + networkTransform.PositionThreshold = 0.0f; + + networkTransform.SyncRotAngleX = false; + networkTransform.SyncRotAngleY = false; + networkTransform.SyncRotAngleZ = false; + networkTransform.SyncScaleX = false; + networkTransform.SyncScaleY = false; + networkTransform.SyncScaleZ = false; + + base.OnServerAndClientsCreated(); + } + + private bool AllInstancesSpawned() + { + m_NonAuthorityInstances.Clear(); + foreach (var networkManager in m_NetworkManagers) + { + if (networkManager == m_AuthorityNetworkManager) + { + continue; + } + + if (!networkManager.SpawnManager.SpawnedObjects.ContainsKey(m_AuthorityInstance.NetworkObjectId)) + { + return false; + } + + m_NonAuthorityInstances.Add(networkManager.SpawnManager.SpawnedObjects[m_AuthorityInstance.NetworkObjectId].GetComponent()); + } + return m_NonAuthorityInstances.Count > 0; + } + + private bool AllInstancesCaughtUp() + { + foreach (var nonAuthority in m_NonAuthorityInstances) + { + if (!Approximately(nonAuthority.transform.position, m_AuthorityInstance.transform.position)) + { + return false; + } + } + return true; + } + + /// + /// Records any movement opposite to the direction the authority is moving. + /// + /// + /// Sampled once per frame rather than once per tick, since the position applied to the transform is what + /// needs to be checked. + /// + private void SampleForRegression() + { + foreach (var nonAuthority in m_NonAuthorityInstances) + { + var current = nonAuthority.transform.position.x; + if (m_LastObserved.TryGetValue(nonAuthority, out var previous)) + { + var regression = previous - current; + if (regression > m_WorstRegression[nonAuthority]) + { + m_WorstRegression[nonAuthority] = regression; + } + } + m_LastObserved[nonAuthority] = current; + } + } + + private void BeginSampling() + { + m_WorstRegression.Clear(); + m_LastObserved.Clear(); + foreach (var nonAuthority in m_NonAuthorityInstances) + { + m_WorstRegression.Add(nonAuthority, 0.0f); + m_LastObserved.Add(nonAuthority, nonAuthority.transform.position.x); + } + } + + private void AssertNoRegression(string phase) + { + foreach (var entry in m_WorstRegression) + { + Assert.LessOrEqual(entry.Value, k_MonotonicEpsilon, + $"[{phase}] {entry.Key.NetworkManager.name} moved {entry.Value} backwards along X while the " + + $"authority only ever moved forwards. Interpolation cannot overshoot, so this motion was " + + $"introduced by the half float position encoding rather than reproduced from the authority."); + } + } + + /// + /// Advances the authority one step per tick along +X. + /// + /// + /// Driven from the tick event so the position written is the one captured for that same tick. + /// + private void OnNetworkTick() + { + m_TicksApplied++; + var position = m_AuthorityInstance.transform.position; + position.x += m_StepThisPhase; + m_AuthorityInstance.transform.position = position; + } + + private IEnumerator DriveAuthority(float stepPerTick, int ticks) + { + m_TicksApplied = 0; + m_StepThisPhase = stepPerTick; + m_AuthorityNetworkManager.NetworkTickSystem.Tick += OnNetworkTick; + yield return WaitForConditionOrTimeOut(() => m_TicksApplied >= ticks); + m_AuthorityNetworkManager.NetworkTickSystem.Tick -= OnNetworkTick; + AssertOnTimeout($"Timed out waiting for {ticks} authority updates (applied {m_TicksApplied})."); + } + + /// + /// Moves an object away from its base position and then moves it forward in very small steps, requiring + /// every non-authority instance to follow without ever moving backwards. + /// + /// An for the test coroutine. + [UnityTest] + public IEnumerator HalfFloatPrecisionDoesNotInvertMotion() + { + m_AuthorityNetworkManager = GetAuthorityNetworkManager(); + m_AuthorityInstance = SpawnObject(m_TestPrefab, m_AuthorityNetworkManager).GetComponent(); + + yield return WaitForConditionOrTimeOut(AllInstancesSpawned); + AssertOnTimeout($"Not all clients spawned {m_AuthorityInstance.name}!"); + + var travelTicks = (int)(k_TravelDistance / k_TravelStep); + yield return DriveAuthority(k_TravelStep, travelTicks); + + yield return WaitForConditionOrTimeOut(AllInstancesCaughtUp); + AssertOnTimeout("Non-authority instances did not catch up to the authority after the travel phase."); + + BeginSampling(); + m_TicksApplied = 0; + m_StepThisPhase = k_CreepStep; + m_AuthorityNetworkManager.NetworkTickSystem.Tick += OnNetworkTick; + while (m_TicksApplied < k_CreepTicks) + { + SampleForRegression(); + yield return null; + } + m_AuthorityNetworkManager.NetworkTickSystem.Tick -= OnNetworkTick; + + // Keep sampling while the last sent states are still being interpolated. + for (var i = 0; i < 30; i++) + { + SampleForRegression(); + yield return null; + } + + AssertNoRegression("creep"); + + // Small movements still have to arrive rather than be discarded. + yield return WaitForConditionOrTimeOut(AllInstancesCaughtUp); + AssertOnTimeout($"Non-authority instances did not converge on the authority position " + + $"{m_AuthorityInstance.transform.position} after creeping, which means slow motion is being " + + $"discarded rather than transmitted."); + } + + /// + /// Requires a stationary authority to produce a stationary non-authority. + /// + /// An for the test coroutine. + [UnityTest] + public IEnumerator HalfFloatPrecisionHoldsStillWhenStationary() + { + m_AuthorityNetworkManager = GetAuthorityNetworkManager(); + m_AuthorityInstance = SpawnObject(m_TestPrefab, m_AuthorityNetworkManager).GetComponent(); + + yield return WaitForConditionOrTimeOut(AllInstancesSpawned); + AssertOnTimeout($"Not all clients spawned {m_AuthorityInstance.name}!"); + + var travelTicks = (int)(k_TravelDistance / k_TravelStep); + yield return DriveAuthority(k_TravelStep, travelTicks); + + // A position that a half float happens to represent exactly leaves no rounding loss behind, and with + // no rounding loss there is nothing that could move the object. Offsetting by less than the encoding + // can represent guarantees there is some, which is the state a settling object is normally left in. + yield return DriveAuthority(k_UnrepresentableOffset, 1); + + yield return WaitForConditionOrTimeOut(AllInstancesCaughtUp); + AssertOnTimeout("Non-authority instances did not catch up to the authority after the travel phase."); + + // Nothing moves for the rest of the test, so the authority's last direction was forwards. Checking for + // backwards movement rather than for drift from a starting point means the instances are still free to + // finish interpolating towards the authority without that counting against them. + BeginSampling(); + for (var i = 0; i < 120; i++) + { + SampleForRegression(); + yield return null; + } + + AssertNoRegression("stationary"); + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatPrecisionTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatPrecisionTests.cs.meta new file mode 100644 index 0000000000..b380ca173d --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatPrecisionTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9130626724ce4dddcfcd533d630aa6b3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 03966e55ddc3ad5d2e804f8a3aad078ccae5690d Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 18 Aug 2026 20:35:12 -0500 Subject: [PATCH 3/5] Update changelog for NetworkTransform precision change Updated the changelog to include issue #4129 regarding the precision of NetworkTransform synchronization. --- com.unity.netcode.gameobjects/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 012778e72e..0ac22a7cf2 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -13,7 +13,7 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Changed -- Changed `NetworkTransform.UseHalfFloatPrecision` to synchronize position with a resolution of approximately 1mm regardless of how far an object has travelled. Previously the resolution could degrade to approximately 3cm. This does not increase bandwidth, but projects using `NetworkTransform.UseUnreliableDeltas` will send full precision position updates more often. +- Changed `NetworkTransform.UseHalfFloatPrecision` to synchronize position with a resolution of approximately 1mm regardless of how far an object has travelled. Previously the resolution could degrade to approximately 3cm. This does not increase bandwidth, but projects using `NetworkTransform.UseUnreliableDeltas` will send full precision position updates more often. (#4129) - All editor assembly definitions are renamed with `Unity.Netcode.GameObjects.x` variants - `Unity.Netcode.Editor` → `Unity.Netcode.GameObjects.Editor` From 8398dc3671ba0ba267d9c67702b52202144f2199 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Tue, 18 Aug 2026 20:36:02 -0500 Subject: [PATCH 4/5] Fix jitter issue with stationary non-authority objects Fixed jitter issue with NetworkTransform.UseHalfFloatPrecision on non-authority instances. --- com.unity.netcode.gameobjects/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 0ac22a7cf2..0bb198bb52 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -27,7 +27,7 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed -- Issue where objects using `NetworkTransform.UseHalfFloatPrecision` appeared to jitter on non-authority instances while they were stationary or coming to rest, even though the authority was not moving them. +- Issue where objects using `NetworkTransform.UseHalfFloatPrecision` appeared to jitter on non-authority instances while they were stationary or coming to rest, even though the authority was not moving them. (#4129) - 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 6100893c82f5b3402e62c33dd61cdba86e5ab1c1 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Thu, 20 Aug 2026 17:34:03 -0500 Subject: [PATCH 5/5] test - update Adding better coverage and adjusting some of the test to better leverage from NetcodeIntegrationTest helper methods. --- ...NetworkTransformHalfFloatPrecisionTests.cs | 426 +++++++++++++++++- 1 file changed, 403 insertions(+), 23 deletions(-) diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatPrecisionTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatPrecisionTests.cs index 5f7adc2319..18d55e346a 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatPrecisionTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatPrecisionTests.cs @@ -1,6 +1,8 @@ using System.Collections; using System.Collections.Generic; using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; using Unity.Netcode.Components; using Unity.Netcode.TestHelpers.Runtime; using UnityEngine; @@ -51,7 +53,6 @@ internal class NetworkTransformHalfFloatPrecisionTests : IntegrationTestWithAppr private GameObject m_TestPrefab; private NetworkManager m_AuthorityNetworkManager; private NetworkTransform m_AuthorityInstance; - private readonly List m_NonAuthorityInstances = new List(); private readonly Dictionary m_WorstRegression = new Dictionary(); private readonly Dictionary m_LastObserved = new Dictionary(); @@ -94,30 +95,16 @@ protected override void OnServerAndClientsCreated() base.OnServerAndClientsCreated(); } - private bool AllInstancesSpawned() + private bool AllInstancesCaughtUp() { - m_NonAuthorityInstances.Clear(); + var authority = GetAuthorityNetworkManager(); foreach (var networkManager in m_NetworkManagers) { - if (networkManager == m_AuthorityNetworkManager) + if (networkManager == authority) { continue; } - - if (!networkManager.SpawnManager.SpawnedObjects.ContainsKey(m_AuthorityInstance.NetworkObjectId)) - { - return false; - } - - m_NonAuthorityInstances.Add(networkManager.SpawnManager.SpawnedObjects[m_AuthorityInstance.NetworkObjectId].GetComponent()); - } - return m_NonAuthorityInstances.Count > 0; - } - - private bool AllInstancesCaughtUp() - { - foreach (var nonAuthority in m_NonAuthorityInstances) - { + var nonAuthority = networkManager.SpawnManager.SpawnedObjects[m_AuthorityInstance.NetworkObjectId]; if (!Approximately(nonAuthority.transform.position, m_AuthorityInstance.transform.position)) { return false; @@ -135,8 +122,14 @@ private bool AllInstancesCaughtUp() /// private void SampleForRegression() { - foreach (var nonAuthority in m_NonAuthorityInstances) + var authority = GetAuthorityNetworkManager(); + foreach (var networkManager in m_NetworkManagers) { + if (networkManager == authority) + { + continue; + } + var nonAuthority = networkManager.SpawnManager.SpawnedObjects[m_AuthorityInstance.NetworkObjectId].GetComponent(); var current = nonAuthority.transform.position.x; if (m_LastObserved.TryGetValue(nonAuthority, out var previous)) { @@ -154,8 +147,14 @@ private void BeginSampling() { m_WorstRegression.Clear(); m_LastObserved.Clear(); - foreach (var nonAuthority in m_NonAuthorityInstances) + var authority = GetAuthorityNetworkManager(); + foreach (var networkManager in m_NetworkManagers) { + if (networkManager == authority) + { + continue; + } + var nonAuthority = networkManager.SpawnManager.SpawnedObjects[m_AuthorityInstance.NetworkObjectId].GetComponent(); m_WorstRegression.Add(nonAuthority, 0.0f); m_LastObserved.Add(nonAuthority, nonAuthority.transform.position.x); } @@ -207,7 +206,7 @@ public IEnumerator HalfFloatPrecisionDoesNotInvertMotion() m_AuthorityNetworkManager = GetAuthorityNetworkManager(); m_AuthorityInstance = SpawnObject(m_TestPrefab, m_AuthorityNetworkManager).GetComponent(); - yield return WaitForConditionOrTimeOut(AllInstancesSpawned); + yield return WaitForSpawnedOnAllOrTimeOut(m_AuthorityInstance.gameObject); AssertOnTimeout($"Not all clients spawned {m_AuthorityInstance.name}!"); var travelTicks = (int)(k_TravelDistance / k_TravelStep); @@ -253,7 +252,7 @@ public IEnumerator HalfFloatPrecisionHoldsStillWhenStationary() m_AuthorityNetworkManager = GetAuthorityNetworkManager(); m_AuthorityInstance = SpawnObject(m_TestPrefab, m_AuthorityNetworkManager).GetComponent(); - yield return WaitForConditionOrTimeOut(AllInstancesSpawned); + yield return WaitForSpawnedOnAllOrTimeOut(m_AuthorityInstance.gameObject); AssertOnTimeout($"Not all clients spawned {m_AuthorityInstance.name}!"); var travelTicks = (int)(k_TravelDistance / k_TravelStep); @@ -280,4 +279,385 @@ public IEnumerator HalfFloatPrecisionHoldsStillWhenStationary() AssertNoRegression("stationary"); } } + + /// + /// Branch coverage for 's encoding math. + /// + /// + /// Separate from because none of this needs a + /// session, and that fixture would run it twice over two topologies. + ///

+ /// A value that is exactly representable as a half float carries no rounding loss, so a test built on + /// one cannot observe the behavior checked here and will pass against broken code. Keep the constants + /// below off the lattice, and derive expected encodings with rather than + /// writing them out as literals. + ///
+ internal class NetworkDeltaPositionTests + { + private const int k_Tick = 100; + + // Lossy as a half float, and two of them still fit under the collapse threshold. + private const float k_LossyStep = 0.7f; + + // Past the threshold and exactly representable, so the collapse cannot hinge on rounding. + private const float k_CollapsingStep = NetworkDeltaPosition.MaxDeltaBeforeAdjustment + 0.5f; + + // Off the half float lattice on every axis, so each conversion leaves rounding loss behind. + private static readonly Vector3 k_Base = new Vector3(30.0007f, -12.0003f, 5.0009f); + + private static Vector3 Offset(float amount) + { + return k_Base + new Vector3(amount, amount, amount); + } + + // The transmitted form, so comparisons are against what actually goes on the wire. + private static ushort[] Encoded(NetworkDeltaPosition deltaPosition) + { + return new[] + { + deltaPosition.HalfVector3.Axis.x.value, + deltaPosition.HalfVector3.Axis.y.value, + deltaPosition.HalfVector3.Axis.z.value, + }; + } + + [Test] + public void ConstructorOverloadsProduceTheSameInitialState() + { + var position = k_Base; + var allAxes = math.bool3(true); + + var instances = new[] + { + new NetworkDeltaPosition(position, k_Tick), + new NetworkDeltaPosition(position, k_Tick, allAxes), + new NetworkDeltaPosition(position.x, position.y, position.z, k_Tick), + new NetworkDeltaPosition(position.x, position.y, position.z, k_Tick, allAxes), + }; + + foreach (var instance in instances) + { + Assert.AreEqual(position, instance.GetCurrentBasePosition(), "The base position should be where the object started."); + Assert.AreEqual(Vector3.zero, instance.GetDeltaPosition(), "Nothing has moved yet, so there is no delta."); + Assert.AreEqual(Vector3.zero, instance.PrecisionLossDelta, "No conversion has lost anything yet."); + Assert.AreEqual(k_Tick, instance.NetworkTick, "The construction tick should be recorded."); + Assert.IsFalse(instance.CollapsedDeltaIntoBase, "A zero delta cannot have collapsed."); + Assert.IsFalse(instance.SynchronizeBase, "The base is only synchronized explicitly."); + Assert.AreEqual(allAxes, instance.HalfVector3.AxisToSynchronize, "All axes should be synchronized by default."); + } + } + + [Test] + public void AccessorsReportTheUnderlyingState() + { + var deltaPosition = new NetworkDeltaPosition(k_Base, k_Tick); + var moved = Offset(k_LossyStep); + deltaPosition.UpdateFrom(ref moved, k_Tick + 1); + + Assert.AreEqual(deltaPosition.CurrentBasePosition, deltaPosition.GetCurrentBasePosition()); + Assert.AreEqual(deltaPosition.DeltaPosition, deltaPosition.GetDeltaPosition()); + Assert.AreEqual(deltaPosition.HalfDeltaConvertedBack, deltaPosition.GetConvertedDelta()); + Assert.AreEqual(deltaPosition.CurrentBasePosition + deltaPosition.DeltaPosition, deltaPosition.GetFullPosition()); + + Assert.AreNotEqual(deltaPosition.GetDeltaPosition().x, deltaPosition.GetConvertedDelta().x, + "The converted delta is the lossy one and should not match the full precision delta."); + } + + [Test] + public void MovingFoldsThePreviousRoundingLossBackIn() + { + var deltaPosition = new NetworkDeltaPosition(k_Base, k_Tick); + + var firstMove = Offset(k_LossyStep); + deltaPosition.UpdateFrom(ref firstMove, k_Tick + 1); + + var carriedLoss = deltaPosition.PrecisionLossDelta; + Assert.AreNotEqual(0.0f, carriedLoss.x, "A step off the lattice has to leave rounding loss behind."); + + var basePosition = deltaPosition.GetCurrentBasePosition(); + var secondMove = Offset(k_LossyStep * 2.0f); + deltaPosition.UpdateFrom(ref secondMove, k_Tick + 2); + + Assert.IsFalse(deltaPosition.CollapsedDeltaIntoBase, + "Both steps together have to stay under the collapse threshold, or the delta asserted on below is reset to zero."); + + // Folding the loss in is what keeps the average position accurate instead of drifting by a + // fraction of a step per send. + var rawDelta = secondMove.x - basePosition.x; + Assert.AreEqual(rawDelta + carriedLoss.x, deltaPosition.GetDeltaPosition().x, 1e-7f, + "The delta being sent should have the carried rounding loss added to it."); + Assert.AreNotEqual(math.half(rawDelta).value, deltaPosition.HalfVector3.Axis.x.value, + "Folding the loss in has to change the transmitted value, or it would have no effect."); + Assert.AreNotEqual(carriedLoss.x, deltaPosition.PrecisionLossDelta.x, + "The carried loss should be recomputed from the conversion that just happened."); + } + + [Test] + public void StandingStillDoesNotChangeWhatIsSent() + { + var deltaPosition = new NetworkDeltaPosition(k_Base, k_Tick); + + // Arrive off the lattice, which is where a settling object ends up. + var arrived = Offset(k_LossyStep); + deltaPosition.UpdateFrom(ref arrived, k_Tick + 1); + + var encodedOnArrival = Encoded(deltaPosition); + var lossOnArrival = deltaPosition.PrecisionLossDelta; + Assert.AreNotEqual(0.0f, lossOnArrival.x, "The arrival conversion has to leave rounding loss behind."); + + // Folding the loss back in while stationary is what made resting objects jitter. + for (var tick = k_Tick + 2; tick <= k_Tick + 5; tick++) + { + deltaPosition.UpdateFrom(ref arrived, tick); + + Assert.AreEqual(encodedOnArrival, Encoded(deltaPosition), + $"The transmitted delta changed on tick {tick} while the position did not move."); + Assert.AreEqual(lossOnArrival, deltaPosition.PrecisionLossDelta, + $"The carried loss should be untouched on tick {tick} so it still applies once movement resumes."); + } + } + + [Test] + public void DeltaCollapsesIntoTheBaseAtTheThreshold() + { + var deltaPosition = new NetworkDeltaPosition(k_Base, k_Tick); + var originalBase = deltaPosition.GetCurrentBasePosition(); + + var moved = Offset(k_CollapsingStep); + deltaPosition.UpdateFrom(ref moved, k_Tick + 1); + + Assert.IsTrue(deltaPosition.CollapsedDeltaIntoBase, "A delta at the threshold should have been folded into the base."); + Assert.AreEqual(0.0f, deltaPosition.GetDeltaPosition().x, "The delta should be reset once it is folded in."); + Assert.AreEqual(0.0f, deltaPosition.GetConvertedDelta().x, "The converted delta should be reset along with it."); + Assert.AreNotEqual(originalBase.x, deltaPosition.GetCurrentBasePosition().x, "The base should have absorbed the delta."); + Assert.AreEqual(moved.x, deltaPosition.GetFullPosition().x, 1e-3f, + "Folding the delta into the base must not move the object it describes."); + } + + [Test] + public void ADeltaUnderTheThresholdIsLeftAsADelta() + { + var deltaPosition = new NetworkDeltaPosition(k_Base, k_Tick); + var originalBase = deltaPosition.GetCurrentBasePosition(); + + var moved = Offset(k_LossyStep); + deltaPosition.UpdateFrom(ref moved, k_Tick + 1); + + Assert.IsFalse(deltaPosition.CollapsedDeltaIntoBase, "A delta under the threshold should stay a delta."); + Assert.AreEqual(originalBase, deltaPosition.GetCurrentBasePosition(), "The base should not move while the delta is small."); + Assert.AreNotEqual(0.0f, deltaPosition.GetDeltaPosition().x, "The delta should hold the movement."); + } + + [Test] + public void UnsynchronizedAxesAreLeftUntouched() + { + var deltaPosition = new NetworkDeltaPosition(k_Base, k_Tick, math.bool3(true, false, false)); + + var moved = Offset(k_LossyStep); + deltaPosition.UpdateFrom(ref moved, k_Tick + 1); + + Assert.AreNotEqual(0.0f, deltaPosition.GetDeltaPosition().x, "The synchronized axis should track the movement."); + Assert.AreEqual(0.0f, deltaPosition.GetDeltaPosition().y, "An unsynchronized axis should not produce a delta."); + Assert.AreEqual(0.0f, deltaPosition.GetDeltaPosition().z, "An unsynchronized axis should not produce a delta."); + + // A stale reference here would break the comparison if the axis is synchronized later. + Assert.AreEqual(moved.x, deltaPosition.PreviousPosition.x, "The synchronized axis should record where it was sent from."); + Assert.AreEqual(k_Base.y, deltaPosition.PreviousPosition.y, "An unsynchronized axis should keep its original reference."); + Assert.AreEqual(k_Base.z, deltaPosition.PreviousPosition.z, "An unsynchronized axis should keep its original reference."); + } + + [Test] + public void DecodingOnTheSameTickDoesNotReadTheEncodedAxes() + { + var deltaPosition = new NetworkDeltaPosition(k_Base, k_Tick); + var moved = Offset(k_LossyStep); + deltaPosition.UpdateFrom(ref moved, k_Tick + 1); + + var expected = deltaPosition.GetFullPosition(); + + // Overwriting the encoded axes proves this path returns the already-decoded value rather than + // decoding again, which would apply the same delta twice. + deltaPosition.HalfVector3.Axis = math.half3(new float3(1.9f, 1.9f, 1.9f)); + + Assert.AreEqual(expected, deltaPosition.ToVector3(k_Tick + 1), + "Decoding the tick that was just written should return the position already held."); + } + + [Test] + public void DecodingANewTickAppliesTheDelta() + { + var authority = new NetworkDeltaPosition(k_Base, k_Tick); + var moved = Offset(k_LossyStep); + authority.UpdateFrom(ref moved, k_Tick + 1); + + var receiver = new NetworkDeltaPosition(k_Base, k_Tick) + { + HalfVector3 = authority.HalfVector3, + }; + + var decoded = receiver.ToVector3(k_Tick + 1); + + Assert.AreEqual(authority.GetConvertedDelta().x, receiver.GetDeltaPosition().x, + "The receiver should decode the same delta the authority encoded."); + Assert.AreEqual(k_Base.x + authority.GetConvertedDelta().x, decoded.x, 1e-4f, + "The decoded position should be the base plus the transmitted delta."); + } + + [Test] + public void DecodingCollapsesIntoTheBaseAtTheThreshold() + { + var authority = new NetworkDeltaPosition(k_Base, k_Tick); + var moved = Offset(k_CollapsingStep); + authority.UpdateFrom(ref moved, k_Tick + 1); + + // The send side folds the delta into its own base but leaves the encoded axes holding it, so the + // receiving side has to perform the same fold to end up on the same base. + var receiver = new NetworkDeltaPosition(k_Base, k_Tick) + { + HalfVector3 = authority.HalfVector3, + }; + + var decoded = receiver.ToVector3(k_Tick + 1); + + Assert.AreEqual(0.0f, receiver.GetDeltaPosition().x, "The delta should be reset once it is folded into the base."); + Assert.AreEqual(0, receiver.HalfVector3.Axis.x.value, "The encoded axis should be cleared along with it."); + Assert.AreEqual(authority.GetCurrentBasePosition().x, receiver.GetCurrentBasePosition().x, 1e-4f, + "Both sides must end up on the same base position or they will disagree from here on."); + Assert.AreEqual(moved.x, decoded.x, 1e-3f, "Folding the delta into the base must not move the object."); + } + + [Test] + public void DecodingIgnoresUnsynchronizedAxes() + { + var axesToSynchronize = math.bool3(true, false, false); + var authority = new NetworkDeltaPosition(k_Base, k_Tick, axesToSynchronize); + var moved = Offset(k_LossyStep); + authority.UpdateFrom(ref moved, k_Tick + 1); + + var receiver = new NetworkDeltaPosition(k_Base, k_Tick, axesToSynchronize) + { + HalfVector3 = authority.HalfVector3, + }; + + var decoded = receiver.ToVector3(k_Tick + 1); + + Assert.AreNotEqual(k_Base.x, decoded.x, "The synchronized axis should have moved."); + Assert.AreEqual(k_Base.y, decoded.y, "An unsynchronized axis should stay at the base value."); + Assert.AreEqual(k_Base.z, decoded.z, "An unsynchronized axis should stay at the base value."); + } + + [Test] + public void HalfDeltaRoundTripsWhenTheBaseIsNotSynchronized() + { + var source = new NetworkDeltaPosition(k_Base, k_Tick); + var moved = Offset(k_LossyStep); + source.UpdateFrom(ref moved, k_Tick + 1); + + var result = RoundTrip(source, synchronizeBase: false); + + Assert.AreEqual(Encoded(source), Encoded(result), "The encoded axes should survive the round trip."); + + // Only the half float axes go on the wire here, so the receiver keeps whatever base it had. + Assert.AreEqual(Vector3.zero, result.GetCurrentBasePosition(), "The base should not be transmitted in this mode."); + } + + [Test] + public void FullPrecisionRoundTripsWhenTheBaseIsSynchronized() + { + var source = new NetworkDeltaPosition(k_Base, k_Tick); + var moved = Offset(k_LossyStep); + source.UpdateFrom(ref moved, k_Tick + 1); + + var result = RoundTrip(source, synchronizeBase: true); + + // Synchronizing sends both values at full precision, so this path has to be lossless. + Assert.AreEqual(source.GetDeltaPosition(), result.GetDeltaPosition(), "The delta should round trip exactly."); + Assert.AreEqual(source.GetCurrentBasePosition(), result.GetCurrentBasePosition(), "The base should round trip exactly."); + } + + [Test] + public void QuantumIsTheSmallestChangeTheEncodingCanSee() + { + // Exactly representable, so "one step away" is unambiguous. + foreach (var value in new[] { 0.5f, 1.0f, -1.0f, 2.0f, 1024.0f }) + { + var quantum = NetworkDeltaPosition.HalfPrecisionQuantum(value); + Assert.Greater(quantum, 0.0f, $"The step size at {value} should be positive."); + + Assert.AreNotEqual(math.half(value).value, math.half(value + quantum).value, + $"A full step from {value} should encode differently, or it is not the step size."); + Assert.AreEqual(math.half(value).value, math.half(value + (quantum * 0.25f)).value, + $"A quarter step from {value} should encode identically, or the step size is too large."); + } + } + + [Test] + public void QuantumDropsTheSignBecauseTheLatticeIsSymmetric() + { + foreach (var value in new[] { 0.5f, 1.0f, 300.0f, 1024.0f }) + { + Assert.AreEqual(NetworkDeltaPosition.HalfPrecisionQuantum(value), + NetworkDeltaPosition.HalfPrecisionQuantum(-value), + $"The step size at {value} and {-value} should be the same."); + } + } + + [TestCase(65504.0f, TestName = "QuantumIsGuarded_AtLargestFiniteHalf")] + [TestCase(-65504.0f, TestName = "QuantumIsGuarded_AtNegativeLargestFiniteHalf")] + [TestCase(70000.0f, TestName = "QuantumIsGuarded_WhenRoundingToInfinity")] + [TestCase(float.PositiveInfinity, TestName = "QuantumIsGuarded_AtPositiveInfinity")] + [TestCase(float.NegativeInfinity, TestName = "QuantumIsGuarded_AtNegativeInfinity")] + [TestCase(float.NaN, TestName = "QuantumIsGuarded_AtNaN")] + public void QuantumIsGuardedAtTheTopOfTheRange(float value) + { + Assert.AreEqual(NetworkDeltaPosition.MaxDeltaBeforeAdjustment, + NetworkDeltaPosition.HalfPrecisionQuantum(value), + $"{value} is at or past the largest finite half float and should fall back to the maximum delta."); + } + + [Test] + public void QuantumIsNeverNonFiniteOrZero() + { + // Why the guard exists: an infinite step size would make the "has it moved?" comparison in + // UpdateFrom false for every input, silently stopping the rounding loss from being applied. + var unguarded = Mathf.HalfToFloat(0x7BFF + 1) - Mathf.HalfToFloat(0x7BFF); + Assert.IsTrue(float.IsInfinity(unguarded) || float.IsNaN(unguarded), + "The unguarded computation at the top of the range should be non-finite, which is why the guard exists."); + + var values = new[] + { + 0.0f, float.Epsilon, 1e-7f, 0.5f, 1.0f, 100.0f, 65503.0f, 65504.0f, -65504.0f, 70000.0f, + float.PositiveInfinity, float.NegativeInfinity, float.NaN, + }; + + foreach (var value in values) + { + var quantum = NetworkDeltaPosition.HalfPrecisionQuantum(value); + Assert.IsFalse(float.IsNaN(quantum) || float.IsInfinity(quantum), $"The step size at {value} should be finite."); + Assert.Greater(quantum, 0.0f, $"The step size at {value} should be positive."); + } + } + + private static NetworkDeltaPosition RoundTrip(NetworkDeltaPosition source, bool synchronizeBase) + { + source.SynchronizeBase = synchronizeBase; + + using var writer = new FastBufferWriter(256, Allocator.Temp); + var writeSerializer = new BufferSerializer(new BufferSerializerWriter(writer)); + source.NetworkSerialize(writeSerializer); + + // Starts from a different state, so a value that failed to arrive shows up as a mismatch. + var result = new NetworkDeltaPosition(Vector3.zero, 0) + { + SynchronizeBase = synchronizeBase, + HalfVector3 = { AxisToSynchronize = source.HalfVector3.AxisToSynchronize }, + }; + + using var reader = new FastBufferReader(writer, Allocator.Temp); + var readSerializer = new BufferSerializer(new BufferSerializerReader(reader)); + result.NetworkSerialize(readSerializer); + + return result; + } + } }