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