Skip to content

Commit 95ba207

Browse files
fix: half float delta position dither (#4128)
* 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 <noreply@anthropic.com> * 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> * Update CHANGELOG for NetworkTransform precision changes Updated changelog entries for NetworkTransform.UseHalfFloatPrecision to reflect changes in issue tracking numbers. * test - update Adding better coverage and adjusting some of the test to better leverage from NetcodeIntegrationTest helper methods. * test - update Moving the NetworkDeltaPositionTests into its own file. * update Bumping the protocol version to assure legacy clients cannot connect to a session with the fixes. While the fixes aren't technically a "breaking change", any projects using the legacy lerp will end up with an offset from the expected final position. Since motion continually feeds the full position (half float or full precision) as deltas this would prevent from "long term drift". Either case, updating the protocol version only assures that clients of a previous version cannot connect to a session with the newer version. * test: Refer to the single non-authority instance directly There is one connected client, so the per instance dictionaries and the loops over m_NetworkManagers only ever held one entry. Resolve the non-authority instance once through GetNonAuthorityNetworkManager and name the two sampling frame counts that were inline literals. * update Removed note about why we don't use time travel (too verbose). Enabling this on the CMB service pass to validate this works as expected against a live session. * test: Drop two NetworkDeltaPosition tests that cover an already covered path ADeltaUnderTheThresholdIsLeftAsADelta asserted nothing the other tests do not: MovingFoldsThePreviousRoundingLossBackIn already requires the delta to stay under the threshold, and UnsynchronizedAxesAreLeftUntouched already requires the synchronized axis to hold the movement. QuantumDropsTheSignBecauseTheLatticeIsSymmetric ran its own value list to assert one thing, so it moves into the loop in QuantumIsTheSmallestChangeTheEncodingCanSee, which already walks the same kind of values. 300f joins that list so no input is lost. The masking it covers is a common path executed by every call, so the coverage score is unchanged either way. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 5493a5d commit 95ba207

7 files changed

Lines changed: 665 additions & 4 deletions

File tree

com.unity.netcode.gameobjects/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ Additional documentation and release notes are available at [Multiplayer Documen
1313

1414
### Changed
1515

16+
- 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)
17+
1618

1719
### Deprecated
1820

@@ -27,6 +29,7 @@ Additional documentation and release notes are available at [Multiplayer Documen
2729
- Issue where `NetworkTransform.GetTickLatencyInSeconds` returned an absolute network timestamp that grew for as long as the session ran, rather than the tick latency as a duration in seconds that it is documented to return. (#4133)
2830
- Issue where lerp smoothing was applied per frame instead of over time, which caused the `Lerp` and `SmoothDampening` interpolation types to smooth by different amounts at different frame rates. Results at 60fps are unchanged. (#4130)
2931
- Issue where setting a maximum interpolation time of 1.0 would stop a `NetworkTransform` from interpolating at all when using the `Lerp` or `SmoothDampening` interpolation types. (#4130)
32+
- 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)
3033

3134
### Security
3235

com.unity.netcode.gameobjects/Runtime/Components/NetworkDeltaPosition.cs

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,14 @@ namespace Unity.Netcode.Components
1111
[Serializable]
1212
public struct NetworkDeltaPosition : INetworkSerializable
1313
{
14-
internal const float MaxDeltaBeforeAdjustment = 64f;
14+
/// <summary>
15+
/// How far the delta may grow before it is folded into the base position.
16+
/// </summary>
17+
/// <remarks>
18+
/// This determines the transmitted position resolution, since a half float's step size grows with its
19+
/// magnitude. Keeping the delta small keeps that step small: at 2 the coarsest step is roughly 1mm.
20+
/// </remarks>
21+
internal const float MaxDeltaBeforeAdjustment = 2f;
1522

1623
/// <summary>
1724
/// The HalfVector3 used to synchronize the delta in position
@@ -138,14 +145,29 @@ public void UpdateFrom(ref Vector3 vector3, int networkTick)
138145
{
139146
CollapsedDeltaIntoBase = false;
140147
NetworkTick = networkTick;
141-
DeltaPosition = (vector3 + PrecisionLossDelta) - CurrentBasePosition;
142148
for (int i = 0; i < HalfVector3.Length; i++)
143149
{
144150
if (HalfVector3.AxisToSynchronize[i])
145151
{
152+
var rawDelta = vector3[i] - CurrentBasePosition[i];
153+
154+
// Adding the previous rounding loss back in keeps the average position accurate while the
155+
// value is moving, but it also changes the value being sent. Once the value stops moving
156+
// that is all it does, which makes a stationary object appear to oscillate.
157+
var movedSinceLastSend = Mathf.Abs(vector3[i] - PreviousPosition[i]);
158+
var applyPrecisionLoss = movedSinceLastSend >= HalfPrecisionQuantum(rawDelta);
159+
160+
DeltaPosition[i] = applyPrecisionLoss ? rawDelta + PrecisionLossDelta[i] : rawDelta;
161+
146162
HalfVector3.Axis[i] = math.half(DeltaPosition[i]);
147163
HalfDeltaConvertedBack[i] = Mathf.HalfToFloat(HalfVector3.Axis[i].value);
148-
PrecisionLossDelta[i] = DeltaPosition[i] - HalfDeltaConvertedBack[i];
164+
165+
// Left unchanged when skipped so it is still applied once movement resumes.
166+
if (applyPrecisionLoss)
167+
{
168+
PrecisionLossDelta[i] = DeltaPosition[i] - HalfDeltaConvertedBack[i];
169+
}
170+
149171
if (Mathf.Abs(HalfDeltaConvertedBack[i]) >= MaxDeltaBeforeAdjustment)
150172
{
151173
CurrentBasePosition[i] += HalfDeltaConvertedBack[i];
@@ -165,6 +187,26 @@ public void UpdateFrom(ref Vector3 vector3, int networkTick)
165187
}
166188
}
167189

190+
/// <summary>
191+
/// The smallest change a half float can represent at the magnitude of the value passed in.
192+
/// </summary>
193+
/// <param name="value">The value to get the step size for.</param>
194+
/// <returns>The distance to the next representable half float value.</returns>
195+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
196+
internal static float HalfPrecisionQuantum(float value)
197+
{
198+
// The step size is symmetric about zero, so the sign is dropped.
199+
var magnitude = (ushort)(math.half(value).value & 0x7FFF);
200+
201+
// Guard only: stepping past the largest finite half float would give infinity.
202+
if (magnitude >= 0x7BFF)
203+
{
204+
return MaxDeltaBeforeAdjustment;
205+
}
206+
207+
return Mathf.HalfToFloat((ushort)(magnitude + 1)) - Mathf.HalfToFloat(magnitude);
208+
}
209+
168210
/// <summary>
169211
/// Constructor
170212
/// </summary>

com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConstants.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@ namespace Unity.Netcode
55
/// </summary>
66
internal static class NetworkConstants
77
{
8-
internal const string PROTOCOL_VERSION = "15.0.0";
8+
internal const string PROTOCOL_VERSION = "15.1.0";
99
}
1010
}

0 commit comments

Comments
 (0)