From 8a7f227fdeed986273e96342f4834e5db863c646 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Fri, 14 Aug 2026 16:37:20 -0500 Subject: [PATCH 1/7] update First phase of the NetworkTransform optimization pass. --- .../Runtime/Components/HalfVector3.cs | 48 +- .../Runtime/Components/NetworkTransform.cs | 412 ++++++++------- .../Components/QuaternionCompressor.cs | 113 +++- .../Runtime/Core/NetworkManager.cs | 19 + .../NetworkTransformStateBaselineTests.cs | 483 ++++++++++++++++++ ...NetworkTransformStateBaselineTests.cs.meta | 2 + 6 files changed, 868 insertions(+), 209 deletions(-) create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformStateBaselineTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformStateBaselineTests.cs.meta 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/NetworkTransform.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs index de4e86999d..a10cd4ed6d 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs @@ -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; } @@ -714,15 +711,20 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade // 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 an unmanaged type in order to store the entire state in a + // NativeArray. + 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 @@ -756,30 +758,30 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade } // 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 +792,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 +837,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 +857,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 +891,7 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade } else { - if (UseHalfFloatPrecision) + if (FlagStates.UseHalfFloatPrecision) { if (isWriting) { @@ -913,14 +915,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 +934,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 +954,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 +975,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 +1014,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 +1053,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 +1062,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 @@ -2329,21 +2331,14 @@ private bool CheckForStateChange(ref NetworkTransformState networkState, bool is isPositionDirty = true; } - // For NetworkDeltaPosition, if any axial value is dirty then we always send a full update + // For NetworkDeltaPosition, if any axial value is dirty then we always send a full update. + // This uses a direct field approach to avoid bounds checking costs. 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; - } - } + var previousPosition = m_HalfPositionState.PreviousPosition; + isPositionDirty = (SyncPositionX && Math.Abs(position.x - previousPosition.x) >= positionThreshold.x) + || (SyncPositionY && Math.Abs(position.y - previousPosition.y) >= positionThreshold.y) + || (SyncPositionZ && Math.Abs(position.z - previousPosition.z) >= positionThreshold.z); } // If the position is dirty or we are teleporting (which includes synchronization) @@ -2459,14 +2454,9 @@ private bool CheckForStateChange(ref NetworkTransformState networkState, bool is if (!isRotationDirty) { var previousRotation = networkState.Rotation.eulerAngles; - for (int i = 0; i < 3; i++) - { - if (Mathf.Abs(Mathf.DeltaAngle(previousRotation[i], rotAngles[i])) >= rotationThreshold[i]) - { - isRotationDirty = true; - break; - } - } + isRotationDirty = Mathf.Abs(Mathf.DeltaAngle(previousRotation.x, rotAngles.x)) >= rotationThreshold.x + || Mathf.Abs(Mathf.DeltaAngle(previousRotation.y, rotAngles.y)) >= rotationThreshold.y + || Mathf.Abs(Mathf.DeltaAngle(previousRotation.z, rotAngles.z)) >= rotationThreshold.z; } if (isRotationDirty) { @@ -2515,14 +2505,28 @@ private bool CheckForStateChange(ref NetworkTransformState networkState, bool is else if (SynchronizeScale) { var previousScale = networkState.Scale; - for (int i = 0; i < 3; i++) + // 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 || Mathf.Abs(scale.x - previousScale.x) >= ScaleThreshold) { - 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); - } + isScaleDirty = true; + networkState.Scale.x = scale.x; + flagStates.SetHasScale(Axis.X, SyncScaleX); + } + + if (alwaysDirty || Mathf.Abs(scale.y - previousScale.y) >= ScaleThreshold) + { + isScaleDirty = true; + networkState.Scale.y = scale.y; + flagStates.SetHasScale(Axis.Y, SyncScaleY); + } + + if (alwaysDirty || Mathf.Abs(scale.z - previousScale.z) >= ScaleThreshold) + { + isScaleDirty = true; + networkState.Scale.z = scale.z; + flagStates.SetHasScale(Axis.Z, SyncScaleZ); } } } @@ -2594,7 +2598,7 @@ private void OnNetworkTick(bool isCalledFromParent = false) #if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D // Let the parent handle the updating of this to keep the two synchronized - if (!isCalledFromParent && m_UseRigidbodyForMotion && m_NetworkRigidbodyInternal.ParentBody != null && !m_LocalAuthoritativeNetworkState.IsTeleportingNextFrame) + if (!isCalledFromParent && m_UseRigidbodyForMotion && m_NetworkRigidbodyInternal.ParentBody != null && !m_LocalAuthoritativeNetworkState.FlagStates.IsTeleportingNextFrame) { return; } @@ -2663,29 +2667,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 @@ -2815,7 +2833,7 @@ protected internal void ApplyAuthoritativeState() // Update our current position if it changed or we are interpolating if (flagStates.HasPositionChange || Interpolate) { - if (SyncPositionX && SyncPositionY && SyncPositionZ) + if (syncAllPosition) { m_InternalCurrentPosition = adjustedPosition; } @@ -2834,7 +2852,7 @@ protected internal void ApplyAuthoritativeState() m_NetworkRigidbodyInternal.MovePosition(m_InternalCurrentPosition); if (LogMotion) { - Debug.Log($"[Client-{m_CachedNetworkManager.LocalClientId}][Interpolate: {networkState.UseInterpolation}][TransPos: {transform.position}][RBPos: {m_NetworkRigidbodyInternal.GetPosition()}][CurrentPos: {m_InternalCurrentPosition}"); + Debug.Log($"[Client-{m_CachedNetworkManager.LocalClientId}][Interpolate: {networkState.FlagStates.UseInterpolation}][TransPos: {transform.position}][RBPos: {m_NetworkRigidbodyInternal.GetPosition()}][CurrentPos: {m_InternalCurrentPosition}"); } } @@ -2856,9 +2874,9 @@ protected internal void ApplyAuthoritativeState() if (SynchronizeRotation) { // Update our current rotation if it changed or we are interpolating - if (networkState.HasRotAngleChange || Interpolate) + if (flagStates.HasRotAngleChange || Interpolate) { - if ((SyncRotAngleX && SyncRotAngleY && SyncRotAngleZ) || UseQuaternionSynchronization) + if (syncAllRotation || UseQuaternionSynchronization) { m_InternalCurrentRotation = adjustedRotation; } @@ -2900,7 +2918,7 @@ protected internal void ApplyAuthoritativeState() // Update our current scale if it changed or we are interpolating if (flagStates.HasScaleChange || Interpolate) { - if (SyncScaleX && SyncScaleY && SyncScaleZ) + if (syncAllScale) { m_InternalCurrentScale = adjustedScale; } @@ -2926,7 +2944,7 @@ protected internal void ApplyAuthoritativeState() /// private void ApplyTeleportingState(NetworkTransformState newState) { - if (!newState.IsTeleportingNextFrame) + if (!newState.FlagStates.IsTeleportingNextFrame) { return; } @@ -2937,7 +2955,7 @@ 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 @@ -3109,7 +3127,7 @@ private void ApplyTeleportingState(NetworkTransformState newState) if (Interpolate) { m_RotationInterpolator.AutoConvertTransformSpace = SwitchTransformSpaceWhenParented; - m_RotationInterpolator.InLocalSpace = newState.InLocalSpace; + m_RotationInterpolator.InLocalSpace = newState.FlagStates.InLocalSpace; m_RotationInterpolator.ResetTo(CachedTransform.parent, currentRotation, sentTime); } } @@ -3153,13 +3171,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; @@ -3950,7 +3968,7 @@ internal override void InternalOnNetworkObjectParentChanged(NetworkObject parent m_PositionInterpolator.InLocalSpace = InLocalSpace; m_PositionInterpolator.Parent = InLocalSpace ? parentNetworkObject.transform : null; - if (LastTickSync == m_LocalAuthoritativeNetworkState.GetNetworkTick()) + if (LastTickSync == m_LocalAuthoritativeNetworkState.NetworkTick) { m_InternalCurrentPosition = m_LastStateTargetPosition = GetSpaceRelativePosition(); m_PositionInterpolator.ResetTo(m_PositionInterpolator.Parent, m_InternalCurrentPosition, m_CachedNetworkManager.ServerTime.Time); @@ -3981,7 +3999,7 @@ internal override void InternalOnNetworkObjectParentChanged(NetworkObject parent m_RotationInterpolator.AutoConvertTransformSpace = SwitchTransformSpaceWhenParented; m_RotationInterpolator.InLocalSpace = InLocalSpace; m_RotationInterpolator.Parent = InLocalSpace ? parentNetworkObject.transform : null; - if (LastTickSync == m_LocalAuthoritativeNetworkState.GetNetworkTick()) + if (LastTickSync == m_LocalAuthoritativeNetworkState.NetworkTick) { m_InternalCurrentRotation = GetSpaceRelativeRotation(); m_TargetRotation = m_InternalCurrentRotation.eulerAngles; @@ -4256,8 +4274,48 @@ internal BufferedLinearInterpolatorQuaternion GetRotationInterpolator() } #endif - // Non-Authority - private void UpdateInterpolation() + /// + /// Represents the commonly shared interpolation values that are identical for every + /// and is updated per frame. + /// + /// + /// These are calculated once per update stage by as opposed + /// to being recalculated by each instance. Both the base and the "one additional tick" variants are + /// pre-calculated because owner authoritative instances owned by another client add a tick to account + /// for the 2xRTT relay time. + /// + internal struct InterpolationFrameData + { + internal double CurrentTime; + internal float DeltaTime; + internal float FixedDeltaTime; + // Smooth dampening and extrapolation specific: + // We clamp between the tick rate frequency and the tick latency x tick rate frequency + internal double MinDeltaTime; + internal bool IsServer; + // Only true if the network topology selected for the session permits the additional owner authority tick. + internal bool OwnerAuthorityTickOffsetAllowed; + // Tick latency (ticks ago) used to process state updates in the queue. + internal double TickLatencyAsTime; + // The maximum time we will lerp between values. If the time exceeds this due to extreme latency then + // the value's interpolation rate will be accelerated to reach the goal and continue interpolating. + internal double MaxDeltaTime; + // Combines the two values above, with any additional owner authority tick applied. + internal double TickLatencyAsTimeExtraTick; + internal double MaxDeltaTimeExtraTick; + // Legacy lerp render times for a "ticks ago" of 1 and 2 (each plus InterpolationBufferTickOffset). + internal double LegacyRenderTime; + internal double LegacyRenderTimeExtraTick; + } + + /// + /// Refreshes the . + /// + /// + /// Invoked once per update stage (authority and rigid body motion relative), prior to updating + /// registered instances. + /// + internal static void RefreshInterpolationFrameData(NetworkManager networkManager) { // Use the local time because: // Client-Server: @@ -4265,48 +4323,65 @@ private void UpdateInterpolation() // Local time on clients takes latency into consideration. // Distributed authority: // Local time is used by the authority. - // Local time on non-authority takes latency into consid]eration. - var timeSystem = m_CachedNetworkManager.LocalTime; - var currentTime = timeSystem.Time; -#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D - var cachedDeltaTime = m_UseRigidbodyForMotion ? m_CachedNetworkManager.RealTimeProvider.FixedDeltaTime : m_CachedNetworkManager.RealTimeProvider.DeltaTime; -#else - var cachedDeltaTime = m_CachedNetworkManager.RealTimeProvider.DeltaTime; -#endif + // Local time on non-authority takes latency into consideration. + var timeSystem = networkManager.LocalTime; + var realTimeProvider = networkManager.RealTimeProvider; + var minDeltaTime = timeSystem.FixedDeltaTimeAsDouble; + // Optional user defined tick offset to be used to push the "render time" (the time that will be used to determine if a state update is available) // back in order to provide more room for the interpolator to interpolate towards when latency conditions are impacting the frequency that state // updates are received. - var tickLatency = Mathf.Max(1, m_CachedNetworkManager.NetworkTimeSystem.TickLatency + InterpolationBufferTickOffset); + var tickLatency = Mathf.Max(1, networkManager.NetworkTimeSystem.TickLatency + InterpolationBufferTickOffset); + var isServer = networkManager.IsServer; + + networkManager.TransformInterpolationFrameData = new InterpolationFrameData() + { + CurrentTime = timeSystem.Time, + DeltaTime = realTimeProvider.DeltaTime, + FixedDeltaTime = realTimeProvider.FixedDeltaTime, + MinDeltaTime = minDeltaTime, + IsServer = isServer, + // The additional owner authority tick only applies within a client-server topology (including + // DAHost) and only on instances that are not the server/host. + OwnerAuthorityTickOffsetAllowed = !isServer && (!networkManager.DistributedAuthorityMode || !networkManager.CMBServiceConnection), + TickLatencyAsTime = timeSystem.TimeTicksAgo(tickLatency).Time, + MaxDeltaTime = tickLatency * minDeltaTime, + TickLatencyAsTimeExtraTick = timeSystem.TimeTicksAgo(tickLatency + 1).Time, + MaxDeltaTimeExtraTick = (tickLatency + 1) * minDeltaTime, + // Since InterpolationBufferTickOffset defaults to zero, this should not impact existing projects but + // still provides users with the ability to tweak their ticks ago time. + LegacyRenderTime = timeSystem.TimeTicksAgo(1 + InterpolationBufferTickOffset).Time, + LegacyRenderTimeExtraTick = timeSystem.TimeTicksAgo(2 + InterpolationBufferTickOffset).Time, + }; + } - // If using an owner authoritative motion model - if (!IsServerAuthoritative()) - { - // 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; - } - } - } + /// + /// Only updated by non-authority instances. + /// + private void UpdateInterpolation() + { + // 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(); - // 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; - } + // If using an owner authoritative motion model and this instance belongs to another client, then + // account for the 2xRTT relay through the host or server by adding 1 to our tick latency. + var useExtraTick = !isServerAuthoritative && frameData.OwnerAuthorityTickOffsetAllowed && !NetworkObject.IsOwnedByServer; - // Get the tick latency (ticks ago) as time (in the past) to process state updates in the queue. - var tickLatencyAsTime = timeSystem.TimeTicksAgo(tickLatency).Time; + var tickLatencyAsTime = useExtraTick ? frameData.TickLatencyAsTimeExtraTick : frameData.TickLatencyAsTime; + var maxDeltaTime = useExtraTick ? frameData.MaxDeltaTimeExtraTick : frameData.MaxDeltaTime; + + // Note: This is for the legacy lerp type in order to maintain the same end result for any games under development that have tuned their + // project's to match the legacy lerp's end result. It is only consumed by the LegacyLerp branches below. + var cachedRenderTime = !isServerAuthoritative && !frameData.IsServer ? frameData.LegacyRenderTimeExtraTick : frameData.LegacyRenderTime; #if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D // If using rigid body for motion, then we need to increment @@ -4319,15 +4394,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) { 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/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index 1b702e76fc..51185321db 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -300,6 +300,13 @@ 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; + internal Dictionary NetworkTransformUpdate = new Dictionary(); #if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D internal Dictionary NetworkTransformFixedUpdate = new Dictionary(); @@ -402,6 +409,12 @@ 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); + } + foreach (var networkObjectEntry in NetworkTransformFixedUpdate) { // if not active or not spawned then skip @@ -442,6 +455,12 @@ 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); + } + // Non-physics based non-authority NetworkTransforms update their states after all other components foreach (var networkObjectEntry in NetworkTransformUpdate) { 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..cf32762edc --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformStateBaselineTests.cs @@ -0,0 +1,483 @@ +using System; +using System.Text; +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using static Unity.Netcode.Components.NetworkTransform; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Captures the exact serialized form of a across the matrix of + /// configurations that drive its serialization branches. + /// + /// + /// This exists as the wire format baseline. Any change to how a state is written (whether intentional or + /// not) will fail with the new signatures, which + /// makes an unintended wire change impossible to land silently.

