feat: NetworkTransform with blittable states and handled in jobs - #4123
feat: NetworkTransform with blittable states and handled in jobs#4123NoelStephensUnity wants to merge 61 commits into
Conversation
Some additional changes but for the most part this is the core framework to abstracting NetworkTransform away from the managed layer. This includes some core-feature parity tests when using per-instance vs batched synchronization modes.
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>
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>
Updated the changelog to include issue #4129 regarding the precision of NetworkTransform synchronization.
Fixed jitter issue with NetworkTransform.UseHalfFloatPrecision on non-authority instances.
…polation time The lerp smoothing pass used by the Lerp and SmoothDampening interpolation types applied a fixed factor of 1.0 minus the maximum interpolation time once per frame, with no delta time. The wall clock smoothing rate therefore scaled with the frame rate, so the same setting smoothed by different amounts on different hardware. The factor is now raised to the number of 60fps reference frames elapsed, which makes the rate a function of elapsed time. Results at 60fps are unchanged for every legal setting. Separately, a maximum interpolation time of 1.0 (the upper bound of the inspector range) produced a factor of exactly 0, so the interpolated value never advanced and the transform stopped moving entirely on all three axes. The retained portion is now clamped just below 1.0. This is an independent defect, as raising 1.0 to any power is still 1.0. The LegacyLerp path was already frame rate correct and is unchanged. The documentation on the lerp smoothing fields described the LegacyLerp formula for all interpolation types and has been corrected.
Adds an integration test that measures how far behind the server clock the state a non-authority NetworkTransform is interpolating towards was sent. Only states sent at or before the render time are eligible to be interpolated towards, and the render time is the server clock minus the tick latency, so that measurement can never be less than the tick latency. It currently is, and goes negative, meaning the interpolator is chasing a state that the server clock says has not happened yet. An in-process integration test has effectively no round trip time, so the test first widens the client's local time buffer to separate LocalTime and ServerTime by a known amount and waits for that separation to take hold. Without it the two clocks sit close enough together that the test would pass regardless of which one the render time is derived from. This commit contains the test only, so it can be run against an unfixed tree.
A NetworkTransform state's SentTime comes from its NetworkTick, which is a server tick, but the render time the interpolators were given was derived from LocalTime. That mixes two clocks. LocalTime leads ServerTime, so subtracting the tick latency from it lands the render time back at approximately ServerTime rather than a whole tick latency behind it, and a state's SentTime is floored to a tick boundary on top of that. The render time therefore sat at or ahead of the newest state that could exist and the interpolator had nothing to interpolate towards. Measuring from ServerTime makes the offset the whole tick latency instead of whatever is left of it, and is self correcting: as the round trip time grows the tick latency grows and the render time moves further back with it. This also matches the rest of the component, which already resets the interpolators using ServerTime. This is a no-op on a host or server, where the two clocks are the same, so it only affects clients. GetTickLatencyInSeconds returns an absolute time rather than a duration and had the same defect, so it now derives from ServerTime as well. GetTickLatency is left alone because it returns a tick count rather than a point in time.
Comment and changelog wording only, no behavioral or test logic changes. Trims the explanation in UpdateInterpolation from twenty one lines to six and drops the measurement anecdote and the unfilled Jira placeholder, keeping the reason the server clock is the correct one to measure from. Shortens the test's remarks and constant comments to match the density of the surrounding tests. The removed detail, the measurements behind the fix, and the metrics that were tried and rejected while building the test are recorded outside the repository.
Adding better coverage and adjusting some of the test to better leverage from NetcodeIntegrationTest helper methods.
Moving the NetworkDeltaPositionTests into its own file.
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.
…render-time-up-port-2
GetTickLatencyInSeconds returned TimeTicksAgo(...).Time, which is an absolute network timestamp rather than a duration, so the value grew for as long as the session ran. It is documented as returning the tick latency in seconds, and NetworkTimeSystem.TickLatency points at it as a way to inspect that latency, so the contract was misleading regardless of which clock it was measured from. It now returns the tick count multiplied by the tick interval. This also takes the clock question out of this method entirely, since a duration does not reference LocalTime or ServerTime. The change to derive interpolation render time from ServerTime now applies only to UpdateInterpolation. Adds integration tests covering the documented contract: the value tracks the tick latency rather than elapsed time, and lengthens by exactly the tick interval for each tick of additional buffering. Both fail against the previous implementation, the second regardless of how long the session has run, since buffering more ticks used to make the reported latency smaller.
NetworkTimeSystem.TickLatency is recomputed from the averaged round trip time and can legitimately change mid-run. Both tests assumed it would not, and one failed on macOS when it moved from two ticks to three, reporting the value as having gone from 0.0666s to 0.1s. The duration is now only held to being unchanged across samples where the tick latency itself did not change, and the buffer offset test accounts for any tick latency movement between its two samples so that only the buffering is held to an exact figure. Both still fail against the previous absolute timestamp implementation.
- Drop the redundant HostOrServer fixture argument and the UseCMBService override; Host is the default and a client-server fixture never runs under the CMB service. - Use WaitForSpawnedOnAllOrTimeOut, GetNonAuthorityNetworkManager and WaitForTicks instead of hand rolled equivalents. - There is only ever one connected client, so drop the collections and refer to the single non-authority instance directly. - Fold the two tick latency tests into one and drop the assertion that recomputed the implementation's own formula. What is left is what can actually regress: the value does not drift with session time, and it grows by exactly the ticks added to the interpolation buffer.
…transform-handled-in-jobs
…ther-up-port' into feat/networktransform-handled-in-jobs # Conflicts: # com.unity.netcode.gameobjects/CHANGELOG.md
…framerate-up-port' into feat/networktransform-handled-in-jobs # Conflicts: # com.unity.netcode.gameobjects/CHANGELOG.md
…on-render-time-up-port-2' into feat/networktransform-handled-in-jobs # Conflicts: # com.unity.netcode.gameobjects/CHANGELOG.md # com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs
…leport check Three separate problems, all found by auditing the batched path against the per instance one. A Teleport or a SetState raises the teleport and explicit set flags on the main thread, between ticks, on m_LocalAuthoritativeNetworkState. The delta check job reads TransformDeltaEntry.State, which ApplyBatchedDeltaEntry then assigns straight back over the top, so those flags never reached the job and a batched teleport converged as an ordinary delta. PrepareBatchedDeltaEntry now seeds the entry from the authoritative state, which is a no-op on any tick where the main thread changed nothing. AxisChangedDeltaPositionCheck ran only from OnUpdateAuthoritativeState, which a registered instance never reaches, so a batched instance skipped the teleport that re-enabling a drifted axis requires. It now runs from PrepareBatchedDeltaEntry as well. That same check assigned its per axis result instead of accumulating it, so re-enabling X and Z together discarded X's out of range result when Z was in range. It also assigned the outcome over NetworkTransformState.IsTeleportingNextFrame, which could clear a teleport already pending for the tick. Both are now or-ed. This one is not specific to the batched path: it is in develop-2.0.0 and develop-3.x.x as written. TransformDeltaEntry.ForceState is removed. It was read in three places and never set, because every caller that forces a full state update is ticking a nested instance and a nested instance is never registered for batching. NetworkTransformSyncModeParityTests gains a state recorder so a teleport is asserted by the flag that arrives rather than by where the object ends up, which interpolation reaches either way. Both tests fail on all four fixtures without these changes.
ClearForNextTick had one production call site, inside OnUpdateAuthoritativeState, which a registered instance never reaches because the tick loop skips anything the batched delta check already handled. So in batched mode the change flags were never reset: any axial group that changed once kept its Has*Change bit for the life of the instance and was serialized on every state update from then on. Measured at 200 instances over 60 ticks, half float position and a compressed quaternion, with scale registered and never touched: 100% of entries carried HasScaleChange and paid 6 bytes for a scale that had not moved since spawn. The per instance path, which does clear, costs the same with scale registered or not. This is the third defect of the same shape, so rather than a second copy of the block the clear is now ClearStateForNextTick, called from OnUpdateAuthoritativeState and from PrepareBatchedDeltaEntry. The per instance side is a pure extraction: same condition, same body, same order relative to the axis check and the delta check.
…onfig The project wide setting reached NetworkConfig through an IProcessSceneWithReport, which cannot reach a scene placed NetworkManager in play mode. NetworkManager.OnEnable moves itself into the DontDestroyOnLoad scene, and it does so before the callback runs, so the scene the callback is handed no longer contains the object it is looking for. It applied to zero NetworkManagers every time and nothing reported that, because the setting it read was correct and only the write was missing. A build was unaffected, since there the callback runs at build time against the authored scene. Its sibling SetInScenePlaced survives the same callback only because NetworkObject sets InScenePlaced at runtime as a fallback, which is why this is the first place the callback's limit was visible. TransformSyncMode is now public on NetworkConfig and authored per NetworkManager under "Transform Synchronization" in the inspector. It is serialized by the same mechanism as every other NetworkConfig value, so scenes, prefabs and builds all carry it with no build pipeline involvement. That also removes the prefab processor, which ran on import and therefore baked whatever the setting happened to be the last time a prefab was imported. Being public also lets a project offer the mode as a pre session choice instead of an editor round trip. NetworkManager captures the value into ActiveTransformSyncMode when it starts and everything on the send and receive paths reads that, so a write during a session cannot leave already spawned instances registered under one mode while new ones use the other, and cannot move the connection configuration hash out from under a client that is still joining. NetworkTransform's inspector no longer hides UseUnreliableDeltas under the batched mode. The mode belongs to the NetworkManager that will run the instance, which a prefab cannot know, and the runtime already ignores the value while batching. NetworkTransformSyncModeConfigurationTests covers the delivery that nothing covered before: every other fixture reaches the mode through the same assignment the integration test harness makes, so a delivery path that never ran left them all green.
Six comments asserted something that was not true. None of them fail a test, and none are visible from the code they sit next to, which is what they have in common: every one is a claim about lifetime, ordering, or the other implementation rather than about the lines below it. - NativeInterpolator.ResetTo claimed the managed implementation seeds a baseline measurement and this one does not. Neither seeds one. The divergence it spent thirty lines explaining does not exist, and the explanation that does belong lives once, on BufferedLinearInterpolator.ResetTo. - NetworkTransformStateManager said Register and Deregister are the only places the collections change. There are two independent sets, and the interpolation set changes in its own pair of methods. - The WriteBatch guard said only the server registers instances for batching, which contradicts RegisterForBatchedStateTracking. A client authority does register. What is client-server only is the queuing, and a distributed authority session queues nothing at all. - The handle allocator field said a handle is assigned to every synchronized instance. Only in batched mode. - DetectTransformDeltaJob said every caller forcing a full state update ticks a nested instance. There is one such caller and it is gated on SwitchTransformSpaceWhenParented, which does not exclude an instance from batching. It runs from CommitDetectedState after the job, which is the real reason the job never needs to be told to force. - Two test remarks described designs that had moved: the interpolator reset test described the bug in the present tense, and the state baseline test attributed the ReliableSequenced derivation to NetworkSerialize rather than to UpdateReliability. The rest is verbosity. Remarks that ran to a paragraph per method are now one statement per line, big-O notation is replaced by what actually happens, and the justifications for why the code is correct are gone where the rule alone is enough. serverTime is dropped from the internal interpolator reset path. BufferedLinearInterpolator.ResetTo forwards it into an AddMeasurement call it does not make, so nothing reads it in either implementation. The managed signature is public API and keeps it, with a note to obsolete it later.
NetworkTransform.cs and NativeInterpolator.cs picked up CRLF when the interpolation render time up-port was merged. A merge writes the result straight into the index, so the .gitattributes eol=lf normalization that runs on add never got a chance at them. - PVP-121-2: both files back to LF. - PVP-124-2: the three lines with trailing whitespace. - PVP-150-1: TransformSyncModes used <see></see> rather than <see/>.
…register Both raised by the automated review on PR #4123. GetConfig caches, and ClearConfigHash only ran on shutdown, so a GetConfig call made before the session started cached a hash built from the default ActiveTransformSyncMode. Initialize then captured the authored mode and the server compared connecting clients against the stale hash, rejecting them for a configuration mismatch. Clear the cache where the mode is captured. RunDeltaCheck iterates by index so an instance that despawns from within its own state update callback cannot invalidate the iteration, but Deregister swaps the last registered instance into the vacated slot and the increment then stepped straight over it. Its entry was already complete, so it was a tick of delay for no reason.
|
@u-pr Re-check the PR for the 2 issues you found. Those should be fixed with the recent commits. |
|
Both issues have been re-checked and verified as fixed:
🤖 Helpful? 👍/👎 |
What the CI standards job's own --fix pass produced. .editorconfig has dotnet_style_null_propagation and dotnet_style_coalesce_expression at error, so the two null ternaries had to go. Both are safe against Unity's == overload: InvokeBehaviourOnNetworkObjectParentChanged passes either a literal null or a spawned object whose transform it has already dereferenced, and SpawnMover is only ever called with no prefab or a real one. NativeInterpolatorTests no longer uses anything from System.
NetworkTransformStateSerializationBaseline failed on both Ubuntu jobs, on Mono and on IL2CPP, with the same two mismatches and the same actual hashes: QuaternionSync.Full and QuaternionSync.Teleport. Byte length was unchanged in both, so it was the payload and not the format. Those two are the only cases that put an uncompressed quaternion on the wire, and the fixture built it with Quaternion.Euler. That is native trig, and Linux lands a ULP away from Windows where the baseline was recorded. Compressed and half float quaternion quantize the difference away, and the euler cases write the RotAngle literals, which is why the other 23 agreed. Not the serializer: FullPrecision.AllAxes writes nine raw floats and passed, so float serialization is byte identical on both platforms. Rotation is now literal components, so every input to the matrix is a literal or a managed function of one. Re-recorded the four QuaternionSync signatures; the lengths are unchanged and the other 21 are untouched.
The why sat in a sixteen line block inside the internal overload's body, while NativeInterpolator.ResetTo pointed at the public overload's remarks for it. Moved it to where that reference already aims and left a one line marker on the call. Dropped the proof that the resulting state is safe and the ownership transition scenario. Both are readable from Clear and InternalReset, or belong in the PR.
…b.com/Unity-Technologies/com.unity.netcode.gameobjects into feat/networktransform-handled-in-jobs
Clean up and organization.
Brings in #4132 and #4135 now that both have landed on develop-3.x.x. Two conflicts, both resolved in favour of this branch: NetworkTransform.cs. #4135 moved UpdateInterpolation from LocalTime to ServerTime, but this branch hoists that work into the static RefreshInterpolationFrameData, where the same change was already mirrored by hand when the up-port was first merged. Develop's per-instance rigidbody delta time selection is also already handled here, per instance in UpdateInterpolation out of InterpolationFrameData, which is where it has to stay since m_UseRigidbodyForMotion is not a per session value. Develop's other changes to the file (the five lerp smoothing doc updates and the GetTickLatencyInSeconds duration fix) merged cleanly and are present. CHANGELOG.md. Kept this branch's entries; the result stays in descending PR order.
| EditorGUILayout.PropertyField(m_TransformSyncModeProperty, new GUIContent("Mode")); | ||
| if (m_NetworkManager.NetworkConfig.TransformSyncMode == TransformSyncModes.Batched) | ||
| { | ||
| EditorGUILayout.HelpBox( |
There was a problem hiding this comment.
If we deprecate UseUnreliableDeltas, then we can remove this, but if we do not then we have to make sure users know this (even though I doubt there are many users actually using that feature).
| EditorGUILayout.Space(); | ||
| EditorGUILayout.LabelField("Delivery", EditorStyles.boldLabel); | ||
| EditorGUILayout.PropertyField(m_TickSyncChildren); | ||
|
|
There was a problem hiding this comment.
ditto on the notes, if deprecated this would change and no need to call it out.
| /// Checked before sending so a client that observes none of this tick's updates gets no message at all | ||
| /// rather than one containing a count of zero. | ||
| /// </remarks> | ||
| private bool HasAnythingFor(ulong clientId) |
There was a problem hiding this comment.
Not happy with this name.
Perhaps HasObservedChangesPending or HasObservedDirtyStates...
Purpose of this PR
This PR does two things:
1.
NetworkTransform's state is now blittable. All of the types it uses to detect and applytransform changes can be used from a job. This includes the interpolators, quaternion compression, and
the lerp and smooth dampening math, which previously relied on engine calls that Burst cannot compile.
2. It adds an opt-in batched synchronization mode. Instead of every
NetworkTransformcheckingitself and sending its own message, one job checks them all in parallel and the server sends a single
message per tick. This uses noticeably less bandwidth.
2.a It defaults to the per-instance mode. Users upgrading to v3.0.0 NetworkTransform will continue to work as expected.
The new mode is off by default and there are no breaking changes.
Architectural overview
Turning it on
NetworkConfig.TransformSyncModeis set perNetworkManager, either under "TransformSynchronization" in the inspector or from script before starting a session. It is a session-wide
setting: everything is either batched or per-instance, not a mix.
Every peer in a session has to agree on the mode. This is part of the connection configuration hash, so
a mismatched client is rejected at connection rather than failing quietly. The value the session starts
with applies for the whole session, so writing the field mid-session has no effect and is safe.
What stays on the per-instance path
Some instances cannot be batched, so they are routed to the per-instance path automatically even when
batched mode is on. No configuration is needed for this to happen, and they behave exactly as they do in v2.x.x.x:
Rigidbody,NetworkRigidbody, and theNetworkRigidbodyhas the "Use Rigidbody for Motion" setting checked, default to per-instance mode because a job cannot read a rigidbody's position and rotation.NetworkTransforminstances, which are already ticked by their parent.Note: These instances will still get the job-based delta detection. Only how the message is sent is the primary difference.
NetworkTransform.UseUnreliableDeltasdoes not apply in batched mode. Delivery is decided per stateupdate rather than per component.
Tradeoffs worth knowing about
Rigidbody.Jira ticket
N/A
Changelog
NetworkConfig.TransformSyncMode, which determines whetherNetworkTransforminstances detectand synchronize their state individually or as a single batched message per tick.
NetworkTransformposition axis that drifted out of half float delta range whileit was disabled did not teleport.
Documentation
NetworkConfig.TransformSyncMode: how to set it, that every peer has tomatch, and which instances stay on the per-instance path.
TransformSyncModesandNetworkConfig.TransformSyncMode).The only public API added is the
TransformSyncModesenum and theNetworkConfig.TransformSyncModefield. Everything else in this PR is internal.
Testing & QA (How your changes can be verified during release Playtest)
Both modes are exercised by the same tests, so anything that regresses one and not the other shows up
as a parity failure.
New test fixtures:
NetworkTransformSyncModeParityTestsruns the same scenarios through both modes and both authoritymodels to assure non-authority instances directly align with the authority instances.
NetworkHide/NetworkShow, ownershipchanges, and despawn/respawn.
NetworkTransformSyncModeConfigurationTestsassures the synchronization mode selected is used during runtime.NativeInterpolatorTestsvalidates that the job-friendly interpolator and the managed one provide the same results when using identical input.NetworkTransformMathTestsmeasures the job-friendly math against the managed math to assure both yield the same expected results.NetworkTransformStateBaselineTestsrecords the exact serialized bytes of aNetworkTransformStateacross every serialization branch. This is the proof that the per-instance wire format did not
change — it passes unmodified.
TransformHandleAllocatorTestsvalidates transform handle allocation and reuse.Playtest suggestion: run an existing scene with many moving
NetworkTransformobjects once in eachmode and confirm motion looks identical. Batched mode is set on the
NetworkManagerbefore starting a session (i.e. it can be set in the editor or at runtime).Worth including a case with a
NetworkRigidbodyand a case with parented objects, since those takethe per-instance path.
Bandwidth
Batched mode can yield up to a ~54% in bandwidth savings.
PR with additional optimizations pending as this PR is already too large.
Functional Testing
Manual testing :
Manual testing doneAutomated tests:
Covered by existing automated testsCovered by new automated testsDoes the change require QA team to:
Review automated tests?Execute manual tests?Provide feedback about the PR?If any boxes above are checked the QA team will be automatically added as a PR reviewer.
Up-port
No up-port is required.
Backports
No backport is required.