diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 931ae821f3..41e7c96198 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -13,6 +13,8 @@ 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. (#4128) + ### Deprecated @@ -22,6 +24,8 @@ 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. (#4128) + ### Security 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 /// diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConstants.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConstants.cs index b9eed28790..bc862c8fe2 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConstants.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConstants.cs @@ -5,6 +5,6 @@ namespace Unity.Netcode /// internal static class NetworkConstants { - internal const string PROTOCOL_VERSION = "15.0.0"; + internal const string PROTOCOL_VERSION = "15.1.0"; } } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkDeltaPositionTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkDeltaPositionTests.cs new file mode 100644 index 0000000000..e9f704c87c --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkDeltaPositionTests.cs @@ -0,0 +1,389 @@ +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; +using Unity.Netcode.Components; +using UnityEngine; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// 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; + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkDeltaPositionTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkDeltaPositionTests.cs.meta new file mode 100644 index 0000000000..8ef2b73daf --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkDeltaPositionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4c273f2393c37e64980677d134606e15 \ No newline at end of file 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..25da66a93f --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatPrecisionTests.cs @@ -0,0 +1,280 @@ +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 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 AllInstancesCaughtUp() + { + var authority = GetAuthorityNetworkManager(); + foreach (var networkManager in m_NetworkManagers) + { + if (networkManager == authority) + { + continue; + } + var nonAuthority = networkManager.SpawnManager.SpawnedObjects[m_AuthorityInstance.NetworkObjectId]; + 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() + { + 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)) + { + 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(); + 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); + } + } + + 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 WaitForSpawnedOnAllOrTimeOut(m_AuthorityInstance.gameObject); + 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 WaitForSpawnedOnAllOrTimeOut(m_AuthorityInstance.gameObject); + 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: