diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index ceaff931bb..c1d5d10b5b 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -10,8 +10,12 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Added +- Added `NetworkConfig.TransformSyncMode`, which determines whether `NetworkTransform` instances detect and synchronize their state individually or as a single batched message per tick. It is set per `NetworkManager` under "Transform Synchronization" in the inspector or from script before a session starts, and the value the session starts with applies for the duration of that session. Every peer in a session has to use the same mode, which the connection configuration hash enforces. (#4123) + ### Changed +- Changed `NetworkTransform.UseHalfFloatPrecision` to synchronize position with a resolution of approximately 1mm regardless of how far an object has travelled. Previously the resolution could degrade to approximately 3cm. This does not increase bandwidth, but projects using `NetworkTransform.UseUnreliableDeltas` will send full precision position updates more often. (#4129) + - All editor assembly definitions are renamed with `Unity.Netcode.GameObjects.x` variants - `Unity.Netcode.Editor` → `Unity.Netcode.GameObjects.Editor` - `Unity.Netcode.Editor.CodeGen` → `Unity.Netcode.GameObjects.Editor.CodeGen` @@ -29,6 +33,8 @@ Additional documentation and release notes are available at [Multiplayer Documen - Issue where `NetworkTransform.GetTickLatencyInSeconds` returned an absolute network timestamp that grew for as long as the session ran, rather than the tick latency as a duration in seconds that it is documented to return. (#4135) - Issue where lerp smoothing was applied per frame instead of over time, which caused the `Lerp` and `SmoothDampening` interpolation types to smooth by different amounts at different frame rates. Results at 60fps are unchanged. (#4132) - Issue where setting a maximum interpolation time of 1.0 would stop a `NetworkTransform` from interpolating at all when using the `Lerp` or `SmoothDampening` interpolation types. (#4132) +- Issue where objects using `NetworkTransform.UseHalfFloatPrecision` appeared to jitter on non-authority instances while they were stationary or coming to rest, even though the authority was not moving them. (#4129) +- Issue where re-enabling a `NetworkTransform` position axis that had drifted out of half float delta range while it was disabled would not teleport, because the per axis check assigned its result rather than accumulating it and an in-range axis discarded what an out-of-range one had found. (#4123) - Issue with not being able to spawn initially disabled in-scene placed objects. (#4093) - Issue with pre-instantiated network prefab instances being marked as in-scene placed. Now pre-instantiated network prefabs are dynamically spawned. (#4093) - Issue where a user could spawn runtime created `NetworkObject` that has a GlobalObjectIdHash of zero. These are not valid instances and will no longer be allowed to spawn. (#4093) diff --git a/com.unity.netcode.gameobjects/Editor/NetworkManagerEditor.cs b/com.unity.netcode.gameobjects/Editor/NetworkManagerEditor.cs index 234a34c7b4..eb71aed97e 100644 --- a/com.unity.netcode.gameobjects/Editor/NetworkManagerEditor.cs +++ b/com.unity.netcode.gameobjects/Editor/NetworkManagerEditor.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using Unity.Netcode.Components; using Unity.Netcode.GameObjects.Editor.Configuration; using Unity.Netcode.Logging; using UnityEditor; @@ -51,6 +52,7 @@ public class NetworkManagerEditor : NetcodeEditorBase private SerializedProperty m_SpawnTimeOutProperty; private SerializedProperty m_RpcHashSizeProperty; private SerializedProperty m_LoadSceneTimeOutProperty; + private SerializedProperty m_TransformSyncModeProperty; private SerializedProperty m_PrefabsList; private SerializedProperty m_NetworkProfileMetrics; @@ -138,6 +140,7 @@ private void Initialize() m_NetworkMessageMetrics = m_NetworkConfigProperty.FindPropertyRelative("NetworkMessageMetrics"); #endif m_RpcHashSizeProperty = m_NetworkConfigProperty.FindPropertyRelative("RpcHashSize"); + m_TransformSyncModeProperty = m_NetworkConfigProperty.FindPropertyRelative(nameof(NetworkConfig.TransformSyncMode)); m_PrefabsList = m_NetworkConfigProperty .FindPropertyRelative(nameof(NetworkConfig.Prefabs)) .FindPropertyRelative(nameof(NetworkPrefabs.NetworkPrefabsLists)); @@ -183,6 +186,7 @@ private void CheckNullProperties() #endif m_RpcHashSizeProperty = m_NetworkConfigProperty.FindPropertyRelative("RpcHashSize"); + m_TransformSyncModeProperty = m_NetworkConfigProperty.FindPropertyRelative(nameof(NetworkConfig.TransformSyncMode)); m_PrefabsList = m_NetworkConfigProperty .FindPropertyRelative(nameof(NetworkConfig.Prefabs)) .FindPropertyRelative(nameof(NetworkPrefabs.NetworkPrefabsLists)); @@ -319,6 +323,17 @@ private void DisplayNetworkManagerProperties() EditorGUILayout.PropertyField(m_PrefabsList); } + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Transform Synchronization", EditorStyles.boldLabel); + EditorGUILayout.PropertyField(m_TransformSyncModeProperty, new GUIContent("Mode")); + if (m_NetworkManager.NetworkConfig.TransformSyncMode == TransformSyncModes.Batched) + { + EditorGUILayout.HelpBox( + $"{nameof(NetworkTransform.UseUnreliableDeltas)} does not apply in this mode. Delivery is determined per state " + + "update as opposed to per component. Every peer in a session has to use the same mode.", + MessageType.Info); + } + EditorGUILayout.Space(); EditorGUILayout.LabelField("Scene Management Settings", EditorStyles.boldLabel); EditorGUILayout.PropertyField(m_EnableSceneManagementProperty); diff --git a/com.unity.netcode.gameobjects/Editor/NetworkTransformEditor.cs b/com.unity.netcode.gameobjects/Editor/NetworkTransformEditor.cs index 3b61dadff2..4cfb4be404 100644 --- a/com.unity.netcode.gameobjects/Editor/NetworkTransformEditor.cs +++ b/com.unity.netcode.gameobjects/Editor/NetworkTransformEditor.cs @@ -218,6 +218,11 @@ private void DisplayNetworkTransformProperties() EditorGUILayout.Space(); EditorGUILayout.LabelField("Delivery", EditorStyles.boldLabel); EditorGUILayout.PropertyField(m_TickSyncChildren); + + // UseUnreliableDeltas only applies to per instance synchronization mode, but the mode is authored on + // the NetworkManager that will run this instance, which a prefab cannot know. So it is always drawn + // and the runtime ignores it under the batched mode, where delivery is determined per state update + // as opposed to per component. // If both are set from a previous configuration, then SwitchTransformSpaceWhenParented takes // precedence. if (networkTransform.UseUnreliableDeltas && networkTransform.SwitchTransformSpaceWhenParented) @@ -242,6 +247,7 @@ private void DisplayNetworkTransformProperties() EditorGUILayout.Space(); EditorGUILayout.LabelField("Configurations", EditorStyles.boldLabel); + // SwitchTransformSpaceWhenParented is only constrained by UseUnreliableDeltas while the latter applies. SetGUIActive(!networkTransform.UseUnreliableDeltas); if (networkTransform.UseUnreliableDeltas) { diff --git a/com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs b/com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs new file mode 100644 index 0000000000..64e0539551 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs @@ -0,0 +1,64 @@ +using Unity.Burst; +using Unity.Collections; +using UnityEngine.Jobs; +using static Unity.Netcode.Components.NetworkTransform; + +namespace Unity.Netcode.Components +{ + /// + /// Motion Authority Only: + /// Detects state changes for every registered instance in a parallel job. + /// + /// + /// + /// is the common method used for both per instance, runs on the main thread, and batched modes. This assures both paths detect changes in state identically. + /// + [BurstCompile] + internal struct DetectTransformDeltaJob : IJobParallelForTransform + { + /// + /// The per instance input and output, parallel to the job's scheduled transforms. + /// + public NativeArray Entries; + + /// + /// This job's primary entry point. + /// + /// + /// TODO: Investigate ways to work around the fact that a Rigidbody's position and rotation cannot + /// be sampled from a job. As such, any NetworkTransform that is using Rigidbody for motion will + /// use the to detect changes in position and rotation + /// states on the authority side. + /// + /// Index for the transform in question. + /// The job safe + public void Execute(int index, TransformAccess transform) + { + if (!transform.isValid) + { + return; + } + + var entry = Entries[index]; + var flagStates = entry.State.FlagStates; + // Only ResolveTransformSpace can raise this on the batched path. The one caller that forces a full + // state update does so from CommitDetectedState on the main thread, after this job has run. + var forceState = false; + + // Resolve the transform space before sampling, otherwise the wrong set of values gets compared. + var transformSpaceChanged = ResolveTransformSpace(ref entry.Config, ref flagStates, entry.TransformHasParent, false, ref forceState); + entry.State.FlagStates = flagStates; + + var rotation = entry.Config.InLocalSpace ? transform.localRotation : transform.rotation; + entry.Sample.Position = entry.Config.InLocalSpace ? transform.localPosition : transform.position; + entry.Sample.Rotation = rotation; + entry.Sample.RotAngles = NetworkTransformMath.EulerAngles(rotation); + entry.Sample.Scale = transform.localScale; + + entry.IsDirty = CheckForStateChange(ref entry.State, ref entry.HalfPositionState, ref entry.Config, + entry.Sample, false, forceState, transformSpaceChanged); + + Entries[index] = entry; + } + } +} diff --git a/com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs.meta b/com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs.meta new file mode 100644 index 0000000000..e1dd1c36d6 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: be2bf90a04d129843b923080c53c36a2 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Runtime/Components/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 4cee25748e..c3eef681e7 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/BufferedLinearInterpolator.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/BufferedLinearInterpolator.cs @@ -274,7 +274,13 @@ public void Clear() /// Resets the current interpolator to the target value. /// /// - /// This is used when first synchronizing/initializing and when telporting an object. + /// This is used when first synchronizing/initializing and when teleporting an object.
+ /// No baseline measurement is recorded.
+ /// A baseline is stamped with the local ServerTime.Time.
+ /// The measurements that follow carry the older tick they were authored on.
+ /// AddMeasurement would drop every one of those.
+ /// The interpolator is left the way a freshly spawned one is instead.
+ /// is not used. Mark this obsolete and deprecate it at a later date. ///
/// The target value to reset the interpolator to /// The current server time @@ -287,7 +293,9 @@ internal void ResetTo(Transform parent, T targetValue, double serverTime) { // Clear the interpolator Clear(); - InternalReset(parent, targetValue, serverTime); + + // No baseline measurement. See the ResetTo remarks above. + 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..746d0affba --- /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: + /// Handles interpolation for every registered non-authority in + /// a parallel job. + /// + /// + /// This performs the buffer consumption and interpolation between two state updates only.
+ /// Applying the results to the transforms stays on the main thread, which keeps this job free + /// of hierarchy write ordering.
+ /// Each entry owns its own slice of , so no two indices address the same items.
+ /// The whole array can be written without aliasing (access is to a distinct, independent memory region). + ///
+ [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..f722ff1847 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs @@ -0,0 +1,677 @@ +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; + + /// + /// The frame rate that is relative to when lerp smoothing. + /// + private const float k_LerpSmoothReferenceFrameRate = 60.0f; + + /// + /// Keeps a of 1.0f from retaining the entire delta + /// each frame, which would stop the value from ever advancing towards the target. + /// + private const float k_MaximumLerpSmoothRetention = 0.99f; + + /// + /// Calculates the frame rate independent lerp smoothing "t" for the current frame. + /// + /// + /// Raising the retained portion to the number of reference frames elapsed makes the smoothing rate + /// a function of elapsed time rather than of how often this is called. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static float GetLerpSmoothTime(in NativeInterpolatorState state, float deltaTime) + { + var retained = math.saturate(state.MaximumInterpolationTime); + if (retained >= 1.0f) + { + retained = k_MaximumLerpSmoothRetention; + } + return 1.0f - math.pow(retained, deltaTime * k_LerpSmoothReferenceFrameRate); + } + + [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; + } + + /// + /// . + /// + /// + /// Clears the buffer and holds as the current value.
+ /// No baseline measurement is recorded, which is what the managed implementation does as well. See + /// for why.
+ /// This leaves the interpolator in the state a freshly spawned one is in, so the next measurement to + /// arrive is taken unconditionally. + ///
+ internal static void ResetTo(ref NativeInterpolatorState state, ref NativeArray items, float4 targetValue) + { + 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 the buffered measurements and the in flight values in a different transform space. + /// + /// + /// Invoked when the instance is reparented. Reparenting is the only thing that changes the + /// transform space for its measurements.
+ /// Converting everything at that point is what keeps the interpolation job free of any parent + /// knowledge.
+ /// The managed interpolator converts lazily as its queue drains instead. Both reach the same result. + ///
+ /// Converts a position from the old transform space to the new one. + /// Converts a rotation from the old transform 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, GetLerpSmoothTime(state, deltaTime)); + } + 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/NetworkDeltaPosition.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkDeltaPosition.cs index a780a07230..3bae7eded3 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkDeltaPosition.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkDeltaPosition.cs @@ -11,7 +11,14 @@ namespace Unity.Netcode.Components [Serializable] public struct NetworkDeltaPosition : INetworkSerializable { - internal const float MaxDeltaBeforeAdjustment = 64f; + /// + /// How far the delta may grow before it is folded into the base position. + /// + /// + /// This determines the transmitted position resolution, since a half float's step size grows with its + /// magnitude. Keeping the delta small keeps that step small: at 2 the coarsest step is roughly 1mm. + /// + internal const float MaxDeltaBeforeAdjustment = 2f; /// /// The HalfVector3 used to synchronize the delta in position @@ -138,14 +145,29 @@ public void UpdateFrom(ref Vector3 vector3, int networkTick) { CollapsedDeltaIntoBase = false; NetworkTick = networkTick; - DeltaPosition = (vector3 + PrecisionLossDelta) - CurrentBasePosition; for (int i = 0; i < HalfVector3.Length; i++) { if (HalfVector3.AxisToSynchronize[i]) { + var rawDelta = vector3[i] - CurrentBasePosition[i]; + + // Adding the previous rounding loss back in keeps the average position accurate while the + // value is moving, but it also changes the value being sent. Once the value stops moving + // that is all it does, which makes a stationary object appear to oscillate. + var movedSinceLastSend = Mathf.Abs(vector3[i] - PreviousPosition[i]); + var applyPrecisionLoss = movedSinceLastSend >= HalfPrecisionQuantum(rawDelta); + + DeltaPosition[i] = applyPrecisionLoss ? rawDelta + PrecisionLossDelta[i] : rawDelta; + HalfVector3.Axis[i] = math.half(DeltaPosition[i]); HalfDeltaConvertedBack[i] = Mathf.HalfToFloat(HalfVector3.Axis[i].value); - PrecisionLossDelta[i] = DeltaPosition[i] - HalfDeltaConvertedBack[i]; + + // Left unchanged when skipped so it is still applied once movement resumes. + if (applyPrecisionLoss) + { + PrecisionLossDelta[i] = DeltaPosition[i] - HalfDeltaConvertedBack[i]; + } + if (Mathf.Abs(HalfDeltaConvertedBack[i]) >= MaxDeltaBeforeAdjustment) { CurrentBasePosition[i] += HalfDeltaConvertedBack[i]; @@ -165,6 +187,26 @@ public void UpdateFrom(ref Vector3 vector3, int networkTick) } } + /// + /// The smallest change a half float can represent at the magnitude of the value passed in. + /// + /// The value to get the step size for. + /// The distance to the next representable half float value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static float HalfPrecisionQuantum(float value) + { + // The step size is symmetric about zero, so the sign is dropped. + var magnitude = (ushort)(math.half(value).value & 0x7FFF); + + // Guard only: stepping past the largest finite half float would give infinity. + if (magnitude >= 0x7BFF) + { + return MaxDeltaBeforeAdjustment; + } + + return Mathf.HalfToFloat((ushort)(magnitude + 1)) - Mathf.HalfToFloat(magnitude); + } + /// /// Constructor /// diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs index 9f4b04d368..bad7bbb993 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 @@ -1769,6 +1786,40 @@ public Vector3 GetScale(bool getCurrentState = false) return m_InternalCurrentScale; } + /// + /// When mode, this is the instance's index within .
+ /// It is -1 when it is not registered. + ///
+ /// + /// Cached here as opposed to using a lookup table, so registering and deregistering do not have to + /// search for the instance.
+ /// The manager keeps this up to date as instances are swapped between slots. + ///
+ internal int StateManagerIndex = -1; + + /// + /// This instance's index within the 's interpolation + /// entries, or -1 when it is not registered. + /// + /// + /// Separate from because an instance is registered as either an + /// authority (delta detection) or a non-authority (interpolation), never both, and the two are tracked + /// in different collections. + /// Only used by . + /// + internal int InterpolatorIndex = -1; + + /// + /// This instance's dense network wide identifier, or + /// when it has not been assigned one. + /// + /// + /// Assigned by whichever instance writes synchronization data and replicated to everyone else through + /// , so it survives changes of ownership. + /// Only used by . + /// + internal ushort TransformHandle = TransformHandleAllocator.InvalidHandle; + // Used by both authoritative and non-authoritative instances. // This represents the most recent local authoritative state. private NetworkTransformState m_LocalAuthoritativeNetworkState; @@ -1916,12 +1967,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.ActiveTransformSyncMode == TransformSyncModes.Batched) + { + if (serializer.IsWriter && TransformHandle == TransformHandleAllocator.InvalidHandle) + { + // Lazily allocated when first write rather than at spawn, which guarantees it exists before + // anything can transmit it regardless of spawn ordering. + TransformHandle = NetworkManager.TransformStateManager.Handles.Allocate(NetworkManager.ServerTime.Time); + } + + serializer.SerializeValue(ref TransformHandle); + NetworkManager.TransformStateManager.Handles.Register(TransformHandle, this); + } + if (serializer.IsWriter) { SynchronizeState.FlagStates.IsTeleportingNextFrame = true; // 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); @@ -2019,6 +2088,157 @@ 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) + { + // Both of these run from OnUpdateAuthoritativeState on the per instance path, which a registered + // instance never reaches. Without the clear the change flags only accumulate, and without the + // axis check a batched instance silently skips the teleport that re-enabling an axis requires. + ClearStateForNextTick(); + AxisChangedDeltaPositionCheck(); + + // The entry is what the job reads, and anything the main thread set between ticks (a Teleport or + // a SetState raising the teleport and explicit set flags) is on m_LocalAuthoritativeNetworkState. + // Seeding from it here is a no-op when nothing changed, since ApplyBatchedDeltaEntry writes the + // two back into step. Without it a teleport converges as an ordinary delta. + entry.State = m_LocalAuthoritativeNetworkState; + + entry.Config = GetTransformDeltaConfig(); + entry.TransformHasParent = transform.parent != null; + entry.HalfPositionState = m_HalfPositionState; + entry.IsDirty = false; + + var flagStates = entry.State.FlagStates; + entry.Sample = default; + + // Same conditions the per instance path uses, both of which need a lookup a job cannot perform. + if (flagStates.IsTeleportingNextFrame || flagStates.IsParented) + { + entry.Sample.HasParentNetworkObject = HasParentNetworkObject(); + entry.Sample.LossyScale = CachedTransform.lossyScale; + } + } + + /// + /// Applies the batched delta check result and sends the state update when one was detected. + /// + internal void ApplyBatchedDeltaEntry(ref TransformDeltaEntry entry) + { + m_LocalAuthoritativeNetworkState = entry.State; + m_HalfPositionState = entry.HalfPositionState; + ApplyTransformDeltaConfig(entry.Config); + + 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) @@ -2034,8 +2254,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; @@ -2139,84 +2374,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.ActiveTransformSyncMode != 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, + }; + } + + /// + /// 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; + } - // 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) + /// + /// 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) @@ -2227,427 +2497,295 @@ 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) + var sample = new TransformSample() { - 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; - } + Position = position, + Rotation = rotation, + RotAngles = NetworkTransformMath.EulerAngles(rotation), + Scale = CachedTransform.localScale, + }; - if (UseQuaternionSynchronization != flagStates.QuaternionSync) + // Only resolved when it can actually be consumed, since both of these are lookups. + if (isSynchronization || networkState.FlagStates.IsTeleportingNextFrame || forceState) { - flagStates.QuaternionSync = UseQuaternionSynchronization; - isDirty = true; - flagStates.IsTeleportingNextFrame = true; + sample.HasParentNetworkObject = HasParentNetworkObject(); + sample.LossyScale = CachedTransform.lossyScale; } - - if (UseQuaternionCompression != flagStates.QuaternionCompression) + else if (networkState.FlagStates.IsParented) { - flagStates.QuaternionCompression = UseQuaternionCompression; - isDirty = true; - flagStates.IsTeleportingNextFrame = true; + // 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; } - if (UseHalfFloatPrecision != flagStates.UseHalfFloatPrecision) + if (isSynchronization) { - flagStates.UseHalfFloatPrecision = UseHalfFloatPrecision; - isDirty = true; - flagStates.IsTeleportingNextFrame = true; + sample.ShouldSynchronizeHalfFloat = ShouldSynchronizeHalfFloat(targetClientId); + sample.UseHalfDeltaConvertedBack = NetworkObject.IsOwnedByServer || IsServerAuthoritative(); } - if (SlerpPosition != flagStates.UsePositionSlerp) + var isDirty = CheckForStateChange(ref networkState, ref m_HalfPositionState, ref config, sample, isSynchronization, forceState, transformSpaceChanged); + + ApplyTransformDeltaConfig(config); + + if (config.LogSynchronizationEntry) { - flagStates.UsePositionSlerp = SlerpPosition; - isDirty = true; - flagStates.IsTeleportingNextFrame = true; + // Add log entry for this update relative to the client being synchronized + AddLogEntry(ref networkState, targetClientId, true); } - if (UseUnreliableDeltas != flagStates.UseUnreliableDeltas) + return isDirty; + } + + /// + /// Authority subscribes to network tick events and will invoke + /// each network tick. + /// + private void OnNetworkTick(bool isCalledFromParent = false) + { + // If not active, then ignore the update + if (!gameObject.activeInHierarchy) { - flagStates.UseUnreliableDeltas = UseUnreliableDeltas; - isDirty = true; - flagStates.IsTeleportingNextFrame = true; + return; } - // Begin delta checks against last sent state update - if (!UseHalfFloatPrecision) + // As long as we are still authority + if (CanCommitToTransform) { - if (SyncPositionX && (Mathf.Abs(networkState.PositionX - position.x) >= positionThreshold.x || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) + if (m_CachedNetworkManager.DistributedAuthorityMode && !IsOwner) { - networkState.PositionX = position.x; - flagStates.SetHasPosition(Axis.X, true); - isPositionDirty = true; + Debug.LogError($"Non-owner Client-{m_CachedNetworkManager.LocalClientId} is being updated by network tick still!!!!"); + return; } - if (SyncPositionY && (Mathf.Abs(networkState.PositionY - position.y) >= positionThreshold.y || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) + // If we are nested and have already sent a state update this tick, then exit early (otherwise check for any changes in state) + if (IsNested && m_LocalAuthoritativeNetworkState.NetworkTick == CurrentTick) { - networkState.PositionY = position.y; - flagStates.SetHasPosition(Axis.Y, true); - isPositionDirty = true; + return; } - if (SyncPositionZ && (Mathf.Abs(networkState.PositionZ - position.z) >= positionThreshold.z || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) +#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.FlagStates.IsTeleportingNextFrame) { - networkState.PositionZ = position.z; - flagStates.SetHasPosition(Axis.Z, true); - isPositionDirty = true; + return; } +#endif + + // Update any changes to the transform based on the current state + OnUpdateAuthoritativeState(isCalledFromParent); +#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D + m_InternalCurrentPosition = m_LastStateTargetPosition = m_UseRigidbodyForMotion ? m_NetworkRigidbodyInternal.GetPosition() : GetSpaceRelativePosition(); + m_InternalCurrentRotation = m_UseRigidbodyForMotion ? m_NetworkRigidbodyInternal.GetRotation() : GetSpaceRelativeRotation(); + m_TargetRotation = m_InternalCurrentRotation.eulerAngles; +#else + m_InternalCurrentPosition = GetSpaceRelativePosition(); + m_LastStateTargetPosition = GetSpaceRelativePosition(); +#endif } - else if (SynchronizePosition) + else // If we are no longer authority, unsubscribe to the tick event { - // If we are teleporting then we can skip the delta threshold check - isPositionDirty = flagStates.IsTeleportingNextFrame || isAxisSync || forceState; - if (m_HalfFloatTargetTickOwnership > CurrentTick) - { - isPositionDirty = true; - } + DeregisterForTickUpdate(); + } + } + #endregion - // 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; - } - } - } + #region NON-AUTHORITY STATE UPDATE - // If the position is dirty or we are teleporting (which includes synchronization) - // then determine what parts of the NetworkDeltaPosition should be updated - if (isPositionDirty) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void UpdatePositionInterpolator(Vector3 position, double time, bool resetInterpolator = false) + { + if (!CanCommitToTransform) + { + if (InterpolatorIndex >= 0) { - // If we are not synchronizing the transform state for the first time - if (!isSynchronization) + var value = new float4(position.x, position.y, position.z, 0.0f); + if (resetInterpolator) { - // 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; - } + m_CachedNetworkManager.TransformStateManager.ResetTo(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Position, value); } - else // If synchronizing is set, then use the current full position value on the server side + else { - 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); + m_CachedNetworkManager.TransformStateManager.AddMeasurement(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Position, value, time); } - flagStates.HasPositionX = SyncPositionX; - flagStates.HasPositionY = SyncPositionY; - flagStates.HasPositionZ = SyncPositionZ; - flagStates.HasPositionChange = SyncPositionX || SyncPositionY || SyncPositionZ; - } - } - - if (!UseQuaternionSynchronization) - { - 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; + return; } - if (SyncRotAngleY && (Mathf.Abs(Mathf.DeltaAngle(networkState.RotAngleY, rotAngles.y)) >= rotationThreshold.y || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) + if (resetInterpolator) { - networkState.RotAngleY = rotAngles.y; - flagStates.SetHasRotation(Axis.Y, true); - isRotationDirty = true; + m_PositionInterpolator.AutoConvertTransformSpace = SwitchTransformSpaceWhenParented; + m_PositionInterpolator.InLocalSpace = InLocalSpace; + m_PositionInterpolator.ResetTo(transform.parent, position, time); } - - if (SyncRotAngleZ && (Mathf.Abs(Mathf.DeltaAngle(networkState.RotAngleZ, rotAngles.z)) >= rotationThreshold.z || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) + else { - networkState.RotAngleZ = rotAngles.z; - flagStates.SetHasRotation(Axis.Z, true); - isRotationDirty = true; + m_PositionInterpolator.AddMeasurement(transform.parent, position, time); } } - else if (SynchronizeRotation) + } + + /// + /// 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) { - // 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 value = new float4(rotation.x, rotation.y, rotation.z, rotation.w); + if (resetInterpolator) { - 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; - } - } + m_CachedNetworkManager.TransformStateManager.ResetTo(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Rotation, value); } - if (isRotationDirty) + else { - networkState.Rotation = rotation; - flagStates.MarkChanged(AxialType.Rotation, true); + m_CachedNetworkManager.TransformStateManager.AddMeasurement(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Rotation, value, time); } + return; } - // For scale, we need to check for parenting when synchronizing and/or teleporting (synchronization is always teleporting) - if (flagStates.IsTeleportingNextFrame) + if (resetInterpolator) { - // 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; - } + m_RotationInterpolator.AutoConvertTransformSpace = SwitchTransformSpaceWhenParented; + m_RotationInterpolator.InLocalSpace = InLocalSpace; + m_RotationInterpolator.ResetTo(CachedTransform.parent, rotation, time); } - - // Checking scale deltas when not synchronizing - if (!isSynchronization) + else { - 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); - } - } - } + m_RotationInterpolator.AddMeasurement(transform.parent, rotation, time); } - // Just apply the full local scale when synchronizing - else if (SynchronizeScale) + } + + /// + /// 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 localScale = CachedTransform.localScale; - if (!UseHalfFloatPrecision) + var value = new float4(scale.x, scale.y, scale.z, 0.0f); + if (resetInterpolator) { - - networkState.ScaleX = localScale.x; - networkState.ScaleY = localScale.y; - networkState.ScaleZ = localScale.z; + m_CachedNetworkManager.TransformStateManager.ResetTo(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Scale, value); } else { - networkState.Scale = localScale; + m_CachedNetworkManager.TransformStateManager.AddMeasurement(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Scale, value, time); } - flagStates.MarkChanged(AxialType.Scale, true); - isScaleDirty = true; + return; } - isDirty |= isPositionDirty || isRotationDirty || isScaleDirty; - if (isDirty) + if (resetInterpolator) { - // 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; - } + m_ScaleInterpolator.ResetTo(scale, time); + } + else + { + m_ScaleInterpolator.AddMeasurement(transform.parent, scale, time); } - - // 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; } /// - /// Authority subscribes to network tick events and will invoke - /// each network tick. + /// Handles converting a batch interpolated transform's state between transform spaces. /// - private void OnNetworkTick(bool isCalledFromParent = false) + /// + /// 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 not active, then ignore the update - if (!gameObject.activeInHierarchy) + if (InterpolatorIndex < 0 || previousParent == newParent) { return; } - // As long as we are still authority - if (CanCommitToTransform) + // Old space to world, then world to new space. + var pointTransform = float4x4.identity; + if (previousParent != null) { - if (m_CachedNetworkManager.DistributedAuthorityMode && !IsOwner) - { - Debug.LogError($"Non-owner Client-{m_CachedNetworkManager.LocalClientId} is being updated by network tick still!!!!"); - return; - } + pointTransform = previousParent.localToWorldMatrix; + } + if (newParent != null) + { + pointTransform = math.mul(newParent.worldToLocalMatrix, pointTransform); + } - // If we are nested and have already sent a state update this tick, then exit early (otherwise check for any changes in state) - if (IsNested && m_LocalAuthoritativeNetworkState.NetworkTick == CurrentTick) - { - return; - } + 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); + } -#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) - { - return; - } -#endif + m_CachedNetworkManager.TransformStateManager.ConvertInterpolationSpace(InterpolatorIndex, pointTransform, rotationTransform); + } - // Update any changes to the transform based on the current state - OnUpdateAuthoritativeState(isCalledFromParent); -#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D - m_InternalCurrentPosition = m_LastStateTargetPosition = m_UseRigidbodyForMotion ? m_NetworkRigidbodyInternal.GetPosition() : GetSpaceRelativePosition(); - m_InternalCurrentRotation = m_UseRigidbodyForMotion ? m_NetworkRigidbodyInternal.GetRotation() : GetSpaceRelativeRotation(); - m_TargetRotation = m_InternalCurrentRotation.eulerAngles; -#else - m_InternalCurrentPosition = GetSpaceRelativePosition(); - m_LastStateTargetPosition = GetSpaceRelativePosition(); -#endif + /// + /// 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; } - else // If we are no longer authority, unsubscribe to the tick event + 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) { - DeregisterForTickUpdate(); + var value = m_CachedNetworkManager.TransformStateManager.GetInterpolatedValue(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Position); + return new Vector3(value.x, value.y, value.z); } + return m_PositionInterpolator.GetInterpolatedValue(); } - #endregion - #region NON-AUTHORITY STATE UPDATE + /// + /// 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)] - internal void UpdatePositionInterpolator(Vector3 position, double time, bool resetInterpolator = false) + private Vector3 GetInterpolatedScale() { - if (!CanCommitToTransform) + if (InterpolatorIndex >= 0) { - if (resetInterpolator) - { - m_PositionInterpolator.AutoConvertTransformSpace = SwitchTransformSpaceWhenParented; - m_PositionInterpolator.InLocalSpace = InLocalSpace; - m_PositionInterpolator.ResetTo(transform.parent, position, time); - } - else - { - m_PositionInterpolator.AddMeasurement(transform.parent, position, time); - } + 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; @@ -2675,29 +2813,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 @@ -2728,7 +2880,7 @@ protected internal void ApplyAuthoritativeState() { if (SynchronizePosition) { - var interpolatedPosition = m_PositionInterpolator.GetInterpolatedValue(); + var interpolatedPosition = GetInterpolatedPosition(); if (UseHalfFloatPrecision) { adjustedPosition = interpolatedPosition; @@ -2745,11 +2897,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; } @@ -2758,7 +2910,7 @@ protected internal void ApplyAuthoritativeState() if (SynchronizeRotation) { - var interpolatedRotation = m_RotationInterpolator.GetInterpolatedValue(); + var interpolatedRotation = GetInterpolatedRotation(); if (UseQuaternionSynchronization) { adjustedRotation = interpolatedRotation; @@ -2827,7 +2979,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; } @@ -2846,7 +2998,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}"); } } @@ -2868,9 +3020,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; } @@ -2912,7 +3064,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; } @@ -2938,7 +3090,7 @@ protected internal void ApplyAuthoritativeState() /// private void ApplyTeleportingState(NetworkTransformState newState) { - if (!newState.IsTeleportingNextFrame) + if (!newState.FlagStates.IsTeleportingNextFrame) { return; } @@ -2949,13 +3101,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) { @@ -3069,7 +3219,7 @@ private void ApplyTeleportingState(NetworkTransformState newState) if (Interpolate) { - m_ScaleInterpolator.ResetTo(currentScale, sentTime); + UpdateScaleInterpolator(currentScale, sentTime, true); } } @@ -3120,9 +3270,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); } } @@ -3165,13 +3313,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; @@ -3268,7 +3416,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 @@ -3303,7 +3451,7 @@ internal void ApplyUpdatedState(NetworkTransformState newState) currentRotation.eulerAngles = currentEulerAngles; } - m_RotationInterpolator.AddMeasurement(transform.parent, currentRotation, sentTime); + UpdateRotationInterpolator(currentRotation, sentTime); } } @@ -3440,45 +3588,67 @@ private void AxisChangedDeltaPositionCheck() // Only if the synchronization of an axis is turned on do we need to // check if a teleport is required due to the delta from the last known // to the currently known axis value exceeds MaxDeltaBeforeAdjustment. + // Accumulated across the axes: any one of them being out of range is enough, and an + // axis that is in range must not clear what an earlier one found. if (SyncPositionX && SyncPositionX != synAxis.x) { - needsToTeleport = Mathf.Abs(relativePosition.x - positionState.x) >= NetworkDeltaPosition.MaxDeltaBeforeAdjustment; + needsToTeleport |= Mathf.Abs(relativePosition.x - positionState.x) >= NetworkDeltaPosition.MaxDeltaBeforeAdjustment; } if (SyncPositionY && SyncPositionY != synAxis.y) { - needsToTeleport = Mathf.Abs(relativePosition.y - positionState.y) >= NetworkDeltaPosition.MaxDeltaBeforeAdjustment; + needsToTeleport |= Mathf.Abs(relativePosition.y - positionState.y) >= NetworkDeltaPosition.MaxDeltaBeforeAdjustment; } if (SyncPositionZ && SyncPositionZ != synAxis.z) { - needsToTeleport = Mathf.Abs(relativePosition.z - positionState.z) >= NetworkDeltaPosition.MaxDeltaBeforeAdjustment; + needsToTeleport |= Mathf.Abs(relativePosition.z - positionState.z) >= NetworkDeltaPosition.MaxDeltaBeforeAdjustment; } - // If needed, force a teleport as the delta is outside of the valid delta boundary - m_LocalAuthoritativeNetworkState.FlagStates.IsTeleportingNextFrame = needsToTeleport; + // If needed, force a teleport as the delta is outside of the valid delta boundary. Or-ed + // in rather than assigned, so a teleport already pending for this tick survives. + m_LocalAuthoritativeNetworkState.FlagStates.IsTeleportingNextFrame |= needsToTeleport; } } } + /// + /// Clears the previous tick's change flags so the next delta check starts from a clean bitset. + /// + /// + /// Skipped while an explicit set or a teleport is pending, since both carry state the next update + /// still has to send.

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

+ /// The job is completed within this call as opposed to being left in flight: the state update has to + /// be sent on the tick it was detected on, so there is no other work to overlap. + ///
+ internal void RunDeltaCheck() + { + var count = GetCount(); + if (count == 0) + { + return; + } + + // Gather what the job cannot resolve for itself. + 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); + if (instance.StateManagerIndex == i) + { + Entries[i] = entry; + continue; + } + + // The instance deregistered while applying, which swapped the last registered instance and + // its already completed entry into this slot. Hold the index so that instance is applied on + // the tick it was detected on as opposed to being skipped by the increment. + i--; + } + } + + public void Dispose() + { + if (m_Disposed) + { + return; + } + m_Disposed = true; + + // Clear the cached index on anything still registered so a late deregister is a no-op as opposed + // to indexing into a disposed collection. + for (int i = 0; i < m_Instances.Count; i++) + { + if (m_Instances[i] != null) + { + m_Instances[i].StateManagerIndex = -1; + } + } + m_Instances.Clear(); + + for (int i = 0; i < m_NonAuthorityInstances.Count; i++) + { + if (m_NonAuthorityInstances[i] != null) + { + m_NonAuthorityInstances[i].InterpolatorIndex = -1; + } + } + m_NonAuthorityInstances.Clear(); + Handles.Clear(); + + if (m_Created) + { + if (InterpolationEntries.IsCreated) + { + InterpolationEntries.Dispose(); + } + if (BufferedItems.IsCreated) + { + BufferedItems.Dispose(); + } + if (Entries.IsCreated) + { + Entries.Dispose(); + } + if (TransformAccess.isCreated) + { + TransformAccess.Dispose(); + } + m_Created = false; + } + } + } +} diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs.meta b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs.meta new file mode 100644 index 0000000000..1b07f98434 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e28ff1ecaaa0f2e4b9cc4c8127c5128d \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Runtime/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..10ec15b362 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs @@ -0,0 +1,139 @@ +using System.Collections.Generic; + +namespace Unity.Netcode.Components +{ + /// + /// Allocates the ushort handles that identify instances in mode. + /// + /// + /// A batched state update names its instance by a handle. The alternative is a + /// and pair, + /// which costs more once bit packed and grows as object ids climb.
+ /// Only the instance writing synchronization data allocates: the server, or the session owner in a + /// distributed authority topology. Everyone else is told the handle at spawn.
+ /// The owner does not allocate. A handle has to outlive ownership changes.
+ /// Freed handles are held for before being reissued. An unreliable + /// state update naming a handle can still be in flight when its instance despawns. + ///
+ 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..5b07c55d5d 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs @@ -7,6 +7,40 @@ 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). + /// + /// + /// The two modes can not be cross pollinated on a per instance basis. As such, it is a per session setting + /// that applies to 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, + // TODO-FixMe: + // BEFORE-6000.7 + // Batching applies to a client-server session only. A distributed authority session sends every state + // update per instance regardless of this setting. Get this working for DA mode. DA mode still benefits + // from most of the bandwidth optimizations, but doesn't benefit completely as batched mode needs some + // Rust server adjustments. + } + /// /// The configuration object used to start server, client and hosts /// @@ -45,6 +79,29 @@ public class NetworkConfig [SerializeField] public NetworkPrefabs Prefabs = new NetworkPrefabs(); + /// + /// Determines how instances detect and synchronize their state. + /// + /// + /// The two modes are not wire compatible, so this is part of the connection configuration hash and every + /// peer in a session has to agree on it. Changing it while a session is running has no effect: the value + /// is captured into when the starts and + /// the new value applies to the next session. + /// + [Tooltip("Determines how NetworkTransform instances detect and synchronize their state. Batched detects changes for all instances within a job and sends them as a single message per tick. Every peer in a session must use the same mode.")] + [SerializeField] + public TransformSyncModes TransformSyncMode = TransformSyncModes.PerInstance; + + /// + /// The mode the current session is actually running with, captured from + /// when the starts. + /// + /// + /// Everything on the sending and receiving paths reads this and not the authored value, so a mid-session + /// write to cannot leave instances registered under one mode while new + /// ones use the other, and cannot shift the connection hash out from under an in progress session. + /// + internal TransformSyncModes ActiveTransformSyncMode; /// /// The tickrate of network ticks. This value controls how often netcode runs user code and sends out data. @@ -354,6 +411,10 @@ 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. This is the mode the session + // started with, so a mid-session edit cannot start rejecting late joiners. + writer.WriteValueSafe((byte)ActiveTransformSyncMode); if (cache) { diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConstants.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConstants.cs index b9eed28790..bc862c8fe2 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConstants.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConstants.cs @@ -5,6 +5,6 @@ namespace Unity.Netcode /// internal static class NetworkConstants { - internal const string PROTOCOL_VERSION = "15.0.0"; + internal const string PROTOCOL_VERSION = "15.1.0"; } } diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index 0a3fee02d7..7d8167ebdd 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) { @@ -1221,6 +1253,15 @@ internal void Initialize(bool server) UpdateTopology(); + // Capture the transform synchronization mode for the session about to start. Everything downstream + // reads the captured value, so a project can expose NetworkConfig.TransformSyncMode in its own pre + // session UI without a mid-session write splitting a running session across both modes. + NetworkConfig.ActiveTransformSyncMode = NetworkConfig.TransformSyncMode; + + // The captured mode is part of the connection configuration hash, so drop anything GetConfig + // cached before the session started or the server compares connecting clients against it. + NetworkConfig.ClearConfigHash(); + // Always create a default session config when starting a NetworkManager instance if (DistributedAuthorityMode) { @@ -1873,6 +1914,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/Editor/NetworkDeltaPositionTests.cs b/com.unity.netcode.gameobjects/Tests/Editor/NetworkDeltaPositionTests.cs new file mode 100644 index 0000000000..ccab36861f --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Editor/NetworkDeltaPositionTests.cs @@ -0,0 +1,380 @@ +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; +using Unity.Netcode.Components; +using UnityEngine; + +namespace Unity.Netcode.GameObjects.EditorTests +{ + /// + /// Branch coverage for 's encoding math. + /// + /// + /// Separate from NetworkTransformHalfFloatPrecisionTests because none of this needs a session, and + /// that fixture would run it twice over two topologies. + ///

+ /// A value that is exactly representable as a half float carries no rounding loss, so a test built on + /// one cannot observe the behavior checked here and will pass against broken code. Keep the constants + /// below off the lattice, and derive expected encodings with rather than + /// writing them out as literals. + ///
+ internal class NetworkDeltaPositionTests + { + private const int k_Tick = 100; + + // Lossy as a half float, and two of them still fit under the collapse threshold. + private const float k_LossyStep = 0.7f; + + // Past the threshold and exactly representable, so the collapse cannot hinge on rounding. + private const float k_CollapsingStep = NetworkDeltaPosition.MaxDeltaBeforeAdjustment + 0.5f; + + // Off the half float lattice on every axis, so each conversion leaves rounding loss behind. + private static readonly Vector3 k_Base = new Vector3(30.0007f, -12.0003f, 5.0009f); + + private static Vector3 Offset(float amount) + { + return k_Base + new Vector3(amount, amount, amount); + } + + // The transmitted form, so comparisons are against what actually goes on the wire. + private static ushort[] Encoded(NetworkDeltaPosition deltaPosition) + { + return new[] + { + deltaPosition.HalfVector3.Axis.x.value, + deltaPosition.HalfVector3.Axis.y.value, + deltaPosition.HalfVector3.Axis.z.value, + }; + } + + [Test] + public void ConstructorOverloadsProduceTheSameInitialState() + { + var position = k_Base; + var allAxes = math.bool3(true); + + var instances = new[] + { + new NetworkDeltaPosition(position, k_Tick), + new NetworkDeltaPosition(position, k_Tick, allAxes), + new NetworkDeltaPosition(position.x, position.y, position.z, k_Tick), + new NetworkDeltaPosition(position.x, position.y, position.z, k_Tick, allAxes), + }; + + foreach (var instance in instances) + { + Assert.AreEqual(position, instance.GetCurrentBasePosition(), "The base position should be where the object started."); + Assert.AreEqual(Vector3.zero, instance.GetDeltaPosition(), "Nothing has moved yet, so there is no delta."); + Assert.AreEqual(Vector3.zero, instance.PrecisionLossDelta, "No conversion has lost anything yet."); + Assert.AreEqual(k_Tick, instance.NetworkTick, "The construction tick should be recorded."); + Assert.IsFalse(instance.CollapsedDeltaIntoBase, "A zero delta cannot have collapsed."); + Assert.IsFalse(instance.SynchronizeBase, "The base is only synchronized explicitly."); + Assert.AreEqual(allAxes, instance.HalfVector3.AxisToSynchronize, "All axes should be synchronized by default."); + } + } + + [Test] + public void AccessorsReportTheUnderlyingState() + { + var deltaPosition = new NetworkDeltaPosition(k_Base, k_Tick); + var moved = Offset(k_LossyStep); + deltaPosition.UpdateFrom(ref moved, k_Tick + 1); + + Assert.AreEqual(deltaPosition.CurrentBasePosition, deltaPosition.GetCurrentBasePosition()); + Assert.AreEqual(deltaPosition.DeltaPosition, deltaPosition.GetDeltaPosition()); + Assert.AreEqual(deltaPosition.HalfDeltaConvertedBack, deltaPosition.GetConvertedDelta()); + Assert.AreEqual(deltaPosition.CurrentBasePosition + deltaPosition.DeltaPosition, deltaPosition.GetFullPosition()); + + Assert.AreNotEqual(deltaPosition.GetDeltaPosition().x, deltaPosition.GetConvertedDelta().x, + "The converted delta is the lossy one and should not match the full precision delta."); + } + + [Test] + public void MovingFoldsThePreviousRoundingLossBackIn() + { + var deltaPosition = new NetworkDeltaPosition(k_Base, k_Tick); + + var firstMove = Offset(k_LossyStep); + deltaPosition.UpdateFrom(ref firstMove, k_Tick + 1); + + var carriedLoss = deltaPosition.PrecisionLossDelta; + Assert.AreNotEqual(0.0f, carriedLoss.x, "A step off the lattice has to leave rounding loss behind."); + + var basePosition = deltaPosition.GetCurrentBasePosition(); + var secondMove = Offset(k_LossyStep * 2.0f); + deltaPosition.UpdateFrom(ref secondMove, k_Tick + 2); + + Assert.IsFalse(deltaPosition.CollapsedDeltaIntoBase, + "Both steps together have to stay under the collapse threshold, or the delta asserted on below is reset to zero."); + + // Folding the loss in is what keeps the average position accurate instead of drifting by a + // fraction of a step per send. + var rawDelta = secondMove.x - basePosition.x; + Assert.AreEqual(rawDelta + carriedLoss.x, deltaPosition.GetDeltaPosition().x, 1e-7f, + "The delta being sent should have the carried rounding loss added to it."); + Assert.AreNotEqual(math.half(rawDelta).value, deltaPosition.HalfVector3.Axis.x.value, + "Folding the loss in has to change the transmitted value, or it would have no effect."); + Assert.AreNotEqual(carriedLoss.x, deltaPosition.PrecisionLossDelta.x, + "The carried loss should be recomputed from the conversion that just happened."); + } + + [Test] + public void StandingStillDoesNotChangeWhatIsSent() + { + var deltaPosition = new NetworkDeltaPosition(k_Base, k_Tick); + + // Arrive off the lattice, which is where a settling object ends up. + var arrived = Offset(k_LossyStep); + deltaPosition.UpdateFrom(ref arrived, k_Tick + 1); + + var encodedOnArrival = Encoded(deltaPosition); + var lossOnArrival = deltaPosition.PrecisionLossDelta; + Assert.AreNotEqual(0.0f, lossOnArrival.x, "The arrival conversion has to leave rounding loss behind."); + + var currentTick = k_Tick + 2; + var maxTick = k_Tick + 5; + + // Folding the loss back in while stationary is what made resting objects jitter. + while (currentTick <= maxTick) + { + deltaPosition.UpdateFrom(ref arrived, currentTick); + + Assert.AreEqual(encodedOnArrival, Encoded(deltaPosition), + $"The transmitted delta changed on tick {currentTick} while the position did not move."); + Assert.AreEqual(lossOnArrival, deltaPosition.PrecisionLossDelta, + $"The carried loss should be untouched on tick {currentTick} so it still applies once movement resumes."); + currentTick++; + } + } + + [Test] + public void DeltaCollapsesIntoTheBaseAtTheThreshold() + { + var deltaPosition = new NetworkDeltaPosition(k_Base, k_Tick); + var originalBase = deltaPosition.GetCurrentBasePosition(); + + var moved = Offset(k_CollapsingStep); + deltaPosition.UpdateFrom(ref moved, k_Tick + 1); + + Assert.IsTrue(deltaPosition.CollapsedDeltaIntoBase, "A delta at the threshold should have been folded into the base."); + Assert.AreEqual(0.0f, deltaPosition.GetDeltaPosition().x, "The delta should be reset once it is folded in."); + Assert.AreEqual(0.0f, deltaPosition.GetConvertedDelta().x, "The converted delta should be reset along with it."); + Assert.AreNotEqual(originalBase.x, deltaPosition.GetCurrentBasePosition().x, "The base should have absorbed the delta."); + Assert.AreEqual(moved.x, deltaPosition.GetFullPosition().x, 1e-3f, + "Folding the delta into the base must not move the object it describes."); + } + + [Test] + public void UnsynchronizedAxesAreLeftUntouched() + { + var deltaPosition = new NetworkDeltaPosition(k_Base, k_Tick, math.bool3(true, false, false)); + + var moved = Offset(k_LossyStep); + deltaPosition.UpdateFrom(ref moved, k_Tick + 1); + + Assert.AreNotEqual(0.0f, deltaPosition.GetDeltaPosition().x, "The synchronized axis should track the movement."); + Assert.AreEqual(0.0f, deltaPosition.GetDeltaPosition().y, "An unsynchronized axis should not produce a delta."); + Assert.AreEqual(0.0f, deltaPosition.GetDeltaPosition().z, "An unsynchronized axis should not produce a delta."); + + // A stale reference here would break the comparison if the axis is synchronized later. + Assert.AreEqual(moved.x, deltaPosition.PreviousPosition.x, "The synchronized axis should record where it was sent from."); + Assert.AreEqual(k_Base.y, deltaPosition.PreviousPosition.y, "An unsynchronized axis should keep its original reference."); + Assert.AreEqual(k_Base.z, deltaPosition.PreviousPosition.z, "An unsynchronized axis should keep its original reference."); + } + + [Test] + public void DecodingOnTheSameTickDoesNotReadTheEncodedAxes() + { + var deltaPosition = new NetworkDeltaPosition(k_Base, k_Tick); + var moved = Offset(k_LossyStep); + deltaPosition.UpdateFrom(ref moved, k_Tick + 1); + + var expected = deltaPosition.GetFullPosition(); + + // Overwriting the encoded axes proves this path returns the already-decoded value rather than + // decoding again, which would apply the same delta twice. + deltaPosition.HalfVector3.Axis = math.half3(new float3(1.9f, 1.9f, 1.9f)); + + Assert.AreEqual(expected, deltaPosition.ToVector3(k_Tick + 1), + "Decoding the tick that was just written should return the position already held."); + } + + [Test] + public void DecodingANewTickAppliesTheDelta() + { + var authority = new NetworkDeltaPosition(k_Base, k_Tick); + var moved = Offset(k_LossyStep); + authority.UpdateFrom(ref moved, k_Tick + 1); + + var receiver = new NetworkDeltaPosition(k_Base, k_Tick) + { + HalfVector3 = authority.HalfVector3, + }; + + var decoded = receiver.ToVector3(k_Tick + 1); + + Assert.AreEqual(authority.GetConvertedDelta().x, receiver.GetDeltaPosition().x, + "The receiver should decode the same delta the authority encoded."); + Assert.AreEqual(k_Base.x + authority.GetConvertedDelta().x, decoded.x, 1e-4f, + "The decoded position should be the base plus the transmitted delta."); + } + + [Test] + public void DecodingCollapsesIntoTheBaseAtTheThreshold() + { + var authority = new NetworkDeltaPosition(k_Base, k_Tick); + var moved = Offset(k_CollapsingStep); + authority.UpdateFrom(ref moved, k_Tick + 1); + + // The send side folds the delta into its own base but leaves the encoded axes holding it, so the + // receiving side has to perform the same fold to end up on the same base. + var receiver = new NetworkDeltaPosition(k_Base, k_Tick) + { + HalfVector3 = authority.HalfVector3, + }; + + var decoded = receiver.ToVector3(k_Tick + 1); + + Assert.AreEqual(0.0f, receiver.GetDeltaPosition().x, "The delta should be reset once it is folded into the base."); + Assert.AreEqual(0, receiver.HalfVector3.Axis.x.value, "The encoded axis should be cleared along with it."); + Assert.AreEqual(authority.GetCurrentBasePosition().x, receiver.GetCurrentBasePosition().x, 1e-4f, + "Both sides must end up on the same base position or they will disagree from here on."); + Assert.AreEqual(moved.x, decoded.x, 1e-3f, "Folding the delta into the base must not move the object."); + } + + [Test] + public void DecodingIgnoresUnsynchronizedAxes() + { + var axesToSynchronize = math.bool3(true, false, false); + var authority = new NetworkDeltaPosition(k_Base, k_Tick, axesToSynchronize); + var moved = Offset(k_LossyStep); + authority.UpdateFrom(ref moved, k_Tick + 1); + + var receiver = new NetworkDeltaPosition(k_Base, k_Tick, axesToSynchronize) + { + HalfVector3 = authority.HalfVector3, + }; + + var decoded = receiver.ToVector3(k_Tick + 1); + + Assert.AreNotEqual(k_Base.x, decoded.x, "The synchronized axis should have moved."); + Assert.AreEqual(k_Base.y, decoded.y, "An unsynchronized axis should stay at the base value."); + Assert.AreEqual(k_Base.z, decoded.z, "An unsynchronized axis should stay at the base value."); + } + + [Test] + public void HalfDeltaRoundTripsWhenTheBaseIsNotSynchronized() + { + var source = new NetworkDeltaPosition(k_Base, k_Tick); + var moved = Offset(k_LossyStep); + source.UpdateFrom(ref moved, k_Tick + 1); + + var result = RoundTrip(source, synchronizeBase: false); + + Assert.AreEqual(Encoded(source), Encoded(result), "The encoded axes should survive the round trip."); + + // Only the half float axes go on the wire here, so the receiver keeps whatever base it had. + Assert.AreEqual(Vector3.zero, result.GetCurrentBasePosition(), "The base should not be transmitted in this mode."); + } + + [Test] + public void FullPrecisionRoundTripsWhenTheBaseIsSynchronized() + { + var source = new NetworkDeltaPosition(k_Base, k_Tick); + var moved = Offset(k_LossyStep); + source.UpdateFrom(ref moved, k_Tick + 1); + + var result = RoundTrip(source, synchronizeBase: true); + + // Synchronizing sends both values at full precision, so this path has to be lossless. + Assert.AreEqual(source.GetDeltaPosition(), result.GetDeltaPosition(), "The delta should round trip exactly."); + Assert.AreEqual(source.GetCurrentBasePosition(), result.GetCurrentBasePosition(), "The base should round trip exactly."); + } + + [Test] + public void QuantumIsTheSmallestChangeTheEncodingCanSee() + { + // Exactly representable, so "one step away" is unambiguous. + var values = new[] { 0.5f, 1.0f, -1.0f, 2.0f, 300.0f, 1024.0f }; + + foreach (var value in values) + { + var quantum = NetworkDeltaPosition.HalfPrecisionQuantum(value); + Assert.Greater(quantum, 0.0f, $"The step size at {value} should be positive."); + + Assert.AreNotEqual(math.half(value).value, math.half(value + quantum).value, + $"A full step from {value} should encode differently, or it is not the step size."); + Assert.AreEqual(math.half(value).value, math.half(value + (quantum * 0.25f)).value, + $"A quarter step from {value} should encode identically, or the step size is too large."); + + // The lattice is symmetric about zero, which is why the sign is dropped. + Assert.AreEqual(quantum, NetworkDeltaPosition.HalfPrecisionQuantum(-value), + $"The step size at {value} and {-value} should be the same."); + } + } + + [Test] + public void QuantumIsGuardedAtTheTopOfTheRange() + { + // 70000f is the finite one: the conversion itself rounds to infinity, which reaches the guard + // by a different path than handing it an infinity outright. + var values = new[] + { + 65504.0f, -65504.0f, 70000.0f, + float.PositiveInfinity, float.NegativeInfinity, float.NaN, + }; + + foreach (var value in values) + { + Assert.AreEqual(NetworkDeltaPosition.MaxDeltaBeforeAdjustment, + NetworkDeltaPosition.HalfPrecisionQuantum(value), + $"{value} is at or past the largest finite half float and should fall back to the maximum delta."); + } + } + + [Test] + public void QuantumIsNeverNonFiniteOrZero() + { + // Why the guard exists: an infinite step size would make the "has it moved?" comparison in + // UpdateFrom false for every input, silently stopping the rounding loss from being applied. + var unguarded = Mathf.HalfToFloat(0x7BFF + 1) - Mathf.HalfToFloat(0x7BFF); + Assert.IsTrue(float.IsInfinity(unguarded) || float.IsNaN(unguarded), + "The unguarded computation at the top of the range should be non-finite, which is why the guard exists."); + + var values = new[] + { + 0.0f, float.Epsilon, 1e-7f, 0.5f, 1.0f, 100.0f, 65503.0f, 65504.0f, -65504.0f, 70000.0f, + float.PositiveInfinity, float.NegativeInfinity, float.NaN, + }; + + foreach (var value in values) + { + var quantum = NetworkDeltaPosition.HalfPrecisionQuantum(value); + Assert.IsFalse(float.IsNaN(quantum) || float.IsInfinity(quantum), $"The step size at {value} should be finite."); + Assert.Greater(quantum, 0.0f, $"The step size at {value} should be positive."); + } + } + + private static NetworkDeltaPosition RoundTrip(NetworkDeltaPosition source, bool synchronizeBase) + { + source.SynchronizeBase = synchronizeBase; + + using var writer = new FastBufferWriter(256, Allocator.Temp); + var writeSerializer = new BufferSerializer(new BufferSerializerWriter(writer)); + source.NetworkSerialize(writeSerializer); + + // Starts from a different state, so a value that failed to arrive shows up as a mismatch. + var result = new NetworkDeltaPosition(Vector3.zero, 0) + { + SynchronizeBase = synchronizeBase, + HalfVector3 = { AxisToSynchronize = source.HalfVector3.AxisToSynchronize }, + }; + + using var reader = new FastBufferReader(writer, Allocator.Temp); + var readSerializer = new BufferSerializer(new BufferSerializerReader(reader)); + result.NetworkSerialize(readSerializer); + + return result; + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Editor/NetworkDeltaPositionTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Editor/NetworkDeltaPositionTests.cs.meta new file mode 100644 index 0000000000..8ef2b73daf --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Editor/NetworkDeltaPositionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4c273f2393c37e64980677d134606e15 \ 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..82b005eb98 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NativeInterpolatorTests.cs @@ -0,0 +1,425 @@ +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 serves and the + /// native one serves , because the managed one cannot run inside a + /// job. Nothing structural keeps them from drifting apart. + /// + // 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 equivalent rather than exact. + /// + /// + /// Vector slerp is the one replacement 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 selects a different, equally + /// valid, euler representative, and the two then dampen toward different angles. + ///
+ 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, + }; + } + + 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)); + + 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)); + + 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); + + // 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 interpolator reset part way through a session still accepts the measurements that follow it. + /// + /// + /// The reset stamps its baseline with the local time, while the measurements that follow carry the + /// older tick they were authored on. See + /// .
+ /// This is the shape of an ownership transfer away from the local instance: the server under server + /// authority, the previous owner under owner authority. + ///
+ [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); + } + + // 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/NetworkTransformHalfFloatPrecisionTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatPrecisionTests.cs new file mode 100644 index 0000000000..de3843b7be --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatPrecisionTests.cs @@ -0,0 +1,232 @@ +using System.Collections; +using NUnit.Framework; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Validates that does not introduce motion of its own. + /// + /// + /// Both tests move the authority in one direction only and require the non-authority instance to follow + /// without ever moving backwards. Interpolation cannot overshoot, so any movement opposite to the direction + /// the authority moved came from how the position was encoded. + /// + [TestFixture(HostOrServer.Host)] + [TestFixture(HostOrServer.DAHost)] + internal class NetworkTransformHalfFloatPrecisionTests : IntegrationTestWithApproximation + { + protected override int NumberOfClients => 1; + + // Half float resolution gets coarser the further the object is from the base position established when it + // spawned, so it has to travel away from that base for the resolution to be worth testing. + private const float k_TravelDistance = 30.0f; + + private const float k_TravelStep = 1.5f; + + // Moves the object off a position that a half float can represent exactly, which leaves no rounding loss + // behind and so cannot show the problem being tested for. + private const float k_UnrepresentableOffset = 0.0007f; + + // Small enough per update that the encoding cannot represent the change on its own. + private const float k_CreepStep = 0.0005f; + + private const int k_CreepTicks = 60; + + // Frames sampled after the creep, while the last states sent are still being interpolated. + private const int k_SettleFrames = 30; + + // Frames sampled with nothing moving at all. + private const int k_StationaryFrames = 120; + + // Tolerated backwards movement, which is float noise only. Well below the roughly 1mm resolution. + private const float k_MonotonicEpsilon = 1e-5f; + + private GameObject m_TestPrefab; + private NetworkManager m_AuthorityNetworkManager; + private NetworkTransform m_AuthorityInstance; + private NetworkTransform m_NonAuthorityInstance; + + private float m_WorstRegression; + private float m_LastObserved; + + private int m_TicksApplied; + private float m_StepThisPhase; + + public NetworkTransformHalfFloatPrecisionTests(HostOrServer hostOrServer) : base(hostOrServer) + { + } + + protected override void OnServerAndClientsCreated() + { + m_TestPrefab = CreateNetworkObjectPrefab("HalfFloatObj"); + var networkTransform = m_TestPrefab.AddComponent(); + + networkTransform.UseHalfFloatPrecision = true; + networkTransform.Interpolate = true; + + // Lerp smoothing would filter out the movement being tested for. + networkTransform.PositionInterpolationType = NetworkTransform.InterpolationTypes.Lerp; + networkTransform.PositionLerpSmoothing = false; + + // No threshold, so the very small movements used below are actually sent. + networkTransform.PositionThreshold = 0.0f; + + networkTransform.SyncRotAngleX = false; + networkTransform.SyncRotAngleY = false; + networkTransform.SyncRotAngleZ = false; + networkTransform.SyncScaleX = false; + networkTransform.SyncScaleY = false; + networkTransform.SyncScaleZ = false; + + base.OnServerAndClientsCreated(); + } + + private bool NonAuthorityCaughtUp() + { + return Approximately(m_NonAuthorityInstance.transform.position, m_AuthorityInstance.transform.position); + } + + /// + /// Records any movement opposite to the direction the authority is moving. + /// + /// + /// Sampled once per frame rather than once per tick, since the position applied to the transform is what + /// needs to be checked. + /// + private void SampleForRegression() + { + var current = m_NonAuthorityInstance.transform.position.x; + m_WorstRegression = Mathf.Max(m_WorstRegression, m_LastObserved - current); + m_LastObserved = current; + } + + private void BeginSampling() + { + m_WorstRegression = 0.0f; + m_LastObserved = m_NonAuthorityInstance.transform.position.x; + } + + private void AssertNoRegression(string phase) + { + Assert.LessOrEqual(m_WorstRegression, k_MonotonicEpsilon, + $"[{phase}] {m_NonAuthorityInstance.NetworkManager.name} moved {m_WorstRegression} backwards along " + + $"X while the authority only ever moved forwards. Interpolation cannot overshoot, so this motion " + + $"was introduced by the half float position encoding rather than reproduced from the authority."); + } + + /// + /// Advances the authority one step per tick along +X. + /// + /// + /// Driven from the tick event so the position written is the one captured for that same tick. + /// + private void OnNetworkTick() + { + m_TicksApplied++; + var position = m_AuthorityInstance.transform.position; + position.x += m_StepThisPhase; + m_AuthorityInstance.transform.position = position; + } + + private IEnumerator DriveAuthority(float stepPerTick, int ticks) + { + m_TicksApplied = 0; + m_StepThisPhase = stepPerTick; + m_AuthorityNetworkManager.NetworkTickSystem.Tick += OnNetworkTick; + yield return WaitForConditionOrTimeOut(() => m_TicksApplied >= ticks); + m_AuthorityNetworkManager.NetworkTickSystem.Tick -= OnNetworkTick; + AssertOnTimeout($"Timed out waiting for {ticks} authority updates (applied {m_TicksApplied})."); + } + + /// + /// Spawns the test object and travels it away from the base position established when it spawned. + /// + private IEnumerator SpawnAndTravel() + { + m_AuthorityNetworkManager = GetAuthorityNetworkManager(); + m_AuthorityInstance = SpawnObject(m_TestPrefab, m_AuthorityNetworkManager).GetComponent(); + + yield return WaitForSpawnedOnAllOrTimeOut(m_AuthorityInstance.NetworkObject); + AssertOnTimeout($"Not all clients spawned {m_AuthorityInstance.name}!"); + + var nonAuthority = GetNonAuthorityNetworkManager(); + m_NonAuthorityInstance = nonAuthority.SpawnManager.SpawnedObjects[m_AuthorityInstance.NetworkObjectId].GetComponent(); + + yield return DriveAuthority(k_TravelStep, (int)(k_TravelDistance / k_TravelStep)); + } + + /// + /// Moves an object away from its base position and then moves it forward in very small steps, requiring + /// the non-authority instance to follow without ever moving backwards. + /// + /// An for the test coroutine. + [UnityTest] + public IEnumerator HalfFloatPrecisionDoesNotInvertMotion() + { + yield return SpawnAndTravel(); + + yield return WaitForConditionOrTimeOut(NonAuthorityCaughtUp); + AssertOnTimeout("The non-authority instance did not catch up to the authority after the travel phase."); + + BeginSampling(); + m_TicksApplied = 0; + m_StepThisPhase = k_CreepStep; + m_AuthorityNetworkManager.NetworkTickSystem.Tick += OnNetworkTick; + while (m_TicksApplied < k_CreepTicks) + { + SampleForRegression(); + yield return null; + } + m_AuthorityNetworkManager.NetworkTickSystem.Tick -= OnNetworkTick; + + // Keep sampling while the last sent states are still being interpolated. + for (var i = 0; i < k_SettleFrames; i++) + { + SampleForRegression(); + yield return null; + } + + AssertNoRegression("creep"); + + // Small movements still have to arrive rather than be discarded. + yield return WaitForConditionOrTimeOut(NonAuthorityCaughtUp); + AssertOnTimeout($"The non-authority instance did not converge on the authority position " + + $"{m_AuthorityInstance.transform.position} after creeping, which means slow motion is being " + + $"discarded rather than transmitted."); + } + + /// + /// Requires a stationary authority to produce a stationary non-authority. + /// + /// An for the test coroutine. + [UnityTest] + public IEnumerator HalfFloatPrecisionHoldsStillWhenStationary() + { + yield return SpawnAndTravel(); + + // A position that a half float happens to represent exactly leaves no rounding loss behind, and with + // no rounding loss there is nothing that could move the object. Offsetting by less than the encoding + // can represent guarantees there is some, which is the state a settling object is normally left in. + yield return DriveAuthority(k_UnrepresentableOffset, 1); + + yield return WaitForConditionOrTimeOut(NonAuthorityCaughtUp); + AssertOnTimeout("The non-authority instance did not catch up to the authority after the travel phase."); + + // Nothing moves for the rest of the test, so the last direction the authority moved was forwards. + // Checking for backwards movement rather than for drift from a starting point means the instance is + // still free to finish interpolating towards the authority without that counting against it. + BeginSampling(); + for (var i = 0; i < k_StationaryFrames; i++) + { + SampleForRegression(); + yield return null; + } + + AssertNoRegression("stationary"); + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatPrecisionTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatPrecisionTests.cs.meta new file mode 100644 index 0000000000..b380ca173d --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformHalfFloatPrecisionTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9130626724ce4dddcfcd533d630aa6b3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: 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..77b4312dd0 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs @@ -0,0 +1,365 @@ +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 Burst cannot compile.
+ /// Some are ports of engine code that is managed C#, and those are expected to match exactly.
+ /// The rest are mathematically equivalent, and cannot be verified as bit identical because the engine's + /// operation order is not observable. + ///
+ // 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. + /// + /// + /// Not worth closing: 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..bfdb74d58c --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformStateBaselineTests.cs @@ -0,0 +1,509 @@ +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. + /// + /// + /// To regenerate: run this test, copy the array literal it prints on failure into + /// , and confirm 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". + /// + 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|EB89F8E7", + "QuaternionSync.Compressed|10|A86AAC6C", + "QuaternionSync.HalfFloat|14|2629A22E", + "QuaternionSync.Teleport|22|51DF412A", + "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, + // Literal components rather than Quaternion.Euler of the angles above: Euler goes through + // native trig, which lands a ULP apart on Linux against Windows, and the uncompressed + // quaternion cases put those bytes on the wire verbatim. (1, 2, 2, 4) / 5 is unit length and + // has a unique largest component for the smallest three compression path. + Rotation = new Quaternion(0.2f, 0.4f, 0.4f, 0.8f), + 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 proves the bytes did not change. This proves they still round trip, so a baseline + /// regenerated against a broken serializer is not silently 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, and + /// takes the state by value and calls it on that copy. The flag reaches the + /// bytes and the state read back from them, but never the source state the case holds. + /// + 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/NetworkTransformSyncModeConfigurationTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeConfigurationTests.cs new file mode 100644 index 0000000000..2f227552bf --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeConfigurationTests.cs @@ -0,0 +1,117 @@ +using System.Collections; +using NUnit.Framework; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Covers the authored reaching the runtime, and staying + /// fixed for the duration of a session. + /// + /// + /// Every other fixture reaches the mode through the same assignment the harness makes, so none of them + /// can see a delivery path that never ran. + /// + // These tests do not need to run against the Rust server, and batching is client-server only. + [IgnoreIfServiceEnvironmentVariableSet] + [TestFixture(TransformSyncModes.PerInstance)] + [TestFixture(TransformSyncModes.Batched)] + internal class NetworkTransformSyncModeConfigurationTests : NetcodeIntegrationTest + { + protected override int NumberOfClients => 1; + + private readonly TransformSyncModes m_SyncMode; + private GameObject m_MoverPrefab; + + public NetworkTransformSyncModeConfigurationTests(TransformSyncModes syncMode) + { + m_SyncMode = syncMode; + } + + internal override TransformSyncModes OnGetSyncMode() + { + return m_SyncMode; + } + + protected override void OnServerAndClientsCreated() + { + m_MoverPrefab = CreateNetworkObjectPrefab("SyncModeMover"); + var networkTransform = m_MoverPrefab.AddComponent(); + networkTransform.AuthorityMode = NetworkTransform.AuthorityModes.Server; + + base.OnServerAndClientsCreated(); + } + + private NetworkTransform SpawnMover() + { + var instance = Object.Instantiate(m_MoverPrefab); + var networkObject = instance.GetComponent(); + networkObject.NetworkManagerOwner = m_ServerNetworkManager; + networkObject.Spawn(); + return networkObject.GetComponent(); + } + + /// + /// A batched instance is registered with the and a per + /// instance one is not, so the index is the observable proof of which path an instance took. + /// + private bool IsRegisteredForBatching(NetworkTransform networkTransform) + { + return networkTransform.StateManagerIndex >= 0; + } + + /// + /// The authored mode has to reach every and route the instances that + /// spawn under it. + /// + [UnityTest] + public IEnumerator AuthoredModeReachesTheRuntime() + { + foreach (var networkManager in m_NetworkManagers) + { + Assert.AreEqual(m_SyncMode, networkManager.NetworkConfig.ActiveTransformSyncMode, + $"{networkManager.name} started with {networkManager.NetworkConfig.ActiveTransformSyncMode} rather than the authored {m_SyncMode}!"); + } + + var mover = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => IsRegisteredForBatching(mover) == (m_SyncMode == TransformSyncModes.Batched)); + AssertOnTimeout($"Authority instance has StateManagerIndex {mover.StateManagerIndex} under {m_SyncMode}!"); + } + + /// + /// Writing the mode while a session is running applies to the next session, not this one. + /// + [UnityTest] + public IEnumerator ModeChangedDuringASessionDoesNotAffectIt() + { + var alreadySpawned = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => IsRegisteredForBatching(alreadySpawned) == (m_SyncMode == TransformSyncModes.Batched)); + AssertOnTimeout($"Authority instance has StateManagerIndex {alreadySpawned.StateManagerIndex} under {m_SyncMode}!"); + + var configHash = m_ServerNetworkManager.NetworkConfig.GetConfig(false); + var otherMode = m_SyncMode == TransformSyncModes.Batched ? TransformSyncModes.PerInstance : TransformSyncModes.Batched; + + foreach (var networkManager in m_NetworkManagers) + { + networkManager.NetworkConfig.TransformSyncMode = otherMode; + } + + foreach (var networkManager in m_NetworkManagers) + { + Assert.AreEqual(m_SyncMode, networkManager.NetworkConfig.ActiveTransformSyncMode, + $"{networkManager.name} changed to {networkManager.NetworkConfig.ActiveTransformSyncMode} while its session was running!"); + } + + Assert.AreEqual(configHash, m_ServerNetworkManager.NetworkConfig.GetConfig(false), + "The connection configuration hash changed while the session was running!"); + + // An instance spawning after the write still follows the mode the session started with. + var afterChange = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => IsRegisteredForBatching(afterChange) == (m_SyncMode == TransformSyncModes.Batched)); + AssertOnTimeout($"An instance spawned after the change has StateManagerIndex {afterChange.StateManagerIndex} under {m_SyncMode}!"); + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeConfigurationTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeConfigurationTests.cs.meta new file mode 100644 index 0000000000..f32cc5eb82 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeConfigurationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5a29c333b758467a92c5f3cd3ad3b2e6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs new file mode 100644 index 0000000000..2fd27db636 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs @@ -0,0 +1,487 @@ +using System.Collections; +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Drives the same scenarios through both and asserts that every + /// non-authority instance ends up where the authority is. + /// + /// + /// The two modes share no wire format, no send path, and no interpolator.
+ /// The comparison is the observable outcome rather than the bytes, which legitimately differ. + ///
+ // These tests do not need to run against the Rust server. + [IgnoreIfServiceEnvironmentVariableSet] + [TestFixture(TransformSyncModes.PerInstance, NetworkTransform.AuthorityModes.Server)] + [TestFixture(TransformSyncModes.PerInstance, NetworkTransform.AuthorityModes.Owner)] + [TestFixture(TransformSyncModes.Batched, NetworkTransform.AuthorityModes.Server)] + [TestFixture(TransformSyncModes.Batched, NetworkTransform.AuthorityModes.Owner)] + internal class NetworkTransformSyncModeParityTests : IntegrationTestWithApproximation + { + protected override int NumberOfClients => 3; + + private readonly TransformSyncModes m_SyncMode; + private readonly NetworkTransform.AuthorityModes m_AuthorityMode; + + private GameObject m_MoverPrefab; + private GameObject m_HalfFloatMoverPrefab; + private readonly List m_SpawnedMovers = new List(); + + public NetworkTransformSyncModeParityTests(TransformSyncModes syncMode, NetworkTransform.AuthorityModes authorityMode) + { + m_SyncMode = syncMode; + m_AuthorityMode = authorityMode; + } + + internal override TransformSyncModes OnGetSyncMode() + { + return m_SyncMode; + } + + /// + /// Records whether any state update it received carried the teleport flag. + /// + /// + /// Interpolation reaches the same position either way, so convergence does not distinguish a teleport + /// from a delta. The flag is the only observable difference. + /// + internal class ParityMover : NetworkTransform + { + internal bool ReceivedTeleport; + + internal void ClearReceived() + { + ReceivedTeleport = false; + } + + protected override void OnNetworkTransformStateUpdated(ref NetworkTransformState oldState, ref NetworkTransformState newState) + { + ReceivedTeleport |= newState.IsTeleportingNextFrame; + base.OnNetworkTransformStateUpdated(ref oldState, ref newState); + } + } + + protected override void OnServerAndClientsCreated() + { + m_MoverPrefab = CreateNetworkObjectPrefab("ParityMover"); + var networkTransform = m_MoverPrefab.AddComponent(); + networkTransform.AuthorityMode = m_AuthorityMode; + networkTransform.Interpolate = true; + + // Half float position is a separate prefab rather than a setting flipped after spawning, because + // the delta position baseline is established during synchronization. + m_HalfFloatMoverPrefab = CreateNetworkObjectPrefab("HalfFloatParityMover"); + var halfFloatTransform = m_HalfFloatMoverPrefab.AddComponent(); + halfFloatTransform.AuthorityMode = m_AuthorityMode; + halfFloatTransform.Interpolate = true; + halfFloatTransform.UseHalfFloatPrecision = true; + + base.OnServerAndClientsCreated(); + } + + protected override IEnumerator OnTearDown() + { + m_SpawnedMovers.Clear(); + return base.OnTearDown(); + } + + private NetworkObject SpawnMover(ulong ownerClientId = NetworkManager.ServerClientId, GameObject prefab = null) + { + var instance = Object.Instantiate(prefab ?? m_MoverPrefab); + var networkObject = instance.GetComponent(); + networkObject.NetworkManagerOwner = m_ServerNetworkManager; + networkObject.SpawnWithOwnership(ownerClientId); + m_SpawnedMovers.Add(networkObject); + return networkObject; + } + + /// + /// The instance that is allowed to move the transform, which depends on the authority mode. + /// + private NetworkTransform GetMotionAuthorityInstance(NetworkObject serverSide) + { + if (m_AuthorityMode == NetworkTransform.AuthorityModes.Server || serverSide.OwnerClientId == NetworkManager.ServerClientId) + { + return serverSide.GetComponent(); + } + + // Owner authoritative and owned by a client, so the owning client's clone drives it. It can be + // absent while a spawn or an ownership change is still propagating. + var owner = GetNetworkManagerByClientId(serverSide.OwnerClientId); + if (owner == null || !owner.SpawnManager.SpawnedObjects.TryGetValue(serverSide.NetworkObjectId, out var clone)) + { + return null; + } + return clone.GetComponent(); + } + + /// + /// Whether the instance that should be driving the transform has actually been told it has authority. + /// + /// + /// The server side has ownership applied synchronously. The owning client + /// only learns of it a round trip later. Moving the transform before then is a non-authority write: + /// interpolation discards it and nothing is sent. + /// + private bool MotionAuthorityIsEstablished(NetworkObject serverSide) + { + var authority = GetMotionAuthorityInstance(serverSide); + return authority != null && authority.CanCommitToTransform; + } + + private NetworkManager GetNetworkManagerByClientId(ulong clientId) + { + if (clientId == NetworkManager.ServerClientId) + { + return m_ServerNetworkManager; + } + foreach (var client in m_ClientNetworkManagers) + { + if (client.LocalClientId == clientId) + { + return client; + } + } + Assert.Fail($"No {nameof(NetworkManager)} for client {clientId}!"); + return null; + } + + private bool AllObserversMatch(NetworkObject serverSide, Vector3 expectedPosition, IReadOnlyList expectedObservers) + { + foreach (var manager in expectedObservers) + { + if (!manager.SpawnManager.SpawnedObjects.TryGetValue(serverSide.NetworkObjectId, out var clone)) + { + return false; + } + if (!Approximately(clone.transform.position, expectedPosition)) + { + return false; + } + } + return true; + } + + /// + /// Every instance, not just the server side one, reports the expected owner. + /// + private bool AllInstancesAgreeOnOwner(NetworkObject serverSide, ulong expectedOwner) + { + foreach (var manager in m_NetworkManagers) + { + if (!manager.SpawnManager.SpawnedObjects.TryGetValue(serverSide.NetworkObjectId, out var clone)) + { + return false; + } + if (clone.OwnerClientId != expectedOwner) + { + return false; + } + } + return true; + } + + private IEnumerator MoveAndConverge(NetworkObject serverSide, Vector3 target, IReadOnlyList observers) + { + yield return WaitForConditionOrTimeOut(() => MotionAuthorityIsEstablished(serverSide)); + AssertOnTimeout($"[{m_SyncMode}][{m_AuthorityMode}] The motion authority instance never gained authority!"); + + var authority = GetMotionAuthorityInstance(serverSide); + authority.transform.position = target; + + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(serverSide, target, observers)); + AssertOnTimeout($"[{m_SyncMode}][{m_AuthorityMode}] Not every observer reached {target}!\n{DescribeObservers(serverSide, target, observers)}"); + } + + /// + /// Reports where each instance actually is, so a convergence failure names the instance left behind. + /// + private string DescribeObservers(NetworkObject serverSide, Vector3 expectedPosition, IReadOnlyList observers) + { + var builder = new System.Text.StringBuilder(); + builder.AppendLine($" expected {expectedPosition}, owner is client {serverSide.OwnerClientId}"); + foreach (var manager in observers) + { + var role = manager.IsServer ? "server" : $"client-{manager.LocalClientId}"; + if (!manager.SpawnManager.SpawnedObjects.TryGetValue(serverSide.NetworkObjectId, out var clone)) + { + builder.AppendLine($" {role}: object not spawned"); + continue; + } + + var networkTransform = clone.GetComponent(); + var matches = Approximately(clone.transform.position, expectedPosition) ? "OK " : "BAD"; + // The two indices discriminate between "never registered for the batched interpolation" and + // "registered but the results are not being applied". + builder.AppendLine($" {role}: {matches} pos={clone.transform.position} owner={clone.OwnerClientId} " + + $"canCommit={networkTransform.CanCommitToTransform} isOwner={clone.IsOwner} " + + $"stateIdx={networkTransform.StateManagerIndex} interpIdx={networkTransform.InterpolatorIndex}"); + if (networkTransform.InterpolatorIndex >= 0) + { + builder.AppendLine($" interp: {manager.TransformStateManager.DescribePositionInterpolator(networkTransform.InterpolatorIndex)}"); + } + } + return builder.ToString(); + } + + /// + /// The baseline: a moving object reaches every observer in both modes. + /// + [UnityTest] + public IEnumerator MotionReachesEveryObserver() + { + var mover = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(mover, mover.transform.position, m_NetworkManagers)); + AssertOnTimeout("Initial spawn did not reach every client!"); + + yield return MoveAndConverge(mover, new Vector3(3.0f, 1.5f, -2.0f), m_NetworkManagers); + yield return MoveAndConverge(mover, new Vector3(-6.25f, 4.0f, 8.5f), m_NetworkManagers); + } + + /// + /// Owner authoritative instances owned by a client. + /// + /// + /// Batched mode leaves these on the per instance path: the batch is assembled per observing client and + /// sent directly, which only the server can do.
+ /// The exclusion is invisible at runtime. Get it wrong and the transform stops replicating with no error. + ///
+ [UnityTest] + public IEnumerator ClientOwnedInstanceStillReplicates() + { + var mover = SpawnMover(m_ClientNetworkManagers[0].LocalClientId); + + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(mover, mover.transform.position, m_NetworkManagers)); + AssertOnTimeout("Client owned instance did not spawn on every client!"); + + yield return MoveAndConverge(mover, new Vector3(5.0f, 2.0f, 1.0f), m_NetworkManagers); + } + + /// + /// Two objects where one is hidden from a single client. + /// + /// + /// The batched message is assembled per client, so this is what proves the observer filtering.
+ /// A filtering mistake shows up as the hidden object appearing, or as the whole batch failing to + /// deserialize for that client and every object freezing. + ///
+ [UnityTest] + public IEnumerator MixedObserversReceiveOnlyWhatTheyObserve() + { + var visibleToAll = SpawnMover(); + var hiddenFromOne = SpawnMover(); + + var hiddenFrom = m_ClientNetworkManagers[2]; + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(hiddenFromOne, hiddenFromOne.transform.position, m_NetworkManagers)); + AssertOnTimeout("Second object did not reach every client before being hidden!"); + + hiddenFromOne.NetworkHide(hiddenFrom.LocalClientId); + yield return WaitForConditionOrTimeOut(() => !hiddenFrom.SpawnManager.SpawnedObjects.ContainsKey(hiddenFromOne.NetworkObjectId)); + AssertOnTimeout("Object was not hidden from the target client!"); + + // Everyone still observing the hidden object has to keep receiving it. + var stillObserving = new List { m_ServerNetworkManager, m_ClientNetworkManagers[0], m_ClientNetworkManagers[1] }; + yield return MoveAndConverge(hiddenFromOne, new Vector3(9.0f, 3.0f, -4.0f), stillObserving); + + // And the client it is hidden from has to keep receiving the object it can still see, which is + // what breaks if a mis-sized batch desynchronizes that client's reader. + yield return MoveAndConverge(visibleToAll, new Vector3(-2.0f, 6.0f, 7.0f), m_NetworkManagers); + + // Bringing it back has to resume delivery to the client it was hidden from. + hiddenFromOne.NetworkShow(hiddenFrom.LocalClientId); + yield return WaitForConditionOrTimeOut(() => hiddenFrom.SpawnManager.SpawnedObjects.ContainsKey(hiddenFromOne.NetworkObjectId)); + AssertOnTimeout("Object was not shown again to the target client!"); + + yield return MoveAndConverge(hiddenFromOne, new Vector3(1.0f, 1.0f, 1.0f), m_NetworkManagers); + } + + private List GetNonAuthorityMovers(NetworkObject serverSide) + { + var authority = GetMotionAuthorityInstance(serverSide); + var movers = new List(); + foreach (var manager in m_NetworkManagers) + { + if (!manager.SpawnManager.SpawnedObjects.TryGetValue(serverSide.NetworkObjectId, out var clone)) + { + continue; + } + var mover = clone.GetComponent(); + if (mover != authority) + { + movers.Add(mover); + } + } + return movers; + } + + /// + /// A teleport has to arrive as a teleport rather than being interpolated towards. + /// + /// + /// Teleports take a different route through both the delta check and the interpolator reset paths, and + /// the batched path captures the state before the teleport flag is cleared.
+ /// A teleport that arrives as an ordinary delta converges to the same place, so the flag is asserted + /// rather than the destination. + ///
+ [UnityTest] + public IEnumerator TeleportArrivesAsATeleport() + { + var mover = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(mover, mover.transform.position, m_NetworkManagers)); + AssertOnTimeout("Initial spawn did not reach every client!"); + + // The spawn synchronization is itself a teleport, so the recorders start from the state that + // follows it rather than from the state at spawn. + var observers = GetNonAuthorityMovers(mover); + foreach (var observer in observers) + { + observer.ClearReceived(); + } + + var authority = GetMotionAuthorityInstance(mover); + var target = new Vector3(120.0f, 45.0f, -85.0f); + authority.SetState(target, null, null, false); + + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(mover, target, m_NetworkManagers)); + AssertOnTimeout($"Teleport to {target} did not reach every client!"); + + foreach (var observer in observers) + { + Assert.IsTrue(observer.ReceivedTeleport, + $"[{m_SyncMode}][{m_AuthorityMode}] Client-{observer.NetworkManager.LocalClientId} reached {target} " + + "without ever receiving a state flagged as a teleport, so it glided there instead of snapping!"); + } + } + + /// + /// Re-enabling an axis that drifted while it was off has to arrive as a teleport. + /// + /// + /// While an axis is disabled the authority keeps moving but stops sending that axis, so the half float + /// delta it would resume from is stale by more than the delta can represent.
+ /// X and Z are moved and re-enabled together because the check accumulates its result per axis. + ///
+ [UnityTest] + public IEnumerator ReEnablingADriftedAxisTeleports() + { + var mover = SpawnMover(prefab: m_HalfFloatMoverPrefab); + yield return WaitForConditionOrTimeOut(() => MotionAuthorityIsEstablished(mover)); + AssertOnTimeout("The motion authority instance never gained authority!"); + + yield return MoveAndConverge(mover, new Vector3(1.0f, 1.0f, 1.0f), m_NetworkManagers); + var authority = (ParityMover)GetMotionAuthorityInstance(mover); + + // X and Z stop being sent, and Y keeps moving so that position updates keep flowing. The axis + // registration the delta position holds is only rewritten on a tick where the position is dirty, + // so without the Y motion the authority would never record that X and Z went quiet. + authority.SyncPositionX = false; + authority.SyncPositionZ = false; + for (int i = 0; i < 6; i++) + { + var position = authority.transform.position; + authority.transform.position = new Vector3(position.x + 100.0f, position.y + 0.25f, position.z); + yield return s_DefaultWaitForTick; + } + + var observers = GetNonAuthorityMovers(mover); + foreach (var observer in observers) + { + observer.ClearReceived(); + } + + // Z is re-enabled alongside X and has not moved, so its in-range result must not mask the + // out-of-range one X produces. + authority.SyncPositionX = true; + authority.SyncPositionZ = true; + + var target = authority.transform.position; + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(mover, target, m_NetworkManagers)); + AssertOnTimeout($"[{m_SyncMode}][{m_AuthorityMode}] Re-enabled axes never converged on {target}!\n{DescribeObservers(mover, target, m_NetworkManagers)}"); + + foreach (var observer in observers) + { + Assert.IsTrue(observer.ReceivedTeleport, + $"[{m_SyncMode}][{m_AuthorityMode}] Client-{observer.NetworkManager.LocalClientId} resumed the drifted axis " + + "without a teleport, so it resumed from a half float delta that can no longer represent the distance!"); + } + } + + /// + /// Ownership moving between clients mid session. + /// + /// + /// A change of ownership re-runs initialization, which moves an instance between the delta tracking + /// and interpolation registrations. Under owner authority it also moves between the batched and per + /// instance send paths.
+ /// The handle has to survive that. It is allocated once and is not reassigned on ownership change. + ///
+ [UnityTest] + public IEnumerator OwnershipChangeKeepsReplicating() + { + var mover = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(mover, mover.transform.position, m_NetworkManagers)); + AssertOnTimeout("Initial spawn did not reach every client!"); + + yield return MoveAndConverge(mover, new Vector3(2.0f, 2.0f, 2.0f), m_NetworkManagers); + + mover.ChangeOwnership(m_ClientNetworkManagers[1].LocalClientId); + // Waited for on every instance, not just the server side object. The server applies ownership + // synchronously, so waiting on it would let the test proceed before the new owner knows it owns + // anything. + yield return WaitForConditionOrTimeOut(() => AllInstancesAgreeOnOwner(mover, m_ClientNetworkManagers[1].LocalClientId)); + AssertOnTimeout("Ownership did not transfer to every instance!"); + + yield return MoveAndConverge(mover, new Vector3(-4.0f, 5.0f, 3.0f), m_NetworkManagers); + + // And back to the server, which under owner authority moves it from the per instance path onto + // the batched one. + mover.ChangeOwnership(NetworkManager.ServerClientId); + yield return WaitForConditionOrTimeOut(() => AllInstancesAgreeOnOwner(mover, NetworkManager.ServerClientId)); + AssertOnTimeout("Ownership did not transfer back to every instance!"); + + yield return MoveAndConverge(mover, new Vector3(7.0f, -1.0f, 0.5f), m_NetworkManagers); + } + + /// + /// Despawning and respawning, which exercises handle release and reuse. + /// + /// + /// Handles are held for several seconds before being reissued, so a respawn inside that window has to + /// receive a different handle.
+ /// Sharing one would have the surviving object and the new one fight over the same address. + ///
+ [UnityTest] + public IEnumerator DespawnAndRespawnDoNotShareAHandle() + { + var first = SpawnMover(); + var second = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(second, second.transform.position, m_NetworkManagers)); + AssertOnTimeout("Initial spawns did not reach every client!"); + + yield return MoveAndConverge(first, new Vector3(10.0f, 0.0f, 0.0f), m_NetworkManagers); + + first.Despawn(); + yield return WaitForConditionOrTimeOut(() => !m_ClientNetworkManagers[0].SpawnManager.SpawnedObjects.ContainsKey(first.NetworkObjectId)); + AssertOnTimeout("Despawn did not reach the clients!"); + + // Respawn immediately, inside the window where the released handle is still being held. + var third = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(third, third.transform.position, m_NetworkManagers)); + AssertOnTimeout("Respawned object did not reach every client!"); + + // If the new object had inherited the despawned one's handle, moving it would drag the survivor + // with it, so both are checked. + var secondPosition = second.transform.position; + yield return MoveAndConverge(third, new Vector3(-15.0f, 2.0f, 6.0f), m_NetworkManagers); + + Assert.IsTrue(AllObserversMatch(second, secondPosition, m_NetworkManagers), + $"[{m_SyncMode}] Moving the respawned object also moved an unrelated one, which means they share a handle!"); + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs.meta new file mode 100644 index 0000000000..868b620615 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fe70eeb6a2cb4684db3d052f19926c5e \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/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 f7a1ba1001..1b2180c628 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs @@ -801,6 +801,9 @@ public IEnumerator SetUp() ConfigureFramesPerTick(); } + // Get the transform synchronization mode before setup. + GetSyncMode(); + if (m_SetupIsACoroutine) { yield return OnSetup(); @@ -929,6 +932,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 /// @@ -977,6 +993,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 @@ -1061,6 +1078,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);