From 7f2a809a00d2e42b9aafe3b171da903e71df73bd Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Thu, 29 Oct 2026 10:59:57 -0500 Subject: [PATCH 1/2] feat: synchronize NetworkTransform as one batched message per tick Last of five PRs that replace #4123. With NetworkConfig.TransformSyncMode set to Batched, NetworkTransformStateManager detects changes for every registered instance in one Burst job, the server sends one NetworkTransformBatchMessage per observing client per tick, and non-authority instances interpolate in a job. Rigidbody driven and nested instances stay on the per-instance path. NetworkTransformSyncModeParityTests runs both modes side by side. --- .../Editor/NetworkTransformEditor.cs | 6 + .../Components/DetectTransformDeltaJob.cs | 64 ++ .../DetectTransformDeltaJob.cs.meta | 2 + .../Interpolator/NativeInterpolator.cs | 3 +- .../Runtime/Components/NetworkTransform.cs | 485 ++++++++++++- .../NetworkTransformStateManager.cs | 677 ++++++++++++++++++ .../NetworkTransformStateManager.cs.meta | 2 + .../Runtime/Core/NetworkManager.cs | 29 + .../Runtime/Messaging/ILPPMessageProvider.cs | 2 + .../Runtime/Messaging/MessageDelivery.cs | 1 + .../Messages/NetworkTransformBatchMessage.cs | 97 +++ .../NetworkTransformBatchMessage.cs.meta | 2 + ...workTransformSyncModeConfigurationTests.cs | 117 +++ ...ransformSyncModeConfigurationTests.cs.meta | 11 + .../NetworkTransformSyncModeParityTests.cs | 487 +++++++++++++ ...etworkTransformSyncModeParityTests.cs.meta | 2 + .../TestHelpers/NetcodeIntegrationTest.cs | 18 + 17 files changed, 1999 insertions(+), 6 deletions(-) create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs.meta create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs.meta create mode 100644 com.unity.netcode.gameobjects/Runtime/Messaging/Messages/NetworkTransformBatchMessage.cs create mode 100644 com.unity.netcode.gameobjects/Runtime/Messaging/Messages/NetworkTransformBatchMessage.cs.meta create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeConfigurationTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeConfigurationTests.cs.meta create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs.meta diff --git a/com.unity.netcode.gameobjects/Editor/NetworkTransformEditor.cs b/com.unity.netcode.gameobjects/Editor/NetworkTransformEditor.cs index 3b61dadff2..4cfb4be404 100644 --- a/com.unity.netcode.gameobjects/Editor/NetworkTransformEditor.cs +++ b/com.unity.netcode.gameobjects/Editor/NetworkTransformEditor.cs @@ -218,6 +218,11 @@ private void DisplayNetworkTransformProperties() EditorGUILayout.Space(); EditorGUILayout.LabelField("Delivery", EditorStyles.boldLabel); EditorGUILayout.PropertyField(m_TickSyncChildren); + + // UseUnreliableDeltas only applies to per instance synchronization mode, but the mode is authored on + // the NetworkManager that will run this instance, which a prefab cannot know. So it is always drawn + // and the runtime ignores it under the batched mode, where delivery is determined per state update + // as opposed to per component. // If both are set from a previous configuration, then SwitchTransformSpaceWhenParented takes // precedence. if (networkTransform.UseUnreliableDeltas && networkTransform.SwitchTransformSpaceWhenParented) @@ -242,6 +247,7 @@ private void DisplayNetworkTransformProperties() EditorGUILayout.Space(); EditorGUILayout.LabelField("Configurations", EditorStyles.boldLabel); + // SwitchTransformSpaceWhenParented is only constrained by UseUnreliableDeltas while the latter applies. SetGUIActive(!networkTransform.UseUnreliableDeltas); if (networkTransform.UseUnreliableDeltas) { diff --git a/com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs b/com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs new file mode 100644 index 0000000000..64e0539551 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs @@ -0,0 +1,64 @@ +using Unity.Burst; +using Unity.Collections; +using UnityEngine.Jobs; +using static Unity.Netcode.Components.NetworkTransform; + +namespace Unity.Netcode.Components +{ + /// + /// Motion Authority Only: + /// Detects state changes for every registered instance in a parallel job. + /// + /// + /// + /// is the common method used for both per instance, runs on the main thread, and batched modes. This assures both paths detect changes in state identically. + /// + [BurstCompile] + internal struct DetectTransformDeltaJob : IJobParallelForTransform + { + /// + /// The per instance input and output, parallel to the job's scheduled transforms. + /// + public NativeArray Entries; + + /// + /// This job's primary entry point. + /// + /// + /// TODO: Investigate ways to work around the fact that a Rigidbody's position and rotation cannot + /// be sampled from a job. As such, any NetworkTransform that is using Rigidbody for motion will + /// use the to detect changes in position and rotation + /// states on the authority side. + /// + /// Index for the transform in question. + /// The job safe + public void Execute(int index, TransformAccess transform) + { + if (!transform.isValid) + { + return; + } + + var entry = Entries[index]; + var flagStates = entry.State.FlagStates; + // Only ResolveTransformSpace can raise this on the batched path. The one caller that forces a full + // state update does so from CommitDetectedState on the main thread, after this job has run. + var forceState = false; + + // Resolve the transform space before sampling, otherwise the wrong set of values gets compared. + var transformSpaceChanged = ResolveTransformSpace(ref entry.Config, ref flagStates, entry.TransformHasParent, false, ref forceState); + entry.State.FlagStates = flagStates; + + var rotation = entry.Config.InLocalSpace ? transform.localRotation : transform.rotation; + entry.Sample.Position = entry.Config.InLocalSpace ? transform.localPosition : transform.position; + entry.Sample.Rotation = rotation; + entry.Sample.RotAngles = NetworkTransformMath.EulerAngles(rotation); + entry.Sample.Scale = transform.localScale; + + entry.IsDirty = CheckForStateChange(ref entry.State, ref entry.HalfPositionState, ref entry.Config, + entry.Sample, false, forceState, transformSpaceChanged); + + Entries[index] = entry; + } + } +} diff --git a/com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs.meta b/com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs.meta new file mode 100644 index 0000000000..e1dd1c36d6 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: be2bf90a04d129843b923080c53c36a2 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs index 6a67adf3d8..e9d3d8cd10 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs @@ -46,7 +46,8 @@ internal struct BufferedItemNative ///
/// The smooth parenting transition flag, , /// is excluded from this state as it is handled differently.
- /// - provides additional details on this. + /// - provides additional details on this.
+ /// - is where this happens. /// internal struct NativeInterpolatorState { diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs index c916a87bd7..246d039472 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs @@ -1786,6 +1786,40 @@ public Vector3 GetScale(bool getCurrentState = false) return m_InternalCurrentScale; } + /// + /// When mode, this is the instance's index within .
+ /// It is -1 when it is not registered. + ///
+ /// + /// Cached here as opposed to using a lookup table, so registering and deregistering do not have to + /// search for the instance.
+ /// The manager keeps this up to date as instances are swapped between slots. + ///
+ internal int StateManagerIndex = -1; + + /// + /// This instance's index within the 's interpolation + /// entries, or -1 when it is not registered. + /// + /// + /// Separate from because an instance is registered as either an + /// authority (delta detection) or a non-authority (interpolation), never both, and the two are tracked + /// in different collections. + /// Only used by . + /// + internal int InterpolatorIndex = -1; + + /// + /// This instance's dense network wide identifier, or + /// when it has not been assigned one. + /// + /// + /// Assigned by whichever instance writes synchronization data and replicated to everyone else through + /// , so it survives changes of ownership. + /// Only used by . + /// + internal ushort TransformHandle = TransformHandleAllocator.InvalidHandle; + // Used by both authoritative and non-authoritative instances. // This represents the most recent local authoritative state. private NetworkTransformState m_LocalAuthoritativeNetworkState; @@ -1937,6 +1971,23 @@ protected override void OnSynchronize(ref BufferSerializer serializer) NetworkDeltaPosition = new NetworkDeltaPosition(), }; + // This uses a more compressed identifier handle for this instance when using batched mode. + // This is the best place to define the handle since it is the first thing that reaches + // every receiver and is only ever invoked once for the entire duration of the objects spawn + // life cycle. + if (NetworkManager.NetworkConfig.ActiveTransformSyncMode == TransformSyncModes.Batched) + { + if (serializer.IsWriter && TransformHandle == TransformHandleAllocator.InvalidHandle) + { + // Lazily allocated when first write rather than at spawn, which guarantees it exists before + // anything can transmit it regardless of spawn ordering. + TransformHandle = NetworkManager.TransformStateManager.Handles.Allocate(NetworkManager.ServerTime.Time); + } + + serializer.SerializeValue(ref TransformHandle); + NetworkManager.TransformStateManager.Handles.Register(TransformHandle, this); + } + if (serializer.IsWriter) { SynchronizeState.FlagStates.IsTeleportingNextFrame = true; @@ -2046,6 +2097,143 @@ private void TryCommitTransform(bool synchronize = false, bool settingState = fa } } + /// + /// Main Thread: + /// Contributes this instance's per frame interpolation inputs before the interpolation job runs. + /// + /// + /// The equivalent of what gathers for the per instance path. Values + /// shared by every instance come from , + /// which is already calculated once per update stage. + /// + internal void PrepareInterpolationEntry(ref InterpolationEntry entry) + { + var frameData = m_CachedNetworkManager.TransformInterpolationFrameData; + var isServerAuthoritative = IsServerAuthoritative(); + var useExtraTick = !isServerAuthoritative && frameData.OwnerAuthorityTickOffsetAllowed && !NetworkObject.IsOwnedByServer; + +#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D + entry.DeltaTime = m_UseRigidbodyForMotion ? frameData.FixedDeltaTime : frameData.DeltaTime; +#else + entry.DeltaTime = frameData.DeltaTime; +#endif + entry.TickLatencyAsTime = useExtraTick ? frameData.TickLatencyAsTimeExtraTick : frameData.TickLatencyAsTime; + entry.MaxDeltaTime = useExtraTick ? frameData.MaxDeltaTimeExtraTick : frameData.MaxDeltaTime; + entry.LegacyRenderTime = !isServerAuthoritative && !frameData.IsServer ? frameData.LegacyRenderTimeExtraTick : frameData.LegacyRenderTime; + entry.CurrentTime = frameData.CurrentTime; + entry.MinDeltaTime = frameData.MinDeltaTime; + + entry.PositionInterpolationType = PositionInterpolationType; + entry.RotationInterpolationType = RotationInterpolationType; + entry.ScaleInterpolationType = ScaleInterpolationType; + + entry.SynchronizePosition = SynchronizePosition; + entry.SynchronizeRotation = SynchronizeRotation; + entry.SynchronizeScale = SynchronizeScale; + + // Interpolation tuning can be changed during runtime, so it is refreshed each frame. Changing the + // interpolation type or the smoothing resets the value being interpolated, which is what the per + // instance path does as well. + if (m_PreviousPositionInterpolationType != PositionInterpolationType || m_PreviousPositionLerpSmoothing != PositionLerpSmoothing) + { + m_PreviousPositionInterpolationType = PositionInterpolationType; + m_PreviousPositionLerpSmoothing = PositionLerpSmoothing; + NativeInterpolator.ResetCurrentState(ref entry.Position); + } + + if (m_PreviousRotationInterpolationType != RotationInterpolationType || m_PreviousRotationLerpSmoothing != RotationLerpSmoothing) + { + m_PreviousRotationInterpolationType = RotationInterpolationType; + m_PreviousRotationLerpSmoothing = RotationLerpSmoothing; + NativeInterpolator.ResetCurrentState(ref entry.Rotation); + } + + if (m_PreviousScaleInterpolationType != ScaleInterpolationType || m_PreviousScaleLerpSmoothing != ScaleLerpSmoothing) + { + m_PreviousScaleInterpolationType = ScaleInterpolationType; + m_PreviousScaleLerpSmoothing = ScaleLerpSmoothing; + NativeInterpolator.ResetCurrentState(ref entry.Scale); + } + + entry.Position.LerpSmoothEnabled = PositionLerpSmoothing; + entry.Rotation.LerpSmoothEnabled = RotationLerpSmoothing; + entry.Scale.LerpSmoothEnabled = ScaleLerpSmoothing; + + if (PositionLerpSmoothing) + { + entry.Position.MaximumInterpolationTime = PositionMaxInterpolationTime; + } + if (RotationLerpSmoothing) + { + entry.Rotation.MaximumInterpolationTime = RotationMaxInterpolationTime; + } + if (ScaleLerpSmoothing) + { + entry.Scale.MaximumInterpolationTime = ScaleMaxInterpolationTime; + } + + entry.Position.IsSlerp = SlerpPosition; + // When using half precision, lerp towards the target rotation; at full precision, slerp. + entry.Rotation.IsSlerp = !UseHalfFloatPrecision; + } + + /// + /// Main Thread: + /// Prepares all state that is not already provided for a batched delta check. + /// + internal void PrepareBatchedDeltaEntry(ref TransformDeltaEntry entry) + { + // Both of these run from OnUpdateAuthoritativeState on the per instance path, which a registered + // instance never reaches. Without the clear the change flags only accumulate, and without the + // axis check a batched instance silently skips the teleport that re-enabling an axis requires. + ClearStateForNextTick(); + AxisChangedDeltaPositionCheck(); + + // The entry is what the job reads, and anything the main thread set between ticks (a Teleport or + // a SetState raising the teleport and explicit set flags) is on m_LocalAuthoritativeNetworkState. + // Seeding from it here is a no-op when nothing changed, since ApplyBatchedDeltaEntry writes the + // two back into step. Without it a teleport converges as an ordinary delta. + entry.State = m_LocalAuthoritativeNetworkState; + + entry.Config = GetTransformDeltaConfig(); + entry.TransformHasParent = CachedTransform.parent != null; + entry.HalfPositionState = m_HalfPositionState; + entry.IsDirty = false; + + var flagStates = entry.State.FlagStates; + entry.Sample = default; + + // Same conditions the per instance path uses, both of which need a lookup a job cannot perform. + if (flagStates.IsTeleportingNextFrame || flagStates.IsParented) + { + entry.Sample.HasParentNetworkObject = HasParentNetworkObject(); + entry.Sample.LossyScale = CachedTransform.lossyScale; + } + } + + /// + /// Applies the batched delta check result and sends the state update when one was detected. + /// + /// + /// Takes the entry as read only because nothing here has to write to it: + /// seeds every field of it from this instance, + /// unconditionally, before the next tick's job reads it. Anything this applies onto the instance + /// therefore reaches the entry from there, and writing it back here would copy a few hundred bytes + /// per instance per tick to no effect. Seeding the entry anywhere but in + /// is what this is avoiding. + /// + internal void ApplyBatchedDeltaEntry(in TransformDeltaEntry entry) + { + m_LocalAuthoritativeNetworkState = entry.State; + m_HalfPositionState = entry.HalfPositionState; + ApplyTransformDeltaConfig(entry.Config); + + if (entry.IsDirty || m_LocalAuthoritativeNetworkState.ExplicitSet) + { + CommitDetectedState(false); + } + } + /// /// Sends any detected state updates for the frame. /// @@ -2067,8 +2255,23 @@ private void CommitDetectedState(bool synchronize) } } - // Send the state update - UpdateTransformState(); + // Send the state update. A registered instance contributes to this tick's batch instead of + // sending on its own; the state is captured now because the flags below are cleared + // immediately afterwards. + // + // Only the server can batch: the batch is assembled per observing client and sent directly, + // where a client authority has to send to the server and be relayed. Registration is not + // gated on this, so a client authority still gets its delta detected in the job and only the + // send falls back to the per instance message. + if (StateManagerIndex >= 0 && m_CachedNetworkManager.IsServer && !m_CachedNetworkManager.DistributedAuthorityMode) + { + m_LocalAuthoritativeNetworkState.UpdateReliability(); + m_CachedNetworkManager.TransformStateManager.QueueForBatch(this, m_LocalAuthoritativeNetworkState); + } + else + { + UpdateTransformState(); + } // Preserve our teleporting flag in order to know if the state pushed was a teleport m_LocalAuthoritativeNetworkState.FlagStates.WasTeleported = m_LocalAuthoritativeNetworkState.IsTeleportingNextFrame; @@ -2194,7 +2397,11 @@ private TransformDeltaConfig GetTransformDeltaConfig() UseHalfFloatPrecision = UseHalfFloatPrecision, SlerpPosition = SlerpPosition, Interpolate = Interpolate, - UseUnreliableDeltas = UseUnreliableDeltas, + // Batched mode sends every state update in one reliable message per tick, so there are no + // unreliable deltas to compensate for. Forcing this off also retires the axial frame + // synchronization: that exists solely to re-send a full set of axes once a second in case an + // unreliable delta was lost, which cannot happen here. + UseUnreliableDeltas = UseUnreliableDeltas && m_CachedNetworkManager.NetworkConfig.ActiveTransformSyncMode != TransformSyncModes.Batched, SwitchTransformSpaceWhenParented = SwitchTransformSpaceWhenParented, #if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D UseRigidbodyForMotion = m_UseRigidbodyForMotion, @@ -2391,6 +2598,20 @@ internal void UpdatePositionInterpolator(Vector3 position, double time, bool res { if (!CanCommitToTransform) { + if (InterpolatorIndex >= 0) + { + var value = new float4(position.x, position.y, position.z, 0.0f); + if (resetInterpolator) + { + m_CachedNetworkManager.TransformStateManager.ResetTo(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Position, value); + } + else + { + m_CachedNetworkManager.TransformStateManager.AddMeasurement(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Position, value, time); + } + return; + } + if (resetInterpolator) { m_PositionInterpolator.AutoConvertTransformSpace = SwitchTransformSpaceWhenParented; @@ -2410,6 +2631,20 @@ internal void UpdatePositionInterpolator(Vector3 position, double time, bool res [MethodImpl(MethodImplOptions.AggressiveInlining)] private void UpdateRotationInterpolator(Quaternion rotation, double time, bool resetInterpolator = false) { + if (InterpolatorIndex >= 0) + { + var value = new float4(rotation.x, rotation.y, rotation.z, rotation.w); + if (resetInterpolator) + { + m_CachedNetworkManager.TransformStateManager.ResetTo(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Rotation, value); + } + else + { + m_CachedNetworkManager.TransformStateManager.AddMeasurement(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Rotation, value, time); + } + return; + } + if (resetInterpolator) { m_RotationInterpolator.AutoConvertTransformSpace = SwitchTransformSpaceWhenParented; @@ -2428,6 +2663,20 @@ private void UpdateRotationInterpolator(Quaternion rotation, double time, bool r [MethodImpl(MethodImplOptions.AggressiveInlining)] private void UpdateScaleInterpolator(Vector3 scale, double time, bool resetInterpolator = false) { + if (InterpolatorIndex >= 0) + { + var value = new float4(scale.x, scale.y, scale.z, 0.0f); + if (resetInterpolator) + { + m_CachedNetworkManager.TransformStateManager.ResetTo(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Scale, value); + } + else + { + m_CachedNetworkManager.TransformStateManager.AddMeasurement(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Scale, value, time); + } + return; + } + if (resetInterpolator) { m_ScaleInterpolator.ResetTo(scale, time); @@ -2438,12 +2687,58 @@ private void UpdateScaleInterpolator(Vector3 scale, double time, bool resetInter } } + /// + /// Handles converting a batch interpolated transform's state between transform spaces. + /// + /// + /// The batched interpolators hold every measurement in a single space, so a reparent has to convert + /// what is already buffered. Doing it here means the interpolation job itself never needs to know + /// about parents. + /// + /// The parent the buffered measurements are currently expressed under. + /// The parent they should be expressed under. + private void ConvertBatchedInterpolationSpace(Transform previousParent, Transform newParent) + { + if (InterpolatorIndex < 0 || previousParent == newParent) + { + return; + } + + // Old space to world, then world to new space. + var pointTransform = float4x4.identity; + if (previousParent != null) + { + pointTransform = previousParent.localToWorldMatrix; + } + if (newParent != null) + { + pointTransform = math.mul(newParent.worldToLocalMatrix, pointTransform); + } + + var rotationTransform = quaternion.identity; + if (previousParent != null) + { + rotationTransform = previousParent.rotation; + } + if (newParent != null) + { + rotationTransform = math.mul(math.inverse(new quaternion(newParent.rotation.x, newParent.rotation.y, newParent.rotation.z, newParent.rotation.w)), rotationTransform); + } + + m_CachedNetworkManager.TransformStateManager.ConvertInterpolationSpace(InterpolatorIndex, pointTransform, rotationTransform); + } + /// /// Clears all three interpolators, routed to whichever this instance is using. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private void ClearInterpolators() { + if (InterpolatorIndex >= 0) + { + m_CachedNetworkManager.TransformStateManager.ClearInterpolators(InterpolatorIndex); + return; + } m_ScaleInterpolator.Clear(); m_PositionInterpolator.Clear(); m_RotationInterpolator.Clear(); @@ -2455,6 +2750,11 @@ private void ClearInterpolators() [MethodImpl(MethodImplOptions.AggressiveInlining)] private Vector3 GetInterpolatedPosition() { + if (InterpolatorIndex >= 0) + { + var value = m_CachedNetworkManager.TransformStateManager.GetInterpolatedValue(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Position); + return new Vector3(value.x, value.y, value.z); + } return m_PositionInterpolator.GetInterpolatedValue(); } @@ -2464,6 +2764,11 @@ private Vector3 GetInterpolatedPosition() [MethodImpl(MethodImplOptions.AggressiveInlining)] private Quaternion GetInterpolatedRotation() { + if (InterpolatorIndex >= 0) + { + var value = m_CachedNetworkManager.TransformStateManager.GetInterpolatedValue(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Rotation); + return new Quaternion(value.x, value.y, value.z, value.w); + } return m_RotationInterpolator.GetInterpolatedValue(); } @@ -2473,6 +2778,11 @@ private Quaternion GetInterpolatedRotation() [MethodImpl(MethodImplOptions.AggressiveInlining)] private Vector3 GetInterpolatedScale() { + if (InterpolatorIndex >= 0) + { + var value = m_CachedNetworkManager.TransformStateManager.GetInterpolatedValue(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Scale); + return new Vector3(value.x, value.y, value.z); + } return m_ScaleInterpolator.GetInterpolatedValue(); } @@ -3303,7 +3613,9 @@ private void AxisChangedDeltaPositionCheck() /// /// Skipped while an explicit set or a teleport is pending, since both carry state the next update /// still has to send.

