From b8795fe5ee404c09e15aa61f9ea936f01efcf6af Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Thu, 29 Oct 2026 10:55:46 -0500 Subject: [PATCH] feat: add the native interpolator and the transform sync mode setting Third of five PRs that replace #4123. NativeInterpolator and InterpolateTransformJob interpolate NetworkTransform values over native data in a Burst job. NetworkConfig.TransformSyncMode selects per-instance or batched synchronization. Neither is used until the batched path is switched on. --- .../Editor/NetworkManagerEditor.cs | 15 + .../BufferedLinearInterpolator.cs | 12 +- .../Interpolator/InterpolateTransformJob.cs | 111 +++ .../InterpolateTransformJob.cs.meta | 2 + .../Interpolator/NativeInterpolator.cs | 676 ++++++++++++++++++ .../Interpolator/NativeInterpolator.cs.meta | 2 + .../Runtime/Configuration/NetworkConfig.cs | 61 ++ .../NativeInterpolatorTests.cs | 425 +++++++++++ .../NativeInterpolatorTests.cs.meta | 2 + 9 files changed, 1304 insertions(+), 2 deletions(-) create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/Interpolator/InterpolateTransformJob.cs create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/Interpolator/InterpolateTransformJob.cs.meta create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs.meta create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NativeInterpolatorTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NativeInterpolatorTests.cs.meta diff --git a/com.unity.netcode.gameobjects/Editor/NetworkManagerEditor.cs b/com.unity.netcode.gameobjects/Editor/NetworkManagerEditor.cs index 110e970b23..64dccee78f 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; @@ -54,6 +55,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; @@ -144,6 +146,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)); @@ -192,6 +195,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)); @@ -335,6 +339,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/Runtime/Components/Interpolator/BufferedLinearInterpolator.cs b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/BufferedLinearInterpolator.cs index 4b6de0454e..99d295ce82 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/BufferedLinearInterpolator.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/BufferedLinearInterpolator.cs @@ -275,7 +275,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 @@ -288,7 +294,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..6a67adf3d8 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs @@ -0,0 +1,676 @@ +using System.Runtime.CompilerServices; +using Unity.Collections; +using Unity.Mathematics; + +namespace Unity.Netcode.Components +{ + /// + /// The value type being interpolated for a given . + /// + internal enum InterpolatorValueKind + { + /// + /// Used to define the position or scale states. + /// + Vector3, + + /// + /// Always used for rotation. + /// + Quaternion, + } + + /// + /// The blittable (managed and native compatible) equivalent of . + /// + /// + /// A single covers every transform value type being synchronized.
+ /// For position and scale, the w (4th) element is not used. + ///
+ internal struct BufferedItemNative + { + internal float4 Item; + internal double TimeSent; + internal int ItemId; + } + + /// + /// The blittable (managed and native compatible) equivalent of a and its + /// . + /// + /// + /// The managed interpolator holds its measurements in a + /// and tracks the parent each measurement was taken under, neither of + /// which can exist inside a job. Here the measurements live in a fixed size ring buffer carved out of one + /// shared native array, addressed by .
+ ///
+ /// The smooth parenting transition flag, , + /// is excluded from this state as it is handled differently.
+ /// - provides additional details on this. + ///
+ 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 equivalent. + /// + 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/Configuration/NetworkConfig.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs index 9e9e50cdde..30d12e9364 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. @@ -368,6 +425,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/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