+ /// To (re)generate the baseline: run this test, copy the C# array literal it prints on failure into + /// , and verify every changed entry is an intended wire format change. + ///
+ // These tests do not need to run against the Rust server. + [IgnoreIfServiceEnvironmentVariableSet] + internal class NetworkTransformStateBaselineTests + { + /// + /// One entry per case in , formatted as "name|byteLength|fnv1aHash". + /// + /// + /// Empty until generated. See the remarks on the class for how to populate it. + /// + private static readonly string[] k_ExpectedSignatures = + { + "FullPrecision.AllAxes|42|B454AEF3", + "FullPrecision.PositionOnly|18|53BC0797", + "FullPrecision.PositionX|10|BBA3D614", + "FullPrecision.RotationYOnly|10|3D4B5A51", + "FullPrecision.ScaleZOnly|10|ADD3F579", + "FullPrecision.Teleport|42|E62F1693", + "FullPrecision.Teleport.Parented|30|0167D4EA", + "FullPrecision.TrackByStateId|23|7109AC47", + "FullPrecision.InLocalSpace|18|FE97B9BF", + "QuaternionSync.Full|22|BBBF5D70", + "QuaternionSync.Compressed|10|61D0C0A0", + "QuaternionSync.HalfFloat|14|492F1FBC", + "QuaternionSync.Teleport|22|603F0025", + "HalfFloat.AllAxes|24|56C94289", + "HalfFloat.PositionOnly|12|E0EB69C7", + "HalfFloat.PositionXZ|10|270908C3", + "HalfFloat.SynchronizeBase|30|83FFE5D8", + "HalfFloat.Teleport|30|9407B934", + "HalfFloat.Synchronizing|36|E1EC38D8", + "HalfFloat.ScaleOnly|12|254AC316", + "HalfFloat.EulerRotation|12|F769BA44", + "UnreliableDeltas.FrameSync|19|81628843", + "SwitchTransformSpaceWhenParented|19|DD37211C", + }; + + /// + /// Deterministic payload values so the serialized output is stable between runs. + /// + private static NetworkTransformState CreateBaseState() + { + var state = new NetworkTransformState + { + NetworkTick = 12345, + StateId = 77, + PositionX = 1.25f, + PositionY = -30.5f, + PositionZ = 512.125f, + RotAngleX = 33.75f, + RotAngleY = 190.5f, + RotAngleZ = 271.25f, + Rotation = Quaternion.Euler(33.75f, 190.5f, 271.25f), + ScaleX = 2.5f, + ScaleY = 0.75f, + ScaleZ = 4.0f, + Scale = new Vector3(2.5f, 0.75f, 4.0f), + LossyScale = new Vector3(5.0f, 1.5f, 8.0f), + CurrentPosition = new Vector3(1.25f, -30.5f, 512.125f), + DeltaPosition = new Vector3(0.125f, -0.25f, 0.5f), + }; + + var currentPosition = state.CurrentPosition; + state.NetworkDeltaPosition = new NetworkDeltaPosition(currentPosition, state.NetworkTick, math.bool3(true)); + var deltaTarget = currentPosition + state.DeltaPosition; + state.NetworkDeltaPosition.UpdateFrom(ref deltaTarget, state.NetworkTick); + + state.HalfVectorScale = new HalfVector3(state.Scale, math.bool3(true)); + var rotation = state.Rotation; + state.HalfVectorRotation = new HalfVector4(); + state.HalfVectorRotation.UpdateFrom(ref rotation); + state.HalfEulerRotation = new HalfVector3(state.RotAngleX, state.RotAngleY, state.RotAngleZ); + + return state; + } + + private struct StateCase + { + public string Name; + public NetworkTransformState State; + } + + /// + /// The configuration matrix. Each entry exercises a distinct path through + /// . + /// + private static StateCase[] BuildStateMatrix() + { + var cases = new System.Collections.Generic.List(); + + // Full precision, euler rotation, all axes. + AddCase(cases, "FullPrecision.AllAxes", f => + { + f.MarkChanged(AxialType.Position, true); + f.MarkChanged(AxialType.Rotation, true); + f.MarkChanged(AxialType.Scale, true); + return f; + }); + + AddCase(cases, "FullPrecision.PositionOnly", f => + { + f.MarkChanged(AxialType.Position, true); + return f; + }); + + AddCase(cases, "FullPrecision.PositionX", f => + { + f.SetHasPosition(Axis.X, true); + return f; + }); + + AddCase(cases, "FullPrecision.RotationYOnly", f => + { + f.SetHasRotation(Axis.Y, true); + return f; + }); + + AddCase(cases, "FullPrecision.ScaleZOnly", f => + { + f.SetHasScale(Axis.Z, true); + return f; + }); + + AddCase(cases, "FullPrecision.Teleport", f => + { + f.MarkChanged(AxialType.Position, true); + f.MarkChanged(AxialType.Rotation, true); + f.MarkChanged(AxialType.Scale, true); + f.IsTeleportingNextFrame = true; + return f; + }); + + AddCase(cases, "FullPrecision.Teleport.Parented", f => + { + f.MarkChanged(AxialType.Scale, true); + f.IsTeleportingNextFrame = true; + f.IsParented = true; + return f; + }); + + AddCase(cases, "FullPrecision.TrackByStateId", f => + { + f.MarkChanged(AxialType.Position, true); + f.TrackByStateId = true; + return f; + }); + + AddCase(cases, "FullPrecision.InLocalSpace", f => + { + f.MarkChanged(AxialType.Position, true); + f.InLocalSpace = true; + return f; + }); + + // Quaternion synchronization (full precision quaternion). + AddCase(cases, "QuaternionSync.Full", f => + { + f.MarkChanged(AxialType.Rotation, true); + f.QuaternionSync = true; + return f; + }); + + AddCase(cases, "QuaternionSync.Compressed", f => + { + f.MarkChanged(AxialType.Rotation, true); + f.QuaternionSync = true; + f.QuaternionCompression = true; + return f; + }); + + AddCase(cases, "QuaternionSync.HalfFloat", f => + { + f.MarkChanged(AxialType.Rotation, true); + f.QuaternionSync = true; + f.UseHalfFloatPrecision = true; + return f; + }); + + AddCase(cases, "QuaternionSync.Teleport", f => + { + f.MarkChanged(AxialType.Rotation, true); + f.QuaternionSync = true; + f.QuaternionCompression = true; + f.IsTeleportingNextFrame = true; + return f; + }); + + // Half float precision. + AddCase(cases, "HalfFloat.AllAxes", f => + { + f.MarkChanged(AxialType.Position, true); + f.MarkChanged(AxialType.Rotation, true); + f.MarkChanged(AxialType.Scale, true); + f.UseHalfFloatPrecision = true; + return f; + }); + + AddCase(cases, "HalfFloat.PositionOnly", f => + { + f.MarkChanged(AxialType.Position, true); + f.UseHalfFloatPrecision = true; + return f; + }); + + AddCase(cases, "HalfFloat.PositionXZ", f => + { + f.SetHasPosition(Axis.X, true); + f.SetHasPosition(Axis.Z, true); + f.UseHalfFloatPrecision = true; + return f; + }); + + AddCase(cases, "HalfFloat.SynchronizeBase", f => + { + f.MarkChanged(AxialType.Position, true); + f.UseHalfFloatPrecision = true; + f.SynchronizeBaseHalfFloat = true; + return f; + }); + + AddCase(cases, "HalfFloat.Teleport", f => + { + f.MarkChanged(AxialType.Position, true); + f.MarkChanged(AxialType.Scale, true); + f.UseHalfFloatPrecision = true; + f.IsTeleportingNextFrame = true; + return f; + }); + + AddCase(cases, "HalfFloat.Synchronizing", f => + { + f.MarkChanged(AxialType.Position, true); + f.UseHalfFloatPrecision = true; + f.IsTeleportingNextFrame = true; + f.IsSynchronizing = true; + return f; + }); + + AddCase(cases, "HalfFloat.ScaleOnly", f => + { + f.MarkChanged(AxialType.Scale, true); + f.UseHalfFloatPrecision = true; + return f; + }); + + AddCase(cases, "HalfFloat.EulerRotation", f => + { + f.MarkChanged(AxialType.Rotation, true); + f.UseHalfFloatPrecision = true; + return f; + }); + + // Delivery related flags (these only alter the bitset, but that is part of the wire format). + AddCase(cases, "UnreliableDeltas.FrameSync", f => + { + f.MarkChanged(AxialType.Position, true); + f.UseUnreliableDeltas = true; + f.UnreliableFrameSync = true; + return f; + }); + + 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) + { + var writer = new FastBufferWriter(1024, Allocator.Temp); + try + { + writer.WriteNetworkSerializable(state); + return writer.ToArray(); + } + finally + { + writer.Dispose(); + } + } + + /// + /// Verifies the serialized form of every configuration in the matrix still matches the recorded baseline. + /// + [Test] + public void NetworkTransformStateSerializationBaseline() + { + var cases = BuildStateMatrix(); + var actual = new string[cases.Length]; + + for (int i = 0; i < cases.Length; i++) + { + var bytes = Serialize(cases[i].State); + actual[i] = $"{cases[i].Name}|{bytes.Length}|{Fnv1a(bytes):X8}"; + } + + if (k_ExpectedSignatures.Length != cases.Length) + { + Assert.Fail($"No baseline recorded (expected {cases.Length} entries, found {k_ExpectedSignatures.Length}). " + + $"Verify this is a new or intentionally changed wire format, then paste the following into {nameof(k_ExpectedSignatures)}:\n\n{FormatLiteral(actual)}"); + } + + var mismatches = new StringBuilder(); + for (int i = 0; i < cases.Length; i++) + { + if (k_ExpectedSignatures[i] != actual[i]) + { + mismatches.AppendLine($" [{i}] expected \"{k_ExpectedSignatures[i]}\" but was \"{actual[i]}\""); + } + } + + if (mismatches.Length > 0) + { + Assert.Fail($"The serialized {nameof(NetworkTransformState)} no longer matches the recorded baseline. " + + $"If this is an intentional wire format change, update {nameof(k_ExpectedSignatures)}.\n{mismatches}\nUpdated baseline:\n\n{FormatLiteral(actual)}"); + } + } + + /// + /// Verifies every configuration in the matrix survives a write and read back. + /// + /// + /// The baseline above proves the bytes did not change. This proves those bytes still round trip, which + /// keeps a baseline that was regenerated against a broken serializer from being accepted. + /// + [Test] + public void NetworkTransformStateSerializationRoundTrip() + { + foreach (var stateCase in BuildStateMatrix()) + { + var bytes = Serialize(stateCase.State); + NetworkTransformState deserialized; + var reader = new FastBufferReader(bytes, Allocator.Temp); + try + { + reader.ReadNetworkSerializable(out deserialized); + Assert.AreEqual(bytes.Length, reader.Position, + $"[{stateCase.Name}] Reader consumed {reader.Position} of {bytes.Length} bytes!"); + } + finally + { + reader.Dispose(); + } + + Assert.AreEqual(stateCase.State.NetworkTick, deserialized.NetworkTick, + $"[{stateCase.Name}] NetworkTick did not survive the round trip!"); + + if (stateCase.State.FlagStates.TrackByStateId) + { + Assert.AreEqual(stateCase.State.StateId, deserialized.StateId, + $"[{stateCase.Name}] StateId did not survive the round trip!"); + } + + AssertFlagsSurvived(stateCase, deserialized); + + // Re-serializing what was read back must reproduce the original payload byte for byte. + // This is the primary round trip assertion because it covers every field without the test + // needing to know which of them the serializer derives on the way out. + var reserialized = Serialize(deserialized); + Assert.AreEqual(bytes.Length, reserialized.Length, + $"[{stateCase.Name}] Re-serialized payload was {reserialized.Length} bytes but the original was {bytes.Length}!"); + for (int i = 0; i < bytes.Length; i++) + { + if (bytes[i] != reserialized[i]) + { + Assert.Fail($"[{stateCase.Name}] Re-serialized payload differs at byte {i}: expected 0x{bytes[i]:X2} but was 0x{reserialized[i]:X2}!"); + } + } + } + } + + /// + /// Compares every flag that a state update carries, with the exception of + /// . + /// + /// + /// derives ReliableSequenced while writing + /// (forced true unless UseUnreliableDeltas is set, otherwise derived from the teleporting, + /// synchronizing, frame sync and collapsed base position flags). The state being written is passed by + /// 'in', so that derivation lands on a defensive copy and is only ever observable on the state that + /// was read back. Comparing it against the source state would therefore always fail. + /// + private static void AssertFlagsSurvived(StateCase stateCase, NetworkTransformState deserialized) + { + var expected = stateCase.State.FlagStates; + var actual = deserialized.FlagStates; + + void Check(string flag, bool expectedValue, bool actualValue) + { + Assert.AreEqual(expectedValue, actualValue, $"[{stateCase.Name}] Flag {flag} did not survive the round trip!"); + } + + Check(nameof(FlagStates.InLocalSpace), expected.InLocalSpace, actual.InLocalSpace); + Check(nameof(FlagStates.HasPositionX), expected.HasPositionX, actual.HasPositionX); + Check(nameof(FlagStates.HasPositionY), expected.HasPositionY, actual.HasPositionY); + Check(nameof(FlagStates.HasPositionZ), expected.HasPositionZ, actual.HasPositionZ); + Check(nameof(FlagStates.HasRotAngleX), expected.HasRotAngleX, actual.HasRotAngleX); + Check(nameof(FlagStates.HasRotAngleY), expected.HasRotAngleY, actual.HasRotAngleY); + Check(nameof(FlagStates.HasRotAngleZ), expected.HasRotAngleZ, actual.HasRotAngleZ); + Check(nameof(FlagStates.HasScaleX), expected.HasScaleX, actual.HasScaleX); + Check(nameof(FlagStates.HasScaleY), expected.HasScaleY, actual.HasScaleY); + Check(nameof(FlagStates.HasScaleZ), expected.HasScaleZ, actual.HasScaleZ); + Check(nameof(FlagStates.IsTeleportingNextFrame), expected.IsTeleportingNextFrame, actual.IsTeleportingNextFrame); + Check(nameof(FlagStates.UseInterpolation), expected.UseInterpolation, actual.UseInterpolation); + Check(nameof(FlagStates.QuaternionSync), expected.QuaternionSync, actual.QuaternionSync); + Check(nameof(FlagStates.QuaternionCompression), expected.QuaternionCompression, actual.QuaternionCompression); + Check(nameof(FlagStates.UseHalfFloatPrecision), expected.UseHalfFloatPrecision, actual.UseHalfFloatPrecision); + Check(nameof(FlagStates.IsSynchronizing), expected.IsSynchronizing, actual.IsSynchronizing); + Check(nameof(FlagStates.UsePositionSlerp), expected.UsePositionSlerp, actual.UsePositionSlerp); + Check(nameof(FlagStates.IsParented), expected.IsParented, actual.IsParented); + Check(nameof(FlagStates.SynchronizeBaseHalfFloat), expected.SynchronizeBaseHalfFloat, actual.SynchronizeBaseHalfFloat); + Check(nameof(FlagStates.UseUnreliableDeltas), expected.UseUnreliableDeltas, actual.UseUnreliableDeltas); + Check(nameof(FlagStates.UnreliableFrameSync), expected.UnreliableFrameSync, actual.UnreliableFrameSync); + Check(nameof(FlagStates.SwitchTransformSpaceWhenParented), expected.SwitchTransformSpaceWhenParented, actual.SwitchTransformSpaceWhenParented); + Check(nameof(FlagStates.TrackByStateId), expected.TrackByStateId, actual.TrackByStateId); + } + + private static string FormatLiteral(string[] signatures) + { + var builder = new StringBuilder(); + builder.AppendLine(" private static readonly string[] k_ExpectedSignatures ="); + builder.AppendLine(" {"); + foreach (var signature in signatures) + { + builder.AppendLine($" \"{signature}\","); + } + builder.AppendLine(" };"); + return builder.ToString(); + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformStateBaselineTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformStateBaselineTests.cs.meta new file mode 100644 index 0000000000..fd70a0eecf --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformStateBaselineTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 058fb7f29dcbf5448b15ebc82e54776b \ No newline at end of file From 0c58dc877b194f4c7c5f93265d3b9b3ef0e575ef Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Fri, 14 Aug 2026 18:58:16 -0500 Subject: [PATCH 2/7] update First part of the second pass which begins to get the job integration side of things. --- .../NetcodeForGameObjectsProjectSettings.cs | 10 + .../Configuration/NetcodeSettingsProvider.cs | 23 + .../TransformSyncModeProcessor.cs | 65 ++ .../TransformSyncModeProcessor.cs.meta | 2 + .../Editor/NetworkTransformEditor.cs | 56 +- .../Components/DetectTransformDeltaJob.cs | 55 ++ .../DetectTransformDeltaJob.cs.meta | 2 + .../Runtime/Components/NetworkTransform.cs | 644 +++++++----------- .../Components/NetworkTransformDeltaCheck.cs | 609 +++++++++++++++++ .../NetworkTransformDeltaCheck.cs.meta | 2 + .../NetworkTransformStateManager.cs | 219 ++++++ .../NetworkTransformStateManager.cs.meta | 2 + .../Runtime/Configuration/NetworkConfig.cs | 37 + .../Runtime/Core/NetworkManager.cs | 11 + 14 files changed, 1320 insertions(+), 417 deletions(-) create mode 100644 com.unity.netcode.gameobjects/Editor/Configuration/TransformSyncModeProcessor.cs create mode 100644 com.unity.netcode.gameobjects/Editor/Configuration/TransformSyncModeProcessor.cs.meta create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs.meta create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformDeltaCheck.cs create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformDeltaCheck.cs.meta create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs.meta diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs index 05f83876bd..ab8ac55937 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs @@ -35,6 +35,16 @@ private void OnEnable() [SerializeField] public bool GenerateDefaultNetworkPrefabs = true; + /// + /// The project wide that is applied to . + /// + /// + /// The two modes are not wire compatible with one another, so this is authored once for the project as + /// opposed to per . + /// + [SerializeField] + public TransformSyncModes TransformSyncMode = TransformSyncModes.PerInstance; + internal void SaveSettings() { Save(true); diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs index a8b521117c..efc2454cbe 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.IO; +using Unity.Netcode.Components; using UnityEditor; using UnityEngine; using Directory = UnityEngine.Windows.Directory; @@ -132,6 +133,7 @@ private static void OnGuiHandler(string obj) var settings = NetcodeForGameObjectsProjectSettings.instance; var generateDefaultPrefabs = settings.GenerateDefaultNetworkPrefabs; var networkPrefabsPath = settings.TempNetworkPrefabsPath; + var transformSyncMode = settings.TransformSyncMode; EditorGUI.BeginChangeCheck(); @@ -192,6 +194,26 @@ private static void OnGuiHandler(string obj) networkPrefabsPath, GUILayout.Width(s_MaxLabelWidth + 270)); GUILayout.EndVertical(); + + GUILayout.BeginVertical("Box"); + GUILayout.Label("NetworkTransform Synchronization", EditorStyles.boldLabel); + transformSyncMode = (TransformSyncModes)EditorGUILayout.EnumPopup( + new GUIContent( + "Synchronization Mode", + "Determines how NetworkTransform instances detect and synchronize their state. " + + "Batched mode detects changes for all instances within a job and sends them as a single message per tick. " + + "This is a global setting for all NetworkTransforms since the two modes are not compatible on a per instance basis."), + transformSyncMode, + GUILayout.Width(s_MaxLabelWidth + 120)); + + if (transformSyncMode == TransformSyncModes.Batched) + { + EditorGUILayout.HelpBox( + $"{nameof(NetworkTransform.UseUnreliableDeltas)} does not apply in this mode and will no longer be visible when viewing " + + "NetworkTransform in the inspector view. Delivery is determined per state update as opposed to per component.", + MessageType.Info); + } + GUILayout.EndVertical(); } EditorGUILayout.EndFoldoutHeaderGroup(); GUILayout.EndVertical(); @@ -202,6 +224,7 @@ private static void OnGuiHandler(string obj) NetcodeForGameObjectsEditorSettings.SetNetcodeInstallMultiplayerToolTips(multiplayerToolsTipStatus ? 0 : 1); settings.GenerateDefaultNetworkPrefabs = generateDefaultPrefabs; settings.TempNetworkPrefabsPath = networkPrefabsPath; + settings.TransformSyncMode = transformSyncMode; settings.SaveSettings(); } } diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/TransformSyncModeProcessor.cs b/com.unity.netcode.gameobjects/Editor/Configuration/TransformSyncModeProcessor.cs new file mode 100644 index 0000000000..77cb2355d9 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/Configuration/TransformSyncModeProcessor.cs @@ -0,0 +1,65 @@ +using Unity.Netcode.GameObjects.Editor.Configuration; +using UnityEditor; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace Unity.Netcode.Editor +{ + /// + /// Applies the project wide to the + /// . + /// + /// + /// This runs both when entering play mode and while building, and operates on the scene being processed as + /// opposed to the authored asset, so it never dirties a user's scene. + /// + internal class SetTransformSyncMode : IProcessSceneWithReport + { + public int callbackOrder => 0; + + public void OnProcessScene(Scene scene, BuildReport report) + { + var transformSyncMode = NetcodeForGameObjectsProjectSettings.instance.TransformSyncMode; + foreach (var networkManager in FindObjects.FromSceneByType(scene, true)) + { + if (networkManager.NetworkConfig == null) + { + continue; + } + networkManager.NetworkConfig.TransformSyncMode = transformSyncMode; + } + } + } + + /// + /// Applies the project wide to any + /// within a prefab as the prefab will be is imported. + /// + /// + /// Covers projects that instantiate their from a prefab as opposed to placing + /// it in a scene. + /// + internal class TransformSyncModePrefabProcessor : AssetPostprocessor + { + public void OnPostprocessPrefab(GameObject root) + { + var networkManagers = root.GetComponentsInChildren(true); + if (networkManagers.Length == 0) + { + return; + } + + var transformSyncMode = NetcodeForGameObjectsProjectSettings.instance.TransformSyncMode; + foreach (var networkManager in networkManagers) + { + if (networkManager.NetworkConfig == null) + { + continue; + } + networkManager.NetworkConfig.TransformSyncMode = transformSyncMode; + } + } + } +} diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/TransformSyncModeProcessor.cs.meta b/com.unity.netcode.gameobjects/Editor/Configuration/TransformSyncModeProcessor.cs.meta new file mode 100644 index 0000000000..60027ab941 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/Configuration/TransformSyncModeProcessor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 64427fa980d0a294a960c4e8717a37b5 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Editor/NetworkTransformEditor.cs b/com.unity.netcode.gameobjects/Editor/NetworkTransformEditor.cs index 1fcb77f709..e544c00c4a 100644 --- a/com.unity.netcode.gameobjects/Editor/NetworkTransformEditor.cs +++ b/com.unity.netcode.gameobjects/Editor/NetworkTransformEditor.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using Unity.Netcode.Components; +using Unity.Netcode.GameObjects.Editor.Configuration; using UnityEditor; using UnityEngine; @@ -216,32 +217,42 @@ private void DisplayNetworkTransformProperties() EditorGUILayout.Space(); EditorGUILayout.LabelField("Delivery", EditorStyles.boldLabel); EditorGUILayout.PropertyField(m_TickSyncChildren); - // If both are set from a previous configuration, then SwitchTransformSpaceWhenParented takes - // precedence. - if (networkTransform.UseUnreliableDeltas && networkTransform.SwitchTransformSpaceWhenParented) - { - networkTransform.UseUnreliableDeltas = false; - } - SetGUIActive(!networkTransform.SwitchTransformSpaceWhenParented); - if (networkTransform.SwitchTransformSpaceWhenParented) - { - EditorGUILayout.BeginHorizontal(); - EditorGUILayout.PropertyField(m_UseUnreliableDeltas); - EditorGUILayout.LabelField($"Cannot use with {nameof(NetworkTransform.SwitchTransformSpaceWhenParented)}."); - EditorGUILayout.EndHorizontal(); - } - else + + // UseUnreliableDeltas only applies to per instance synchronization mode. Under the batched mode + // delivery is determined per state update as opposed to per component, so the property (and + // everything it constrains) is hidden. See Project Settings -> Multiplayer -> Netcode for GameObjects. + var perInstanceSync = NetcodeForGameObjectsProjectSettings.instance.TransformSyncMode == TransformSyncModes.PerInstance; + if (perInstanceSync) { - EditorGUILayout.PropertyField(m_UseUnreliableDeltas); - } + // If both are set from a previous configuration, then SwitchTransformSpaceWhenParented takes + // precedence. + if (networkTransform.UseUnreliableDeltas && networkTransform.SwitchTransformSpaceWhenParented) + { + networkTransform.UseUnreliableDeltas = false; + } + SetGUIActive(!networkTransform.SwitchTransformSpaceWhenParented); + if (networkTransform.SwitchTransformSpaceWhenParented) + { + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.PropertyField(m_UseUnreliableDeltas); + EditorGUILayout.LabelField($"Cannot use with {nameof(NetworkTransform.SwitchTransformSpaceWhenParented)}."); + EditorGUILayout.EndHorizontal(); + } + else + { + EditorGUILayout.PropertyField(m_UseUnreliableDeltas); + } - SetGUIActive(true); + SetGUIActive(true); + } EditorGUILayout.Space(); EditorGUILayout.LabelField("Configurations", EditorStyles.boldLabel); - SetGUIActive(!networkTransform.UseUnreliableDeltas); - if (networkTransform.UseUnreliableDeltas) + // SwitchTransformSpaceWhenParented is only constrained by UseUnreliableDeltas while the latter applies. + var blockedByUnreliableDeltas = perInstanceSync && networkTransform.UseUnreliableDeltas; + SetGUIActive(!blockedByUnreliableDeltas); + if (blockedByUnreliableDeltas) { EditorGUILayout.BeginHorizontal(); EditorGUILayout.PropertyField(m_SwitchTransformSpaceWhenParented); @@ -256,7 +267,10 @@ private void DisplayNetworkTransformProperties() if (m_SwitchTransformSpaceWhenParented.boolValue) { m_TickSyncChildren.boolValue = true; - networkTransform.UseUnreliableDeltas = false; + if (perInstanceSync) + { + networkTransform.UseUnreliableDeltas = false; + } } else { diff --git a/com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs b/com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs new file mode 100644 index 0000000000..e44779af42 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs @@ -0,0 +1,55 @@ +using Unity.Collections; +using UnityEngine.Jobs; +using static Unity.Netcode.Components.NetworkTransform; + +namespace Unity.Netcode.Components +{ + /// + /// Motion Authority Only: + /// Detects state changes for every registered instance in parallel. + /// + /// + /// This reads each transform and defers to the very same + /// + /// that the per instance path runs on the main thread to keep the logic between per instance and batched the same.
+ /// Only transform values are read here and only the entries array is written, so there is no hierarchy + /// write hazard: nothing in this job touches a transform other than the one at its own index, and nothing + /// writes to a transform at all. + ///
+ internal struct DetectTransformDeltaJob : IJobParallelForTransform + { + /// + /// The per instance input and output, parallel to the transforms this job is scheduled over. + /// + public NativeArray Entries; + + public void Execute(int index, TransformAccess transform) + { + if (!transform.isValid) + { + return; + } + + var entry = Entries[index]; + var flagStates = entry.State.FlagStates; + var forceState = entry.ForceState; + + // Resolve the transform space before sampling, otherwise the wrong set of values gets compared. + var transformSpaceChanged = ResolveTransformSpace(ref entry.Config, ref flagStates, entry.TransformHasParent, false, ref forceState); + entry.State.FlagStates = flagStates; + + // A rigidbody driven instance cannot be sampled from here, so it is never registered for the + // batched path and always falls back to the per instance flow. + var rotation = entry.Config.InLocalSpace ? transform.localRotation : transform.rotation; + entry.Sample.Position = entry.Config.InLocalSpace ? transform.localPosition : transform.position; + entry.Sample.Rotation = rotation; + entry.Sample.RotAngles = rotation.eulerAngles; + 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/NetworkTransform.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs index a10cd4ed6d..acd3ad0488 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; @@ -1759,6 +1759,17 @@ public Vector3 GetScale(bool getCurrentState = false) return m_InternalCurrentScale; } + /// + /// When mode, this is the instance's index within .
+ /// It is -1 when it is not registered. + /// registered. + ///
+ /// + /// Cached here (as opposed to using a lookup table) so registering and deregistering is O(1). The + /// manager keeps this up to date as instances are swapped between slots. + /// + internal int StateManagerIndex = -1; + // Used by both authoritative and non-authoritative instances. // This represents the most recent local authoritative state. private NetworkTransformState m_LocalAuthoritativeNetworkState; @@ -2009,6 +2020,67 @@ 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: + /// Prepares all state that that is not already provided for a batched delta check. + /// + internal void PrepareBatchedDeltaEntry(ref TransformDeltaEntry entry) + { + entry.Config = GetTransformDeltaConfig(); + entry.TransformHasParent = transform.parent != null; + entry.HalfPositionState = m_HalfPositionState; + entry.IsDirty = false; + // ForceState is set by the main thread, tick relative, and is cleared once applied. + + var flagStates = entry.State.FlagStates; + entry.Sample = default; + + // Same conditions the per instance path uses, both of which need a lookup a job cannot perform. + if (flagStates.IsTeleportingNextFrame || entry.ForceState || flagStates.IsParented) + { + entry.Sample.HasParentNetworkObject = HasParentNetworkObject(); + entry.Sample.LossyScale = CachedTransform.lossyScale; + } + } + + /// + /// Applies the batched delta check result and sends the state update when one was detected. + /// + internal void ApplyBatchedDeltaEntry(ref TransformDeltaEntry entry) + { + m_LocalAuthoritativeNetworkState = entry.State; + m_HalfPositionState = entry.HalfPositionState; + ApplyTransformDeltaConfig(entry.Config); + entry.ForceState = false; + + if (entry.IsDirty || m_LocalAuthoritativeNetworkState.ExplicitSet) + { + CommitDetectedState(false); + // CommitDetectedState mutates the state (it clears the teleport and explicit set flags and + // records the old state), so the entry has to pick those changes back up. + entry.State = m_LocalAuthoritativeNetworkState; + entry.HalfPositionState = m_HalfPositionState; + } + + // The follow up work can raise these again for the next tick. + entry.Config.DeltaSynch = m_DeltaSynch; + entry.Config.NextTickSync = m_NextTickSync; + } + + /// + /// Sends any detected state updates for the frame. + /// + /// + /// This was pulled out of to make it per instance or batched compatible. + /// + /// Whether this state update is an initial synchronization. + private void CommitDetectedState(bool synchronize) + { { // If the state was explicitly set, then update the network tick to match the locally calculate tick if (m_LocalAuthoritativeNetworkState.ExplicitSet) @@ -2129,84 +2201,115 @@ 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, + UseUnreliableDeltas = UseUnreliableDeltas, + 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) @@ -2217,355 +2320,46 @@ private bool CheckForStateChange(ref NetworkTransformState networkState, bool is #else var position = InLocalSpace ? CachedTransform.localPosition : CachedTransform.position; var rotation = InLocalSpace ? CachedTransform.localRotation : CachedTransform.rotation; - var positionThreshold = Vector3.one * PositionThreshold; - var rotationThreshold = Vector3.one * RotAngleThreshold; #endif - var rotAngles = rotation.eulerAngles; - var scale = CachedTransform.localScale; - flagStates.IsSynchronizing = isSynchronization; - - // Check for parenting when synchronizing and/or teleporting - if (isSynchronization || flagStates.IsTeleportingNextFrame || forceState) - { - // This all has to do with complex nested hierarchies and how it impacts scale - // when set for the first time or teleporting and depends upon whether the - // NetworkObject is parented (or "de-parented") at the same time any scale - // values are applied. - var hasParentNetworkObject = false; - - // If the NetworkObject belonging to this NetworkTransform instance has a parent - // (i.e. this handles nested NetworkTransforms under a parent at some layer above) - if (NetworkObject.transform.parent != null) - { - var parentNetworkObject = NetworkObject.transform.parent.GetComponent(); - - // In-scene placed NetworkObjects parented under a GameObject with no - // NetworkObject preserve their lossyScale when synchronizing. - if (parentNetworkObject == null && NetworkObject.InScenePlaced) - { - hasParentNetworkObject = true; - } - else - { - // Or if the relative NetworkObject has a parent NetworkObject - hasParentNetworkObject = parentNetworkObject != null; - } - } - - flagStates.IsParented = hasParentNetworkObject; - } - - if (Interpolate != flagStates.UseInterpolation) + 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; - } - - if (UseQuaternionSynchronization != flagStates.QuaternionSync) - { - flagStates.QuaternionSync = UseQuaternionSynchronization; - isDirty = true; - flagStates.IsTeleportingNextFrame = true; - } - - if (UseQuaternionCompression != flagStates.QuaternionCompression) - { - flagStates.QuaternionCompression = UseQuaternionCompression; - isDirty = true; - flagStates.IsTeleportingNextFrame = true; - } - - if (UseHalfFloatPrecision != flagStates.UseHalfFloatPrecision) - { - flagStates.UseHalfFloatPrecision = UseHalfFloatPrecision; - isDirty = true; - flagStates.IsTeleportingNextFrame = true; - } - - if (SlerpPosition != flagStates.UsePositionSlerp) - { - flagStates.UsePositionSlerp = SlerpPosition; - isDirty = true; - flagStates.IsTeleportingNextFrame = true; - } - - if (UseUnreliableDeltas != flagStates.UseUnreliableDeltas) - { - flagStates.UseUnreliableDeltas = UseUnreliableDeltas; - isDirty = true; - flagStates.IsTeleportingNextFrame = true; - } - - // Begin delta checks against last sent state update - if (!UseHalfFloatPrecision) - { - if (SyncPositionX && (Mathf.Abs(networkState.PositionX - position.x) >= positionThreshold.x || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) - { - networkState.PositionX = position.x; - flagStates.SetHasPosition(Axis.X, true); - isPositionDirty = true; - } - - if (SyncPositionY && (Mathf.Abs(networkState.PositionY - position.y) >= positionThreshold.y || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) - { - networkState.PositionY = position.y; - flagStates.SetHasPosition(Axis.Y, true); - isPositionDirty = true; - } - - if (SyncPositionZ && (Mathf.Abs(networkState.PositionZ - position.z) >= positionThreshold.z || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) - { - networkState.PositionZ = position.z; - flagStates.SetHasPosition(Axis.Z, true); - isPositionDirty = true; - } - } - else if (SynchronizePosition) - { - // If we are teleporting then we can skip the delta threshold check - isPositionDirty = flagStates.IsTeleportingNextFrame || isAxisSync || forceState; - if (m_HalfFloatTargetTickOwnership > CurrentTick) - { - isPositionDirty = true; - } - - // For NetworkDeltaPosition, if any axial value is dirty then we always send a full update. - // This uses a direct field approach to avoid bounds checking costs. - if (!isPositionDirty) - { - var previousPosition = m_HalfPositionState.PreviousPosition; - isPositionDirty = (SyncPositionX && Math.Abs(position.x - previousPosition.x) >= positionThreshold.x) - || (SyncPositionY && Math.Abs(position.y - previousPosition.y) >= positionThreshold.y) - || (SyncPositionZ && Math.Abs(position.z - previousPosition.z) >= positionThreshold.z); - } - - // If the position is dirty or we are teleporting (which includes synchronization) - // then determine what parts of the NetworkDeltaPosition should be updated - if (isPositionDirty) - { - // If we are not synchronizing the transform state for the first time - if (!isSynchronization) - { - // With global teleporting (broadcast to all non-authority instances) - // we re-initialize authority's NetworkDeltaPosition and synchronize all - // non-authority instances with the new full precision position - if (flagStates.IsTeleportingNextFrame) - { - m_HalfPositionState = new NetworkDeltaPosition(position, networkState.NetworkTick, math.bool3(SyncPositionX, SyncPositionY, SyncPositionZ)); - networkState.CurrentPosition = position; - } - else // Otherwise, just synchronize the delta position value - { - m_HalfPositionState.HalfVector3.AxisToSynchronize = math.bool3(SyncPositionX, SyncPositionY, SyncPositionZ); - m_HalfPositionState.UpdateFrom(ref position, networkState.NetworkTick); - } - - networkState.NetworkDeltaPosition = m_HalfPositionState; - - // If ownership offset is greater or we are doing an axial synchronization then synchronize the base position - if ((m_HalfFloatTargetTickOwnership > CurrentTick || isAxisSync) && !flagStates.IsTeleportingNextFrame) - { - flagStates.SynchronizeBaseHalfFloat = true; - } - else - { - flagStates.SynchronizeBaseHalfFloat = UseUnreliableDeltas ? m_HalfPositionState.CollapsedDeltaIntoBase : false; - } - } - else // If synchronizing is set, then use the current full position value on the server side - { - if (ShouldSynchronizeHalfFloat(targetClientId)) - { - // If we have a NetworkDeltaPosition that has a state applied, then we want to determine - // what needs to be synchronized. For owner authoritative mode, the server side - // will have no valid state yet. - if (m_HalfPositionState.NetworkTick > 0) - { - // Always synchronize the base position and the ushort values of the - // current m_HalfPositionState - networkState.CurrentPosition = m_HalfPositionState.CurrentBasePosition; - networkState.NetworkDeltaPosition = m_HalfPositionState; - // If the server is the owner, in both server and owner authoritative modes, - // or we are running in server authoritative mode, then we use the - // HalfDeltaConvertedBack value as the delta position - if (NetworkObject.IsOwnedByServer || IsServerAuthoritative()) - { - networkState.DeltaPosition = m_HalfPositionState.HalfDeltaConvertedBack; - } - else - { - // Otherwise, we are in owner authoritative mode and the server's NetworkDeltaPosition - // state is "non-authoritative" relative so we use the DeltaPosition. - networkState.DeltaPosition = m_HalfPositionState.DeltaPosition; - } - } - else // Reset everything and just send the current position - { - networkState.NetworkDeltaPosition = new NetworkDeltaPosition(Vector3.zero, 0, math.bool3(SyncPositionX, SyncPositionY, SyncPositionZ)); - networkState.DeltaPosition = Vector3.zero; - networkState.CurrentPosition = position; - } - } - else - { - networkState.NetworkDeltaPosition = new NetworkDeltaPosition(Vector3.zero, 0, math.bool3(SyncPositionX, SyncPositionY, SyncPositionZ)); - networkState.CurrentPosition = position; - } - // Add log entry for this update relative to the client being synchronized - AddLogEntry(ref networkState, targetClientId, true); - } - flagStates.HasPositionX = SyncPositionX; - flagStates.HasPositionY = SyncPositionY; - flagStates.HasPositionZ = SyncPositionZ; - flagStates.HasPositionChange = SyncPositionX || SyncPositionY || SyncPositionZ; - } - } + Position = position, + Rotation = rotation, + RotAngles = rotation.eulerAngles, + Scale = CachedTransform.localScale, + }; - if (!UseQuaternionSynchronization) + // Only resolved when it can actually be consumed, since both of these are lookups. + if (isSynchronization || networkState.FlagStates.IsTeleportingNextFrame || forceState) { - if (SyncRotAngleX && (Mathf.Abs(Mathf.DeltaAngle(networkState.RotAngleX, rotAngles.x)) >= rotationThreshold.x || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) - { - networkState.RotAngleX = rotAngles.x; - flagStates.SetHasRotation(Axis.X, true); - isRotationDirty = true; - } - - if (SyncRotAngleY && (Mathf.Abs(Mathf.DeltaAngle(networkState.RotAngleY, rotAngles.y)) >= rotationThreshold.y || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) - { - networkState.RotAngleY = rotAngles.y; - flagStates.SetHasRotation(Axis.Y, true); - isRotationDirty = true; - } - - if (SyncRotAngleZ && (Mathf.Abs(Mathf.DeltaAngle(networkState.RotAngleZ, rotAngles.z)) >= rotationThreshold.z || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) - { - networkState.RotAngleZ = rotAngles.z; - flagStates.SetHasRotation(Axis.Z, true); - isRotationDirty = true; - } + sample.HasParentNetworkObject = HasParentNetworkObject(); + sample.LossyScale = CachedTransform.lossyScale; } - else if (SynchronizeRotation) + else if (networkState.FlagStates.IsParented) { - // If we are teleporting then we can skip the delta threshold check - isRotationDirty = flagStates.IsTeleportingNextFrame || isAxisSync || forceState; - // For quaternion synchronization, if one angle is dirty we send a full update - if (!isRotationDirty) - { - var previousRotation = networkState.Rotation.eulerAngles; - isRotationDirty = Mathf.Abs(Mathf.DeltaAngle(previousRotation.x, rotAngles.x)) >= rotationThreshold.x - || Mathf.Abs(Mathf.DeltaAngle(previousRotation.y, rotAngles.y)) >= rotationThreshold.y - || Mathf.Abs(Mathf.DeltaAngle(previousRotation.z, rotAngles.z)) >= rotationThreshold.z; - } - if (isRotationDirty) - { - networkState.Rotation = rotation; - flagStates.MarkChanged(AxialType.Rotation, 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; } - // For scale, we need to check for parenting when synchronizing and/or teleporting (synchronization is always teleporting) - if (flagStates.IsTeleportingNextFrame) + if (isSynchronization) { - // 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; - } + sample.ShouldSynchronizeHalfFloat = ShouldSynchronizeHalfFloat(targetClientId); + sample.UseHalfDeltaConvertedBack = NetworkObject.IsOwnedByServer || IsServerAuthoritative(); } - // Checking scale deltas when not synchronizing - if (!isSynchronization) - { - if (!UseHalfFloatPrecision) - { - if (SyncScaleX && (Mathf.Abs(networkState.ScaleX - scale.x) >= ScaleThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) - { - networkState.ScaleX = scale.x; - flagStates.SetHasScale(Axis.X, true); - isScaleDirty = true; - } - - if (SyncScaleY && (Mathf.Abs(networkState.ScaleY - scale.y) >= ScaleThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) - { - networkState.ScaleY = scale.y; - flagStates.SetHasScale(Axis.Y, true); - isScaleDirty = true; - } - - if (SyncScaleZ && (Mathf.Abs(networkState.ScaleZ - scale.z) >= ScaleThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) - { - networkState.ScaleZ = scale.z; - flagStates.SetHasScale(Axis.Z, true); - isScaleDirty = true; - } - } - else if (SynchronizeScale) - { - var previousScale = networkState.Scale; - // 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 || Mathf.Abs(scale.x - previousScale.x) >= ScaleThreshold) - { - isScaleDirty = true; - networkState.Scale.x = scale.x; - flagStates.SetHasScale(Axis.X, SyncScaleX); - } - - if (alwaysDirty || Mathf.Abs(scale.y - previousScale.y) >= ScaleThreshold) - { - isScaleDirty = true; - networkState.Scale.y = scale.y; - flagStates.SetHasScale(Axis.Y, SyncScaleY); - } - - if (alwaysDirty || Mathf.Abs(scale.z - previousScale.z) >= ScaleThreshold) - { - isScaleDirty = true; - networkState.Scale.z = scale.z; - flagStates.SetHasScale(Axis.Z, SyncScaleZ); - } - } - } - // Just apply the full local scale when synchronizing - else if (SynchronizeScale) - { - var localScale = CachedTransform.localScale; - if (!UseHalfFloatPrecision) - { + var isDirty = CheckForStateChange(ref networkState, ref m_HalfPositionState, ref config, sample, isSynchronization, forceState, transformSpaceChanged); - networkState.ScaleX = localScale.x; - networkState.ScaleY = localScale.y; - networkState.ScaleZ = localScale.z; - } - else - { - networkState.Scale = localScale; - } - flagStates.MarkChanged(AxialType.Scale, true); - isScaleDirty = true; - } - isDirty |= isPositionDirty || isRotationDirty || isScaleDirty; + ApplyTransformDeltaConfig(config); - if (isDirty) + if (config.LogSynchronizationEntry) { - // 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; - } + // Add log entry for this update relative to the client being synchronized + AddLogEntry(ref networkState, targetClientId, true); } - // 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; } @@ -3678,9 +3472,54 @@ private void CleanUpOnDestroyOrDespawn() } DeregisterForTickUpdate(); + DeregisterFromBatchedStateTracking(); CanCommitToTransform = false; } + /// + /// Adds this instance to the when the session is using . + /// + private void RegisterForBatchedStateTracking() + { + if (m_CachedNetworkManager == null || m_CachedNetworkManager.NetworkConfig.TransformSyncMode != TransformSyncModes.Batched) + { + return; + } + +#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; + } + + 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() { @@ -3825,6 +3664,7 @@ internal virtual void InternalInitialization(bool isOwnershipChange = false) m_LastStateTargetPosition = currentPosition; RegisterForTickUpdate(); + RegisterForBatchedStateTracking(); if (UseHalfFloatPrecision && isOwnershipChange && !IsServerAuthoritative() && Interpolate) { @@ -3845,6 +3685,9 @@ 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(); ResetInterpolatedStateToCurrentAuthoritativeState(); m_InternalCurrentPosition = currentPosition; m_LastStateTargetPosition = currentPosition; @@ -4852,9 +4695,18 @@ internal void TickUpdate() Remove(); return; } + if (m_NetworkManager.NetworkConfig.TransformSyncMode == TransformSyncModes.Batched) + { + // Batched: every registered instance is checked in parallel and anything that came back + // dirty sends its state update afterwards. Instances that could not be registered (a + // rigidbody driven one, for example) still tick individually below. + m_NetworkManager.TransformStateManager.RunDeltaCheck(); + } + 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(); } 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..4fa5420ad0 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformDeltaCheck.cs @@ -0,0 +1,609 @@ +using System.Runtime.CompilerServices; +using Unity.Mathematics; +using UnityEngine; + +namespace Unity.Netcode.Components +{ + public partial class NetworkTransform + { + /// + /// Abstraction layer config: + /// Everything needs from the instance it is checking. + /// + /// + /// This creates the abstraction layer between itself and the configuration + /// of the in order to assure the delta check can be run both on the main + /// thread and from within a job.
+ /// A few members are read/write: the check can change the transform space it operates in and it + /// advances the axial frame synchronization bookkeeping, both of which have to make it back to the + /// instance. + ///
+ internal struct TransformDeltaConfig + { + internal float PositionThreshold; + internal float RotAngleThreshold; + internal float ScaleThreshold; + + internal bool SyncPositionX; + internal bool SyncPositionY; + internal bool SyncPositionZ; + internal bool SyncRotAngleX; + internal bool SyncRotAngleY; + internal bool SyncRotAngleZ; + internal bool SyncScaleX; + internal bool SyncScaleY; + internal bool SyncScaleZ; + + internal bool UseQuaternionSynchronization; + internal bool UseQuaternionCompression; + internal bool UseHalfFloatPrecision; + internal bool SlerpPosition; + internal bool Interpolate; + internal bool UseUnreliableDeltas; + internal bool SwitchTransformSpaceWhenParented; + internal bool UseRigidbodyForMotion; + + /// + /// Read/write. can change this when + /// is enabled. + /// + internal bool InLocalSpace; + + internal int CurrentTick; + internal int CachedTickRate; + internal int HalfFloatTargetTickOwnership; + + /// + /// Read/write. The tick slot this instance next sends an axial frame synchronization on. + /// + internal int NextTickSync; + + /// + /// Read/write. Whether a delta has been sent since the last axial frame synchronization. + /// + internal bool DeltaSynch; + + /// + /// Some integration and unit tests disable the , in which case the + /// network tick is not applied to the state. + /// + internal bool Enabled; + + /// + /// Write only. Set when the synchronization path produced a state that the (debug only) log entry + /// handler should be given. Reported back as opposed to being invoked inline so that the delta + /// check itself stays free of anything that cannot run within a job. + /// + internal bool LogSynchronizationEntry; + } + + /// + /// Abstraction layer struct: + /// The transform values compares against, along with the handful of + /// lookups that can only be resolved on the main thread. + /// + /// + /// The position and rotation are already resolved for local versus world space and for whether a + /// rigidbody is driving the motion, so the delta check never has to touch a + /// or a rigidbody itself. + /// + internal struct TransformSample + { + internal Vector3 Position; + internal Quaternion Rotation; + internal Vector3 RotAngles; + internal Vector3 Scale; + internal Vector3 LossyScale; + + /// + /// Whether the associated is considered parented. Resolving this needs + /// a lookup, so it is passed in already resolved. + /// + internal bool HasParentNetworkObject; + + /// + /// Synchronization only. The result of for the client + /// being synchronized. + /// + internal bool ShouldSynchronizeHalfFloat; + + /// + /// Synchronization only. When set, the half float delta uses the converted back value as opposed + /// to the full precision delta position. + /// + internal bool UseHalfDeltaConvertedBack; + } + + /// + /// Abstraction layer struct: + /// Everything the batched delta check reads and writes for a single . + /// + /// + /// Held as one struct (as opposed to several parallel native arrays) so that adding or removing an + /// instance only ever has to keep three collections in step rather than a growing number of them. + /// + internal struct TransformDeltaEntry + { + /// + /// The last sent state, updated in place by the delta check. + /// + internal NetworkTransformState State; + + /// + /// The instance's , updated in place by the delta check. + /// + internal NetworkDeltaPosition HalfPositionState; + + internal TransformDeltaConfig Config; + + /// + /// The parts of the sample that can only be resolved on the main thread. The job fills in the + /// transform values it reads through the . + /// + internal TransformSample Sample; + + /// + /// Whether the transform currently has a parent, resolved on the main thread since a job cannot + /// walk the hierarchy. + /// + internal bool TransformHasParent; + + /// + /// Set by the main thread when a full state update is being forced for this instance. + /// + internal bool ForceState; + + /// + /// Result. Set by the job when there is a state update to send. + /// + internal bool IsDirty; + } + + /// + /// Abstraction Layer Method: + /// Resolves which transform space the delta check operates in. + /// + /// + /// Runs before the transform is sampled because it determines whether the local or the world values + /// are the ones being compared. Kept separate (as opposed to being folded into + /// ) so that neither caller has to sample both spaces. + /// + /// The instance configuration. may be updated. + /// The state flags being updated. + /// Whether the transform currently has a parent. + /// Whether this is the initial synchronization of the state. + /// Set when the resulting state update has to be a full one. + /// true when the transform space changed, which makes the state dirty on its own. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool ResolveTransformSpace(ref TransformDeltaConfig config, ref FlagStates flagStates, bool transformHasParent, bool isSynchronization, ref bool forceState) + { + // All of the checks below, up to the delta position checking portion, are to determine if the + // authority changed a property during runtime that requires a full synchronizing. +#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D + if (config.UseRigidbodyForMotion || (config.InLocalSpace == flagStates.InLocalSpace && !isSynchronization)) + { + return false; + } +#else + if (config.InLocalSpace == flagStates.InLocalSpace) + { + return false; + } +#endif + + // When SwitchTransformSpaceWhenParented is set we automatically set our local space based on whether + // we are parented or not. + flagStates.InLocalSpace = config.SwitchTransformSpaceWhenParented ? transformHasParent : config.InLocalSpace; + if (config.SwitchTransformSpaceWhenParented) + { + config.InLocalSpace = flagStates.InLocalSpace; + } + + // If we are already teleporting preserve the teleport flag. + // If we don't have SwitchTransformSpaceWhenParented set or we are synchronizing, + // then set the teleport flag. + flagStates.IsTeleportingNextFrame |= !config.SwitchTransformSpaceWhenParented || isSynchronization; + + // Otherwise, if SwitchTransformSpaceWhenParented is set we force a full state update. + // If interpolation is enabled, then any non-authority instance will update any pending + // buffered values to the correct world or local space values. + forceState = config.SwitchTransformSpaceWhenParented; + return true; + } + + /// + /// Abstraction Layer Method: + /// Determines whether the sampled transform differs from the last state that was sent. + /// + /// + /// This is primary delta check implementation.
+ /// When running in per-instance mode, invokes this on a per instance basis.
+ /// When running in batched synchronization mode, this is invoked from within the job. + ///
+ /// The last sent state, updated in place with any changes. + /// The instance's , updated in place. + /// The instance configuration, some of which is updated in place. + /// The sampled transform values. + /// Whether this is the initial synchronization of the state. + /// Whether a full state update is being forced. + /// The result of . + /// true when there is a state update to send. + internal static bool CheckForStateChange(ref NetworkTransformState networkState, ref NetworkDeltaPosition halfPositionState, + ref TransformDeltaConfig config, in TransformSample sample, bool isSynchronization, bool forceState, bool transformSpaceChanged) + { + var flagStates = networkState.FlagStates; + + // As long as we are not doing our first synchronization and we are sending unreliable deltas, each + // NetworkTransform will stagger its full transfom synchronization over a 1 second period based on the + // assigned tick slot (m_TickSync). + // More about DeltaSynch: + // If we have not sent any deltas since our last frame synch, then this will prevent us from sending + // frame synch's when the object is at rest. If this is false and a state update is detected and sent, + // then it will be set to true and each subsequent tick will do this check to determine if it should + // send a full frame synch. + var isAxisSync = false; + // We compare against the NetworkTickSystem version since ServerTime is set when updating ticks + if (config.UseUnreliableDeltas && !isSynchronization && config.DeltaSynch && config.NextTickSync <= config.CurrentTick) + { + // Increment to the next frame synch tick position for this instance + config.NextTickSync += config.CachedTickRate; + // If we are teleporting, we do not need to send a frame synch for this tick slot + // as a "frame synch" really is effectively just a teleport. + isAxisSync = !flagStates.IsTeleportingNextFrame; + // Reset our delta synch trigger so we don't send another frame synch until we + // send at least 1 unreliable state update after this fame synch or teleport + config.DeltaSynch = false; + } + + // This is used to determine if we need to send the state update reliably (if we are doing an axial sync) + flagStates.UnreliableFrameSync = isAxisSync; + + var isTeleportingAndNotSynchronizing = flagStates.IsTeleportingNextFrame && !isSynchronization; + // The transform space changing is a state change on its own. + var isDirty = transformSpaceChanged; + var isPositionDirty = isTeleportingAndNotSynchronizing ? flagStates.HasPositionChange : false; + var isRotationDirty = isTeleportingAndNotSynchronizing ? flagStates.HasRotAngleChange : false; + var isScaleDirty = isTeleportingAndNotSynchronizing ? flagStates.HasScaleChange : false; + + flagStates.SwitchTransformSpaceWhenParented = config.SwitchTransformSpaceWhenParented; + + var position = sample.Position; + var rotation = sample.Rotation; + var rotAngles = sample.RotAngles; + var scale = sample.Scale; + var positionThreshold = config.PositionThreshold; + var rotationThreshold = config.RotAngleThreshold; + + var synchronizePosition = config.SyncPositionX || config.SyncPositionY || config.SyncPositionZ; + var synchronizeRotation = config.SyncRotAngleX || config.SyncRotAngleY || config.SyncRotAngleZ; + var synchronizeScale = config.SyncScaleX || config.SyncScaleY || config.SyncScaleZ; + + flagStates.IsSynchronizing = isSynchronization; + + // Check for parenting when synchronizing and/or teleporting + if (isSynchronization || flagStates.IsTeleportingNextFrame || forceState) + { + // This all has to do with complex nested hierarchies and how it impacts scale + // when set for the first time or teleporting and depends upon whether the + // NetworkObject is parented (or "de-parented") at the same time any scale + // values are applied. + flagStates.IsParented = sample.HasParentNetworkObject; + } + + if (config.Interpolate != flagStates.UseInterpolation) + { + flagStates.UseInterpolation = config.Interpolate; + isDirty = true; + // When we change from interpolating to not interpolating (or vice versa) we need to synchronize/reset everything + flagStates.IsTeleportingNextFrame = true; + } + + if (config.UseQuaternionSynchronization != flagStates.QuaternionSync) + { + flagStates.QuaternionSync = config.UseQuaternionSynchronization; + isDirty = true; + flagStates.IsTeleportingNextFrame = true; + } + + if (config.UseQuaternionCompression != flagStates.QuaternionCompression) + { + flagStates.QuaternionCompression = config.UseQuaternionCompression; + isDirty = true; + flagStates.IsTeleportingNextFrame = true; + } + + if (config.UseHalfFloatPrecision != flagStates.UseHalfFloatPrecision) + { + flagStates.UseHalfFloatPrecision = config.UseHalfFloatPrecision; + isDirty = true; + flagStates.IsTeleportingNextFrame = true; + } + + if (config.SlerpPosition != flagStates.UsePositionSlerp) + { + flagStates.UsePositionSlerp = config.SlerpPosition; + isDirty = true; + flagStates.IsTeleportingNextFrame = true; + } + + if (config.UseUnreliableDeltas != flagStates.UseUnreliableDeltas) + { + flagStates.UseUnreliableDeltas = config.UseUnreliableDeltas; + isDirty = true; + flagStates.IsTeleportingNextFrame = true; + } + + // Begin delta checks against last sent state update + if (!config.UseHalfFloatPrecision) + { + if (config.SyncPositionX && (Mathf.Abs(networkState.PositionX - position.x) >= positionThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) + { + networkState.PositionX = position.x; + flagStates.SetHasPosition(Axis.X, true); + isPositionDirty = true; + } + + if (config.SyncPositionY && (Mathf.Abs(networkState.PositionY - position.y) >= positionThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) + { + networkState.PositionY = position.y; + flagStates.SetHasPosition(Axis.Y, true); + isPositionDirty = true; + } + + if (config.SyncPositionZ && (Mathf.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 && Mathf.Abs(position.x - previousPosition.x) >= positionThreshold) + || (config.SyncPositionY && Mathf.Abs(position.y - previousPosition.y) >= positionThreshold) + || (config.SyncPositionZ && Mathf.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 && (Mathf.Abs(Mathf.DeltaAngle(networkState.RotAngleX, rotAngles.x)) >= rotationThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) + { + networkState.RotAngleX = rotAngles.x; + flagStates.SetHasRotation(Axis.X, true); + isRotationDirty = true; + } + + if (config.SyncRotAngleY && (Mathf.Abs(Mathf.DeltaAngle(networkState.RotAngleY, rotAngles.y)) >= rotationThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) + { + networkState.RotAngleY = rotAngles.y; + flagStates.SetHasRotation(Axis.Y, true); + isRotationDirty = true; + } + + if (config.SyncRotAngleZ && (Mathf.Abs(Mathf.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) + { + var previousRotation = networkState.Rotation.eulerAngles; + isRotationDirty = Mathf.Abs(Mathf.DeltaAngle(previousRotation.x, rotAngles.x)) >= rotationThreshold + || Mathf.Abs(Mathf.DeltaAngle(previousRotation.y, rotAngles.y)) >= rotationThreshold + || Mathf.Abs(Mathf.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 && (Mathf.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 && (Mathf.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 && (Mathf.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 || Mathf.Abs(scale.x - previousScale.x) >= config.ScaleThreshold) + { + isScaleDirty = true; + networkState.Scale.x = scale.x; + flagStates.SetHasScale(Axis.X, config.SyncScaleX); + } + + if (alwaysDirty || Mathf.Abs(scale.y - previousScale.y) >= config.ScaleThreshold) + { + isScaleDirty = true; + networkState.Scale.y = scale.y; + flagStates.SetHasScale(Axis.Y, config.SyncScaleY); + } + + if (alwaysDirty || Mathf.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/NetworkTransformStateManager.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs new file mode 100644 index 0000000000..72a60fc2dd --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using Unity.Collections; +using UnityEngine.Jobs; + +namespace Unity.Netcode.Components +{ + /// + /// Owns the native state that uses to detect and apply + /// changes within a job. + /// + /// + /// One instance per . It is created the first time an instance registers and + /// is disposed when the shuts down, so a project running in + /// never allocates any of this.

+ /// The collections it owns are parallel: index i of each refers to the same + /// . They are only ever mutated through and + /// , both of which apply the same swap back to every collection, so they cannot + /// drift apart. Each registered instance caches its own index in + /// , which makes deregistration O(1) and removes the need + /// for a lookup table. + ///
+ internal class NetworkTransformStateManager : IDisposable + { + private const int k_InitialCapacity = 64; + + /// + /// The registered instances, that are aligned in parallel to and . + /// + private readonly List m_Instances = new List(k_InitialCapacity); + + /// + /// The transform access array for the registered instances. + /// + internal TransformAccessArray TransformAccess; + + /// + /// The most recently sent (authority) or received (non-authority) state per registered instance. + /// + internal NativeList Entries; + + 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); + m_Created = true; + } + + /// + /// Authority Only:
+ /// Registers a so its state is tracked natively. + ///
+ /// + /// Invoked whenever an instance becomes an authority, which includes ownership changes since + /// runs again on each change of ownership. + /// Registering an instance that is already registered does nothing. + /// + internal void Register(NetworkTransform networkTransform) + { + if (m_Disposed || networkTransform.StateManagerIndex >= 0) + { + return; + } + + EnsureCreated(); + + networkTransform.StateManagerIndex = Entries.Length; + m_Instances.Add(networkTransform); + TransformAccess.Add(networkTransform.transform); + // Seed with whatever the instance has already established so the first delta check compares + // against a real state as opposed to a default one. + Entries.Add(new NetworkTransform.TransformDeltaEntry() + { + State = networkTransform.LocalAuthoritativeNetworkState, + }); + } + + /// + /// Authority Only:
+ /// Deregisers a instance from having its transform deltas tracked. + ///
+ /// + /// Invoked on despawn, destroy, and whenever an instance stops being an authority. + /// + internal void Deregister(NetworkTransform networkTransform) + { + var index = networkTransform.StateManagerIndex; + if (m_Disposed || index < 0) + { + return; + } + + networkTransform.StateManagerIndex = -1; + + var lastIndex = m_Instances.Count - 1; + var moved = m_Instances[lastIndex]; + + // Every collection has to receive the same swap back or they stop referring to the same instance. + Entries.RemoveAtSwapBack(index); + TransformAccess.RemoveAtSwapBack(index); + m_Instances[index] = moved; + m_Instances.RemoveAt(lastIndex); + + // The instance that was swapped into this slot has to be told where it now lives. When the + // instance being removed was already the last one there is nothing to move. + if (index != lastIndex) + { + moved.StateManagerIndex = index; + } + } + + /// + /// Runs the delta check job for every registered instance. + /// + /// + /// Invoked once per network tick in place of iterating the instances and having each one check itself. + /// Each instance contributes what only the main thread can resolve, the job performs the detection in + /// parallel, and anything that came back dirty then sends its state update on the main thread in the + /// same order it would have otherwise.

+ /// The job is completed within this call as opposed to being left in flight: the state update has to + /// be sent on the tick it was detected on, so there is nothing to overlap with. + ///
+ internal void RunDeltaCheck() + { + var count = GetCount(); + if (count == 0) + { + return; + } + + // Gather what the job cannot resolve for itself. + for (int i = 0; i < count; i++) + { + var instance = m_Instances[i]; + var entry = Entries[i]; + instance.PrepareBatchedDeltaEntry(ref entry); + Entries[i] = entry; + } + + var job = new DetectTransformDeltaJob() + { + Entries = Entries.AsArray(), + }; + job.Schedule(TransformAccess).Complete(); + + // Apply the results. Iterated by index rather than by instance so that an instance which + // deregisters as a result of its own state update (a despawn from within a callback) cannot + // invalidate the iteration. + for (int i = 0; i < Entries.Length; i++) + { + var instance = m_Instances[i]; + var entry = Entries[i]; + instance.ApplyBatchedDeltaEntry(ref entry); + // The instance may have deregistered while applying, in which case this slot now belongs to a + // different instance and must not be written back. + if (instance.StateManagerIndex == i) + { + Entries[i] = entry; + } + } + } + + public void Dispose() + { + if (m_Disposed) + { + return; + } + m_Disposed = true; + + // Clear the cached index on anything still registered so a late deregister is a no-op as opposed + // to indexing into a disposed collection. + for (int i = 0; i < m_Instances.Count; i++) + { + if (m_Instances[i] != null) + { + m_Instances[i].StateManagerIndex = -1; + } + } + m_Instances.Clear(); + + if (m_Created) + { + 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/Configuration/NetworkConfig.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs index 00e7719e4a..fc5339d59f 100644 --- a/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/NetworkConfig.cs @@ -7,6 +7,35 @@ namespace Unity.Netcode { + /// + /// The synchronization modes for instances that determines + /// whether transform state changes and synchronization are handled per instance or in a parallel job + /// and sent via a single message (i.e. batched NetworkTransform). + /// + /// + /// This is a session wide setting located under Project Settings -> Multiplayer ->Netcode for GameObjects.
+ /// The two modes can not be cross pollinated on a per instance basis. As such, it is a per session global setting + /// for every component instance. + ///
+ public enum TransformSyncModes + { + /// + /// Each detects its own changes on the network tick and + /// sends them as an individual message. This is the original, legacy, approach. + /// + PerInstance, + + /// + /// Changes for all instances are detected within a job and + /// sent as a single batched message per tick. + /// + /// + /// does not apply in this mode. Delivery + /// is determined per state update as opposed to per component. + /// + Batched, + } + /// /// The configuration object used to start server, client and hosts /// @@ -45,6 +74,11 @@ public class NetworkConfig [SerializeField] public NetworkPrefabs Prefabs = new NetworkPrefabs(); + /// + /// A global setting, per session, that determines how instances detect and synchronize their state. + /// + [SerializeField] + internal TransformSyncModes TransformSyncMode = TransformSyncModes.PerInstance; /// /// The tickrate of network ticks. This value controls how often netcode runs user code and sends out data. @@ -354,6 +388,9 @@ public ulong GetConfig(bool cache = true) writer.WriteValueSafe(EnableSceneManagement); writer.WriteValueSafe(EnsureNetworkVariableLengthSafety); writer.WriteValueSafe(RpcHashSize); + // The two transform synchronization modes are not compatible and this needs to be part + // of the hash check during the initial connection request. + writer.WriteValueSafe((byte)TransformSyncMode); if (cache) { diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index 51185321db..46ea108681 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -307,6 +307,11 @@ internal void PromoteSessionOwner(ulong clientId) /// 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(); @@ -1892,6 +1897,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(); From 331f295af6cfe818a0c6c4735c84bdbe2948c07e Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Fri, 14 Aug 2026 21:04:23 -0500 Subject: [PATCH 3/7] update This provides burst compatible math operations for the non-burst compatible ones that NetworkTransform users. --- .../Components/DetectTransformDeltaJob.cs | 4 +- .../Runtime/Components/NetworkTransform.cs | 2 +- .../Components/NetworkTransformDeltaCheck.cs | 40 +- .../Components/NetworkTransformMath.cs | 258 ++++++++++++ .../Components/NetworkTransformMath.cs.meta | 2 + .../NetworkTransformMathTests.cs | 369 ++++++++++++++++++ .../NetworkTransformMathTests.cs.meta | 2 + 7 files changed, 656 insertions(+), 21 deletions(-) create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformMath.cs create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformMath.cs.meta create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs.meta diff --git a/com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs b/com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs index e44779af42..cd6a79fb06 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/DetectTransformDeltaJob.cs @@ -1,3 +1,4 @@ +using Unity.Burst; using Unity.Collections; using UnityEngine.Jobs; using static Unity.Netcode.Components.NetworkTransform; @@ -16,6 +17,7 @@ namespace Unity.Netcode.Components /// write hazard: nothing in this job touches a transform other than the one at its own index, and nothing /// writes to a transform at all. ///
+ [BurstCompile] internal struct DetectTransformDeltaJob : IJobParallelForTransform { /// @@ -43,7 +45,7 @@ public void Execute(int index, TransformAccess transform) 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 = rotation.eulerAngles; + entry.Sample.RotAngles = NetworkTransformMath.EulerAngles(rotation); entry.Sample.Scale = transform.localScale; entry.IsDirty = CheckForStateChange(ref entry.State, ref entry.HalfPositionState, ref entry.Config, diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs index acd3ad0488..314e5ec8b6 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs @@ -2325,7 +2325,7 @@ private bool CheckForStateChange(ref NetworkTransformState networkState, bool is { Position = position, Rotation = rotation, - RotAngles = rotation.eulerAngles, + RotAngles = NetworkTransformMath.EulerAngles(rotation), Scale = CachedTransform.localScale, }; diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformDeltaCheck.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformDeltaCheck.cs index 4fa5420ad0..a14b4fc9a0 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformDeltaCheck.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformDeltaCheck.cs @@ -336,21 +336,21 @@ internal static bool CheckForStateChange(ref NetworkTransformState networkState, // Begin delta checks against last sent state update if (!config.UseHalfFloatPrecision) { - if (config.SyncPositionX && (Mathf.Abs(networkState.PositionX - position.x) >= positionThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) + 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 && (Mathf.Abs(networkState.PositionY - position.y) >= positionThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) + 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 && (Mathf.Abs(networkState.PositionZ - position.z) >= positionThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) + if (config.SyncPositionZ && (math.abs(networkState.PositionZ - position.z) >= positionThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) { networkState.PositionZ = position.z; flagStates.SetHasPosition(Axis.Z, true); @@ -372,9 +372,9 @@ internal static bool CheckForStateChange(ref NetworkTransformState networkState, if (!isPositionDirty) { var previousPosition = halfPositionState.PreviousPosition; - isPositionDirty = (config.SyncPositionX && Mathf.Abs(position.x - previousPosition.x) >= positionThreshold) - || (config.SyncPositionY && Mathf.Abs(position.y - previousPosition.y) >= positionThreshold) - || (config.SyncPositionZ && Mathf.Abs(position.z - previousPosition.z) >= positionThreshold); + 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) @@ -464,21 +464,21 @@ internal static bool CheckForStateChange(ref NetworkTransformState networkState, if (!config.UseQuaternionSynchronization) { - if (config.SyncRotAngleX && (Mathf.Abs(Mathf.DeltaAngle(networkState.RotAngleX, rotAngles.x)) >= rotationThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) + 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 && (Mathf.Abs(Mathf.DeltaAngle(networkState.RotAngleY, rotAngles.y)) >= rotationThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) + 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 && (Mathf.Abs(Mathf.DeltaAngle(networkState.RotAngleZ, rotAngles.z)) >= rotationThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) + 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); @@ -492,10 +492,12 @@ internal static bool CheckForStateChange(ref NetworkTransformState networkState, // For quaternion synchronization, if one angle is dirty we send a full update if (!isRotationDirty) { - var previousRotation = networkState.Rotation.eulerAngles; - isRotationDirty = Mathf.Abs(Mathf.DeltaAngle(previousRotation.x, rotAngles.x)) >= rotationThreshold - || Mathf.Abs(Mathf.DeltaAngle(previousRotation.y, rotAngles.y)) >= rotationThreshold - || Mathf.Abs(Mathf.DeltaAngle(previousRotation.z, rotAngles.z)) >= rotationThreshold; + // 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) { @@ -520,21 +522,21 @@ internal static bool CheckForStateChange(ref NetworkTransformState networkState, { if (!config.UseHalfFloatPrecision) { - if (config.SyncScaleX && (Mathf.Abs(networkState.ScaleX - scale.x) >= config.ScaleThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) + 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 && (Mathf.Abs(networkState.ScaleY - scale.y) >= config.ScaleThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) + 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 && (Mathf.Abs(networkState.ScaleZ - scale.z) >= config.ScaleThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) + if (config.SyncScaleZ && (math.abs(networkState.ScaleZ - scale.z) >= config.ScaleThreshold || flagStates.IsTeleportingNextFrame || isAxisSync || forceState)) { networkState.ScaleZ = scale.z; flagStates.SetHasScale(Axis.Z, true); @@ -547,21 +549,21 @@ internal static bool CheckForStateChange(ref NetworkTransformState networkState, // 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 || Mathf.Abs(scale.x - previousScale.x) >= config.ScaleThreshold) + 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 || Mathf.Abs(scale.y - previousScale.y) >= config.ScaleThreshold) + 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 || Mathf.Abs(scale.z - previousScale.z) >= config.ScaleThreshold) + if (alwaysDirty || math.abs(scale.z - previousScale.z) >= config.ScaleThreshold) { isScaleDirty = true; networkState.Scale.z = scale.z; 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/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs new file mode 100644 index 0000000000..6a05b301df --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs @@ -0,0 +1,369 @@ +using System; +using System.Text; +using NUnit.Framework; +using Unity.Mathematics; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Measures how closely agrees with the engine math it replaces. + /// + /// + /// The replacements exist because the engine equivalents are native bindings that Burst cannot compile. + /// Some of them are ports of implementations that are managed C# in the engine and are expected to match + /// exactly; the rest are mathematically equivalent but cannot be verified as bit identical because the + /// engine's operation order is not observable.

+ /// Each test reports the worst disagreement it found, so a failure states the actual measured error rather + /// than just that a threshold was crossed. + ///
+ // These tests do not need to run against the Rust server. + [IgnoreIfServiceEnvironmentVariableSet] + internal class NetworkTransformMathTests + { + private const int k_Iterations = 20000; + + /// + /// Ports of engine implementations that are managed C#, so they are expected to match exactly. + /// + private const float k_ExactTolerance = 0.0f; + + /// + /// Replacements for native implementations. Tight enough that a real divergence fails while ordinary + /// floating point reassociation does not. + /// + private const float k_EquivalentTolerance = 0.001f; + + /// + /// agrees with the engine to within a single float ulp + /// over the value ranges used here, but not bit for bit. + /// + /// + /// The remaining difference is one rounding step, not an algorithmic one: every other ported function + /// (including the scalar , which shares this + /// arithmetic) matches exactly. Chasing it would mean guessing at how the engine's build contracts + /// multiply and add, and a difference this size is far below anything interpolation can express. + /// + private const float k_SingleUlpTolerance = 1E-5f; + + private static System.Random s_Random; + + [SetUp] + public void SetUp() + { + // Fixed seed so a failure is reproducible. + s_Random = new System.Random(20260814); + } + + private static float RandomFloat(float min, float max) + { + return (float)(s_Random.NextDouble() * (max - min) + min); + } + + private static Vector3 RandomVector(float range) + { + return new Vector3(RandomFloat(-range, range), RandomFloat(-range, range), RandomFloat(-range, range)); + } + + private static Quaternion RandomRotation() + { + // Uniformly distributed rotations, which reaches the pole cases the euler conversion special cases. + var u1 = (float)s_Random.NextDouble(); + var u2 = (float)s_Random.NextDouble(); + var u3 = (float)s_Random.NextDouble(); + var sqrt1MinusU1 = Mathf.Sqrt(1.0f - u1); + var sqrtU1 = Mathf.Sqrt(u1); + return new Quaternion( + sqrt1MinusU1 * Mathf.Sin(2.0f * Mathf.PI * u2), + sqrt1MinusU1 * Mathf.Cos(2.0f * Mathf.PI * u2), + sqrtU1 * Mathf.Sin(2.0f * Mathf.PI * u3), + sqrtU1 * Mathf.Cos(2.0f * Mathf.PI * u3)); + } + + /// + /// Tracks the largest disagreement seen so a failure can report it. + /// + private struct Worst + { + public float Error; + public string Detail; + + public void Record(float error, Func detail) + { + if (error > Error) + { + Error = error; + Detail = detail(); + } + } + + public void Assert(string name, float tolerance) + { + NUnit.Framework.Assert.LessOrEqual(Error, tolerance, + $"{name} deviates from the engine implementation by {Error} (tolerance {tolerance}).\n{Detail}"); + } + } + + [Test] + public void DeltaAngleMatchesEngine() + { + var worst = new Worst(); + for (int i = 0; i < k_Iterations; i++) + { + var current = RandomFloat(-1080.0f, 1080.0f); + var target = RandomFloat(-1080.0f, 1080.0f); + var expected = Mathf.DeltaAngle(current, target); + var actual = NetworkTransformMath.DeltaAngle(current, target); + worst.Record(Mathf.Abs(expected - actual), () => $"current={current} target={target} expected={expected} actual={actual}"); + } + worst.Assert(nameof(NetworkTransformMath.DeltaAngle), k_ExactTolerance); + } + + [Test] + public void RepeatMatchesEngine() + { + var worst = new Worst(); + for (int i = 0; i < k_Iterations; i++) + { + var t = RandomFloat(-1080.0f, 1080.0f); + var expected = Mathf.Repeat(t, 360.0f); + var actual = NetworkTransformMath.Repeat(t, 360.0f); + worst.Record(Mathf.Abs(expected - actual), () => $"t={t} expected={expected} actual={actual}"); + } + worst.Assert(nameof(NetworkTransformMath.Repeat), k_ExactTolerance); + } + + [Test] + public void LerpVector3MatchesEngine() + { + var worst = new Worst(); + for (int i = 0; i < k_Iterations; i++) + { + var start = RandomVector(100.0f); + var end = RandomVector(100.0f); + var t = RandomFloat(-0.5f, 1.5f); + var expected = Vector3.Lerp(start, end, t); + var actual = (Vector3)NetworkTransformMath.Lerp(start, end, t); + worst.Record(Vector3.Distance(expected, actual), () => $"start={start} end={end} t={t} expected={expected} actual={actual}"); + } + worst.Assert("Lerp(Vector3)", k_ExactTolerance); + } + + [Test] + public void SmoothDampVector3MatchesEngine() + { + var worst = new Worst(); + for (int i = 0; i < k_Iterations; i++) + { + var current = RandomVector(50.0f); + var target = RandomVector(50.0f); + var velocity = RandomVector(10.0f); + var smoothTime = RandomFloat(0.001f, 1.0f); + var maxSpeed = RandomFloat(0.1f, 100.0f); + var deltaTime = RandomFloat(0.001f, 0.1f); + + var engineVelocity = velocity; + var expected = Vector3.SmoothDamp(current, target, ref engineVelocity, smoothTime, maxSpeed, deltaTime); + + float3 portedVelocity = velocity; + var actual = (Vector3)NetworkTransformMath.SmoothDamp(current, target, ref portedVelocity, smoothTime, maxSpeed, deltaTime); + + var error = Mathf.Max(Vector3.Distance(expected, actual), Vector3.Distance(engineVelocity, (Vector3)portedVelocity)); + worst.Record(error, () => $"current={current} target={target} smoothTime={smoothTime} maxSpeed={maxSpeed} dt={deltaTime}\n" + + $" expected={expected} vel={engineVelocity}\n actual ={actual} vel={(Vector3)portedVelocity}"); + } + worst.Assert("SmoothDamp(Vector3)", k_SingleUlpTolerance); + } + + [Test] + public void SmoothDampAngleMatchesEngine() + { + var worst = new Worst(); + for (int i = 0; i < k_Iterations; i++) + { + var current = RandomFloat(-720.0f, 720.0f); + var target = RandomFloat(-720.0f, 720.0f); + var velocity = RandomFloat(-50.0f, 50.0f); + var smoothTime = RandomFloat(0.001f, 1.0f); + var maxSpeed = RandomFloat(0.1f, 500.0f); + var deltaTime = RandomFloat(0.001f, 0.1f); + + var engineVelocity = velocity; + var expected = Mathf.SmoothDampAngle(current, target, ref engineVelocity, smoothTime, maxSpeed, deltaTime); + + var portedVelocity = velocity; + var actual = NetworkTransformMath.SmoothDampAngle(current, target, ref portedVelocity, smoothTime, maxSpeed, deltaTime); + + var error = Mathf.Max(Mathf.Abs(expected - actual), Mathf.Abs(engineVelocity - portedVelocity)); + worst.Record(error, () => $"current={current} target={target} smoothTime={smoothTime} dt={deltaTime} expected={expected} actual={actual}"); + } + worst.Assert("SmoothDampAngle", k_ExactTolerance); + } + + [Test] + public void EulerAnglesMatchesEngine() + { + var worst = new Worst(); + for (int i = 0; i < k_Iterations; i++) + { + var rotation = RandomRotation(); + var expected = rotation.eulerAngles; + var actual = (Vector3)NetworkTransformMath.EulerAngles(rotation); + + // Compared as angles so that 359.999 and 0.001 are not treated as a large disagreement. + var error = Mathf.Max(Mathf.Abs(Mathf.DeltaAngle(expected.x, actual.x)), + Mathf.Max(Mathf.Abs(Mathf.DeltaAngle(expected.y, actual.y)), Mathf.Abs(Mathf.DeltaAngle(expected.z, actual.z)))); + worst.Record(error, () => $"rotation={rotation} expected={expected} actual={actual}"); + } + worst.Assert(nameof(NetworkTransformMath.EulerAngles), k_EquivalentTolerance); + } + + [Test] + public void EulerMatchesEngine() + { + var worst = new Worst(); + for (int i = 0; i < k_Iterations; i++) + { + var euler = new Vector3(RandomFloat(-360.0f, 360.0f), RandomFloat(-360.0f, 360.0f), RandomFloat(-360.0f, 360.0f)); + var expected = Quaternion.Euler(euler); + var actual = (Quaternion)NetworkTransformMath.Euler(euler); + + // q and -q are the same rotation, so compare the angle between them. + var error = Quaternion.Angle(expected, actual); + worst.Record(error, () => $"euler={euler} expected={expected} actual={actual}"); + } + worst.Assert(nameof(NetworkTransformMath.Euler), k_EquivalentTolerance); + } + + [Test] + public void SlerpQuaternionMatchesEngine() + { + var worst = new Worst(); + for (int i = 0; i < k_Iterations; i++) + { + var start = RandomRotation(); + var end = RandomRotation(); + var t = RandomFloat(0.0f, 1.0f); + var expected = Quaternion.Slerp(start, end, t); + var actual = (Quaternion)NetworkTransformMath.Slerp(start, end, t); + worst.Record(Quaternion.Angle(expected, actual), () => $"start={start} end={end} t={t} expected={expected} actual={actual}"); + } + worst.Assert("Slerp(Quaternion)", k_EquivalentTolerance); + } + + [Test] + public void LerpQuaternionMatchesEngine() + { + var worst = new Worst(); + for (int i = 0; i < k_Iterations; i++) + { + var start = RandomRotation(); + var end = RandomRotation(); + var t = RandomFloat(0.0f, 1.0f); + var expected = Quaternion.Lerp(start, end, t); + var actual = (Quaternion)NetworkTransformMath.Nlerp(start, end, t); + worst.Record(Quaternion.Angle(expected, actual), () => $"start={start} end={end} t={t} expected={expected} actual={actual}"); + } + worst.Assert(nameof(NetworkTransformMath.Nlerp), k_EquivalentTolerance); + } + + [Test] + public void SlerpVector3MatchesEngine() + { + var worst = new Worst(); + var worstAntiparallel = new Worst(); + var antiparallelCount = 0; + + for (int i = 0; i < k_Iterations; i++) + { + var start = RandomVector(50.0f); + var end = RandomVector(50.0f); + var t = RandomFloat(0.0f, 1.0f); + var expected = Vector3.Slerp(start, end, t); + var actual = (Vector3)NetworkTransformMath.Slerp((float3)start, (float3)end, t); + var error = Vector3.Distance(expected, actual); + + // Nearly antiparallel inputs have no defined rotation plane, so both implementations have to + // pick one arbitrarily. Measured and reported, but not asserted on. + var cosAngle = Vector3.Dot(start.normalized, end.normalized); + if (cosAngle < -0.999f) + { + antiparallelCount++; + worstAntiparallel.Record(error, () => $"start={start} end={end} t={t}"); + } + else + { + worst.Record(error, () => $"start={start} end={end} t={t} expected={expected} actual={actual}"); + } + } + + Debug.Log($"Slerp(Vector3): nearly antiparallel max deviation {worstAntiparallel.Error:E3} over {antiparallelCount} samples (not asserted)."); + worst.Assert("Slerp(Vector3)", k_EquivalentTolerance); + } + + /// + /// Reports every measurement in one place so the numbers can be reviewed together rather than one + /// assertion at a time. + /// + [Test] + public void ReportAllDeviations() + { + var report = new StringBuilder(); + report.AppendLine($"{nameof(NetworkTransformMath)} agreement with the engine ({k_Iterations} samples each):"); + + void Measure(string name, Func sample) + { + var worst = 0.0f; + for (int i = 0; i < k_Iterations; i++) + { + worst = Mathf.Max(worst, sample()); + } + report.AppendLine($" {name,-24} max deviation {worst:E3}"); + } + + Measure("DeltaAngle", () => + { + var a = RandomFloat(-1080.0f, 1080.0f); + var b = RandomFloat(-1080.0f, 1080.0f); + return Mathf.Abs(Mathf.DeltaAngle(a, b) - NetworkTransformMath.DeltaAngle(a, b)); + }); + Measure("EulerAngles", () => + { + var r = RandomRotation(); + var e = r.eulerAngles; + var a = (Vector3)NetworkTransformMath.EulerAngles(r); + return Mathf.Max(Mathf.Abs(Mathf.DeltaAngle(e.x, a.x)), Mathf.Max(Mathf.Abs(Mathf.DeltaAngle(e.y, a.y)), Mathf.Abs(Mathf.DeltaAngle(e.z, a.z)))); + }); + Measure("Euler", () => + { + var e = new Vector3(RandomFloat(-360.0f, 360.0f), RandomFloat(-360.0f, 360.0f), RandomFloat(-360.0f, 360.0f)); + return Quaternion.Angle(Quaternion.Euler(e), (Quaternion)NetworkTransformMath.Euler(e)); + }); + Measure("Slerp(Quaternion)", () => + { + var s = RandomRotation(); + var e = RandomRotation(); + var t = RandomFloat(0.0f, 1.0f); + return Quaternion.Angle(Quaternion.Slerp(s, e, t), (Quaternion)NetworkTransformMath.Slerp(s, e, t)); + }); + Measure("Lerp(Quaternion)", () => + { + var s = RandomRotation(); + var e = RandomRotation(); + var t = RandomFloat(0.0f, 1.0f); + return Quaternion.Angle(Quaternion.Lerp(s, e, t), (Quaternion)NetworkTransformMath.Nlerp(s, e, t)); + }); + Measure("Slerp(Vector3)", () => + { + var s = RandomVector(50.0f); + var e = RandomVector(50.0f); + var t = RandomFloat(0.0f, 1.0f); + return Vector3.Distance(Vector3.Slerp(s, e, t), (Vector3)NetworkTransformMath.Slerp((float3)s, (float3)e, t)); + }); + + Debug.Log(report.ToString()); + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs.meta new file mode 100644 index 0000000000..6aa632ae1a --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformMathTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 78923d5c3a7b9814096112ab77c999a7 \ No newline at end of file From e3267b71e869c6a73d4baaec35f372ab6a79a56b Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Sat, 15 Aug 2026 15:33:44 -0500 Subject: [PATCH 4/7] update Connecting the jobs to the previous work as well as the batched message type. Detecting changes in state and interpolation are handled in jobs as opposed to the main thread. --- .../Interpolator/InterpolateTransformJob.cs | 111 +++ .../InterpolateTransformJob.cs.meta | 2 + .../Interpolator/NativeInterpolator.cs | 646 ++++++++++++++++++ .../Interpolator/NativeInterpolator.cs.meta | 2 + .../Runtime/Components/NetworkTransform.cs | 542 +++++++++++++-- .../NetworkTransformStateManager.cs | 433 +++++++++++- .../Components/TransformHandleAllocator.cs | 147 ++++ .../TransformHandleAllocator.cs.meta | 2 + .../Runtime/Core/NetworkManager.cs | 8 + .../Runtime/Messaging/ILPPMessageProvider.cs | 2 + .../Runtime/Messaging/MessageDelivery.cs | 1 + .../Messages/NetworkTransformBatchMessage.cs | 97 +++ .../NetworkTransformBatchMessage.cs.meta | 2 + .../NativeInterpolatorTests.cs | 337 +++++++++ .../NativeInterpolatorTests.cs.meta | 2 + .../NetworkTransformStateBaselineTests.cs | 30 + .../TransformHandleAllocatorTests.cs | 140 ++++ .../TransformHandleAllocatorTests.cs.meta | 2 + .../Assets/AddressableAssetsData/link.xml | 22 + .../AddressableAssetsData/link.xml.meta | 7 + 20 files changed, 2479 insertions(+), 56 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/Runtime/Components/TransformHandleAllocator.cs create mode 100644 com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs.meta create mode 100644 com.unity.netcode.gameobjects/Runtime/Messaging/Messages/NetworkTransformBatchMessage.cs create mode 100644 com.unity.netcode.gameobjects/Runtime/Messaging/Messages/NetworkTransformBatchMessage.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 create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/TransformHandleAllocatorTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/TransformHandleAllocatorTests.cs.meta create mode 100644 testproject/Assets/AddressableAssetsData/link.xml create mode 100644 testproject/Assets/AddressableAssetsData/link.xml.meta diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/InterpolateTransformJob.cs b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/InterpolateTransformJob.cs new file mode 100644 index 0000000000..893aeed9b5 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/InterpolateTransformJob.cs @@ -0,0 +1,111 @@ +using Unity.Burst; +using Unity.Collections; +using Unity.Jobs; +using Unity.Mathematics; + +namespace Unity.Netcode.Components +{ + /// + /// The NGO non-authority instance's transform state used by . + /// See also: + /// - + /// - + /// + internal struct InterpolationEntry + { + internal NativeInterpolatorState Position; + internal NativeInterpolatorState Rotation; + internal NativeInterpolatorState Scale; + + /// + /// The delta frame time whether fixed or standard delta. + /// + internal float DeltaTime; + + /// + /// The "ticks ago" time used to decide which buffered measurements are ready to consume. + /// + internal double TickLatencyAsTime; + + /// + /// The render time used by only. + /// + internal double LegacyRenderTime; + + internal double CurrentTime; + internal double MinDeltaTime; + internal double MaxDeltaTime; + + internal NetworkTransform.InterpolationTypes PositionInterpolationType; + internal NetworkTransform.InterpolationTypes RotationInterpolationType; + internal NetworkTransform.InterpolationTypes ScaleInterpolationType; + + internal bool SynchronizePosition; + internal bool SynchronizeRotation; + internal bool SynchronizeScale; + + // Results, read back on the main thread and applied to the transform there. + internal float4 InterpolatedPosition; + internal float4 InterpolatedRotation; + internal float4 InterpolatedScale; + } + + /// + /// Non-Authority Only: + /// Advances the interpolators for every registered non-authority in + /// parallel. + /// + /// + /// This performs the buffer consumption and the interpolation math only. Applying the results to the + /// transforms stays on the main thread for now (keeps this free of any hierarchy write order of operation complexities).
+ ///
+ /// Also, note that each entry owns its own slice of which assures no two indices address the same + /// items and that the whole array can be written without aliasing (pointing to the same thing). + ///
+ [BurstCompile] + internal struct InterpolateTransformJob : IJobParallelFor + { + public NativeArray Entries; + + /// + /// The shared state measurement storage. Disabling the safety restriction is what allows each index to write + /// into its own slice of one array; keeps those + /// slices disjoint. + /// + [NativeDisableParallelForRestriction] + public NativeArray BufferedItems; + + public void Execute(int index) + { + var entry = Entries[index]; + + if (entry.SynchronizePosition) + { + entry.InterpolatedPosition = Advance(ref entry.Position, ref entry, entry.PositionInterpolationType); + } + + if (entry.SynchronizeRotation) + { + entry.InterpolatedRotation = Advance(ref entry.Rotation, ref entry, entry.RotationInterpolationType); + } + + if (entry.SynchronizeScale) + { + entry.InterpolatedScale = Advance(ref entry.Scale, ref entry, entry.ScaleInterpolationType); + } + + Entries[index] = entry; + } + + private float4 Advance(ref NativeInterpolatorState state, ref InterpolationEntry entry, NetworkTransform.InterpolationTypes interpolationType) + { + if (interpolationType == NetworkTransform.InterpolationTypes.LegacyLerp) + { + return NativeInterpolator.UpdateLegacy(ref state, ref BufferedItems, entry.DeltaTime, entry.LegacyRenderTime, entry.CurrentTime); + } + + return NativeInterpolator.Update(ref state, ref BufferedItems, entry.DeltaTime, entry.TickLatencyAsTime, + entry.MinDeltaTime, entry.MaxDeltaTime, interpolationType == NetworkTransform.InterpolationTypes.Lerp); + } + } +} diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/InterpolateTransformJob.cs.meta b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/InterpolateTransformJob.cs.meta new file mode 100644 index 0000000000..fcdfab692b --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/InterpolateTransformJob.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 12ab83c3ca168ef4ab2c9b4addaa61f4 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs new file mode 100644 index 0000000000..dd6a645dcb --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs @@ -0,0 +1,646 @@ +using System.Runtime.CompilerServices; +using Unity.Collections; +using Unity.Mathematics; + +namespace Unity.Netcode.Components +{ + /// + /// The value type being interpolated for a given . + /// + internal enum InterpolatorValueKind + { + /// + /// Used to define the position or scale states. + /// + Vector3, + + /// + /// Always used for rotation. + /// + Quaternion, + } + + /// + /// The blittable (managed and native compatible) equivalent of . + /// + /// + /// A single covers every transform value type being synchronized.
+ /// For position and scale, the w (4th) element is not used. + ///
+ internal struct BufferedItemNative + { + internal float4 Item; + internal double TimeSent; + internal int ItemId; + } + + /// + /// The blittable (managed and native compatible) equivalent of a and its + /// . + /// + /// + /// The managed interpolator holds its measurements in a + /// and tracks the parent each measurement was taken under, neither of + /// which can exist inside a job. Here the measurements live in a fixed size ring buffer carved out of one + /// shared native array, addressed by .
+ ///
+ /// The smooth parenting transition flag, , + /// is excluded from this state as it is handled differently.
+ /// - provides additional details on this.
+ /// - is where this happens (for now). + ///
+ internal struct NativeInterpolatorState + { + /// + /// Where this interpolator's slice of the shared item array begins. + /// + internal int BufferOffset; + internal int BufferCapacity; + + /// + /// Index of the oldest buffered item, relative to . + /// + internal int BufferHead; + internal int BufferCount; + + internal InterpolatorValueKind ValueKind; + + /// + /// Whether to slerp rather than lerp. Position uses this for + /// and rotation uses it when not running at half + /// precision. + /// + internal bool IsSlerp; + + internal bool LerpSmoothEnabled; + internal float MaximumInterpolationTime; + + /// + /// The blittable equivalbent. + /// + internal float4 CurrentValue; + internal float4 PreviousValue; + internal float4 NextValue; + internal float4 RateOfChange; + internal BufferedItemNative Target; + internal bool HasTarget; + internal double StartTime; + internal double EndTime; + internal double TimeToTargetValue; + internal double DeltaTime; + internal double MaxDeltaTime; + internal double LastRemainingTime; + internal float LerpT; + internal bool TargetReached; + internal float CurrentDeltaTime; + + // State measurement tracking related properties + internal double LastMeasurementAddedTime; + internal int BufferCounter; + internal int ItemsReceivedThisFrame; + internal BufferedItemNative LastBufferedItemReceived; + } + + /// + /// A job friendly version of the . + /// + /// + /// The managed implementation stays in place as batched s is + /// a user opt-in feature and the original managed version must continue to work as expected + /// until it becomes deprecated. + /// + internal static class NativeInterpolator + { + /// + /// Matches 's buffer count limit, which is the point at + /// which it gives up on interpolating and teleports to the newest value. + /// + internal const int BufferCountLimit = 100; + + private const float k_ApproximateLowPrecision = 0.000001f; + private const float k_ApproximateHighPrecision = 1E-10f; + private const double k_SmallValue = 9.999999439624929E-11; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static float GetPrecision(in NativeInterpolatorState state) + { + return state.BufferCount == 0 ? k_ApproximateHighPrecision : k_ApproximateLowPrecision; + } + + #region Job friendly ring buffer (i.e. Queue) methods + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static BufferedItemNative Peek(in NativeInterpolatorState state, in NativeArray items) + { + return items[state.BufferOffset + state.BufferHead]; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static BufferedItemNative Dequeue(ref NativeInterpolatorState state, in NativeArray items) + { + var item = items[state.BufferOffset + state.BufferHead]; + state.BufferHead = (state.BufferHead + 1) % state.BufferCapacity; + state.BufferCount--; + return item; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void Enqueue(ref NativeInterpolatorState state, ref NativeArray items, in BufferedItemNative item) + { + if (state.BufferCount == state.BufferCapacity) + { + // Full: drop the oldest so the newest always makes it in, which is the behavior the managed + // interpolator gets from its unbounded queue combined with the buffer count limit below. + state.BufferHead = (state.BufferHead + 1) % state.BufferCapacity; + state.BufferCount--; + } + var tail = (state.BufferHead + state.BufferCount) % state.BufferCapacity; + items[state.BufferOffset + tail] = item; + state.BufferCount++; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void ClearBuffer(ref NativeInterpolatorState state) + { + state.BufferHead = 0; + state.BufferCount = 0; + } + + #endregion + + #region Interpolation, Smooth dampening, and approximation methods + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static float4 Interpolate(in NativeInterpolatorState state, float4 start, float4 end, float time) + { + if (state.ValueKind == InterpolatorValueKind.Quaternion) + { + return state.IsSlerp + ? NetworkTransformMath.Slerp(new quaternion(start), new quaternion(end), time).value + : NetworkTransformMath.Nlerp(new quaternion(start), new quaternion(end), time).value; + } + + var result = state.IsSlerp + ? NetworkTransformMath.Slerp(start.xyz, end.xyz, time) + : NetworkTransformMath.Lerp(start.xyz, end.xyz, time); + return new float4(result, 0.0f); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static float4 SmoothDamp(ref NativeInterpolatorState state, float4 current, float4 target, float duration, float deltaTime) + { + if (state.ValueKind == InterpolatorValueKind.Quaternion) + { + // Matches BufferedLinearInterpolatorQuaternion, which smooth dampens each euler angle. + var currentEuler = NetworkTransformMath.EulerAngles(new quaternion(current)); + var targetEuler = NetworkTransformMath.EulerAngles(new quaternion(target)); + var rate = state.RateOfChange; + var result = float3.zero; + for (int i = 0; i < 3; i++) + { + var velocity = rate[i]; + result[i] = NetworkTransformMath.SmoothDampAngle(currentEuler[i], targetEuler[i], ref velocity, duration, float.PositiveInfinity, deltaTime); + rate[i] = velocity; + } + state.RateOfChange = rate; + return NetworkTransformMath.Euler(result).value; + } + + var rateOfChange = state.RateOfChange.xyz; + var damped = NetworkTransformMath.SmoothDamp(current.xyz, target.xyz, ref rateOfChange, duration, float.PositiveInfinity, deltaTime); + state.RateOfChange = new float4(rateOfChange, 0.0f); + return new float4(damped, 0.0f); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsApproximately(in NativeInterpolatorState state, float4 first, float4 second, float precision) + { + if (state.ValueKind == InterpolatorValueKind.Quaternion) + { + return math.abs(first.x - second.x) <= precision + && math.abs(first.y - second.y) <= precision + && math.abs(first.z - second.z) <= precision + && math.abs(first.w - second.w) <= precision; + } + + // Matches BufferedLinearInterpolatorVector3, which rounds to two decimal places first. + return math.round(math.abs(first.x - second.x) * 100.0f) * 0.01f <= precision + && math.round(math.abs(first.y - second.y) * 100.0f) * 0.01f <= precision + && math.round(math.abs(first.z - second.z) * 100.0f) * 0.01f <= precision; + } + + #endregion + + #region State measurement, resetting, clearing, and related state methods + + internal static void Clear(ref NativeInterpolatorState state) + { + ClearBuffer(ref state); + state.BufferCounter = 0; + state.LastMeasurementAddedTime = 0.0; + Reset(ref state, float4.zero); + state.RateOfChange = float4.zero; + } + + /// + /// . + /// + internal static void Reset(ref NativeInterpolatorState state, float4 currentValue) + { + state.HasTarget = false; + state.Target = default; + state.CurrentValue = currentValue; + state.NextValue = currentValue; + state.PreviousValue = currentValue; + state.TargetReached = false; + state.LerpT = 0.0f; + state.EndTime = 0.0; + state.StartTime = 0.0; + state.TimeToTargetValue = 0.0; + state.DeltaTime = 0.0; + state.CurrentDeltaTime = 0.0f; + state.MaxDeltaTime = 0.0; + state.LastRemainingTime = 0.0; + } + + /// + /// . + /// + internal static void ResetTo(ref NativeInterpolatorState state, ref NativeArray items, float4 targetValue, double serverTime) + { + Clear(ref state); + state.RateOfChange = float4.zero; + Reset(ref state, targetValue); + AddMeasurement(ref state, ref items, targetValue, serverTime); + } + + /// + /// . + /// + internal static void AddMeasurement(ref NativeInterpolatorState state, ref NativeArray items, float4 newMeasurement, double sentTime) + { + state.ItemsReceivedThisFrame++; + + // This situation can happen after a game is paused. When starting to receive again, the server will + // have sent a bunch of messages in the meantime; instead of going through thousands of value updates + // just to get a big teleport, give up on interpolating and teleport to the latest value. + if (state.ItemsReceivedThisFrame > BufferCountLimit) + { + if (state.LastBufferedItemReceived.TimeSent < sentTime) + { + ClearBuffer(ref state); + state.BufferCounter = 0; + state.LastMeasurementAddedTime = 0.0; + state.RateOfChange = float4.zero; + Reset(ref state, newMeasurement); + + state.LastMeasurementAddedTime = sentTime; + state.LastBufferedItemReceived = new BufferedItemNative() + { + Item = newMeasurement, + TimeSent = sentTime, + ItemId = state.BufferCounter, + }; + // Keeps render time above the consumed start time, which fixes pause and unpause. + Enqueue(ref state, ref items, state.LastBufferedItemReceived); + } + return; + } + + // Drop measurements received out of order or late (unreliable deltas can do both). + if (sentTime > state.LastMeasurementAddedTime || state.BufferCounter == 0) + { + state.BufferCounter++; + state.LastBufferedItemReceived = new BufferedItemNative() + { + Item = newMeasurement, + TimeSent = sentTime, + ItemId = state.BufferCounter, + }; + Enqueue(ref state, ref items, state.LastBufferedItemReceived); + state.LastMeasurementAddedTime = sentTime; + } + } + + /// + /// . + /// + internal static void ResetCurrentState(ref NativeInterpolatorState state) + { + if (state.HasTarget) + { + Reset(ref state, state.CurrentValue); + state.RateOfChange = float4.zero; + } + } + + /// + /// Re-expresses every buffered measurement, and the values currently in flight, in a different space. + /// + /// + /// Invoked when the instance is reparented, which is the only thing that changes the space its + /// measurements are interpreted in. Converting the whole buffer at that moment keeps everything in one + /// space, which is what allows the interpolation job to have no knowledge of parents at all.
+ ///
+ /// The managed interpolator instead tags each measurement with the parent it arrived under and + /// converts lazily as the queue drains past the boundary. The net effect is the same transition; doing + /// it here means one conversion at a known instant, using both parents' poses as they are at that + /// instant, rather than a conversion per buffered item using poses sampled as each is consumed. + ///
+ /// Converts a position from the old space to the new one. + /// Converts a rotation from the old space to the new one. + internal static void ConvertSpace(ref NativeInterpolatorState state, ref NativeArray items, in float4x4 pointTransform, in quaternion rotationTransform) + { + for (int i = 0; i < state.BufferCount; i++) + { + var index = state.BufferOffset + (state.BufferHead + i) % state.BufferCapacity; + var item = items[index]; + item.Item = ConvertValue(state.ValueKind, item.Item, pointTransform, rotationTransform); + items[index] = item; + } + + state.CurrentValue = ConvertValue(state.ValueKind, state.CurrentValue, pointTransform, rotationTransform); + state.PreviousValue = ConvertValue(state.ValueKind, state.PreviousValue, pointTransform, rotationTransform); + state.NextValue = ConvertValue(state.ValueKind, state.NextValue, pointTransform, rotationTransform); + + if (state.HasTarget) + { + var target = state.Target; + target.Item = ConvertValue(state.ValueKind, target.Item, pointTransform, rotationTransform); + state.Target = target; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static float4 ConvertValue(InterpolatorValueKind valueKind, float4 value, in float4x4 pointTransform, in quaternion rotationTransform) + { + if (valueKind == InterpolatorValueKind.Quaternion) + { + return math.mul(rotationTransform, new quaternion(value)).value; + } + return new float4(math.transform(pointTransform, value.xyz), 0.0f); + } + + #endregion + + #region Buffer consumption and timing related methods + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void AddDeltaTime(ref NativeInterpolatorState state, float deltaTime) + { + state.CurrentDeltaTime = deltaTime; + state.DeltaTime = math.min(state.DeltaTime + deltaTime, state.TimeToTargetValue); + state.LerpT = (float)(state.TimeToTargetValue == 0.0 ? 1.0 : state.DeltaTime / state.TimeToTargetValue); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void SetTimeToTarget(ref NativeInterpolatorState state, double timeToTarget) + { + state.LerpT = 0.0f; + state.DeltaTime = 0.0; + state.TimeToTargetValue = timeToTarget; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static double FinalTimeToTarget(in NativeInterpolatorState state) + { + return math.max(0.0, state.TimeToTargetValue - state.DeltaTime); + } + + /// + /// The smooth dampening and lerp ahead version of + /// 's buffer consumption. + /// + private static void TryConsumeFromBuffer(ref NativeInterpolatorState state, ref NativeArray items, double renderTime, double minDeltaTime, double maxDeltaTime) + { + var hasPreviousItem = false; + var previousTimeSent = 0.0; + var startTime = 0.0; + var alreadyHasBufferItem = false; + var noStateSet = !state.HasTarget; + + // With nothing left in the queue (motion stopped) the target still has to be checked for arrival. + if (!noStateSet && !state.TargetReached) + { + state.TargetReached = IsApproximately(state, state.CurrentValue, state.Target.Item, GetPrecision(state)); + } + + while (state.BufferCount > 0) + { + var potentialItem = Peek(state, items); + + // Still on the same buffered item, so there is nothing to consume. + if (hasPreviousItem && previousTimeSent == potentialItem.TimeSent) + { + break; + } + + var potentialItemNeedsProcessing = false; + if (!noStateSet) + { + potentialItemNeedsProcessing = potentialItem.TimeSent <= renderTime && potentialItem.TimeSent > state.Target.TimeSent; + } + + if ((noStateSet && potentialItem.TimeSent <= renderTime) || potentialItemNeedsProcessing) + { + var target = Dequeue(ref state, items); + + if (!state.HasTarget) + { + state.Target = target; + state.HasTarget = true; + alreadyHasBufferItem = true; + state.NextValue = state.CurrentValue; + state.PreviousValue = state.CurrentValue; + SetTimeToTarget(ref state, minDeltaTime); + startTime = state.Target.TimeSent; + state.TargetReached = false; + state.MaxDeltaTime = maxDeltaTime; + } + else + { + if (!alreadyHasBufferItem) + { + alreadyHasBufferItem = true; + state.LastRemainingTime = FinalTimeToTarget(state); + state.TargetReached = false; + state.MaxDeltaTime = maxDeltaTime; + state.PreviousValue = state.NextValue; + startTime = state.Target.TimeSent; + } + SetTimeToTarget(ref state, math.max(target.TimeSent - startTime, minDeltaTime)); + state.Target = target; + } + // noStateSet is deliberately not cleared here. The managed implementation evaluates it + // once before the loop, so when it starts out true every pass keeps taking the branch that + // only compares against render time. + } + else + { + break; + } + + hasPreviousItem = true; + previousTimeSent = potentialItem.TimeSent; + } + } + + /// + /// The lerping version of 's buffer consumption, which + /// preserves the original consumption pattern used by . + /// + private static void TryConsumeFromBufferLegacy(ref NativeInterpolatorState state, ref NativeArray items, double renderTime, double serverTime) + { + if (state.HasTarget && state.Target.TimeSent > renderTime) + { + return; + } + + var hasPreviousItem = false; + var previousTimeSent = 0.0; + var alreadyHasBufferItem = false; + + while (state.BufferCount > 0) + { + var potentialItem = Peek(state, items); + if (hasPreviousItem && previousTimeSent == potentialItem.TimeSent) + { + break; + } + + // Continue processing until reaching the most current state. + if (potentialItem.TimeSent <= serverTime && (!state.HasTarget || potentialItem.TimeSent > state.Target.TimeSent)) + { + var target = Dequeue(ref state, items); + if (!state.HasTarget) + { + state.Target = target; + state.HasTarget = true; + alreadyHasBufferItem = true; + state.NextValue = state.CurrentValue; + state.PreviousValue = state.CurrentValue; + state.StartTime = target.TimeSent; + state.EndTime = target.TimeSent; + } + else + { + if (!alreadyHasBufferItem) + { + alreadyHasBufferItem = true; + state.StartTime = state.Target.TimeSent; + state.PreviousValue = state.NextValue; + state.TargetReached = false; + } + state.EndTime = target.TimeSent; + state.TimeToTargetValue = state.EndTime - state.StartTime; + state.Target = target; + } + } + else + { + break; + } + + hasPreviousItem = true; + previousTimeSent = potentialItem.TimeSent; + } + } + + #endregion + + #region Update methods + + /// + /// The smooth dampening and lerp version of . + /// + internal static float4 Update(ref NativeInterpolatorState state, ref NativeArray items, + float deltaTime, double tickLatencyAsTime, double minDeltaTime, double maxDeltaTime, bool lerp) + { + TryConsumeFromBuffer(ref state, ref items, tickLatencyAsTime, minDeltaTime, maxDeltaTime); + + // Only begin interpolation when there is a start and end point. + if (state.HasTarget) + { + if (!state.TargetReached) + { + AddDeltaTime(ref state, deltaTime); + + if (!lerp) + { + state.NextValue = SmoothDamp(ref state, state.NextValue, state.Target.Item, + (float)state.TimeToTargetValue * state.LerpT, deltaTime); + } + else + { + state.NextValue = Interpolate(state, state.PreviousValue, state.Target.Item, state.LerpT); + } + + if (state.LerpSmoothEnabled) + { + state.CurrentValue = Interpolate(state, state.CurrentValue, state.NextValue, + math.clamp(1.0f - state.MaximumInterpolationTime, 0.0f, 1.0f)); + } + else + { + state.CurrentValue = state.NextValue; + } + } + else if (state.BufferCount == 0) + { + // Once the target is reached and nothing is left, reset if enough time has passed that the + // rate of change should be considered zero. Without this the next state update's time is + // measured against a stale one, producing a large delta after a pause in motion. + if (tickLatencyAsTime - state.Target.TimeSent > state.MaxDeltaTime + minDeltaTime) + { + Reset(ref state, state.CurrentValue); + } + } + } + state.ItemsReceivedThisFrame = 0; + return state.CurrentValue; + } + + /// + /// The legacy lerp version of . + /// + internal static float4 UpdateLegacy(ref NativeInterpolatorState state, ref NativeArray items, + float deltaTime, double renderTime, double serverTime) + { + TryConsumeFromBufferLegacy(ref state, ref items, renderTime, serverTime); + + if (!state.TargetReached && state.HasTarget) + { + state.LerpT = 1.0f; + if (state.TimeToTargetValue > k_SmallValue) + { + state.LerpT = math.clamp((float)((renderTime - state.StartTime) / state.TimeToTargetValue), 0.0f, 1.0f); + } + + state.NextValue = Interpolate(state, state.PreviousValue, state.Target.Item, state.LerpT); + + if (state.LerpSmoothEnabled) + { + state.CurrentValue = Interpolate(state, state.CurrentValue, state.NextValue, deltaTime / state.MaximumInterpolationTime); + } + else + { + state.CurrentValue = state.NextValue; + } + + state.TargetReached = IsApproximately(state, state.CurrentValue, state.Target.Item, GetPrecision(state)); + } + else if (state.TargetReached && state.BufferCount == 0) + { + // If nothing has been received within 300ms, assume motion stopped. + if (renderTime - state.Target.TimeSent > 0.3) + { + Reset(ref state, state.CurrentValue); + } + } + state.ItemsReceivedThisFrame = 0; + return state.CurrentValue; + } + + #endregion + } +} diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs.meta b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs.meta new file mode 100644 index 0000000000..097777f256 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9c48be63ce0910144939ecfb7357cc35 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs index 314e5ec8b6..fe401cb629 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs @@ -705,6 +705,39 @@ 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 { @@ -712,8 +745,9 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade var positionStart = 0; var isWriting = serializer.IsWriter; // Moving the reader and writer properties into this method, as opposed to fields, to assure - // NetworkTransformState remains an unmanaged type in order to store the entire state in a - // NativeArray. + // 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) @@ -738,25 +772,6 @@ 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(writer, FlagStates.GetBitsetRepresentation()); @@ -1770,6 +1785,29 @@ public Vector3 GetScale(bool getCurrentState = false) /// 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; @@ -1917,12 +1955,30 @@ protected override void OnSynchronize(ref BufferSerializer serializer) NetworkDeltaPosition = new NetworkDeltaPosition(), }; + // This uses a more compressed identifier handle for this instance when using batched mode. + // This is the best place to define the handle since it is the first thing that reaches + // every receiver and is only ever invoked once for the entire duration of the objects spawn + // life cycle. + if (NetworkManager.NetworkConfig.TransformSyncMode == TransformSyncModes.Batched) + { + if (serializer.IsWriter && TransformHandle == TransformHandleAllocator.InvalidHandle) + { + // Lazily allocated when first write rather than at spawn, which guarantees it exists before + // anything can transmit it regardless of spawn ordering. + TransformHandle = NetworkManager.TransformStateManager.Handles.Allocate(NetworkManager.ServerTime.Time); + } + + serializer.SerializeValue(ref TransformHandle); + NetworkManager.TransformStateManager.Handles.Register(TransformHandle, this); + } + if (serializer.IsWriter) { SynchronizeState.FlagStates.IsTeleportingNextFrame = true; // If we are using Half Float Precision, then we want to only synchronize the authority's m_HalfPositionState.FullPosition in order for // for the non-authority side to be able to properly synchronize delta position updates. CheckForStateChange(ref SynchronizeState, true, targetClientId); + SynchronizeState.UpdateReliability(); SynchronizeState.NetworkSerialize(serializer); LastTickSync = SynchronizeState.GetNetworkTick(); OnAuthorityPushTransformState(ref SynchronizeState); @@ -2025,6 +2081,86 @@ private void TryCommitTransform(bool synchronize = false, bool settingState = fa } } + /// + /// Main Thread: + /// Contributes this instance's per frame interpolation inputs before the interpolation job runs. + /// + /// + /// The equivalent of what gathers for the per instance path. Values + /// shared by every instance come from , + /// which is already calculated once per update stage. + /// + internal void PrepareInterpolationEntry(ref InterpolationEntry entry) + { + var frameData = m_CachedNetworkManager.TransformInterpolationFrameData; + var isServerAuthoritative = IsServerAuthoritative(); + var useExtraTick = !isServerAuthoritative && frameData.OwnerAuthorityTickOffsetAllowed && !NetworkObject.IsOwnedByServer; + +#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D + entry.DeltaTime = m_UseRigidbodyForMotion ? frameData.FixedDeltaTime : frameData.DeltaTime; +#else + entry.DeltaTime = frameData.DeltaTime; +#endif + entry.TickLatencyAsTime = useExtraTick ? frameData.TickLatencyAsTimeExtraTick : frameData.TickLatencyAsTime; + entry.MaxDeltaTime = useExtraTick ? frameData.MaxDeltaTimeExtraTick : frameData.MaxDeltaTime; + entry.LegacyRenderTime = !isServerAuthoritative && !frameData.IsServer ? frameData.LegacyRenderTimeExtraTick : frameData.LegacyRenderTime; + entry.CurrentTime = frameData.CurrentTime; + entry.MinDeltaTime = frameData.MinDeltaTime; + + entry.PositionInterpolationType = PositionInterpolationType; + entry.RotationInterpolationType = RotationInterpolationType; + entry.ScaleInterpolationType = ScaleInterpolationType; + + entry.SynchronizePosition = SynchronizePosition; + entry.SynchronizeRotation = SynchronizeRotation; + entry.SynchronizeScale = SynchronizeScale; + + // Interpolation tuning can be changed during runtime, so it is refreshed each frame. Changing the + // interpolation type or the smoothing resets the value being interpolated, which is what the per + // instance path does as well. + if (m_PreviousPositionInterpolationType != PositionInterpolationType || m_PreviousPositionLerpSmoothing != PositionLerpSmoothing) + { + m_PreviousPositionInterpolationType = PositionInterpolationType; + m_PreviousPositionLerpSmoothing = PositionLerpSmoothing; + NativeInterpolator.ResetCurrentState(ref entry.Position); + } + + if (m_PreviousRotationInterpolationType != RotationInterpolationType || m_PreviousRotationLerpSmoothing != RotationLerpSmoothing) + { + m_PreviousRotationInterpolationType = RotationInterpolationType; + m_PreviousRotationLerpSmoothing = RotationLerpSmoothing; + NativeInterpolator.ResetCurrentState(ref entry.Rotation); + } + + if (m_PreviousScaleInterpolationType != ScaleInterpolationType || m_PreviousScaleLerpSmoothing != ScaleLerpSmoothing) + { + m_PreviousScaleInterpolationType = ScaleInterpolationType; + m_PreviousScaleLerpSmoothing = ScaleLerpSmoothing; + NativeInterpolator.ResetCurrentState(ref entry.Scale); + } + + entry.Position.LerpSmoothEnabled = PositionLerpSmoothing; + entry.Rotation.LerpSmoothEnabled = RotationLerpSmoothing; + entry.Scale.LerpSmoothEnabled = ScaleLerpSmoothing; + + if (PositionLerpSmoothing) + { + entry.Position.MaximumInterpolationTime = PositionMaxInterpolationTime; + } + if (RotationLerpSmoothing) + { + entry.Rotation.MaximumInterpolationTime = RotationMaxInterpolationTime; + } + if (ScaleLerpSmoothing) + { + entry.Scale.MaximumInterpolationTime = ScaleMaxInterpolationTime; + } + + entry.Position.IsSlerp = SlerpPosition; + // When using half precision, lerp towards the target rotation; at full precision, slerp. + entry.Rotation.IsSlerp = !UseHalfFloatPrecision; + } + /// /// Main Thread: /// Prepares all state that that is not already provided for a batched delta check. @@ -2096,8 +2232,18 @@ private void CommitDetectedState(bool synchronize) } } - // Send the state update - UpdateTransformState(); + // Send the state update. A registered instance contributes to this tick's batch instead of + // sending on its own; the state is captured now because the flags below are cleared + // immediately afterwards. + if (StateManagerIndex >= 0 && !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; @@ -2226,7 +2372,11 @@ private TransformDeltaConfig GetTransformDeltaConfig() UseHalfFloatPrecision = UseHalfFloatPrecision, SlerpPosition = SlerpPosition, Interpolate = Interpolate, - UseUnreliableDeltas = UseUnreliableDeltas, + // Batched mode sends every state update in one reliable message per tick, so there are no + // unreliable deltas to compensate for. Forcing this off also retires the axial frame + // synchronization: that exists solely to re-send a full set of axes once a second in case an + // unreliable delta was lost, which cannot happen here. + UseUnreliableDeltas = UseUnreliableDeltas && m_CachedNetworkManager.NetworkConfig.TransformSyncMode != TransformSyncModes.Batched, SwitchTransformSpaceWhenParented = SwitchTransformSpaceWhenParented, #if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D UseRigidbodyForMotion = m_UseRigidbodyForMotion, @@ -2423,6 +2573,20 @@ internal void UpdatePositionInterpolator(Vector3 position, double time, bool res { if (!CanCommitToTransform) { + if (InterpolatorIndex >= 0) + { + var value = new float4(position.x, position.y, position.z, 0.0f); + if (resetInterpolator) + { + m_CachedNetworkManager.TransformStateManager.ResetTo(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Position, value, time); + } + else + { + m_CachedNetworkManager.TransformStateManager.AddMeasurement(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Position, value, time); + } + return; + } + if (resetInterpolator) { m_PositionInterpolator.AutoConvertTransformSpace = SwitchTransformSpaceWhenParented; @@ -2436,6 +2600,167 @@ internal void UpdatePositionInterpolator(Vector3 position, double time, bool res } } + /// + /// Adds a rotation measurement, routed to whichever interpolator this instance is using. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void UpdateRotationInterpolator(Quaternion rotation, double time, bool resetInterpolator = false) + { + if (InterpolatorIndex >= 0) + { + var value = new float4(rotation.x, rotation.y, rotation.z, rotation.w); + if (resetInterpolator) + { + m_CachedNetworkManager.TransformStateManager.ResetTo(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Rotation, value, time); + } + else + { + m_CachedNetworkManager.TransformStateManager.AddMeasurement(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Rotation, value, time); + } + return; + } + + if (resetInterpolator) + { + m_RotationInterpolator.AutoConvertTransformSpace = SwitchTransformSpaceWhenParented; + m_RotationInterpolator.InLocalSpace = InLocalSpace; + m_RotationInterpolator.ResetTo(CachedTransform.parent, rotation, time); + } + else + { + m_RotationInterpolator.AddMeasurement(transform.parent, rotation, time); + } + } + + /// + /// Adds a scale measurement, routed to whichever interpolator this instance is using. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void UpdateScaleInterpolator(Vector3 scale, double time, bool resetInterpolator = false) + { + if (InterpolatorIndex >= 0) + { + var value = new float4(scale.x, scale.y, scale.z, 0.0f); + if (resetInterpolator) + { + m_CachedNetworkManager.TransformStateManager.ResetTo(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Scale, value, time); + } + else + { + m_CachedNetworkManager.TransformStateManager.AddMeasurement(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Scale, value, time); + } + return; + } + + if (resetInterpolator) + { + m_ScaleInterpolator.ResetTo(scale, time); + } + else + { + m_ScaleInterpolator.AddMeasurement(transform.parent, scale, time); + } + } + + /// + /// Handles converting a batch interpolated transform's state between transform spaces. + /// + /// + /// The batched interpolators hold every measurement in a single space, so a reparent has to convert + /// what is already buffered. Doing it here means the interpolation job itself never needs to know + /// about parents. + /// + /// The parent the buffered measurements are currently expressed under. + /// The parent they should be expressed under. + private void ConvertBatchedInterpolationSpace(Transform previousParent, Transform newParent) + { + if (InterpolatorIndex < 0 || previousParent == newParent) + { + return; + } + + // Old space to world, then world to new space. + var pointTransform = float4x4.identity; + if (previousParent != null) + { + pointTransform = previousParent.localToWorldMatrix; + } + if (newParent != null) + { + pointTransform = math.mul(newParent.worldToLocalMatrix, pointTransform); + } + + var rotationTransform = quaternion.identity; + if (previousParent != null) + { + rotationTransform = previousParent.rotation; + } + if (newParent != null) + { + rotationTransform = math.mul(math.inverse(new quaternion(newParent.rotation.x, newParent.rotation.y, newParent.rotation.z, newParent.rotation.w)), rotationTransform); + } + + m_CachedNetworkManager.TransformStateManager.ConvertInterpolationSpace(InterpolatorIndex, pointTransform, rotationTransform); + } + + /// + /// Clears all three interpolators, routed to whichever this instance is using. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ClearInterpolators() + { + if (InterpolatorIndex >= 0) + { + m_CachedNetworkManager.TransformStateManager.ClearInterpolators(InterpolatorIndex); + return; + } + m_ScaleInterpolator.Clear(); + m_PositionInterpolator.Clear(); + m_RotationInterpolator.Clear(); + } + + /// + /// The current interpolated position, from whichever interpolator this instance is using. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private Vector3 GetInterpolatedPosition() + { + if (InterpolatorIndex >= 0) + { + var value = m_CachedNetworkManager.TransformStateManager.GetInterpolatedValue(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Position); + return new Vector3(value.x, value.y, value.z); + } + return m_PositionInterpolator.GetInterpolatedValue(); + } + + /// + /// The current interpolated rotation, from whichever interpolator this instance is using. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private Quaternion GetInterpolatedRotation() + { + if (InterpolatorIndex >= 0) + { + var value = m_CachedNetworkManager.TransformStateManager.GetInterpolatedValue(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Rotation); + return new Quaternion(value.x, value.y, value.z, value.w); + } + return m_RotationInterpolator.GetInterpolatedValue(); + } + + /// + /// The current interpolated scale, from whichever interpolator this instance is using. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private Vector3 GetInterpolatedScale() + { + if (InterpolatorIndex >= 0) + { + var value = m_CachedNetworkManager.TransformStateManager.GetInterpolatedValue(InterpolatorIndex, NetworkTransformStateManager.InterpolatorTarget.Scale); + return new Vector3(value.x, value.y, value.z); + } + return m_ScaleInterpolator.GetInterpolatedValue(); + } + internal bool LogMotion; /// @@ -2528,7 +2853,7 @@ protected internal void ApplyAuthoritativeState() { if (SynchronizePosition) { - var interpolatedPosition = m_PositionInterpolator.GetInterpolatedValue(); + var interpolatedPosition = GetInterpolatedPosition(); if (UseHalfFloatPrecision) { adjustedPosition = interpolatedPosition; @@ -2545,11 +2870,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; } @@ -2558,7 +2883,7 @@ protected internal void ApplyAuthoritativeState() if (SynchronizeRotation) { - var interpolatedRotation = m_RotationInterpolator.GetInterpolatedValue(); + var interpolatedRotation = GetInterpolatedRotation(); if (UseQuaternionSynchronization) { adjustedRotation = interpolatedRotation; @@ -2753,9 +3078,7 @@ private void ApplyTeleportingState(NetworkTransformState newState) var flagStates = newState.FlagStates; // Clear all interpolators - m_ScaleInterpolator.Clear(); - m_PositionInterpolator.Clear(); - m_RotationInterpolator.Clear(); + ClearInterpolators(); if (flagStates.HasPositionChange) { @@ -2869,7 +3192,7 @@ private void ApplyTeleportingState(NetworkTransformState newState) if (Interpolate) { - m_ScaleInterpolator.ResetTo(currentScale, sentTime); + UpdateScaleInterpolator(currentScale, sentTime, true); } } @@ -2920,9 +3243,7 @@ private void ApplyTeleportingState(NetworkTransformState newState) if (Interpolate) { - m_RotationInterpolator.AutoConvertTransformSpace = SwitchTransformSpaceWhenParented; - m_RotationInterpolator.InLocalSpace = newState.FlagStates.InLocalSpace; - m_RotationInterpolator.ResetTo(CachedTransform.parent, currentRotation, sentTime); + UpdateRotationInterpolator(currentRotation, sentTime, true); } } @@ -3068,7 +3389,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 @@ -3103,7 +3424,7 @@ internal void ApplyUpdatedState(NetworkTransformState newState) currentRotation.eulerAngles = currentEulerAngles; } - m_RotationInterpolator.AddMeasurement(transform.parent, currentRotation, sentTime); + UpdateRotationInterpolator(currentRotation, sentTime); } } @@ -3473,12 +3794,77 @@ private void CleanUpOnDestroyOrDespawn() DeregisterForTickUpdate(); DeregisterFromBatchedStateTracking(); + DeregisterFromBatchedInterpolation(); + ReleaseTransformHandle(); CanCommitToTransform = false; } + /// + /// Releases the transform compressed handle. + /// + /// + /// Only the authority that allocates handles puts one back into circulation while the clients with + /// non-authority instances just forgets the TransformHandle. + /// + private void ReleaseTransformHandle() + { + if (m_CachedNetworkManager == null || TransformHandle == TransformHandleAllocator.InvalidHandle) + { + return; + } + + var handles = m_CachedNetworkManager.TransformStateManager.Handles; + if (m_CachedNetworkManager.IsServer) + { + handles.Release(TransformHandle, m_CachedNetworkManager.ServerTime.Time); + } + else + { + handles.Unregister(TransformHandle); + } + TransformHandle = TransformHandleAllocator.InvalidHandle; + } + /// /// Adds this instance to the when the session is using . /// + /// + /// Adds this instance to the 's interpolation when the + /// session is running in . + /// + private void RegisterForBatchedInterpolation() + { + if (m_CachedNetworkManager == null || m_CachedNetworkManager.NetworkConfig.TransformSyncMode != TransformSyncModes.Batched) + { + return; + } + + // The native interpolator handles this differently and, for now, anything configured with this setting is excluded. + // TODO-JIRA-TICKET: Investigate just ignoring this setting and allowing instances with this flag to be included. + if (SwitchTransformSpaceWhenParented) + { + return; + } + + m_CachedNetworkManager.TransformStateManager.RegisterForInterpolation(this); + } + + /// + /// Deregisters this instance from 's interpolation job. + /// + /// + /// If an instance was registered it MUST be unregistered. This can happen with ownership + /// changes and/or despawning. + /// + private void DeregisterFromBatchedInterpolation() + { + if (m_CachedNetworkManager == null) + { + return; + } + m_CachedNetworkManager.TransformStateManager.DeregisterFromInterpolation(this); + } + private void RegisterForBatchedStateTracking() { if (m_CachedNetworkManager == null || m_CachedNetworkManager.NetworkConfig.TransformSyncMode != TransformSyncModes.Batched) @@ -3486,6 +3872,20 @@ private void RegisterForBatchedStateTracking() 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. @@ -3501,6 +3901,15 @@ private void RegisterForBatchedStateTracking() return; } + // The batch is assembled per observing client and sent directly, which only the server can do. An + // owner authoritative instance owned by a client has to send to the server and be relayed from + // there, and the batch has no relay: that is the same forwarding problem as distributed authority + // and is deferred with it. Such instances stay on the per instance path, which already relays. + if (!m_CachedNetworkManager.IsServer) + { + return; + } + m_CachedNetworkManager.TransformStateManager.Register(this); } @@ -3575,9 +3984,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); } /// @@ -3665,6 +4074,8 @@ internal virtual void InternalInitialization(bool isOwnershipChange = false) RegisterForTickUpdate(); RegisterForBatchedStateTracking(); + // Authority interpolates nothing, so make sure it is not also registered for interpolation. + DeregisterFromBatchedInterpolation(); if (UseHalfFloatPrecision && isOwnershipChange && !IsServerAuthoritative() && Interpolate) { @@ -3688,6 +4099,8 @@ internal virtual void InternalInitialization(bool isOwnershipChange = false) // 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; @@ -3777,15 +4190,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); } } @@ -3803,6 +4214,9 @@ internal override void InternalOnNetworkObjectParentChanged(NetworkObject parent return; } + // Handle transform space re-parenting transitions for batched transforms. + ConvertBatchedInterpolationSpace(m_PositionInterpolator.Parent, parentNetworkObject != null ? parentNetworkObject.transform : null); + InLocalSpace = parentNetworkObject != null; if (SynchronizePosition) @@ -4203,6 +4617,13 @@ internal static void RefreshInterpolationFrameData(NetworkManager networkManager /// private void UpdateInterpolation() { + // TODO-JIRA-TICKET: + // This could be further optimized by excluding batched transforms from the Update/FixedUpdate invocations. + if (InterpolatorIndex >= 0) + { + return; + } + // Get the InterpolationFrameData for this frame var frameData = m_CachedNetworkManager.TransformInterpolationFrameData; var currentTime = frameData.CurrentTime; @@ -4543,6 +4964,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) { @@ -4554,13 +4978,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) @@ -4695,12 +5114,22 @@ internal void TickUpdate() Remove(); return; } + + // if (m_NetworkManager.NetworkConfig.TransformSyncMode == TransformSyncModes.Batched) { - // Batched: every registered instance is checked in parallel and anything that came back - // dirty sends its state update afterwards. Instances that could not be registered (a - // rigidbody driven one, for example) still tick individually below. + // 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) @@ -4711,6 +5140,11 @@ internal void TickUpdate() networkTransform.OnNetworkTick(); } } + + // Flushed after the per instance updates so that anything they force through TickSyncChildren + // lands in the same tick's batch rather than the next one. + m_NetworkManager.TransformStateManager.SendBatchedStateUpdates(m_NetworkManager); + m_LastTick = CurrentTick; } public NetworkTransformTickRegistration(NetworkManager networkManager) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs index 72a60fc2dd..61c5594777 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs @@ -1,13 +1,14 @@ using System; using System.Collections.Generic; using Unity.Collections; +using Unity.Mathematics; using UnityEngine.Jobs; namespace Unity.Netcode.Components { /// - /// Owns the native state that uses to detect and apply - /// changes within a job. + /// When using mode, this manages the jobs to detect changes in + /// or apply changes to the transform assigned to each instance. /// /// /// One instance per . It is created the first time an instance registers and @@ -39,6 +40,56 @@ internal class NetworkTransformStateManager : IDisposable /// internal NativeList Entries; + /// + /// The maximum number of measurements an interpolator instance's buffer can hold. + /// + /// + /// The managed interpolator's queue is unbounded and only bounded in practice by + /// , which is the point at which it gives up and + /// teleports. In practice a queue never holds more than the tick latency plus a couple of entries, so + /// this is sized for that with headroom rather than for the panic threshold. Overflow drops the oldest + /// measurement, which is the same value the interpolator would have consumed and discarded next. + /// + private const int k_InterpolatorBufferCapacity = 32; + + /// + /// Position, rotation and scale. + /// + private const int k_InterpolatorsPerInstance = 3; + + private const int k_ItemsPerInstance = k_InterpolatorBufferCapacity * k_InterpolatorsPerInstance; + + /// + /// The registered non-authority instances, parallel to . + /// + private readonly List m_NonAuthorityInstances = new List(k_InitialCapacity); + + /// + /// The interpolation state per registered non-authority instance. + /// + internal NativeList InterpolationEntries; + + /// + /// The native list, where states are stored, that is used like a ring buffer. + /// + /// + /// An instance at index i owns the range starting at i * k_ItemsPerInstance, which is + /// what lets the interpolation job write into one shared array without the indices aliasing. + /// + internal NativeList BufferedItems; + + /// + /// The bandwidth friendly transform identifiers used to uniquely identify each transform to its managed + /// component. + /// + /// + /// Managed only: + /// Unlike the native collections, it is available without anything having registered. + /// A handle is assigned to every synchronized instance, whether or not that instance is eligible for + /// batched jobs or not now. + /// + internal readonly TransformHandleAllocator Handles = new TransformHandleAllocator(); + private bool m_Created; private bool m_Disposed; @@ -66,9 +117,369 @@ private void EnsureCreated() } TransformAccess = new TransformAccessArray(k_InitialCapacity); Entries = new NativeList(k_InitialCapacity, Allocator.Persistent); + InterpolationEntries = new NativeList(k_InitialCapacity, Allocator.Persistent); + BufferedItems = new NativeList(k_InitialCapacity * k_ItemsPerInstance, Allocator.Persistent); m_Created = true; } + /// + /// Registers a non-authority so its interpolation runs within a job. + /// + /// + /// Separate from because the two run at different points (authority on the + /// network tick, non-authority every frame) and need different data. An instance is only ever one or + /// the other, and a change of ownership re-runs + /// , which moves it between the two. + /// + internal void RegisterForInterpolation(NetworkTransform networkTransform) + { + if (m_Disposed || networkTransform.InterpolatorIndex >= 0) + { + return; + } + + EnsureCreated(); + + var index = InterpolationEntries.Length; + networkTransform.InterpolatorIndex = index; + m_NonAuthorityInstances.Add(networkTransform); + + // Give this instance its own slice of the shared measurement storage. + BufferedItems.Length = (index + 1) * k_ItemsPerInstance; + var offset = index * k_ItemsPerInstance; + + InterpolationEntries.Add(new InterpolationEntry() + { + Position = CreateInterpolatorState(offset, InterpolatorValueKind.Vector3), + Rotation = CreateInterpolatorState(offset + k_InterpolatorBufferCapacity, InterpolatorValueKind.Quaternion), + Scale = CreateInterpolatorState(offset + k_InterpolatorBufferCapacity * 2, InterpolatorValueKind.Vector3), + }); + } + + private static NativeInterpolatorState CreateInterpolatorState(int bufferOffset, InterpolatorValueKind valueKind) + { + return new NativeInterpolatorState() + { + BufferOffset = bufferOffset, + BufferCapacity = k_InterpolatorBufferCapacity, + ValueKind = valueKind, + }; + } + + /// + /// Removes a from native interpolation. + /// + internal void DeregisterFromInterpolation(NetworkTransform networkTransform) + { + var index = networkTransform.InterpolatorIndex; + if (m_Disposed || index < 0) + { + return; + } + + networkTransform.InterpolatorIndex = -1; + + var lastIndex = m_NonAuthorityInstances.Count - 1; + var moved = m_NonAuthorityInstances[lastIndex]; + + if (index != lastIndex) + { + // The buffer offsets are derived from the index, so the instance being swapped into this slot + // has to have its measurements moved into this slot's range as well. + var destination = index * k_ItemsPerInstance; + var source = lastIndex * k_ItemsPerInstance; + for (int i = 0; i < k_ItemsPerInstance; i++) + { + BufferedItems[destination + i] = BufferedItems[source + i]; + } + + var movedEntry = InterpolationEntries[lastIndex]; + movedEntry.Position.BufferOffset = destination; + movedEntry.Rotation.BufferOffset = destination + k_InterpolatorBufferCapacity; + movedEntry.Scale.BufferOffset = destination + k_InterpolatorBufferCapacity * 2; + InterpolationEntries[lastIndex] = movedEntry; + + moved.InterpolatorIndex = index; + } + + InterpolationEntries.RemoveAtSwapBack(index); + m_NonAuthorityInstances[index] = moved; + m_NonAuthorityInstances.RemoveAt(lastIndex); + BufferedItems.Length = m_NonAuthorityInstances.Count * k_ItemsPerInstance; + } + + /// + /// Advances the interpolators for every registered non-authority instance. + /// + /// + /// Invoked once per update stage in place of each instance interpolating itself. Only the buffer + /// consumption and interpolation math run within the job; the results are applied to the transforms on + /// the main thread afterwards by each instance's normal apply path. + /// + internal void RunInterpolation() + { + var count = m_NonAuthorityInstances.Count; + if (count == 0) + { + return; + } + + for (int i = 0; i < count; i++) + { + var entry = InterpolationEntries[i]; + m_NonAuthorityInstances[i].PrepareInterpolationEntry(ref entry); + InterpolationEntries[i] = entry; + } + + var job = new InterpolateTransformJob() + { + Entries = InterpolationEntries.AsArray(), + BufferedItems = BufferedItems.AsArray(), + }; + // Explicitly qualified: UnityEngine.Jobs is in scope for TransformAccessArray, and its Schedule + // extension would otherwise be preferred over the IJobParallelFor one. + Jobs.IJobParallelForExtensions.Schedule(job, count, 16).Complete(); + } + + /// + /// A state update waiting to go out in this tick's batch. + /// + /// + /// The state is captured rather than read back from the instance later, because committing a state + /// update clears the teleport and explicit set flags immediately afterwards. Reading it at send time + /// would transmit the already cleared version. + /// + private struct PendingStateUpdate + { + internal NetworkTransform Instance; + internal NetworkTransform.NetworkTransformState State; + } + + private readonly List m_PendingBatch = new List(k_InitialCapacity); + private NetworkTransformBatchMessage m_BatchMessage = new NetworkTransformBatchMessage(); + + /// + /// Queues a detected state update for this tick's batch instead of sending it on its own. + /// + internal void QueueForBatch(NetworkTransform networkTransform, in NetworkTransform.NetworkTransformState state) + { + m_PendingBatch.Add(new PendingStateUpdate() + { + Instance = networkTransform, + State = state, + }); + } + + /// + /// Sends everything queued this tick, one message per observing client. + /// + /// + /// Assembled per client rather than once for everyone because observer sets differ between clients. + /// + internal void SendBatchedStateUpdates(NetworkManager networkManager) + { + if (m_PendingBatch.Count == 0) + { + return; + } + + // Only the server registers instances for batching, so a non-server should never have anything + // queued. Kept as a safety net rather than an assumption: silently dropping is still better than + // a client attempting a send it cannot address, but it should not be reachable. + if (networkManager.ShutdownInProgress || !networkManager.IsServer) + { + m_PendingBatch.Clear(); + return; + } + + m_BatchMessage.Manager = this; + + var connectedClients = networkManager.ConnectionManager.ConnectedClientsList; + for (int i = 0; i < connectedClients.Count; i++) + { + var clientId = connectedClients[i].ClientId; + if (clientId == NetworkManager.ServerClientId) + { + continue; + } + + if (!HasAnythingFor(clientId)) + { + continue; + } + + m_BatchMessage.TargetClientId = clientId; + networkManager.MessageManager.SendMessage(ref m_BatchMessage, NetworkDelivery.ReliableFragmentedSequenced, clientId); + } + + m_PendingBatch.Clear(); + } + + /// + /// Whether any queued state update is observed by the given client. + /// + /// + /// Checked before sending so a client that observes none of this tick's updates gets no message at all + /// rather than one containing a count of zero. + /// + private bool HasAnythingFor(ulong clientId) + { + for (int i = 0; i < m_PendingBatch.Count; i++) + { + var instance = m_PendingBatch[i].Instance; + if (instance != null && instance.NetworkObject != null && instance.NetworkObject.Observers.Contains(clientId)) + { + return true; + } + } + return false; + } + + /// + /// Writes the queued state updates the given client observes. + /// + /// + /// The count is backfilled once the entries are written, since it is not known until the observer + /// filtering has run. + /// + internal void WriteBatch(FastBufferWriter writer, ulong targetClientId) + { + // Written at a fixed width rather than bit packed. The value is not known until the observer + // filtering below has run, and a bit packed placeholder that later needs more bytes would overrun + // the first entry when seeking back. One byte is not worth that failure mode. + var count = (ushort)0; + var countPosition = writer.Position; + writer.WriteValueSafe(count); + + for (int i = 0; i < m_PendingBatch.Count; i++) + { + var pending = m_PendingBatch[i]; + var instance = pending.Instance; + if (instance == null || instance.NetworkObject == null || !instance.NetworkObject.Observers.Contains(targetClientId)) + { + continue; + } + + BytePacker.WriteValueBitPacked(writer, instance.TransformHandle); + writer.WriteNetworkSerializable(pending.State); + count++; + } + + var tailPosition = writer.Position; + writer.Seek(countPosition); + writer.WriteValueSafe(count); + writer.Seek(tailPosition); + } + + /// + /// Which of an instance's three interpolators an operation applies to. + /// + internal enum InterpolatorTarget + { + Position, + Rotation, + Scale, + } + + /// + /// The native equivalent of . + /// + internal void AddMeasurement(int index, InterpolatorTarget target, float4 value, double time) + { + var entry = InterpolationEntries[index]; + var items = BufferedItems.AsArray(); + switch (target) + { + case InterpolatorTarget.Position: + NativeInterpolator.AddMeasurement(ref entry.Position, ref items, value, time); + break; + case InterpolatorTarget.Rotation: + NativeInterpolator.AddMeasurement(ref entry.Rotation, ref items, value, time); + break; + default: + NativeInterpolator.AddMeasurement(ref entry.Scale, ref items, value, time); + break; + } + InterpolationEntries[index] = entry; + } + + /// + /// The native equivalent of . + /// + internal void ResetTo(int index, InterpolatorTarget target, float4 value, double time) + { + var entry = InterpolationEntries[index]; + var items = BufferedItems.AsArray(); + switch (target) + { + case InterpolatorTarget.Position: + NativeInterpolator.ResetTo(ref entry.Position, ref items, value, time); + entry.InterpolatedPosition = value; + break; + case InterpolatorTarget.Rotation: + NativeInterpolator.ResetTo(ref entry.Rotation, ref items, value, time); + entry.InterpolatedRotation = value; + break; + default: + NativeInterpolator.ResetTo(ref entry.Scale, ref items, value, time); + entry.InterpolatedScale = value; + break; + } + InterpolationEntries[index] = entry; + } + + /// + /// The native equivalent of clearing all three of an instance's interpolators. + /// + internal void ClearInterpolators(int index) + { + var entry = InterpolationEntries[index]; + NativeInterpolator.Clear(ref entry.Position); + NativeInterpolator.Clear(ref entry.Rotation); + NativeInterpolator.Clear(ref entry.Scale); + InterpolationEntries[index] = entry; + } + + /// + /// Re-expresses an instance's buffered measurements and in flight values in a different space. + /// + /// + /// Scale is deliberately not converted: it is a local scale, so it is already parent relative and + /// means the same thing under either parent. The managed interpolator does not convert it either. + /// + internal void ConvertInterpolationSpace(int index, in float4x4 pointTransform, in quaternion rotationTransform) + { + var entry = InterpolationEntries[index]; + var items = BufferedItems.AsArray(); + + NativeInterpolator.ConvertSpace(ref entry.Position, ref items, pointTransform, rotationTransform); + NativeInterpolator.ConvertSpace(ref entry.Rotation, ref items, pointTransform, rotationTransform); + + // The most recently produced results are converted as well, otherwise the value applied on the + // frame of the reparent would still be in the old space. + entry.InterpolatedPosition = new float4(math.transform(pointTransform, entry.InterpolatedPosition.xyz), 0.0f); + entry.InterpolatedRotation = math.mul(rotationTransform, new quaternion(entry.InterpolatedRotation)).value; + + InterpolationEntries[index] = entry; + } + + /// + /// 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. @@ -202,8 +613,26 @@ public void Dispose() } 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(); diff --git a/com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs b/com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs new file mode 100644 index 0000000000..f3faf91c9a --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs @@ -0,0 +1,147 @@ +using System.Collections.Generic; + +namespace Unity.Netcode.Components +{ + /// + /// A compressed/bandwidth-friendly identifer allocation system that is used when + /// mode is set. This helps to reduce the identifier from a bitpacked ulong and uint down to a ushort. + /// + /// + /// A batched state update identifies its instance by this handle rather than by a + /// and pair. + /// The pair costs two to four bytes once bit packed and grows as object ids climb, where a "dense handle" + /// ranges between one to two bytes for the lifetime of a session.
+ ///
+ /// Only the instance writing synchronization data (the server, or the session owner in a distributed + /// authority topology) allocates. Everyone else is told the handle at spawn. That is deliberate: allocating + /// on the owner would reassign the handle on every change of ownership, and the identity has to outlive + /// ownership.
+ ///
+ /// Freed handles are not re-issued immediately.
+ /// An unreliable state update naming a handle can still be in flight when the instance it referred to despawns, + /// and reissuing straight away would let that packet apply to whichever instance picked the handle up next. + /// Holding to-be-released handles for has no impact/cost and avoids running + /// into this scenario. + ///
+ internal class TransformHandleAllocator + { + /// + /// Reserved to mean "no handle assigned". + /// + internal const ushort InvalidHandle = 0; + + /// + /// How long a freed handle is held before it can be reissued. Comfortably longer than any state + /// update can remain in flight. + /// + private const double k_RecycleDelaySeconds = 5.0; + + private struct PendingHandle + { + internal ushort Handle; + internal double ReusableAtTime; + } + + private ushort m_NextHandle = 1; + + /// + /// Freed handles in the order they were released, which is also the order they become reusable. + /// + private readonly Queue m_PendingRecycle = new Queue(); + + /// + /// Resolves a handle back to its instance when a batched state update is applied. + /// + private readonly Dictionary m_ByHandle = new Dictionary(); + + /// + /// Issues a handle, reusing a previously freed one once it has been held long enough. + /// + /// The current network time, used to age freed handles. + internal ushort Allocate(double currentTime) + { + if (m_PendingRecycle.Count > 0 && m_PendingRecycle.Peek().ReusableAtTime <= currentTime) + { + return m_PendingRecycle.Dequeue().Handle; + } + + if (m_NextHandle != ushort.MaxValue) + { + return m_NextHandle++; + } + + // Every handle is in use or still cooling down. Reusing the oldest one is the only way to keep + // going, and it is the least likely to still be named by anything in flight. + if (m_PendingRecycle.Count > 0) + { + NetworkLog.LogWarning($"[{nameof(NetworkTransform)}] Ran out of transform handles and had to reuse one before its hold expired. " + + "A state update still in flight for the previous instance could be applied to the new one."); + return m_PendingRecycle.Dequeue().Handle; + } + + NetworkLog.LogError($"[{nameof(NetworkTransform)}] Exhausted all {ushort.MaxValue - 1} transform handles. " + + "Any further instances cannot be synchronized."); + return InvalidHandle; + } + + /// + /// Releases a handle that only becomes reusable when the k_RecycleDelaySeconds + /// delay period has expired. + /// + internal void Release(ushort handle, double currentTime) + { + if (handle == InvalidHandle) + { + return; + } + m_ByHandle.Remove(handle); + m_PendingRecycle.Enqueue(new PendingHandle() + { + Handle = handle, + ReusableAtTime = currentTime + k_RecycleDelaySeconds, + }); + } + + /// + /// Associates a handle with the instance it addresses, on both the sending and receiving sides. + /// + internal void Register(ushort handle, NetworkTransform networkTransform) + { + if (handle == InvalidHandle) + { + return; + } + m_ByHandle[handle] = networkTransform; + } + + /// + /// Removes the association without making the handle reusable for the non-authoritative + /// instances. + /// + internal void Unregister(ushort handle) + { + if (handle == InvalidHandle) + { + return; + } + m_ByHandle.Remove(handle); + } + + internal bool TryGet(ushort handle, out NetworkTransform networkTransform) + { + return m_ByHandle.TryGetValue(handle, out networkTransform); + } + + internal int GetRegisteredCount() + { + return m_ByHandle.Count; + } + + internal void Clear() + { + m_ByHandle.Clear(); + m_PendingRecycle.Clear(); + m_NextHandle = 1; + } + } +} diff --git a/com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs.meta b/com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs.meta new file mode 100644 index 0000000000..0d476551b3 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Components/TransformHandleAllocator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dc11efbe776870d419c04b48f17bb61c \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index 46ea108681..dfc984926e 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -420,6 +420,10 @@ public void NetworkUpdate(NetworkUpdateStage updateStage) NetworkTransform.RefreshInterpolationFrameData(this); } + // Advance every registered non-authority interpolator in parallel, before the + // instances below read the results and apply them to their transforms. + TransformStateManager.RunInterpolation(); + foreach (var networkObjectEntry in NetworkTransformFixedUpdate) { // if not active or not spawned then skip @@ -465,6 +469,10 @@ public void NetworkUpdate(NetworkUpdateStage updateStage) { NetworkTransform.RefreshInterpolationFrameData(this); } + + // Advance every registered non-authority interpolator in parallel, before the + // instances below read the results and apply them to their transforms. + TransformStateManager.RunInterpolation(); // Non-physics based non-authority NetworkTransforms update their states after all other components foreach (var networkObjectEntry in NetworkTransformUpdate) diff --git a/com.unity.netcode.gameobjects/Runtime/Messaging/ILPPMessageProvider.cs b/com.unity.netcode.gameobjects/Runtime/Messaging/ILPPMessageProvider.cs index 8d8379dc05..39937cc856 100644 --- a/com.unity.netcode.gameobjects/Runtime/Messaging/ILPPMessageProvider.cs +++ b/com.unity.netcode.gameobjects/Runtime/Messaging/ILPPMessageProvider.cs @@ -42,6 +42,7 @@ internal enum NetworkMessageTypes : uint Unnamed = 22, AnticipationCounterSyncPingMessage = 23, AnticipationCounterSyncPongMessage = 24, + NetworkTransformBatchMessage = 25, } internal struct ILPPMessageProvider : INetworkMessageProvider @@ -83,6 +84,7 @@ internal static Dictionary GetMessageTypesMap() { typeof(ForwardServerRpcMessage), NetworkMessageTypes.ForwardServerRpc }, { typeof(NamedMessage), NetworkMessageTypes.NamedMessage }, { typeof(NetworkTransformMessage), NetworkMessageTypes.NetworkTransformMessage }, + { typeof(NetworkTransformBatchMessage), NetworkMessageTypes.NetworkTransformBatchMessage }, { typeof(NetworkVariableDeltaMessage), NetworkMessageTypes.NetworkVariableDelta }, { typeof(ParentSyncMessage), NetworkMessageTypes.ParentSync }, { typeof(ProxyMessage), NetworkMessageTypes.Proxy }, diff --git a/com.unity.netcode.gameobjects/Runtime/Messaging/MessageDelivery.cs b/com.unity.netcode.gameobjects/Runtime/Messaging/MessageDelivery.cs index cc54d9d102..b7b8a6dae7 100644 --- a/com.unity.netcode.gameobjects/Runtime/Messaging/MessageDelivery.cs +++ b/com.unity.netcode.gameobjects/Runtime/Messaging/MessageDelivery.cs @@ -63,6 +63,7 @@ private static void UpdateMessageTypes() MessageDeliveryType.Initialize(); MessageDeliveryType.Initialize(); MessageDeliveryType.Initialize(); + MessageDeliveryType.Initialize(); MessageDeliveryType.Initialize(); MessageDeliveryType.Initialize(); MessageDeliveryType.Initialize(); diff --git a/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/NetworkTransformBatchMessage.cs b/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/NetworkTransformBatchMessage.cs new file mode 100644 index 0000000000..f2f7898065 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/NetworkTransformBatchMessage.cs @@ -0,0 +1,97 @@ +using Unity.Netcode.Components; +using UnityEngine; + +namespace Unity.Netcode +{ + /// + /// The message that delivers the + /// state updates, per tick, as a single message. + /// + /// + /// - TransformHandle helps reduce bandwidth overhead. + /// - These messages are always delivered reliably. + /// + internal struct NetworkTransformBatchMessage : INetworkMessage + { + public int Version => 0; + private const string k_Name = "NetworkTransformBatchMessage"; + + /// + /// The state manager is set before sending. + /// + internal NetworkTransformStateManager Manager; + + /// + /// Only instances this client observes are written. + /// + internal ulong TargetClientId; + + internal int BytesWritten; + + /// + /// Placeholder to read an entry whose handle does not resolve locally. + /// + /// + /// For batched transforms, we don't worry about deferring messages for + /// received state updates since we currently are synchronizing the full + /// state (i.e. no delta compression). + /// + private NetworkTransform.NetworkTransformState m_Discarded; + + public void Serialize(FastBufferWriter writer, int targetVersion) + { + var startPosition = writer.Position; + Manager.WriteBatch(writer, TargetClientId); + BytesWritten = writer.Position - startPosition; + } + + public bool Deserialize(FastBufferReader reader, ref NetworkContext context, int receivedMessageVersion) + { + var networkManager = context.SystemOwner as NetworkManager; + if (networkManager == null) + { + Debug.LogError($"[{k_Name}] System owner context was not of type {nameof(NetworkManager)}!"); + return false; + } + if (networkManager.ShutdownInProgress) + { + return false; + } + + // Fixed width to match the writer cannot be bit packed. + reader.ReadValueSafe(out ushort count); + var handles = networkManager.TransformStateManager.Handles; + + for (int i = 0; i < count; i++) + { + ByteUnpacker.ReadValueBitPacked(reader, out ushort handle); + + // An entry is applied as it is read rather than being collected and applied in Handle, since + // holding onto every state would mean allocating the in-bound payload per message. + if (handles.TryGet(handle, out var networkTransform) && networkTransform != null) + { + var currentPosition = reader.Position; + reader.ReadNetworkSerializableInPlace(ref networkTransform.InboundState); + networkTransform.InboundState.LastSerializedSize = reader.Position - currentPosition; + networkTransform.TransformStateUpdate(); + continue; + } + + // If the handle does not resolve locally, which can happen while a spawn is still in flight or just + // after a despawn, the entry is read and dropped rather than deferring the whole message. + reader.ReadNetworkSerializableInPlace(ref m_Discarded); + } + + return true; + } + + /// + /// Since states are applied during deserialization, we have nothing + /// to "handle" for this message + /// + public void Handle(ref NetworkContext context) + { + // NOP + } + } +} diff --git a/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/NetworkTransformBatchMessage.cs.meta b/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/NetworkTransformBatchMessage.cs.meta new file mode 100644 index 0000000000..0675667221 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/NetworkTransformBatchMessage.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6987e191b5dfa2b4c959c55b6c8df8b5 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NativeInterpolatorTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NativeInterpolatorTests.cs new file mode 100644 index 0000000000..f5b1aeea98 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NativeInterpolatorTests.cs @@ -0,0 +1,337 @@ +using System; +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Drives and with identical + /// measurement sequences and compares them step for step. + /// + /// + /// The two exist in parallel: the managed one continues to serve + /// and the native one serves + /// , because the managed one cannot run inside a job. Unlike the + /// delta check, which the two synchronization modes genuinely share, there is nothing structural stopping + /// these two from drifting apart. This is what stops it. + /// + // These tests do not need to run against the Rust server. + [IgnoreIfServiceEnvironmentVariableSet] + internal class NativeInterpolatorTests + { + private const int k_BufferCapacity = NativeInterpolator.BufferCountLimit + 1; + private const float k_TickRate = 30.0f; + private const double k_MinDeltaTime = 1.0 / k_TickRate; + + /// + /// The two implementations use the same operations but not always in the same order, so agreement is + /// to float precision rather than bit for bit. + /// + private const float k_Tolerance = 1E-4f; + + /// + /// Allowed while a value is still in motion, for the two paths that are known to be sensitive rather + /// than exact. + /// + /// + /// Vector slerp is the one replacement in that is equivalent rather + /// than exact, so its small per step difference compounds through the interpolator's feedback. + /// Quaternion smooth dampening converts to euler angles, dampens each angle, and converts back every + /// frame; near a gimbal transition a sub thousandth of a degree difference in the conversion is enough + /// to select a different (equally valid) euler representative, after which the two dampen toward + /// different angles. The managed implementation is just as fragile there, so this is the two diverging + /// under a shared weakness rather than one of them being wrong.

+ /// What matters is that neither drifts permanently, which is what the settle phase asserts. + ///
+ private const float k_TransientTolerance = 5.0f; + + /// + /// Once measurements stop, both implementations have to arrive at the same value. + /// + private const float k_SettledTolerance = 1E-3f; + + /// + /// Frames run with no new measurements, to let both settle onto the final target. + /// + private const int k_SettleFrames = 240; + + private NativeArray m_Items; + + [SetUp] + public void SetUp() + { + m_Items = new NativeArray(k_BufferCapacity, Allocator.Temp); + } + + [TearDown] + public void TearDown() + { + if (m_Items.IsCreated) + { + m_Items.Dispose(); + } + } + + private NativeInterpolatorState CreateState(InterpolatorValueKind kind, bool isSlerp, bool lerpSmoothing, float maxInterpolationTime) + { + return new NativeInterpolatorState() + { + BufferOffset = 0, + BufferCapacity = k_BufferCapacity, + ValueKind = kind, + IsSlerp = isSlerp, + LerpSmoothEnabled = lerpSmoothing, + MaximumInterpolationTime = maxInterpolationTime, + }; + } + + /// + /// A deterministic motion path, so a failure is reproducible. + /// + private static Vector3 PositionAt(int tick) + { + return new Vector3( + Mathf.Sin(tick * 0.31f) * 12.0f, + tick * 0.45f, + Mathf.Cos(tick * 0.17f) * 7.5f); + } + + private static Quaternion RotationAt(int tick) + { + return Quaternion.Euler(tick * 3.7f, tick * -2.3f, tick * 1.1f); + } + + /// + /// Steps both implementations through the same sequence of measurements and frames. + /// + /// + /// Measurements are added on tick boundaries and both are updated every frame, which is how a + /// non-authority instance actually consumes them. + /// + private void CompareVector3(NetworkTransform.InterpolationTypes interpolationType, bool isSlerp, bool lerpSmoothing, string label, float transientTolerance = k_Tolerance) + { + const float maxInterpolationTime = 0.1f; + const float deltaTime = 1.0f / 60.0f; + const int ticks = 120; + + var managed = new BufferedLinearInterpolatorVector3() + { + IsSlerp = isSlerp, + LerpSmoothEnabled = lerpSmoothing, + MaximumInterpolationTime = maxInterpolationTime, + }; + var native = CreateState(InterpolatorValueKind.Vector3, isSlerp, lerpSmoothing, maxInterpolationTime); + + var start = PositionAt(0); + managed.ResetTo(start, 0.0); + NativeInterpolator.ResetTo(ref native, ref m_Items, new float4(start.x, start.y, start.z, 0.0f), 0.0); + + var worstError = 0.0f; + var worstDetail = string.Empty; + var time = 0.0; + var nextTick = 1; + + for (int frame = 1; frame <= ticks * 2 + k_SettleFrames; frame++) + { + time += deltaTime; + + // Feed a measurement whenever a tick boundary is crossed. Nothing is fed during the settle + // frames at the end, which is what lets both converge onto the final target. + while (nextTick * k_MinDeltaTime <= time && nextTick <= ticks) + { + var sentTime = nextTick * k_MinDeltaTime; + var measurement = PositionAt(nextTick); + managed.AddMeasurement(measurement, sentTime); + NativeInterpolator.AddMeasurement(ref native, ref m_Items, new float4(measurement.x, measurement.y, measurement.z, 0.0f), sentTime); + nextTick++; + } + + var tickLatencyAsTime = time - 2.0 * k_MinDeltaTime; + var maxDeltaTime = 2.0 * k_MinDeltaTime; + + Vector3 managedValue; + float4 nativeValue; + if (interpolationType == NetworkTransform.InterpolationTypes.LegacyLerp) + { + managed.Update(deltaTime, tickLatencyAsTime, time); + nativeValue = NativeInterpolator.UpdateLegacy(ref native, ref m_Items, deltaTime, tickLatencyAsTime, time); + } + else + { + var lerp = interpolationType == NetworkTransform.InterpolationTypes.Lerp; + managed.Update(deltaTime, tickLatencyAsTime, k_MinDeltaTime, maxDeltaTime, lerp); + nativeValue = NativeInterpolator.Update(ref native, ref m_Items, deltaTime, tickLatencyAsTime, k_MinDeltaTime, maxDeltaTime, lerp); + } + managedValue = managed.GetInterpolatedValue(); + + var error = Vector3.Distance(managedValue, new Vector3(nativeValue.x, nativeValue.y, nativeValue.z)); + if (error > worstError) + { + worstError = error; + worstDetail = $"worst at frame {frame} time {time:F4}: managed={managedValue} native=({nativeValue.x},{nativeValue.y},{nativeValue.z})"; + } + + // The final value, once nothing more is being fed in. + if (frame == ticks * 2 + k_SettleFrames) + { + Assert.LessOrEqual(error, k_SettledTolerance, + $"[{label}] native and managed Vector3 interpolation settled on different values ({error} apart). " + + $"managed={managedValue} native=({nativeValue.x},{nativeValue.y},{nativeValue.z})"); + } + } + + Assert.LessOrEqual(worstError, transientTolerance, + $"[{label}] native and managed Vector3 interpolation diverged by {worstError} while in motion.\n{worstDetail}"); + } + + private void CompareQuaternion(NetworkTransform.InterpolationTypes interpolationType, bool isSlerp, bool lerpSmoothing, string label, float transientTolerance = 0.01f) + { + const float maxInterpolationTime = 0.1f; + const float deltaTime = 1.0f / 60.0f; + const int ticks = 120; + + var managed = new BufferedLinearInterpolatorQuaternion() + { + IsSlerp = isSlerp, + LerpSmoothEnabled = lerpSmoothing, + MaximumInterpolationTime = maxInterpolationTime, + }; + var native = CreateState(InterpolatorValueKind.Quaternion, isSlerp, lerpSmoothing, maxInterpolationTime); + + var start = RotationAt(0); + managed.ResetTo(start, 0.0); + NativeInterpolator.ResetTo(ref native, ref m_Items, new float4(start.x, start.y, start.z, start.w), 0.0); + + var worstError = 0.0f; + var worstDetail = string.Empty; + var time = 0.0; + var nextTick = 1; + + for (int frame = 1; frame <= ticks * 2 + k_SettleFrames; frame++) + { + time += deltaTime; + + // Nothing is fed during the settle frames at the end, which is what lets both converge. + while (nextTick * k_MinDeltaTime <= time && nextTick <= ticks) + { + var sentTime = nextTick * k_MinDeltaTime; + var measurement = RotationAt(nextTick); + managed.AddMeasurement(measurement, sentTime); + NativeInterpolator.AddMeasurement(ref native, ref m_Items, new float4(measurement.x, measurement.y, measurement.z, measurement.w), sentTime); + nextTick++; + } + + var tickLatencyAsTime = time - 2.0 * k_MinDeltaTime; + var maxDeltaTime = 2.0 * k_MinDeltaTime; + + float4 nativeValue; + if (interpolationType == NetworkTransform.InterpolationTypes.LegacyLerp) + { + managed.Update(deltaTime, tickLatencyAsTime, time); + nativeValue = NativeInterpolator.UpdateLegacy(ref native, ref m_Items, deltaTime, tickLatencyAsTime, time); + } + else + { + var lerp = interpolationType == NetworkTransform.InterpolationTypes.Lerp; + managed.Update(deltaTime, tickLatencyAsTime, k_MinDeltaTime, maxDeltaTime, lerp); + nativeValue = NativeInterpolator.Update(ref native, ref m_Items, deltaTime, tickLatencyAsTime, k_MinDeltaTime, maxDeltaTime, lerp); + } + var managedValue = managed.GetInterpolatedValue(); + + var error = Quaternion.Angle(managedValue, new Quaternion(nativeValue.x, nativeValue.y, nativeValue.z, nativeValue.w)); + if (error > worstError) + { + worstError = error; + worstDetail = $"worst at frame {frame} time {time:F4}: managed={managedValue} native=({nativeValue.x},{nativeValue.y},{nativeValue.z},{nativeValue.w})"; + } + + // The final value, once nothing more is being fed in. + if (frame == ticks * 2 + k_SettleFrames) + { + Assert.LessOrEqual(error, 0.01f, + $"[{label}] native and managed Quaternion interpolation settled on different rotations ({error} degrees apart). " + + $"managed={managedValue} native=({nativeValue.x},{nativeValue.y},{nativeValue.z},{nativeValue.w})"); + } + } + + // Compared as an angle, so the tolerances are in degrees. + Assert.LessOrEqual(worstError, transientTolerance, + $"[{label}] native and managed Quaternion interpolation diverged by {worstError} degrees while in motion.\n{worstDetail}"); + } + + [Test] + public void Vector3LegacyLerpMatchesManaged([Values] bool lerpSmoothing) + { + CompareVector3(NetworkTransform.InterpolationTypes.LegacyLerp, false, lerpSmoothing, $"LegacyLerp smoothing={lerpSmoothing}"); + } + + [Test] + public void Vector3LerpMatchesManaged([Values] bool lerpSmoothing) + { + CompareVector3(NetworkTransform.InterpolationTypes.Lerp, false, lerpSmoothing, $"Lerp smoothing={lerpSmoothing}"); + } + + [Test] + public void Vector3SmoothDampeningMatchesManaged([Values] bool lerpSmoothing) + { + CompareVector3(NetworkTransform.InterpolationTypes.SmoothDampening, false, lerpSmoothing, $"SmoothDampening smoothing={lerpSmoothing}"); + } + + [Test] + public void Vector3SlerpMatchesManaged() + { + // Vector slerp is the one NetworkTransformMath replacement that is equivalent rather than exact, + // so it is held to the transient bound while moving and the tight bound once settled. + CompareVector3(NetworkTransform.InterpolationTypes.Lerp, true, false, "Lerp slerp", k_TransientTolerance); + } + + [Test] + public void QuaternionLegacyLerpMatchesManaged([Values] bool isSlerp) + { + CompareQuaternion(NetworkTransform.InterpolationTypes.LegacyLerp, isSlerp, false, $"LegacyLerp slerp={isSlerp}"); + } + + [Test] + public void QuaternionLerpMatchesManaged([Values] bool isSlerp) + { + CompareQuaternion(NetworkTransform.InterpolationTypes.Lerp, isSlerp, false, $"Lerp slerp={isSlerp}"); + } + + [Test] + public void QuaternionSmoothDampeningMatchesManaged() + { + // Dampening through euler angles can select a different euler representative near a gimbal + // transition, so this is held to the transient bound while moving and the tight bound once settled. + CompareQuaternion(NetworkTransform.InterpolationTypes.SmoothDampening, true, false, "SmoothDampening", k_TransientTolerance); + } + + /// + /// The ring buffer has a fixed capacity where the managed queue does not, so the overflow behavior has + /// to be checked explicitly rather than only through the comparisons above. + /// + [Test] + public void BufferOverflowKeepsNewestMeasurement() + { + var native = CreateState(InterpolatorValueKind.Vector3, false, false, 0.1f); + NativeInterpolator.ResetTo(ref native, ref m_Items, float4.zero, 0.0); + + // More measurements than the buffer can hold, without tripping the teleport threshold. + const int count = NativeInterpolator.BufferCountLimit - 1; + for (int i = 1; i <= count; i++) + { + NativeInterpolator.AddMeasurement(ref native, ref m_Items, new float4(i, 0.0f, 0.0f, 0.0f), i * k_MinDeltaTime); + } + + Assert.LessOrEqual(native.BufferCount, k_BufferCapacity, "Buffer count exceeded its capacity!"); + + // Consume everything and confirm the newest measurement is the one that survived. + var value = NativeInterpolator.Update(ref native, ref m_Items, 1.0f, count * k_MinDeltaTime, k_MinDeltaTime, 1.0, true); + Assert.AreEqual(count, native.Target.Item.x, "The newest measurement was not the one interpolated towards!"); + Assert.IsTrue(math.all(math.isfinite(value)), "Interpolated value was not finite!"); + } + } +} 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/NetworkTransformStateBaselineTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformStateBaselineTests.cs index cf32762edc..d37e4cf7b8 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformStateBaselineTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformStateBaselineTests.cs @@ -55,6 +55,8 @@ internal class NetworkTransformStateBaselineTests "HalfFloat.ScaleOnly|12|254AC316", "HalfFloat.EulerRotation|12|F769BA44", "UnreliableDeltas.FrameSync|19|81628843", + "UnreliableDeltas.SynchronizeBaseHalfFloat|30|0DF74758", + "UnreliableDeltas.PlainDelta|12|7B1C8307", "SwitchTransformSpaceWhenParented|19|DD37211C", }; @@ -282,6 +284,28 @@ private static StateCase[] BuildStateMatrix() 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); @@ -319,6 +343,12 @@ private static uint Fnv1a(byte[] bytes) 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 { 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/testproject/Assets/AddressableAssetsData/link.xml b/testproject/Assets/AddressableAssetsData/link.xml new file mode 100644 index 0000000000..40a82bfd44 --- /dev/null +++ b/testproject/Assets/AddressableAssetsData/link.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/testproject/Assets/AddressableAssetsData/link.xml.meta b/testproject/Assets/AddressableAssetsData/link.xml.meta new file mode 100644 index 0000000000..e58995fca4 --- /dev/null +++ b/testproject/Assets/AddressableAssetsData/link.xml.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 7921a2f9789f9d445a6bcf339f53c48e +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 9856dea1e7c56eb7f683e6568fd67c1f451e37f1 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Sat, 15 Aug 2026 16:50:22 -0500 Subject: [PATCH 5/7] update and test Some additional changes but for the most part this is the core framework to abstracting NetworkTransform away from the managed layer. This includes some core-feature parity tests when using per-instance vs batched synchronization modes. --- .../Runtime/Components/NetworkTransform.cs | 21 +- .../NetworkTransformSyncModeParityTests.cs | 352 ++++++++++++++++++ ...etworkTransformSyncModeParityTests.cs.meta | 2 + .../TestHelpers/NetcodeIntegrationTest.cs | 18 + 4 files changed, 383 insertions(+), 10 deletions(-) create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs.meta diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs index fe401cb629..07382fc504 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs @@ -2235,7 +2235,12 @@ private void CommitDetectedState(bool synchronize) // 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. - if (StateManagerIndex >= 0 && !m_CachedNetworkManager.DistributedAuthorityMode) + // + // 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); @@ -3901,15 +3906,11 @@ private void RegisterForBatchedStateTracking() return; } - // The batch is assembled per observing client and sent directly, which only the server can do. An - // owner authoritative instance owned by a client has to send to the server and be relayed from - // there, and the batch has no relay: that is the same forwarding problem as distributed authority - // and is deferred with it. Such instances stay on the per instance path, which already relays. - if (!m_CachedNetworkManager.IsServer) - { - 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); } 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..275ea5c974 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs @@ -0,0 +1,352 @@ +using System.Collections; +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Drives the same scenarios through both and asserts that every + /// non-authority instance ends up where the authority is. + /// + /// + /// The two modes do not share a wire format, a send path, or an interpolator, so nothing structural keeps + /// them equivalent. This is the regression net for that: it does not compare bytes (they legitimately + /// differ) but compares the observable outcome, which is what has to stay the same.

+ /// The scenarios were chosen from the places where the batched path diverges from the per instance one + /// rather than from a general notion of coverage. Each one is documented with what it would catch. + ///
+ // These tests do not need to run against the Rust server. + [IgnoreIfServiceEnvironmentVariableSet] + [TestFixture(TransformSyncModes.PerInstance, NetworkTransform.AuthorityModes.Server)] + [TestFixture(TransformSyncModes.PerInstance, NetworkTransform.AuthorityModes.Owner)] + [TestFixture(TransformSyncModes.Batched, NetworkTransform.AuthorityModes.Server)] + [TestFixture(TransformSyncModes.Batched, NetworkTransform.AuthorityModes.Owner)] + internal class NetworkTransformSyncModeParityTests : IntegrationTestWithApproximation + { + protected override int NumberOfClients => 3; + + private readonly TransformSyncModes m_SyncMode; + private readonly NetworkTransform.AuthorityModes m_AuthorityMode; + + private GameObject m_MoverPrefab; + private readonly List m_SpawnedMovers = new List(); + + public NetworkTransformSyncModeParityTests(TransformSyncModes syncMode, NetworkTransform.AuthorityModes authorityMode) + { + m_SyncMode = syncMode; + m_AuthorityMode = authorityMode; + } + + internal override TransformSyncModes OnGetSyncMode() + { + return m_SyncMode; + } + + protected override void OnServerAndClientsCreated() + { + m_MoverPrefab = CreateNetworkObjectPrefab("ParityMover"); + var networkTransform = m_MoverPrefab.AddComponent(); + networkTransform.AuthorityMode = m_AuthorityMode; + networkTransform.Interpolate = true; + + base.OnServerAndClientsCreated(); + } + + protected override IEnumerator OnTearDown() + { + m_SpawnedMovers.Clear(); + return base.OnTearDown(); + } + + /// + /// Spawns an instance owned by the given client, or by the server when no owner is given. + /// + private NetworkObject SpawnMover(ulong ownerClientId = NetworkManager.ServerClientId) + { + var instance = Object.Instantiate(m_MoverPrefab); + var networkObject = instance.GetComponent(); + networkObject.NetworkManagerOwner = m_ServerNetworkManager; + networkObject.SpawnWithOwnership(ownerClientId); + m_SpawnedMovers.Add(networkObject); + return networkObject; + } + + /// + /// The instance that is allowed to move the transform, which depends on the authority mode. + /// + private NetworkTransform GetMotionAuthorityInstance(NetworkObject serverSide) + { + if (m_AuthorityMode == NetworkTransform.AuthorityModes.Server || serverSide.OwnerClientId == NetworkManager.ServerClientId) + { + return serverSide.GetComponent(); + } + + // Owner authoritative and owned by a client, so the owning client's clone drives it. It can be + // absent while a spawn or an ownership change is still propagating. + var owner = GetNetworkManagerByClientId(serverSide.OwnerClientId); + if (owner == null || !owner.SpawnManager.SpawnedObjects.TryGetValue(serverSide.NetworkObjectId, out var clone)) + { + return null; + } + return clone.GetComponent(); + } + + /// + /// Whether the instance that should be driving the transform has actually been told it has authority. + /// + /// + /// Ownership is applied to the server side synchronously, but the owning + /// client only learns of it a round trip later. Moving the transform before then is a non-authority + /// write: it is discarded by interpolation and nothing is ever sent, which looks exactly like a + /// replication failure. + /// + private bool MotionAuthorityIsEstablished(NetworkObject serverSide) + { + var authority = GetMotionAuthorityInstance(serverSide); + return authority != null && authority.CanCommitToTransform; + } + + private NetworkManager GetNetworkManagerByClientId(ulong clientId) + { + if (clientId == NetworkManager.ServerClientId) + { + return m_ServerNetworkManager; + } + foreach (var client in m_ClientNetworkManagers) + { + if (client.LocalClientId == clientId) + { + return client; + } + } + Assert.Fail($"No {nameof(NetworkManager)} for client {clientId}!"); + return null; + } + + /// + /// Every manager that should be able to see the object agrees with the authority's transform. + /// + private bool AllObserversMatch(NetworkObject serverSide, Vector3 expectedPosition, IReadOnlyList expectedObservers) + { + foreach (var manager in expectedObservers) + { + if (!manager.SpawnManager.SpawnedObjects.TryGetValue(serverSide.NetworkObjectId, out var clone)) + { + return false; + } + if (!Approximately(clone.transform.position, expectedPosition)) + { + return false; + } + } + return true; + } + + /// + /// Every instance, not just the server side one, reports the expected owner. + /// + private bool AllInstancesAgreeOnOwner(NetworkObject serverSide, ulong expectedOwner) + { + foreach (var manager in m_NetworkManagers) + { + if (!manager.SpawnManager.SpawnedObjects.TryGetValue(serverSide.NetworkObjectId, out var clone)) + { + return false; + } + if (clone.OwnerClientId != expectedOwner) + { + return false; + } + } + return true; + } + + /// + /// Moves the authority instance and waits for everyone to agree. + /// + private IEnumerator MoveAndConverge(NetworkObject serverSide, Vector3 target, IReadOnlyList observers) + { + yield return WaitForConditionOrTimeOut(() => MotionAuthorityIsEstablished(serverSide)); + AssertOnTimeout($"[{m_SyncMode}][{m_AuthorityMode}] The motion authority instance never gained authority!"); + + var authority = GetMotionAuthorityInstance(serverSide); + authority.transform.position = target; + + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(serverSide, target, observers)); + AssertOnTimeout($"[{m_SyncMode}][{m_AuthorityMode}] Not every observer reached {target}!"); + } + + /// + /// The baseline: a moving object reaches every observer in both modes. + /// + [UnityTest] + public IEnumerator MotionReachesEveryObserver() + { + var mover = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(mover, mover.transform.position, m_NetworkManagers)); + AssertOnTimeout("Initial spawn did not reach every client!"); + + yield return MoveAndConverge(mover, new Vector3(3.0f, 1.5f, -2.0f), m_NetworkManagers); + yield return MoveAndConverge(mover, new Vector3(-6.25f, 4.0f, 8.5f), m_NetworkManagers); + } + + /// + /// Owner authoritative instances owned by a client. + /// + /// + /// Batched mode deliberately leaves these on the per instance path, because the batch is assembled per + /// observing client and sent directly, which only the server can do. This is here because that + /// exclusion is invisible at runtime: get it wrong and the transform simply stops replicating with no + /// error, which is exactly what happened before the exclusion was added. + /// + [UnityTest] + public IEnumerator ClientOwnedInstanceStillReplicates() + { + var mover = SpawnMover(m_ClientNetworkManagers[0].LocalClientId); + + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(mover, mover.transform.position, m_NetworkManagers)); + AssertOnTimeout("Client owned instance did not spawn on every client!"); + + yield return MoveAndConverge(mover, new Vector3(5.0f, 2.0f, 1.0f), m_NetworkManagers); + } + + /// + /// Two objects where one is hidden from a single client. + /// + /// + /// The batched message is assembled per client, so this is what proves the observer filtering: the + /// hidden object's entry must be absent from that one client's batch while still reaching the others. + /// A filtering mistake shows up either as the hidden object appearing, or as the whole batch failing + /// to deserialize for that client and every object freezing. + /// + [UnityTest] + public IEnumerator MixedObserversReceiveOnlyWhatTheyObserve() + { + var visibleToAll = SpawnMover(); + var hiddenFromOne = SpawnMover(); + + var hiddenFrom = m_ClientNetworkManagers[2]; + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(hiddenFromOne, hiddenFromOne.transform.position, m_NetworkManagers)); + AssertOnTimeout("Second object did not reach every client before being hidden!"); + + hiddenFromOne.NetworkHide(hiddenFrom.LocalClientId); + yield return WaitForConditionOrTimeOut(() => !hiddenFrom.SpawnManager.SpawnedObjects.ContainsKey(hiddenFromOne.NetworkObjectId)); + AssertOnTimeout("Object was not hidden from the target client!"); + + // Everyone still observing the hidden object has to keep receiving it. + var stillObserving = new List { m_ServerNetworkManager, m_ClientNetworkManagers[0], m_ClientNetworkManagers[1] }; + yield return MoveAndConverge(hiddenFromOne, new Vector3(9.0f, 3.0f, -4.0f), stillObserving); + + // And the client it is hidden from has to keep receiving the object it can still see, which is + // what breaks if a mis-sized batch desynchronizes that client's reader. + yield return MoveAndConverge(visibleToAll, new Vector3(-2.0f, 6.0f, 7.0f), m_NetworkManagers); + + // Bringing it back has to resume delivery to the client it was hidden from. + hiddenFromOne.NetworkShow(hiddenFrom.LocalClientId); + yield return WaitForConditionOrTimeOut(() => hiddenFrom.SpawnManager.SpawnedObjects.ContainsKey(hiddenFromOne.NetworkObjectId)); + AssertOnTimeout("Object was not shown again to the target client!"); + + yield return MoveAndConverge(hiddenFromOne, new Vector3(1.0f, 1.0f, 1.0f), m_NetworkManagers); + } + + /// + /// A teleport has to arrive as a teleport rather than being interpolated towards. + /// + /// + /// Teleports take a different route through both the delta check and the interpolator reset paths, + /// and the batched path captures the state before the teleport flag is cleared. Capturing it at the + /// wrong point turns a teleport into an ordinary delta, which is only visible as a long glide. + /// + [UnityTest] + public IEnumerator TeleportArrivesAsATeleport() + { + var mover = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(mover, mover.transform.position, m_NetworkManagers)); + AssertOnTimeout("Initial spawn did not reach every client!"); + + var authority = GetMotionAuthorityInstance(mover); + var target = new Vector3(120.0f, 45.0f, -85.0f); + authority.SetState(target, null, null, false); + + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(mover, target, m_NetworkManagers)); + AssertOnTimeout($"Teleport to {target} did not reach every client!"); + } + + /// + /// Ownership moving between clients mid session. + /// + /// + /// A change of ownership re-runs initialization, which moves an instance between the delta tracking + /// and interpolation registrations, and under owner authority it also moves it between the batched + /// and per instance send paths. The handle has to survive that, since it is allocated once and is not + /// reassigned on ownership change. + /// + [UnityTest] + public IEnumerator OwnershipChangeKeepsReplicating() + { + var mover = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(mover, mover.transform.position, m_NetworkManagers)); + AssertOnTimeout("Initial spawn did not reach every client!"); + + yield return MoveAndConverge(mover, new Vector3(2.0f, 2.0f, 2.0f), m_NetworkManagers); + + mover.ChangeOwnership(m_ClientNetworkManagers[1].LocalClientId); + // Waited for on every instance, not just the server side object. The server applies ownership + // synchronously, so waiting on it would let the test proceed before the new owner knows it owns + // anything. + yield return WaitForConditionOrTimeOut(() => AllInstancesAgreeOnOwner(mover, m_ClientNetworkManagers[1].LocalClientId)); + AssertOnTimeout("Ownership did not transfer to every instance!"); + + yield return MoveAndConverge(mover, new Vector3(-4.0f, 5.0f, 3.0f), m_NetworkManagers); + + // And back to the server, which under owner authority moves it from the per instance path onto + // the batched one. + mover.ChangeOwnership(NetworkManager.ServerClientId); + yield return WaitForConditionOrTimeOut(() => AllInstancesAgreeOnOwner(mover, NetworkManager.ServerClientId)); + AssertOnTimeout("Ownership did not transfer back to every instance!"); + + yield return MoveAndConverge(mover, new Vector3(7.0f, -1.0f, 0.5f), m_NetworkManagers); + } + + /// + /// Despawning and respawning, which is what exercises handle release and reuse. + /// + /// + /// Handles are held for several seconds before being reissued so a state update still in flight cannot + /// land on whichever instance picks the handle up next. A respawn inside that window therefore has to + /// receive a different handle; if it did not, the surviving object and the new one would fight over + /// the same address and one would snap to the other's position. + /// + [UnityTest] + public IEnumerator DespawnAndRespawnDoNotShareAHandle() + { + var first = SpawnMover(); + var second = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(second, second.transform.position, m_NetworkManagers)); + AssertOnTimeout("Initial spawns did not reach every client!"); + + yield return MoveAndConverge(first, new Vector3(10.0f, 0.0f, 0.0f), m_NetworkManagers); + + first.Despawn(); + yield return WaitForConditionOrTimeOut(() => !m_ClientNetworkManagers[0].SpawnManager.SpawnedObjects.ContainsKey(first.NetworkObjectId)); + AssertOnTimeout("Despawn did not reach the clients!"); + + // Respawn immediately, inside the window where the released handle is still being held. + var third = SpawnMover(); + yield return WaitForConditionOrTimeOut(() => AllObserversMatch(third, third.transform.position, m_NetworkManagers)); + AssertOnTimeout("Respawned object did not reach every client!"); + + // If the new object had inherited the despawned one's handle, moving it would drag the survivor + // with it, so both are checked. + var secondPosition = second.transform.position; + yield return MoveAndConverge(third, new Vector3(-15.0f, 2.0f, 6.0f), m_NetworkManagers); + + Assert.IsTrue(AllObserversMatch(second, secondPosition, m_NetworkManagers), + $"[{m_SyncMode}] Moving the respawned object also moved an unrelated one, which means they share a handle!"); + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs.meta new file mode 100644 index 0000000000..868b620615 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fe70eeb6a2cb4684db3d052f19926c5e \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs index 51d91eea3d..d428d152bb 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/TestHelpers/NetcodeIntegrationTest.cs @@ -697,6 +697,9 @@ public IEnumerator SetUp() ConfigureFramesPerTick(); } + // Get the transform synchronization mode before setup. + GetSyncMode(); + if (m_SetupIsACoroutine) { yield return OnSetup(); @@ -825,6 +828,19 @@ internal virtual bool ShouldCreatePlayerPrefab() return true; } + internal TransformSyncModes SyncMode { get; private set; } + + internal virtual TransformSyncModes OnGetSyncMode() + { + // Always default to per instance + return TransformSyncModes.PerInstance; + } + + private void GetSyncMode() + { + SyncMode = OnGetSyncMode(); + } + /// /// Creates the server and clients /// @@ -873,6 +889,7 @@ protected void CreateServerAndClients(int numberOfClients) // Set the player prefab for the server and clients foreach (var manager in m_NetworkManagers) { + manager.NetworkConfig.TransformSyncMode = SyncMode; manager.NetworkConfig.PlayerPrefab = m_PlayerPrefab; SetDistributedAuthorityProperties(manager); #if UNIFIED_NETCODE @@ -957,6 +974,7 @@ protected virtual bool ShouldWaitForNewClientToConnect(NetworkManager networkMan protected NetworkManager CreateNewClient() { var networkManager = NetcodeIntegrationTestHelpers.CreateNewClient(m_ClientNetworkManagers.Length, m_UseMockTransport, m_UseCmbService); + networkManager.NetworkConfig.TransformSyncMode = SyncMode; networkManager.NetworkConfig.PlayerPrefab = m_PlayerPrefab; SetDistributedAuthorityProperties(networkManager); From a578c34b2984f56a5f1204e3b6121ab5baab0fdd Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Sat, 15 Aug 2026 17:43:15 -0500 Subject: [PATCH 6/7] fix Ownership transfer when using an owner authoritative motion model was not replicating the state properly upon the transfer of ownership causing an out of sync server transform state. --- .../BufferedLinearInterpolator.cs | 19 +++- .../Interpolator/NativeInterpolator.cs | 32 +++++- .../NetworkTransformStateManager.cs | 28 +++++ .../NativeInterpolatorTests.cs | 101 ++++++++++++++++++ .../NetworkTransformSyncModeParityTests.cs | 34 +++++- 5 files changed, 211 insertions(+), 3 deletions(-) diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/BufferedLinearInterpolator.cs b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/BufferedLinearInterpolator.cs index 8ee288b02f..5170d95b02 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/BufferedLinearInterpolator.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/BufferedLinearInterpolator.cs @@ -276,7 +276,24 @@ internal void ResetTo(Transform parent, T targetValue, double serverTime) { // Clear the interpolator Clear(); - InternalReset(parent, targetValue, serverTime); + + // The baseline measurement is deliberately not seeded here. Callers stamp it with + // NetworkManager.ServerTime.Time (the local current time) while the measurements that follow are + // stamped with the tick they were authored on (NetworkTransformState.SentTime), which is always at + // least a tick older. Seeding the baseline therefore establishes an ordering floor that later + // measurements cannot clear: AddMeasurement drops anything not newer than m_LastMeasurementAddedTime, + // and TryConsumeFromBuffer drops anything not newer than InterpolateState.Target.TimeSent. + // + // This only reaches an instance that resets part way through a session, which in practice means one + // that just stopped being the authority (in a client server topology, only ever the server). Such an + // instance would otherwise reject everything the new authority sends until a measurement happens to + // be authored on a later tick than the reset, and if motion has already stopped that never arrives. + // + // Clear() has left the buffer empty with a zeroed m_LastMeasurementAddedTime, and InternalReset seeds + // CurrentValue/NextValue/PreviousValue below, so the value is still held. That is exactly the state a + // freshly spawned interpolator is in: the first measurement to arrive is taken unconditionally + // because m_BufferCount is zero, and it is consumed against render time alone. + InternalReset(parent, targetValue, serverTime, false); } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs index dd6a645dcb..2a61800564 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/Interpolator/NativeInterpolator.cs @@ -266,12 +266,42 @@ internal static void Reset(ref NativeInterpolatorState state, float4 currentValu /// /// . /// + /// + /// The managed implementation seeds a baseline measurement here, stamped with the caller's + /// . This one deliberately does not, because that baseline becomes an + /// ordering floor that the measurements which follow it cannot clear.
+ ///
+ /// Callers pass NetworkManager.ServerTime.Time, the local current time, while incoming + /// measurements are stamped with the tick they were authored on + /// (), which is always at least a tick + /// older. Once the baseline is consumed it becomes , and + /// both of the guards that admit a measurement compare against it: + /// requires a stamp newer than + /// , and + /// requires one newer than Target.TimeSent. An instance that + /// resets part way through a session therefore rejects everything the authority sends next, and the + /// rejection is permanent: with a target already reached and a non empty buffer, + /// neither interpolates nor takes the stale target reset, so elapsed time alone + /// never recovers it.
+ ///
+ /// In practice this only reaches an instance that just stopped being the authority, which in a client + /// server topology is only ever the server. Leaving the buffer empty puts the interpolator in exactly + /// the state a freshly spawned one is in: the value is still held ( seeds all three + /// of the in flight values), the first measurement to arrive is taken unconditionally because + /// is zero, and it is consumed against render time + /// alone. Seeding at spawn is unaffected — the baseline stamp there is one tick ahead of the first + /// measurement, so the interval the first measurement is interpolated over is the tick length either + /// way. + ///
+ /// + /// Retained for signature parity with . Not stored, + /// for the reason above. + /// internal static void ResetTo(ref NativeInterpolatorState state, ref NativeArray items, float4 targetValue, double serverTime) { Clear(ref state); state.RateOfChange = float4.zero; Reset(ref state, targetValue); - AddMeasurement(ref state, ref items, targetValue, serverTime); } /// diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs index 61c5594777..24f39f7ebb 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransformStateManager.cs @@ -463,6 +463,34 @@ internal void ConvertInterpolationSpace(int index, in float4x4 pointTransform, i 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 . /// diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NativeInterpolatorTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NativeInterpolatorTests.cs index f5b1aeea98..ff7138a8d2 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NativeInterpolatorTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NativeInterpolatorTests.cs @@ -333,5 +333,106 @@ public void BufferOverflowKeepsNewestMeasurement() Assert.AreEqual(count, native.Target.Item.x, "The newest measurement was not the one interpolated towards!"); Assert.IsTrue(math.all(math.isfinite(value)), "Interpolated value was not finite!"); } + + /// + /// An instance that stops being the authority part way through a session resets its interpolator with + /// the local current time, while the measurements that follow are stamped with the tick they were + /// authored on. Those stamps are older, so both of the interpolator's ordering guards reject them and + /// the instance never converges onto anything the new authority sends. + /// + /// + /// This is the shape of an ownership transfer away from the local instance, which in a client server + /// topology only ever happens to the server. It is reproduced here rather than only through + /// NetworkTransformSyncModeParityTests.OwnershipChangeKeepsReplicating because the deadlock is + /// entirely internal to the interpolator: once the buffered measurements are all older than the reset + /// baseline, no amount of elapsed time recovers it. + /// + [Test] + public void ResetPartWayThroughSessionStillAcceptsOlderStampedMeasurements([Values] bool useManaged) + { + const float maxInterpolationTime = 0.1f; + const float deltaTime = 1.0f / 60.0f; + const double tickLatency = 2.0 * k_MinDeltaTime; + + // The session has been running for a while when authority is lost. + const int transitionTick = 60; + var transitionTime = transitionTick * k_MinDeltaTime; + + var held = new float4(2.0f, 2.0f, 2.0f, 0.0f); + var target = new float4(-4.0f, 5.0f, 3.0f, 0.0f); + + var managed = new BufferedLinearInterpolatorVector3() + { + IsSlerp = false, + LerpSmoothEnabled = false, + MaximumInterpolationTime = maxInterpolationTime, + }; + var native = CreateState(InterpolatorValueKind.Vector3, false, false, maxInterpolationTime); + + // ResetInterpolatedStateToCurrentAuthoritativeState stamps the baseline with ServerTime.Time. + if (useManaged) + { + managed.ResetTo(new Vector3(held.x, held.y, held.z), transitionTime); + } + else + { + NativeInterpolator.ResetTo(ref native, ref m_Items, held, transitionTime); + } + + // The new authority's first states were authored on ticks at or before the transition, so their + // NetworkTransformState.SentTime is not newer than the baseline's stamp. + var sentTicks = new[] { transitionTick - 1, transitionTick }; + + var time = transitionTime; + var pending = 0; + + // Long enough that nothing is still merely waiting on render time to catch up. + const int frames = 600; + for (int frame = 1; frame <= frames; frame++) + { + time += deltaTime; + + while (pending < sentTicks.Length && frame > pending * 4) + { + var sentTime = sentTicks[pending] * k_MinDeltaTime; + if (useManaged) + { + managed.AddMeasurement(new Vector3(target.x, target.y, target.z), sentTime); + } + else + { + NativeInterpolator.AddMeasurement(ref native, ref m_Items, target, sentTime); + } + pending++; + } + + var renderTime = time - tickLatency; + if (useManaged) + { + managed.Update(deltaTime, renderTime, k_MinDeltaTime, tickLatency, true); + } + else + { + NativeInterpolator.Update(ref native, ref m_Items, deltaTime, renderTime, k_MinDeltaTime, tickLatency, true); + } + } + + var label = useManaged ? "managed" : "native"; + if (useManaged) + { + var result = managed.GetInterpolatedValue(); + Assert.LessOrEqual(Vector3.Distance(result, new Vector3(target.x, target.y, target.z)), k_SettledTolerance, + $"[{label}] interpolator never converged onto the measurements sent after the reset! " + + $"expected={target.xyz} actual={result}"); + } + else + { + Assert.LessOrEqual(math.distance(native.CurrentValue.xyz, target.xyz), k_SettledTolerance, + $"[{label}] interpolator never converged onto the measurements sent after the reset! " + + $"expected={target.xyz} actual={native.CurrentValue.xyz} " + + $"buffered={native.BufferCount} hasTarget={native.HasTarget} " + + $"targetStamp={native.Target.TimeSent} received={native.BufferCounter}"); + } + } } } diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs index 275ea5c974..931c225d4e 100644 --- a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformSyncModeParityTests.cs @@ -177,7 +177,39 @@ private IEnumerator MoveAndConverge(NetworkObject serverSide, Vector3 target, IR authority.transform.position = target; yield return WaitForConditionOrTimeOut(() => AllObserversMatch(serverSide, target, observers)); - AssertOnTimeout($"[{m_SyncMode}][{m_AuthorityMode}] Not every observer reached {target}!"); + AssertOnTimeout($"[{m_SyncMode}][{m_AuthorityMode}] Not every observer reached {target}!\n{DescribeObservers(serverSide, target, observers)}"); + } + + /// + /// Reports where each instance actually is, so a convergence failure identifies which instance was + /// left behind rather than only that one was. + /// + private string DescribeObservers(NetworkObject serverSide, Vector3 expectedPosition, IReadOnlyList observers) + { + var builder = new System.Text.StringBuilder(); + builder.AppendLine($" expected {expectedPosition}, owner is client {serverSide.OwnerClientId}"); + foreach (var manager in observers) + { + var role = manager.IsServer ? "server" : $"client-{manager.LocalClientId}"; + if (!manager.SpawnManager.SpawnedObjects.TryGetValue(serverSide.NetworkObjectId, out var clone)) + { + builder.AppendLine($" {role}: object not spawned"); + continue; + } + + var networkTransform = clone.GetComponent(); + var matches = Approximately(clone.transform.position, expectedPosition) ? "OK " : "BAD"; + // The two indices discriminate between "never registered for the batched interpolation" and + // "registered but the results are not being applied". + builder.AppendLine($" {role}: {matches} pos={clone.transform.position} owner={clone.OwnerClientId} " + + $"canCommit={networkTransform.CanCommitToTransform} isOwner={clone.IsOwner} " + + $"stateIdx={networkTransform.StateManagerIndex} interpIdx={networkTransform.InterpolatorIndex}"); + if (networkTransform.InterpolatorIndex >= 0) + { + builder.AppendLine($" interp: {manager.TransformStateManager.DescribePositionInterpolator(networkTransform.InterpolatorIndex)}"); + } + } + return builder.ToString(); } /// From 202190c9fa53604941398a0bb47c4bb4cf693536 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Sat, 15 Aug 2026 17:45:10 -0500 Subject: [PATCH 7/7] chore removing link.xml that was accidentally included. --- .../Assets/AddressableAssetsData/link.xml | 22 ------------------- .../AddressableAssetsData/link.xml.meta | 7 ------ 2 files changed, 29 deletions(-) delete mode 100644 testproject/Assets/AddressableAssetsData/link.xml delete mode 100644 testproject/Assets/AddressableAssetsData/link.xml.meta diff --git a/testproject/Assets/AddressableAssetsData/link.xml b/testproject/Assets/AddressableAssetsData/link.xml deleted file mode 100644 index 40a82bfd44..0000000000 --- a/testproject/Assets/AddressableAssetsData/link.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/testproject/Assets/AddressableAssetsData/link.xml.meta b/testproject/Assets/AddressableAssetsData/link.xml.meta deleted file mode 100644 index e58995fca4..0000000000 --- a/testproject/Assets/AddressableAssetsData/link.xml.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 7921a2f9789f9d445a6bcf339f53c48e -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: