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)
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);