From 6ad2698002adbe454db9b737c5d8ca61007e4ebd Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Thu, 29 Oct 2026 10:59:29 -0500 Subject: [PATCH 1/2] refactor: make NetworkTransformState blittable and derive its reliability before serializing Fourth of five PRs that replace #4123. NetworkTransformState drops its reader and writer fields so it can live in native memory, and UpdateReliability decides ReliableSequenced before the state is written. NetworkTransform routes its interpolators through helpers and computes its interpolation frame data once per update stage. NetworkTransformStateBaselineTests pins the serialized output. --- .../Runtime/Components/NetworkTransform.cs | 604 +++++++++++------- .../Runtime/Core/NetworkManager.cs | 18 + .../NetworkTransformStateBaselineTests.cs | 509 +++++++++++++++ ...NetworkTransformStateBaselineTests.cs.meta | 2 + 4 files changed, 917 insertions(+), 216 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/NetworkTransform.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs index b62aaddf13..c916a87bd7 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs @@ -354,10 +354,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; /// @@ -626,9 +622,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; } @@ -653,11 +650,11 @@ public Quaternion GetRotation() /// public Vector3 GetPosition() { - if (HasPositionChange) + if (FlagStates.HasPositionChange) { - if (UseHalfFloatPrecision) + if (FlagStates.UseHalfFloatPrecision) { - if (IsTeleportingNextFrame) + if (FlagStates.IsTeleportingNextFrame) { return CurrentPosition; } @@ -681,11 +678,11 @@ public Vector3 GetPosition() /// public Vector3 GetScale() { - if (HasScaleChange) + if (FlagStates.HasScaleChange) { - if (UseHalfFloatPrecision) + if (FlagStates.UseHalfFloatPrecision) { - if (IsTeleportingNextFrame) + if (FlagStates.IsTeleportingNextFrame) { return Scale; } @@ -709,21 +706,59 @@ 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 matches how the message is actually sent. + FlagStates.ReliableSequenced = FlagStates.IsTeleportingNextFrame || FlagStates.IsSynchronizing + || FlagStates.UnreliableFrameSync || FlagStates.SynchronizeBaseHalfFloat; + } + /// public void NetworkSerialize(BufferSerializer serializer) where T : IReaderWriter { // Used to calculate the LastSerializedSize value var positionStart = 0; var isWriting = serializer.IsWriter; + // The reader and writer are locals rather than fields so that + // NetworkTransformState remains blittable (i.e. can be used in managed or native realms). + // The NetworkSerialize method is always invoked by managed code (for now) so accessing + // the non-blittable FastBufferWriter or FastBufferReader is "ok". + var writer = default(FastBufferWriter); + var reader = default(FastBufferReader); if (isWriting) { - m_Writer = serializer.GetFastBufferWriter(); - positionStart = m_Writer.Position; + writer = serializer.GetFastBufferWriter(); + positionStart = writer.Position; } else { - m_Reader = serializer.GetFastBufferReader(); - positionStart = m_Reader.Position; + reader = serializer.GetFastBufferReader(); + positionStart = reader.Position; } #if NGO_NETWORKTRANSFORMSTATE_LOGWRITESIZE @@ -737,50 +772,31 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade { if (isWriting) { - if (FlagStates.UseUnreliableDeltas) - { - // If teleporting, synchronizing, doing an axial frame sync, or using half float precision and we collapsed a delta into the base position - if (FlagStates.IsTeleportingNextFrame || FlagStates.IsSynchronizing || FlagStates.UnreliableFrameSync - || (FlagStates.UseHalfFloatPrecision && NetworkDeltaPosition.CollapsedDeltaIntoBase)) - { - // Send the message reliably - FlagStates.ReliableSequenced = true; - } - else - { - FlagStates.ReliableSequenced = false; - } - } - else // If not using UseUnreliableDeltas, then always use reliable fragmented sequenced - { - FlagStates.ReliableSequenced = true; - } - // Serialize the flags as an unsigned int - BytePacker.WriteValueBitPacked(m_Writer, FlagStates.GetBitsetRepresentation()); + BytePacker.WriteValueBitPacked(writer, FlagStates.GetBitsetRepresentation()); // We use network ticks as opposed to absolute time as the authoritative // side updates on every new tick. - BytePacker.WriteValueBitPacked(m_Writer, NetworkTick); + BytePacker.WriteValueBitPacked(writer, NetworkTick); } else { // Deserialize the flags - ByteUnpacker.ReadValueBitPacked(m_Reader, out uint bitset); + ByteUnpacker.ReadValueBitPacked(reader, out uint bitset); // Set the flags FlagStates.SetStateFromBitset(bitset); // We use network ticks as opposed to absolute time as the authoritative // side updates on every new tick. - ByteUnpacker.ReadValueBitPacked(m_Reader, out NetworkTick); + ByteUnpacker.ReadValueBitPacked(reader, out NetworkTick); } } #if NGO_NETWORKTRANSFORMSTATE_LOGWRITESIZE if (isWriting) { - bitSetAndTickSize = m_Writer.Position - positionStart; - lastPosition = m_Writer.Position; + bitSetAndTickSize = writer.Position - positionStart; + lastPosition = writer.Position; } #endif @@ -791,23 +807,23 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade } // Synchronize Position - if (HasPositionChange) + if (FlagStates.HasPositionChange) { - if (UseHalfFloatPrecision) + if (FlagStates.UseHalfFloatPrecision) { NetworkDeltaPosition.SynchronizeBase = FlagStates.SynchronizeBaseHalfFloat; // Apply which axis should be updated for both write/read (teleporting, synchronizing, or just updating) - NetworkDeltaPosition.HalfVector3.AxisToSynchronize[0] = HasPositionX; - NetworkDeltaPosition.HalfVector3.AxisToSynchronize[1] = HasPositionY; - NetworkDeltaPosition.HalfVector3.AxisToSynchronize[2] = HasPositionZ; + NetworkDeltaPosition.HalfVector3.AxisToSynchronize[0] = FlagStates.HasPositionX; + NetworkDeltaPosition.HalfVector3.AxisToSynchronize[1] = FlagStates.HasPositionY; + NetworkDeltaPosition.HalfVector3.AxisToSynchronize[2] = FlagStates.HasPositionZ; - if (IsTeleportingNextFrame) + if (FlagStates.IsTeleportingNextFrame) { // **Always use full precision when teleporting and UseHalfFloatPrecision is enabled** serializer.SerializeValue(ref CurrentPosition); // If we are synchronizing, then include the half vector position's delta offset - if (IsSynchronizing) + if (FlagStates.IsSynchronizing) { serializer.SerializeValue(ref DeltaPosition); if (!isWriting) @@ -836,17 +852,17 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade } else // Full precision axis specific position synchronization { - if (HasPositionX) + if (FlagStates.HasPositionX) { serializer.SerializeValue(ref PositionX); } - if (HasPositionY) + if (FlagStates.HasPositionY) { serializer.SerializeValue(ref PositionY); } - if (HasPositionZ) + if (FlagStates.HasPositionZ) { serializer.SerializeValue(ref PositionZ); } @@ -856,25 +872,25 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade #if NGO_NETWORKTRANSFORMSTATE_LOGWRITESIZE if (isWriting) { - positionSize = m_Writer.Position - lastPosition; - lastPosition = m_Writer.Position; + positionSize = writer.Position - lastPosition; + lastPosition = writer.Position; } #endif // Synchronize Rotation - if (HasRotAngleChange) + if (FlagStates.HasRotAngleChange) { - if (QuaternionSync) + if (FlagStates.QuaternionSync) { // Always use the full quaternion if teleporting - if (IsTeleportingNextFrame) + if (FlagStates.IsTeleportingNextFrame) { serializer.SerializeValue(ref Rotation); } else { // Use the quaternion compressor if enabled - if (QuaternionCompression) + if (FlagStates.QuaternionCompression) { if (isWriting) { @@ -890,7 +906,7 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade } else { - if (UseHalfFloatPrecision) + if (FlagStates.UseHalfFloatPrecision) { if (isWriting) { @@ -914,14 +930,14 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade else // Euler Rotation Synchronization { // Half float precision (full precision when teleporting) - if (UseHalfFloatPrecision && !IsTeleportingNextFrame) + if (FlagStates.UseHalfFloatPrecision && !FlagStates.IsTeleportingNextFrame) { - if (HasRotAngleChange) + if (FlagStates.HasRotAngleChange) { // Apply which axis should be updated for both write/read - HalfEulerRotation.AxisToSynchronize[0] = HasRotAngleX; - HalfEulerRotation.AxisToSynchronize[1] = HasRotAngleY; - HalfEulerRotation.AxisToSynchronize[2] = HasRotAngleZ; + HalfEulerRotation.AxisToSynchronize[0] = FlagStates.HasRotAngleX; + HalfEulerRotation.AxisToSynchronize[1] = FlagStates.HasRotAngleY; + HalfEulerRotation.AxisToSynchronize[2] = FlagStates.HasRotAngleZ; if (isWriting) { @@ -933,17 +949,17 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade if (!isWriting) { var eulerRotation = HalfEulerRotation.ToVector3(); - if (HasRotAngleX) + if (FlagStates.HasRotAngleX) { RotAngleX = eulerRotation.x; } - if (HasRotAngleY) + if (FlagStates.HasRotAngleY) { RotAngleY = eulerRotation.y; } - if (HasRotAngleZ) + if (FlagStates.HasRotAngleZ) { RotAngleZ = eulerRotation.z; } @@ -953,17 +969,17 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade else // Full precision Euler { // RotAngle Values - if (HasRotAngleX) + if (FlagStates.HasRotAngleX) { serializer.SerializeValue(ref RotAngleX); } - if (HasRotAngleY) + if (FlagStates.HasRotAngleY) { serializer.SerializeValue(ref RotAngleY); } - if (HasRotAngleZ) + if (FlagStates.HasRotAngleZ) { serializer.SerializeValue(ref RotAngleZ); } @@ -974,33 +990,33 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade #if NGO_NETWORKTRANSFORMSTATE_LOGWRITESIZE if (isWriting) { - rotationSize = m_Writer.Position - lastPosition; - lastPosition = m_Writer.Position; + rotationSize = writer.Position - lastPosition; + lastPosition = writer.Position; } #endif // Synchronize Scale - if (HasScaleChange) + if (FlagStates.HasScaleChange) { // If we are teleporting (which includes synchronizing) and the associated NetworkObject has a parent // then we want to serialize the LossyScale since NetworkObject spawn order is not guaranteed - if (IsTeleportingNextFrame && FlagStates.IsParented) + if (FlagStates.IsTeleportingNextFrame && FlagStates.IsParented) { serializer.SerializeValue(ref LossyScale); } // Half precision scale synchronization - if (UseHalfFloatPrecision) + if (FlagStates.UseHalfFloatPrecision) { - if (IsTeleportingNextFrame) + if (FlagStates.IsTeleportingNextFrame) { serializer.SerializeValue(ref Scale); } else { // Apply which axis should be updated for both write/read - HalfVectorScale.AxisToSynchronize[0] = HasScaleX; - HalfVectorScale.AxisToSynchronize[1] = HasScaleY; - HalfVectorScale.AxisToSynchronize[2] = HasScaleZ; + HalfVectorScale.AxisToSynchronize[0] = FlagStates.HasScaleX; + HalfVectorScale.AxisToSynchronize[1] = FlagStates.HasScaleY; + HalfVectorScale.AxisToSynchronize[2] = FlagStates.HasScaleZ; // For scale, when half precision is enabled we can still only send the axis with deltas if (isWriting) @@ -1013,36 +1029,36 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade if (!isWriting) { Scale = HalfVectorScale.ToVector3(); - if (HasScaleX) + if (FlagStates.HasScaleX) { ScaleX = Scale.x; } - if (HasScaleY) + if (FlagStates.HasScaleY) { ScaleY = Scale.y; } - if (HasScaleZ) + if (FlagStates.HasScaleZ) { - ScaleZ = Scale.x; + ScaleZ = Scale.z; } } } } else // Full precision scale synchronization { - if (HasScaleX) + if (FlagStates.HasScaleX) { serializer.SerializeValue(ref ScaleX); } - if (HasScaleY) + if (FlagStates.HasScaleY) { serializer.SerializeValue(ref ScaleY); } - if (HasScaleZ) + if (FlagStates.HasScaleZ) { serializer.SerializeValue(ref ScaleZ); } @@ -1052,8 +1068,8 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade #if NGO_NETWORKTRANSFORMSTATE_LOGWRITESIZE if (isWriting) { - scaleSize = m_Writer.Position - lastPosition; - lastPosition = m_Writer.Position; + scaleSize = writer.Position - lastPosition; + lastPosition = writer.Position; } #endif @@ -1061,12 +1077,12 @@ public void NetworkSerialize(BufferSerializer serializer) where T : IReade if (!isWriting) { // Go ahead and mark the local state dirty - FlagStates.IsDirty = HasPositionChange || HasRotAngleChange || HasScaleChange; - LastSerializedSize = m_Reader.Position - positionStart; + FlagStates.IsDirty = FlagStates.HasPositionChange || FlagStates.HasRotAngleChange || FlagStates.HasScaleChange; + LastSerializedSize = reader.Position - positionStart; } else { - LastSerializedSize = m_Writer.Position - positionStart; + LastSerializedSize = writer.Position - positionStart; #if NGO_NETWORKTRANSFORMSTATE_LOGWRITESIZE Debug.Log($"[NT-WriteSize][BitsAndTick: {bitSetAndTickSize}][position: {positionSize}][rotation: {rotationSize}][scale: {scaleSize}]"); #endif @@ -1787,9 +1803,6 @@ internal NetworkTransformState LocalAuthoritativeNetworkState // rotation is a single Quaternion since each Euler axis will affect the quaternion's final value private BufferedLinearInterpolatorQuaternion m_RotationInterpolator; - // The previous network state - private NetworkTransformState m_OldState = new NetworkTransformState(); - // Non-Authoritative's current position, scale, and rotation that is used to assure the non-authoritative side cannot make adjustments to // the portions of the transform being synchronized. private Vector3 m_InternalCurrentPosition; @@ -1930,6 +1943,7 @@ protected override void OnSynchronize(ref BufferSerializer serializer) // 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); @@ -2027,6 +2041,17 @@ 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); + } + } + + /// + /// Sends any detected state updates for the frame. + /// + /// 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) @@ -2045,9 +2070,6 @@ private void TryCommitTransform(bool synchronize = false, bool settingState = fa // Send the state update UpdateTransformState(); - // Mark the last tick and the old state (for next ticks) - m_OldState = m_LocalAuthoritativeNetworkState; - // Preserve our teleporting flag in order to know if the state pushed was a teleport m_LocalAuthoritativeNetworkState.FlagStates.WasTeleported = m_LocalAuthoritativeNetworkState.IsTeleportingNextFrame; @@ -2382,6 +2404,78 @@ 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 (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 (resetInterpolator) + { + m_ScaleInterpolator.ResetTo(scale, time); + } + else + { + m_ScaleInterpolator.AddMeasurement(transform.parent, scale, time); + } + } + + /// + /// Clears all three interpolators, routed to whichever this instance is using. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ClearInterpolators() + { + 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() + { + return m_PositionInterpolator.GetInterpolatedValue(); + } + + /// + /// The current interpolated rotation, from whichever interpolator this instance is using. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private Quaternion GetInterpolatedRotation() + { + return m_RotationInterpolator.GetInterpolatedValue(); + } + + /// + /// The current interpolated scale, from whichever interpolator this instance is using. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private Vector3 GetInterpolatedScale() + { + return m_ScaleInterpolator.GetInterpolatedValue(); + } + internal bool LogMotion; /// @@ -2407,29 +2501,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 @@ -2460,7 +2568,7 @@ protected internal void ApplyAuthoritativeState() { if (SynchronizePosition) { - var interpolatedPosition = m_PositionInterpolator.GetInterpolatedValue(); + var interpolatedPosition = GetInterpolatedPosition(); if (UseHalfFloatPrecision) { adjustedPosition = interpolatedPosition; @@ -2477,11 +2585,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; } @@ -2490,7 +2598,7 @@ protected internal void ApplyAuthoritativeState() if (SynchronizeRotation) { - var interpolatedRotation = m_RotationInterpolator.GetInterpolatedValue(); + var interpolatedRotation = GetInterpolatedRotation(); if (UseQuaternionSynchronization) { adjustedRotation = interpolatedRotation; @@ -2559,7 +2667,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; } @@ -2578,7 +2686,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}"); } } @@ -2600,9 +2708,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; } @@ -2644,7 +2752,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; } @@ -2670,7 +2778,7 @@ protected internal void ApplyAuthoritativeState() /// private void ApplyTeleportingState(NetworkTransformState newState) { - if (!newState.IsTeleportingNextFrame) + if (!newState.FlagStates.IsTeleportingNextFrame) { return; } @@ -2681,13 +2789,11 @@ private void ApplyTeleportingState(NetworkTransformState newState) var currentEulerAngles = currentRotation.eulerAngles; var currentScale = CachedTransform.localScale; - var isSynchronization = newState.IsSynchronizing; + var isSynchronization = newState.FlagStates.IsSynchronizing; var flagStates = newState.FlagStates; // Clear all interpolators - m_ScaleInterpolator.Clear(); - m_PositionInterpolator.Clear(); - m_RotationInterpolator.Clear(); + ClearInterpolators(); if (flagStates.HasPositionChange) { @@ -2801,7 +2907,7 @@ private void ApplyTeleportingState(NetworkTransformState newState) if (Interpolate) { - m_ScaleInterpolator.ResetTo(currentScale, sentTime); + UpdateScaleInterpolator(currentScale, sentTime, true); } } @@ -2852,9 +2958,7 @@ private void ApplyTeleportingState(NetworkTransformState newState) if (Interpolate) { - m_RotationInterpolator.AutoConvertTransformSpace = SwitchTransformSpaceWhenParented; - m_RotationInterpolator.InLocalSpace = newState.InLocalSpace; - m_RotationInterpolator.ResetTo(CachedTransform.parent, currentRotation, sentTime); + UpdateRotationInterpolator(currentRotation, sentTime, true); } } @@ -2897,13 +3001,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; @@ -3000,7 +3104,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 @@ -3035,7 +3139,7 @@ internal void ApplyUpdatedState(NetworkTransformState newState) currentRotation.eulerAngles = currentEulerAngles; } - m_RotationInterpolator.AddMeasurement(transform.parent, currentRotation, sentTime); + UpdateRotationInterpolator(currentRotation, sentTime); } } @@ -3172,45 +3276,65 @@ private void AxisChangedDeltaPositionCheck() // Only if the synchronization of an axis is turned on do we need to // check if a teleport is required due to the delta from the last known // to the currently known axis value exceeds MaxDeltaBeforeAdjustment. + // Accumulated across the axes: any one of them being out of range is enough, and an + // axis that is in range must not clear what an earlier one found. if (SyncPositionX && SyncPositionX != synAxis.x) { - needsToTeleport = Mathf.Abs(relativePosition.x - positionState.x) >= NetworkDeltaPosition.MaxDeltaBeforeAdjustment; + needsToTeleport |= Mathf.Abs(relativePosition.x - positionState.x) >= NetworkDeltaPosition.MaxDeltaBeforeAdjustment; } if (SyncPositionY && SyncPositionY != synAxis.y) { - needsToTeleport = Mathf.Abs(relativePosition.y - positionState.y) >= NetworkDeltaPosition.MaxDeltaBeforeAdjustment; + needsToTeleport |= Mathf.Abs(relativePosition.y - positionState.y) >= NetworkDeltaPosition.MaxDeltaBeforeAdjustment; } if (SyncPositionZ && SyncPositionZ != synAxis.z) { - needsToTeleport = Mathf.Abs(relativePosition.z - positionState.z) >= NetworkDeltaPosition.MaxDeltaBeforeAdjustment; + needsToTeleport |= Mathf.Abs(relativePosition.z - positionState.z) >= NetworkDeltaPosition.MaxDeltaBeforeAdjustment; } - // If needed, force a teleport as the delta is outside of the valid delta boundary - m_LocalAuthoritativeNetworkState.FlagStates.IsTeleportingNextFrame = needsToTeleport; + // If needed, force a teleport as the delta is outside of the valid delta boundary. Or-ed + // in rather than assigned, so a teleport already pending for this tick survives. + m_LocalAuthoritativeNetworkState.FlagStates.IsTeleportingNextFrame |= needsToTeleport; } } } + /// + /// Clears the previous tick's change flags so the next delta check starts from a clean bitset. + /// + /// + /// Skipped while an explicit set or a teleport is pending, since both carry state the next update + /// still has to send.

