diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md
index 79d8e69c83..115b4e07af 100644
--- a/com.unity.netcode.gameobjects/CHANGELOG.md
+++ b/com.unity.netcode.gameobjects/CHANGELOG.md
@@ -14,6 +14,7 @@ Additional documentation and release notes are available at [Multiplayer Documen
### Fixed
+- Fixed issue where non-authority `NetworkTransform` instances would not allow non-synchronized axis values to be updated locally. (#3471)
- Fixed issue where invoking `NetworkObject.NetworkShow` and `NetworkObject.ChangeOwnership` consecutively within the same call stack location could result in an unnecessary change in ownership error message generated on the target client side. (#3468)
- Fixed issue where `NetworkVariable`s on a `NetworkBehaviour` could fail to synchronize changes if one has `NetworkVariableUpdateTraits` set and is dirty but is not ready to send. (#3466)
- Fixed inconsistencies in the `OnSceneEvent` callback. (#3458)
diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs
index 05eeb22cfe..c4321958d5 100644
--- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs
+++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkTransform.cs
@@ -2524,10 +2524,25 @@ protected internal void ApplyAuthoritativeState()
// 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.
var adjustedPosition = m_InternalCurrentPosition;
- var adjustedRotation = m_InternalCurrentRotation;
+ var currentPosistion = GetSpaceRelativePosition();
+ adjustedPosition.x = SyncPositionX ? m_InternalCurrentPosition.x : currentPosistion.x;
+ adjustedPosition.y = SyncPositionY ? m_InternalCurrentPosition.y : currentPosistion.y;
+ adjustedPosition.z = SyncPositionZ ? m_InternalCurrentPosition.z : currentPosistion.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;
+
+
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;
// Non-Authority Preservers the authority's transform state update modes
InLocalSpace = networkState.InLocalSpace;
@@ -2650,7 +2665,18 @@ protected internal void ApplyAuthoritativeState()
// Update our current position if it changed or we are interpolating
if (networkState.HasPositionChange || Interpolate)
{
- m_InternalCurrentPosition = adjustedPosition;
+ if (SyncPositionX && SyncPositionY && SyncPositionZ)
+ {
+ m_InternalCurrentPosition = adjustedPosition;
+ }
+ else
+ {
+ // Preserve any non-synchronized changes to the local instance's position
+ var position = InLocalSpace ? transform.localPosition : transform.position;
+ m_InternalCurrentPosition.x = SyncPositionX ? adjustedPosition.x : position.x;
+ m_InternalCurrentPosition.y = SyncPositionY ? adjustedPosition.y : position.y;
+ m_InternalCurrentPosition.z = SyncPositionZ ? adjustedPosition.z : position.z;
+ }
}
#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D
if (m_UseRigidbodyForMotion)
@@ -2696,7 +2722,21 @@ protected internal void ApplyAuthoritativeState()
// Update our current rotation if it changed or we are interpolating
if (networkState.HasRotAngleChange || Interpolate)
{
- m_InternalCurrentRotation = adjustedRotation;
+ if ((SyncRotAngleX && SyncRotAngleY && SyncRotAngleZ) || UseQuaternionSynchronization)
+ {
+ m_InternalCurrentRotation = adjustedRotation;
+ }
+ else
+ {
+ // Preserve any non-synchronized changes to the local instance's rotation
+ var rotation = InLocalSpace ? transform.localRotation.eulerAngles : transform.rotation.eulerAngles;
+ var currentEuler = m_InternalCurrentRotation.eulerAngles;
+ var updatedEuler = adjustedRotation.eulerAngles;
+ currentEuler.x = SyncRotAngleX ? updatedEuler.x : rotation.x;
+ currentEuler.y = SyncRotAngleY ? updatedEuler.y : rotation.y;
+ currentEuler.z = SyncRotAngleZ ? updatedEuler.z : rotation.z;
+ m_InternalCurrentRotation.eulerAngles = currentEuler;
+ }
}
#if COM_UNITY_MODULES_PHYSICS || COM_UNITY_MODULES_PHYSICS2D
@@ -2737,7 +2777,18 @@ protected internal void ApplyAuthoritativeState()
// Update our current scale if it changed or we are interpolating
if (networkState.HasScaleChange || Interpolate)
{
- m_InternalCurrentScale = adjustedScale;
+ if (SyncScaleX && SyncScaleY && SyncScaleZ)
+ {
+ m_InternalCurrentScale = adjustedScale;
+ }
+ else
+ {
+ // Preserve any non-synchronized changes to the local instance's scale
+ var scale = transform.localScale;
+ m_InternalCurrentScale.x = SyncScaleX ? adjustedScale.x : scale.x;
+ m_InternalCurrentScale.y = SyncScaleY ? adjustedScale.y : scale.y;
+ m_InternalCurrentScale.z = SyncScaleZ ? adjustedScale.z : scale.z;
+ }
}
transform.localScale = m_InternalCurrentScale;
}
diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformNonAuthorityTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformNonAuthorityTests.cs
new file mode 100644
index 0000000000..e5fb5a4ba9
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformNonAuthorityTests.cs
@@ -0,0 +1,590 @@
+using System.Collections;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Text;
+using NUnit.Framework;
+using Unity.Netcode.Components;
+using Unity.Netcode.TestHelpers.Runtime;
+using UnityEngine;
+using UnityEngine.TestTools;
+
+namespace Unity.Netcode.RuntimeTests
+{
+ [TestFixture(HostOrServer.Server)]
+ [TestFixture(HostOrServer.Host)]
+ [TestFixture(HostOrServer.DAHost)]
+ internal class NetworkTransformNonAuthorityTests : IntegrationTestWithApproximation
+ {
+ private const int k_NumberOfPasses = 3;
+ private const float k_LerpTime = 0.1f;
+ protected override int NumberOfClients => 2;
+
+ private StringBuilder m_ErrorMsg = new StringBuilder();
+
+ private GameObject m_PrefabToSpawn;
+
+ private NetworkObject m_AuthorityInstance;
+
+ public NetworkTransformNonAuthorityTests(HostOrServer hostOrServer) : base(hostOrServer) { }
+
+ ///
+ /// The NetworkTransform testing component used for this test
+ ///
+ public class NetworkTransformTestComponent : NetworkTransform, INetworkUpdateSystem
+ {
+ public static NetworkTransformTestComponent AuthorityInstance;
+ public static readonly List AllInstances = new List();
+
+ public static bool VerboseDebug;
+
+ public static void Reset()
+ {
+ AllInstances.Clear();
+ }
+
+ ///
+ /// All of the below bools are set when the non-synchronized axis
+ /// have reached their target values.
+ ///
+ public bool NonSynchronizedPositionReached { get; private set; }
+ public bool NonSynchronizedRotationReached { get; private set; }
+ public bool NonSynchronizedScaleReached { get; private set; }
+
+ private bool m_UpdateNonSynchronizedAxis;
+ private float m_StartMotionTime;
+ private float m_Lerp;
+
+ ///
+ /// The below properties are used to
+ /// lerp from the current non-synchronized axis values to
+ /// the target non-synchronized axis values.
+ ///
+ private Vector3 m_OriginalPosition;
+ private Vector3 m_OriginalRotation;
+ private Vector3 m_OriginalScale;
+
+ ///
+ /// The below properties are the
+ /// target non-synchronized axis values.
+ ///
+ private Vector3 m_TargetPosition;
+ private Vector3 m_TargetRotation;
+ private Vector3 m_TargetScale;
+
+ private void Log(string msg)
+ {
+ if (!VerboseDebug)
+ {
+ return;
+ }
+ Debug.Log(msg);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private bool Approximately(Vector3 a, Vector3 b, float deltaVariance = 0.0001f)
+ {
+ return System.Math.Round(Mathf.Abs(a.x - b.x), 4) <= deltaVariance &&
+ System.Math.Round(Mathf.Abs(a.y - b.y), 4) <= deltaVariance &&
+ System.Math.Round(Mathf.Abs(a.z - b.z), 4) <= deltaVariance;
+ }
+
+ ///
+ /// For debugging
+ ///
+ public void GetUnSynchronizedTargetInfo(StringBuilder builder)
+ {
+ if (!NonSynchronizedPositionReached)
+ {
+ builder.Append($"[Position] Current: {GetNonSynchronizedPosition(transform.position)} | Target: {m_TargetPosition}");
+ }
+ if (!NonSynchronizedRotationReached)
+ {
+ builder.Append($"[Rotation] Current: {GetNonSynchronizedRotation(transform.rotation.eulerAngles)} | Target: {m_TargetRotation}");
+ }
+ if (!NonSynchronizedScaleReached)
+ {
+ builder.Append($"[Scale] Current: {GetNonSynchronizedScale(transform.localScale)} | Target: {m_TargetScale}");
+ }
+
+ builder.Append("\n");
+ }
+
+ public void ShouldMove(bool shouldMove = false)
+ {
+ m_UpdateNonSynchronizedAxis = shouldMove;
+ if (m_UpdateNonSynchronizedAxis)
+ {
+ m_StartMotionTime = Time.realtimeSinceStartup;
+ m_Lerp = 0.0f;
+ }
+ }
+
+ public bool HasCompletedMotion()
+ {
+ return NonSynchronizedPositionReached && NonSynchronizedRotationReached && NonSynchronizedScaleReached;
+ }
+
+ #region Generate Random Non-Synchronized Axis Values
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private float GenerateRandom(float range)
+ {
+ var random = Random.Range(-range, range);
+ var negMult = random < 0 ? -1 : 1;
+ random = Mathf.Clamp(Mathf.Abs(random), range * 0.10f, range) * negMult;
+ return random;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public Vector3 SetRandomNonSynchPosition(float range)
+ {
+ SetNonSynchPositionTarget(GetNonSynchronizedPosition(Vector3.one) * GenerateRandom(range));
+ return m_TargetPosition;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public void SetNonSynchPositionTarget(Vector3 target)
+ {
+ m_OriginalPosition = GetNonSynchronizedPosition(transform.position);
+ m_TargetPosition = GetNonSynchronizedPosition(target);
+ NonSynchronizedPositionReached = false;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public Vector3 SetRandomNonSynchRotation(float range)
+ {
+ m_OriginalRotation = GetNonSynchronizedRotation(transform.rotation.eulerAngles);
+ SetNonSynchRotationTarget(GetNonSynchronizedRotation(Vector3.one) * GenerateRandom(range));
+ return m_TargetRotation;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public void SetNonSynchRotationTarget(Vector3 target)
+ {
+ m_TargetRotation = GetNonSynchronizedRotation(target);
+ NonSynchronizedRotationReached = false;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public Vector3 SetRandomNonSynchScale(float range)
+ {
+ SetNonSynchScaleTarget(GetNonSynchronizedScale(Vector3.one) * GenerateRandom(range));
+ return m_TargetScale;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public void SetNonSynchScaleTarget(Vector3 target)
+ {
+ m_OriginalScale = GetNonSynchronizedScale(transform.localScale);
+ m_TargetScale = target;
+ NonSynchronizedScaleReached = false;
+ }
+ #endregion
+
+ #region Update Synchronized Axis Values
+ public Vector3 MovePosition(Vector3 position)
+ {
+ if (!CanCommitToTransform)
+ {
+ return Vector3.zero;
+ }
+
+ transform.position += GetSynchronizedPosition(position);
+ return transform.position;
+ }
+
+ public Vector3 MoveRotation(Vector3 eulerAngles)
+ {
+ if (!CanCommitToTransform)
+ {
+ return Vector3.zero;
+ }
+ var rotation = transform.rotation;
+ rotation.eulerAngles += GetSynchronizedRotation(eulerAngles);
+ transform.rotation = rotation;
+ return rotation.eulerAngles;
+ }
+
+ public Vector3 MoveScale(Vector3 scale)
+ {
+ if (!CanCommitToTransform)
+ {
+ return Vector3.zero;
+ }
+
+ transform.localScale += GetSynchronizedScale(scale);
+ return transform.localScale;
+ }
+ #endregion
+
+ #region Methods to Get Synchronized and Non-Synchronized Values
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private Vector3 GetSynchronizedPosition(Vector3 position)
+ {
+ position.x *= SyncPositionX ? 1 : 0;
+ position.y *= SyncPositionY ? 1 : 0;
+ position.z *= SyncPositionZ ? 1 : 0;
+ return position;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private Vector3 GetNonSynchronizedPosition(Vector3 position)
+ {
+ position.x *= !SyncPositionX ? 1 : 0;
+ position.y *= !SyncPositionY ? 1 : 0;
+ position.z *= !SyncPositionZ ? 1 : 0;
+ return position;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private Vector3 GetSynchronizedRotation(Vector3 rotation)
+ {
+ rotation.x *= SyncRotAngleX ? 1 : 0;
+ rotation.y *= SyncRotAngleY ? 1 : 0;
+ rotation.z *= SyncRotAngleZ ? 1 : 0;
+ return rotation;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private Vector3 GetNonSynchronizedRotation(Vector3 rotation)
+ {
+
+ rotation.x *= !SyncRotAngleX ? 1 : 0;
+ rotation.y *= !SyncRotAngleY ? 1 : 0;
+ rotation.z *= !SyncRotAngleZ ? 1 : 0;
+
+ return rotation;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private Vector3 GetSynchronizedScale(Vector3 scale)
+ {
+ scale.x *= SyncScaleX ? 1 : 0;
+ scale.y *= SyncScaleY ? 1 : 0;
+ scale.z *= SyncScaleZ ? 1 : 0;
+ return scale;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private Vector3 GetNonSynchronizedScale(Vector3 scale)
+ {
+ scale.x *= !SyncScaleX ? 1 : 0;
+ scale.y *= !SyncScaleY ? 1 : 0;
+ scale.z *= !SyncScaleZ ? 1 : 0;
+ return scale;
+ }
+ #endregion
+
+ #region Spawn, Despawn, and Update Methods
+ public override void OnNetworkSpawn()
+ {
+ base.OnNetworkSpawn();
+
+ if (CanCommitToTransform)
+ {
+ NetworkUpdateLoop.RegisterNetworkUpdate(this, NetworkUpdateStage.PreUpdate);
+ }
+ AllInstances.Add(this);
+ }
+
+ public override void OnNetworkDespawn()
+ {
+ NetworkUpdateLoop.UnregisterNetworkUpdate(this, NetworkUpdateStage.PreUpdate);
+ base.OnNetworkDespawn();
+ }
+
+ public void NetworkUpdate(NetworkUpdateStage updateStage)
+ {
+ if (updateStage == NetworkUpdateStage.PreUpdate)
+ {
+ UpdateNonSynchronizedAxis();
+ }
+ }
+
+ public override void OnUpdate()
+ {
+ UpdateNonSynchronizedAxis();
+ base.OnUpdate();
+ }
+
+ ///
+ /// Updates the non-synchronized axis values
+ ///
+ private void UpdateNonSynchronizedAxis()
+ {
+ if (!m_UpdateNonSynchronizedAxis || HasCompletedMotion())
+ {
+ return;
+ }
+
+ // Calculate the lerp factor based on when we started motion vs the time to
+ // finish lerping.
+ var lerpComplete = m_Lerp >= 1.0f;
+ if (!lerpComplete)
+ {
+ var deltaTime = Time.realtimeSinceStartup - m_StartMotionTime;
+ if (deltaTime > 0.0f)
+ {
+ m_Lerp = Mathf.Clamp(deltaTime / k_LerpTime, 0.001f, 1.0f);
+ }
+ }
+
+ // Handle non-synchronized position axis updates
+ if (!NonSynchronizedPositionReached)
+ {
+ m_OriginalPosition = Vector3.Lerp(m_OriginalPosition, m_TargetPosition, m_Lerp);
+ // Get the sum of the synchronized value with the lerped un-synchronized value and apply the new position.
+ transform.position = GetSynchronizedPosition(transform.position) + m_OriginalPosition;
+ NonSynchronizedPositionReached = Approximately(m_OriginalPosition, m_TargetPosition);
+ if (NonSynchronizedPositionReached || lerpComplete)
+ {
+ Log($"[{name}][Position] Current: {transform.position} | Current-NonSync: {GetNonSynchronizedPosition(transform.position)} | Original: {m_OriginalPosition} Target: {m_TargetPosition}");
+ }
+ }
+
+ // Handle non-synchronized rotation axis updates
+ if (!NonSynchronizedRotationReached)
+ {
+ var rotation = transform.rotation;
+ m_OriginalRotation = Vector3.Lerp(m_OriginalRotation, m_TargetRotation, m_Lerp);
+ rotation.eulerAngles = GetSynchronizedRotation(rotation.eulerAngles) + m_OriginalRotation;
+ transform.rotation = rotation;
+ NonSynchronizedRotationReached = Approximately(m_OriginalRotation, m_TargetRotation);
+ if (NonSynchronizedRotationReached || lerpComplete)
+ {
+ Log($"[{name}][Rotation] Current: {transform.rotation.eulerAngles} | Current-NonSync: {GetNonSynchronizedRotation(transform.rotation.eulerAngles)} | Target: {m_TargetRotation}");
+ }
+ }
+
+ // Handle non-synchronized scale axis updates
+ if (!NonSynchronizedScaleReached)
+ {
+ m_OriginalScale = Vector3.Lerp(m_OriginalScale, m_TargetScale, m_Lerp);
+ transform.localScale = GetSynchronizedScale(transform.localScale) + m_OriginalScale;
+ NonSynchronizedScaleReached = Approximately(m_OriginalScale, m_TargetScale);
+ if (NonSynchronizedScaleReached || lerpComplete)
+ {
+ Log($"[{name}][Scale] Current: {transform.localScale} | Current-NonSync: {GetNonSynchronizedScale(transform.localScale)} | Target: {m_TargetScale}");
+ }
+ }
+ }
+ #endregion
+ }
+
+ protected override IEnumerator OnSetup()
+ {
+ NetworkTransformTestComponent.Reset();
+ return base.OnSetup();
+ }
+
+ ///
+ /// All of the below versions of
+ /// assure that at least 1 axis is disabled and/or 1 axis is enabled
+ ///
+ ///
+ private bool ShouldSyncAxis()
+ {
+ return ShouldSyncAxis(true, true, false);
+ }
+
+ private bool ShouldSyncAxis(bool first)
+ {
+ return ShouldSyncAxis(first, true, false);
+ }
+
+ private bool ShouldSyncAxis(bool first, bool second, bool lastValue)
+ {
+ // Increase chances to not synchronize based on previous values
+ var start = 0;
+ if (first)
+ {
+ start += 20;
+ }
+ if (second)
+ {
+ start += 30;
+ }
+
+ // If we are on the last axis value, then
+ // we want to check for the previous two
+ // being both enabled or disabled in order
+ // to assure there is at least one axis that
+ // is enabled and at least one axis that is
+ // disabled.
+ if (lastValue)
+ {
+ if (first && second)
+ {
+ // If the previous two are enabled, then
+ // make the last one disabled.
+ return false;
+ }
+ else
+ if (!first && !second)
+ {
+ // If both are disabled, then make the
+ // last one enabled.
+ return true;
+ }
+ }
+ return Random.Range(start, 100) >= 50 ? false : true;
+ }
+
+ protected override void OnServerAndClientsCreated()
+ {
+ m_PrefabToSpawn = CreateNetworkObjectPrefab("TestObject");
+ var networkTransform = m_PrefabToSpawn.AddComponent();
+
+ // Randomly select one or more axis to disable
+ networkTransform.SyncPositionX = ShouldSyncAxis();
+ networkTransform.SyncPositionY = ShouldSyncAxis(networkTransform.SyncPositionX);
+ networkTransform.SyncPositionZ = ShouldSyncAxis(networkTransform.SyncPositionX, networkTransform.SyncPositionY, true);
+ networkTransform.SyncRotAngleX = ShouldSyncAxis();
+ networkTransform.SyncRotAngleY = ShouldSyncAxis(networkTransform.SyncRotAngleX);
+ networkTransform.SyncRotAngleZ = ShouldSyncAxis(networkTransform.SyncRotAngleX, networkTransform.SyncRotAngleY, true);
+ networkTransform.SyncScaleX = ShouldSyncAxis();
+ networkTransform.SyncScaleY = ShouldSyncAxis(networkTransform.SyncScaleX);
+ networkTransform.SyncScaleZ = ShouldSyncAxis(networkTransform.SyncScaleX, networkTransform.SyncScaleY, true);
+ base.OnServerAndClientsCreated();
+ }
+
+ ///
+ /// Conditional to verify that all spawned instances' transform values match
+ ///
+ private bool AllTransformsAreApproximatelyTheSame()
+ {
+ m_ErrorMsg.Clear();
+ var authorityInstance = m_AuthorityInstance.GetComponent();
+
+ foreach (var instance in NetworkTransformTestComponent.AllInstances)
+ {
+ if (instance == authorityInstance)
+ {
+ continue;
+ }
+ if (!Approximately(instance.transform.position, authorityInstance.transform.position))
+ {
+ m_ErrorMsg.AppendLine($"[{instance.name}] Position ({instance.transform.position}) is not " +
+ $"equal to authority's ({authorityInstance.transform.position})! ");
+ }
+ if (!ApproximatelyEuler(instance.transform.rotation.eulerAngles, authorityInstance.transform.rotation.eulerAngles))
+ {
+ m_ErrorMsg.AppendLine($"[{instance.name}] Rotation ({instance.transform.rotation.eulerAngles}) is not " +
+ $"equal to authority's ({authorityInstance.transform.rotation.eulerAngles})! ");
+ }
+ if (!Approximately(instance.transform.localScale, authorityInstance.transform.localScale))
+ {
+ m_ErrorMsg.AppendLine($"[{instance.name}] Scale ({instance.transform.localScale}) is not " +
+ $"equal to authority's ({authorityInstance.transform.localScale})! ");
+ }
+ }
+ return m_ErrorMsg.Length == 0;
+ }
+
+ ///
+ /// Conditional to verify that all spawned instances' finished their local
+ /// non-synchronized axis motion.
+ ///
+ private bool AllNonSynchronizedMotionCompleted()
+ {
+ m_ErrorMsg.Clear();
+ foreach (var instance in NetworkTransformTestComponent.AllInstances)
+ {
+ if (!instance.HasCompletedMotion())
+ {
+ m_ErrorMsg.Append($"[{instance.name}] Has not completed local motion!\n");
+ instance.GetUnSynchronizedTargetInfo(m_ErrorMsg);
+ }
+ }
+ return m_ErrorMsg.Length == 0;
+ }
+
+ ///
+ /// Conditional to verify that all clients have spawned an instance of the test object.
+ ///
+ private bool AllClientsSpawnedObject()
+ {
+ foreach (var networkManager in m_NetworkManagers)
+ {
+ if (!networkManager.SpawnManager.SpawnedObjects.ContainsKey(m_AuthorityInstance.NetworkObjectId))
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ ///
+ /// Validates that a non-authority instances can apply changes to any non-synchronized
+ /// axis value when using a NetworkTransform.
+ ///
+ [UnityTest]
+ public IEnumerator NonAuthorityUpdateNonSynchronizedAxis()
+ {
+ var authority = GetNonAuthorityNetworkManager();
+ m_AuthorityInstance = SpawnObject(m_PrefabToSpawn, authority).GetComponent();
+ NetworkTransformTestComponent.AuthorityInstance = m_AuthorityInstance.GetComponent();
+ yield return WaitForConditionOrTimeOut(AllClientsSpawnedObject);
+ AssertOnTimeout($"All clients did not spawn {m_AuthorityInstance.name}!");
+
+ var authorityComponent = m_AuthorityInstance.GetComponent();
+ for (int i = 0; i < k_NumberOfPasses; i++)
+ {
+ // Start moving the authority on the axis being synchronized
+ var movePosition = NetworkTransformTestComponent.AuthorityInstance.MovePosition(GetRandomVector3(-4, 4));
+ var moveRotation = NetworkTransformTestComponent.AuthorityInstance.MoveRotation(GetRandomVector3(-20, 20));
+ var moveScale = NetworkTransformTestComponent.AuthorityInstance.MoveScale(GetRandomVector3(-2, 2));
+
+ // Set the non-synchronized axis delta on the authority and preserve each axis delta
+ // to be applied to all other non-authority instances.
+ var positionDelta = authorityComponent.SetRandomNonSynchPosition(4);
+ var rotationDelta = authorityComponent.SetRandomNonSynchRotation(20);
+ var scaleDelta = authorityComponent.SetRandomNonSynchScale(2);
+
+ var builder = new StringBuilder();
+ builder.AppendLine($"[Iteration-{i}]Final Expected Position: {movePosition + positionDelta} | Non-Synch: {positionDelta}");
+ VerboseDebug(builder.ToString());
+ foreach (var testTransform in NetworkTransformTestComponent.AllInstances)
+ {
+ // We only need to start the authority instance moving
+ // for the non-synchronized axis
+ if (testTransform == authorityComponent)
+ {
+ testTransform.ShouldMove(true);
+ continue;
+ }
+ // Apply the non-synchronized axis deltas to each cloned instance
+ // and start the local motion.
+ testTransform.SetNonSynchPositionTarget(positionDelta);
+ testTransform.SetNonSynchRotationTarget(rotationDelta);
+ testTransform.SetNonSynchScaleTarget(scaleDelta);
+ testTransform.ShouldMove(true);
+ }
+
+ // Wait for all instances to finish their local, non-synchronized, axis changes
+ yield return WaitForConditionOrTimeOut(AllNonSynchronizedMotionCompleted);
+ AssertOnTimeout($"[Iteration: {i}] Not all instances completed local motion! {m_ErrorMsg}");
+
+ // Verify that upon completing motion, all instances' transforms match
+ yield return WaitForConditionOrTimeOut(AllTransformsAreApproximatelyTheSame);
+
+ // For debugging purposes
+ if (s_GlobalTimeoutHelper.HasTimedOut())
+ {
+ builder.Clear();
+ builder.AppendLine($"Final Expected Position: {movePosition + positionDelta}");
+ builder.AppendLine($"Final Expected Rotation: {moveRotation + rotationDelta}");
+ builder.AppendLine($"Final Expected Scale: {moveScale + scaleDelta}");
+ foreach (var testTransform in NetworkTransformTestComponent.AllInstances)
+ {
+ builder.AppendLine($"[Client-{testTransform.NetworkManager.LocalClientId}] " +
+ $"Position: {testTransform.transform.position}" +
+ $"Rotation: {testTransform.transform.rotation.eulerAngles}" +
+ $"Scale: {testTransform.transform.localScale}");
+ }
+ Debug.Log(builder.ToString());
+ }
+ AssertOnTimeout($"[Iteration: {i}] Not all instances' transforms match! {m_ErrorMsg}");
+ }
+ }
+ }
+}
diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformNonAuthorityTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformNonAuthorityTests.cs.meta
new file mode 100644
index 0000000000..8c780a2920
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/NetworkTransformNonAuthorityTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b917fea2cf5d9214ebc068f2f12a1e4f
\ No newline at end of file