- /// Runs from . Without it the + /// Both synchronization modes have to run this and neither can borrow the other's call: the per + /// instance path runs it from , which a registered instance + /// never reaches, and the batched path from . Without it the /// bitset only ever accumulates, so an axial group that changed once keeps its change flag for the /// life of the instance and is serialized on every state update from then on. ///
@@ -3538,9 +3850,134 @@ private void CleanUpOnDestroyOrDespawn() } DeregisterForTickUpdate(); + DeregisterFromBatchedStateTracking(); + DeregisterFromBatchedInterpolation(); + ReleaseTransformHandle(); CanCommitToTransform = false; } + /// + /// Releases the transform compressed handle. + /// + /// + /// Only the authority that allocates handles puts one back into circulation while the clients with + /// non-authority instances just forgets the TransformHandle. + /// + private void ReleaseTransformHandle() + { + if (m_CachedNetworkManager == null || TransformHandle == TransformHandleAllocator.InvalidHandle) + { + return; + } + + var handles = m_CachedNetworkManager.TransformStateManager.Handles; + if (m_CachedNetworkManager.IsServer) + { + handles.Release(TransformHandle, m_CachedNetworkManager.ServerTime.Time); + } + else + { + handles.Unregister(TransformHandle); + } + TransformHandle = TransformHandleAllocator.InvalidHandle; + } + /// + /// Adds this instance to the 's interpolation when the + /// session is running in . + /// + private void RegisterForBatchedInterpolation() + { + if (m_CachedNetworkManager == null || m_CachedNetworkManager.NetworkConfig.ActiveTransformSyncMode != TransformSyncModes.Batched) + { + return; + } + + // The native interpolator handles this differently and, for now, anything configured with this setting is excluded. + // TODO-JIRA-TICKET: Investigate just ignoring this setting and allowing instances with this flag to be included. + if (SwitchTransformSpaceWhenParented) + { + return; + } + + m_CachedNetworkManager.TransformStateManager.RegisterForInterpolation(this); + } + + /// + /// Deregisters this instance from 's interpolation job. + /// + /// + /// If an instance was registered it MUST be unregistered. This can happen with ownership + /// changes and/or despawning. + /// + private void DeregisterFromBatchedInterpolation() + { + if (m_CachedNetworkManager == null) + { + return; + } + m_CachedNetworkManager.TransformStateManager.DeregisterFromInterpolation(this); + } + + private void RegisterForBatchedStateTracking() + { + if (m_CachedNetworkManager == null || m_CachedNetworkManager.NetworkConfig.ActiveTransformSyncMode != TransformSyncModes.Batched) + { + return; + } + + // TODO-JIRA-TICKET: + // If we had a way to access Rigidbody's position and rotation within a job, then this would + // become less complicated. Alternately, if we had a away to "batch set" a rigid body's + // position and rotation then that would make this less complicated. Finally, we could just + // use the "Remove Rigidbody components from non-authority instances", but that becomes + // problematic when using an owner authoritative motion model and the ownership changes. + // (i.e. if you remove the Rigidbody, then how do you put it back with its original settings?). + // Finally, we could just: + // - Keep the kinematic setting + // - Disable gravity + // - Disable all colliders + // Then just apply values to the transform. If it is using an owner authoritative motion model, + // then upon ownership changing, the NetworkRigidbody handles setting it to non-kinematic and + // we would re-enable gravity and the colliders. +#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D + // A rigidbody driven instance reads its position and rotation from the rigidbody, which a job + // cannot do, so it stays on the per instance path. + if (m_UseRigidbodyForMotion) + { + return; + } +#endif + // A nested instance is force ticked by its parent through TickSyncChildren, which would double up + // with the batched check, so it also stays on the per instance path. + if (IsNested) + { + return; + } + + // Deliberately not gated on being the server. Detecting the delta in a job only reads this + // instance's transform, so a client that owns an owner authoritative instance benefits from it + // just as much. Only the sending differs: assembling a batch per observing client is something + // only the server can do, so CommitDetectedState routes a non-server authority to the per + // instance message, which the server already relays. + m_CachedNetworkManager.TransformStateManager.Register(this); + } + + /// + /// Removes this instance from the . + /// + /// + /// Not conditional on the current : if this instance was registered it + /// has to be removed regardless, and deregistering something that was never registered is a no-op. + /// + private void DeregisterFromBatchedStateTracking() + { + if (m_CachedNetworkManager == null) + { + return; + } + m_CachedNetworkManager.TransformStateManager.Deregister(this); + } + /// public override void OnNetworkDespawn() { @@ -3719,6 +4156,9 @@ internal virtual void InternalInitialization(bool isOwnershipChange = false) m_LastStateTargetPosition = currentPosition; RegisterForTickUpdate(); + RegisterForBatchedStateTracking(); + // Authority interpolates nothing, so make sure it is not also registered for interpolation. + DeregisterFromBatchedInterpolation(); if (UseHalfFloatPrecision && isOwnershipChange && !IsServerAuthoritative() && Interpolate) { @@ -3739,6 +4179,11 @@ internal virtual void InternalInitialization(bool isOwnershipChange = false) m_CachedNetworkManager.NetworkTransformRegistration(NetworkObject, forUpdate, true); // Remove this instance from the tick update DeregisterForTickUpdate(); + // This instance is no longer an authority (this also covers a change of ownership since + // InternalInitialization runs again each time ownership changes). + DeregisterFromBatchedStateTracking(); + // Registered before resetting below so the reset lands on the interpolator that will be used. + RegisterForBatchedInterpolation(); ResetInterpolatedStateToCurrentAuthoritativeState(); m_InternalCurrentPosition = currentPosition; m_LastStateTargetPosition = currentPosition; @@ -3848,6 +4293,9 @@ internal override void InternalOnNetworkObjectParentChanged(NetworkObject parent return; } + // Handle transform space re-parenting transitions for batched transforms. + ConvertBatchedInterpolationSpace(m_PositionInterpolator.Parent, parentNetworkObject?.transform); + InLocalSpace = parentNetworkObject != null; if (SynchronizePosition) @@ -4249,6 +4697,11 @@ private void UpdateInterpolation() { // TODO-JIRA-TICKET: // This could be further optimized by excluding batched transforms from the Update/FixedUpdate invocations. + if (InterpolatorIndex >= 0) + { + return; + } + // Get the InterpolationFrameData for this frame var frameData = m_CachedNetworkManager.TransformInterpolationFrameData; var currentTime = frameData.CurrentTime; @@ -4743,14 +5196,36 @@ internal void TickUpdate() return; } + // + if (m_NetworkManager.NetworkConfig.ActiveTransformSyncMode == TransformSyncModes.Batched) + { + // Batched: every registered instance is checked in parallel and anything that comes back + // dirty will add its state update to the outbound batch. + // TODO-Jira-Ticket: + // Instances that could not be registered (a rigidbody driven one, for example) continue + // to use the managed path and are handled below when ticked. + m_NetworkManager.TransformStateManager.RunDeltaCheck(); + // Everything the delta check committed goes out as one message per observing client, + // after the per instance updates below have had a chance to contribute as well. + // TODO-Testing: + // Create an integration test that validates batched transforms properly handle mixed + // client observers on spawned instances (i.e. clients a, b, and c observe object-1 and + // object-2 but client-d only observes object-1). + } + foreach (var networkTransform in NetworkTransforms) { - if (networkTransform.IsSpawned) + // Anything registered for the batched delta check was already handled above. + if (networkTransform.IsSpawned && networkTransform.StateManagerIndex < 0) { networkTransform.OnNetworkTick(); } } + // Flushed after the per instance updates so that anything they force through TickSyncChildren + // lands in the same tick's batch rather than the next one. + m_NetworkManager.TransformStateManager.SendBatchedStateUpdates(m_NetworkManager); + m_LastTick = CurrentTick; } public NetworkTransformTickRegistration(NetworkManager networkManager) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs new file mode 100644 index 0000000000..a265f4416c --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs @@ -0,0 +1,677 @@ +using System; +using System.Collections.Generic; +using Unity.Collections; +using Unity.Mathematics; +using UnityEngine.Jobs; + +namespace Unity.Netcode.Components +{ + /// + /// Manages the jobs that detect and apply state changes when using + /// mode. + /// + /// + /// One instance per , disposed and replaced when the + /// shuts down.
+ /// A project configured to use mode never registers anything, + /// so the native collections are never allocated.
+ /// The collections run in parallel as two sets, one for the authority instances and one for the + /// non-authority instances. The same index within a set refers to the same + /// .
+ /// A set changes only in its own register and deregister methods, which apply the same swap back to every + /// collection in that set to keep the indices lined up.
+ /// Each instance remembers its own index, for the + /// authority set and for the other, so removing one does + /// not have to search for it. + ///
+ internal class NetworkTransformStateManager : IDisposable + { + private const int k_InitialCapacity = 64; + + /// + /// The registered instances, that are aligned in parallel to and . + /// + private readonly List m_Instances = new List(k_InitialCapacity); + + /// + /// The transform access array for the registered instances. + /// + internal TransformAccessArray TransformAccess; + + /// + /// The most recently sent (authority) or received (non-authority) state per registered instance. + /// + internal NativeList Entries; + + /// + /// The maximum number of measurements an interpolator instance's buffer can hold. + /// + /// + /// A buffer never holds more than the tick latency plus a couple of entries, so this is sized for that + /// with headroom. Overflow drops the oldest measurement, which is the one the interpolator would have + /// consumed and discarded next. + /// + private const int k_InterpolatorBufferCapacity = 32; + + /// + /// Position, rotation and scale. + /// + private const int k_InterpolatorsPerInstance = 3; + + private const int k_ItemsPerInstance = k_InterpolatorBufferCapacity * k_InterpolatorsPerInstance; + + /// + /// The registered non-authority instances, parallel to . + /// + private readonly List m_NonAuthorityInstances = new List(k_InitialCapacity); + + /// + /// The interpolation state per registered non-authority instance. + /// + internal NativeList InterpolationEntries; + + /// + /// The native list, where states are stored, that is used like a ring buffer. + /// + /// + /// An instance at index i owns the range starting at i * k_ItemsPerInstance, which is + /// what lets the interpolation job write into one shared array without the indices aliasing. + /// + internal NativeList BufferedItems; + + /// + /// The bandwidth friendly transform identifiers used to uniquely identify each transform to its managed + /// component. + /// + /// + /// Managed only: + /// Unlike the native collections, it is available without anything having registered. + /// In batched mode a handle is assigned to every synchronized instance, whether or not that instance + /// is eligible for the delta job. + /// + internal readonly TransformHandleAllocator Handles = new TransformHandleAllocator(); + + private bool m_Created; + private bool m_Disposed; + + /// + /// The number of currently registered instances. + /// + /// + /// Kept as the length of the native list as opposed to a separate counter so that it cannot drift. + /// + internal int GetCount() + { + return m_Created ? Entries.Length : 0; + } + + internal NetworkTransform GetInstance(int index) + { + return m_Instances[index]; + } + + private void EnsureCreated() + { + if (m_Created) + { + return; + } + TransformAccess = new TransformAccessArray(k_InitialCapacity); + Entries = new NativeList(k_InitialCapacity, Allocator.Persistent); + InterpolationEntries = new NativeList(k_InitialCapacity, Allocator.Persistent); + BufferedItems = new NativeList(k_InitialCapacity * k_ItemsPerInstance, Allocator.Persistent); + m_Created = true; + } + + /// + /// Registers a non-authority so its interpolation runs within a job. + /// + /// + /// Separate from because the two run at different points (authority on the + /// network tick, non-authority every frame) and need different data. An instance is only ever one or + /// the other, and a change of ownership re-runs + /// , which moves it between the two. + /// + internal void RegisterForInterpolation(NetworkTransform networkTransform) + { + if (m_Disposed || networkTransform.InterpolatorIndex >= 0) + { + return; + } + + EnsureCreated(); + + var index = InterpolationEntries.Length; + networkTransform.InterpolatorIndex = index; + m_NonAuthorityInstances.Add(networkTransform); + + // Give this instance its own slice of the shared measurement storage. + BufferedItems.Length = (index + 1) * k_ItemsPerInstance; + var offset = index * k_ItemsPerInstance; + + InterpolationEntries.Add(new InterpolationEntry() + { + Position = CreateInterpolatorState(offset, InterpolatorValueKind.Vector3), + Rotation = CreateInterpolatorState(offset + k_InterpolatorBufferCapacity, InterpolatorValueKind.Quaternion), + Scale = CreateInterpolatorState(offset + k_InterpolatorBufferCapacity * 2, InterpolatorValueKind.Vector3), + }); + } + + private static NativeInterpolatorState CreateInterpolatorState(int bufferOffset, InterpolatorValueKind valueKind) + { + return new NativeInterpolatorState() + { + BufferOffset = bufferOffset, + BufferCapacity = k_InterpolatorBufferCapacity, + ValueKind = valueKind, + }; + } + + /// + /// Removes a from native interpolation. + /// + internal void DeregisterFromInterpolation(NetworkTransform networkTransform) + { + var index = networkTransform.InterpolatorIndex; + if (m_Disposed || index < 0) + { + return; + } + + networkTransform.InterpolatorIndex = -1; + + var lastIndex = m_NonAuthorityInstances.Count - 1; + var moved = m_NonAuthorityInstances[lastIndex]; + + if (index != lastIndex) + { + // The buffer offsets are derived from the index, so the instance being swapped into this slot + // has to have its measurements moved into this slot's range as well. + var destination = index * k_ItemsPerInstance; + var source = lastIndex * k_ItemsPerInstance; + for (int i = 0; i < k_ItemsPerInstance; i++) + { + BufferedItems[destination + i] = BufferedItems[source + i]; + } + + var movedEntry = InterpolationEntries[lastIndex]; + movedEntry.Position.BufferOffset = destination; + movedEntry.Rotation.BufferOffset = destination + k_InterpolatorBufferCapacity; + movedEntry.Scale.BufferOffset = destination + k_InterpolatorBufferCapacity * 2; + InterpolationEntries[lastIndex] = movedEntry; + + moved.InterpolatorIndex = index; + } + + InterpolationEntries.RemoveAtSwapBack(index); + m_NonAuthorityInstances[index] = moved; + m_NonAuthorityInstances.RemoveAt(lastIndex); + BufferedItems.Length = m_NonAuthorityInstances.Count * k_ItemsPerInstance; + } + + /// + /// Advances the interpolators for every registered non-authority instance. + /// + /// + /// Invoked once per update stage in place of each instance interpolating itself. See + /// for what the job does and does not do. + /// + internal void RunInterpolation() + { + var count = m_NonAuthorityInstances.Count; + if (count == 0) + { + return; + } + + for (int i = 0; i < count; i++) + { + var entry = InterpolationEntries[i]; + m_NonAuthorityInstances[i].PrepareInterpolationEntry(ref entry); + InterpolationEntries[i] = entry; + } + + var job = new InterpolateTransformJob() + { + Entries = InterpolationEntries.AsArray(), + BufferedItems = BufferedItems.AsArray(), + }; + // Explicitly qualified: UnityEngine.Jobs is in scope for TransformAccessArray, and its Schedule + // extension would otherwise be preferred over the IJobParallelFor one. + Jobs.IJobParallelForExtensions.Schedule(job, count, 16).Complete(); + } + + /// + /// A state update waiting to go out in this tick's batch. + /// + /// + /// The state is captured rather than read back from the instance later, because committing a state + /// update clears the teleport and explicit set flags immediately afterwards. Reading it at send time + /// would transmit the already cleared version. + /// + private struct PendingStateUpdate + { + internal NetworkTransform Instance; + internal NetworkTransform.NetworkTransformState State; + } + + private readonly List m_PendingBatch = new List(k_InitialCapacity); + private NetworkTransformBatchMessage m_BatchMessage = new NetworkTransformBatchMessage(); + + /// + /// Queues a detected state update for this tick's batch instead of sending it on its own. + /// + internal void QueueForBatch(NetworkTransform networkTransform, in NetworkTransform.NetworkTransformState state) + { + m_PendingBatch.Add(new PendingStateUpdate() + { + Instance = networkTransform, + State = state, + }); + } + + /// + /// Sends everything queued this tick, one message per observing client. + /// + /// + /// Assembled per client rather than once for everyone because observer sets differ between clients. + /// + internal void SendBatchedStateUpdates(NetworkManager networkManager) + { + if (m_PendingBatch.Count == 0) + { + return; + } + + // Only a client-server session queues anything. CommitDetectedState gates queuing on being the + // server and not in distributed authority mode, so a distributed authority session sends every + // state update per instance instead. Kept as a safety net: dropping is better than a client + // attempting a send it cannot address. + if (networkManager.ShutdownInProgress || !networkManager.IsServer) + { + m_PendingBatch.Clear(); + return; + } + + m_BatchMessage.Manager = this; + + var connectedClients = networkManager.ConnectionManager.ConnectedClientsList; + for (int i = 0; i < connectedClients.Count; i++) + { + var clientId = connectedClients[i].ClientId; + if (clientId == NetworkManager.ServerClientId) + { + continue; + } + + if (!HasAnythingFor(clientId)) + { + continue; + } + + m_BatchMessage.TargetClientId = clientId; + networkManager.MessageManager.SendMessage(ref m_BatchMessage, NetworkDelivery.ReliableFragmentedSequenced, clientId); + } + + m_PendingBatch.Clear(); + } + + /// + /// Whether any queued state update is observed by the given client. + /// + /// + /// 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. + /// + private bool HasAnythingFor(ulong clientId) + { + for (int i = 0; i < m_PendingBatch.Count; i++) + { + var instance = m_PendingBatch[i].Instance; + if (instance != null && instance.NetworkObject != null && instance.NetworkObject.Observers.Contains(clientId)) + { + return true; + } + } + return false; + } + + /// + /// Writes the queued state updates the given client observes. + /// + /// + /// The count is backfilled once the entries are written. + /// + internal void WriteBatch(FastBufferWriter writer, ulong targetClientId) + { + // Written at a fixed width rather than bit packed. The value is not known until the observer + // filtering below has run, and a bit packed placeholder that later needs more bytes would overrun + // the first entry when seeking back. One byte is not worth that failure mode. + var count = (ushort)0; + var countPosition = writer.Position; + writer.WriteValueSafe(count); + + for (int i = 0; i < m_PendingBatch.Count; i++) + { + var pending = m_PendingBatch[i]; + var instance = pending.Instance; + if (instance == null || instance.NetworkObject == null || !instance.NetworkObject.Observers.Contains(targetClientId)) + { + continue; + } + + BytePacker.WriteValueBitPacked(writer, instance.TransformHandle); + writer.WriteNetworkSerializable(pending.State); + count++; + } + + var tailPosition = writer.Position; + writer.Seek(countPosition); + writer.WriteValueSafe(count); + writer.Seek(tailPosition); + } + + /// + /// Identifies which of an instance's three interpolators an operation targets. + /// + internal enum InterpolatorTarget + { + Position, + Rotation, + Scale, + } + + /// + /// The native equivalent of . + /// + internal void AddMeasurement(int index, InterpolatorTarget target, float4 value, double time) + { + var entry = InterpolationEntries[index]; + var items = BufferedItems.AsArray(); + switch (target) + { + case InterpolatorTarget.Position: + NativeInterpolator.AddMeasurement(ref entry.Position, ref items, value, time); + break; + case InterpolatorTarget.Rotation: + NativeInterpolator.AddMeasurement(ref entry.Rotation, ref items, value, time); + break; + default: + NativeInterpolator.AddMeasurement(ref entry.Scale, ref items, value, time); + break; + } + InterpolationEntries[index] = entry; + } + + /// + /// The native equivalent of . + /// + internal void ResetTo(int index, InterpolatorTarget target, float4 value) + { + var entry = InterpolationEntries[index]; + var items = BufferedItems.AsArray(); + switch (target) + { + case InterpolatorTarget.Position: + NativeInterpolator.ResetTo(ref entry.Position, ref items, value); + entry.InterpolatedPosition = value; + break; + case InterpolatorTarget.Rotation: + NativeInterpolator.ResetTo(ref entry.Rotation, ref items, value); + entry.InterpolatedRotation = value; + break; + default: + NativeInterpolator.ResetTo(ref entry.Scale, ref items, value); + entry.InterpolatedScale = value; + break; + } + InterpolationEntries[index] = entry; + } + + /// + /// The native equivalent of clearing all three of an instance's interpolators. + /// + internal void ClearInterpolators(int index) + { + var entry = InterpolationEntries[index]; + NativeInterpolator.Clear(ref entry.Position); + NativeInterpolator.Clear(ref entry.Rotation); + NativeInterpolator.Clear(ref entry.Scale); + InterpolationEntries[index] = entry; + } + + /// + /// Re-expresses an instance's buffered measurements and in flight values in a different transform space. + /// + /// + /// Scale is deliberately not converted: it is a local scale, so it is already parent relative and + /// means the same thing under either parent. The managed interpolator does not convert it either. + /// + internal void ConvertInterpolationSpace(int index, in float4x4 pointTransform, in quaternion rotationTransform) + { + var entry = InterpolationEntries[index]; + var items = BufferedItems.AsArray(); + + NativeInterpolator.ConvertSpace(ref entry.Position, ref items, pointTransform, rotationTransform); + NativeInterpolator.ConvertSpace(ref entry.Rotation, ref items, pointTransform, rotationTransform); + + // The most recently produced results are converted as well, otherwise the value applied on the + // frame of the reparent would still be in the old transform space. + entry.InterpolatedPosition = new float4(math.transform(pointTransform, entry.InterpolatedPosition.xyz), 0.0f); + entry.InterpolatedRotation = math.mul(rotationTransform, new quaternion(entry.InterpolatedRotation)).value; + + InterpolationEntries[index] = entry; + } + + /// + /// Diagnostics for an instance's position interpolator: how many measurements are buffered, whether it + /// has a target, and what the job last produced. + /// + /// + /// Separates "no state is arriving" from "state is arriving but not being advanced or applied", which + /// are otherwise indistinguishable from the outside. + /// + internal string DescribePositionInterpolator(int index) + { + if (index < 0 || !m_Created || index >= InterpolationEntries.Length) + { + return "not registered"; + } + var entry = InterpolationEntries[index]; + var position = entry.Position; + var items = BufferedItems.AsArray(); + var oldest = position.BufferCount > 0 + ? items[position.BufferOffset + position.BufferHead].TimeSent.ToString("F4") + : "none"; + return $"buffered={position.BufferCount} hasTarget={position.HasTarget} " + + $"target={(position.HasTarget ? position.Target.Item.xyz.ToString() : "none")} " + + $"targetStamp={(position.HasTarget ? position.Target.TimeSent.ToString("F4") : "none")} " + + $"oldestStamp={oldest} " + + $"current={position.CurrentValue.xyz} result={entry.InterpolatedPosition.xyz} " + + $"received={position.BufferCounter} syncPos={entry.SynchronizePosition}"; + } + + /// + /// The native equivalent of . + /// + internal float4 GetInterpolatedValue(int index, InterpolatorTarget target) + { + var entry = InterpolationEntries[index]; + switch (target) + { + case InterpolatorTarget.Position: + return entry.InterpolatedPosition; + case InterpolatorTarget.Rotation: + return entry.InterpolatedRotation; + default: + return entry.InterpolatedScale; + } + } + + /// + /// Authority Only:
+ /// Registers a so its state is tracked natively. + ///
+ /// + /// Invoked whenever an instance becomes an authority, which includes ownership changes since + /// runs again on each change of ownership. + /// Registering an instance that is already registered does nothing. + /// + internal void Register(NetworkTransform networkTransform) + { + if (m_Disposed || networkTransform.StateManagerIndex >= 0) + { + return; + } + + EnsureCreated(); + + networkTransform.StateManagerIndex = Entries.Length; + m_Instances.Add(networkTransform); + TransformAccess.Add(networkTransform.transform); + // Seed with whatever the instance has already established so the first delta check compares + // against a real state as opposed to a default one. + Entries.Add(new NetworkTransform.TransformDeltaEntry() + { + State = networkTransform.LocalAuthoritativeNetworkState, + }); + } + + /// + /// Authority Only:
+ /// Deregisters a instance from having its transform deltas tracked. + ///
+ /// + /// Invoked on despawn, destroy, and whenever an instance stops being an authority. + /// + internal void Deregister(NetworkTransform networkTransform) + { + var index = networkTransform.StateManagerIndex; + if (m_Disposed || index < 0) + { + return; + } + + networkTransform.StateManagerIndex = -1; + + var lastIndex = m_Instances.Count - 1; + var moved = m_Instances[lastIndex]; + + // Every collection has to receive the same swap back or they stop referring to the same instance. + Entries.RemoveAtSwapBack(index); + TransformAccess.RemoveAtSwapBack(index); + m_Instances[index] = moved; + m_Instances.RemoveAt(lastIndex); + + // The instance that was swapped into this slot has to be told where it now lives. When the + // instance being removed was already the last one there is nothing to move. + if (index != lastIndex) + { + moved.StateManagerIndex = index; + } + } + + /// + /// Runs the delta check job for every registered instance. + /// + /// + /// Invoked once per network tick in place of iterating the instances and having each one check itself. + /// Each instance contributes what only the main thread can resolve, the job performs the detection in + /// parallel, and anything that came back dirty then sends its state update on the main thread in the + /// same order it would have otherwise.

+ /// The job is completed within this call as opposed to being left in flight: the state update has to + /// be sent on the tick it was detected on, so there is no other work to overlap. + ///
+ internal void RunDeltaCheck() + { + var count = GetCount(); + if (count == 0) + { + return; + } + + // Gather what the job cannot resolve for itself. Written through ElementAt rather than read into + // a local and put back, which copies the whole entry each way. + for (int i = 0; i < count; i++) + { + m_Instances[i].PrepareBatchedDeltaEntry(ref Entries.ElementAt(i)); + } + + var job = new DetectTransformDeltaJob() + { + Entries = Entries.AsArray(), + }; + job.Schedule(TransformAccess).Complete(); + + // Apply the results. Iterated by index rather than by instance so that an instance which + // deregisters as a result of its own state update (a despawn from within a callback) cannot + // invalidate the iteration. Applying reads the entry and never writes to it, so there is nothing + // to put back: the gather above is the only place an entry is seeded from its instance. + for (int i = 0; i < Entries.Length; i++) + { + var instance = m_Instances[i]; + instance.ApplyBatchedDeltaEntry(Entries[i]); + if (instance.StateManagerIndex == i) + { + continue; + } + + // The instance deregistered while applying, which swapped the last registered instance and + // its already completed entry into this slot. Hold the index so that instance is applied on + // the tick it was detected on as opposed to being skipped by the increment. + i--; + } + } + + public void Dispose() + { + if (m_Disposed) + { + return; + } + m_Disposed = true; + + // Clear the cached index on anything still registered so a late deregister is a no-op as opposed + // to indexing into a disposed collection. + for (int i = 0; i < m_Instances.Count; i++) + { + if (m_Instances[i] != null) + { + m_Instances[i].StateManagerIndex = -1; + } + } + m_Instances.Clear(); + + for (int i = 0; i < m_NonAuthorityInstances.Count; i++) + { + if (m_NonAuthorityInstances[i] != null) + { + m_NonAuthorityInstances[i].InterpolatorIndex = -1; + } + } + m_NonAuthorityInstances.Clear(); + Handles.Clear(); + + if (m_Created) + { + if (InterpolationEntries.IsCreated) + { + InterpolationEntries.Dispose(); + } + if (BufferedItems.IsCreated) + { + BufferedItems.Dispose(); + } + if (Entries.IsCreated) + { + Entries.Dispose(); + } + if (TransformAccess.isCreated) + { + TransformAccess.Dispose(); + } + m_Created = false; + } + } + } +} diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs.meta b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs.meta new file mode 100644 index 0000000000..1b07f98434 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e28ff1ecaaa0f2e4b9cc4c8127c5128d \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index e4fa088223..90a1c310db 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -296,6 +296,12 @@ internal void PromoteSessionOwner(ulong clientId) /// per update stage as opposed to once per . /// internal NetworkTransform.InterpolationFrameData TransformInterpolationFrameData; + + /// + /// The manager for native state used by . + /// + internal NetworkTransformStateManager TransformStateManager = new NetworkTransformStateManager(); + internal Dictionary NetworkTransformUpdate = new Dictionary(); #if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D internal Dictionary NetworkTransformFixedUpdate = new Dictionary(); @@ -404,6 +410,10 @@ public void NetworkUpdate(NetworkUpdateStage updateStage) NetworkTransform.RefreshInterpolationFrameData(this); } + // Advance every registered non-authority interpolator in parallel, before the + // instances below read the results and apply them to their transforms. + TransformStateManager.RunInterpolation(); + foreach (var networkObjectEntry in NetworkTransformFixedUpdate) { // if not active or not spawned then skip @@ -450,6 +460,10 @@ public void NetworkUpdate(NetworkUpdateStage updateStage) NetworkTransform.RefreshInterpolationFrameData(this); } + // Advance every registered non-authority interpolator in parallel, before the + // instances below read the results and apply them to their transforms. + TransformStateManager.RunInterpolation(); + // Non-physics based non-authority NetworkTransforms update their states after all other components foreach (var networkObjectEntry in NetworkTransformUpdate) { @@ -1229,6 +1243,15 @@ internal void Initialize(bool server) UpdateTopology(); + // Capture the transform synchronization mode for the session about to start. Everything downstream + // reads the captured value, so a project can expose NetworkConfig.TransformSyncMode in its own pre + // session UI without a mid-session write splitting a running session across both modes. + NetworkConfig.ActiveTransformSyncMode = NetworkConfig.TransformSyncMode; + + // The captured mode is part of the connection configuration hash, so drop anything GetConfig + // cached before the session started or the server compares connecting clients against it. + NetworkConfig.ClearConfigHash(); + // Always create a default session config when starting a NetworkManager instance if (DistributedAuthorityMode) { @@ -1932,6 +1955,12 @@ internal void ShutdownInternal() NetworkConfig?.Prefabs?.Shutdown(); PrefabHandler.Shutdown(); + // Release any native NetworkTransform state and replace the manager so a subsequent session starts + // from a clean one. Dispose clears the cached index on anything still registered, so a despawn + // that arrives after this point deregisters against the new manager as a no-op. + TransformStateManager?.Dispose(); + TransformStateManager = new NetworkTransformStateManager(); + // Reset the configuration hash for next session in the event // that the prefab list changes NetworkConfig?.ClearConfigHash(); diff --git a/com.unity.netcode.gameobjects/Runtime/Messaging/ILPPMessageProvider.cs b/com.unity.netcode.gameobjects/Runtime/Messaging/ILPPMessageProvider.cs index 8d8379dc05..39937cc856 100644 --- a/com.unity.netcode.gameobjects/Runtime/Messaging/ILPPMessageProvider.cs +++ b/com.unity.netcode.gameobjects/Runtime/Messaging/ILPPMessageProvider.cs @@ -42,6 +42,7 @@ internal enum NetworkMessageTypes : uint Unnamed = 22, AnticipationCounterSyncPingMessage = 23, AnticipationCounterSyncPongMessage = 24, + NetworkTransformBatchMessage = 25, } internal struct ILPPMessageProvider : INetworkMessageProvider @@ -83,6 +84,7 @@ internal static Dictionary GetMessageTypesMap() { typeof(ForwardServerRpcMessage), NetworkMessageTypes.ForwardServerRpc }, { typeof(NamedMessage), NetworkMessageTypes.NamedMessage }, { typeof(NetworkTransformMessage), NetworkMessageTypes.NetworkTransformMessage }, + { typeof(NetworkTransformBatchMessage), NetworkMessageTypes.NetworkTransformBatchMessage }, { typeof(NetworkVariableDeltaMessage), NetworkMessageTypes.NetworkVariableDelta }, { typeof(ParentSyncMessage), NetworkMessageTypes.ParentSync }, { typeof(ProxyMessage), NetworkMessageTypes.Proxy }, diff --git a/com.unity.netcode.gameobjects/Runtime/Messaging/MessageDelivery.cs b/com.unity.netcode.gameobjects/Runtime/Messaging/MessageDelivery.cs index cc54d9d102..b7b8a6dae7 100644 --- a/com.unity.netcode.gameobjects/Runtime/Messaging/MessageDelivery.cs +++ b/com.unity.netcode.gameobjects/Runtime/Messaging/MessageDelivery.cs @@ -63,6 +63,7 @@ private static void UpdateMessageTypes() MessageDeliveryType.Initialize(); MessageDeliveryType.Initialize(); MessageDeliveryType.Initialize(); + MessageDeliveryType.Initialize(); MessageDeliveryType.Initialize(); MessageDeliveryType.Initialize(); MessageDeliveryType.Initialize(); diff --git a/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/NetworkTransformBatchMessage.cs b/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/NetworkTransformBatchMessage.cs new file mode 100644 index 0000000000..f2f7898065 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/NetworkTransformBatchMessage.cs @@ -0,0 +1,97 @@ +using Unity.Netcode.Components; +using UnityEngine; + +namespace Unity.Netcode +{ + /// + /// The message that delivers the + /// state updates, per tick, as a single message. + /// + /// + /// - TransformHandle helps reduce bandwidth overhead. + /// - These messages are always delivered reliably. + /// + internal struct NetworkTransformBatchMessage : INetworkMessage + { + public int Version => 0; + private const string k_Name = "NetworkTransformBatchMessage"; + + /// + /// The state manager is set before sending. + /// + internal NetworkTransformStateManager Manager; + + /// + /// Only instances this client observes are written. + /// + internal ulong TargetClientId; + + internal int BytesWritten; + + /// + /// Placeholder to read an entry whose handle does not resolve locally. + /// + /// + /// For batched transforms, we don't worry about deferring messages for + /// received state updates since we currently are synchronizing the full + /// state (i.e. no delta compression). + /// + private NetworkTransform.NetworkTransformState m_Discarded; + + public void Serialize(FastBufferWriter writer, int targetVersion) + { + var startPosition = writer.Position; + Manager.WriteBatch(writer, TargetClientId); + BytesWritten = writer.Position - startPosition; + } + + public bool Deserialize(FastBufferReader reader, ref NetworkContext context, int receivedMessageVersion) + { + var networkManager = context.SystemOwner as NetworkManager; + if (networkManager == null) + { + Debug.LogError($"[{k_Name}] System owner context was not of type {nameof(NetworkManager)}!"); + return false; + } + if (networkManager.ShutdownInProgress) + { + return false; + } + + // Fixed width to match the writer cannot be bit packed. + reader.ReadValueSafe(out ushort count); + var handles = networkManager.TransformStateManager.Handles; + + for (int i = 0; i < count; i++) + { + ByteUnpacker.ReadValueBitPacked(reader, out ushort handle); + + // An entry is applied as it is read rather than being collected and applied in Handle, since + // holding onto every state would mean allocating the in-bound payload per message. + if (handles.TryGet(handle, out var networkTransform) && networkTransform != null) + { + var currentPosition = reader.Position; + reader.ReadNetworkSerializableInPlace(ref networkTransform.InboundState); + networkTransform.InboundState.LastSerializedSize = reader.Position - currentPosition; + networkTransform.TransformStateUpdate(); + continue; + } + + // If the handle does not resolve locally, which can happen while a spawn is still in flight or just + // after a despawn, the entry is read and dropped rather than deferring the whole message. + reader.ReadNetworkSerializableInPlace(ref m_Discarded); + } + + return true; + } + + /// + /// Since states are applied during deserialization, we have nothing + /// to "handle" for this message + /// + public void Handle(ref NetworkContext context) + { + // NOP + } + } +} diff --git a/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/NetworkTransformBatchMessage.cs.meta b/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/NetworkTransformBatchMessage.cs.meta new file mode 100644 index 0000000000..0675667221 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/NetworkTransformBatchMessage.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6987e191b5dfa2b4c959c55b6c8df8b5 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeConfigurationTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeConfigurationTests.cs new file mode 100644 index 0000000000..2f227552bf --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeConfigurationTests.cs @@ -0,0 +1,117 @@ +using System.Collections; +using NUnit.Framework; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Covers the authored reaching the runtime, and staying + /// fixed for the duration of a session. + /// + /// + /// Every other fixture reaches the mode through the same assignment the harness makes, so none of them + /// can see a delivery path that never ran. + /// + // These tests do not need to run against the Rust server, and batching is client-server only. + [IgnoreIfServiceEnvironmentVariableSet] + [TestFixture(TransformSyncModes.PerInstance)] + [TestFixture(TransformSyncModes.Batched)] + internal class NetworkTransformSyncModeConfigurationTests : NetcodeIntegrationTest + { + protected override int NumberOfClients => 1; + + private readonly TransformSyncModes m_SyncMode; + private GameObject m_MoverPrefab; + + public NetworkTransformSyncModeConfigurationTests(TransformSyncModes syncMode) + { + m_SyncMode = syncMode; + } + + internal override TransformSyncModes OnGetSyncMode() + { + return m_SyncMode; + } + + protected override void OnServerAndClientsCreated() + { + m_MoverPrefab = CreateNetworkObjectPrefab("SyncModeMover"); + var networkTransform = m_MoverPrefab.AddComponent(); + networkTransform.AuthorityMode = NetworkTransform.AuthorityModes.Server; + + base.OnServerAndClientsCreated(); + } + + private NetworkTransform SpawnMover() + { + var instance = Object.Instantiate(m_MoverPrefab); + var networkObject = instance.GetComponent(); + networkObject.NetworkManagerOwner = m_ServerNetworkManager; + networkObject.Spawn(); + return networkObject.GetComponent(); + } + + /// + /// A batched instance is registered with the and a per + /// instance one is not, so the index is the observable proof of which path an instance took. + /// + private bool IsRegisteredForBatching(NetworkTransform networkTransform) + { + return networkTransform.StateManagerIndex >= 0; + } + + /// + /// The authored mode has to reach every and route the instances that + /// spawn under it. + /// + [UnityTest] + public IEnumerator AuthoredModeReachesTheRuntime() + { + foreach (var networkManager in m_NetworkManagers) + { + Assert.AreEqual(m_SyncMode, networkManager.NetworkConfig.ActiveTransformSyncMode, + $"{networkManager.name} started with {networkManager.NetworkConfig.ActiveTransformSyncMode} rather than the authored {m_SyncMode}!"); + } + + var mover = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => IsRegisteredForBatching(mover) == (m_SyncMode == TransformSyncModes.Batched)); + AssertOnTimeout($"Authority instance has StateManagerIndex {mover.StateManagerIndex} under {m_SyncMode}!"); + } + + /// + /// Writing the mode while a session is running applies to the next session, not this one. + /// + [UnityTest] + public IEnumerator ModeChangedDuringASessionDoesNotAffectIt() + { + var alreadySpawned = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => IsRegisteredForBatching(alreadySpawned) == (m_SyncMode == TransformSyncModes.Batched)); + AssertOnTimeout($"Authority instance has StateManagerIndex {alreadySpawned.StateManagerIndex} under {m_SyncMode}!"); + + var configHash = m_ServerNetworkManager.NetworkConfig.GetConfig(false); + var otherMode = m_SyncMode == TransformSyncModes.Batched ? TransformSyncModes.PerInstance : TransformSyncModes.Batched; + + foreach (var networkManager in m_NetworkManagers) + { + networkManager.NetworkConfig.TransformSyncMode = otherMode; + } + + foreach (var networkManager in m_NetworkManagers) + { + Assert.AreEqual(m_SyncMode, networkManager.NetworkConfig.ActiveTransformSyncMode, + $"{networkManager.name} changed to {networkManager.NetworkConfig.ActiveTransformSyncMode} while its session was running!"); + } + + Assert.AreEqual(configHash, m_ServerNetworkManager.NetworkConfig.GetConfig(false), + "The connection configuration hash changed while the session was running!"); + + // An instance spawning after the write still follows the mode the session started with. + var afterChange = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => IsRegisteredForBatching(afterChange) == (m_SyncMode == TransformSyncModes.Batched)); + AssertOnTimeout($"An instance spawned after the change has StateManagerIndex {afterChange.StateManagerIndex} under {m_SyncMode}!"); + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeConfigurationTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeConfigurationTests.cs.meta new file mode 100644 index 0000000000..f32cc5eb82 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeConfigurationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5a29c333b758467a92c5f3cd3ad3b2e6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs new file mode 100644 index 0000000000..2fd27db636 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs @@ -0,0 +1,487 @@ +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 +{ + /// + /// Drives the same scenarios through both and asserts that every + /// non-authority instance ends up where the authority is. + /// + /// + /// The two modes share no wire format, no send path, and no interpolator.
+ /// The comparison is the observable outcome rather than the bytes, which legitimately differ. + ///
+ // These tests do not need to run against the Rust server. + [IgnoreIfServiceEnvironmentVariableSet] + [TestFixture(TransformSyncModes.PerInstance, NetworkTransform.AuthorityModes.Server)] + [TestFixture(TransformSyncModes.PerInstance, NetworkTransform.AuthorityModes.Owner)] + [TestFixture(TransformSyncModes.Batched, NetworkTransform.AuthorityModes.Server)] + [TestFixture(TransformSyncModes.Batched, NetworkTransform.AuthorityModes.Owner)] + internal class NetworkTransformSyncModeParityTests : IntegrationTestWithApproximation + { + protected override int NumberOfClients => 3; + + private readonly TransformSyncModes m_SyncMode; + private readonly NetworkTransform.AuthorityModes m_AuthorityMode; + + private GameObject m_MoverPrefab; + private GameObject m_HalfFloatMoverPrefab; + private readonly List m_SpawnedMovers = new List(); + + public NetworkTransformSyncModeParityTests(TransformSyncModes syncMode, NetworkTransform.AuthorityModes authorityMode) + { + m_SyncMode = syncMode; + m_AuthorityMode = authorityMode; + } + + internal override TransformSyncModes OnGetSyncMode() + { + return m_SyncMode; + } + + /// + /// Records whether any state update it received carried the teleport flag. + /// + /// + /// Interpolation reaches the same position either way, so convergence does not distinguish a teleport + /// from a delta. The flag is the only observable difference. + /// + internal class ParityMover : NetworkTransform + { + internal bool ReceivedTeleport; + + internal void ClearReceived() + { + ReceivedTeleport = false; + } + + protected override void OnNetworkTransformStateUpdated(ref NetworkTransformState oldState, ref NetworkTransformState newState) + { + ReceivedTeleport |= newState.IsTeleportingNextFrame; + base.OnNetworkTransformStateUpdated(ref oldState, ref newState); + } + } + + protected override void OnServerAndClientsCreated() + { + m_MoverPrefab = CreateNetworkObjectPrefab("ParityMover"); + var networkTransform = m_MoverPrefab.AddComponent(); + networkTransform.AuthorityMode = m_AuthorityMode; + networkTransform.Interpolate = true; + + // Half float position is a separate prefab rather than a setting flipped after spawning, because + // the delta position baseline is established during synchronization. + m_HalfFloatMoverPrefab = CreateNetworkObjectPrefab("HalfFloatParityMover"); + var halfFloatTransform = m_HalfFloatMoverPrefab.AddComponent(); + halfFloatTransform.AuthorityMode = m_AuthorityMode; + halfFloatTransform.Interpolate = true; + halfFloatTransform.UseHalfFloatPrecision = true; + + base.OnServerAndClientsCreated(); + } + + protected override IEnumerator OnTearDown() + { + m_SpawnedMovers.Clear(); + return base.OnTearDown(); + } + + private NetworkObject SpawnMover(ulong ownerClientId = NetworkManager.ServerClientId, GameObject prefab = null) + { + var instance = Object.Instantiate(prefab ?? m_MoverPrefab); + var networkObject = instance.GetComponent(); + networkObject.NetworkManagerOwner = m_ServerNetworkManager; + networkObject.SpawnWithOwnership(ownerClientId); + m_SpawnedMovers.Add(networkObject); + return networkObject; + } + + /// + /// The instance that is allowed to move the transform, which depends on the authority mode. + /// + private NetworkTransform GetMotionAuthorityInstance(NetworkObject serverSide) + { + if (m_AuthorityMode == NetworkTransform.AuthorityModes.Server || serverSide.OwnerClientId == NetworkManager.ServerClientId) + { + return serverSide.GetComponent(); + } + + // Owner authoritative and owned by a client, so the owning client's clone drives it. It can be + // absent while a spawn or an ownership change is still propagating. + var owner = GetNetworkManagerByClientId(serverSide.OwnerClientId); + if (owner == null || !owner.SpawnManager.SpawnedObjects.TryGetValue(serverSide.NetworkObjectId, out var clone)) + { + return null; + } + return clone.GetComponent(); + } + + /// + /// Whether the instance that should be driving the transform has actually been told it has authority. + /// + /// + /// The server side has ownership applied synchronously. The owning client + /// only learns of it a round trip later. Moving the transform before then is a non-authority write: + /// interpolation discards it and nothing is sent. + /// + private bool MotionAuthorityIsEstablished(NetworkObject serverSide) + { + var authority = GetMotionAuthorityInstance(serverSide); + return authority != null && authority.CanCommitToTransform; + } + + private NetworkManager GetNetworkManagerByClientId(ulong clientId) + { + if (clientId == NetworkManager.ServerClientId) + { + return m_ServerNetworkManager; + } + foreach (var client in m_ClientNetworkManagers) + { + if (client.LocalClientId == clientId) + { + return client; + } + } + Assert.Fail($"No {nameof(NetworkManager)} for client {clientId}!"); + return null; + } + + private bool AllObserversMatch(NetworkObject serverSide, Vector3 expectedPosition, IReadOnlyList expectedObservers) + { + foreach (var manager in expectedObservers) + { + if (!manager.SpawnManager.SpawnedObjects.TryGetValue(serverSide.NetworkObjectId, out var clone)) + { + return false; + } + if (!Approximately(clone.transform.position, expectedPosition)) + { + return false; + } + } + return true; + } + + /// + /// Every instance, not just the server side one, reports the expected owner. + /// + private bool AllInstancesAgreeOnOwner(NetworkObject serverSide, ulong expectedOwner) + { + foreach (var manager in m_NetworkManagers) + { + if (!manager.SpawnManager.SpawnedObjects.TryGetValue(serverSide.NetworkObjectId, out var clone)) + { + return false; + } + if (clone.OwnerClientId != expectedOwner) + { + return false; + } + } + return true; + } + + private IEnumerator MoveAndConverge(NetworkObject serverSide, Vector3 target, IReadOnlyList observers) + { + yield return WaitForConditionOrTimeOut(() => MotionAuthorityIsEstablished(serverSide)); + AssertOnTimeout($"[{m_SyncMode}][{m_AuthorityMode}] The motion authority instance never gained authority!"); + + var authority = GetMotionAuthorityInstance(serverSide); + authority.transform.position = target; + + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(serverSide, target, observers)); + AssertOnTimeout($"[{m_SyncMode}][{m_AuthorityMode}] Not every observer reached {target}!\n{DescribeObservers(serverSide, target, observers)}"); + } + + /// + /// Reports where each instance actually is, so a convergence failure names the instance left behind. + /// + private string DescribeObservers(NetworkObject serverSide, Vector3 expectedPosition, IReadOnlyList observers) + { + var builder = new System.Text.StringBuilder(); + builder.AppendLine($" expected {expectedPosition}, owner is client {serverSide.OwnerClientId}"); + foreach (var manager in observers) + { + var role = manager.IsServer ? "server" : $"client-{manager.LocalClientId}"; + if (!manager.SpawnManager.SpawnedObjects.TryGetValue(serverSide.NetworkObjectId, out var clone)) + { + builder.AppendLine($" {role}: object not spawned"); + continue; + } + + var networkTransform = clone.GetComponent(); + var matches = Approximately(clone.transform.position, expectedPosition) ? "OK " : "BAD"; + // The two indices discriminate between "never registered for the batched interpolation" and + // "registered but the results are not being applied". + builder.AppendLine($" {role}: {matches} pos={clone.transform.position} owner={clone.OwnerClientId} " + + $"canCommit={networkTransform.CanCommitToTransform} isOwner={clone.IsOwner} " + + $"stateIdx={networkTransform.StateManagerIndex} interpIdx={networkTransform.InterpolatorIndex}"); + if (networkTransform.InterpolatorIndex >= 0) + { + builder.AppendLine($" interp: {manager.TransformStateManager.DescribePositionInterpolator(networkTransform.InterpolatorIndex)}"); + } + } + return builder.ToString(); + } + + /// + /// The baseline: a moving object reaches every observer in both modes. + /// + [UnityTest] + public IEnumerator MotionReachesEveryObserver() + { + var mover = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(mover, mover.transform.position, m_NetworkManagers)); + AssertOnTimeout("Initial spawn did not reach every client!"); + + yield return MoveAndConverge(mover, new Vector3(3.0f, 1.5f, -2.0f), m_NetworkManagers); + yield return MoveAndConverge(mover, new Vector3(-6.25f, 4.0f, 8.5f), m_NetworkManagers); + } + + /// + /// Owner authoritative instances owned by a client. + /// + /// + /// Batched mode leaves these on the per instance path: the batch is assembled per observing client and + /// sent directly, which only the server can do.
+ /// The exclusion is invisible at runtime. Get it wrong and the transform stops replicating with no error. + ///
+ [UnityTest] + public IEnumerator ClientOwnedInstanceStillReplicates() + { + var mover = SpawnMover(m_ClientNetworkManagers[0].LocalClientId); + + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(mover, mover.transform.position, m_NetworkManagers)); + AssertOnTimeout("Client owned instance did not spawn on every client!"); + + yield return MoveAndConverge(mover, new Vector3(5.0f, 2.0f, 1.0f), m_NetworkManagers); + } + + /// + /// Two objects where one is hidden from a single client. + /// + /// + /// The batched message is assembled per client, so this is what proves the observer filtering.
+ /// A filtering mistake shows up as the hidden object appearing, or as the whole batch failing to + /// deserialize for that client and every object freezing. + ///
+ [UnityTest] + public IEnumerator MixedObserversReceiveOnlyWhatTheyObserve() + { + var visibleToAll = SpawnMover(); + var hiddenFromOne = SpawnMover(); + + var hiddenFrom = m_ClientNetworkManagers[2]; + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(hiddenFromOne, hiddenFromOne.transform.position, m_NetworkManagers)); + AssertOnTimeout("Second object did not reach every client before being hidden!"); + + hiddenFromOne.NetworkHide(hiddenFrom.LocalClientId); + yield return WaitForConditionOrTimeOut(() => !hiddenFrom.SpawnManager.SpawnedObjects.ContainsKey(hiddenFromOne.NetworkObjectId)); + AssertOnTimeout("Object was not hidden from the target client!"); + + // Everyone still observing the hidden object has to keep receiving it. + var stillObserving = new List { m_ServerNetworkManager, m_ClientNetworkManagers[0], m_ClientNetworkManagers[1] }; + yield return MoveAndConverge(hiddenFromOne, new Vector3(9.0f, 3.0f, -4.0f), stillObserving); + + // And the client it is hidden from has to keep receiving the object it can still see, which is + // what breaks if a mis-sized batch desynchronizes that client's reader. + yield return MoveAndConverge(visibleToAll, new Vector3(-2.0f, 6.0f, 7.0f), m_NetworkManagers); + + // Bringing it back has to resume delivery to the client it was hidden from. + hiddenFromOne.NetworkShow(hiddenFrom.LocalClientId); + yield return WaitForConditionOrTimeOut(() => hiddenFrom.SpawnManager.SpawnedObjects.ContainsKey(hiddenFromOne.NetworkObjectId)); + AssertOnTimeout("Object was not shown again to the target client!"); + + yield return MoveAndConverge(hiddenFromOne, new Vector3(1.0f, 1.0f, 1.0f), m_NetworkManagers); + } + + private List GetNonAuthorityMovers(NetworkObject serverSide) + { + var authority = GetMotionAuthorityInstance(serverSide); + var movers = new List(); + foreach (var manager in m_NetworkManagers) + { + if (!manager.SpawnManager.SpawnedObjects.TryGetValue(serverSide.NetworkObjectId, out var clone)) + { + continue; + } + var mover = clone.GetComponent(); + if (mover != authority) + { + movers.Add(mover); + } + } + return movers; + } + + /// + /// A teleport has to arrive as a teleport rather than being interpolated towards. + /// + /// + /// Teleports take a different route through both the delta check and the interpolator reset paths, and + /// the batched path captures the state before the teleport flag is cleared.
+ /// A teleport that arrives as an ordinary delta converges to the same place, so the flag is asserted + /// rather than the destination. + ///
+ [UnityTest] + public IEnumerator TeleportArrivesAsATeleport() + { + var mover = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(mover, mover.transform.position, m_NetworkManagers)); + AssertOnTimeout("Initial spawn did not reach every client!"); + + // The spawn synchronization is itself a teleport, so the recorders start from the state that + // follows it rather than from the state at spawn. + var observers = GetNonAuthorityMovers(mover); + foreach (var observer in observers) + { + observer.ClearReceived(); + } + + var authority = GetMotionAuthorityInstance(mover); + var target = new Vector3(120.0f, 45.0f, -85.0f); + authority.SetState(target, null, null, false); + + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(mover, target, m_NetworkManagers)); + AssertOnTimeout($"Teleport to {target} did not reach every client!"); + + foreach (var observer in observers) + { + Assert.IsTrue(observer.ReceivedTeleport, + $"[{m_SyncMode}][{m_AuthorityMode}] Client-{observer.NetworkManager.LocalClientId} reached {target} " + + "without ever receiving a state flagged as a teleport, so it glided there instead of snapping!"); + } + } + + /// + /// Re-enabling an axis that drifted while it was off has to arrive as a teleport. + /// + /// + /// While an axis is disabled the authority keeps moving but stops sending that axis, so the half float + /// delta it would resume from is stale by more than the delta can represent.
+ /// X and Z are moved and re-enabled together because the check accumulates its result per axis. + ///
+ [UnityTest] + public IEnumerator ReEnablingADriftedAxisTeleports() + { + var mover = SpawnMover(prefab: m_HalfFloatMoverPrefab); + yield return WaitForConditionOrTimeOut(() => MotionAuthorityIsEstablished(mover)); + AssertOnTimeout("The motion authority instance never gained authority!"); + + yield return MoveAndConverge(mover, new Vector3(1.0f, 1.0f, 1.0f), m_NetworkManagers); + var authority = (ParityMover)GetMotionAuthorityInstance(mover); + + // X and Z stop being sent, and Y keeps moving so that position updates keep flowing. The axis + // registration the delta position holds is only rewritten on a tick where the position is dirty, + // so without the Y motion the authority would never record that X and Z went quiet. + authority.SyncPositionX = false; + authority.SyncPositionZ = false; + for (int i = 0; i < 6; i++) + { + var position = authority.transform.position; + authority.transform.position = new Vector3(position.x + 100.0f, position.y + 0.25f, position.z); + yield return s_DefaultWaitForTick; + } + + var observers = GetNonAuthorityMovers(mover); + foreach (var observer in observers) + { + observer.ClearReceived(); + } + + // Z is re-enabled alongside X and has not moved, so its in-range result must not mask the + // out-of-range one X produces. + authority.SyncPositionX = true; + authority.SyncPositionZ = true; + + var target = authority.transform.position; + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(mover, target, m_NetworkManagers)); + AssertOnTimeout($"[{m_SyncMode}][{m_AuthorityMode}] Re-enabled axes never converged on {target}!\n{DescribeObservers(mover, target, m_NetworkManagers)}"); + + foreach (var observer in observers) + { + Assert.IsTrue(observer.ReceivedTeleport, + $"[{m_SyncMode}][{m_AuthorityMode}] Client-{observer.NetworkManager.LocalClientId} resumed the drifted axis " + + "without a teleport, so it resumed from a half float delta that can no longer represent the distance!"); + } + } + + /// + /// Ownership moving between clients mid session. + /// + /// + /// A change of ownership re-runs initialization, which moves an instance between the delta tracking + /// and interpolation registrations. Under owner authority it also moves between the batched and per + /// instance send paths.
+ /// The handle has to survive that. It is allocated once and is not reassigned on ownership change. + ///
+ [UnityTest] + public IEnumerator OwnershipChangeKeepsReplicating() + { + var mover = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(mover, mover.transform.position, m_NetworkManagers)); + AssertOnTimeout("Initial spawn did not reach every client!"); + + yield return MoveAndConverge(mover, new Vector3(2.0f, 2.0f, 2.0f), m_NetworkManagers); + + mover.ChangeOwnership(m_ClientNetworkManagers[1].LocalClientId); + // Waited for on every instance, not just the server side object. The server applies ownership + // synchronously, so waiting on it would let the test proceed before the new owner knows it owns + // anything. + yield return WaitForConditionOrTimeOut(() => AllInstancesAgreeOnOwner(mover, m_ClientNetworkManagers[1].LocalClientId)); + AssertOnTimeout("Ownership did not transfer to every instance!"); + + yield return MoveAndConverge(mover, new Vector3(-4.0f, 5.0f, 3.0f), m_NetworkManagers); + + // And back to the server, which under owner authority moves it from the per instance path onto + // the batched one. + mover.ChangeOwnership(NetworkManager.ServerClientId); + yield return WaitForConditionOrTimeOut(() => AllInstancesAgreeOnOwner(mover, NetworkManager.ServerClientId)); + AssertOnTimeout("Ownership did not transfer back to every instance!"); + + yield return MoveAndConverge(mover, new Vector3(7.0f, -1.0f, 0.5f), m_NetworkManagers); + } + + /// + /// Despawning and respawning, which exercises handle release and reuse. + /// + /// + /// Handles are held for several seconds before being reissued, so a respawn inside that window has to + /// receive a different handle.
+ /// Sharing one would have the surviving object and the new one fight over the same address. + ///
+ [UnityTest] + public IEnumerator DespawnAndRespawnDoNotShareAHandle() + { + var first = SpawnMover(); + var second = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(second, second.transform.position, m_NetworkManagers)); + AssertOnTimeout("Initial spawns did not reach every client!"); + + yield return MoveAndConverge(first, new Vector3(10.0f, 0.0f, 0.0f), m_NetworkManagers); + + first.Despawn(); + yield return WaitForConditionOrTimeOut(() => !m_ClientNetworkManagers[0].SpawnManager.SpawnedObjects.ContainsKey(first.NetworkObjectId)); + AssertOnTimeout("Despawn did not reach the clients!"); + + // Respawn immediately, inside the window where the released handle is still being held. + var third = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(third, third.transform.position, m_NetworkManagers)); + AssertOnTimeout("Respawned object did not reach every client!"); + + // If the new object had inherited the despawned one's handle, moving it would drag the survivor + // with it, so both are checked. + var secondPosition = second.transform.position; + yield return MoveAndConverge(third, new Vector3(-15.0f, 2.0f, 6.0f), m_NetworkManagers); + + Assert.IsTrue(AllObserversMatch(second, secondPosition, m_NetworkManagers), + $"[{m_SyncMode}] Moving the respawned object also moved an unrelated one, which means they share a handle!"); + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs.meta new file mode 100644 index 0000000000..868b620615 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fe70eeb6a2cb4684db3d052f19926c5e \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs index 7170782a10..c33b26ac3c 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs @@ -851,6 +851,9 @@ public IEnumerator SetUp() ConfigureFramesPerTick(); } + // Get the transform synchronization mode before setup. + GetSyncMode(); + if (m_SetupIsACoroutine) { yield return OnSetup(); @@ -985,6 +988,19 @@ internal virtual bool ShouldCreatePlayerPrefab() return true; } + internal TransformSyncModes SyncMode { get; private set; } + + internal virtual TransformSyncModes OnGetSyncMode() + { + // Always default to per instance + return TransformSyncModes.PerInstance; + } + + private void GetSyncMode() + { + SyncMode = OnGetSyncMode(); + } + /// /// Creates the server and clients /// @@ -1033,6 +1049,7 @@ protected void CreateServerAndClients(int numberOfClients) // Set the player prefab for the server and clients foreach (var manager in m_NetworkManagers) { + manager.NetworkConfig.TransformSyncMode = SyncMode; manager.NetworkConfig.PlayerPrefab = m_PlayerPrefab; SetDistributedAuthorityProperties(manager); #if UNIFIED_NETCODE @@ -1125,6 +1142,7 @@ protected virtual bool ShouldWaitForNewClientToConnect(NetworkManager networkMan protected NetworkManager CreateNewClient() { var networkManager = NetcodeIntegrationTestHelpers.CreateNewClient(m_ClientNetworkManagers.Length, m_UseMockTransport, m_UseCmbService); + networkManager.NetworkConfig.TransformSyncMode = SyncMode; networkManager.NetworkConfig.PlayerPrefab = m_PlayerPrefab; SetDistributedAuthorityProperties(networkManager); From a1db4ec27067c135afda4cca766f02cc989be148 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Thu, 29 Oct 2026 11:22:45 -0500 Subject: [PATCH 2/2] chore: CHANGELOG entry for NetworkConfig.TransformSyncMode --- com.unity.netcode.gameobjects/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 0a3a124e10..417d7755ed 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -10,6 +10,7 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Added +- Added `NetworkConfig.TransformSyncMode`, which determines whether `NetworkTransform` instances detect and synchronize their state individually or as a single batched message per tick. It is set per `NetworkManager` under "Transform Synchronization" in the inspector or from script before a session starts, and the value the session starts with applies for the duration of that session. Every peer in a session has to use the same mode, which the connection configuration hash enforces. (#4235) - Added alignment of the Netcode for Entities tick rates with `NetworkConfig.TickRate` when a session carrying `GhostObject` prefabs is started, so `GhostObject` updates land on the same interval as the rest of Netcode for GameObjects. (#4144) - Added `NetworkConfig.AutoConfigureSnapshotSize`, enabled by default, which sets the Netcode for Entities snapshot packet size to 4096 bytes when a session carrying `GhostObject` prefabs is started. Netcode for Entities defaults to a single MTU, which updates `GhostObject`s well below the tick rate at the instance counts Netcode for GameObjects projects typically run. Disable it to manage the value yourself. (#4144)