+ /// Runs from . Without it the + /// bitset only ever accumulates, so an axial group that changed once keeps its change flag for the + /// life of the instance and is serialized on every state update from then on. + ///
+ private void ClearStateForNextTick() + { + if (m_LocalAuthoritativeNetworkState.ExplicitSet + || !m_LocalAuthoritativeNetworkState.FlagStates.IsDirty + || m_LocalAuthoritativeNetworkState.IsTeleportingNextFrame) + { + return; + } + + m_LocalAuthoritativeNetworkState.FlagStates.ClearForNextTick(); + if (TrackStateUpdateId) + { + m_LocalAuthoritativeNetworkState.FlagStates.TrackByStateId = true; + m_LocalAuthoritativeNetworkState.StateId++; + } + else + { + m_LocalAuthoritativeNetworkState.FlagStates.TrackByStateId = false; + } + } + /// /// Called by authority to check for deltas and update non-authoritative instances /// if any are found. /// internal void OnUpdateAuthoritativeState(bool settingState = false) { - // If our replicated state is not dirty and our local authority state is dirty, clear it. - if (!m_LocalAuthoritativeNetworkState.ExplicitSet && m_LocalAuthoritativeNetworkState.FlagStates.IsDirty && !m_LocalAuthoritativeNetworkState.IsTeleportingNextFrame) - { - // Now clear our bitset and prepare for next network tick state update - m_LocalAuthoritativeNetworkState.FlagStates.ClearForNextTick(); - if (TrackStateUpdateId) - { - m_LocalAuthoritativeNetworkState.FlagStates.TrackByStateId = true; - m_LocalAuthoritativeNetworkState.StateId++; - } - else - { - m_LocalAuthoritativeNetworkState.FlagStates.TrackByStateId = false; - } - } + ClearStateForNextTick(); AxisChangedDeltaPositionCheck(); @@ -3472,9 +3596,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); } /// @@ -3693,15 +3817,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); } } @@ -3734,7 +3856,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); @@ -3765,7 +3887,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; @@ -4040,8 +4162,48 @@ internal BufferedLinearInterpolatorQuaternion GetRotationInterpolator() } #endif - // Non-Authority - private void UpdateInterpolation() + /// + /// Represents the commonly shared interpolation values that are identical for every + /// and is updated per frame. + /// + /// + /// These are calculated once per update stage by as opposed + /// to being recalculated by each instance. Both the base and the "one additional tick" variants are + /// pre-calculated because owner authoritative instances owned by another client add a tick to account + /// for the 2xRTT relay time. + /// + internal struct InterpolationFrameData + { + internal double CurrentTime; + internal float DeltaTime; + internal float FixedDeltaTime; + // Smooth dampening and extrapolation specific: + // We clamp between the tick rate frequency and the tick latency x tick rate frequency + internal double MinDeltaTime; + internal bool IsServer; + // Only true if the network topology selected for the session permits the additional owner authority tick. + internal bool OwnerAuthorityTickOffsetAllowed; + // Tick latency (ticks ago) used to process state updates in the queue. + internal double TickLatencyAsTime; + // The maximum time we will lerp between values. If the time exceeds this due to extreme latency then + // the value's interpolation rate will be accelerated to reach the goal and continue interpolating. + internal double MaxDeltaTime; + // Combines the two values above, with any additional owner authority tick applied. + internal double TickLatencyAsTimeExtraTick; + internal double MaxDeltaTimeExtraTick; + // Legacy lerp render times for a "ticks ago" of 1 and 2 (each plus InterpolationBufferTickOffset). + internal double LegacyRenderTime; + internal double LegacyRenderTimeExtraTick; + } + + /// + /// Refreshes the . + /// + /// + /// Invoked once per update stage (authority and rigid body motion relative), prior to updating + /// registered instances. + /// + internal static void RefreshInterpolationFrameData(NetworkManager networkManager) { // Use the server time, since that is the clock the states being interpolated between are stamped on // (a state's SentTime is derived from its NetworkTick). Deriving the render time from LocalTime @@ -4049,47 +4211,66 @@ private void UpdateInterpolation() // leaves the render time at or ahead of the newest state that can exist and starves the interpolator. // Measuring from ServerTime is also self correcting, as the tick latency grows with the round trip // time. This is a no-op on a host or server, where both clocks are the same. - var timeSystem = m_CachedNetworkManager.ServerTime; - var currentTime = timeSystem.Time; -#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D - var cachedDeltaTime = m_UseRigidbodyForMotion ? m_CachedNetworkManager.RealTimeProvider.FixedDeltaTime : m_CachedNetworkManager.RealTimeProvider.DeltaTime; -#else - var cachedDeltaTime = m_CachedNetworkManager.RealTimeProvider.DeltaTime; -#endif + var timeSystem = networkManager.ServerTime; + var realTimeProvider = networkManager.RealTimeProvider; + var minDeltaTime = timeSystem.FixedDeltaTimeAsDouble; + // Optional user defined tick offset to be used to push the "render time" (the time that will be used to determine if a state update is available) // back in order to provide more room for the interpolator to interpolate towards when latency conditions are impacting the frequency that state // updates are received. - var tickLatency = Mathf.Max(1, m_CachedNetworkManager.NetworkTimeSystem.TickLatency + InterpolationBufferTickOffset); + var tickLatency = Mathf.Max(1, networkManager.NetworkTimeSystem.TickLatency + InterpolationBufferTickOffset); + var isServer = networkManager.IsServer; + + networkManager.TransformInterpolationFrameData = new InterpolationFrameData() + { + CurrentTime = timeSystem.Time, + DeltaTime = realTimeProvider.DeltaTime, + FixedDeltaTime = realTimeProvider.FixedDeltaTime, + MinDeltaTime = minDeltaTime, + IsServer = isServer, + // The additional owner authority tick only applies within a client-server topology (including + // DAHost) and only on instances that are not the server/host. + OwnerAuthorityTickOffsetAllowed = !isServer && (!networkManager.DistributedAuthorityMode || !networkManager.CMBServiceConnection), + TickLatencyAsTime = timeSystem.TimeTicksAgo(tickLatency).Time, + MaxDeltaTime = tickLatency * minDeltaTime, + TickLatencyAsTimeExtraTick = timeSystem.TimeTicksAgo(tickLatency + 1).Time, + MaxDeltaTimeExtraTick = (tickLatency + 1) * minDeltaTime, + // Since InterpolationBufferTickOffset defaults to zero, this should not impact existing projects but + // still provides users with the ability to tweak their ticks ago time. + LegacyRenderTime = timeSystem.TimeTicksAgo(1 + InterpolationBufferTickOffset).Time, + LegacyRenderTimeExtraTick = timeSystem.TimeTicksAgo(2 + InterpolationBufferTickOffset).Time, + }; + } - // If using an owner authoritative motion model - if (!IsServerAuthoritative()) - { - // 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() + { + // TODO-JIRA-TICKET: + // This could be further optimized by excluding batched transforms from the Update/FixedUpdate invocations. + // 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; + + var tickLatencyAsTime = useExtraTick ? frameData.TickLatencyAsTimeExtraTick : frameData.TickLatencyAsTime; + var maxDeltaTime = useExtraTick ? frameData.MaxDeltaTimeExtraTick : frameData.MaxDeltaTime; - // Get the tick latency (ticks ago) as time (in the past) to process state updates in the queue. - var tickLatencyAsTime = timeSystem.TimeTicksAgo(tickLatency).Time; + // Note: This is for the legacy lerp type in order to maintain the same end result for projects that have tuned their + // settings 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 @@ -4102,15 +4283,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) { @@ -4395,13 +4567,13 @@ internal void TransformStateUpdate() return; } // Store the previous/old state - m_OldState = m_LocalAuthoritativeNetworkState; + var oldState = m_LocalAuthoritativeNetworkState; // Assign the new incoming state m_LocalAuthoritativeNetworkState = InboundState; // Apply the state update - OnNetworkStateChanged(m_OldState, m_LocalAuthoritativeNetworkState); + OnNetworkStateChanged(oldState, m_LocalAuthoritativeNetworkState); } // Used to send outbound messages @@ -4417,6 +4589,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) { @@ -4428,13 +4603,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) @@ -4572,6 +4742,7 @@ internal void TickUpdate() Remove(); return; } + foreach (var networkTransform in NetworkTransforms) { if (networkTransform.IsSpawned) @@ -4579,6 +4750,7 @@ internal void TickUpdate() networkTransform.OnNetworkTick(); } } + m_LastTick = CurrentTick; } public NetworkTransformTickRegistration(NetworkManager networkManager) diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index e8775ed5e9..e4fa088223 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -290,6 +290,12 @@ 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(); @@ -392,6 +398,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 @@ -432,6 +444,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..bfdb74d58c --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformStateBaselineTests.cs @@ -0,0 +1,509 @@ +using System; +using System.Text; +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using static Unity.Netcode.Components.NetworkTransform; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Captures the exact serialized form of a across the matrix of + /// configurations that drive its serialization branches. + /// + /// + /// To regenerate: run this test, copy the array literal it prints on failure into + /// , and confirm every changed entry is an intended wire format change. + /// + // These tests do not need to run against the Rust server. + [IgnoreIfServiceEnvironmentVariableSet] + internal class NetworkTransformStateBaselineTests + { + /// + /// One entry per case in , formatted as "name|byteLength|fnv1aHash". + /// + private static readonly string[] k_ExpectedSignatures = + { + "FullPrecision.AllAxes|42|B454AEF3", + "FullPrecision.PositionOnly|18|53BC0797", + "FullPrecision.PositionX|10|BBA3D614", + "FullPrecision.RotationYOnly|10|3D4B5A51", + "FullPrecision.ScaleZOnly|10|ADD3F579", + "FullPrecision.Teleport|42|E62F1693", + "FullPrecision.Teleport.Parented|30|0167D4EA", + "FullPrecision.TrackByStateId|23|7109AC47", + "FullPrecision.InLocalSpace|18|FE97B9BF", + "QuaternionSync.Full|22|EB89F8E7", + "QuaternionSync.Compressed|10|A86AAC6C", + "QuaternionSync.HalfFloat|14|2629A22E", + "QuaternionSync.Teleport|22|51DF412A", + "HalfFloat.AllAxes|24|56C94289", + "HalfFloat.PositionOnly|12|E0EB69C7", + "HalfFloat.PositionXZ|10|270908C3", + "HalfFloat.SynchronizeBase|30|83FFE5D8", + "HalfFloat.Teleport|30|9407B934", + "HalfFloat.Synchronizing|36|E1EC38D8", + "HalfFloat.ScaleOnly|12|254AC316", + "HalfFloat.EulerRotation|12|F769BA44", + "UnreliableDeltas.FrameSync|19|81628843", + "UnreliableDeltas.SynchronizeBaseHalfFloat|30|0DF74758", + "UnreliableDeltas.PlainDelta|12|7B1C8307", + "SwitchTransformSpaceWhenParented|19|DD37211C", + }; + + /// + /// Deterministic payload values so the serialized output is stable between runs. + /// + private static NetworkTransformState CreateBaseState() + { + var state = new NetworkTransformState + { + NetworkTick = 12345, + StateId = 77, + PositionX = 1.25f, + PositionY = -30.5f, + PositionZ = 512.125f, + RotAngleX = 33.75f, + RotAngleY = 190.5f, + RotAngleZ = 271.25f, + // Literal components rather than Quaternion.Euler of the angles above: Euler goes through + // native trig, which lands a ULP apart on Linux against Windows, and the uncompressed + // quaternion cases put those bytes on the wire verbatim. (1, 2, 2, 4) / 5 is unit length and + // has a unique largest component for the smallest three compression path. + Rotation = new Quaternion(0.2f, 0.4f, 0.4f, 0.8f), + ScaleX = 2.5f, + ScaleY = 0.75f, + ScaleZ = 4.0f, + Scale = new Vector3(2.5f, 0.75f, 4.0f), + LossyScale = new Vector3(5.0f, 1.5f, 8.0f), + CurrentPosition = new Vector3(1.25f, -30.5f, 512.125f), + DeltaPosition = new Vector3(0.125f, -0.25f, 0.5f), + }; + + var currentPosition = state.CurrentPosition; + state.NetworkDeltaPosition = new NetworkDeltaPosition(currentPosition, state.NetworkTick, math.bool3(true)); + var deltaTarget = currentPosition + state.DeltaPosition; + state.NetworkDeltaPosition.UpdateFrom(ref deltaTarget, state.NetworkTick); + + state.HalfVectorScale = new HalfVector3(state.Scale, math.bool3(true)); + var rotation = state.Rotation; + state.HalfVectorRotation = new HalfVector4(); + state.HalfVectorRotation.UpdateFrom(ref rotation); + state.HalfEulerRotation = new HalfVector3(state.RotAngleX, state.RotAngleY, state.RotAngleZ); + + return state; + } + + private struct StateCase + { + public string Name; + public NetworkTransformState State; + } + + /// + /// The configuration matrix. Each entry exercises a distinct path through + /// . + /// + private static StateCase[] BuildStateMatrix() + { + var cases = new System.Collections.Generic.List(); + + // Full precision, euler rotation, all axes. + AddCase(cases, "FullPrecision.AllAxes", f => + { + f.MarkChanged(AxialType.Position, true); + f.MarkChanged(AxialType.Rotation, true); + f.MarkChanged(AxialType.Scale, true); + return f; + }); + + AddCase(cases, "FullPrecision.PositionOnly", f => + { + f.MarkChanged(AxialType.Position, true); + return f; + }); + + AddCase(cases, "FullPrecision.PositionX", f => + { + f.SetHasPosition(Axis.X, true); + return f; + }); + + AddCase(cases, "FullPrecision.RotationYOnly", f => + { + f.SetHasRotation(Axis.Y, true); + return f; + }); + + AddCase(cases, "FullPrecision.ScaleZOnly", f => + { + f.SetHasScale(Axis.Z, true); + return f; + }); + + AddCase(cases, "FullPrecision.Teleport", f => + { + f.MarkChanged(AxialType.Position, true); + f.MarkChanged(AxialType.Rotation, true); + f.MarkChanged(AxialType.Scale, true); + f.IsTeleportingNextFrame = true; + return f; + }); + + AddCase(cases, "FullPrecision.Teleport.Parented", f => + { + f.MarkChanged(AxialType.Scale, true); + f.IsTeleportingNextFrame = true; + f.IsParented = true; + return f; + }); + + AddCase(cases, "FullPrecision.TrackByStateId", f => + { + f.MarkChanged(AxialType.Position, true); + f.TrackByStateId = true; + return f; + }); + + AddCase(cases, "FullPrecision.InLocalSpace", f => + { + f.MarkChanged(AxialType.Position, true); + f.InLocalSpace = true; + return f; + }); + + // Quaternion synchronization (full precision quaternion). + AddCase(cases, "QuaternionSync.Full", f => + { + f.MarkChanged(AxialType.Rotation, true); + f.QuaternionSync = true; + return f; + }); + + AddCase(cases, "QuaternionSync.Compressed", f => + { + f.MarkChanged(AxialType.Rotation, true); + f.QuaternionSync = true; + f.QuaternionCompression = true; + return f; + }); + + AddCase(cases, "QuaternionSync.HalfFloat", f => + { + f.MarkChanged(AxialType.Rotation, true); + f.QuaternionSync = true; + f.UseHalfFloatPrecision = true; + return f; + }); + + AddCase(cases, "QuaternionSync.Teleport", f => + { + f.MarkChanged(AxialType.Rotation, true); + f.QuaternionSync = true; + f.QuaternionCompression = true; + f.IsTeleportingNextFrame = true; + return f; + }); + + // Half float precision. + AddCase(cases, "HalfFloat.AllAxes", f => + { + f.MarkChanged(AxialType.Position, true); + f.MarkChanged(AxialType.Rotation, true); + f.MarkChanged(AxialType.Scale, true); + f.UseHalfFloatPrecision = true; + return f; + }); + + AddCase(cases, "HalfFloat.PositionOnly", f => + { + f.MarkChanged(AxialType.Position, true); + f.UseHalfFloatPrecision = true; + return f; + }); + + AddCase(cases, "HalfFloat.PositionXZ", f => + { + f.SetHasPosition(Axis.X, true); + f.SetHasPosition(Axis.Z, true); + f.UseHalfFloatPrecision = true; + return f; + }); + + AddCase(cases, "HalfFloat.SynchronizeBase", f => + { + f.MarkChanged(AxialType.Position, true); + f.UseHalfFloatPrecision = true; + f.SynchronizeBaseHalfFloat = true; + return f; + }); + + AddCase(cases, "HalfFloat.Teleport", f => + { + f.MarkChanged(AxialType.Position, true); + f.MarkChanged(AxialType.Scale, true); + f.UseHalfFloatPrecision = true; + f.IsTeleportingNextFrame = true; + return f; + }); + + AddCase(cases, "HalfFloat.Synchronizing", f => + { + f.MarkChanged(AxialType.Position, true); + f.UseHalfFloatPrecision = true; + f.IsTeleportingNextFrame = true; + f.IsSynchronizing = true; + return f; + }); + + AddCase(cases, "HalfFloat.ScaleOnly", f => + { + f.MarkChanged(AxialType.Scale, true); + f.UseHalfFloatPrecision = true; + return f; + }); + + AddCase(cases, "HalfFloat.EulerRotation", f => + { + f.MarkChanged(AxialType.Rotation, true); + f.UseHalfFloatPrecision = true; + return f; + }); + + // Delivery related flags (these only alter the bitset, but that is part of the wire format). + AddCase(cases, "UnreliableDeltas.FrameSync", f => + { + f.MarkChanged(AxialType.Position, true); + f.UseUnreliableDeltas = true; + f.UnreliableFrameSync = true; + return f; + }); + + // The only combination where the delivery reliability is actually derived rather than short + // circuited: unreliable deltas enabled, not teleporting, not synchronizing, no frame sync, but the + // half float base position is being synchronized. Every other case above has UseUnreliableDeltas + // off, which forces reliable delivery before any of the other conditions are consulted. + AddCase(cases, "UnreliableDeltas.SynchronizeBaseHalfFloat", f => + { + f.MarkChanged(AxialType.Position, true); + f.UseUnreliableDeltas = true; + f.UseHalfFloatPrecision = true; + f.SynchronizeBaseHalfFloat = true; + return f; + }); + + // The same shape with the base synchronization off, so the pair brackets the condition. + AddCase(cases, "UnreliableDeltas.PlainDelta", f => + { + f.MarkChanged(AxialType.Position, true); + f.UseUnreliableDeltas = true; + f.UseHalfFloatPrecision = true; + return f; + }); + + AddCase(cases, "SwitchTransformSpaceWhenParented", f => + { + f.MarkChanged(AxialType.Position, true); + f.SwitchTransformSpaceWhenParented = true; + f.UsePositionSlerp = true; + f.UseInterpolation = true; + return f; + }); + + return cases.ToArray(); + } + + private static void AddCase(System.Collections.Generic.List cases, string name, Func configure) + { + var state = CreateBaseState(); + state.FlagStates = configure(state.FlagStates); + cases.Add(new StateCase { Name = name, State = state }); + } + + /// + /// FNV-1a over the serialized payload. Small, stable, and dependency free. + /// + private static uint Fnv1a(byte[] bytes) + { + const uint offsetBasis = 2166136261; + const uint prime = 16777619; + var hash = offsetBasis; + for (int i = 0; i < bytes.Length; i++) + { + hash ^= bytes[i]; + hash *= prime; + } + return hash; + } + + private static byte[] Serialize(NetworkTransformState state) + { + // Resolving the delivery reliability used to happen inside NetworkSerialize. It now happens before + // writing, because the batched synchronization mode uses the result to pick which of its two per + // tick messages a state belongs to. Every send path calls this first, so the baseline does too; + // without it these signatures would move for a reason that has nothing to do with the wire format. + state.UpdateReliability(); + + var writer = new FastBufferWriter(1024, Allocator.Temp); + try + { + writer.WriteNetworkSerializable(state); + return writer.ToArray(); + } + finally + { + writer.Dispose(); + } + } + + /// + /// Verifies the serialized form of every configuration in the matrix still matches the recorded baseline. + /// + [Test] + public void NetworkTransformStateSerializationBaseline() + { + var cases = BuildStateMatrix(); + var actual = new string[cases.Length]; + + for (int i = 0; i < cases.Length; i++) + { + var bytes = Serialize(cases[i].State); + actual[i] = $"{cases[i].Name}|{bytes.Length}|{Fnv1a(bytes):X8}"; + } + + if (k_ExpectedSignatures.Length != cases.Length) + { + Assert.Fail($"No baseline recorded (expected {cases.Length} entries, found {k_ExpectedSignatures.Length}). " + + $"Verify this is a new or intentionally changed wire format, then paste the following into {nameof(k_ExpectedSignatures)}:\n\n{FormatLiteral(actual)}"); + } + + var mismatches = new StringBuilder(); + for (int i = 0; i < cases.Length; i++) + { + if (k_ExpectedSignatures[i] != actual[i]) + { + mismatches.AppendLine($" [{i}] expected \"{k_ExpectedSignatures[i]}\" but was \"{actual[i]}\""); + } + } + + if (mismatches.Length > 0) + { + Assert.Fail($"The serialized {nameof(NetworkTransformState)} no longer matches the recorded baseline. " + + $"If this is an intentional wire format change, update {nameof(k_ExpectedSignatures)}.\n{mismatches}\nUpdated baseline:\n\n{FormatLiteral(actual)}"); + } + } + + /// + /// Verifies every configuration in the matrix survives a write and read back. + /// + /// + /// The baseline proves the bytes did not change. This proves they still round trip, so a baseline + /// regenerated against a broken serializer is not silently accepted. + /// + [Test] + public void NetworkTransformStateSerializationRoundTrip() + { + foreach (var stateCase in BuildStateMatrix()) + { + var bytes = Serialize(stateCase.State); + NetworkTransformState deserialized; + var reader = new FastBufferReader(bytes, Allocator.Temp); + try + { + reader.ReadNetworkSerializable(out deserialized); + Assert.AreEqual(bytes.Length, reader.Position, + $"[{stateCase.Name}] Reader consumed {reader.Position} of {bytes.Length} bytes!"); + } + finally + { + reader.Dispose(); + } + + Assert.AreEqual(stateCase.State.NetworkTick, deserialized.NetworkTick, + $"[{stateCase.Name}] NetworkTick did not survive the round trip!"); + + if (stateCase.State.FlagStates.TrackByStateId) + { + Assert.AreEqual(stateCase.State.StateId, deserialized.StateId, + $"[{stateCase.Name}] StateId did not survive the round trip!"); + } + + AssertFlagsSurvived(stateCase, deserialized); + + // Re-serializing what was read back must reproduce the original payload byte for byte. + // This is the primary round trip assertion because it covers every field without the test + // needing to know which of them the serializer derives on the way out. + var reserialized = Serialize(deserialized); + Assert.AreEqual(bytes.Length, reserialized.Length, + $"[{stateCase.Name}] Re-serialized payload was {reserialized.Length} bytes but the original was {bytes.Length}!"); + for (int i = 0; i < bytes.Length; i++) + { + if (bytes[i] != reserialized[i]) + { + Assert.Fail($"[{stateCase.Name}] Re-serialized payload differs at byte {i}: expected 0x{bytes[i]:X2} but was 0x{reserialized[i]:X2}!"); + } + } + } + } + + /// + /// Compares every flag that a state update carries, with the exception of + /// . + /// + /// + /// derives ReliableSequenced, and + /// takes the state by value and calls it on that copy. The flag reaches the + /// bytes and the state read back from them, but never the source state the case holds. + /// + private static void AssertFlagsSurvived(StateCase stateCase, NetworkTransformState deserialized) + { + var expected = stateCase.State.FlagStates; + var actual = deserialized.FlagStates; + + void Check(string flag, bool expectedValue, bool actualValue) + { + Assert.AreEqual(expectedValue, actualValue, $"[{stateCase.Name}] Flag {flag} did not survive the round trip!"); + } + + Check(nameof(FlagStates.InLocalSpace), expected.InLocalSpace, actual.InLocalSpace); + Check(nameof(FlagStates.HasPositionX), expected.HasPositionX, actual.HasPositionX); + Check(nameof(FlagStates.HasPositionY), expected.HasPositionY, actual.HasPositionY); + Check(nameof(FlagStates.HasPositionZ), expected.HasPositionZ, actual.HasPositionZ); + Check(nameof(FlagStates.HasRotAngleX), expected.HasRotAngleX, actual.HasRotAngleX); + Check(nameof(FlagStates.HasRotAngleY), expected.HasRotAngleY, actual.HasRotAngleY); + Check(nameof(FlagStates.HasRotAngleZ), expected.HasRotAngleZ, actual.HasRotAngleZ); + Check(nameof(FlagStates.HasScaleX), expected.HasScaleX, actual.HasScaleX); + Check(nameof(FlagStates.HasScaleY), expected.HasScaleY, actual.HasScaleY); + Check(nameof(FlagStates.HasScaleZ), expected.HasScaleZ, actual.HasScaleZ); + Check(nameof(FlagStates.IsTeleportingNextFrame), expected.IsTeleportingNextFrame, actual.IsTeleportingNextFrame); + Check(nameof(FlagStates.UseInterpolation), expected.UseInterpolation, actual.UseInterpolation); + Check(nameof(FlagStates.QuaternionSync), expected.QuaternionSync, actual.QuaternionSync); + Check(nameof(FlagStates.QuaternionCompression), expected.QuaternionCompression, actual.QuaternionCompression); + Check(nameof(FlagStates.UseHalfFloatPrecision), expected.UseHalfFloatPrecision, actual.UseHalfFloatPrecision); + Check(nameof(FlagStates.IsSynchronizing), expected.IsSynchronizing, actual.IsSynchronizing); + Check(nameof(FlagStates.UsePositionSlerp), expected.UsePositionSlerp, actual.UsePositionSlerp); + Check(nameof(FlagStates.IsParented), expected.IsParented, actual.IsParented); + Check(nameof(FlagStates.SynchronizeBaseHalfFloat), expected.SynchronizeBaseHalfFloat, actual.SynchronizeBaseHalfFloat); + Check(nameof(FlagStates.UseUnreliableDeltas), expected.UseUnreliableDeltas, actual.UseUnreliableDeltas); + Check(nameof(FlagStates.UnreliableFrameSync), expected.UnreliableFrameSync, actual.UnreliableFrameSync); + Check(nameof(FlagStates.SwitchTransformSpaceWhenParented), expected.SwitchTransformSpaceWhenParented, actual.SwitchTransformSpaceWhenParented); + Check(nameof(FlagStates.TrackByStateId), expected.TrackByStateId, actual.TrackByStateId); + } + + private static string FormatLiteral(string[] signatures) + { + var builder = new StringBuilder(); + builder.AppendLine(" private static readonly string[] k_ExpectedSignatures ="); + builder.AppendLine(" {"); + foreach (var signature in signatures) + { + builder.AppendLine($" \"{signature}\","); + } + builder.AppendLine(" };"); + return builder.ToString(); + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformStateBaselineTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformStateBaselineTests.cs.meta new file mode 100644 index 0000000000..fd70a0eecf --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformStateBaselineTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 058fb7f29dcbf5448b15ebc82e54776b \ No newline at end of file From 962edcbb614997d5dc711745c25fad78dd7e4928 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Thu, 29 Oct 2026 11:22:26 -0500 Subject: [PATCH 2/2] chore: CHANGELOG entry for the axis re-enable teleport fix --- com.unity.netcode.gameobjects/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 79e264c937..0a3a124e10 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -24,6 +24,7 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed +- Issue where re-enabling a `NetworkTransform` position axis that drifted out of half float delta range while it was disabled did not teleport. (#4234) - Issue where a growable `FastBufferWriter` created with a size of 0 never returned from its first write or `TryBeginWrite` call that needed the buffer to grow. (#4224) - Issue where a `FastBufferReader` created with an offset and the default length of -1 read past the end of its source. The `ArraySegment` overload that takes an offset now applies it from the start of the segment. (#4222) - Issue where a client promoted to session owner could throw an exception when synchronizing a newly joining client. (#4214)