diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs
index 05f83876bd..ab8ac55937 100644
--- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs
+++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs
@@ -35,6 +35,16 @@ private void OnEnable()
[SerializeField]
public bool GenerateDefaultNetworkPrefabs = true;
+ ///
+ /// The project wide that is applied to .
+ ///
+ ///
+ /// The two modes are not wire compatible with one another, so this is authored once for the project as
+ /// opposed to per .
+ ///
+ [SerializeField]
+ public TransformSyncModes TransformSyncMode = TransformSyncModes.PerInstance;
+
internal void SaveSettings()
{
Save(true);
diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs
index a8b521117c..efc2454cbe 100644
--- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs
+++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.IO;
+using Unity.Netcode.Components;
using UnityEditor;
using UnityEngine;
using Directory = UnityEngine.Windows.Directory;
@@ -132,6 +133,7 @@ private static void OnGuiHandler(string obj)
var settings = NetcodeForGameObjectsProjectSettings.instance;
var generateDefaultPrefabs = settings.GenerateDefaultNetworkPrefabs;
var networkPrefabsPath = settings.TempNetworkPrefabsPath;
+ var transformSyncMode = settings.TransformSyncMode;
EditorGUI.BeginChangeCheck();
@@ -192,6 +194,26 @@ private static void OnGuiHandler(string obj)
networkPrefabsPath,
GUILayout.Width(s_MaxLabelWidth + 270));
GUILayout.EndVertical();
+
+ GUILayout.BeginVertical("Box");
+ GUILayout.Label("NetworkTransform Synchronization", EditorStyles.boldLabel);
+ transformSyncMode = (TransformSyncModes)EditorGUILayout.EnumPopup(
+ new GUIContent(
+ "Synchronization Mode",
+ "Determines how NetworkTransform instances detect and synchronize their state. " +
+ "Batched mode detects changes for all instances within a job and sends them as a single message per tick. " +
+ "This is a global setting for all NetworkTransforms since the two modes are not compatible on a per instance basis."),
+ transformSyncMode,
+ GUILayout.Width(s_MaxLabelWidth + 120));
+
+ if (transformSyncMode == TransformSyncModes.Batched)
+ {
+ EditorGUILayout.HelpBox(
+ $"{nameof(NetworkTransform.UseUnreliableDeltas)} does not apply in this mode and will no longer be visible when viewing " +
+ "NetworkTransform in the inspector view. Delivery is determined per state update as opposed to per component.",
+ MessageType.Info);
+ }
+ GUILayout.EndVertical();
}
EditorGUILayout.EndFoldoutHeaderGroup();
GUILayout.EndVertical();
@@ -202,6 +224,7 @@ private static void OnGuiHandler(string obj)
NetcodeForGameObjectsEditorSettings.SetNetcodeInstallMultiplayerToolTips(multiplayerToolsTipStatus ? 0 : 1);
settings.GenerateDefaultNetworkPrefabs = generateDefaultPrefabs;
settings.TempNetworkPrefabsPath = networkPrefabsPath;
+ settings.TransformSyncMode = transformSyncMode;
settings.SaveSettings();
}
}
diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/TransformSyncModeProcessor.cs b/com.unity.netcode.gameobjects/Editor/Configuration/TransformSyncModeProcessor.cs
new file mode 100644
index 0000000000..77cb2355d9
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Editor/Configuration/TransformSyncModeProcessor.cs
@@ -0,0 +1,65 @@
+using Unity.Netcode.GameObjects.Editor.Configuration;
+using UnityEditor;
+using UnityEditor.Build;
+using UnityEditor.Build.Reporting;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+
+namespace Unity.Netcode.Editor
+{
+ ///
+ /// Applies the project wide to the
+ /// .
+ ///
+ ///
+ /// This runs both when entering play mode and while building, and operates on the scene being processed as
+ /// opposed to the authored asset, so it never dirties a user's scene.
+ ///
+ internal class SetTransformSyncMode : IProcessSceneWithReport
+ {
+ public int callbackOrder => 0;
+
+ public void OnProcessScene(Scene scene, BuildReport report)
+ {
+ var transformSyncMode = NetcodeForGameObjectsProjectSettings.instance.TransformSyncMode;
+ foreach (var networkManager in FindObjects.FromSceneByType(scene, true))
+ {
+ if (networkManager.NetworkConfig == null)
+ {
+ continue;
+ }
+ networkManager.NetworkConfig.TransformSyncMode = transformSyncMode;
+ }
+ }
+ }
+
+ ///
+ /// Applies the project wide to any
+ /// within a prefab as the prefab will be is imported.
+ ///
+ ///
+ /// Covers projects that instantiate their from a prefab as opposed to placing
+ /// it in a scene.
+ ///
+ internal class TransformSyncModePrefabProcessor : AssetPostprocessor
+ {
+ public void OnPostprocessPrefab(GameObject root)
+ {
+ var networkManagers = root.GetComponentsInChildren(true);
+ if (networkManagers.Length == 0)
+ {
+ return;
+ }
+
+ var transformSyncMode = NetcodeForGameObjectsProjectSettings.instance.TransformSyncMode;
+ foreach (var networkManager in networkManagers)
+ {
+ if (networkManager.NetworkConfig == null)
+ {
+ continue;
+ }
+ networkManager.NetworkConfig.TransformSyncMode = transformSyncMode;
+ }
+ }
+ }
+}
diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/TransformSyncModeProcessor.cs.meta b/com.unity.netcode.gameobjects/Editor/Configuration/TransformSyncModeProcessor.cs.meta
new file mode 100644
index 0000000000..60027ab941
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Editor/Configuration/TransformSyncModeProcessor.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 64427fa980d0a294a960c4e8717a37b5
\ No newline at end of file
diff --git a/com.unity.netcode.gameobjects/Editor/NetworkTransformEditor.cs b/com.unity.netcode.gameobjects/Editor/NetworkTransformEditor.cs
index 1fcb77f709..e544c00c4a 100644
--- a/com.unity.netcode.gameobjects/Editor/NetworkTransformEditor.cs
+++ b/com.unity.netcode.gameobjects/Editor/NetworkTransformEditor.cs
@@ -1,5 +1,6 @@
using System.Runtime.CompilerServices;
using Unity.Netcode.Components;
+using Unity.Netcode.GameObjects.Editor.Configuration;
using UnityEditor;
using UnityEngine;
@@ -216,32 +217,42 @@ private void DisplayNetworkTransformProperties()
EditorGUILayout.Space();
EditorGUILayout.LabelField("Delivery", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(m_TickSyncChildren);
- // If both are set from a previous configuration, then SwitchTransformSpaceWhenParented takes
- // precedence.
- if (networkTransform.UseUnreliableDeltas && networkTransform.SwitchTransformSpaceWhenParented)
- {
- networkTransform.UseUnreliableDeltas = false;
- }
- SetGUIActive(!networkTransform.SwitchTransformSpaceWhenParented);
- if (networkTransform.SwitchTransformSpaceWhenParented)
- {
- EditorGUILayout.BeginHorizontal();
- EditorGUILayout.PropertyField(m_UseUnreliableDeltas);
- EditorGUILayout.LabelField($"Cannot use with {nameof(NetworkTransform.SwitchTransformSpaceWhenParented)}.");
- EditorGUILayout.EndHorizontal();
- }
- else
+
+ // UseUnreliableDeltas only applies to per instance synchronization mode. Under the batched mode
+ // delivery is determined per state update as opposed to per component, so the property (and
+ // everything it constrains) is hidden. See Project Settings -> Multiplayer -> Netcode for GameObjects.
+ var perInstanceSync = NetcodeForGameObjectsProjectSettings.instance.TransformSyncMode == TransformSyncModes.PerInstance;
+ if (perInstanceSync)
{
- EditorGUILayout.PropertyField(m_UseUnreliableDeltas);
- }
+ // If both are set from a previous configuration, then SwitchTransformSpaceWhenParented takes
+ // precedence.
+ if (networkTransform.UseUnreliableDeltas && networkTransform.SwitchTransformSpaceWhenParented)
+ {
+ networkTransform.UseUnreliableDeltas = false;
+ }
+ SetGUIActive(!networkTransform.SwitchTransformSpaceWhenParented);
+ if (networkTransform.SwitchTransformSpaceWhenParented)
+ {
+ EditorGUILayout.BeginHorizontal();
+ EditorGUILayout.PropertyField(m_UseUnreliableDeltas);
+ EditorGUILayout.LabelField($"Cannot use with {nameof(NetworkTransform.SwitchTransformSpaceWhenParented)}.");
+ EditorGUILayout.EndHorizontal();
+ }
+ else
+ {
+ EditorGUILayout.PropertyField(m_UseUnreliableDeltas);
+ }
- SetGUIActive(true);
+ SetGUIActive(true);
+ }
EditorGUILayout.Space();
EditorGUILayout.LabelField("Configurations", EditorStyles.boldLabel);
- SetGUIActive(!networkTransform.UseUnreliableDeltas);
- if (networkTransform.UseUnreliableDeltas)
+ // SwitchTransformSpaceWhenParented is only constrained by UseUnreliableDeltas while the latter applies.
+ var blockedByUnreliableDeltas = perInstanceSync && networkTransform.UseUnreliableDeltas;
+ SetGUIActive(!blockedByUnreliableDeltas);
+ if (blockedByUnreliableDeltas)
{
EditorGUILayout.BeginHorizontal();
EditorGUILayout.PropertyField(m_SwitchTransformSpaceWhenParented);
@@ -256,7 +267,10 @@ private void DisplayNetworkTransformProperties()
if (m_SwitchTransformSpaceWhenParented.boolValue)
{
m_TickSyncChildren.boolValue = true;
- networkTransform.UseUnreliableDeltas = false;
+ if (perInstanceSync)
+ {
+ networkTransform.UseUnreliableDeltas = false;
+ }
}
else
{
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..cd6a79fb06
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs
@@ -0,0 +1,57 @@
+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 parallel.
+ ///
+ ///
+ /// This reads each transform and defers to the very same
+ ///
+ /// that the per instance path runs on the main thread to keep the logic between per instance and batched the same.
+ /// Only transform values are read here and only the entries array is written, so there is no hierarchy
+ /// write hazard: nothing in this job touches a transform other than the one at its own index, and nothing
+ /// writes to a transform at all.
+ ///
+ [BurstCompile]
+ internal struct DetectTransformDeltaJob : IJobParallelForTransform
+ {
+ ///
+ /// The per instance input and output, parallel to the transforms this job is scheduled over.
+ ///
+ public NativeArray Entries;
+
+ public void Execute(int index, TransformAccess transform)
+ {
+ if (!transform.isValid)
+ {
+ return;
+ }
+
+ var entry = Entries[index];
+ var flagStates = entry.State.FlagStates;
+ var forceState = entry.ForceState;
+
+ // 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;
+
+ // A rigidbody driven instance cannot be sampled from here, so it is never registered for the
+ // batched path and always falls back to the per instance flow.
+ 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/HalfVector3.cs b/com.unity.netcode.gameobjects/Runtime/Components/HalfVector3.cs
index fa7d1c9fb3..3ae97ed5e4 100644
--- a/com.unity.netcode.gameobjects/Runtime/Components/HalfVector3.cs
+++ b/com.unity.netcode.gameobjects/Runtime/Components/HalfVector3.cs
@@ -96,16 +96,19 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Vector3 ToVector3()
{
- Vector3 fullPrecision = Vector3.zero;
- Vector3 fullConversion = math.float3(Axis);
- for (int i = 0; i < Length; i++)
- {
- if (AxisToSynchronize[i])
- {
- fullPrecision[i] = fullConversion[i];
- }
- }
- return fullPrecision;
+ return ToFloat3(Axis, AxisToSynchronize);
+ }
+
+ ///
+ /// The based implementation of .
+ ///
+ ///
+ /// This is a job safe method to be used in place of .
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static float3 ToFloat3(half3 axis, bool3 axisToSynchronize)
+ {
+ return math.select(float3.zero, math.float3(axis), axisToSynchronize);
}
///
@@ -115,14 +118,23 @@ public Vector3 ToVector3()
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void UpdateFrom(ref Vector3 vector3)
{
- var half3Full = math.half3(vector3);
- for (int i = 0; i < Length; i++)
- {
- if (AxisToSynchronize[i])
- {
- Axis[i] = half3Full[i];
- }
- }
+ Axis = UpdatedAxis(Axis, math.float3(vector3), AxisToSynchronize);
+ }
+
+ ///
+ /// The based implementation of .
+ ///
+ ///
+ /// This is a job safe method to be used in place of .
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static half3 UpdatedAxis(half3 axis, float3 value, bool3 axisToSynchronize)
+ {
+ var updated = math.half3(value);
+ axis.x = axisToSynchronize.x ? updated.x : axis.x;
+ axis.y = axisToSynchronize.y ? updated.y : axis.y;
+ axis.z = axisToSynchronize.z ? updated.z : axis.z;
+ return axis;
}
///
diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/BufferedLinearInterpolator.cs b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/BufferedLinearInterpolator.cs
index 8ee288b02f..5170d95b02 100644
--- a/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/BufferedLinearInterpolator.cs
+++ b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/BufferedLinearInterpolator.cs
@@ -276,7 +276,24 @@ internal void ResetTo(Transform parent, T targetValue, double serverTime)
{
// Clear the interpolator
Clear();
- InternalReset(parent, targetValue, serverTime);
+
+ // The baseline measurement is deliberately not seeded here. Callers stamp it with
+ // NetworkManager.ServerTime.Time (the local current time) while the measurements that follow are
+ // stamped with the tick they were authored on (NetworkTransformState.SentTime), which is always at
+ // least a tick older. Seeding the baseline therefore establishes an ordering floor that later
+ // measurements cannot clear: AddMeasurement drops anything not newer than m_LastMeasurementAddedTime,
+ // and TryConsumeFromBuffer drops anything not newer than InterpolateState.Target.TimeSent.
+ //
+ // This only reaches an instance that resets part way through a session, which in practice means one
+ // that just stopped being the authority (in a client server topology, only ever the server). Such an
+ // instance would otherwise reject everything the new authority sends until a measurement happens to
+ // be authored on a later tick than the reset, and if motion has already stopped that never arrives.
+ //
+ // Clear() has left the buffer empty with a zeroed m_LastMeasurementAddedTime, and InternalReset seeds
+ // CurrentValue/NextValue/PreviousValue below, so the value is still held. That is exactly the state a
+ // freshly spawned interpolator is in: the first measurement to arrive is taken unconditionally
+ // because m_BufferCount is zero, and it is consumed against render time alone.
+ InternalReset(parent, targetValue, serverTime, false);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/InterpolateTransformJob.cs b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/InterpolateTransformJob.cs
new file mode 100644
index 0000000000..893aeed9b5
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/InterpolateTransformJob.cs
@@ -0,0 +1,111 @@
+using Unity.Burst;
+using Unity.Collections;
+using Unity.Jobs;
+using Unity.Mathematics;
+
+namespace Unity.Netcode.Components
+{
+ ///
+ /// The NGO non-authority instance's transform state used by .
+ /// See also:
+ /// -
+ /// -
+ ///
+ internal struct InterpolationEntry
+ {
+ internal NativeInterpolatorState Position;
+ internal NativeInterpolatorState Rotation;
+ internal NativeInterpolatorState Scale;
+
+ ///
+ /// The delta frame time whether fixed or standard delta.
+ ///
+ internal float DeltaTime;
+
+ ///
+ /// The "ticks ago" time used to decide which buffered measurements are ready to consume.
+ ///
+ internal double TickLatencyAsTime;
+
+ ///
+ /// The render time used by only.
+ ///
+ internal double LegacyRenderTime;
+
+ internal double CurrentTime;
+ internal double MinDeltaTime;
+ internal double MaxDeltaTime;
+
+ internal NetworkTransform.InterpolationTypes PositionInterpolationType;
+ internal NetworkTransform.InterpolationTypes RotationInterpolationType;
+ internal NetworkTransform.InterpolationTypes ScaleInterpolationType;
+
+ internal bool SynchronizePosition;
+ internal bool SynchronizeRotation;
+ internal bool SynchronizeScale;
+
+ // Results, read back on the main thread and applied to the transform there.
+ internal float4 InterpolatedPosition;
+ internal float4 InterpolatedRotation;
+ internal float4 InterpolatedScale;
+ }
+
+ ///
+ /// Non-Authority Only:
+ /// Advances the interpolators for every registered non-authority in
+ /// parallel.
+ ///
+ ///
+ /// This performs the buffer consumption and the interpolation math only. Applying the results to the
+ /// transforms stays on the main thread for now (keeps this free of any hierarchy write order of operation complexities).
+ ///
+ /// Also, note that each entry owns its own slice of which assures no two indices address the same
+ /// items and that the whole array can be written without aliasing (pointing to the same thing).
+ ///
+ [BurstCompile]
+ internal struct InterpolateTransformJob : IJobParallelFor
+ {
+ public NativeArray Entries;
+
+ ///
+ /// The shared state measurement storage. Disabling the safety restriction is what allows each index to write
+ /// into its own slice of one array; keeps those
+ /// slices disjoint.
+ ///
+ [NativeDisableParallelForRestriction]
+ public NativeArray BufferedItems;
+
+ public void Execute(int index)
+ {
+ var entry = Entries[index];
+
+ if (entry.SynchronizePosition)
+ {
+ entry.InterpolatedPosition = Advance(ref entry.Position, ref entry, entry.PositionInterpolationType);
+ }
+
+ if (entry.SynchronizeRotation)
+ {
+ entry.InterpolatedRotation = Advance(ref entry.Rotation, ref entry, entry.RotationInterpolationType);
+ }
+
+ if (entry.SynchronizeScale)
+ {
+ entry.InterpolatedScale = Advance(ref entry.Scale, ref entry, entry.ScaleInterpolationType);
+ }
+
+ Entries[index] = entry;
+ }
+
+ private float4 Advance(ref NativeInterpolatorState state, ref InterpolationEntry entry, NetworkTransform.InterpolationTypes interpolationType)
+ {
+ if (interpolationType == NetworkTransform.InterpolationTypes.LegacyLerp)
+ {
+ return NativeInterpolator.UpdateLegacy(ref state, ref BufferedItems, entry.DeltaTime, entry.LegacyRenderTime, entry.CurrentTime);
+ }
+
+ return NativeInterpolator.Update(ref state, ref BufferedItems, entry.DeltaTime, entry.TickLatencyAsTime,
+ entry.MinDeltaTime, entry.MaxDeltaTime, interpolationType == NetworkTransform.InterpolationTypes.Lerp);
+ }
+ }
+}
diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/InterpolateTransformJob.cs.meta b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/InterpolateTransformJob.cs.meta
new file mode 100644
index 0000000000..fcdfab692b
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/InterpolateTransformJob.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 12ab83c3ca168ef4ab2c9b4addaa61f4
\ 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
new file mode 100644
index 0000000000..2a61800564
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs
@@ -0,0 +1,676 @@
+using System.Runtime.CompilerServices;
+using Unity.Collections;
+using Unity.Mathematics;
+
+namespace Unity.Netcode.Components
+{
+ ///
+ /// The value type being interpolated for a given .
+ ///
+ internal enum InterpolatorValueKind
+ {
+ ///
+ /// Used to define the position or scale states.
+ ///
+ Vector3,
+
+ ///
+ /// Always used for rotation.
+ ///
+ Quaternion,
+ }
+
+ ///
+ /// The blittable (managed and native compatible) equivalent of .
+ ///
+ ///
+ /// A single covers every transform value type being synchronized.
+ /// For position and scale, the w (4th) element is not used.
+ ///
+ internal struct BufferedItemNative
+ {
+ internal float4 Item;
+ internal double TimeSent;
+ internal int ItemId;
+ }
+
+ ///
+ /// The blittable (managed and native compatible) equivalent of a and its
+ /// .
+ ///
+ ///
+ /// The managed interpolator holds its measurements in a
+ /// and tracks the parent each measurement was taken under, neither of
+ /// which can exist inside a job. Here the measurements live in a fixed size ring buffer carved out of one
+ /// shared native array, addressed by .
+ ///
+ /// The smooth parenting transition flag, ,
+ /// is excluded from this state as it is handled differently.
+ /// - provides additional details on this.
+ /// - is where this happens (for now).
+ ///
+ internal struct NativeInterpolatorState
+ {
+ ///
+ /// Where this interpolator's slice of the shared item array begins.
+ ///
+ internal int BufferOffset;
+ internal int BufferCapacity;
+
+ ///
+ /// Index of the oldest buffered item, relative to .
+ ///
+ internal int BufferHead;
+ internal int BufferCount;
+
+ internal InterpolatorValueKind ValueKind;
+
+ ///
+ /// Whether to slerp rather than lerp. Position uses this for
+ /// and rotation uses it when not running at half
+ /// precision.
+ ///
+ internal bool IsSlerp;
+
+ internal bool LerpSmoothEnabled;
+ internal float MaximumInterpolationTime;
+
+ ///
+ /// The blittable equivalbent.
+ ///
+ internal float4 CurrentValue;
+ internal float4 PreviousValue;
+ internal float4 NextValue;
+ internal float4 RateOfChange;
+ internal BufferedItemNative Target;
+ internal bool HasTarget;
+ internal double StartTime;
+ internal double EndTime;
+ internal double TimeToTargetValue;
+ internal double DeltaTime;
+ internal double MaxDeltaTime;
+ internal double LastRemainingTime;
+ internal float LerpT;
+ internal bool TargetReached;
+ internal float CurrentDeltaTime;
+
+ // State measurement tracking related properties
+ internal double LastMeasurementAddedTime;
+ internal int BufferCounter;
+ internal int ItemsReceivedThisFrame;
+ internal BufferedItemNative LastBufferedItemReceived;
+ }
+
+ ///
+ /// A job friendly version of the .
+ ///
+ ///
+ /// The managed implementation stays in place as batched s is
+ /// a user opt-in feature and the original managed version must continue to work as expected
+ /// until it becomes deprecated.
+ ///
+ internal static class NativeInterpolator
+ {
+ ///
+ /// Matches 's buffer count limit, which is the point at
+ /// which it gives up on interpolating and teleports to the newest value.
+ ///
+ internal const int BufferCountLimit = 100;
+
+ private const float k_ApproximateLowPrecision = 0.000001f;
+ private const float k_ApproximateHighPrecision = 1E-10f;
+ private const double k_SmallValue = 9.999999439624929E-11;
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static float GetPrecision(in NativeInterpolatorState state)
+ {
+ return state.BufferCount == 0 ? k_ApproximateHighPrecision : k_ApproximateLowPrecision;
+ }
+
+ #region Job friendly ring buffer (i.e. Queue) methods
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static BufferedItemNative Peek(in NativeInterpolatorState state, in NativeArray items)
+ {
+ return items[state.BufferOffset + state.BufferHead];
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static BufferedItemNative Dequeue(ref NativeInterpolatorState state, in NativeArray items)
+ {
+ var item = items[state.BufferOffset + state.BufferHead];
+ state.BufferHead = (state.BufferHead + 1) % state.BufferCapacity;
+ state.BufferCount--;
+ return item;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void Enqueue(ref NativeInterpolatorState state, ref NativeArray items, in BufferedItemNative item)
+ {
+ if (state.BufferCount == state.BufferCapacity)
+ {
+ // Full: drop the oldest so the newest always makes it in, which is the behavior the managed
+ // interpolator gets from its unbounded queue combined with the buffer count limit below.
+ state.BufferHead = (state.BufferHead + 1) % state.BufferCapacity;
+ state.BufferCount--;
+ }
+ var tail = (state.BufferHead + state.BufferCount) % state.BufferCapacity;
+ items[state.BufferOffset + tail] = item;
+ state.BufferCount++;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void ClearBuffer(ref NativeInterpolatorState state)
+ {
+ state.BufferHead = 0;
+ state.BufferCount = 0;
+ }
+
+ #endregion
+
+ #region Interpolation, Smooth dampening, and approximation methods
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static float4 Interpolate(in NativeInterpolatorState state, float4 start, float4 end, float time)
+ {
+ if (state.ValueKind == InterpolatorValueKind.Quaternion)
+ {
+ return state.IsSlerp
+ ? NetworkTransformMath.Slerp(new quaternion(start), new quaternion(end), time).value
+ : NetworkTransformMath.Nlerp(new quaternion(start), new quaternion(end), time).value;
+ }
+
+ var result = state.IsSlerp
+ ? NetworkTransformMath.Slerp(start.xyz, end.xyz, time)
+ : NetworkTransformMath.Lerp(start.xyz, end.xyz, time);
+ return new float4(result, 0.0f);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static float4 SmoothDamp(ref NativeInterpolatorState state, float4 current, float4 target, float duration, float deltaTime)
+ {
+ if (state.ValueKind == InterpolatorValueKind.Quaternion)
+ {
+ // Matches BufferedLinearInterpolatorQuaternion, which smooth dampens each euler angle.
+ var currentEuler = NetworkTransformMath.EulerAngles(new quaternion(current));
+ var targetEuler = NetworkTransformMath.EulerAngles(new quaternion(target));
+ var rate = state.RateOfChange;
+ var result = float3.zero;
+ for (int i = 0; i < 3; i++)
+ {
+ var velocity = rate[i];
+ result[i] = NetworkTransformMath.SmoothDampAngle(currentEuler[i], targetEuler[i], ref velocity, duration, float.PositiveInfinity, deltaTime);
+ rate[i] = velocity;
+ }
+ state.RateOfChange = rate;
+ return NetworkTransformMath.Euler(result).value;
+ }
+
+ var rateOfChange = state.RateOfChange.xyz;
+ var damped = NetworkTransformMath.SmoothDamp(current.xyz, target.xyz, ref rateOfChange, duration, float.PositiveInfinity, deltaTime);
+ state.RateOfChange = new float4(rateOfChange, 0.0f);
+ return new float4(damped, 0.0f);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static bool IsApproximately(in NativeInterpolatorState state, float4 first, float4 second, float precision)
+ {
+ if (state.ValueKind == InterpolatorValueKind.Quaternion)
+ {
+ return math.abs(first.x - second.x) <= precision
+ && math.abs(first.y - second.y) <= precision
+ && math.abs(first.z - second.z) <= precision
+ && math.abs(first.w - second.w) <= precision;
+ }
+
+ // Matches BufferedLinearInterpolatorVector3, which rounds to two decimal places first.
+ return math.round(math.abs(first.x - second.x) * 100.0f) * 0.01f <= precision
+ && math.round(math.abs(first.y - second.y) * 100.0f) * 0.01f <= precision
+ && math.round(math.abs(first.z - second.z) * 100.0f) * 0.01f <= precision;
+ }
+
+ #endregion
+
+ #region State measurement, resetting, clearing, and related state methods
+
+ internal static void Clear(ref NativeInterpolatorState state)
+ {
+ ClearBuffer(ref state);
+ state.BufferCounter = 0;
+ state.LastMeasurementAddedTime = 0.0;
+ Reset(ref state, float4.zero);
+ state.RateOfChange = float4.zero;
+ }
+
+ ///
+ /// .
+ ///
+ internal static void Reset(ref NativeInterpolatorState state, float4 currentValue)
+ {
+ state.HasTarget = false;
+ state.Target = default;
+ state.CurrentValue = currentValue;
+ state.NextValue = currentValue;
+ state.PreviousValue = currentValue;
+ state.TargetReached = false;
+ state.LerpT = 0.0f;
+ state.EndTime = 0.0;
+ state.StartTime = 0.0;
+ state.TimeToTargetValue = 0.0;
+ state.DeltaTime = 0.0;
+ state.CurrentDeltaTime = 0.0f;
+ state.MaxDeltaTime = 0.0;
+ state.LastRemainingTime = 0.0;
+ }
+
+ ///
+ /// .
+ ///
+ ///
+ /// The managed implementation seeds a baseline measurement here, stamped with the caller's
+ /// . This one deliberately does not, because that baseline becomes an
+ /// ordering floor that the measurements which follow it cannot clear.
+ ///
+ /// Callers pass NetworkManager.ServerTime.Time, the local current time, while incoming
+ /// measurements are stamped with the tick they were authored on
+ /// (), which is always at least a tick
+ /// older. Once the baseline is consumed it becomes , and
+ /// both of the guards that admit a measurement compare against it:
+ /// requires a stamp newer than
+ /// , and
+ /// requires one newer than Target.TimeSent. An instance that
+ /// resets part way through a session therefore rejects everything the authority sends next, and the
+ /// rejection is permanent: with a target already reached and a non empty buffer,
+ /// neither interpolates nor takes the stale target reset, so elapsed time alone
+ /// never recovers it.
+ ///
+ /// In practice this only reaches an instance that just stopped being the authority, which in a client
+ /// server topology is only ever the server. Leaving the buffer empty puts the interpolator in exactly
+ /// the state a freshly spawned one is in: the value is still held ( seeds all three
+ /// of the in flight values), the first measurement to arrive is taken unconditionally because
+ /// is zero, and it is consumed against render time
+ /// alone. Seeding at spawn is unaffected — the baseline stamp there is one tick ahead of the first
+ /// measurement, so the interval the first measurement is interpolated over is the tick length either
+ /// way.
+ ///
+ ///
+ /// Retained for signature parity with . Not stored,
+ /// for the reason above.
+ ///
+ internal static void ResetTo(ref NativeInterpolatorState state, ref NativeArray items, float4 targetValue, double serverTime)
+ {
+ Clear(ref state);
+ state.RateOfChange = float4.zero;
+ Reset(ref state, targetValue);
+ }
+
+ ///
+ /// .
+ ///
+ internal static void AddMeasurement(ref NativeInterpolatorState state, ref NativeArray items, float4 newMeasurement, double sentTime)
+ {
+ state.ItemsReceivedThisFrame++;
+
+ // This situation can happen after a game is paused. When starting to receive again, the server will
+ // have sent a bunch of messages in the meantime; instead of going through thousands of value updates
+ // just to get a big teleport, give up on interpolating and teleport to the latest value.
+ if (state.ItemsReceivedThisFrame > BufferCountLimit)
+ {
+ if (state.LastBufferedItemReceived.TimeSent < sentTime)
+ {
+ ClearBuffer(ref state);
+ state.BufferCounter = 0;
+ state.LastMeasurementAddedTime = 0.0;
+ state.RateOfChange = float4.zero;
+ Reset(ref state, newMeasurement);
+
+ state.LastMeasurementAddedTime = sentTime;
+ state.LastBufferedItemReceived = new BufferedItemNative()
+ {
+ Item = newMeasurement,
+ TimeSent = sentTime,
+ ItemId = state.BufferCounter,
+ };
+ // Keeps render time above the consumed start time, which fixes pause and unpause.
+ Enqueue(ref state, ref items, state.LastBufferedItemReceived);
+ }
+ return;
+ }
+
+ // Drop measurements received out of order or late (unreliable deltas can do both).
+ if (sentTime > state.LastMeasurementAddedTime || state.BufferCounter == 0)
+ {
+ state.BufferCounter++;
+ state.LastBufferedItemReceived = new BufferedItemNative()
+ {
+ Item = newMeasurement,
+ TimeSent = sentTime,
+ ItemId = state.BufferCounter,
+ };
+ Enqueue(ref state, ref items, state.LastBufferedItemReceived);
+ state.LastMeasurementAddedTime = sentTime;
+ }
+ }
+
+ ///
+ /// .
+ ///
+ internal static void ResetCurrentState(ref NativeInterpolatorState state)
+ {
+ if (state.HasTarget)
+ {
+ Reset(ref state, state.CurrentValue);
+ state.RateOfChange = float4.zero;
+ }
+ }
+
+ ///
+ /// Re-expresses every buffered measurement, and the values currently in flight, in a different space.
+ ///
+ ///
+ /// Invoked when the instance is reparented, which is the only thing that changes the space its
+ /// measurements are interpreted in. Converting the whole buffer at that moment keeps everything in one
+ /// space, which is what allows the interpolation job to have no knowledge of parents at all.
+ ///
+ /// The managed interpolator instead tags each measurement with the parent it arrived under and
+ /// converts lazily as the queue drains past the boundary. The net effect is the same transition; doing
+ /// it here means one conversion at a known instant, using both parents' poses as they are at that
+ /// instant, rather than a conversion per buffered item using poses sampled as each is consumed.
+ ///
+ /// Converts a position from the old space to the new one.
+ /// Converts a rotation from the old space to the new one.
+ internal static void ConvertSpace(ref NativeInterpolatorState state, ref NativeArray items, in float4x4 pointTransform, in quaternion rotationTransform)
+ {
+ for (int i = 0; i < state.BufferCount; i++)
+ {
+ var index = state.BufferOffset + (state.BufferHead + i) % state.BufferCapacity;
+ var item = items[index];
+ item.Item = ConvertValue(state.ValueKind, item.Item, pointTransform, rotationTransform);
+ items[index] = item;
+ }
+
+ state.CurrentValue = ConvertValue(state.ValueKind, state.CurrentValue, pointTransform, rotationTransform);
+ state.PreviousValue = ConvertValue(state.ValueKind, state.PreviousValue, pointTransform, rotationTransform);
+ state.NextValue = ConvertValue(state.ValueKind, state.NextValue, pointTransform, rotationTransform);
+
+ if (state.HasTarget)
+ {
+ var target = state.Target;
+ target.Item = ConvertValue(state.ValueKind, target.Item, pointTransform, rotationTransform);
+ state.Target = target;
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static float4 ConvertValue(InterpolatorValueKind valueKind, float4 value, in float4x4 pointTransform, in quaternion rotationTransform)
+ {
+ if (valueKind == InterpolatorValueKind.Quaternion)
+ {
+ return math.mul(rotationTransform, new quaternion(value)).value;
+ }
+ return new float4(math.transform(pointTransform, value.xyz), 0.0f);
+ }
+
+ #endregion
+
+ #region Buffer consumption and timing related methods
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void AddDeltaTime(ref NativeInterpolatorState state, float deltaTime)
+ {
+ state.CurrentDeltaTime = deltaTime;
+ state.DeltaTime = math.min(state.DeltaTime + deltaTime, state.TimeToTargetValue);
+ state.LerpT = (float)(state.TimeToTargetValue == 0.0 ? 1.0 : state.DeltaTime / state.TimeToTargetValue);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void SetTimeToTarget(ref NativeInterpolatorState state, double timeToTarget)
+ {
+ state.LerpT = 0.0f;
+ state.DeltaTime = 0.0;
+ state.TimeToTargetValue = timeToTarget;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static double FinalTimeToTarget(in NativeInterpolatorState state)
+ {
+ return math.max(0.0, state.TimeToTargetValue - state.DeltaTime);
+ }
+
+ ///
+ /// The smooth dampening and lerp ahead version of
+ /// 's buffer consumption.
+ ///
+ private static void TryConsumeFromBuffer(ref NativeInterpolatorState state, ref NativeArray items, double renderTime, double minDeltaTime, double maxDeltaTime)
+ {
+ var hasPreviousItem = false;
+ var previousTimeSent = 0.0;
+ var startTime = 0.0;
+ var alreadyHasBufferItem = false;
+ var noStateSet = !state.HasTarget;
+
+ // With nothing left in the queue (motion stopped) the target still has to be checked for arrival.
+ if (!noStateSet && !state.TargetReached)
+ {
+ state.TargetReached = IsApproximately(state, state.CurrentValue, state.Target.Item, GetPrecision(state));
+ }
+
+ while (state.BufferCount > 0)
+ {
+ var potentialItem = Peek(state, items);
+
+ // Still on the same buffered item, so there is nothing to consume.
+ if (hasPreviousItem && previousTimeSent == potentialItem.TimeSent)
+ {
+ break;
+ }
+
+ var potentialItemNeedsProcessing = false;
+ if (!noStateSet)
+ {
+ potentialItemNeedsProcessing = potentialItem.TimeSent <= renderTime && potentialItem.TimeSent > state.Target.TimeSent;
+ }
+
+ if ((noStateSet && potentialItem.TimeSent <= renderTime) || potentialItemNeedsProcessing)
+ {
+ var target = Dequeue(ref state, items);
+
+ if (!state.HasTarget)
+ {
+ state.Target = target;
+ state.HasTarget = true;
+ alreadyHasBufferItem = true;
+ state.NextValue = state.CurrentValue;
+ state.PreviousValue = state.CurrentValue;
+ SetTimeToTarget(ref state, minDeltaTime);
+ startTime = state.Target.TimeSent;
+ state.TargetReached = false;
+ state.MaxDeltaTime = maxDeltaTime;
+ }
+ else
+ {
+ if (!alreadyHasBufferItem)
+ {
+ alreadyHasBufferItem = true;
+ state.LastRemainingTime = FinalTimeToTarget(state);
+ state.TargetReached = false;
+ state.MaxDeltaTime = maxDeltaTime;
+ state.PreviousValue = state.NextValue;
+ startTime = state.Target.TimeSent;
+ }
+ SetTimeToTarget(ref state, math.max(target.TimeSent - startTime, minDeltaTime));
+ state.Target = target;
+ }
+ // noStateSet is deliberately not cleared here. The managed implementation evaluates it
+ // once before the loop, so when it starts out true every pass keeps taking the branch that
+ // only compares against render time.
+ }
+ else
+ {
+ break;
+ }
+
+ hasPreviousItem = true;
+ previousTimeSent = potentialItem.TimeSent;
+ }
+ }
+
+ ///
+ /// The lerping version of 's buffer consumption, which
+ /// preserves the original consumption pattern used by .
+ ///
+ private static void TryConsumeFromBufferLegacy(ref NativeInterpolatorState state, ref NativeArray items, double renderTime, double serverTime)
+ {
+ if (state.HasTarget && state.Target.TimeSent > renderTime)
+ {
+ return;
+ }
+
+ var hasPreviousItem = false;
+ var previousTimeSent = 0.0;
+ var alreadyHasBufferItem = false;
+
+ while (state.BufferCount > 0)
+ {
+ var potentialItem = Peek(state, items);
+ if (hasPreviousItem && previousTimeSent == potentialItem.TimeSent)
+ {
+ break;
+ }
+
+ // Continue processing until reaching the most current state.
+ if (potentialItem.TimeSent <= serverTime && (!state.HasTarget || potentialItem.TimeSent > state.Target.TimeSent))
+ {
+ var target = Dequeue(ref state, items);
+ if (!state.HasTarget)
+ {
+ state.Target = target;
+ state.HasTarget = true;
+ alreadyHasBufferItem = true;
+ state.NextValue = state.CurrentValue;
+ state.PreviousValue = state.CurrentValue;
+ state.StartTime = target.TimeSent;
+ state.EndTime = target.TimeSent;
+ }
+ else
+ {
+ if (!alreadyHasBufferItem)
+ {
+ alreadyHasBufferItem = true;
+ state.StartTime = state.Target.TimeSent;
+ state.PreviousValue = state.NextValue;
+ state.TargetReached = false;
+ }
+ state.EndTime = target.TimeSent;
+ state.TimeToTargetValue = state.EndTime - state.StartTime;
+ state.Target = target;
+ }
+ }
+ else
+ {
+ break;
+ }
+
+ hasPreviousItem = true;
+ previousTimeSent = potentialItem.TimeSent;
+ }
+ }
+
+ #endregion
+
+ #region Update methods
+
+ ///
+ /// The smooth dampening and lerp version of .
+ ///
+ internal static float4 Update(ref NativeInterpolatorState state, ref NativeArray items,
+ float deltaTime, double tickLatencyAsTime, double minDeltaTime, double maxDeltaTime, bool lerp)
+ {
+ TryConsumeFromBuffer(ref state, ref items, tickLatencyAsTime, minDeltaTime, maxDeltaTime);
+
+ // Only begin interpolation when there is a start and end point.
+ if (state.HasTarget)
+ {
+ if (!state.TargetReached)
+ {
+ AddDeltaTime(ref state, deltaTime);
+
+ if (!lerp)
+ {
+ state.NextValue = SmoothDamp(ref state, state.NextValue, state.Target.Item,
+ (float)state.TimeToTargetValue * state.LerpT, deltaTime);
+ }
+ else
+ {
+ state.NextValue = Interpolate(state, state.PreviousValue, state.Target.Item, state.LerpT);
+ }
+
+ if (state.LerpSmoothEnabled)
+ {
+ state.CurrentValue = Interpolate(state, state.CurrentValue, state.NextValue,
+ math.clamp(1.0f - state.MaximumInterpolationTime, 0.0f, 1.0f));
+ }
+ else
+ {
+ state.CurrentValue = state.NextValue;
+ }
+ }
+ else if (state.BufferCount == 0)
+ {
+ // Once the target is reached and nothing is left, reset if enough time has passed that the
+ // rate of change should be considered zero. Without this the next state update's time is
+ // measured against a stale one, producing a large delta after a pause in motion.
+ if (tickLatencyAsTime - state.Target.TimeSent > state.MaxDeltaTime + minDeltaTime)
+ {
+ Reset(ref state, state.CurrentValue);
+ }
+ }
+ }
+ state.ItemsReceivedThisFrame = 0;
+ return state.CurrentValue;
+ }
+
+ ///
+ /// The legacy lerp version of .
+ ///
+ internal static float4 UpdateLegacy(ref NativeInterpolatorState state, ref NativeArray items,
+ float deltaTime, double renderTime, double serverTime)
+ {
+ TryConsumeFromBufferLegacy(ref state, ref items, renderTime, serverTime);
+
+ if (!state.TargetReached && state.HasTarget)
+ {
+ state.LerpT = 1.0f;
+ if (state.TimeToTargetValue > k_SmallValue)
+ {
+ state.LerpT = math.clamp((float)((renderTime - state.StartTime) / state.TimeToTargetValue), 0.0f, 1.0f);
+ }
+
+ state.NextValue = Interpolate(state, state.PreviousValue, state.Target.Item, state.LerpT);
+
+ if (state.LerpSmoothEnabled)
+ {
+ state.CurrentValue = Interpolate(state, state.CurrentValue, state.NextValue, deltaTime / state.MaximumInterpolationTime);
+ }
+ else
+ {
+ state.CurrentValue = state.NextValue;
+ }
+
+ state.TargetReached = IsApproximately(state, state.CurrentValue, state.Target.Item, GetPrecision(state));
+ }
+ else if (state.TargetReached && state.BufferCount == 0)
+ {
+ // If nothing has been received within 300ms, assume motion stopped.
+ if (renderTime - state.Target.TimeSent > 0.3)
+ {
+ Reset(ref state, state.CurrentValue);
+ }
+ }
+ state.ItemsReceivedThisFrame = 0;
+ return state.CurrentValue;
+ }
+
+ #endregion
+ }
+}
diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs.meta b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs.meta
new file mode 100644
index 0000000000..097777f256
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 9c48be63ce0910144939ecfb7357cc35
\ No newline at end of file
diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs
index de4e86999d..07382fc504 100644
--- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs
+++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs
@@ -16,7 +16,7 @@ namespace Unity.Netcode.Components
[DisallowMultipleComponent]
[AddComponentMenu("Netcode/Network Transform")]
[HelpURL(HelpUrls.NetworkTransform)]
- public class NetworkTransform : NetworkBehaviour
+ public partial class NetworkTransform : NetworkBehaviour
{
#if UNITY_EDITOR
internal virtual bool HideInterpolateValue => false;
@@ -353,10 +353,6 @@ public struct NetworkTransformState : INetworkSerializable
// Set when a state has been explicitly set (i.e. SetState)
internal bool ExplicitSet;
- // Used during serialization
- private FastBufferReader m_Reader;
- private FastBufferWriter m_Writer;
-
internal FlagStates FlagStates;
///
@@ -625,9 +621,10 @@ public bool IsReliableStateUpdate()
///
public Quaternion GetRotation()
{
- if (HasRotAngleChange)
+ // Internal reads use FlagStates fields as opposed to using the public properties (property access has a measurable cost).
+ if (FlagStates.HasRotAngleChange)
{
- if (QuaternionSync)
+ if (FlagStates.QuaternionSync)
{
return Rotation;
}
@@ -652,11 +649,11 @@ public Quaternion GetRotation()
///
public Vector3 GetPosition()
{
- if (HasPositionChange)
+ if (FlagStates.HasPositionChange)
{
- if (UseHalfFloatPrecision)
+ if (FlagStates.UseHalfFloatPrecision)
{
- if (IsTeleportingNextFrame)
+ if (FlagStates.IsTeleportingNextFrame)
{
return CurrentPosition;
}
@@ -680,11 +677,11 @@ public Vector3 GetPosition()
///
public Vector3 GetScale()
{
- if (HasScaleChange)
+ if (FlagStates.HasScaleChange)
{
- if (UseHalfFloatPrecision)
+ if (FlagStates.UseHalfFloatPrecision)
{
- if (IsTeleportingNextFrame)
+ if (FlagStates.IsTeleportingNextFrame)
{
return Scale;
}
@@ -708,21 +705,60 @@ public int GetNetworkTick()
internal HalfVector3 HalfEulerRotation;
+ ///
+ /// Determines whether this state update has to be delivered reliably, and sets the
+ /// flag if so.
+ ///
+ ///
+ /// Has to be resolved before serializing rather than during it. The batched synchronization mode
+ /// uses the result to decide which of its two per tick messages a state belongs to, and that
+ /// choice is made while assembling the batch, before anything is written.
+ ///
+ /// Callers that write a state must invoke this first, otherwise the flag that goes onto the wire is
+ /// whatever the state happened to be carrying.
+ ///
+ internal void UpdateReliability()
+ {
+ if (!FlagStates.UseUnreliableDeltas)
+ {
+ // If not using UseUnreliableDeltas, then always use reliable fragmented sequenced
+ FlagStates.ReliableSequenced = true;
+ return;
+ }
+
+ // If teleporting, synchronizing, doing a full axial frame sync, or synchronizing the base position
+ // for NetworkDeltaPosition:
+ //
+ // SynchronizeBaseHalfFloat is used here rather than testing CollapsedDeltaIntoBase directly.
+ // It covers that case and also the ownership offset and axial sync ticks, which is what the
+ // delivery method has always been chosen from. Deriving the flag from the same condition means
+ // there is one rule: what gets serialized now matches how the message is actually sent, so
+ // IsReliableStateUpdate no longer contradicts the delivery that was used.
+ FlagStates.ReliableSequenced = FlagStates.IsTeleportingNextFrame || FlagStates.IsSynchronizing
+ || FlagStates.UnreliableFrameSync || FlagStates.SynchronizeBaseHalfFloat;
+ }
+
///
public void NetworkSerialize(BufferSerializer serializer) where T : IReaderWriter
{
// Used to calculate the LastSerializedSize value
var positionStart = 0;
var isWriting = serializer.IsWriter;
+ // Moving the reader and writer properties into this method, as opposed to fields, to assure
+ // NetworkTransformState remains bittable (i.e. can be used in managed or native realms).
+ // The NetworkSerialize method is always invoked by managed code (for now) so accessing
+ // the non-blittable FastBufferWriter or FastBufferReader is "ok".
+ var writer = default(FastBufferWriter);
+ var reader = default(FastBufferReader);
if (isWriting)
{
- m_Writer = serializer.GetFastBufferWriter();
- positionStart = m_Writer.Position;
+ writer = serializer.GetFastBufferWriter();
+ positionStart = writer.Position;
}
else
{
- m_Reader = serializer.GetFastBufferReader();
- positionStart = m_Reader.Position;
+ reader = serializer.GetFastBufferReader();
+ positionStart = reader.Position;
}
#if NGO_NETWORKTRANSFORMSTATE_LOGWRITESIZE
@@ -736,50 +772,31 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade
{
if (isWriting)
{
- if (FlagStates.UseUnreliableDeltas)
- {
- // If teleporting, synchronizing, doing an axial frame sync, or using half float precision and we collapsed a delta into the base position
- if (FlagStates.IsTeleportingNextFrame || FlagStates.IsSynchronizing || FlagStates.UnreliableFrameSync
- || (FlagStates.UseHalfFloatPrecision && NetworkDeltaPosition.CollapsedDeltaIntoBase))
- {
- // Send the message reliably
- FlagStates.ReliableSequenced = true;
- }
- else
- {
- FlagStates.ReliableSequenced = false;
- }
- }
- else // If not using UseUnreliableDeltas, then always use reliable fragmented sequenced
- {
- FlagStates.ReliableSequenced = true;
- }
-
// Serialize the flags as an unsigned int
- BytePacker.WriteValueBitPacked(m_Writer, FlagStates.GetBitsetRepresentation());
+ BytePacker.WriteValueBitPacked(writer, FlagStates.GetBitsetRepresentation());
// We use network ticks as opposed to absolute time as the authoritative
// side updates on every new tick.
- BytePacker.WriteValueBitPacked(m_Writer, NetworkTick);
+ BytePacker.WriteValueBitPacked(writer, NetworkTick);
}
else
{
// Deserialize the flags
- ByteUnpacker.ReadValueBitPacked(m_Reader, out uint bitset);
+ ByteUnpacker.ReadValueBitPacked(reader, out uint bitset);
// Set the flags
FlagStates.SetStateFromBitset(bitset);
// We use network ticks as opposed to absolute time as the authoritative
// side updates on every new tick.
- ByteUnpacker.ReadValueBitPacked(m_Reader, out NetworkTick);
+ ByteUnpacker.ReadValueBitPacked(reader, out NetworkTick);
}
}
#if NGO_NETWORKTRANSFORMSTATE_LOGWRITESIZE
if (isWriting)
{
- bitSetAndTickSize = m_Writer.Position - positionStart;
- lastPosition = m_Writer.Position;
+ bitSetAndTickSize = writer.Position - positionStart;
+ lastPosition = writer.Position;
}
#endif
@@ -790,23 +807,23 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade
}
// Synchronize Position
- if (HasPositionChange)
+ if (FlagStates.HasPositionChange)
{
- if (UseHalfFloatPrecision)
+ if (FlagStates.UseHalfFloatPrecision)
{
NetworkDeltaPosition.SynchronizeBase = FlagStates.SynchronizeBaseHalfFloat;
// Apply which axis should be updated for both write/read (teleporting, synchronizing, or just updating)
- NetworkDeltaPosition.HalfVector3.AxisToSynchronize[0] = HasPositionX;
- NetworkDeltaPosition.HalfVector3.AxisToSynchronize[1] = HasPositionY;
- NetworkDeltaPosition.HalfVector3.AxisToSynchronize[2] = HasPositionZ;
+ NetworkDeltaPosition.HalfVector3.AxisToSynchronize[0] = FlagStates.HasPositionX;
+ NetworkDeltaPosition.HalfVector3.AxisToSynchronize[1] = FlagStates.HasPositionY;
+ NetworkDeltaPosition.HalfVector3.AxisToSynchronize[2] = FlagStates.HasPositionZ;
- if (IsTeleportingNextFrame)
+ if (FlagStates.IsTeleportingNextFrame)
{
// **Always use full precision when teleporting and UseHalfFloatPrecision is enabled**
serializer.SerializeValue(ref CurrentPosition);
// If we are synchronizing, then include the half vector position's delta offset
- if (IsSynchronizing)
+ if (FlagStates.IsSynchronizing)
{
serializer.SerializeValue(ref DeltaPosition);
if (!isWriting)
@@ -835,17 +852,17 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade
}
else // Full precision axis specific position synchronization
{
- if (HasPositionX)
+ if (FlagStates.HasPositionX)
{
serializer.SerializeValue(ref PositionX);
}
- if (HasPositionY)
+ if (FlagStates.HasPositionY)
{
serializer.SerializeValue(ref PositionY);
}
- if (HasPositionZ)
+ if (FlagStates.HasPositionZ)
{
serializer.SerializeValue(ref PositionZ);
}
@@ -855,25 +872,25 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade
#if NGO_NETWORKTRANSFORMSTATE_LOGWRITESIZE
if (isWriting)
{
- positionSize = m_Writer.Position - lastPosition;
- lastPosition = m_Writer.Position;
+ positionSize = writer.Position - lastPosition;
+ lastPosition = writer.Position;
}
#endif
// Synchronize Rotation
- if (HasRotAngleChange)
+ if (FlagStates.HasRotAngleChange)
{
- if (QuaternionSync)
+ if (FlagStates.QuaternionSync)
{
// Always use the full quaternion if teleporting
- if (IsTeleportingNextFrame)
+ if (FlagStates.IsTeleportingNextFrame)
{
serializer.SerializeValue(ref Rotation);
}
else
{
// Use the quaternion compressor if enabled
- if (QuaternionCompression)
+ if (FlagStates.QuaternionCompression)
{
if (isWriting)
{
@@ -889,7 +906,7 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade
}
else
{
- if (UseHalfFloatPrecision)
+ if (FlagStates.UseHalfFloatPrecision)
{
if (isWriting)
{
@@ -913,14 +930,14 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade
else // Euler Rotation Synchronization
{
// Half float precision (full precision when teleporting)
- if (UseHalfFloatPrecision && !IsTeleportingNextFrame)
+ if (FlagStates.UseHalfFloatPrecision && !FlagStates.IsTeleportingNextFrame)
{
- if (HasRotAngleChange)
+ if (FlagStates.HasRotAngleChange)
{
// Apply which axis should be updated for both write/read
- HalfEulerRotation.AxisToSynchronize[0] = HasRotAngleX;
- HalfEulerRotation.AxisToSynchronize[1] = HasRotAngleY;
- HalfEulerRotation.AxisToSynchronize[2] = HasRotAngleZ;
+ HalfEulerRotation.AxisToSynchronize[0] = FlagStates.HasRotAngleX;
+ HalfEulerRotation.AxisToSynchronize[1] = FlagStates.HasRotAngleY;
+ HalfEulerRotation.AxisToSynchronize[2] = FlagStates.HasRotAngleZ;
if (isWriting)
{
@@ -932,17 +949,17 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade
if (!isWriting)
{
var eulerRotation = HalfEulerRotation.ToVector3();
- if (HasRotAngleX)
+ if (FlagStates.HasRotAngleX)
{
RotAngleX = eulerRotation.x;
}
- if (HasRotAngleY)
+ if (FlagStates.HasRotAngleY)
{
RotAngleY = eulerRotation.y;
}
- if (HasRotAngleZ)
+ if (FlagStates.HasRotAngleZ)
{
RotAngleZ = eulerRotation.z;
}
@@ -952,17 +969,17 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade
else // Full precision Euler
{
// RotAngle Values
- if (HasRotAngleX)
+ if (FlagStates.HasRotAngleX)
{
serializer.SerializeValue(ref RotAngleX);
}
- if (HasRotAngleY)
+ if (FlagStates.HasRotAngleY)
{
serializer.SerializeValue(ref RotAngleY);
}
- if (HasRotAngleZ)
+ if (FlagStates.HasRotAngleZ)
{
serializer.SerializeValue(ref RotAngleZ);
}
@@ -973,33 +990,33 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade
#if NGO_NETWORKTRANSFORMSTATE_LOGWRITESIZE
if (isWriting)
{
- rotationSize = m_Writer.Position - lastPosition;
- lastPosition = m_Writer.Position;
+ rotationSize = writer.Position - lastPosition;
+ lastPosition = writer.Position;
}
#endif
// Synchronize Scale
- if (HasScaleChange)
+ if (FlagStates.HasScaleChange)
{
// If we are teleporting (which includes synchronizing) and the associated NetworkObject has a parent
// then we want to serialize the LossyScale since NetworkObject spawn order is not guaranteed
- if (IsTeleportingNextFrame && FlagStates.IsParented)
+ if (FlagStates.IsTeleportingNextFrame && FlagStates.IsParented)
{
serializer.SerializeValue(ref LossyScale);
}
// Half precision scale synchronization
- if (UseHalfFloatPrecision)
+ if (FlagStates.UseHalfFloatPrecision)
{
- if (IsTeleportingNextFrame)
+ if (FlagStates.IsTeleportingNextFrame)
{
serializer.SerializeValue(ref Scale);
}
else
{
// Apply which axis should be updated for both write/read
- HalfVectorScale.AxisToSynchronize[0] = HasScaleX;
- HalfVectorScale.AxisToSynchronize[1] = HasScaleY;
- HalfVectorScale.AxisToSynchronize[2] = HasScaleZ;
+ HalfVectorScale.AxisToSynchronize[0] = FlagStates.HasScaleX;
+ HalfVectorScale.AxisToSynchronize[1] = FlagStates.HasScaleY;
+ HalfVectorScale.AxisToSynchronize[2] = FlagStates.HasScaleZ;
// For scale, when half precision is enabled we can still only send the axis with deltas
if (isWriting)
@@ -1012,36 +1029,36 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade
if (!isWriting)
{
Scale = HalfVectorScale.ToVector3();
- if (HasScaleX)
+ if (FlagStates.HasScaleX)
{
ScaleX = Scale.x;
}
- if (HasScaleY)
+ if (FlagStates.HasScaleY)
{
ScaleY = Scale.y;
}
- if (HasScaleZ)
+ if (FlagStates.HasScaleZ)
{
- ScaleZ = Scale.x;
+ ScaleZ = Scale.z;
}
}
}
}
else // Full precision scale synchronization
{
- if (HasScaleX)
+ if (FlagStates.HasScaleX)
{
serializer.SerializeValue(ref ScaleX);
}
- if (HasScaleY)
+ if (FlagStates.HasScaleY)
{
serializer.SerializeValue(ref ScaleY);
}
- if (HasScaleZ)
+ if (FlagStates.HasScaleZ)
{
serializer.SerializeValue(ref ScaleZ);
}
@@ -1051,8 +1068,8 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade
#if NGO_NETWORKTRANSFORMSTATE_LOGWRITESIZE
if (isWriting)
{
- scaleSize = m_Writer.Position - lastPosition;
- lastPosition = m_Writer.Position;
+ scaleSize = writer.Position - lastPosition;
+ lastPosition = writer.Position;
}
#endif
@@ -1060,12 +1077,12 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade
if (!isWriting)
{
// Go ahead and mark the local state dirty
- FlagStates.IsDirty = HasPositionChange || HasRotAngleChange || HasScaleChange;
- LastSerializedSize = m_Reader.Position - positionStart;
+ FlagStates.IsDirty = FlagStates.HasPositionChange || FlagStates.HasRotAngleChange || FlagStates.HasScaleChange;
+ LastSerializedSize = reader.Position - positionStart;
}
else
{
- LastSerializedSize = m_Writer.Position - positionStart;
+ LastSerializedSize = writer.Position - positionStart;
#if NGO_NETWORKTRANSFORMSTATE_LOGWRITESIZE
Debug.Log($"[NT-WriteSize][BitsAndTick: {bitSetAndTickSize}][position: {positionSize}][rotation: {rotationSize}][scale: {scaleSize}]");
#endif
@@ -1757,6 +1774,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.
+ /// registered.
+ ///
+ ///
+ /// Cached here (as opposed to using a lookup table) so registering and deregistering is O(1). 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;
@@ -1904,12 +1955,30 @@ 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.TransformSyncMode == 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;
// If we are using Half Float Precision, then we want to only synchronize the authority's m_HalfPositionState.FullPosition in order for
// for the non-authority side to be able to properly synchronize delta position updates.
CheckForStateChange(ref SynchronizeState, true, targetClientId);
+ SynchronizeState.UpdateReliability();
SynchronizeState.NetworkSerialize(serializer);
LastTickSync = SynchronizeState.GetNetworkTick();
OnAuthorityPushTransformState(ref SynchronizeState);
@@ -2007,6 +2076,147 @@ private void TryCommitTransform(bool synchronize = false, bool settingState = fa
// If the transform has deltas (returns dirty) or if an explicitly set state is pending
if (m_LocalAuthoritativeNetworkState.ExplicitSet || CheckForStateChange(ref m_LocalAuthoritativeNetworkState, synchronize, forceState: settingState))
+ {
+ CommitDetectedState(synchronize);
+ }
+ }
+
+ ///
+ /// 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 that is not already provided for a batched delta check.
+ ///
+ internal void PrepareBatchedDeltaEntry(ref TransformDeltaEntry entry)
+ {
+ entry.Config = GetTransformDeltaConfig();
+ entry.TransformHasParent = transform.parent != null;
+ entry.HalfPositionState = m_HalfPositionState;
+ entry.IsDirty = false;
+ // ForceState is set by the main thread, tick relative, and is cleared once applied.
+
+ 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 || entry.ForceState || 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.
+ ///
+ internal void ApplyBatchedDeltaEntry(ref TransformDeltaEntry entry)
+ {
+ m_LocalAuthoritativeNetworkState = entry.State;
+ m_HalfPositionState = entry.HalfPositionState;
+ ApplyTransformDeltaConfig(entry.Config);
+ entry.ForceState = false;
+
+ if (entry.IsDirty || m_LocalAuthoritativeNetworkState.ExplicitSet)
+ {
+ CommitDetectedState(false);
+ // CommitDetectedState mutates the state (it clears the teleport and explicit set flags and
+ // records the old state), so the entry has to pick those changes back up.
+ entry.State = m_LocalAuthoritativeNetworkState;
+ entry.HalfPositionState = m_HalfPositionState;
+ }
+
+ // The follow up work can raise these again for the next tick.
+ entry.Config.DeltaSynch = m_DeltaSynch;
+ entry.Config.NextTickSync = m_NextTickSync;
+ }
+
+ ///
+ /// Sends any detected state updates for the frame.
+ ///
+ ///
+ /// This was pulled out of to make it per instance or batched compatible.
+ ///
+ /// Whether this state update is an initial synchronization.
+ private void CommitDetectedState(bool synchronize)
+ {
{
// If the state was explicitly set, then update the network tick to match the locally calculate tick
if (m_LocalAuthoritativeNetworkState.ExplicitSet)
@@ -2022,8 +2232,23 @@ private void TryCommitTransform(bool synchronize = false, bool settingState = fa
}
}
- // 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();
+ }
// Mark the last tick and the old state (for next ticks)
m_OldState = m_LocalAuthoritativeNetworkState;
@@ -2127,84 +2352,119 @@ internal bool ApplyTransformToNetworkState(ref NetworkTransformState networkStat
}
///
- /// Applies the transform to the specified.
+ /// Authority:
+ /// Gets the instance's configuration for a delta check.
///
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- private bool CheckForStateChange(ref NetworkTransformState networkState, bool isSynchronization = false, ulong targetClientId = 0, bool forceState = false)
+ private TransformDeltaConfig GetTransformDeltaConfig()
{
- var flagStates = networkState.FlagStates;
+ return new TransformDeltaConfig()
+ {
+ PositionThreshold = PositionThreshold,
+ RotAngleThreshold = RotAngleThreshold,
+ ScaleThreshold = ScaleThreshold,
+ SyncPositionX = SyncPositionX,
+ SyncPositionY = SyncPositionY,
+ SyncPositionZ = SyncPositionZ,
+ SyncRotAngleX = SyncRotAngleX,
+ SyncRotAngleY = SyncRotAngleY,
+ SyncRotAngleZ = SyncRotAngleZ,
+ SyncScaleX = SyncScaleX,
+ SyncScaleY = SyncScaleY,
+ SyncScaleZ = SyncScaleZ,
+ UseQuaternionSynchronization = UseQuaternionSynchronization,
+ UseQuaternionCompression = UseQuaternionCompression,
+ UseHalfFloatPrecision = UseHalfFloatPrecision,
+ SlerpPosition = SlerpPosition,
+ Interpolate = Interpolate,
+ // 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.TransformSyncMode != TransformSyncModes.Batched,
+ SwitchTransformSpaceWhenParented = SwitchTransformSpaceWhenParented,
+#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D
+ UseRigidbodyForMotion = m_UseRigidbodyForMotion,
+#else
+ UseRigidbodyForMotion = false,
+#endif
+ InLocalSpace = InLocalSpace,
+ CurrentTick = CurrentTick,
+ CachedTickRate = m_CachedTickRate,
+ HalfFloatTargetTickOwnership = m_HalfFloatTargetTickOwnership,
+ NextTickSync = m_NextTickSync,
+ DeltaSynch = m_DeltaSynch,
+ Enabled = enabled,
+ };
+ }
- // As long as we are not doing our first synchronization and we are sending unreliable deltas, each
- // NetworkTransform will stagger its full transfom synchronization over a 1 second period based on the
- // assigned tick slot (m_TickSync).
- // More about m_DeltaSynch:
- // If we have not sent any deltas since our last frame synch, then this will prevent us from sending
- // frame synch's when the object is at rest. If this is false and a state update is detected and sent,
- // then it will be set to true and each subsequent tick will do this check to determine if it should
- // send a full frame synch.
- var isAxisSync = false;
- // We compare against the NetworkTickSystem version since ServerTime is set when updating ticks
- if (UseUnreliableDeltas && !isSynchronization && m_DeltaSynch && m_NextTickSync <= CurrentTick)
+ ///
+ /// Applies anything the delta check updated back onto this instance.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private void ApplyTransformDeltaConfig(in TransformDeltaConfig config)
+ {
+ InLocalSpace = config.InLocalSpace;
+ m_NextTickSync = config.NextTickSync;
+ m_DeltaSynch = config.DeltaSynch;
+ }
+
+ ///
+ /// Determines whether the associated should be treated as parented.
+ ///
+ ///
+ /// Needs a component lookup, so it is resolved here and handed to the delta check as a value. Only
+ /// relevant while synchronizing, teleporting, or forcing a full state update.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private bool HasParentNetworkObject()
+ {
+ // This all has to do with complex nested hierarchies and how it impacts scale
+ // when set for the first time or teleporting and depends upon whether the
+ // NetworkObject is parented (or "de-parented") at the same time any scale
+ // values are applied.
+ // If the NetworkObject belonging to this NetworkTransform instance has a parent
+ // (i.e. this handles nested NetworkTransforms under a parent at some layer above)
+ if (NetworkObject.transform.parent == null)
{
- // Increment to the next frame synch tick position for this instance
- m_NextTickSync += m_CachedTickRate;
- // If we are teleporting, we do not need to send a frame synch for this tick slot
- // as a "frame synch" really is effectively just a teleport.
- isAxisSync = !flagStates.IsTeleportingNextFrame;
- // Reset our delta synch trigger so we don't send another frame synch until we
- // send at least 1 unreliable state update after this fame synch or teleport
- m_DeltaSynch = false;
+ return false;
}
- // This is used to determine if we need to send the state update reliably (if we are doing an axial sync)
- flagStates.UnreliableFrameSync = isAxisSync;
-
- var isTeleportingAndNotSynchronizing = flagStates.IsTeleportingNextFrame && !isSynchronization;
- var isDirty = false;
- var isPositionDirty = isTeleportingAndNotSynchronizing ? flagStates.HasPositionChange : false;
- var isRotationDirty = isTeleportingAndNotSynchronizing ? flagStates.HasRotAngleChange : false;
- var isScaleDirty = isTeleportingAndNotSynchronizing ? flagStates.HasScaleChange : false;
+ var parentNetworkObject = NetworkObject.transform.parent.GetComponent();
- flagStates.SwitchTransformSpaceWhenParented = SwitchTransformSpaceWhenParented;
-
-
-
- // All of the checks below, up to the delta position checking portion, are to determine if the
- // authority changed a property during runtime that requires a full synchronizing.
-#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D
- if ((InLocalSpace != flagStates.InLocalSpace || isSynchronization) && !m_UseRigidbodyForMotion)
-#else
- if (InLocalSpace != flagStates.InLocalSpace)
-#endif
+ // In-scene placed NetworkObjects parented under a GameObject with no
+ // NetworkObject preserve their lossyScale when synchronizing.
+ if (parentNetworkObject == null && NetworkObject.InScenePlaced)
{
- // When SwitchTransformSpaceWhenParented is set we automatically set our local space based on whether
- // we are parented or not.
- flagStates.InLocalSpace = SwitchTransformSpaceWhenParented ? transform.parent != null : InLocalSpace;
- if (SwitchTransformSpaceWhenParented)
- {
- InLocalSpace = flagStates.InLocalSpace;
- }
- isDirty = true;
+ return true;
+ }
- // If we are already teleporting preserve the teleport flag.
- // If we don't have SwitchTransformSpaceWhenParented set or we are synchronizing,
- // then set the teleport flag.
- flagStates.IsTeleportingNextFrame |= !SwitchTransformSpaceWhenParented || isSynchronization;
+ // Or if the relative NetworkObject has a parent NetworkObject
+ return parentNetworkObject != null;
+ }
- // Otherwise, if SwitchTransformSpaceWhenParented is set we force a full state update.
- // If interpolation is enabled, then any non-authority instance will update any pending
- // buffered values to the correct world or local space values.
- forceState = SwitchTransformSpaceWhenParented;
- }
+ ///
+ /// Applies the transform to the .
+ ///
+ ///
+ /// Splits out to be job friendly.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private bool CheckForStateChange(ref NetworkTransformState networkState, bool isSynchronization = false, ulong targetClientId = 0, bool forceState = false)
+ {
+ var config = GetTransformDeltaConfig();
+ var flagStates = networkState.FlagStates;
+ // Resolve which transform space is being compared before sampling, otherwise the wrong set of
+ // values would be read.
+ var transformSpaceChanged = ResolveTransformSpace(ref config, ref flagStates, transform.parent != null, isSynchronization, ref forceState);
+ networkState.FlagStates = flagStates;
+ InLocalSpace = config.InLocalSpace;
#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D
var position = m_UseRigidbodyForMotion ? m_NetworkRigidbodyInternal.GetPosition() : InLocalSpace ? CachedTransform.localPosition : CachedTransform.position;
var rotation = m_UseRigidbodyForMotion ? m_NetworkRigidbodyInternal.GetRotation() : InLocalSpace ? CachedTransform.localRotation : CachedTransform.rotation;
- var positionThreshold = Vector3.one * PositionThreshold;
- var rotationThreshold = Vector3.one * RotAngleThreshold;
-
// NSS: Disabling this for the time being
// TODO: Determine if we actually need this and if not remove this from NetworkRigidBodyBase
//if (m_UseRigidbodyForMotion)
@@ -2215,353 +2475,46 @@ private bool CheckForStateChange(ref NetworkTransformState networkState, bool is
#else
var position = InLocalSpace ? CachedTransform.localPosition : CachedTransform.position;
var rotation = InLocalSpace ? CachedTransform.localRotation : CachedTransform.rotation;
- var positionThreshold = Vector3.one * PositionThreshold;
- var rotationThreshold = Vector3.one * RotAngleThreshold;
#endif
- var rotAngles = rotation.eulerAngles;
- var scale = CachedTransform.localScale;
- flagStates.IsSynchronizing = isSynchronization;
-
- // Check for parenting when synchronizing and/or teleporting
- if (isSynchronization || flagStates.IsTeleportingNextFrame || forceState)
- {
- // This all has to do with complex nested hierarchies and how it impacts scale
- // when set for the first time or teleporting and depends upon whether the
- // NetworkObject is parented (or "de-parented") at the same time any scale
- // values are applied.
- var hasParentNetworkObject = false;
-
- // If the NetworkObject belonging to this NetworkTransform instance has a parent
- // (i.e. this handles nested NetworkTransforms under a parent at some layer above)
- if (NetworkObject.transform.parent != null)
- {
- var parentNetworkObject = NetworkObject.transform.parent.GetComponent();
-
- // In-scene placed NetworkObjects parented under a GameObject with no
- // NetworkObject preserve their lossyScale when synchronizing.
- if (parentNetworkObject == null && NetworkObject.InScenePlaced)
- {
- hasParentNetworkObject = true;
- }
- else
- {
- // Or if the relative NetworkObject has a parent NetworkObject
- hasParentNetworkObject = parentNetworkObject != null;
- }
- }
-
- flagStates.IsParented = hasParentNetworkObject;
- }
-
- if (Interpolate != flagStates.UseInterpolation)
- {
- flagStates.UseInterpolation = Interpolate;
- isDirty = true;
- // When we change from interpolating to not interpolating (or vice versa) we need to synchronize/reset everything
- flagStates.IsTeleportingNextFrame = true;
- }
-
- if (UseQuaternionSynchronization != flagStates.QuaternionSync)
- {
- flagStates.QuaternionSync = UseQuaternionSynchronization;
- isDirty = true;
- flagStates.IsTeleportingNextFrame = true;
- }
-
- if (UseQuaternionCompression != flagStates.QuaternionCompression)
- {
- flagStates.QuaternionCompression = UseQuaternionCompression;
- isDirty = true;
- flagStates.IsTeleportingNextFrame = true;
- }
-
- if (UseHalfFloatPrecision != flagStates.UseHalfFloatPrecision)
- {
- flagStates.UseHalfFloatPrecision = UseHalfFloatPrecision;
- isDirty = true;
- flagStates.IsTeleportingNextFrame = true;
- }
-
- if (SlerpPosition != flagStates.UsePositionSlerp)
- {
- flagStates.UsePositionSlerp = SlerpPosition;
- isDirty = true;
- flagStates.IsTeleportingNextFrame = true;
- }
-
- if (UseUnreliableDeltas != flagStates.UseUnreliableDeltas)
- {
- flagStates.UseUnreliableDeltas = UseUnreliableDeltas;
- isDirty = true;
- flagStates.IsTeleportingNextFrame = true;
- }
-
- // Begin delta checks against last sent state update
- if (!UseHalfFloatPrecision)
- {
- if (SyncPositionX && (Mathf.Abs(networkState.PositionX - position.x) >= positionThreshold.x || flagStates.IsTeleportingNextFrame || isAxisSync || forceState))
- {
- networkState.PositionX = position.x;
- flagStates.SetHasPosition(Axis.X, true);
- isPositionDirty = true;
- }
-
- if (SyncPositionY && (Mathf.Abs(networkState.PositionY - position.y) >= positionThreshold.y || flagStates.IsTeleportingNextFrame || isAxisSync || forceState))
- {
- networkState.PositionY = position.y;
- flagStates.SetHasPosition(Axis.Y, true);
- isPositionDirty = true;
- }
-
- if (SyncPositionZ && (Mathf.Abs(networkState.PositionZ - position.z) >= positionThreshold.z || flagStates.IsTeleportingNextFrame || isAxisSync || forceState))
- {
- networkState.PositionZ = position.z;
- flagStates.SetHasPosition(Axis.Z, true);
- isPositionDirty = true;
- }
- }
- else if (SynchronizePosition)
- {
- // If we are teleporting then we can skip the delta threshold check
- isPositionDirty = flagStates.IsTeleportingNextFrame || isAxisSync || forceState;
- if (m_HalfFloatTargetTickOwnership > CurrentTick)
- {
- isPositionDirty = true;
- }
-
- // For NetworkDeltaPosition, if any axial value is dirty then we always send a full update
- if (!isPositionDirty)
- {
- for (int i = 0; i < 3; i++)
- {
- if (Math.Abs(position[i] - m_HalfPositionState.PreviousPosition[i]) >= positionThreshold[i])
- {
- isPositionDirty = i == 0 ? SyncPositionX : i == 1 ? SyncPositionY : SyncPositionZ;
- if (!isPositionDirty)
- {
- continue;
- }
- break;
- }
- }
- }
-
- // If the position is dirty or we are teleporting (which includes synchronization)
- // then determine what parts of the NetworkDeltaPosition should be updated
- if (isPositionDirty)
- {
- // If we are not synchronizing the transform state for the first time
- if (!isSynchronization)
- {
- // With global teleporting (broadcast to all non-authority instances)
- // we re-initialize authority's NetworkDeltaPosition and synchronize all
- // non-authority instances with the new full precision position
- if (flagStates.IsTeleportingNextFrame)
- {
- m_HalfPositionState = new NetworkDeltaPosition(position, networkState.NetworkTick, math.bool3(SyncPositionX, SyncPositionY, SyncPositionZ));
- networkState.CurrentPosition = position;
- }
- else // Otherwise, just synchronize the delta position value
- {
- m_HalfPositionState.HalfVector3.AxisToSynchronize = math.bool3(SyncPositionX, SyncPositionY, SyncPositionZ);
- m_HalfPositionState.UpdateFrom(ref position, networkState.NetworkTick);
- }
-
- networkState.NetworkDeltaPosition = m_HalfPositionState;
-
- // If ownership offset is greater or we are doing an axial synchronization then synchronize the base position
- if ((m_HalfFloatTargetTickOwnership > CurrentTick || isAxisSync) && !flagStates.IsTeleportingNextFrame)
- {
- flagStates.SynchronizeBaseHalfFloat = true;
- }
- else
- {
- flagStates.SynchronizeBaseHalfFloat = UseUnreliableDeltas ? m_HalfPositionState.CollapsedDeltaIntoBase : false;
- }
- }
- else // If synchronizing is set, then use the current full position value on the server side
- {
- if (ShouldSynchronizeHalfFloat(targetClientId))
- {
- // If we have a NetworkDeltaPosition that has a state applied, then we want to determine
- // what needs to be synchronized. For owner authoritative mode, the server side
- // will have no valid state yet.
- if (m_HalfPositionState.NetworkTick > 0)
- {
- // Always synchronize the base position and the ushort values of the
- // current m_HalfPositionState
- networkState.CurrentPosition = m_HalfPositionState.CurrentBasePosition;
- networkState.NetworkDeltaPosition = m_HalfPositionState;
- // If the server is the owner, in both server and owner authoritative modes,
- // or we are running in server authoritative mode, then we use the
- // HalfDeltaConvertedBack value as the delta position
- if (NetworkObject.IsOwnedByServer || IsServerAuthoritative())
- {
- networkState.DeltaPosition = m_HalfPositionState.HalfDeltaConvertedBack;
- }
- else
- {
- // Otherwise, we are in owner authoritative mode and the server's NetworkDeltaPosition
- // state is "non-authoritative" relative so we use the DeltaPosition.
- networkState.DeltaPosition = m_HalfPositionState.DeltaPosition;
- }
- }
- else // Reset everything and just send the current position
- {
- networkState.NetworkDeltaPosition = new NetworkDeltaPosition(Vector3.zero, 0, math.bool3(SyncPositionX, SyncPositionY, SyncPositionZ));
- networkState.DeltaPosition = Vector3.zero;
- networkState.CurrentPosition = position;
- }
- }
- else
- {
- networkState.NetworkDeltaPosition = new NetworkDeltaPosition(Vector3.zero, 0, math.bool3(SyncPositionX, SyncPositionY, SyncPositionZ));
- networkState.CurrentPosition = position;
- }
- // Add log entry for this update relative to the client being synchronized
- AddLogEntry(ref networkState, targetClientId, true);
- }
- flagStates.HasPositionX = SyncPositionX;
- flagStates.HasPositionY = SyncPositionY;
- flagStates.HasPositionZ = SyncPositionZ;
- flagStates.HasPositionChange = SyncPositionX || SyncPositionY || SyncPositionZ;
- }
- }
-
- if (!UseQuaternionSynchronization)
+ var sample = new TransformSample()
{
- if (SyncRotAngleX && (Mathf.Abs(Mathf.DeltaAngle(networkState.RotAngleX, rotAngles.x)) >= rotationThreshold.x || flagStates.IsTeleportingNextFrame || isAxisSync || forceState))
- {
- networkState.RotAngleX = rotAngles.x;
- flagStates.SetHasRotation(Axis.X, true);
- isRotationDirty = true;
- }
-
- if (SyncRotAngleY && (Mathf.Abs(Mathf.DeltaAngle(networkState.RotAngleY, rotAngles.y)) >= rotationThreshold.y || flagStates.IsTeleportingNextFrame || isAxisSync || forceState))
- {
- networkState.RotAngleY = rotAngles.y;
- flagStates.SetHasRotation(Axis.Y, true);
- isRotationDirty = true;
- }
-
- if (SyncRotAngleZ && (Mathf.Abs(Mathf.DeltaAngle(networkState.RotAngleZ, rotAngles.z)) >= rotationThreshold.z || flagStates.IsTeleportingNextFrame || isAxisSync || forceState))
- {
- networkState.RotAngleZ = rotAngles.z;
- flagStates.SetHasRotation(Axis.Z, true);
- isRotationDirty = true;
- }
- }
- else if (SynchronizeRotation)
- {
- // If we are teleporting then we can skip the delta threshold check
- isRotationDirty = flagStates.IsTeleportingNextFrame || isAxisSync || forceState;
- // For quaternion synchronization, if one angle is dirty we send a full update
- if (!isRotationDirty)
- {
- var previousRotation = networkState.Rotation.eulerAngles;
- for (int i = 0; i < 3; i++)
- {
- if (Mathf.Abs(Mathf.DeltaAngle(previousRotation[i], rotAngles[i])) >= rotationThreshold[i])
- {
- isRotationDirty = true;
- break;
- }
- }
- }
- if (isRotationDirty)
- {
- networkState.Rotation = rotation;
- flagStates.MarkChanged(AxialType.Rotation, true);
- }
- }
-
- // For scale, we need to check for parenting when synchronizing and/or teleporting (synchronization is always teleporting)
- if (flagStates.IsTeleportingNextFrame)
- {
- // If we are synchronizing and the associated NetworkObject has a parent then we want to send the
- // LossyScale if the NetworkObject has a parent since NetworkObject spawn order is not guaranteed
- if (flagStates.IsParented)
- {
- networkState.LossyScale = CachedTransform.lossyScale;
- }
- }
-
- // Checking scale deltas when not synchronizing
- if (!isSynchronization)
- {
- if (!UseHalfFloatPrecision)
- {
- if (SyncScaleX && (Mathf.Abs(networkState.ScaleX - scale.x) >= ScaleThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState))
- {
- networkState.ScaleX = scale.x;
- flagStates.SetHasScale(Axis.X, true);
- isScaleDirty = true;
- }
-
- if (SyncScaleY && (Mathf.Abs(networkState.ScaleY - scale.y) >= ScaleThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState))
- {
- networkState.ScaleY = scale.y;
- flagStates.SetHasScale(Axis.Y, true);
- isScaleDirty = true;
- }
-
- if (SyncScaleZ && (Mathf.Abs(networkState.ScaleZ - scale.z) >= ScaleThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState))
- {
- networkState.ScaleZ = scale.z;
- flagStates.SetHasScale(Axis.Z, true);
- isScaleDirty = true;
- }
- }
- else if (SynchronizeScale)
- {
- var previousScale = networkState.Scale;
- for (int i = 0; i < 3; i++)
- {
- if (Mathf.Abs(scale[i] - previousScale[i]) >= ScaleThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)
- {
- isScaleDirty = true;
- networkState.Scale[i] = scale[i];
- flagStates.SetHasScale((Axis)i, i == 0 ? SyncScaleX : i == 1 ? SyncScaleY : SyncScaleZ);
- }
- }
- }
- }
- // Just apply the full local scale when synchronizing
- else if (SynchronizeScale)
- {
- var localScale = CachedTransform.localScale;
- if (!UseHalfFloatPrecision)
- {
+ Position = position,
+ Rotation = rotation,
+ RotAngles = NetworkTransformMath.EulerAngles(rotation),
+ Scale = CachedTransform.localScale,
+ };
- networkState.ScaleX = localScale.x;
- networkState.ScaleY = localScale.y;
- networkState.ScaleZ = localScale.z;
- }
- else
- {
- networkState.Scale = localScale;
- }
- flagStates.MarkChanged(AxialType.Scale, true);
- isScaleDirty = true;
+ // Only resolved when it can actually be consumed, since both of these are lookups.
+ if (isSynchronization || networkState.FlagStates.IsTeleportingNextFrame || forceState)
+ {
+ sample.HasParentNetworkObject = HasParentNetworkObject();
+ sample.LossyScale = CachedTransform.lossyScale;
+ }
+ else if (networkState.FlagStates.IsParented)
+ {
+ // IsParented can still be set from a previous state update while none of the conditions above
+ // are met, and the delta check itself can raise the teleport flag after this point (a change to
+ // any of the interpolation or precision settings does so). Both together are what makes the
+ // lossy scale get written, so it has to be sampled here as well.
+ sample.LossyScale = CachedTransform.lossyScale;
}
- isDirty |= isPositionDirty || isRotationDirty || isScaleDirty;
- if (isDirty)
+ if (isSynchronization)
{
- // Some integration/unit tests disable the NetworkTransform and there is no
- // NetworkManager
- if (enabled)
- {
- // We use the NetworkTickSystem version since ServerTime is set when updating ticks
- networkState.NetworkTick = CurrentTick;
- }
+ sample.ShouldSynchronizeHalfFloat = ShouldSynchronizeHalfFloat(targetClientId);
+ sample.UseHalfDeltaConvertedBack = NetworkObject.IsOwnedByServer || IsServerAuthoritative();
}
- // Mark the state dirty for the next network tick update to clear out the bitset values
- flagStates.IsDirty |= isDirty;
+ var isDirty = CheckForStateChange(ref networkState, ref m_HalfPositionState, ref config, sample, isSynchronization, forceState, transformSpaceChanged);
+
+ ApplyTransformDeltaConfig(config);
+
+ if (config.LogSynchronizationEntry)
+ {
+ // Add log entry for this update relative to the client being synchronized
+ AddLogEntry(ref networkState, targetClientId, true);
+ }
- // Apply any flag state changes
- networkState.FlagStates = flagStates;
return isDirty;
}
@@ -2594,7 +2547,7 @@ private void OnNetworkTick(bool isCalledFromParent = false)
#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D
// Let the parent handle the updating of this to keep the two synchronized
- if (!isCalledFromParent && m_UseRigidbodyForMotion && m_NetworkRigidbodyInternal.ParentBody != null && !m_LocalAuthoritativeNetworkState.IsTeleportingNextFrame)
+ if (!isCalledFromParent && m_UseRigidbodyForMotion && m_NetworkRigidbodyInternal.ParentBody != null && !m_LocalAuthoritativeNetworkState.FlagStates.IsTeleportingNextFrame)
{
return;
}
@@ -2625,6 +2578,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, time);
+ }
+ else
+ {
+ m_CachedNetworkManager.TransformStateManager.AddMeasurement(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Position, value, time);
+ }
+ return;
+ }
+
if (resetInterpolator)
{
m_PositionInterpolator.AutoConvertTransformSpace = SwitchTransformSpaceWhenParented;
@@ -2638,6 +2605,167 @@ internal void UpdatePositionInterpolator(Vector3 position, double time, bool res
}
}
+ ///
+ /// Adds a rotation measurement, routed to whichever interpolator this instance is using.
+ ///
+ [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, time);
+ }
+ else
+ {
+ m_CachedNetworkManager.TransformStateManager.AddMeasurement(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Rotation, value, time);
+ }
+ return;
+ }
+
+ if (resetInterpolator)
+ {
+ m_RotationInterpolator.AutoConvertTransformSpace = SwitchTransformSpaceWhenParented;
+ m_RotationInterpolator.InLocalSpace = InLocalSpace;
+ m_RotationInterpolator.ResetTo(CachedTransform.parent, rotation, time);
+ }
+ else
+ {
+ m_RotationInterpolator.AddMeasurement(transform.parent, rotation, time);
+ }
+ }
+
+ ///
+ /// Adds a scale measurement, routed to whichever interpolator this instance is using.
+ ///
+ [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, time);
+ }
+ else
+ {
+ m_CachedNetworkManager.TransformStateManager.AddMeasurement(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Scale, value, time);
+ }
+ return;
+ }
+
+ if (resetInterpolator)
+ {
+ m_ScaleInterpolator.ResetTo(scale, time);
+ }
+ else
+ {
+ m_ScaleInterpolator.AddMeasurement(transform.parent, scale, time);
+ }
+ }
+
+ ///
+ /// 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();
+ }
+
+ ///
+ /// The current interpolated position, from whichever interpolator this instance is using.
+ ///
+ [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();
+ }
+
+ ///
+ /// The current interpolated rotation, from whichever interpolator this instance is using.
+ ///
+ [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();
+ }
+
+ ///
+ /// The current interpolated scale, from whichever interpolator this instance is using.
+ ///
+ [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();
+ }
+
internal bool LogMotion;
///
@@ -2663,29 +2791,43 @@ protected internal void ApplyAuthoritativeState()
#endif
var networkState = m_LocalAuthoritativeNetworkState;
var flagStates = m_LocalAuthoritativeNetworkState.FlagStates;
+ // Cached since each is used more than once below.
+ var syncAllPosition = SyncPositionX && SyncPositionY && SyncPositionZ;
+ var syncAllRotation = SyncRotAngleX && SyncRotAngleY && SyncRotAngleZ;
+ var syncAllScale = SyncScaleX && SyncScaleY && SyncScaleZ;
+
// The m_InternalCurrentPosition, m_InternalCurrentRotation, and m_InternalCurrentScale values are continually updated
// at the end of this method and assure that when not interpolating the non-authoritative side
// cannot make adjustments to any portions the transform not being synchronized.
+ // Optimization: When every axis of a given property is synchronized there is no reason to eat the cost of transform reads per axis.
var adjustedPosition = m_InternalCurrentPosition;
- var currentPosition = GetSpaceRelativePosition();
- adjustedPosition.x = SyncPositionX ? m_InternalCurrentPosition.x : currentPosition.x;
- adjustedPosition.y = SyncPositionY ? m_InternalCurrentPosition.y : currentPosition.y;
- adjustedPosition.z = SyncPositionZ ? m_InternalCurrentPosition.z : currentPosition.z;
+ if (!syncAllPosition)
+ {
+ var currentPosition = GetSpaceRelativePosition();
+ adjustedPosition.x = SyncPositionX ? m_InternalCurrentPosition.x : currentPosition.x;
+ adjustedPosition.y = SyncPositionY ? m_InternalCurrentPosition.y : currentPosition.y;
+ adjustedPosition.z = SyncPositionZ ? m_InternalCurrentPosition.z : currentPosition.z;
+ }
var adjustedRotation = m_InternalCurrentRotation;
var adjustedRotAngles = adjustedRotation.eulerAngles;
- var currentRotation = GetSpaceRelativeRotation().eulerAngles;
- adjustedRotAngles.x = SyncRotAngleX ? adjustedRotAngles.x : currentRotation.x;
- adjustedRotAngles.y = SyncRotAngleY ? adjustedRotAngles.y : currentRotation.y;
- adjustedRotAngles.z = SyncRotAngleZ ? adjustedRotAngles.z : currentRotation.z;
- adjustedRotation.eulerAngles = adjustedRotAngles;
-
+ if (!syncAllRotation)
+ {
+ var currentRotation = GetSpaceRelativeRotation().eulerAngles;
+ adjustedRotAngles.x = SyncRotAngleX ? adjustedRotAngles.x : currentRotation.x;
+ adjustedRotAngles.y = SyncRotAngleY ? adjustedRotAngles.y : currentRotation.y;
+ adjustedRotAngles.z = SyncRotAngleZ ? adjustedRotAngles.z : currentRotation.z;
+ adjustedRotation.eulerAngles = adjustedRotAngles;
+ }
var adjustedScale = m_InternalCurrentScale;
- var currentScale = GetScale();
- adjustedScale.x = SyncScaleX ? adjustedScale.x : currentScale.x;
- adjustedScale.y = SyncScaleY ? adjustedScale.y : currentScale.y;
- adjustedScale.z = SyncScaleZ ? adjustedScale.z : currentScale.z;
+ if (!syncAllScale)
+ {
+ var currentScale = GetScale();
+ adjustedScale.x = SyncScaleX ? adjustedScale.x : currentScale.x;
+ adjustedScale.y = SyncScaleY ? adjustedScale.y : currentScale.y;
+ adjustedScale.z = SyncScaleZ ? adjustedScale.z : currentScale.z;
+ }
// Only if SwitchTransformSpaceWhenParented is not enabled should
// non-authority instances preserve the current state's local space
@@ -2716,7 +2858,7 @@ protected internal void ApplyAuthoritativeState()
{
if (SynchronizePosition)
{
- var interpolatedPosition = m_PositionInterpolator.GetInterpolatedValue();
+ var interpolatedPosition = GetInterpolatedPosition();
if (UseHalfFloatPrecision)
{
adjustedPosition = interpolatedPosition;
@@ -2733,11 +2875,11 @@ protected internal void ApplyAuthoritativeState()
{
if (UseHalfFloatPrecision)
{
- adjustedScale = m_ScaleInterpolator.GetInterpolatedValue();
+ adjustedScale = GetInterpolatedScale();
}
else
{
- var interpolatedScale = m_ScaleInterpolator.GetInterpolatedValue();
+ var interpolatedScale = GetInterpolatedScale();
if (SyncScaleX) { adjustedScale.x = interpolatedScale.x; }
if (SyncScaleY) { adjustedScale.y = interpolatedScale.y; }
if (SyncScaleZ) { adjustedScale.z = interpolatedScale.z; }
@@ -2746,7 +2888,7 @@ protected internal void ApplyAuthoritativeState()
if (SynchronizeRotation)
{
- var interpolatedRotation = m_RotationInterpolator.GetInterpolatedValue();
+ var interpolatedRotation = GetInterpolatedRotation();
if (UseQuaternionSynchronization)
{
adjustedRotation = interpolatedRotation;
@@ -2815,7 +2957,7 @@ protected internal void ApplyAuthoritativeState()
// Update our current position if it changed or we are interpolating
if (flagStates.HasPositionChange || Interpolate)
{
- if (SyncPositionX && SyncPositionY && SyncPositionZ)
+ if (syncAllPosition)
{
m_InternalCurrentPosition = adjustedPosition;
}
@@ -2834,7 +2976,7 @@ protected internal void ApplyAuthoritativeState()
m_NetworkRigidbodyInternal.MovePosition(m_InternalCurrentPosition);
if (LogMotion)
{
- Debug.Log($"[Client-{m_CachedNetworkManager.LocalClientId}][Interpolate: {networkState.UseInterpolation}][TransPos: {transform.position}][RBPos: {m_NetworkRigidbodyInternal.GetPosition()}][CurrentPos: {m_InternalCurrentPosition}");
+ Debug.Log($"[Client-{m_CachedNetworkManager.LocalClientId}][Interpolate: {networkState.FlagStates.UseInterpolation}][TransPos: {transform.position}][RBPos: {m_NetworkRigidbodyInternal.GetPosition()}][CurrentPos: {m_InternalCurrentPosition}");
}
}
@@ -2856,9 +2998,9 @@ protected internal void ApplyAuthoritativeState()
if (SynchronizeRotation)
{
// Update our current rotation if it changed or we are interpolating
- if (networkState.HasRotAngleChange || Interpolate)
+ if (flagStates.HasRotAngleChange || Interpolate)
{
- if ((SyncRotAngleX && SyncRotAngleY && SyncRotAngleZ) || UseQuaternionSynchronization)
+ if (syncAllRotation || UseQuaternionSynchronization)
{
m_InternalCurrentRotation = adjustedRotation;
}
@@ -2900,7 +3042,7 @@ protected internal void ApplyAuthoritativeState()
// Update our current scale if it changed or we are interpolating
if (flagStates.HasScaleChange || Interpolate)
{
- if (SyncScaleX && SyncScaleY && SyncScaleZ)
+ if (syncAllScale)
{
m_InternalCurrentScale = adjustedScale;
}
@@ -2926,7 +3068,7 @@ protected internal void ApplyAuthoritativeState()
///
private void ApplyTeleportingState(NetworkTransformState newState)
{
- if (!newState.IsTeleportingNextFrame)
+ if (!newState.FlagStates.IsTeleportingNextFrame)
{
return;
}
@@ -2937,13 +3079,11 @@ private void ApplyTeleportingState(NetworkTransformState newState)
var currentEulerAngles = currentRotation.eulerAngles;
var currentScale = CachedTransform.localScale;
- var isSynchronization = newState.IsSynchronizing;
+ var isSynchronization = newState.FlagStates.IsSynchronizing;
var flagStates = newState.FlagStates;
// Clear all interpolators
- m_ScaleInterpolator.Clear();
- m_PositionInterpolator.Clear();
- m_RotationInterpolator.Clear();
+ ClearInterpolators();
if (flagStates.HasPositionChange)
{
@@ -3057,7 +3197,7 @@ private void ApplyTeleportingState(NetworkTransformState newState)
if (Interpolate)
{
- m_ScaleInterpolator.ResetTo(currentScale, sentTime);
+ UpdateScaleInterpolator(currentScale, sentTime, true);
}
}
@@ -3108,9 +3248,7 @@ private void ApplyTeleportingState(NetworkTransformState newState)
if (Interpolate)
{
- m_RotationInterpolator.AutoConvertTransformSpace = SwitchTransformSpaceWhenParented;
- m_RotationInterpolator.InLocalSpace = newState.InLocalSpace;
- m_RotationInterpolator.ResetTo(CachedTransform.parent, currentRotation, sentTime);
+ UpdateRotationInterpolator(currentRotation, sentTime, true);
}
}
@@ -3153,13 +3291,13 @@ internal void ApplyUpdatedState(NetworkTransformState newState)
m_LocalAuthoritativeNetworkState = newState;
if (flagStates.IsTeleportingNextFrame)
{
- LastTickSync = m_LocalAuthoritativeNetworkState.GetNetworkTick();
+ LastTickSync = m_LocalAuthoritativeNetworkState.NetworkTick;
ApplyTeleportingState(m_LocalAuthoritativeNetworkState);
return;
}
else if (flagStates.IsSynchronizing)
{
- LastTickSync = m_LocalAuthoritativeNetworkState.GetNetworkTick();
+ LastTickSync = m_LocalAuthoritativeNetworkState.NetworkTick;
}
var sentTime = newState.SentTime;
@@ -3256,7 +3394,7 @@ internal void ApplyUpdatedState(NetworkTransformState newState)
}
}
m_TargetScale = currentScale;
- m_ScaleInterpolator.AddMeasurement(transform.parent, currentScale, sentTime);
+ UpdateScaleInterpolator(currentScale, sentTime);
}
// With rotation, we check if there are any changes first and
@@ -3291,7 +3429,7 @@ internal void ApplyUpdatedState(NetworkTransformState newState)
currentRotation.eulerAngles = currentEulerAngles;
}
- m_RotationInterpolator.AddMeasurement(transform.parent, currentRotation, sentTime);
+ UpdateRotationInterpolator(currentRotation, sentTime);
}
}
@@ -3660,9 +3798,138 @@ 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 when the session is using .
+ ///
+ ///
+ /// Adds this instance to the 's interpolation when the
+ /// session is running in .
+ ///
+ private void RegisterForBatchedInterpolation()
+ {
+ if (m_CachedNetworkManager == null || m_CachedNetworkManager.NetworkConfig.TransformSyncMode != 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.TransformSyncMode != 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()
{
@@ -3718,9 +3985,9 @@ private void ResetInterpolatedStateToCurrentAuthoritativeState()
m_RotationInterpolator.AutoConvertTransformSpace = SwitchTransformSpaceWhenParented;
m_RotationInterpolator.InLocalSpace = InLocalSpace;
- m_RotationInterpolator.ResetTo(transform.parent, rotation, serverTime);
+ UpdateRotationInterpolator(rotation, serverTime, true);
- m_ScaleInterpolator.ResetTo(transform.parent, transform.localScale, serverTime);
+ UpdateScaleInterpolator(transform.localScale, serverTime, true);
}
///
@@ -3807,6 +4074,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)
{
@@ -3827,6 +4097,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;
@@ -3916,15 +4191,13 @@ private void DefaultParentChanged()
if (Interpolate)
{
- m_ScaleInterpolator.Clear();
- m_PositionInterpolator.Clear();
- m_RotationInterpolator.Clear();
+ ClearInterpolators();
// Always use NetworkManager here as this can be invoked prior to spawning
var tempTime = new NetworkTime(NetworkManager.NetworkConfig.TickRate, NetworkManager.ServerTime.Tick).Time;
UpdatePositionInterpolator(m_InternalCurrentPosition, tempTime, true);
- m_ScaleInterpolator.ResetTo(m_InternalCurrentScale, tempTime);
- m_RotationInterpolator.ResetTo(m_InternalCurrentRotation, tempTime);
+ UpdateScaleInterpolator(m_InternalCurrentScale, tempTime, true);
+ UpdateRotationInterpolator(m_InternalCurrentRotation, tempTime, true);
}
}
@@ -3942,6 +4215,9 @@ internal override void InternalOnNetworkObjectParentChanged(NetworkObject parent
return;
}
+ // Handle transform space re-parenting transitions for batched transforms.
+ ConvertBatchedInterpolationSpace(m_PositionInterpolator.Parent, parentNetworkObject != null ? parentNetworkObject.transform : null);
+
InLocalSpace = parentNetworkObject != null;
if (SynchronizePosition)
@@ -3950,7 +4226,7 @@ internal override void InternalOnNetworkObjectParentChanged(NetworkObject parent
m_PositionInterpolator.InLocalSpace = InLocalSpace;
m_PositionInterpolator.Parent = InLocalSpace ? parentNetworkObject.transform : null;
- if (LastTickSync == m_LocalAuthoritativeNetworkState.GetNetworkTick())
+ if (LastTickSync == m_LocalAuthoritativeNetworkState.NetworkTick)
{
m_InternalCurrentPosition = m_LastStateTargetPosition = GetSpaceRelativePosition();
m_PositionInterpolator.ResetTo(m_PositionInterpolator.Parent, m_InternalCurrentPosition, m_CachedNetworkManager.ServerTime.Time);
@@ -3981,7 +4257,7 @@ internal override void InternalOnNetworkObjectParentChanged(NetworkObject parent
m_RotationInterpolator.AutoConvertTransformSpace = SwitchTransformSpaceWhenParented;
m_RotationInterpolator.InLocalSpace = InLocalSpace;
m_RotationInterpolator.Parent = InLocalSpace ? parentNetworkObject.transform : null;
- if (LastTickSync == m_LocalAuthoritativeNetworkState.GetNetworkTick())
+ if (LastTickSync == m_LocalAuthoritativeNetworkState.NetworkTick)
{
m_InternalCurrentRotation = GetSpaceRelativeRotation();
m_TargetRotation = m_InternalCurrentRotation.eulerAngles;
@@ -4256,8 +4532,48 @@ internal BufferedLinearInterpolatorQuaternion GetRotationInterpolator()
}
#endif
- // Non-Authority
- private void UpdateInterpolation()
+ ///
+ /// Represents the commonly shared interpolation values that are identical for every
+ /// and is updated per frame.
+ ///
+ ///
+ /// These are calculated once per update stage by as opposed
+ /// to being recalculated by each instance. Both the base and the "one additional tick" variants are
+ /// pre-calculated because owner authoritative instances owned by another client add a tick to account
+ /// for the 2xRTT relay time.
+ ///
+ internal struct InterpolationFrameData
+ {
+ internal double CurrentTime;
+ internal float DeltaTime;
+ internal float FixedDeltaTime;
+ // Smooth dampening and extrapolation specific:
+ // We clamp between the tick rate frequency and the tick latency x tick rate frequency
+ internal double MinDeltaTime;
+ internal bool IsServer;
+ // Only true if the network topology selected for the session permits the additional owner authority tick.
+ internal bool OwnerAuthorityTickOffsetAllowed;
+ // Tick latency (ticks ago) used to process state updates in the queue.
+ internal double TickLatencyAsTime;
+ // The maximum time we will lerp between values. If the time exceeds this due to extreme latency then
+ // the value's interpolation rate will be accelerated to reach the goal and continue interpolating.
+ internal double MaxDeltaTime;
+ // Combines the two values above, with any additional owner authority tick applied.
+ internal double TickLatencyAsTimeExtraTick;
+ internal double MaxDeltaTimeExtraTick;
+ // Legacy lerp render times for a "ticks ago" of 1 and 2 (each plus InterpolationBufferTickOffset).
+ internal double LegacyRenderTime;
+ internal double LegacyRenderTimeExtraTick;
+ }
+
+ ///
+ /// Refreshes the .
+ ///
+ ///
+ /// Invoked once per update stage (authority and rigid body motion relative), prior to updating
+ /// registered instances.
+ ///
+ internal static void RefreshInterpolationFrameData(NetworkManager networkManager)
{
// Use the local time because:
// Client-Server:
@@ -4265,48 +4581,72 @@ private void UpdateInterpolation()
// Local time on clients takes latency into consideration.
// Distributed authority:
// Local time is used by the authority.
- // Local time on non-authority takes latency into consid]eration.
- var timeSystem = m_CachedNetworkManager.LocalTime;
- var currentTime = timeSystem.Time;
-#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D
- var cachedDeltaTime = m_UseRigidbodyForMotion ? m_CachedNetworkManager.RealTimeProvider.FixedDeltaTime : m_CachedNetworkManager.RealTimeProvider.DeltaTime;
-#else
- var cachedDeltaTime = m_CachedNetworkManager.RealTimeProvider.DeltaTime;
-#endif
+ // Local time on non-authority takes latency into consideration.
+ var timeSystem = networkManager.LocalTime;
+ var realTimeProvider = networkManager.RealTimeProvider;
+ var minDeltaTime = timeSystem.FixedDeltaTimeAsDouble;
+
// Optional user defined tick offset to be used to push the "render time" (the time that will be used to determine if a state update is available)
// back in order to provide more room for the interpolator to interpolate towards when latency conditions are impacting the frequency that state
// updates are received.
- var tickLatency = Mathf.Max(1, m_CachedNetworkManager.NetworkTimeSystem.TickLatency + InterpolationBufferTickOffset);
+ var tickLatency = Mathf.Max(1, networkManager.NetworkTimeSystem.TickLatency + InterpolationBufferTickOffset);
+ var isServer = networkManager.IsServer;
+
+ networkManager.TransformInterpolationFrameData = new InterpolationFrameData()
+ {
+ CurrentTime = timeSystem.Time,
+ DeltaTime = realTimeProvider.DeltaTime,
+ FixedDeltaTime = realTimeProvider.FixedDeltaTime,
+ MinDeltaTime = minDeltaTime,
+ IsServer = isServer,
+ // The additional owner authority tick only applies within a client-server topology (including
+ // DAHost) and only on instances that are not the server/host.
+ OwnerAuthorityTickOffsetAllowed = !isServer && (!networkManager.DistributedAuthorityMode || !networkManager.CMBServiceConnection),
+ TickLatencyAsTime = timeSystem.TimeTicksAgo(tickLatency).Time,
+ MaxDeltaTime = tickLatency * minDeltaTime,
+ TickLatencyAsTimeExtraTick = timeSystem.TimeTicksAgo(tickLatency + 1).Time,
+ MaxDeltaTimeExtraTick = (tickLatency + 1) * minDeltaTime,
+ // Since InterpolationBufferTickOffset defaults to zero, this should not impact existing projects but
+ // still provides users with the ability to tweak their ticks ago time.
+ LegacyRenderTime = timeSystem.TimeTicksAgo(1 + InterpolationBufferTickOffset).Time,
+ LegacyRenderTimeExtraTick = timeSystem.TimeTicksAgo(2 + InterpolationBufferTickOffset).Time,
+ };
+ }
- // If using an owner authoritative motion model
- if (!IsServerAuthoritative())
+ ///
+ /// Only updated by non-authority instances.
+ ///
+ private void UpdateInterpolation()
+ {
+ // TODO-JIRA-TICKET:
+ // This could be further optimized by excluding batched transforms from the Update/FixedUpdate invocations.
+ if (InterpolatorIndex >= 0)
{
- // and if we are in a client-server topology (including DAHost)
- if (!m_CachedNetworkManager.DistributedAuthorityMode ||
- (m_CachedNetworkManager.DistributedAuthorityMode && !m_CachedNetworkManager.CMBServiceConnection))
- {
- // If this instance belongs to another client (i.e. not the server/host), then add 1 to our tick latency.
- if (!m_CachedNetworkManager.IsServer && !NetworkObject.IsOwnedByServer)
- {
- // Account for the 2xRTT with owner authoritative
- tickLatency += 1;
- }
- }
+ return;
}
- // Note: This is for the legacy lerp type in order to maintain the same end result for any games under development that have tuned their
- // project's to match the legacy lerp's end result.
- var cachedRenderTime = 0.0;
- if (PositionInterpolationType == InterpolationTypes.LegacyLerp || RotationInterpolationType == InterpolationTypes.LegacyLerp || ScaleInterpolationType == InterpolationTypes.LegacyLerp)
- {
- // Since InterpolationBufferTickOffset defaults to zero, this should not impact exist projects but still provides users with the ability to tweak
- // their ticks ago time.
- var ticksAgo = (!IsServerAuthoritative() && !IsServer ? 2 : 1) + InterpolationBufferTickOffset;
- cachedRenderTime = timeSystem.TimeTicksAgo(ticksAgo).Time;
- }
+ // Get the InterpolationFrameData for this frame
+ var frameData = m_CachedNetworkManager.TransformInterpolationFrameData;
+ var currentTime = frameData.CurrentTime;
+ var minDeltaTime = frameData.MinDeltaTime;
+#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D
+ var cachedDeltaTime = m_UseRigidbodyForMotion ? frameData.FixedDeltaTime : frameData.DeltaTime;
+#else
+ var cachedDeltaTime = frameData.DeltaTime;
+#endif
+ // IsServerAuthoritative is virtual, so resolve it once and reuse it below.
+ var isServerAuthoritative = IsServerAuthoritative();
+
+ // If using an owner authoritative motion model and this instance belongs to another client, then
+ // account for the 2xRTT relay through the host or server by adding 1 to our tick latency.
+ var useExtraTick = !isServerAuthoritative && frameData.OwnerAuthorityTickOffsetAllowed && !NetworkObject.IsOwnedByServer;
- // Get the tick latency (ticks ago) as time (in the past) to process state updates in the queue.
- var tickLatencyAsTime = timeSystem.TimeTicksAgo(tickLatency).Time;
+ var tickLatencyAsTime = useExtraTick ? frameData.TickLatencyAsTimeExtraTick : frameData.TickLatencyAsTime;
+ var maxDeltaTime = useExtraTick ? frameData.MaxDeltaTimeExtraTick : frameData.MaxDeltaTime;
+
+ // Note: This is for the legacy lerp type in order to maintain the same end result for any games under development that have tuned their
+ // project's to match the legacy lerp's end result. It is only consumed by the LegacyLerp branches below.
+ var cachedRenderTime = !isServerAuthoritative && !frameData.IsServer ? frameData.LegacyRenderTimeExtraTick : frameData.LegacyRenderTime;
#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D
// If using rigid body for motion, then we need to increment
@@ -4319,15 +4659,6 @@ private void UpdateInterpolation()
}
#endif
- // Smooth dampening and extrapolation specific:
- // We clamp between the tick rate frequency and the tick latency x tick rate frequency
- var minDeltaTime = timeSystem.FixedDeltaTimeAsDouble;
-
- // Maximum delta time is the maximum time we will lerp between values. If the time exceeds this due to extreme
- // latency then the value's interpolation rate will be accelerated to reach the goal and continue interpolating
- // the next state updates.
- var maxDeltaTime = tickLatency * minDeltaTime;
-
// Now only update the interpolators for the portions of the transform being synchronized
if (SynchronizePosition)
{
@@ -4634,6 +4965,9 @@ private void UpdateTransformState()
return;
}
+ // Go ahead and apply the network delivery flag first to assure it is included with the state.
+ m_LocalAuthoritativeNetworkState.UpdateReliability();
+
bool isServerAuthoritative = IsServerAuthoritative();
if (isServerAuthoritative && !IsServer)
{
@@ -4645,13 +4979,8 @@ private void UpdateTransformState()
}
m_OutboundMessage.NetworkTransform = this;
- // Determine what network delivery method to use:
- // When to send reliable packets:
- // - If UsUnrealiable is not enabled
- // - If teleporting or synchronizing
- // - If sending an UnrealiableFrameSync or synchronizing the base position of the NetworkDeltaPosition
- var networkDelivery = !UseUnreliableDeltas | m_LocalAuthoritativeNetworkState.FlagStates.IsTeleportingNextFrame | m_LocalAuthoritativeNetworkState.FlagStates.IsSynchronizing
- | m_LocalAuthoritativeNetworkState.FlagStates.UnreliableFrameSync | m_LocalAuthoritativeNetworkState.FlagStates.SynchronizeBaseHalfFloat
+ // Determine the network delivery type to use
+ var networkDelivery = m_LocalAuthoritativeNetworkState.FlagStates.ReliableSequenced
? MessageDeliveryType.DefaultDelivery : NetworkDelivery.UnreliableSequenced;
// Server-host-dahost always sends updates to all clients (but itself)
@@ -4786,13 +5115,37 @@ internal void TickUpdate()
Remove();
return;
}
+
+ //
+ if (m_NetworkManager.NetworkConfig.TransformSyncMode == 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/NetworkTransformDeltaCheck.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformDeltaCheck.cs
new file mode 100644
index 0000000000..a14b4fc9a0
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformDeltaCheck.cs
@@ -0,0 +1,611 @@
+using System.Runtime.CompilerServices;
+using Unity.Mathematics;
+using UnityEngine;
+
+namespace Unity.Netcode.Components
+{
+ public partial class NetworkTransform
+ {
+ ///
+ /// Abstraction layer config:
+ /// Everything needs from the instance it is checking.
+ ///
+ ///
+ /// This creates the abstraction layer between itself and the configuration
+ /// of the in order to assure the delta check can be run both on the main
+ /// thread and from within a job.
+ /// A few members are read/write: the check can change the transform space it operates in and it
+ /// advances the axial frame synchronization bookkeeping, both of which have to make it back to the
+ /// instance.
+ ///
+ internal struct TransformDeltaConfig
+ {
+ internal float PositionThreshold;
+ internal float RotAngleThreshold;
+ internal float ScaleThreshold;
+
+ internal bool SyncPositionX;
+ internal bool SyncPositionY;
+ internal bool SyncPositionZ;
+ internal bool SyncRotAngleX;
+ internal bool SyncRotAngleY;
+ internal bool SyncRotAngleZ;
+ internal bool SyncScaleX;
+ internal bool SyncScaleY;
+ internal bool SyncScaleZ;
+
+ internal bool UseQuaternionSynchronization;
+ internal bool UseQuaternionCompression;
+ internal bool UseHalfFloatPrecision;
+ internal bool SlerpPosition;
+ internal bool Interpolate;
+ internal bool UseUnreliableDeltas;
+ internal bool SwitchTransformSpaceWhenParented;
+ internal bool UseRigidbodyForMotion;
+
+ ///
+ /// Read/write. can change this when
+ /// is enabled.
+ ///
+ internal bool InLocalSpace;
+
+ internal int CurrentTick;
+ internal int CachedTickRate;
+ internal int HalfFloatTargetTickOwnership;
+
+ ///
+ /// Read/write. The tick slot this instance next sends an axial frame synchronization on.
+ ///
+ internal int NextTickSync;
+
+ ///
+ /// Read/write. Whether a delta has been sent since the last axial frame synchronization.
+ ///
+ internal bool DeltaSynch;
+
+ ///
+ /// Some integration and unit tests disable the , in which case the
+ /// network tick is not applied to the state.
+ ///
+ internal bool Enabled;
+
+ ///
+ /// Write only. Set when the synchronization path produced a state that the (debug only) log entry
+ /// handler should be given. Reported back as opposed to being invoked inline so that the delta
+ /// check itself stays free of anything that cannot run within a job.
+ ///
+ internal bool LogSynchronizationEntry;
+ }
+
+ ///
+ /// Abstraction layer struct:
+ /// The transform values compares against, along with the handful of
+ /// lookups that can only be resolved on the main thread.
+ ///
+ ///
+ /// The position and rotation are already resolved for local versus world space and for whether a
+ /// rigidbody is driving the motion, so the delta check never has to touch a
+ /// or a rigidbody itself.
+ ///
+ internal struct TransformSample
+ {
+ internal Vector3 Position;
+ internal Quaternion Rotation;
+ internal Vector3 RotAngles;
+ internal Vector3 Scale;
+ internal Vector3 LossyScale;
+
+ ///
+ /// Whether the associated is considered parented. Resolving this needs
+ /// a lookup, so it is passed in already resolved.
+ ///
+ internal bool HasParentNetworkObject;
+
+ ///
+ /// Synchronization only. The result of for the client
+ /// being synchronized.
+ ///
+ internal bool ShouldSynchronizeHalfFloat;
+
+ ///
+ /// Synchronization only. When set, the half float delta uses the converted back value as opposed
+ /// to the full precision delta position.
+ ///
+ internal bool UseHalfDeltaConvertedBack;
+ }
+
+ ///
+ /// Abstraction layer struct:
+ /// Everything the batched delta check reads and writes for a single .
+ ///
+ ///
+ /// Held as one struct (as opposed to several parallel native arrays) so that adding or removing an
+ /// instance only ever has to keep three collections in step rather than a growing number of them.
+ ///
+ internal struct TransformDeltaEntry
+ {
+ ///
+ /// The last sent state, updated in place by the delta check.
+ ///
+ internal NetworkTransformState State;
+
+ ///
+ /// The instance's , updated in place by the delta check.
+ ///
+ internal NetworkDeltaPosition HalfPositionState;
+
+ internal TransformDeltaConfig Config;
+
+ ///
+ /// The parts of the sample that can only be resolved on the main thread. The job fills in the
+ /// transform values it reads through the .
+ ///
+ internal TransformSample Sample;
+
+ ///
+ /// Whether the transform currently has a parent, resolved on the main thread since a job cannot
+ /// walk the hierarchy.
+ ///
+ internal bool TransformHasParent;
+
+ ///
+ /// Set by the main thread when a full state update is being forced for this instance.
+ ///
+ internal bool ForceState;
+
+ ///
+ /// Result. Set by the job when there is a state update to send.
+ ///
+ internal bool IsDirty;
+ }
+
+ ///
+ /// Abstraction Layer Method:
+ /// Resolves which transform space the delta check operates in.
+ ///
+ ///
+ /// Runs before the transform is sampled because it determines whether the local or the world values
+ /// are the ones being compared. Kept separate (as opposed to being folded into
+ /// ) so that neither caller has to sample both spaces.
+ ///
+ /// The instance configuration. may be updated.
+ /// The state flags being updated.
+ /// Whether the transform currently has a parent.
+ /// Whether this is the initial synchronization of the state.
+ /// Set when the resulting state update has to be a full one.
+ /// true when the transform space changed, which makes the state dirty on its own.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static bool ResolveTransformSpace(ref TransformDeltaConfig config, ref FlagStates flagStates, bool transformHasParent, bool isSynchronization, ref bool forceState)
+ {
+ // All of the checks below, up to the delta position checking portion, are to determine if the
+ // authority changed a property during runtime that requires a full synchronizing.
+#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D
+ if (config.UseRigidbodyForMotion || (config.InLocalSpace == flagStates.InLocalSpace && !isSynchronization))
+ {
+ return false;
+ }
+#else
+ if (config.InLocalSpace == flagStates.InLocalSpace)
+ {
+ return false;
+ }
+#endif
+
+ // When SwitchTransformSpaceWhenParented is set we automatically set our local space based on whether
+ // we are parented or not.
+ flagStates.InLocalSpace = config.SwitchTransformSpaceWhenParented ? transformHasParent : config.InLocalSpace;
+ if (config.SwitchTransformSpaceWhenParented)
+ {
+ config.InLocalSpace = flagStates.InLocalSpace;
+ }
+
+ // If we are already teleporting preserve the teleport flag.
+ // If we don't have SwitchTransformSpaceWhenParented set or we are synchronizing,
+ // then set the teleport flag.
+ flagStates.IsTeleportingNextFrame |= !config.SwitchTransformSpaceWhenParented || isSynchronization;
+
+ // Otherwise, if SwitchTransformSpaceWhenParented is set we force a full state update.
+ // If interpolation is enabled, then any non-authority instance will update any pending
+ // buffered values to the correct world or local space values.
+ forceState = config.SwitchTransformSpaceWhenParented;
+ return true;
+ }
+
+ ///
+ /// Abstraction Layer Method:
+ /// Determines whether the sampled transform differs from the last state that was sent.
+ ///
+ ///
+ /// This is primary delta check implementation.
+ /// When running in per-instance mode, invokes this on a per instance basis.
+ /// When running in batched synchronization mode, this is invoked from within the job.
+ ///
+ /// The last sent state, updated in place with any changes.
+ /// The instance's , updated in place.
+ /// The instance configuration, some of which is updated in place.
+ /// The sampled transform values.
+ /// Whether this is the initial synchronization of the state.
+ /// Whether a full state update is being forced.
+ /// The result of .
+ /// true when there is a state update to send.
+ internal static bool CheckForStateChange(ref NetworkTransformState networkState, ref NetworkDeltaPosition halfPositionState,
+ ref TransformDeltaConfig config, in TransformSample sample, bool isSynchronization, bool forceState, bool transformSpaceChanged)
+ {
+ var flagStates = networkState.FlagStates;
+
+ // As long as we are not doing our first synchronization and we are sending unreliable deltas, each
+ // NetworkTransform will stagger its full transfom synchronization over a 1 second period based on the
+ // assigned tick slot (m_TickSync).
+ // More about DeltaSynch:
+ // If we have not sent any deltas since our last frame synch, then this will prevent us from sending
+ // frame synch's when the object is at rest. If this is false and a state update is detected and sent,
+ // then it will be set to true and each subsequent tick will do this check to determine if it should
+ // send a full frame synch.
+ var isAxisSync = false;
+ // We compare against the NetworkTickSystem version since ServerTime is set when updating ticks
+ if (config.UseUnreliableDeltas && !isSynchronization && config.DeltaSynch && config.NextTickSync <= config.CurrentTick)
+ {
+ // Increment to the next frame synch tick position for this instance
+ config.NextTickSync += config.CachedTickRate;
+ // If we are teleporting, we do not need to send a frame synch for this tick slot
+ // as a "frame synch" really is effectively just a teleport.
+ isAxisSync = !flagStates.IsTeleportingNextFrame;
+ // Reset our delta synch trigger so we don't send another frame synch until we
+ // send at least 1 unreliable state update after this fame synch or teleport
+ config.DeltaSynch = false;
+ }
+
+ // This is used to determine if we need to send the state update reliably (if we are doing an axial sync)
+ flagStates.UnreliableFrameSync = isAxisSync;
+
+ var isTeleportingAndNotSynchronizing = flagStates.IsTeleportingNextFrame && !isSynchronization;
+ // The transform space changing is a state change on its own.
+ var isDirty = transformSpaceChanged;
+ var isPositionDirty = isTeleportingAndNotSynchronizing ? flagStates.HasPositionChange : false;
+ var isRotationDirty = isTeleportingAndNotSynchronizing ? flagStates.HasRotAngleChange : false;
+ var isScaleDirty = isTeleportingAndNotSynchronizing ? flagStates.HasScaleChange : false;
+
+ flagStates.SwitchTransformSpaceWhenParented = config.SwitchTransformSpaceWhenParented;
+
+ var position = sample.Position;
+ var rotation = sample.Rotation;
+ var rotAngles = sample.RotAngles;
+ var scale = sample.Scale;
+ var positionThreshold = config.PositionThreshold;
+ var rotationThreshold = config.RotAngleThreshold;
+
+ var synchronizePosition = config.SyncPositionX || config.SyncPositionY || config.SyncPositionZ;
+ var synchronizeRotation = config.SyncRotAngleX || config.SyncRotAngleY || config.SyncRotAngleZ;
+ var synchronizeScale = config.SyncScaleX || config.SyncScaleY || config.SyncScaleZ;
+
+ flagStates.IsSynchronizing = isSynchronization;
+
+ // Check for parenting when synchronizing and/or teleporting
+ if (isSynchronization || flagStates.IsTeleportingNextFrame || forceState)
+ {
+ // This all has to do with complex nested hierarchies and how it impacts scale
+ // when set for the first time or teleporting and depends upon whether the
+ // NetworkObject is parented (or "de-parented") at the same time any scale
+ // values are applied.
+ flagStates.IsParented = sample.HasParentNetworkObject;
+ }
+
+ if (config.Interpolate != flagStates.UseInterpolation)
+ {
+ flagStates.UseInterpolation = config.Interpolate;
+ isDirty = true;
+ // When we change from interpolating to not interpolating (or vice versa) we need to synchronize/reset everything
+ flagStates.IsTeleportingNextFrame = true;
+ }
+
+ if (config.UseQuaternionSynchronization != flagStates.QuaternionSync)
+ {
+ flagStates.QuaternionSync = config.UseQuaternionSynchronization;
+ isDirty = true;
+ flagStates.IsTeleportingNextFrame = true;
+ }
+
+ if (config.UseQuaternionCompression != flagStates.QuaternionCompression)
+ {
+ flagStates.QuaternionCompression = config.UseQuaternionCompression;
+ isDirty = true;
+ flagStates.IsTeleportingNextFrame = true;
+ }
+
+ if (config.UseHalfFloatPrecision != flagStates.UseHalfFloatPrecision)
+ {
+ flagStates.UseHalfFloatPrecision = config.UseHalfFloatPrecision;
+ isDirty = true;
+ flagStates.IsTeleportingNextFrame = true;
+ }
+
+ if (config.SlerpPosition != flagStates.UsePositionSlerp)
+ {
+ flagStates.UsePositionSlerp = config.SlerpPosition;
+ isDirty = true;
+ flagStates.IsTeleportingNextFrame = true;
+ }
+
+ if (config.UseUnreliableDeltas != flagStates.UseUnreliableDeltas)
+ {
+ flagStates.UseUnreliableDeltas = config.UseUnreliableDeltas;
+ isDirty = true;
+ flagStates.IsTeleportingNextFrame = true;
+ }
+
+ // Begin delta checks against last sent state update
+ if (!config.UseHalfFloatPrecision)
+ {
+ if (config.SyncPositionX && (math.abs(networkState.PositionX - position.x) >= positionThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState))
+ {
+ networkState.PositionX = position.x;
+ flagStates.SetHasPosition(Axis.X, true);
+ isPositionDirty = true;
+ }
+
+ if (config.SyncPositionY && (math.abs(networkState.PositionY - position.y) >= positionThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState))
+ {
+ networkState.PositionY = position.y;
+ flagStates.SetHasPosition(Axis.Y, true);
+ isPositionDirty = true;
+ }
+
+ if (config.SyncPositionZ && (math.abs(networkState.PositionZ - position.z) >= positionThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState))
+ {
+ networkState.PositionZ = position.z;
+ flagStates.SetHasPosition(Axis.Z, true);
+ isPositionDirty = true;
+ }
+ }
+ else if (synchronizePosition)
+ {
+ // If we are teleporting then we can skip the delta threshold check
+ isPositionDirty = flagStates.IsTeleportingNextFrame || isAxisSync || forceState;
+ if (config.HalfFloatTargetTickOwnership > config.CurrentTick)
+ {
+ isPositionDirty = true;
+ }
+
+ // For NetworkDeltaPosition, if any axial value is dirty then we always send a full update.
+ // Unrolled (as opposed to indexing into the Vector3s) since the indexer is a bounds checked
+ // property as opposed to a direct field access.
+ if (!isPositionDirty)
+ {
+ var previousPosition = halfPositionState.PreviousPosition;
+ isPositionDirty = (config.SyncPositionX && math.abs(position.x - previousPosition.x) >= positionThreshold)
+ || (config.SyncPositionY && math.abs(position.y - previousPosition.y) >= positionThreshold)
+ || (config.SyncPositionZ && math.abs(position.z - previousPosition.z) >= positionThreshold);
+ }
+
+ // If the position is dirty or we are teleporting (which includes synchronization)
+ // then determine what parts of the NetworkDeltaPosition should be updated
+ if (isPositionDirty)
+ {
+ var axisToSynchronize = math.bool3(config.SyncPositionX, config.SyncPositionY, config.SyncPositionZ);
+
+ // If we are not synchronizing the transform state for the first time
+ if (!isSynchronization)
+ {
+ // With global teleporting (broadcast to all non-authority instances)
+ // we re-initialize authority's NetworkDeltaPosition and synchronize all
+ // non-authority instances with the new full precision position
+ if (flagStates.IsTeleportingNextFrame)
+ {
+ halfPositionState = new NetworkDeltaPosition(position, networkState.NetworkTick, axisToSynchronize);
+ networkState.CurrentPosition = position;
+ }
+ else // Otherwise, just synchronize the delta position value
+ {
+ halfPositionState.HalfVector3.AxisToSynchronize = axisToSynchronize;
+ halfPositionState.UpdateFrom(ref position, networkState.NetworkTick);
+ }
+
+ networkState.NetworkDeltaPosition = halfPositionState;
+
+ // If ownership offset is greater or we are doing an axial synchronization then synchronize the base position
+ if ((config.HalfFloatTargetTickOwnership > config.CurrentTick || isAxisSync) && !flagStates.IsTeleportingNextFrame)
+ {
+ flagStates.SynchronizeBaseHalfFloat = true;
+ }
+ else
+ {
+ flagStates.SynchronizeBaseHalfFloat = config.UseUnreliableDeltas ? halfPositionState.CollapsedDeltaIntoBase : false;
+ }
+ }
+ else // If synchronizing is set, then use the current full position value on the server side
+ {
+ if (sample.ShouldSynchronizeHalfFloat)
+ {
+ // If we have a NetworkDeltaPosition that has a state applied, then we want to determine
+ // what needs to be synchronized. For owner authoritative mode, the server side
+ // will have no valid state yet.
+ if (halfPositionState.NetworkTick > 0)
+ {
+ // Always synchronize the base position and the ushort values of the
+ // current halfPositionState
+ networkState.CurrentPosition = halfPositionState.CurrentBasePosition;
+ networkState.NetworkDeltaPosition = halfPositionState;
+ // If the server is the owner, in both server and owner authoritative modes,
+ // or we are running in server authoritative mode, then we use the
+ // HalfDeltaConvertedBack value as the delta position
+ if (sample.UseHalfDeltaConvertedBack)
+ {
+ networkState.DeltaPosition = halfPositionState.HalfDeltaConvertedBack;
+ }
+ else
+ {
+ // Otherwise, we are in owner authoritative mode and the server's NetworkDeltaPosition
+ // state is "non-authoritative" relative so we use the DeltaPosition.
+ networkState.DeltaPosition = halfPositionState.DeltaPosition;
+ }
+ }
+ else // Reset everything and just send the current position
+ {
+ networkState.NetworkDeltaPosition = new NetworkDeltaPosition(Vector3.zero, 0, axisToSynchronize);
+ networkState.DeltaPosition = Vector3.zero;
+ networkState.CurrentPosition = position;
+ }
+ }
+ else
+ {
+ networkState.NetworkDeltaPosition = new NetworkDeltaPosition(Vector3.zero, 0, axisToSynchronize);
+ networkState.CurrentPosition = position;
+ }
+ // Report that a log entry should be added for this update relative to the client being
+ // synchronized. The caller invokes the handler once this returns.
+ config.LogSynchronizationEntry = true;
+ }
+ flagStates.HasPositionX = config.SyncPositionX;
+ flagStates.HasPositionY = config.SyncPositionY;
+ flagStates.HasPositionZ = config.SyncPositionZ;
+ flagStates.HasPositionChange = config.SyncPositionX || config.SyncPositionY || config.SyncPositionZ;
+ }
+ }
+
+ if (!config.UseQuaternionSynchronization)
+ {
+ if (config.SyncRotAngleX && (math.abs(NetworkTransformMath.DeltaAngle(networkState.RotAngleX, rotAngles.x)) >= rotationThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState))
+ {
+ networkState.RotAngleX = rotAngles.x;
+ flagStates.SetHasRotation(Axis.X, true);
+ isRotationDirty = true;
+ }
+
+ if (config.SyncRotAngleY && (math.abs(NetworkTransformMath.DeltaAngle(networkState.RotAngleY, rotAngles.y)) >= rotationThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState))
+ {
+ networkState.RotAngleY = rotAngles.y;
+ flagStates.SetHasRotation(Axis.Y, true);
+ isRotationDirty = true;
+ }
+
+ if (config.SyncRotAngleZ && (math.abs(NetworkTransformMath.DeltaAngle(networkState.RotAngleZ, rotAngles.z)) >= rotationThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState))
+ {
+ networkState.RotAngleZ = rotAngles.z;
+ flagStates.SetHasRotation(Axis.Z, true);
+ isRotationDirty = true;
+ }
+ }
+ else if (synchronizeRotation)
+ {
+ // If we are teleporting then we can skip the delta threshold check
+ isRotationDirty = flagStates.IsTeleportingNextFrame || isAxisSync || forceState;
+ // For quaternion synchronization, if one angle is dirty we send a full update
+ if (!isRotationDirty)
+ {
+ // Uses the ported conversion so this stays free of engine bindings. Verified against
+ // Quaternion.eulerAngles by NetworkTransformMathTests.
+ var previousRotation = NetworkTransformMath.EulerAngles(networkState.Rotation);
+ isRotationDirty = math.abs(NetworkTransformMath.DeltaAngle(previousRotation.x, rotAngles.x)) >= rotationThreshold
+ || math.abs(NetworkTransformMath.DeltaAngle(previousRotation.y, rotAngles.y)) >= rotationThreshold
+ || math.abs(NetworkTransformMath.DeltaAngle(previousRotation.z, rotAngles.z)) >= rotationThreshold;
+ }
+ if (isRotationDirty)
+ {
+ networkState.Rotation = rotation;
+ flagStates.MarkChanged(AxialType.Rotation, true);
+ }
+ }
+
+ // For scale, we need to check for parenting when synchronizing and/or teleporting (synchronization is always teleporting)
+ if (flagStates.IsTeleportingNextFrame)
+ {
+ // If we are synchronizing and the associated NetworkObject has a parent then we want to send the
+ // LossyScale if the NetworkObject has a parent since NetworkObject spawn order is not guaranteed
+ if (flagStates.IsParented)
+ {
+ networkState.LossyScale = sample.LossyScale;
+ }
+ }
+
+ // Checking scale deltas when not synchronizing
+ if (!isSynchronization)
+ {
+ if (!config.UseHalfFloatPrecision)
+ {
+ if (config.SyncScaleX && (math.abs(networkState.ScaleX - scale.x) >= config.ScaleThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState))
+ {
+ networkState.ScaleX = scale.x;
+ flagStates.SetHasScale(Axis.X, true);
+ isScaleDirty = true;
+ }
+
+ if (config.SyncScaleY && (math.abs(networkState.ScaleY - scale.y) >= config.ScaleThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState))
+ {
+ networkState.ScaleY = scale.y;
+ flagStates.SetHasScale(Axis.Y, true);
+ isScaleDirty = true;
+ }
+
+ if (config.SyncScaleZ && (math.abs(networkState.ScaleZ - scale.z) >= config.ScaleThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState))
+ {
+ networkState.ScaleZ = scale.z;
+ flagStates.SetHasScale(Axis.Z, true);
+ isScaleDirty = true;
+ }
+ }
+ else if (synchronizeScale)
+ {
+ var previousScale = networkState.Scale;
+ // Precompute if it is considered always dirty.
+ var alwaysDirty = flagStates.IsTeleportingNextFrame || isAxisSync || forceState;
+ // Use direct field assignment as opposed to indexing to avoid bounds checking.
+ if (alwaysDirty || math.abs(scale.x - previousScale.x) >= config.ScaleThreshold)
+ {
+ isScaleDirty = true;
+ networkState.Scale.x = scale.x;
+ flagStates.SetHasScale(Axis.X, config.SyncScaleX);
+ }
+
+ if (alwaysDirty || math.abs(scale.y - previousScale.y) >= config.ScaleThreshold)
+ {
+ isScaleDirty = true;
+ networkState.Scale.y = scale.y;
+ flagStates.SetHasScale(Axis.Y, config.SyncScaleY);
+ }
+
+ if (alwaysDirty || math.abs(scale.z - previousScale.z) >= config.ScaleThreshold)
+ {
+ isScaleDirty = true;
+ networkState.Scale.z = scale.z;
+ flagStates.SetHasScale(Axis.Z, config.SyncScaleZ);
+ }
+ }
+ }
+ // Just apply the full local scale when synchronizing
+ else if (synchronizeScale)
+ {
+ if (!config.UseHalfFloatPrecision)
+ {
+ networkState.ScaleX = scale.x;
+ networkState.ScaleY = scale.y;
+ networkState.ScaleZ = scale.z;
+ }
+ else
+ {
+ networkState.Scale = scale;
+ }
+ flagStates.MarkChanged(AxialType.Scale, true);
+ isScaleDirty = true;
+ }
+ isDirty |= isPositionDirty || isRotationDirty || isScaleDirty;
+
+ if (isDirty)
+ {
+ // Some integration/unit tests disable the NetworkTransform and there is no
+ // NetworkManager
+ if (config.Enabled)
+ {
+ // We use the NetworkTickSystem version since ServerTime is set when updating ticks
+ networkState.NetworkTick = config.CurrentTick;
+ }
+ }
+
+ // Mark the state dirty for the next network tick update to clear out the bitset values
+ flagStates.IsDirty |= isDirty;
+
+ // Apply any flag state changes
+ networkState.FlagStates = flagStates;
+ return isDirty;
+ }
+ }
+}
diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformDeltaCheck.cs.meta b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformDeltaCheck.cs.meta
new file mode 100644
index 0000000000..7519e150d4
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformDeltaCheck.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: bbac16055cc27c546af7143757db776e
\ No newline at end of file
diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformMath.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformMath.cs
new file mode 100644
index 0000000000..0f9dd748d8
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformMath.cs
@@ -0,0 +1,258 @@
+using System.Runtime.CompilerServices;
+using Unity.Mathematics;
+using UnityEngine;
+
+namespace Unity.Netcode.Components
+{
+ ///
+ /// Burst compatible replacement methods for non-burst compatible math methods that uses.
+ ///
+ ///
+ /// NetworkTransformMathTests measures each method against the non-burst compatible version that it replaces.
+ ///
+ internal static class NetworkTransformMath
+ {
+ internal const float Rad2Deg = 360f / (math.PI * 2f);
+ internal const float Deg2Rad = (math.PI * 2f) / 360f;
+
+ ///
+ /// .
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static float Repeat(float t, float length)
+ {
+ return math.clamp(t - math.floor(t / length) * length, 0.0f, length);
+ }
+
+ ///
+ /// .
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static float DeltaAngle(float current, float target)
+ {
+ var delta = Repeat(target - current, 360.0f);
+ if (delta > 180.0f)
+ {
+ delta -= 360.0f;
+ }
+ return delta;
+ }
+
+ ///
+ /// The burst compatible version of .
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static float3 Lerp(float3 start, float3 end, float time)
+ {
+ // Written per component in the same form the engine uses so the rounding matches.
+ time = math.clamp(time, 0.0f, 1.0f);
+ return new float3(
+ start.x + (end.x - start.x) * time,
+ start.y + (end.y - start.y) * time,
+ start.z + (end.z - start.z) * time);
+ }
+
+ ///
+ /// Brings each euler angle into the 0 to 360 range, matching what the engine returns.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static float NormalizeAngle(float angle)
+ {
+ // Written as a loop free expression so it stays branch predictable and Burst friendly.
+ var normalized = Repeat(angle, 360.0f);
+ return normalized;
+ }
+
+ ///
+ /// The burst compatible version of .
+ ///
+ ///
+ /// Extracted in ZXY order to match , which applies Z, then X,
+ /// then Y. For a rotation matrix R = Ry * Rx * Rz that gives sin(x) = -m12, y = atan2(m02, m22) and
+ /// z = atan2(m10, m11), written below directly in terms of the quaternion components.
+ ///
+ internal static float3 EulerAngles(quaternion rotation)
+ {
+ var q = rotation.value;
+ var sqx = q.x * q.x;
+ var sqy = q.y * q.y;
+ var sqz = q.z * q.z;
+
+ var m10 = 2.0f * (q.x * q.y + q.w * q.z);
+ var m11 = 1.0f - 2.0f * (sqx + sqz);
+ var sinX = 2.0f * (q.x * q.w - q.y * q.z);
+
+ float3 result;
+ result.x = math.atan2(sinX, math.sqrt(m10 * m10 + m11 * m11));
+ result.y = math.atan2(2.0f * (q.x * q.z + q.w * q.y), 1.0f - 2.0f * (sqx + sqy));
+ result.z = math.atan2(m10, m11);
+
+ result *= Rad2Deg;
+ result.x = NormalizeAngle(result.x);
+ result.y = NormalizeAngle(result.y);
+ result.z = NormalizeAngle(result.z);
+ return result;
+ }
+
+ ///
+ /// The burst compatible version of with the
+ /// exception that it takes a for all axis.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static quaternion Euler(float3 eulerDegrees)
+ {
+ // The engine applies the rotations in Z, X, then Y order.
+ return quaternion.EulerZXY(eulerDegrees * Deg2Rad);
+ }
+
+ ///
+ /// The burst compatible version of .
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static quaternion Slerp(quaternion start, quaternion end, float time)
+ {
+ return math.slerp(start, end, math.clamp(time, 0.0f, 1.0f));
+ }
+
+ ///
+ /// The burst compatible version of , which normalizes its result.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static quaternion Nlerp(quaternion start, quaternion end, float time)
+ {
+ return math.nlerp(start, end, math.clamp(time, 0.0f, 1.0f));
+ }
+
+ ///
+ /// The burst compatible version of , which interpolates both direction and
+ /// magnitude.
+ ///
+ internal static float3 Slerp(float3 start, float3 end, float time)
+ {
+ time = math.clamp(time, 0.0f, 1.0f);
+
+ var startMagnitude = math.length(start);
+ var endMagnitude = math.length(end);
+
+ // With a zero length input there is no direction to rotate through, so this degenerates to a lerp.
+ if (startMagnitude < math.EPSILON || endMagnitude < math.EPSILON)
+ {
+ return math.lerp(start, end, time);
+ }
+
+ var startDirection = start / startMagnitude;
+ var endDirection = end / endMagnitude;
+ var magnitude = math.lerp(startMagnitude, endMagnitude, time);
+
+ var dot = math.clamp(math.dot(startDirection, endDirection), -1.0f, 1.0f);
+ var angle = math.acos(dot);
+ var sinAngle = math.sin(angle);
+
+ // Both ends of the range make sinAngle approach zero, which the division below cannot survive.
+ // Nearly parallel is safe to lerp through. Nearly antiparallel has no defined rotation plane at
+ // all, so any implementation has to pick one; that case is expected to differ from the engine.
+ if (sinAngle < 0.001f)
+ {
+ return math.normalizesafe(math.lerp(startDirection, endDirection, time), startDirection) * magnitude;
+ }
+
+ var direction = (math.sin((1.0f - time) * angle) * startDirection + math.sin(time * angle) * endDirection) / sinAngle;
+ return direction * magnitude;
+ }
+
+ ///
+ /// The burst compatible version of .
+ ///
+ ///
+ /// A direct port of the engine's managed implementation.
+ ///
+ internal static float3 SmoothDamp(float3 current, float3 target, ref float3 currentVelocity, float smoothTime, float maxSpeed, float deltaTime)
+ {
+ smoothTime = math.max(0.0001f, smoothTime);
+ var omega = 2.0f / smoothTime;
+
+ var x = omega * deltaTime;
+ var exp = 1.0f / (1.0f + x + 0.48f * x * x + 0.235f * x * x * x);
+
+ var changeX = current.x - target.x;
+ var changeY = current.y - target.y;
+ var changeZ = current.z - target.z;
+ var originalTo = target;
+
+ // Clamp the maximum speed. The engine takes this square root in double precision, which is
+ // observable in the result, so it is taken the same way here.
+ var maxChange = maxSpeed * smoothTime;
+ var maxChangeSq = maxChange * maxChange;
+ var sqrMagnitude = changeX * changeX + changeY * changeY + changeZ * changeZ;
+ if (sqrMagnitude > maxChangeSq)
+ {
+ var magnitude = (float)math.sqrt((double)sqrMagnitude);
+ changeX = changeX / magnitude * maxChange;
+ changeY = changeY / magnitude * maxChange;
+ changeZ = changeZ / magnitude * maxChange;
+ }
+
+ var targetX = current.x - changeX;
+ var targetY = current.y - changeY;
+ var targetZ = current.z - changeZ;
+
+ var tempX = (currentVelocity.x + omega * changeX) * deltaTime;
+ var tempY = (currentVelocity.y + omega * changeY) * deltaTime;
+ var tempZ = (currentVelocity.z + omega * changeZ) * deltaTime;
+
+ currentVelocity.x = (currentVelocity.x - omega * tempX) * exp;
+ currentVelocity.y = (currentVelocity.y - omega * tempY) * exp;
+ currentVelocity.z = (currentVelocity.z - omega * tempZ) * exp;
+
+ var output = new float3(
+ targetX + (changeX + tempX) * exp,
+ targetY + (changeY + tempY) * exp,
+ targetZ + (changeZ + tempZ) * exp);
+
+ // Prevent overshooting.
+ var originalMinusCurrent = originalTo - current;
+ var outputMinusOriginal = output - originalTo;
+ if (math.dot(originalMinusCurrent, outputMinusOriginal) > 0.0f)
+ {
+ output = originalTo;
+ currentVelocity = (output - originalTo) / deltaTime;
+ }
+ return output;
+ }
+
+ ///
+ /// The burst compatible version of .
+ ///
+ ///
+ /// A direct port of the engine's managed implementation.
+ ///
+ internal static float SmoothDampAngle(float current, float target, ref float currentVelocity, float smoothTime, float maxSpeed, float deltaTime)
+ {
+ target = current + DeltaAngle(current, target);
+
+ smoothTime = math.max(0.0001f, smoothTime);
+ var omega = 2.0f / smoothTime;
+
+ var x = omega * deltaTime;
+ var exp = 1.0f / (1.0f + x + 0.48f * x * x + 0.235f * x * x * x);
+
+ var change = current - target;
+ var originalTo = target;
+
+ var maxChange = maxSpeed * smoothTime;
+ change = math.clamp(change, -maxChange, maxChange);
+ target = current - change;
+
+ var temp = (currentVelocity + omega * change) * deltaTime;
+ currentVelocity = (currentVelocity - omega * temp) * exp;
+ var output = target + (change + temp) * exp;
+
+ if (originalTo - current > 0.0f == output > originalTo)
+ {
+ output = originalTo;
+ currentVelocity = (output - originalTo) / deltaTime;
+ }
+ return output;
+ }
+ }
+}
diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformMath.cs.meta b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformMath.cs.meta
new file mode 100644
index 0000000000..10534c0472
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformMath.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3d5966883c252634997cc156985cf0ed
\ No newline at end of file
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..24f39f7ebb
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs
@@ -0,0 +1,676 @@
+using System;
+using System.Collections.Generic;
+using Unity.Collections;
+using Unity.Mathematics;
+using UnityEngine.Jobs;
+
+namespace Unity.Netcode.Components
+{
+ ///
+ /// When using mode, this manages the jobs to detect changes in
+ /// or apply changes to the transform assigned to each instance.
+ ///
+ ///
+ /// One instance per . It is created the first time an instance registers and
+ /// is disposed when the shuts down, so a project running in
+ /// never allocates any of this.
+ /// The collections it owns are parallel: index i of each refers to the same
+ /// . They are only ever mutated through and
+ /// , both of which apply the same swap back to every collection, so they cannot
+ /// drift apart. Each registered instance caches its own index in
+ /// , which makes deregistration O(1) and removes the need
+ /// for a lookup table.
+ ///
+ 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.
+ ///
+ ///
+ /// The managed interpolator's queue is unbounded and only bounded in practice by
+ /// , which is the point at which it gives up and
+ /// teleports. In practice a queue never holds more than the tick latency plus a couple of entries, so
+ /// this is sized for that with headroom rather than for the panic threshold. Overflow drops the oldest
+ /// measurement, which is the same value 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.
+ /// A handle is assigned to every synchronized instance, whether or not that instance is eligible for
+ /// batched jobs or not now.
+ ///
+ 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. Only the buffer
+ /// consumption and interpolation math run within the job; the results are applied to the transforms on
+ /// the main thread afterwards by each instance's normal apply path.
+ ///
+ 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 the server registers instances for batching, so a non-server should never have anything
+ // queued. Kept as a safety net rather than an assumption: silently dropping is still better than
+ // a client attempting a send it cannot address, but it should not be reachable.
+ 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, since it is not known until the observer
+ /// filtering has run.
+ ///
+ 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);
+ }
+
+ ///
+ /// Which of an instance's three interpolators an operation applies to.
+ ///
+ 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, double time)
+ {
+ var entry = InterpolationEntries[index];
+ var items = BufferedItems.AsArray();
+ switch (target)
+ {
+ case InterpolatorTarget.Position:
+ NativeInterpolator.ResetTo(ref entry.Position, ref items, value, time);
+ entry.InterpolatedPosition = value;
+ break;
+ case InterpolatorTarget.Rotation:
+ NativeInterpolator.ResetTo(ref entry.Rotation, ref items, value, time);
+ entry.InterpolatedRotation = value;
+ break;
+ default:
+ NativeInterpolator.ResetTo(ref entry.Scale, ref items, value, time);
+ 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 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 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:
+ /// Deregisers 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 nothing to overlap with.
+ ///
+ internal void RunDeltaCheck()
+ {
+ var count = GetCount();
+ if (count == 0)
+ {
+ return;
+ }
+
+ // Gather what the job cannot resolve for itself.
+ for (int i = 0; i < count; i++)
+ {
+ var instance = m_Instances[i];
+ var entry = Entries[i];
+ instance.PrepareBatchedDeltaEntry(ref entry);
+ Entries[i] = entry;
+ }
+
+ 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.
+ for (int i = 0; i < Entries.Length; i++)
+ {
+ var instance = m_Instances[i];
+ var entry = Entries[i];
+ instance.ApplyBatchedDeltaEntry(ref entry);
+ // The instance may have deregistered while applying, in which case this slot now belongs to a
+ // different instance and must not be written back.
+ if (instance.StateManagerIndex == i)
+ {
+ Entries[i] = entry;
+ }
+ }
+ }
+
+ 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/Components/QuaternionCompressor.cs b/com.unity.netcode.gameobjects/Runtime/Components/QuaternionCompressor.cs
index 3c338a2a84..b6568e4806 100644
--- a/com.unity.netcode.gameobjects/Runtime/Components/QuaternionCompressor.cs
+++ b/com.unity.netcode.gameobjects/Runtime/Components/QuaternionCompressor.cs
@@ -1,10 +1,12 @@
using System.Runtime.CompilerServices;
+using Unity.Mathematics;
using UnityEngine;
namespace Unity.Netcode
{
///
/// The Smallest Three Quaternion Compressor Implementation
+ /// (Job friendly version)
///
///
/// Explanation of why "The smallest three":
@@ -49,21 +51,48 @@ public static class QuaternionCompressor
/// the compressed as an unsigned integer
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static uint CompressQuaternion(ref Quaternion quaternion)
+ {
+ return Compress(new float4(quaternion.x, quaternion.y, quaternion.z, quaternion.w));
+ }
+
+ ///
+ /// Decompress an unsigned integer into a .
+ ///
+ /// quaternion to store the decompressed values within
+ /// the compressed quaternion
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void DecompressQuaternion(ref Quaternion quaternion, uint compressed)
+ {
+ Decompress(out var decompressed, compressed);
+ quaternion.x = decompressed.x;
+ quaternion.y = decompressed.y;
+ quaternion.z = decompressed.z;
+ quaternion.w = decompressed.w;
+ }
+
+ ///
+ /// The based implementation of .
+ ///
+ ///
+ /// This is a job safe method to be used in place of .
+ ///
+ /// the quaternion, as a , to be compressed
+ /// the quaternion compressed as an unsigned integer
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static uint Compress(in float4 quaternion)
{
// Store off the absolute value for each Quaternion element
- var quatAbsValue0 = Mathf.Abs(quaternion[0]);
- var quatAbsValue1 = Mathf.Abs(quaternion[1]);
- var quatAbsValue2 = Mathf.Abs(quaternion[2]);
- var quatAbsValue3 = Mathf.Abs(quaternion[3]);
+ var quatAbsValues = math.abs(quaternion);
// Get the largest element value of the quaternion to know what the remaining "Smallest Three" values are
- var quatMax = Mathf.Max(quatAbsValue0, quatAbsValue1, quatAbsValue2, quatAbsValue3);
+ var quatMax = math.cmax(quatAbsValues);
// Find the index of the largest element, so we can skip that element while compressing and decompressing
- var indexToSkip = (ushort)(quatAbsValue0 == quatMax ? 0 : quatAbsValue1 == quatMax ? 1 : quatAbsValue2 == quatMax ? 2 : 3);
+ var indexToSkip = (ushort)(quatAbsValues.x == quatMax ? 0 : quatAbsValues.y == quatMax ? 1 : quatAbsValues.z == quatMax ? 2 : 3);
// Get the sign of the largest element which is all that is needed when calculating the sum of squares of a normalized quaternion.
- var quatMaxSign = (quaternion[indexToSkip] < 0 ? k_True : k_False);
+ var maxValue = indexToSkip == 0 ? quaternion.x : indexToSkip == 1 ? quaternion.y : indexToSkip == 2 ? quaternion.z : quaternion.w;
+ var quatMaxSign = maxValue < 0 ? k_True : k_False;
// Start with the index to skip which will be shifted to the highest two bits
var compressed = (uint)indexToSkip;
@@ -71,24 +100,45 @@ public static uint CompressQuaternion(ref Quaternion quaternion)
// Step 1: If we are on the index to skip, preserve the current compressed value, otherwise proceed to step 2 and 3
// Step 2: Get the sign of the element we are processing. If it is not the same as the largest value's sign bit then we set the bit
// Step 3: Get the compressed and encoded value by multiplying the absolute value of the current element by k_CompressionEncodingMask and round that result up
- compressed = 0 != indexToSkip ? (compressed << 10) | (uint)((quaternion[0] < 0 ? k_True : k_False) != quatMaxSign ? k_True : k_False) << k_ShiftNegativeBit | (ushort)Mathf.Round(k_CompressionEncodingMask * quatAbsValue0) : compressed;
+ compressed = 0 != indexToSkip ? EncodeElement(compressed, quaternion.x, quatAbsValues.x, quatMaxSign) : compressed;
// Repeat the 3 steps for the remaining elements
- compressed = 1 != indexToSkip ? (compressed << 10) | (uint)((quaternion[1] < 0 ? k_True : k_False) != quatMaxSign ? k_True : k_False) << k_ShiftNegativeBit | (ushort)Mathf.Round(k_CompressionEncodingMask * quatAbsValue1) : compressed;
- compressed = 2 != indexToSkip ? (compressed << 10) | (uint)((quaternion[2] < 0 ? k_True : k_False) != quatMaxSign ? k_True : k_False) << k_ShiftNegativeBit | (ushort)Mathf.Round(k_CompressionEncodingMask * quatAbsValue2) : compressed;
- compressed = 3 != indexToSkip ? (compressed << 10) | (uint)((quaternion[3] < 0 ? k_True : k_False) != quatMaxSign ? k_True : k_False) << k_ShiftNegativeBit | (ushort)Mathf.Round(k_CompressionEncodingMask * quatAbsValue3) : compressed;
+ compressed = 1 != indexToSkip ? EncodeElement(compressed, quaternion.y, quatAbsValues.y, quatMaxSign) : compressed;
+ compressed = 2 != indexToSkip ? EncodeElement(compressed, quaternion.z, quatAbsValues.z, quatMaxSign) : compressed;
+ compressed = 3 != indexToSkip ? EncodeElement(compressed, quaternion.w, quatAbsValues.w, quatMaxSign) : compressed;
// Return the compress quaternion
return compressed;
}
///
- /// Decompress a compressed quaternion
+ /// The ecoding algorithm broken down to its fundamental, easier to understand, elements.
///
- /// quaternion to store the decompressed values within
+ /// The current compressed value.
+ /// The value to be compressed into the compressed value.
+ /// The absolute value of the value to be compressed.
+ /// The sign of the largest value that is calculated upon decompression.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static uint EncodeElement(uint compressed, float value, float absValue, ushort quatMaxSign)
+ {
+ return (compressed << 10)
+ | (uint)((value < 0 ? k_True : k_False) != quatMaxSign ? k_True : k_False) << k_ShiftNegativeBit
+ | (ushort)math.round(k_CompressionEncodingMask * absValue);
+ }
+
+ ///
+ /// The based implementation of .
+ ///
+ ///
+ /// This is a job safe method to be used in place of .
+ ///
+ /// the decompressed quaternion as a
/// the compressed quaternion
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static void DecompressQuaternion(ref Quaternion quaternion, uint compressed)
+ internal static void Decompress(out float4 quaternion, uint compressed)
{
+ quaternion = float4.zero;
+
// Get the last two bits for the index to skip (0-3)
var indexToSkip = (int)(compressed >> 30);
@@ -101,13 +151,40 @@ public static void DecompressQuaternion(ref Quaternion quaternion, uint compress
continue;
}
// Check the negative bit and multiply that result with the decompressed and decoded value
- quaternion[i] = ((compressed & k_NegShortBit) > 0 ? -1.0f : 1.0f) * ((compressed & k_PrecisionMask) * k_DecompressionDecodingMask);
- sumOfSquaredMagnitudes += quaternion[i] * quaternion[i];
+ var value = ((compressed & k_NegShortBit) > 0 ? -1.0f : 1.0f) * ((compressed & k_PrecisionMask) * k_DecompressionDecodingMask);
+ SetAxis(ref quaternion, i, value);
+ sumOfSquaredMagnitudes += value * value;
compressed = compressed >> 10;
}
// Since a normalized quaternion's magnitude is 1.0f, we subtract the sum of the squared smallest three from the unit value and take
- // the square root of the difference to find the final largest value
- quaternion[indexToSkip] = Mathf.Sqrt(1.0f - sumOfSquaredMagnitudes);
+ // the square root of the difference to find the final largest value.
+ SetAxis(ref quaternion, indexToSkip, math.sqrt(1.0f - sumOfSquaredMagnitudes));
+ }
+
+ ///
+ /// Sets the value of the value directly as opposed to indexing into the array to avoid bounds checking cost.
+ ///
+ /// The current decompressed quaternion.
+ /// The index of the decompressed quaternion to be set.
+ /// The axis value to apply to the decompressed quaternion.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void SetAxis(ref float4 decompressed, int index, float value)
+ {
+ switch (index)
+ {
+ case 0:
+ decompressed.x = value;
+ break;
+ case 1:
+ decompressed.y = value;
+ break;
+ case 2:
+ decompressed.z = value;
+ break;
+ default:
+ decompressed.w = value;
+ break;
+ }
}
}
}
diff --git a/com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs b/com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs
new file mode 100644
index 0000000000..f3faf91c9a
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs
@@ -0,0 +1,147 @@
+using System.Collections.Generic;
+
+namespace Unity.Netcode.Components
+{
+ ///
+ /// A compressed/bandwidth-friendly identifer allocation system that is used when
+ /// mode is set. This helps to reduce the identifier from a bitpacked ulong and uint down to a ushort.
+ ///
+ ///
+ /// A batched state update identifies its instance by this handle rather than by a
+ /// and pair.
+ /// The pair costs two to four bytes once bit packed and grows as object ids climb, where a "dense handle"
+ /// ranges between one to two bytes for the lifetime of a session.
+ ///
+ /// Only the instance writing synchronization data (the server, or the session owner in a distributed
+ /// authority topology) allocates. Everyone else is told the handle at spawn. That is deliberate: allocating
+ /// on the owner would reassign the handle on every change of ownership, and the identity has to outlive
+ /// ownership.
+ ///
+ /// Freed handles are not re-issued immediately.
+ /// An unreliable state update naming a handle can still be in flight when the instance it referred to despawns,
+ /// and reissuing straight away would let that packet apply to whichever instance picked the handle up next.
+ /// Holding to-be-released handles for has no impact/cost and avoids running
+ /// into this scenario.
+ ///
+ internal class TransformHandleAllocator
+ {
+ ///
+ /// Reserved to mean "no handle assigned".
+ ///
+ internal const ushort InvalidHandle = 0;
+
+ ///
+ /// How long a freed handle is held before it can be reissued. Comfortably longer than any state
+ /// update can remain in flight.
+ ///
+ private const double k_RecycleDelaySeconds = 5.0;
+
+ private struct PendingHandle
+ {
+ internal ushort Handle;
+ internal double ReusableAtTime;
+ }
+
+ private ushort m_NextHandle = 1;
+
+ ///
+ /// Freed handles in the order they were released, which is also the order they become reusable.
+ ///
+ private readonly Queue m_PendingRecycle = new Queue();
+
+ ///
+ /// Resolves a handle back to its instance when a batched state update is applied.
+ ///
+ private readonly Dictionary m_ByHandle = new Dictionary();
+
+ ///
+ /// Issues a handle, reusing a previously freed one once it has been held long enough.
+ ///
+ /// The current network time, used to age freed handles.
+ internal ushort Allocate(double currentTime)
+ {
+ if (m_PendingRecycle.Count > 0 && m_PendingRecycle.Peek().ReusableAtTime <= currentTime)
+ {
+ return m_PendingRecycle.Dequeue().Handle;
+ }
+
+ if (m_NextHandle != ushort.MaxValue)
+ {
+ return m_NextHandle++;
+ }
+
+ // Every handle is in use or still cooling down. Reusing the oldest one is the only way to keep
+ // going, and it is the least likely to still be named by anything in flight.
+ if (m_PendingRecycle.Count > 0)
+ {
+ NetworkLog.LogWarning($"[{nameof(NetworkTransform)}] Ran out of transform handles and had to reuse one before its hold expired. " +
+ "A state update still in flight for the previous instance could be applied to the new one.");
+ return m_PendingRecycle.Dequeue().Handle;
+ }
+
+ NetworkLog.LogError($"[{nameof(NetworkTransform)}] Exhausted all {ushort.MaxValue - 1} transform handles. " +
+ "Any further instances cannot be synchronized.");
+ return InvalidHandle;
+ }
+
+ ///
+ /// Releases a handle that only becomes reusable when the k_RecycleDelaySeconds
+ /// delay period has expired.
+ ///
+ internal void Release(ushort handle, double currentTime)
+ {
+ if (handle == InvalidHandle)
+ {
+ return;
+ }
+ m_ByHandle.Remove(handle);
+ m_PendingRecycle.Enqueue(new PendingHandle()
+ {
+ Handle = handle,
+ ReusableAtTime = currentTime + k_RecycleDelaySeconds,
+ });
+ }
+
+ ///
+ /// Associates a handle with the instance it addresses, on both the sending and receiving sides.
+ ///
+ internal void Register(ushort handle, NetworkTransform networkTransform)
+ {
+ if (handle == InvalidHandle)
+ {
+ return;
+ }
+ m_ByHandle[handle] = networkTransform;
+ }
+
+ ///
+ /// Removes the association without making the handle reusable for the non-authoritative
+ /// instances.
+ ///
+ internal void Unregister(ushort handle)
+ {
+ if (handle == InvalidHandle)
+ {
+ return;
+ }
+ m_ByHandle.Remove(handle);
+ }
+
+ internal bool TryGet(ushort handle, out NetworkTransform networkTransform)
+ {
+ return m_ByHandle.TryGetValue(handle, out networkTransform);
+ }
+
+ internal int GetRegisteredCount()
+ {
+ return m_ByHandle.Count;
+ }
+
+ internal void Clear()
+ {
+ m_ByHandle.Clear();
+ m_PendingRecycle.Clear();
+ m_NextHandle = 1;
+ }
+ }
+}
diff --git a/com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs.meta b/com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs.meta
new file mode 100644
index 0000000000..0d476551b3
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: dc11efbe776870d419c04b48f17bb61c
\ No newline at end of file
diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs
index 00e7719e4a..fc5339d59f 100644
--- a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs
+++ b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs
@@ -7,6 +7,35 @@
namespace Unity.Netcode
{
+ ///
+ /// The synchronization modes for instances that determines
+ /// whether transform state changes and synchronization are handled per instance or in a parallel job
+ /// and sent via a single message (i.e. batched NetworkTransform).
+ ///
+ ///
+ /// This is a session wide setting located under Project Settings -> Multiplayer ->Netcode for GameObjects.
+ /// The two modes can not be cross pollinated on a per instance basis. As such, it is a per session global setting
+ /// for every component instance.
+ ///
+ public enum TransformSyncModes
+ {
+ ///
+ /// Each detects its own changes on the network tick and
+ /// sends them as an individual message. This is the original, legacy, approach.
+ ///
+ PerInstance,
+
+ ///
+ /// Changes for all instances are detected within a job and
+ /// sent as a single batched message per tick.
+ ///
+ ///
+ /// does not apply in this mode. Delivery
+ /// is determined per state update as opposed to per component.
+ ///
+ Batched,
+ }
+
///
/// The configuration object used to start server, client and hosts
///
@@ -45,6 +74,11 @@ public class NetworkConfig
[SerializeField]
public NetworkPrefabs Prefabs = new NetworkPrefabs();
+ ///
+ /// A global setting, per session, that determines how instances detect and synchronize their state.
+ ///
+ [SerializeField]
+ internal TransformSyncModes TransformSyncMode = TransformSyncModes.PerInstance;
///
/// The tickrate of network ticks. This value controls how often netcode runs user code and sends out data.
@@ -354,6 +388,9 @@ public ulong GetConfig(bool cache = true)
writer.WriteValueSafe(EnableSceneManagement);
writer.WriteValueSafe(EnsureNetworkVariableLengthSafety);
writer.WriteValueSafe(RpcHashSize);
+ // The two transform synchronization modes are not compatible and this needs to be part
+ // of the hash check during the initial connection request.
+ writer.WriteValueSafe((byte)TransformSyncMode);
if (cache)
{
diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs
index 1b702e76fc..dfc984926e 100644
--- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs
+++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs
@@ -300,6 +300,18 @@ internal void PromoteSessionOwner(ulong clientId)
}
}
+ ///
+ /// This contains all of the interpolation related properties used by each non-authoritative
+ /// instance on the non-authority side, but recalculated once
+ /// 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();
@@ -402,6 +414,16 @@ public void NetworkUpdate(NetworkUpdateStage updateStage)
#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D
case NetworkUpdateStage.FixedUpdate:
{
+ // Only refresh if there are NetworkTransforms to be updated
+ if (NetworkTransformFixedUpdate.Count > 0)
+ {
+ 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
@@ -442,6 +464,16 @@ public void NetworkUpdate(NetworkUpdateStage updateStage)
break;
case NetworkUpdateStage.PreLateUpdate:
{
+ // Only refresh if there are NetworkTransforms to be updated
+ if (NetworkTransformUpdate.Count > 0)
+ {
+ 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)
{
@@ -1873,6 +1905,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/NativeInterpolatorTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NativeInterpolatorTests.cs
new file mode 100644
index 0000000000..ff7138a8d2
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NativeInterpolatorTests.cs
@@ -0,0 +1,438 @@
+using System;
+using NUnit.Framework;
+using Unity.Collections;
+using Unity.Mathematics;
+using Unity.Netcode.Components;
+using Unity.Netcode.TestHelpers.Runtime;
+using UnityEngine;
+
+namespace Unity.Netcode.RuntimeTests
+{
+ ///
+ /// Drives and with identical
+ /// measurement sequences and compares them step for step.
+ ///
+ ///
+ /// The two exist in parallel: the managed one continues to serve
+ /// and the native one serves
+ /// , because the managed one cannot run inside a job. Unlike the
+ /// delta check, which the two synchronization modes genuinely share, there is nothing structural stopping
+ /// these two from drifting apart. This is what stops it.
+ ///
+ // These tests do not need to run against the Rust server.
+ [IgnoreIfServiceEnvironmentVariableSet]
+ internal class NativeInterpolatorTests
+ {
+ private const int k_BufferCapacity = NativeInterpolator.BufferCountLimit + 1;
+ private const float k_TickRate = 30.0f;
+ private const double k_MinDeltaTime = 1.0 / k_TickRate;
+
+ ///
+ /// The two implementations use the same operations but not always in the same order, so agreement is
+ /// to float precision rather than bit for bit.
+ ///
+ private const float k_Tolerance = 1E-4f;
+
+ ///
+ /// Allowed while a value is still in motion, for the two paths that are known to be sensitive rather
+ /// than exact.
+ ///
+ ///
+ /// Vector slerp is the one replacement in that is equivalent rather
+ /// than exact, so its small per step difference compounds through the interpolator's feedback.
+ /// Quaternion smooth dampening converts to euler angles, dampens each angle, and converts back every
+ /// frame; near a gimbal transition a sub thousandth of a degree difference in the conversion is enough
+ /// to select a different (equally valid) euler representative, after which the two dampen toward
+ /// different angles. The managed implementation is just as fragile there, so this is the two diverging
+ /// under a shared weakness rather than one of them being wrong.
+ /// What matters is that neither drifts permanently, which is what the settle phase asserts.
+ ///
+ private const float k_TransientTolerance = 5.0f;
+
+ ///
+ /// Once measurements stop, both implementations have to arrive at the same value.
+ ///
+ private const float k_SettledTolerance = 1E-3f;
+
+ ///
+ /// Frames run with no new measurements, to let both settle onto the final target.
+ ///
+ private const int k_SettleFrames = 240;
+
+ private NativeArray m_Items;
+
+ [SetUp]
+ public void SetUp()
+ {
+ m_Items = new NativeArray(k_BufferCapacity, Allocator.Temp);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (m_Items.IsCreated)
+ {
+ m_Items.Dispose();
+ }
+ }
+
+ private NativeInterpolatorState CreateState(InterpolatorValueKind kind, bool isSlerp, bool lerpSmoothing, float maxInterpolationTime)
+ {
+ return new NativeInterpolatorState()
+ {
+ BufferOffset = 0,
+ BufferCapacity = k_BufferCapacity,
+ ValueKind = kind,
+ IsSlerp = isSlerp,
+ LerpSmoothEnabled = lerpSmoothing,
+ MaximumInterpolationTime = maxInterpolationTime,
+ };
+ }
+
+ ///
+ /// A deterministic motion path, so a failure is reproducible.
+ ///
+ private static Vector3 PositionAt(int tick)
+ {
+ return new Vector3(
+ Mathf.Sin(tick * 0.31f) * 12.0f,
+ tick * 0.45f,
+ Mathf.Cos(tick * 0.17f) * 7.5f);
+ }
+
+ private static Quaternion RotationAt(int tick)
+ {
+ return Quaternion.Euler(tick * 3.7f, tick * -2.3f, tick * 1.1f);
+ }
+
+ ///
+ /// Steps both implementations through the same sequence of measurements and frames.
+ ///
+ ///
+ /// Measurements are added on tick boundaries and both are updated every frame, which is how a
+ /// non-authority instance actually consumes them.
+ ///
+ private void CompareVector3(NetworkTransform.InterpolationTypes interpolationType, bool isSlerp, bool lerpSmoothing, string label, float transientTolerance = k_Tolerance)
+ {
+ const float maxInterpolationTime = 0.1f;
+ const float deltaTime = 1.0f / 60.0f;
+ const int ticks = 120;
+
+ var managed = new BufferedLinearInterpolatorVector3()
+ {
+ IsSlerp = isSlerp,
+ LerpSmoothEnabled = lerpSmoothing,
+ MaximumInterpolationTime = maxInterpolationTime,
+ };
+ var native = CreateState(InterpolatorValueKind.Vector3, isSlerp, lerpSmoothing, maxInterpolationTime);
+
+ var start = PositionAt(0);
+ managed.ResetTo(start, 0.0);
+ NativeInterpolator.ResetTo(ref native, ref m_Items, new float4(start.x, start.y, start.z, 0.0f), 0.0);
+
+ var worstError = 0.0f;
+ var worstDetail = string.Empty;
+ var time = 0.0;
+ var nextTick = 1;
+
+ for (int frame = 1; frame <= ticks * 2 + k_SettleFrames; frame++)
+ {
+ time += deltaTime;
+
+ // Feed a measurement whenever a tick boundary is crossed. Nothing is fed during the settle
+ // frames at the end, which is what lets both converge onto the final target.
+ while (nextTick * k_MinDeltaTime <= time && nextTick <= ticks)
+ {
+ var sentTime = nextTick * k_MinDeltaTime;
+ var measurement = PositionAt(nextTick);
+ managed.AddMeasurement(measurement, sentTime);
+ NativeInterpolator.AddMeasurement(ref native, ref m_Items, new float4(measurement.x, measurement.y, measurement.z, 0.0f), sentTime);
+ nextTick++;
+ }
+
+ var tickLatencyAsTime = time - 2.0 * k_MinDeltaTime;
+ var maxDeltaTime = 2.0 * k_MinDeltaTime;
+
+ Vector3 managedValue;
+ float4 nativeValue;
+ if (interpolationType == NetworkTransform.InterpolationTypes.LegacyLerp)
+ {
+ managed.Update(deltaTime, tickLatencyAsTime, time);
+ nativeValue = NativeInterpolator.UpdateLegacy(ref native, ref m_Items, deltaTime, tickLatencyAsTime, time);
+ }
+ else
+ {
+ var lerp = interpolationType == NetworkTransform.InterpolationTypes.Lerp;
+ managed.Update(deltaTime, tickLatencyAsTime, k_MinDeltaTime, maxDeltaTime, lerp);
+ nativeValue = NativeInterpolator.Update(ref native, ref m_Items, deltaTime, tickLatencyAsTime, k_MinDeltaTime, maxDeltaTime, lerp);
+ }
+ managedValue = managed.GetInterpolatedValue();
+
+ var error = Vector3.Distance(managedValue, new Vector3(nativeValue.x, nativeValue.y, nativeValue.z));
+ if (error > worstError)
+ {
+ worstError = error;
+ worstDetail = $"worst at frame {frame} time {time:F4}: managed={managedValue} native=({nativeValue.x},{nativeValue.y},{nativeValue.z})";
+ }
+
+ // The final value, once nothing more is being fed in.
+ if (frame == ticks * 2 + k_SettleFrames)
+ {
+ Assert.LessOrEqual(error, k_SettledTolerance,
+ $"[{label}] native and managed Vector3 interpolation settled on different values ({error} apart). " +
+ $"managed={managedValue} native=({nativeValue.x},{nativeValue.y},{nativeValue.z})");
+ }
+ }
+
+ Assert.LessOrEqual(worstError, transientTolerance,
+ $"[{label}] native and managed Vector3 interpolation diverged by {worstError} while in motion.\n{worstDetail}");
+ }
+
+ private void CompareQuaternion(NetworkTransform.InterpolationTypes interpolationType, bool isSlerp, bool lerpSmoothing, string label, float transientTolerance = 0.01f)
+ {
+ const float maxInterpolationTime = 0.1f;
+ const float deltaTime = 1.0f / 60.0f;
+ const int ticks = 120;
+
+ var managed = new BufferedLinearInterpolatorQuaternion()
+ {
+ IsSlerp = isSlerp,
+ LerpSmoothEnabled = lerpSmoothing,
+ MaximumInterpolationTime = maxInterpolationTime,
+ };
+ var native = CreateState(InterpolatorValueKind.Quaternion, isSlerp, lerpSmoothing, maxInterpolationTime);
+
+ var start = RotationAt(0);
+ managed.ResetTo(start, 0.0);
+ NativeInterpolator.ResetTo(ref native, ref m_Items, new float4(start.x, start.y, start.z, start.w), 0.0);
+
+ var worstError = 0.0f;
+ var worstDetail = string.Empty;
+ var time = 0.0;
+ var nextTick = 1;
+
+ for (int frame = 1; frame <= ticks * 2 + k_SettleFrames; frame++)
+ {
+ time += deltaTime;
+
+ // Nothing is fed during the settle frames at the end, which is what lets both converge.
+ while (nextTick * k_MinDeltaTime <= time && nextTick <= ticks)
+ {
+ var sentTime = nextTick * k_MinDeltaTime;
+ var measurement = RotationAt(nextTick);
+ managed.AddMeasurement(measurement, sentTime);
+ NativeInterpolator.AddMeasurement(ref native, ref m_Items, new float4(measurement.x, measurement.y, measurement.z, measurement.w), sentTime);
+ nextTick++;
+ }
+
+ var tickLatencyAsTime = time - 2.0 * k_MinDeltaTime;
+ var maxDeltaTime = 2.0 * k_MinDeltaTime;
+
+ float4 nativeValue;
+ if (interpolationType == NetworkTransform.InterpolationTypes.LegacyLerp)
+ {
+ managed.Update(deltaTime, tickLatencyAsTime, time);
+ nativeValue = NativeInterpolator.UpdateLegacy(ref native, ref m_Items, deltaTime, tickLatencyAsTime, time);
+ }
+ else
+ {
+ var lerp = interpolationType == NetworkTransform.InterpolationTypes.Lerp;
+ managed.Update(deltaTime, tickLatencyAsTime, k_MinDeltaTime, maxDeltaTime, lerp);
+ nativeValue = NativeInterpolator.Update(ref native, ref m_Items, deltaTime, tickLatencyAsTime, k_MinDeltaTime, maxDeltaTime, lerp);
+ }
+ var managedValue = managed.GetInterpolatedValue();
+
+ var error = Quaternion.Angle(managedValue, new Quaternion(nativeValue.x, nativeValue.y, nativeValue.z, nativeValue.w));
+ if (error > worstError)
+ {
+ worstError = error;
+ worstDetail = $"worst at frame {frame} time {time:F4}: managed={managedValue} native=({nativeValue.x},{nativeValue.y},{nativeValue.z},{nativeValue.w})";
+ }
+
+ // The final value, once nothing more is being fed in.
+ if (frame == ticks * 2 + k_SettleFrames)
+ {
+ Assert.LessOrEqual(error, 0.01f,
+ $"[{label}] native and managed Quaternion interpolation settled on different rotations ({error} degrees apart). " +
+ $"managed={managedValue} native=({nativeValue.x},{nativeValue.y},{nativeValue.z},{nativeValue.w})");
+ }
+ }
+
+ // Compared as an angle, so the tolerances are in degrees.
+ Assert.LessOrEqual(worstError, transientTolerance,
+ $"[{label}] native and managed Quaternion interpolation diverged by {worstError} degrees while in motion.\n{worstDetail}");
+ }
+
+ [Test]
+ public void Vector3LegacyLerpMatchesManaged([Values] bool lerpSmoothing)
+ {
+ CompareVector3(NetworkTransform.InterpolationTypes.LegacyLerp, false, lerpSmoothing, $"LegacyLerp smoothing={lerpSmoothing}");
+ }
+
+ [Test]
+ public void Vector3LerpMatchesManaged([Values] bool lerpSmoothing)
+ {
+ CompareVector3(NetworkTransform.InterpolationTypes.Lerp, false, lerpSmoothing, $"Lerp smoothing={lerpSmoothing}");
+ }
+
+ [Test]
+ public void Vector3SmoothDampeningMatchesManaged([Values] bool lerpSmoothing)
+ {
+ CompareVector3(NetworkTransform.InterpolationTypes.SmoothDampening, false, lerpSmoothing, $"SmoothDampening smoothing={lerpSmoothing}");
+ }
+
+ [Test]
+ public void Vector3SlerpMatchesManaged()
+ {
+ // Vector slerp is the one NetworkTransformMath replacement that is equivalent rather than exact,
+ // so it is held to the transient bound while moving and the tight bound once settled.
+ CompareVector3(NetworkTransform.InterpolationTypes.Lerp, true, false, "Lerp slerp", k_TransientTolerance);
+ }
+
+ [Test]
+ public void QuaternionLegacyLerpMatchesManaged([Values] bool isSlerp)
+ {
+ CompareQuaternion(NetworkTransform.InterpolationTypes.LegacyLerp, isSlerp, false, $"LegacyLerp slerp={isSlerp}");
+ }
+
+ [Test]
+ public void QuaternionLerpMatchesManaged([Values] bool isSlerp)
+ {
+ CompareQuaternion(NetworkTransform.InterpolationTypes.Lerp, isSlerp, false, $"Lerp slerp={isSlerp}");
+ }
+
+ [Test]
+ public void QuaternionSmoothDampeningMatchesManaged()
+ {
+ // Dampening through euler angles can select a different euler representative near a gimbal
+ // transition, so this is held to the transient bound while moving and the tight bound once settled.
+ CompareQuaternion(NetworkTransform.InterpolationTypes.SmoothDampening, true, false, "SmoothDampening", k_TransientTolerance);
+ }
+
+ ///
+ /// The ring buffer has a fixed capacity where the managed queue does not, so the overflow behavior has
+ /// to be checked explicitly rather than only through the comparisons above.
+ ///
+ [Test]
+ public void BufferOverflowKeepsNewestMeasurement()
+ {
+ var native = CreateState(InterpolatorValueKind.Vector3, false, false, 0.1f);
+ NativeInterpolator.ResetTo(ref native, ref m_Items, float4.zero, 0.0);
+
+ // More measurements than the buffer can hold, without tripping the teleport threshold.
+ const int count = NativeInterpolator.BufferCountLimit - 1;
+ for (int i = 1; i <= count; i++)
+ {
+ NativeInterpolator.AddMeasurement(ref native, ref m_Items, new float4(i, 0.0f, 0.0f, 0.0f), i * k_MinDeltaTime);
+ }
+
+ Assert.LessOrEqual(native.BufferCount, k_BufferCapacity, "Buffer count exceeded its capacity!");
+
+ // Consume everything and confirm the newest measurement is the one that survived.
+ var value = NativeInterpolator.Update(ref native, ref m_Items, 1.0f, count * k_MinDeltaTime, k_MinDeltaTime, 1.0, true);
+ Assert.AreEqual(count, native.Target.Item.x, "The newest measurement was not the one interpolated towards!");
+ Assert.IsTrue(math.all(math.isfinite(value)), "Interpolated value was not finite!");
+ }
+
+ ///
+ /// An instance that stops being the authority part way through a session resets its interpolator with
+ /// the local current time, while the measurements that follow are stamped with the tick they were
+ /// authored on. Those stamps are older, so both of the interpolator's ordering guards reject them and
+ /// the instance never converges onto anything the new authority sends.
+ ///
+ ///
+ /// This is the shape of an ownership transfer away from the local instance, which in a client server
+ /// topology only ever happens to the server. It is reproduced here rather than only through
+ /// NetworkTransformSyncModeParityTests.OwnershipChangeKeepsReplicating because the deadlock is
+ /// entirely internal to the interpolator: once the buffered measurements are all older than the reset
+ /// baseline, no amount of elapsed time recovers it.
+ ///
+ [Test]
+ public void ResetPartWayThroughSessionStillAcceptsOlderStampedMeasurements([Values] bool useManaged)
+ {
+ const float maxInterpolationTime = 0.1f;
+ const float deltaTime = 1.0f / 60.0f;
+ const double tickLatency = 2.0 * k_MinDeltaTime;
+
+ // The session has been running for a while when authority is lost.
+ const int transitionTick = 60;
+ var transitionTime = transitionTick * k_MinDeltaTime;
+
+ var held = new float4(2.0f, 2.0f, 2.0f, 0.0f);
+ var target = new float4(-4.0f, 5.0f, 3.0f, 0.0f);
+
+ var managed = new BufferedLinearInterpolatorVector3()
+ {
+ IsSlerp = false,
+ LerpSmoothEnabled = false,
+ MaximumInterpolationTime = maxInterpolationTime,
+ };
+ var native = CreateState(InterpolatorValueKind.Vector3, false, false, maxInterpolationTime);
+
+ // ResetInterpolatedStateToCurrentAuthoritativeState stamps the baseline with ServerTime.Time.
+ if (useManaged)
+ {
+ managed.ResetTo(new Vector3(held.x, held.y, held.z), transitionTime);
+ }
+ else
+ {
+ NativeInterpolator.ResetTo(ref native, ref m_Items, held, transitionTime);
+ }
+
+ // The new authority's first states were authored on ticks at or before the transition, so their
+ // NetworkTransformState.SentTime is not newer than the baseline's stamp.
+ var sentTicks = new[] { transitionTick - 1, transitionTick };
+
+ var time = transitionTime;
+ var pending = 0;
+
+ // Long enough that nothing is still merely waiting on render time to catch up.
+ const int frames = 600;
+ for (int frame = 1; frame <= frames; frame++)
+ {
+ time += deltaTime;
+
+ while (pending < sentTicks.Length && frame > pending * 4)
+ {
+ var sentTime = sentTicks[pending] * k_MinDeltaTime;
+ if (useManaged)
+ {
+ managed.AddMeasurement(new Vector3(target.x, target.y, target.z), sentTime);
+ }
+ else
+ {
+ NativeInterpolator.AddMeasurement(ref native, ref m_Items, target, sentTime);
+ }
+ pending++;
+ }
+
+ var renderTime = time - tickLatency;
+ if (useManaged)
+ {
+ managed.Update(deltaTime, renderTime, k_MinDeltaTime, tickLatency, true);
+ }
+ else
+ {
+ NativeInterpolator.Update(ref native, ref m_Items, deltaTime, renderTime, k_MinDeltaTime, tickLatency, true);
+ }
+ }
+
+ var label = useManaged ? "managed" : "native";
+ if (useManaged)
+ {
+ var result = managed.GetInterpolatedValue();
+ Assert.LessOrEqual(Vector3.Distance(result, new Vector3(target.x, target.y, target.z)), k_SettledTolerance,
+ $"[{label}] interpolator never converged onto the measurements sent after the reset! " +
+ $"expected={target.xyz} actual={result}");
+ }
+ else
+ {
+ Assert.LessOrEqual(math.distance(native.CurrentValue.xyz, target.xyz), k_SettledTolerance,
+ $"[{label}] interpolator never converged onto the measurements sent after the reset! " +
+ $"expected={target.xyz} actual={native.CurrentValue.xyz} " +
+ $"buffered={native.BufferCount} hasTarget={native.HasTarget} " +
+ $"targetStamp={native.Target.TimeSent} received={native.BufferCounter}");
+ }
+ }
+ }
+}
diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NativeInterpolatorTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NativeInterpolatorTests.cs.meta
new file mode 100644
index 0000000000..652ec87e69
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NativeInterpolatorTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 2ef3c93be5dfb2741b3672095d9ff920
\ No newline at end of file
diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs
new file mode 100644
index 0000000000..6a05b301df
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs
@@ -0,0 +1,369 @@
+using System;
+using System.Text;
+using NUnit.Framework;
+using Unity.Mathematics;
+using Unity.Netcode.Components;
+using Unity.Netcode.TestHelpers.Runtime;
+using UnityEngine;
+
+namespace Unity.Netcode.RuntimeTests
+{
+ ///
+ /// Measures how closely agrees with the engine math it replaces.
+ ///
+ ///
+ /// The replacements exist because the engine equivalents are native bindings that Burst cannot compile.
+ /// Some of them are ports of implementations that are managed C# in the engine and are expected to match
+ /// exactly; the rest are mathematically equivalent but cannot be verified as bit identical because the
+ /// engine's operation order is not observable.
+ /// Each test reports the worst disagreement it found, so a failure states the actual measured error rather
+ /// than just that a threshold was crossed.
+ ///
+ // These tests do not need to run against the Rust server.
+ [IgnoreIfServiceEnvironmentVariableSet]
+ internal class NetworkTransformMathTests
+ {
+ private const int k_Iterations = 20000;
+
+ ///
+ /// Ports of engine implementations that are managed C#, so they are expected to match exactly.
+ ///
+ private const float k_ExactTolerance = 0.0f;
+
+ ///
+ /// Replacements for native implementations. Tight enough that a real divergence fails while ordinary
+ /// floating point reassociation does not.
+ ///
+ private const float k_EquivalentTolerance = 0.001f;
+
+ ///
+ /// agrees with the engine to within a single float ulp
+ /// over the value ranges used here, but not bit for bit.
+ ///
+ ///
+ /// The remaining difference is one rounding step, not an algorithmic one: every other ported function
+ /// (including the scalar , which shares this
+ /// arithmetic) matches exactly. Chasing it would mean guessing at how the engine's build contracts
+ /// multiply and add, and a difference this size is far below anything interpolation can express.
+ ///
+ private const float k_SingleUlpTolerance = 1E-5f;
+
+ private static System.Random s_Random;
+
+ [SetUp]
+ public void SetUp()
+ {
+ // Fixed seed so a failure is reproducible.
+ s_Random = new System.Random(20260814);
+ }
+
+ private static float RandomFloat(float min, float max)
+ {
+ return (float)(s_Random.NextDouble() * (max - min) + min);
+ }
+
+ private static Vector3 RandomVector(float range)
+ {
+ return new Vector3(RandomFloat(-range, range), RandomFloat(-range, range), RandomFloat(-range, range));
+ }
+
+ private static Quaternion RandomRotation()
+ {
+ // Uniformly distributed rotations, which reaches the pole cases the euler conversion special cases.
+ var u1 = (float)s_Random.NextDouble();
+ var u2 = (float)s_Random.NextDouble();
+ var u3 = (float)s_Random.NextDouble();
+ var sqrt1MinusU1 = Mathf.Sqrt(1.0f - u1);
+ var sqrtU1 = Mathf.Sqrt(u1);
+ return new Quaternion(
+ sqrt1MinusU1 * Mathf.Sin(2.0f * Mathf.PI * u2),
+ sqrt1MinusU1 * Mathf.Cos(2.0f * Mathf.PI * u2),
+ sqrtU1 * Mathf.Sin(2.0f * Mathf.PI * u3),
+ sqrtU1 * Mathf.Cos(2.0f * Mathf.PI * u3));
+ }
+
+ ///
+ /// Tracks the largest disagreement seen so a failure can report it.
+ ///
+ private struct Worst
+ {
+ public float Error;
+ public string Detail;
+
+ public void Record(float error, Func detail)
+ {
+ if (error > Error)
+ {
+ Error = error;
+ Detail = detail();
+ }
+ }
+
+ public void Assert(string name, float tolerance)
+ {
+ NUnit.Framework.Assert.LessOrEqual(Error, tolerance,
+ $"{name} deviates from the engine implementation by {Error} (tolerance {tolerance}).\n{Detail}");
+ }
+ }
+
+ [Test]
+ public void DeltaAngleMatchesEngine()
+ {
+ var worst = new Worst();
+ for (int i = 0; i < k_Iterations; i++)
+ {
+ var current = RandomFloat(-1080.0f, 1080.0f);
+ var target = RandomFloat(-1080.0f, 1080.0f);
+ var expected = Mathf.DeltaAngle(current, target);
+ var actual = NetworkTransformMath.DeltaAngle(current, target);
+ worst.Record(Mathf.Abs(expected - actual), () => $"current={current} target={target} expected={expected} actual={actual}");
+ }
+ worst.Assert(nameof(NetworkTransformMath.DeltaAngle), k_ExactTolerance);
+ }
+
+ [Test]
+ public void RepeatMatchesEngine()
+ {
+ var worst = new Worst();
+ for (int i = 0; i < k_Iterations; i++)
+ {
+ var t = RandomFloat(-1080.0f, 1080.0f);
+ var expected = Mathf.Repeat(t, 360.0f);
+ var actual = NetworkTransformMath.Repeat(t, 360.0f);
+ worst.Record(Mathf.Abs(expected - actual), () => $"t={t} expected={expected} actual={actual}");
+ }
+ worst.Assert(nameof(NetworkTransformMath.Repeat), k_ExactTolerance);
+ }
+
+ [Test]
+ public void LerpVector3MatchesEngine()
+ {
+ var worst = new Worst();
+ for (int i = 0; i < k_Iterations; i++)
+ {
+ var start = RandomVector(100.0f);
+ var end = RandomVector(100.0f);
+ var t = RandomFloat(-0.5f, 1.5f);
+ var expected = Vector3.Lerp(start, end, t);
+ var actual = (Vector3)NetworkTransformMath.Lerp(start, end, t);
+ worst.Record(Vector3.Distance(expected, actual), () => $"start={start} end={end} t={t} expected={expected} actual={actual}");
+ }
+ worst.Assert("Lerp(Vector3)", k_ExactTolerance);
+ }
+
+ [Test]
+ public void SmoothDampVector3MatchesEngine()
+ {
+ var worst = new Worst();
+ for (int i = 0; i < k_Iterations; i++)
+ {
+ var current = RandomVector(50.0f);
+ var target = RandomVector(50.0f);
+ var velocity = RandomVector(10.0f);
+ var smoothTime = RandomFloat(0.001f, 1.0f);
+ var maxSpeed = RandomFloat(0.1f, 100.0f);
+ var deltaTime = RandomFloat(0.001f, 0.1f);
+
+ var engineVelocity = velocity;
+ var expected = Vector3.SmoothDamp(current, target, ref engineVelocity, smoothTime, maxSpeed, deltaTime);
+
+ float3 portedVelocity = velocity;
+ var actual = (Vector3)NetworkTransformMath.SmoothDamp(current, target, ref portedVelocity, smoothTime, maxSpeed, deltaTime);
+
+ var error = Mathf.Max(Vector3.Distance(expected, actual), Vector3.Distance(engineVelocity, (Vector3)portedVelocity));
+ worst.Record(error, () => $"current={current} target={target} smoothTime={smoothTime} maxSpeed={maxSpeed} dt={deltaTime}\n" +
+ $" expected={expected} vel={engineVelocity}\n actual ={actual} vel={(Vector3)portedVelocity}");
+ }
+ worst.Assert("SmoothDamp(Vector3)", k_SingleUlpTolerance);
+ }
+
+ [Test]
+ public void SmoothDampAngleMatchesEngine()
+ {
+ var worst = new Worst();
+ for (int i = 0; i < k_Iterations; i++)
+ {
+ var current = RandomFloat(-720.0f, 720.0f);
+ var target = RandomFloat(-720.0f, 720.0f);
+ var velocity = RandomFloat(-50.0f, 50.0f);
+ var smoothTime = RandomFloat(0.001f, 1.0f);
+ var maxSpeed = RandomFloat(0.1f, 500.0f);
+ var deltaTime = RandomFloat(0.001f, 0.1f);
+
+ var engineVelocity = velocity;
+ var expected = Mathf.SmoothDampAngle(current, target, ref engineVelocity, smoothTime, maxSpeed, deltaTime);
+
+ var portedVelocity = velocity;
+ var actual = NetworkTransformMath.SmoothDampAngle(current, target, ref portedVelocity, smoothTime, maxSpeed, deltaTime);
+
+ var error = Mathf.Max(Mathf.Abs(expected - actual), Mathf.Abs(engineVelocity - portedVelocity));
+ worst.Record(error, () => $"current={current} target={target} smoothTime={smoothTime} dt={deltaTime} expected={expected} actual={actual}");
+ }
+ worst.Assert("SmoothDampAngle", k_ExactTolerance);
+ }
+
+ [Test]
+ public void EulerAnglesMatchesEngine()
+ {
+ var worst = new Worst();
+ for (int i = 0; i < k_Iterations; i++)
+ {
+ var rotation = RandomRotation();
+ var expected = rotation.eulerAngles;
+ var actual = (Vector3)NetworkTransformMath.EulerAngles(rotation);
+
+ // Compared as angles so that 359.999 and 0.001 are not treated as a large disagreement.
+ var error = Mathf.Max(Mathf.Abs(Mathf.DeltaAngle(expected.x, actual.x)),
+ Mathf.Max(Mathf.Abs(Mathf.DeltaAngle(expected.y, actual.y)), Mathf.Abs(Mathf.DeltaAngle(expected.z, actual.z))));
+ worst.Record(error, () => $"rotation={rotation} expected={expected} actual={actual}");
+ }
+ worst.Assert(nameof(NetworkTransformMath.EulerAngles), k_EquivalentTolerance);
+ }
+
+ [Test]
+ public void EulerMatchesEngine()
+ {
+ var worst = new Worst();
+ for (int i = 0; i < k_Iterations; i++)
+ {
+ var euler = new Vector3(RandomFloat(-360.0f, 360.0f), RandomFloat(-360.0f, 360.0f), RandomFloat(-360.0f, 360.0f));
+ var expected = Quaternion.Euler(euler);
+ var actual = (Quaternion)NetworkTransformMath.Euler(euler);
+
+ // q and -q are the same rotation, so compare the angle between them.
+ var error = Quaternion.Angle(expected, actual);
+ worst.Record(error, () => $"euler={euler} expected={expected} actual={actual}");
+ }
+ worst.Assert(nameof(NetworkTransformMath.Euler), k_EquivalentTolerance);
+ }
+
+ [Test]
+ public void SlerpQuaternionMatchesEngine()
+ {
+ var worst = new Worst();
+ for (int i = 0; i < k_Iterations; i++)
+ {
+ var start = RandomRotation();
+ var end = RandomRotation();
+ var t = RandomFloat(0.0f, 1.0f);
+ var expected = Quaternion.Slerp(start, end, t);
+ var actual = (Quaternion)NetworkTransformMath.Slerp(start, end, t);
+ worst.Record(Quaternion.Angle(expected, actual), () => $"start={start} end={end} t={t} expected={expected} actual={actual}");
+ }
+ worst.Assert("Slerp(Quaternion)", k_EquivalentTolerance);
+ }
+
+ [Test]
+ public void LerpQuaternionMatchesEngine()
+ {
+ var worst = new Worst();
+ for (int i = 0; i < k_Iterations; i++)
+ {
+ var start = RandomRotation();
+ var end = RandomRotation();
+ var t = RandomFloat(0.0f, 1.0f);
+ var expected = Quaternion.Lerp(start, end, t);
+ var actual = (Quaternion)NetworkTransformMath.Nlerp(start, end, t);
+ worst.Record(Quaternion.Angle(expected, actual), () => $"start={start} end={end} t={t} expected={expected} actual={actual}");
+ }
+ worst.Assert(nameof(NetworkTransformMath.Nlerp), k_EquivalentTolerance);
+ }
+
+ [Test]
+ public void SlerpVector3MatchesEngine()
+ {
+ var worst = new Worst();
+ var worstAntiparallel = new Worst();
+ var antiparallelCount = 0;
+
+ for (int i = 0; i < k_Iterations; i++)
+ {
+ var start = RandomVector(50.0f);
+ var end = RandomVector(50.0f);
+ var t = RandomFloat(0.0f, 1.0f);
+ var expected = Vector3.Slerp(start, end, t);
+ var actual = (Vector3)NetworkTransformMath.Slerp((float3)start, (float3)end, t);
+ var error = Vector3.Distance(expected, actual);
+
+ // Nearly antiparallel inputs have no defined rotation plane, so both implementations have to
+ // pick one arbitrarily. Measured and reported, but not asserted on.
+ var cosAngle = Vector3.Dot(start.normalized, end.normalized);
+ if (cosAngle < -0.999f)
+ {
+ antiparallelCount++;
+ worstAntiparallel.Record(error, () => $"start={start} end={end} t={t}");
+ }
+ else
+ {
+ worst.Record(error, () => $"start={start} end={end} t={t} expected={expected} actual={actual}");
+ }
+ }
+
+ Debug.Log($"Slerp(Vector3): nearly antiparallel max deviation {worstAntiparallel.Error:E3} over {antiparallelCount} samples (not asserted).");
+ worst.Assert("Slerp(Vector3)", k_EquivalentTolerance);
+ }
+
+ ///
+ /// Reports every measurement in one place so the numbers can be reviewed together rather than one
+ /// assertion at a time.
+ ///
+ [Test]
+ public void ReportAllDeviations()
+ {
+ var report = new StringBuilder();
+ report.AppendLine($"{nameof(NetworkTransformMath)} agreement with the engine ({k_Iterations} samples each):");
+
+ void Measure(string name, Func sample)
+ {
+ var worst = 0.0f;
+ for (int i = 0; i < k_Iterations; i++)
+ {
+ worst = Mathf.Max(worst, sample());
+ }
+ report.AppendLine($" {name,-24} max deviation {worst:E3}");
+ }
+
+ Measure("DeltaAngle", () =>
+ {
+ var a = RandomFloat(-1080.0f, 1080.0f);
+ var b = RandomFloat(-1080.0f, 1080.0f);
+ return Mathf.Abs(Mathf.DeltaAngle(a, b) - NetworkTransformMath.DeltaAngle(a, b));
+ });
+ Measure("EulerAngles", () =>
+ {
+ var r = RandomRotation();
+ var e = r.eulerAngles;
+ var a = (Vector3)NetworkTransformMath.EulerAngles(r);
+ return Mathf.Max(Mathf.Abs(Mathf.DeltaAngle(e.x, a.x)), Mathf.Max(Mathf.Abs(Mathf.DeltaAngle(e.y, a.y)), Mathf.Abs(Mathf.DeltaAngle(e.z, a.z))));
+ });
+ Measure("Euler", () =>
+ {
+ var e = new Vector3(RandomFloat(-360.0f, 360.0f), RandomFloat(-360.0f, 360.0f), RandomFloat(-360.0f, 360.0f));
+ return Quaternion.Angle(Quaternion.Euler(e), (Quaternion)NetworkTransformMath.Euler(e));
+ });
+ Measure("Slerp(Quaternion)", () =>
+ {
+ var s = RandomRotation();
+ var e = RandomRotation();
+ var t = RandomFloat(0.0f, 1.0f);
+ return Quaternion.Angle(Quaternion.Slerp(s, e, t), (Quaternion)NetworkTransformMath.Slerp(s, e, t));
+ });
+ Measure("Lerp(Quaternion)", () =>
+ {
+ var s = RandomRotation();
+ var e = RandomRotation();
+ var t = RandomFloat(0.0f, 1.0f);
+ return Quaternion.Angle(Quaternion.Lerp(s, e, t), (Quaternion)NetworkTransformMath.Nlerp(s, e, t));
+ });
+ Measure("Slerp(Vector3)", () =>
+ {
+ var s = RandomVector(50.0f);
+ var e = RandomVector(50.0f);
+ var t = RandomFloat(0.0f, 1.0f);
+ return Vector3.Distance(Vector3.Slerp(s, e, t), (Vector3)NetworkTransformMath.Slerp((float3)s, (float3)e, t));
+ });
+
+ Debug.Log(report.ToString());
+ }
+ }
+}
diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs.meta
new file mode 100644
index 0000000000..6aa632ae1a
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 78923d5c3a7b9814096112ab77c999a7
\ No newline at end of file
diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformStateBaselineTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformStateBaselineTests.cs
new file mode 100644
index 0000000000..d37e4cf7b8
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformStateBaselineTests.cs
@@ -0,0 +1,513 @@
+using System;
+using System.Text;
+using NUnit.Framework;
+using Unity.Collections;
+using Unity.Mathematics;
+using Unity.Netcode.Components;
+using Unity.Netcode.TestHelpers.Runtime;
+using UnityEngine;
+using static Unity.Netcode.Components.NetworkTransform;
+
+namespace Unity.Netcode.RuntimeTests
+{
+ ///
+ /// Captures the exact serialized form of a across the matrix of
+ /// configurations that drive its serialization branches.
+ ///
+ ///
+ /// This exists as the wire format baseline. Any change to how a state is written (whether intentional or
+ /// not) will fail with the new signatures, which
+ /// makes an unintended wire change impossible to land silently.
+ /// To (re)generate the baseline: run this test, copy the C# array literal it prints on failure into
+ /// , and verify every changed entry is an intended wire format change.
+ ///
+ // These tests do not need to run against the Rust server.
+ [IgnoreIfServiceEnvironmentVariableSet]
+ internal class NetworkTransformStateBaselineTests
+ {
+ ///
+ /// One entry per case in , formatted as "name|byteLength|fnv1aHash".
+ ///
+ ///
+ /// Empty until generated. See the remarks on the class for how to populate it.
+ ///
+ private static readonly string[] k_ExpectedSignatures =
+ {
+ "FullPrecision.AllAxes|42|B454AEF3",
+ "FullPrecision.PositionOnly|18|53BC0797",
+ "FullPrecision.PositionX|10|BBA3D614",
+ "FullPrecision.RotationYOnly|10|3D4B5A51",
+ "FullPrecision.ScaleZOnly|10|ADD3F579",
+ "FullPrecision.Teleport|42|E62F1693",
+ "FullPrecision.Teleport.Parented|30|0167D4EA",
+ "FullPrecision.TrackByStateId|23|7109AC47",
+ "FullPrecision.InLocalSpace|18|FE97B9BF",
+ "QuaternionSync.Full|22|BBBF5D70",
+ "QuaternionSync.Compressed|10|61D0C0A0",
+ "QuaternionSync.HalfFloat|14|492F1FBC",
+ "QuaternionSync.Teleport|22|603F0025",
+ "HalfFloat.AllAxes|24|56C94289",
+ "HalfFloat.PositionOnly|12|E0EB69C7",
+ "HalfFloat.PositionXZ|10|270908C3",
+ "HalfFloat.SynchronizeBase|30|83FFE5D8",
+ "HalfFloat.Teleport|30|9407B934",
+ "HalfFloat.Synchronizing|36|E1EC38D8",
+ "HalfFloat.ScaleOnly|12|254AC316",
+ "HalfFloat.EulerRotation|12|F769BA44",
+ "UnreliableDeltas.FrameSync|19|81628843",
+ "UnreliableDeltas.SynchronizeBaseHalfFloat|30|0DF74758",
+ "UnreliableDeltas.PlainDelta|12|7B1C8307",
+ "SwitchTransformSpaceWhenParented|19|DD37211C",
+ };
+
+ ///
+ /// Deterministic payload values so the serialized output is stable between runs.
+ ///
+ private static NetworkTransformState CreateBaseState()
+ {
+ var state = new NetworkTransformState
+ {
+ NetworkTick = 12345,
+ StateId = 77,
+ PositionX = 1.25f,
+ PositionY = -30.5f,
+ PositionZ = 512.125f,
+ RotAngleX = 33.75f,
+ RotAngleY = 190.5f,
+ RotAngleZ = 271.25f,
+ Rotation = Quaternion.Euler(33.75f, 190.5f, 271.25f),
+ ScaleX = 2.5f,
+ ScaleY = 0.75f,
+ ScaleZ = 4.0f,
+ Scale = new Vector3(2.5f, 0.75f, 4.0f),
+ LossyScale = new Vector3(5.0f, 1.5f, 8.0f),
+ CurrentPosition = new Vector3(1.25f, -30.5f, 512.125f),
+ DeltaPosition = new Vector3(0.125f, -0.25f, 0.5f),
+ };
+
+ var currentPosition = state.CurrentPosition;
+ state.NetworkDeltaPosition = new NetworkDeltaPosition(currentPosition, state.NetworkTick, math.bool3(true));
+ var deltaTarget = currentPosition + state.DeltaPosition;
+ state.NetworkDeltaPosition.UpdateFrom(ref deltaTarget, state.NetworkTick);
+
+ state.HalfVectorScale = new HalfVector3(state.Scale, math.bool3(true));
+ var rotation = state.Rotation;
+ state.HalfVectorRotation = new HalfVector4();
+ state.HalfVectorRotation.UpdateFrom(ref rotation);
+ state.HalfEulerRotation = new HalfVector3(state.RotAngleX, state.RotAngleY, state.RotAngleZ);
+
+ return state;
+ }
+
+ private struct StateCase
+ {
+ public string Name;
+ public NetworkTransformState State;
+ }
+
+ ///
+ /// The configuration matrix. Each entry exercises a distinct path through
+ /// .
+ ///
+ private static StateCase[] BuildStateMatrix()
+ {
+ var cases = new System.Collections.Generic.List();
+
+ // Full precision, euler rotation, all axes.
+ AddCase(cases, "FullPrecision.AllAxes", f =>
+ {
+ f.MarkChanged(AxialType.Position, true);
+ f.MarkChanged(AxialType.Rotation, true);
+ f.MarkChanged(AxialType.Scale, true);
+ return f;
+ });
+
+ AddCase(cases, "FullPrecision.PositionOnly", f =>
+ {
+ f.MarkChanged(AxialType.Position, true);
+ return f;
+ });
+
+ AddCase(cases, "FullPrecision.PositionX", f =>
+ {
+ f.SetHasPosition(Axis.X, true);
+ return f;
+ });
+
+ AddCase(cases, "FullPrecision.RotationYOnly", f =>
+ {
+ f.SetHasRotation(Axis.Y, true);
+ return f;
+ });
+
+ AddCase(cases, "FullPrecision.ScaleZOnly", f =>
+ {
+ f.SetHasScale(Axis.Z, true);
+ return f;
+ });
+
+ AddCase(cases, "FullPrecision.Teleport", f =>
+ {
+ f.MarkChanged(AxialType.Position, true);
+ f.MarkChanged(AxialType.Rotation, true);
+ f.MarkChanged(AxialType.Scale, true);
+ f.IsTeleportingNextFrame = true;
+ return f;
+ });
+
+ AddCase(cases, "FullPrecision.Teleport.Parented", f =>
+ {
+ f.MarkChanged(AxialType.Scale, true);
+ f.IsTeleportingNextFrame = true;
+ f.IsParented = true;
+ return f;
+ });
+
+ AddCase(cases, "FullPrecision.TrackByStateId", f =>
+ {
+ f.MarkChanged(AxialType.Position, true);
+ f.TrackByStateId = true;
+ return f;
+ });
+
+ AddCase(cases, "FullPrecision.InLocalSpace", f =>
+ {
+ f.MarkChanged(AxialType.Position, true);
+ f.InLocalSpace = true;
+ return f;
+ });
+
+ // Quaternion synchronization (full precision quaternion).
+ AddCase(cases, "QuaternionSync.Full", f =>
+ {
+ f.MarkChanged(AxialType.Rotation, true);
+ f.QuaternionSync = true;
+ return f;
+ });
+
+ AddCase(cases, "QuaternionSync.Compressed", f =>
+ {
+ f.MarkChanged(AxialType.Rotation, true);
+ f.QuaternionSync = true;
+ f.QuaternionCompression = true;
+ return f;
+ });
+
+ AddCase(cases, "QuaternionSync.HalfFloat", f =>
+ {
+ f.MarkChanged(AxialType.Rotation, true);
+ f.QuaternionSync = true;
+ f.UseHalfFloatPrecision = true;
+ return f;
+ });
+
+ AddCase(cases, "QuaternionSync.Teleport", f =>
+ {
+ f.MarkChanged(AxialType.Rotation, true);
+ f.QuaternionSync = true;
+ f.QuaternionCompression = true;
+ f.IsTeleportingNextFrame = true;
+ return f;
+ });
+
+ // Half float precision.
+ AddCase(cases, "HalfFloat.AllAxes", f =>
+ {
+ f.MarkChanged(AxialType.Position, true);
+ f.MarkChanged(AxialType.Rotation, true);
+ f.MarkChanged(AxialType.Scale, true);
+ f.UseHalfFloatPrecision = true;
+ return f;
+ });
+
+ AddCase(cases, "HalfFloat.PositionOnly", f =>
+ {
+ f.MarkChanged(AxialType.Position, true);
+ f.UseHalfFloatPrecision = true;
+ return f;
+ });
+
+ AddCase(cases, "HalfFloat.PositionXZ", f =>
+ {
+ f.SetHasPosition(Axis.X, true);
+ f.SetHasPosition(Axis.Z, true);
+ f.UseHalfFloatPrecision = true;
+ return f;
+ });
+
+ AddCase(cases, "HalfFloat.SynchronizeBase", f =>
+ {
+ f.MarkChanged(AxialType.Position, true);
+ f.UseHalfFloatPrecision = true;
+ f.SynchronizeBaseHalfFloat = true;
+ return f;
+ });
+
+ AddCase(cases, "HalfFloat.Teleport", f =>
+ {
+ f.MarkChanged(AxialType.Position, true);
+ f.MarkChanged(AxialType.Scale, true);
+ f.UseHalfFloatPrecision = true;
+ f.IsTeleportingNextFrame = true;
+ return f;
+ });
+
+ AddCase(cases, "HalfFloat.Synchronizing", f =>
+ {
+ f.MarkChanged(AxialType.Position, true);
+ f.UseHalfFloatPrecision = true;
+ f.IsTeleportingNextFrame = true;
+ f.IsSynchronizing = true;
+ return f;
+ });
+
+ AddCase(cases, "HalfFloat.ScaleOnly", f =>
+ {
+ f.MarkChanged(AxialType.Scale, true);
+ f.UseHalfFloatPrecision = true;
+ return f;
+ });
+
+ AddCase(cases, "HalfFloat.EulerRotation", f =>
+ {
+ f.MarkChanged(AxialType.Rotation, true);
+ f.UseHalfFloatPrecision = true;
+ return f;
+ });
+
+ // Delivery related flags (these only alter the bitset, but that is part of the wire format).
+ AddCase(cases, "UnreliableDeltas.FrameSync", f =>
+ {
+ f.MarkChanged(AxialType.Position, true);
+ f.UseUnreliableDeltas = true;
+ f.UnreliableFrameSync = true;
+ return f;
+ });
+
+ // The only combination where the delivery reliability is actually derived rather than short
+ // circuited: unreliable deltas enabled, not teleporting, not synchronizing, no frame sync, but the
+ // half float base position is being synchronized. Every other case above has UseUnreliableDeltas
+ // off, which forces reliable delivery before any of the other conditions are consulted.
+ AddCase(cases, "UnreliableDeltas.SynchronizeBaseHalfFloat", f =>
+ {
+ f.MarkChanged(AxialType.Position, true);
+ f.UseUnreliableDeltas = true;
+ f.UseHalfFloatPrecision = true;
+ f.SynchronizeBaseHalfFloat = true;
+ return f;
+ });
+
+ // The same shape with the base synchronization off, so the pair brackets the condition.
+ AddCase(cases, "UnreliableDeltas.PlainDelta", f =>
+ {
+ f.MarkChanged(AxialType.Position, true);
+ f.UseUnreliableDeltas = true;
+ f.UseHalfFloatPrecision = true;
+ return f;
+ });
+
+ AddCase(cases, "SwitchTransformSpaceWhenParented", f =>
+ {
+ f.MarkChanged(AxialType.Position, true);
+ f.SwitchTransformSpaceWhenParented = true;
+ f.UsePositionSlerp = true;
+ f.UseInterpolation = true;
+ return f;
+ });
+
+ return cases.ToArray();
+ }
+
+ private static void AddCase(System.Collections.Generic.List cases, string name, Func configure)
+ {
+ var state = CreateBaseState();
+ state.FlagStates = configure(state.FlagStates);
+ cases.Add(new StateCase { Name = name, State = state });
+ }
+
+ ///
+ /// FNV-1a over the serialized payload. Small, stable, and dependency free.
+ ///
+ private static uint Fnv1a(byte[] bytes)
+ {
+ const uint offsetBasis = 2166136261;
+ const uint prime = 16777619;
+ var hash = offsetBasis;
+ for (int i = 0; i < bytes.Length; i++)
+ {
+ hash ^= bytes[i];
+ hash *= prime;
+ }
+ return hash;
+ }
+
+ private static byte[] Serialize(NetworkTransformState state)
+ {
+ // Resolving the delivery reliability used to happen inside NetworkSerialize. It now happens before
+ // writing, because the batched synchronization mode uses the result to pick which of its two per
+ // tick messages a state belongs to. Every send path calls this first, so the baseline does too;
+ // without it these signatures would move for a reason that has nothing to do with the wire format.
+ state.UpdateReliability();
+
+ var writer = new FastBufferWriter(1024, Allocator.Temp);
+ try
+ {
+ writer.WriteNetworkSerializable(state);
+ return writer.ToArray();
+ }
+ finally
+ {
+ writer.Dispose();
+ }
+ }
+
+ ///
+ /// Verifies the serialized form of every configuration in the matrix still matches the recorded baseline.
+ ///
+ [Test]
+ public void NetworkTransformStateSerializationBaseline()
+ {
+ var cases = BuildStateMatrix();
+ var actual = new string[cases.Length];
+
+ for (int i = 0; i < cases.Length; i++)
+ {
+ var bytes = Serialize(cases[i].State);
+ actual[i] = $"{cases[i].Name}|{bytes.Length}|{Fnv1a(bytes):X8}";
+ }
+
+ if (k_ExpectedSignatures.Length != cases.Length)
+ {
+ Assert.Fail($"No baseline recorded (expected {cases.Length} entries, found {k_ExpectedSignatures.Length}). " +
+ $"Verify this is a new or intentionally changed wire format, then paste the following into {nameof(k_ExpectedSignatures)}:\n\n{FormatLiteral(actual)}");
+ }
+
+ var mismatches = new StringBuilder();
+ for (int i = 0; i < cases.Length; i++)
+ {
+ if (k_ExpectedSignatures[i] != actual[i])
+ {
+ mismatches.AppendLine($" [{i}] expected \"{k_ExpectedSignatures[i]}\" but was \"{actual[i]}\"");
+ }
+ }
+
+ if (mismatches.Length > 0)
+ {
+ Assert.Fail($"The serialized {nameof(NetworkTransformState)} no longer matches the recorded baseline. " +
+ $"If this is an intentional wire format change, update {nameof(k_ExpectedSignatures)}.\n{mismatches}\nUpdated baseline:\n\n{FormatLiteral(actual)}");
+ }
+ }
+
+ ///
+ /// Verifies every configuration in the matrix survives a write and read back.
+ ///
+ ///
+ /// The baseline above proves the bytes did not change. This proves those bytes still round trip, which
+ /// keeps a baseline that was regenerated against a broken serializer from being accepted.
+ ///
+ [Test]
+ public void NetworkTransformStateSerializationRoundTrip()
+ {
+ foreach (var stateCase in BuildStateMatrix())
+ {
+ var bytes = Serialize(stateCase.State);
+ NetworkTransformState deserialized;
+ var reader = new FastBufferReader(bytes, Allocator.Temp);
+ try
+ {
+ reader.ReadNetworkSerializable(out deserialized);
+ Assert.AreEqual(bytes.Length, reader.Position,
+ $"[{stateCase.Name}] Reader consumed {reader.Position} of {bytes.Length} bytes!");
+ }
+ finally
+ {
+ reader.Dispose();
+ }
+
+ Assert.AreEqual(stateCase.State.NetworkTick, deserialized.NetworkTick,
+ $"[{stateCase.Name}] NetworkTick did not survive the round trip!");
+
+ if (stateCase.State.FlagStates.TrackByStateId)
+ {
+ Assert.AreEqual(stateCase.State.StateId, deserialized.StateId,
+ $"[{stateCase.Name}] StateId did not survive the round trip!");
+ }
+
+ AssertFlagsSurvived(stateCase, deserialized);
+
+ // Re-serializing what was read back must reproduce the original payload byte for byte.
+ // This is the primary round trip assertion because it covers every field without the test
+ // needing to know which of them the serializer derives on the way out.
+ var reserialized = Serialize(deserialized);
+ Assert.AreEqual(bytes.Length, reserialized.Length,
+ $"[{stateCase.Name}] Re-serialized payload was {reserialized.Length} bytes but the original was {bytes.Length}!");
+ for (int i = 0; i < bytes.Length; i++)
+ {
+ if (bytes[i] != reserialized[i])
+ {
+ Assert.Fail($"[{stateCase.Name}] Re-serialized payload differs at byte {i}: expected 0x{bytes[i]:X2} but was 0x{reserialized[i]:X2}!");
+ }
+ }
+ }
+ }
+
+ ///
+ /// Compares every flag that a state update carries, with the exception of
+ /// .
+ ///
+ ///
+ /// derives ReliableSequenced while writing
+ /// (forced true unless UseUnreliableDeltas is set, otherwise derived from the teleporting,
+ /// synchronizing, frame sync and collapsed base position flags). The state being written is passed by
+ /// 'in', so that derivation lands on a defensive copy and is only ever observable on the state that
+ /// was read back. Comparing it against the source state would therefore always fail.
+ ///
+ private static void AssertFlagsSurvived(StateCase stateCase, NetworkTransformState deserialized)
+ {
+ var expected = stateCase.State.FlagStates;
+ var actual = deserialized.FlagStates;
+
+ void Check(string flag, bool expectedValue, bool actualValue)
+ {
+ Assert.AreEqual(expectedValue, actualValue, $"[{stateCase.Name}] Flag {flag} did not survive the round trip!");
+ }
+
+ Check(nameof(FlagStates.InLocalSpace), expected.InLocalSpace, actual.InLocalSpace);
+ Check(nameof(FlagStates.HasPositionX), expected.HasPositionX, actual.HasPositionX);
+ Check(nameof(FlagStates.HasPositionY), expected.HasPositionY, actual.HasPositionY);
+ Check(nameof(FlagStates.HasPositionZ), expected.HasPositionZ, actual.HasPositionZ);
+ Check(nameof(FlagStates.HasRotAngleX), expected.HasRotAngleX, actual.HasRotAngleX);
+ Check(nameof(FlagStates.HasRotAngleY), expected.HasRotAngleY, actual.HasRotAngleY);
+ Check(nameof(FlagStates.HasRotAngleZ), expected.HasRotAngleZ, actual.HasRotAngleZ);
+ Check(nameof(FlagStates.HasScaleX), expected.HasScaleX, actual.HasScaleX);
+ Check(nameof(FlagStates.HasScaleY), expected.HasScaleY, actual.HasScaleY);
+ Check(nameof(FlagStates.HasScaleZ), expected.HasScaleZ, actual.HasScaleZ);
+ Check(nameof(FlagStates.IsTeleportingNextFrame), expected.IsTeleportingNextFrame, actual.IsTeleportingNextFrame);
+ Check(nameof(FlagStates.UseInterpolation), expected.UseInterpolation, actual.UseInterpolation);
+ Check(nameof(FlagStates.QuaternionSync), expected.QuaternionSync, actual.QuaternionSync);
+ Check(nameof(FlagStates.QuaternionCompression), expected.QuaternionCompression, actual.QuaternionCompression);
+ Check(nameof(FlagStates.UseHalfFloatPrecision), expected.UseHalfFloatPrecision, actual.UseHalfFloatPrecision);
+ Check(nameof(FlagStates.IsSynchronizing), expected.IsSynchronizing, actual.IsSynchronizing);
+ Check(nameof(FlagStates.UsePositionSlerp), expected.UsePositionSlerp, actual.UsePositionSlerp);
+ Check(nameof(FlagStates.IsParented), expected.IsParented, actual.IsParented);
+ Check(nameof(FlagStates.SynchronizeBaseHalfFloat), expected.SynchronizeBaseHalfFloat, actual.SynchronizeBaseHalfFloat);
+ Check(nameof(FlagStates.UseUnreliableDeltas), expected.UseUnreliableDeltas, actual.UseUnreliableDeltas);
+ Check(nameof(FlagStates.UnreliableFrameSync), expected.UnreliableFrameSync, actual.UnreliableFrameSync);
+ Check(nameof(FlagStates.SwitchTransformSpaceWhenParented), expected.SwitchTransformSpaceWhenParented, actual.SwitchTransformSpaceWhenParented);
+ Check(nameof(FlagStates.TrackByStateId), expected.TrackByStateId, actual.TrackByStateId);
+ }
+
+ private static string FormatLiteral(string[] signatures)
+ {
+ var builder = new StringBuilder();
+ builder.AppendLine(" private static readonly string[] k_ExpectedSignatures =");
+ builder.AppendLine(" {");
+ foreach (var signature in signatures)
+ {
+ builder.AppendLine($" \"{signature}\",");
+ }
+ builder.AppendLine(" };");
+ return builder.ToString();
+ }
+ }
+}
diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformStateBaselineTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformStateBaselineTests.cs.meta
new file mode 100644
index 0000000000..fd70a0eecf
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformStateBaselineTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 058fb7f29dcbf5448b15ebc82e54776b
\ No newline at end of file
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..931c225d4e
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs
@@ -0,0 +1,384 @@
+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 do not share a wire format, a send path, or an interpolator, so nothing structural keeps
+ /// them equivalent. This is the regression net for that: it does not compare bytes (they legitimately
+ /// differ) but compares the observable outcome, which is what has to stay the same.
+ /// The scenarios were chosen from the places where the batched path diverges from the per instance one
+ /// rather than from a general notion of coverage. Each one is documented with what it would catch.
+ ///
+ // 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 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;
+ }
+
+ protected override void OnServerAndClientsCreated()
+ {
+ m_MoverPrefab = CreateNetworkObjectPrefab("ParityMover");
+ var networkTransform = m_MoverPrefab.AddComponent();
+ networkTransform.AuthorityMode = m_AuthorityMode;
+ networkTransform.Interpolate = true;
+
+ base.OnServerAndClientsCreated();
+ }
+
+ protected override IEnumerator OnTearDown()
+ {
+ m_SpawnedMovers.Clear();
+ return base.OnTearDown();
+ }
+
+ ///
+ /// Spawns an instance owned by the given client, or by the server when no owner is given.
+ ///
+ private NetworkObject SpawnMover(ulong ownerClientId = NetworkManager.ServerClientId)
+ {
+ var instance = Object.Instantiate(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.
+ ///
+ ///
+ /// Ownership is applied to the server side synchronously, but the owning
+ /// client only learns of it a round trip later. Moving the transform before then is a non-authority
+ /// write: it is discarded by interpolation and nothing is ever sent, which looks exactly like a
+ /// replication failure.
+ ///
+ 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;
+ }
+
+ ///
+ /// Every manager that should be able to see the object agrees with the authority's transform.
+ ///
+ 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;
+ }
+
+ ///
+ /// Moves the authority instance and waits for everyone to agree.
+ ///
+ 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 identifies which instance was
+ /// left behind rather than only that one was.
+ ///
+ 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 deliberately leaves these on the per instance path, because the batch is assembled per
+ /// observing client and sent directly, which only the server can do. This is here because that
+ /// exclusion is invisible at runtime: get it wrong and the transform simply stops replicating with no
+ /// error, which is exactly what happened before the exclusion was added.
+ ///
+ [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: the
+ /// hidden object's entry must be absent from that one client's batch while still reaching the others.
+ /// A filtering mistake shows up either 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);
+ }
+
+ ///
+ /// 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. Capturing it at the
+ /// wrong point turns a teleport into an ordinary delta, which is only visible as a long glide.
+ ///
+ [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!");
+
+ 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!");
+ }
+
+ ///
+ /// Ownership moving between clients mid session.
+ ///
+ ///
+ /// A change of ownership re-runs initialization, which moves an instance between the delta tracking
+ /// and interpolation registrations, and under owner authority it also moves it between the batched
+ /// and per instance send paths. The handle has to survive that, since 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 is what exercises handle release and reuse.
+ ///
+ ///
+ /// Handles are held for several seconds before being reissued so a state update still in flight cannot
+ /// land on whichever instance picks the handle up next. A respawn inside that window therefore has to
+ /// receive a different handle; if it did not, the surviving object and the new one would fight over
+ /// the same address and one would snap to the other's position.
+ ///
+ [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/NetworkTransform/TransformHandleAllocatorTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/TransformHandleAllocatorTests.cs
new file mode 100644
index 0000000000..bdc59a32da
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/TransformHandleAllocatorTests.cs
@@ -0,0 +1,140 @@
+using NUnit.Framework;
+using Unity.Netcode.Components;
+using Unity.Netcode.TestHelpers.Runtime;
+
+namespace Unity.Netcode.RuntimeTests
+{
+ ///
+ /// Covers , in particular that a freed handle is held long enough
+ /// that a state update still in flight for the instance that owned it cannot be applied to whichever
+ /// instance picks it up next.
+ ///
+ // These tests do not need to run against the Rust server.
+ [IgnoreIfServiceEnvironmentVariableSet]
+ internal class TransformHandleAllocatorTests
+ {
+ ///
+ /// Has to match the allocator's hold duration.
+ ///
+ private const double k_RecycleDelaySeconds = 5.0;
+
+ [Test]
+ public void AllocatesDenselyAndSkipsTheInvalidHandle()
+ {
+ var allocator = new TransformHandleAllocator();
+
+ for (ushort expected = 1; expected <= 100; expected++)
+ {
+ var handle = allocator.Allocate(0.0);
+ Assert.AreNotEqual(TransformHandleAllocator.InvalidHandle, handle, "Allocated the reserved invalid handle!");
+ Assert.AreEqual(expected, handle, "Handles were not allocated densely!");
+ }
+ }
+
+ [Test]
+ public void ReleasedHandleIsNotReissuedBeforeItsHoldExpires()
+ {
+ var allocator = new TransformHandleAllocator();
+
+ var first = allocator.Allocate(0.0);
+ var second = allocator.Allocate(0.0);
+ allocator.Release(first, 0.0);
+
+ // Anything allocated before the hold expires has to be a fresh handle, not the released one.
+ for (double time = 0.0; time < k_RecycleDelaySeconds; time += 1.0)
+ {
+ var handle = allocator.Allocate(time);
+ Assert.AreNotEqual(first, handle,
+ $"Released handle {first} was reissued at time {time}, before its hold expired!");
+ Assert.AreNotEqual(second, handle, "Reissued a handle that was never released!");
+ }
+ }
+
+ [Test]
+ public void ReleasedHandleIsReissuedOnceItsHoldExpires()
+ {
+ var allocator = new TransformHandleAllocator();
+
+ var first = allocator.Allocate(0.0);
+ allocator.Release(first, 0.0);
+
+ var reissued = allocator.Allocate(k_RecycleDelaySeconds);
+ Assert.AreEqual(first, reissued, "A handle held past its delay was not reused, which would leak the handle space!");
+ }
+
+ [Test]
+ public void ReleasedHandlesAreReissuedInReleaseOrder()
+ {
+ var allocator = new TransformHandleAllocator();
+
+ var first = allocator.Allocate(0.0);
+ var second = allocator.Allocate(0.0);
+ var third = allocator.Allocate(0.0);
+
+ // Released at increasing times, so they become reusable in the same order.
+ allocator.Release(first, 0.0);
+ allocator.Release(second, 1.0);
+ allocator.Release(third, 2.0);
+
+ Assert.AreEqual(first, allocator.Allocate(k_RecycleDelaySeconds), "Oldest released handle was not reissued first!");
+ Assert.AreEqual(second, allocator.Allocate(k_RecycleDelaySeconds + 1.0), "Handles were not reissued in release order!");
+ Assert.AreEqual(third, allocator.Allocate(k_RecycleDelaySeconds + 2.0), "Handles were not reissued in release order!");
+ }
+
+ [Test]
+ public void ReleasingTheInvalidHandleIsIgnored()
+ {
+ var allocator = new TransformHandleAllocator();
+
+ allocator.Release(TransformHandleAllocator.InvalidHandle, 0.0);
+
+ // If the invalid handle had been queued it would come back out here.
+ Assert.AreEqual(1, allocator.Allocate(k_RecycleDelaySeconds * 2.0), "The reserved invalid handle entered the recycle queue!");
+ }
+
+ [Test]
+ public void RegisteredHandleResolvesBackToItsInstance()
+ {
+ var allocator = new TransformHandleAllocator();
+ var handle = allocator.Allocate(0.0);
+
+ // The association is what the receiving side uses to route a batched state update. A null instance
+ // is enough to prove the table behavior without needing a spawned NetworkObject.
+ allocator.Register(handle, null);
+ Assert.IsTrue(allocator.TryGet(handle, out _), "A registered handle did not resolve!");
+ Assert.AreEqual(1, allocator.GetRegisteredCount());
+
+ allocator.Unregister(handle);
+ Assert.IsFalse(allocator.TryGet(handle, out _), "An unregistered handle still resolved!");
+ Assert.AreEqual(0, allocator.GetRegisteredCount());
+ }
+
+ [Test]
+ public void ReleaseAlsoDropsTheRegistration()
+ {
+ var allocator = new TransformHandleAllocator();
+ var handle = allocator.Allocate(0.0);
+ allocator.Register(handle, null);
+
+ allocator.Release(handle, 0.0);
+
+ Assert.IsFalse(allocator.TryGet(handle, out _),
+ "A released handle still resolved, which would route state updates to a despawned instance!");
+ }
+
+ [Test]
+ public void ClearResetsTheAllocator()
+ {
+ var allocator = new TransformHandleAllocator();
+ allocator.Allocate(0.0);
+ allocator.Allocate(0.0);
+ var third = allocator.Allocate(0.0);
+ allocator.Register(third, null);
+
+ allocator.Clear();
+
+ Assert.AreEqual(0, allocator.GetRegisteredCount(), "Clear left registrations behind!");
+ Assert.AreEqual(1, allocator.Allocate(0.0), "Clear did not reset the handle sequence, so a new session would not start from the beginning!");
+ }
+ }
+}
diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/TransformHandleAllocatorTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/TransformHandleAllocatorTests.cs.meta
new file mode 100644
index 0000000000..07671fec58
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/TransformHandleAllocatorTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3e0cf23068d2423479f2ffe62ae6ed51
\ 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 51d91eea3d..d428d152bb 100644
--- a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs
+++ b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs
@@ -697,6 +697,9 @@ public IEnumerator SetUp()
ConfigureFramesPerTick();
}
+ // Get the transform synchronization mode before setup.
+ GetSyncMode();
+
if (m_SetupIsACoroutine)
{
yield return OnSetup();
@@ -825,6 +828,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
///
@@ -873,6 +889,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
@@ -957,6 +974,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);