Skip to content

Commit 20d9ccd

Browse files
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 <noreply@anthropic.com>
1 parent 1854d2d commit 20d9ccd

2 files changed

Lines changed: 294 additions & 0 deletions

File tree

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
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+
}

com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatPrecisionTests.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)