diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md
index 2b5a367421..5fa9d87daf 100644
--- a/com.unity.netcode.gameobjects/CHANGELOG.md
+++ b/com.unity.netcode.gameobjects/CHANGELOG.md
@@ -22,7 +22,9 @@ Additional documentation and release notes are available at [Multiplayer Documen
### Fixed
-- Fixed an issue where mixed authority nested `NetworkTransform` instances could stop child/nested instances from updating because an authoritative `NetworkTransform` (root or otherwise) would remove the `NetworkObject` from the update group, preventing non-authoritative instances from updating their state on the authority side. (#4169)
+- Issue where moving a `NetworkObject` into another scene made the clients that did not observe it log "Trying to synchronize NetworkObjectId but it was not spawned". The scene migration is now only sent to the clients that observe the `NetworkObject`. (#4185)
+- Issue where a `NetworkObject` that was moved into another scene while hidden from a client spawned in that client's active scene when it was shown with `NetworkShow`, instead of the scene it is in on the server. (#4185)
+- Issue where mixed authority nested `NetworkTransform` instances could stop child/nested instances from updating because an authoritative `NetworkTransform` (root or otherwise) would remove the `NetworkObject` from the update group, preventing non-authoritative instances from updating their state on the authority side. (#4169)
### Security
diff --git a/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/CreateObjectMessage.cs b/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/CreateObjectMessage.cs
index a5f3039251..02961498ac 100644
--- a/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/CreateObjectMessage.cs
+++ b/com.unity.netcode.gameobjects/Runtime/Messaging/Messages/CreateObjectMessage.cs
@@ -178,6 +178,13 @@ internal static void CreateObject(ref NetworkManager networkManager, ulong sende
networkManager.Log.ErrorServer(new Context(LogLevel.Developer, $"Failed to deserialize {nameof(NetworkObject)}.").AddInfo(nameof(NetworkObject.GlobalObjectIdHash), serializedObject.Hash).AddInfo(nameof(NetworkObject.NetworkObjectId), serializedObject.NetworkObjectId));
return;
}
+
+ // A NetworkObject shown to this client after it migrated into another scene was instantiated in the
+ // active scene, and the migration was only sent to the clients observing it at the time.
+ if (networkManager.NetworkConfig.EnableSceneManagement)
+ {
+ networkManager.SceneManager.SynchronizeNetworkObjectScene(networkObject);
+ }
}
else
{
@@ -206,6 +213,12 @@ internal static void CreateObject(ref NetworkManager networkManager, ulong sende
networkManager.Log.ErrorServer(new Context(LogLevel.Developer, $"Failed to deserialize {nameof(NetworkObject)}.").AddInfo(nameof(NetworkObject.GlobalObjectIdHash), serializedObject.Hash).AddInfo(nameof(NetworkObject.NetworkObjectId), serializedObject.NetworkObjectId));
return;
}
+
+ // Same as client-server: the migration was only sent to the clients observing it at the time
+ if (networkManager.NetworkConfig.EnableSceneManagement)
+ {
+ networkManager.SceneManager.SynchronizeNetworkObjectScene(networkObject);
+ }
}
// DA - NGO CMB SERVICE NOTES:
diff --git a/com.unity.netcode.gameobjects/Runtime/SceneManagement/NetworkSceneManager.cs b/com.unity.netcode.gameobjects/Runtime/SceneManagement/NetworkSceneManager.cs
index c7ae07164f..74d8e4b8f0 100644
--- a/com.unity.netcode.gameobjects/Runtime/SceneManagement/NetworkSceneManager.cs
+++ b/com.unity.netcode.gameobjects/Runtime/SceneManagement/NetworkSceneManager.cs
@@ -2253,27 +2253,39 @@ private void SynchronizeNetworkObjectScene()
{
foreach (var networkObject in NetworkManager.SpawnManager.SpawnedObjectsList)
{
- // This is only done for dynamically spawned NetworkObjects
- // Theoretically, a server could have NetworkObjects in a server-side only scene, if the client doesn't have that scene loaded
- // then skip it (it will reside in the currently active scene in this scenario on the client-side)
- if (!networkObject.InScenePlaced && ServerSceneHandleToClientSceneHandle.ContainsKey(networkObject.NetworkSceneHandle))
- {
- networkObject.SceneOriginHandle = ServerSceneHandleToClientSceneHandle[networkObject.NetworkSceneHandle];
+ SynchronizeNetworkObjectScene(networkObject);
+ }
+ }
- // If the NetworkObject does not have a parent and is not in the same scene as it is on the server side, then find the right scene
- // and move it to that scene.
- if (networkObject.gameObject.scene.handle != networkObject.SceneOriginHandle && networkObject.transform.parent == null)
+ ///
+ /// Migrates a single client-side dynamically spawned NetworkObject into the scene it is in on the server-side.
+ ///
+ ///
+ /// Also used for hybrid prefab instances that are part of the initial synchronization but only spawn once
+ /// their ghost arrives, which can be after the synchronization has completed.
+ ///
+ internal void SynchronizeNetworkObjectScene(NetworkObject networkObject)
+ {
+ // This is only done for dynamically spawned NetworkObjects
+ // Theoretically, a server could have NetworkObjects in a server-side only scene, if the client doesn't have that scene loaded
+ // then skip it (it will reside in the currently active scene in this scenario on the client-side)
+ if (!networkObject.InScenePlaced && ServerSceneHandleToClientSceneHandle.ContainsKey(networkObject.NetworkSceneHandle))
+ {
+ networkObject.SceneOriginHandle = ServerSceneHandleToClientSceneHandle[networkObject.NetworkSceneHandle];
+
+ // If the NetworkObject does not have a parent and is not in the same scene as it is on the server side, then find the right scene
+ // and move it to that scene.
+ if (networkObject.gameObject.scene.handle != networkObject.SceneOriginHandle && networkObject.transform.parent == null)
+ {
+ if (ScenesLoaded.ContainsKey(networkObject.SceneOriginHandle))
{
- if (ScenesLoaded.ContainsKey(networkObject.SceneOriginHandle))
- {
- var scene = ScenesLoaded[networkObject.SceneOriginHandle];
- SceneManager.MoveGameObjectToScene(networkObject.gameObject, scene);
- }
- else if (NetworkManager.LogLevel <= LogLevel.Normal)
- {
- NetworkLog.LogWarningServer($"[Client-{NetworkManager.LocalClientId}][{networkObject.gameObject.name}] Server - " +
- $"client scene mismatch detected! Client-side has no scene loaded with handle ({networkObject.SceneOriginHandle})!");
- }
+ var scene = ScenesLoaded[networkObject.SceneOriginHandle];
+ SceneManager.MoveGameObjectToScene(networkObject.gameObject, scene);
+ }
+ else if (NetworkManager.LogLevel <= LogLevel.Normal)
+ {
+ NetworkLog.LogWarningServer($"[Client-{NetworkManager.LocalClientId}][{networkObject.gameObject.name}] Server - " +
+ $"client scene mismatch detected! Client-side has no scene loaded with handle ({networkObject.SceneOriginHandle})!");
}
}
}
@@ -2992,6 +3004,26 @@ internal void MigrateNetworkObjectsIntoScenes()
private List m_ScenesToRemoveFromObjectMigration = new();
+ private List m_SceneMigrationTargetIds = new();
+
+ private bool ObservesMigratedNetworkObject(ulong clientId)
+ {
+ foreach (var sceneEntry in ObjectsMigratedIntoNewScene)
+ {
+ if (!sceneEntry.Value.TryGetValue(NetworkManager.LocalClientId, out var migratedObjects))
+ {
+ continue;
+ }
+ foreach (var networkObject in migratedObjects)
+ {
+ if (networkObject.Observers.Contains(clientId))
+ {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
///
/// Should be invoked during PostLateUpdate just prior to the NetworkMessageManager processes its outbound message queue.
@@ -3056,13 +3088,30 @@ internal void CheckForAndSendNetworkObjectSceneChanged()
return;
}
+ // Only send to the clients that observe at least one of the migrated NetworkObjects
+ m_SceneMigrationTargetIds.Clear();
+ foreach (var clientId in NetworkManager.ConnectedClientsIds)
+ {
+ if (clientId != NetworkManager.LocalClientId && ObservesMigratedNetworkObject(clientId))
+ {
+ m_SceneMigrationTargetIds.Add(clientId);
+ }
+ }
+
+ // The session owner still sends the CMB service its copy when no other client observes the migrated objects
+ if (m_SceneMigrationTargetIds.Count == 0 && !NetworkManager.CMBServiceConnection)
+ {
+ ObjectsMigratedIntoNewScene.Clear();
+ return;
+ }
+
// Some NetworkObjects still exist, send the message
var sceneEvent = BeginSceneEvent();
sceneEvent.SceneEventType = SceneEventType.ObjectSceneChanged;
// SendSceneEventData can throw an exception. We need to wrap this and recover from the exception gracefully.
try
{
- SendSceneEventData(sceneEvent.SceneEventId, NetworkManager.ConnectedClientsIds.Where(c => c != NetworkManager.LocalClientId).ToArray());
+ SendSceneEventData(sceneEvent.SceneEventId, m_SceneMigrationTargetIds.ToArray());
}
catch (Exception ex)
{
diff --git a/com.unity.netcode.gameobjects/Runtime/SceneManagement/SceneEventData.cs b/com.unity.netcode.gameobjects/Runtime/SceneManagement/SceneEventData.cs
index 6195fa2335..78b0789522 100644
--- a/com.unity.netcode.gameobjects/Runtime/SceneManagement/SceneEventData.cs
+++ b/com.unity.netcode.gameobjects/Runtime/SceneManagement/SceneEventData.cs
@@ -1201,6 +1201,18 @@ private void ReadSceneEventProgressDone(FastBufferReader reader)
internal bool IsForwarding;
private ulong m_OwnerId;
+ ///
+ /// Whether a migrated is sent to the target client: only when the target observes it.
+ ///
+ ///
+ /// The CMB service keeps the session state, so its own copy includes every migrated NetworkObject.
+ /// A forwarded migration was already filtered by the owner that sent it.
+ ///
+ private bool IsMigrationSentToTarget(NetworkObject networkObject)
+ {
+ return IsForwarding || (m_NetworkManager.CMBServiceConnection && TargetClientId == NetworkManager.ServerClientId) || networkObject.Observers.Contains(TargetClientId);
+ }
+
private void SerializeObjectsMovedIntoNewScene(FastBufferWriter writer)
{
var sceneManager = m_NetworkManager.SceneManager;
@@ -1224,17 +1236,35 @@ private void SerializeObjectsMovedIntoNewScene(FastBufferWriter writer)
{
// Since these are separated by scene then owner, there could be scenes that have
// no changes.
- if (!sceneHandleObjects.Value.ContainsKey(networkManagerClientId))
+ if (!sceneHandleObjects.Value.TryGetValue(networkManagerClientId, out var migratedObjects))
+ {
+ continue;
+ }
+
+ // A client is only told about the objects it observes, since it has not spawned the others.
+ var objectCount = 0;
+ foreach (var networkObject in migratedObjects)
+ {
+ if (IsMigrationSentToTarget(networkObject))
+ {
+ objectCount++;
+ }
+ }
+ if (objectCount == 0)
{
continue;
}
+
// Write the scene handle
writer.WriteValueSafe(sceneHandleObjects.Key);
// Write the number of NetworkObjectIds to expect
- writer.WriteValueSafe(sceneHandleObjects.Value[networkManagerClientId].Count);
- foreach (var networkObject in sceneHandleObjects.Value[networkManagerClientId])
+ writer.WriteValueSafe(objectCount);
+ foreach (var networkObject in migratedObjects)
{
- writer.WriteValueSafe(networkObject.NetworkObjectId);
+ if (IsMigrationSentToTarget(networkObject))
+ {
+ writer.WriteValueSafe(networkObject.NetworkObjectId);
+ }
}
entriesWritten++;
}
diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs
new file mode 100644
index 0000000000..c44b5e234c
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs
@@ -0,0 +1,149 @@
+using System.Collections;
+using NUnit.Framework;
+using Unity.Netcode.TestHelpers.Runtime;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+using UnityEngine.TestTools;
+
+namespace Unity.Netcode.RuntimeTests
+{
+ ///
+ /// Validates that a client is only told about the scene migrations of the s it observes,
+ /// and that a shown after it migrated spawns in the authority's scene.
+ ///
+ [TestFixture(HostOrServer.Host)]
+ [TestFixture(HostOrServer.DAHost)]
+ [TestFixture(HostOrServer.Server)]
+ internal class NetworkObjectSceneMigrationObserverTests : NetcodeIntegrationTest
+ {
+ protected override int NumberOfClients => 2;
+
+ private GameObject m_PrefabNoObserversSpawn;
+ private GameObject m_PrefabWithObservers;
+
+ public NetworkObjectSceneMigrationObserverTests(HostOrServer hostOrServer) : base(hostOrServer) { }
+
+ protected override void OnServerAndClientsCreated()
+ {
+ m_PrefabNoObserversSpawn = CreateNetworkObjectPrefab("NoObserversObject");
+ m_PrefabNoObserversSpawn.GetComponent().SpawnWithObservers = false;
+ m_PrefabWithObservers = CreateNetworkObjectPrefab("WithObserversObject");
+ base.OnServerAndClientsCreated();
+ }
+
+ ///
+ /// Rust server needs an update
+ ///
+ protected override bool UseCMBService()
+ {
+ return false;
+ }
+
+ private NetworkObject SpawnAndShow(NetworkManager authority, NetworkManager observer)
+ {
+ var networkObject = SpawnObject(m_PrefabNoObserversSpawn, authority).GetComponent();
+ networkObject.NetworkShow(observer.LocalClientId);
+ return networkObject;
+ }
+
+ private IEnumerator WaitForSpawnedOn(NetworkManager networkManager, NetworkObject networkObject)
+ {
+ yield return WaitForConditionOrTimeOut(() => networkManager.SpawnManager.SpawnedObjects.ContainsKey(networkObject.NetworkObjectId));
+ AssertOnTimeout($"[Client-{networkManager.LocalClientId}] Failed to spawn {networkObject.name} when it was shown!");
+ }
+
+ ///
+ /// Two objects each shown to a different client migrate into different scenes in the same frame. Each client
+ /// must only receive the migration of the object it observes.
+ ///
+ [UnityTest]
+ public IEnumerator SceneMigrationIsOnlySentToObservers()
+ {
+ var authority = GetAuthorityNetworkManager();
+ var firstClient = GetNonAuthorityNetworkManager(0);
+ var secondClient = GetNonAuthorityNetworkManager(1);
+ var activeScene = SceneManager.GetActiveScene();
+
+ var firstObject = SpawnAndShow(authority, firstClient);
+ var secondObject = SpawnAndShow(authority, secondClient);
+ // Start the first object in the DontDestroyOnLoad scene so the two objects migrate into different scenes
+ Object.DontDestroyOnLoad(firstObject.gameObject);
+ yield return WaitForSpawnedOn(firstClient, firstObject);
+ yield return WaitForSpawnedOn(secondClient, secondObject);
+ yield return s_DefaultWaitForTick;
+
+ SceneManager.MoveGameObjectToScene(firstObject.gameObject, activeScene);
+ Object.DontDestroyOnLoad(secondObject.gameObject);
+
+ var firstClientInstance = firstClient.SpawnManager.SpawnedObjects[firstObject.NetworkObjectId];
+ var secondClientInstance = secondClient.SpawnManager.SpawnedObjects[secondObject.NetworkObjectId];
+ yield return WaitForConditionOrTimeOut(() => firstClientInstance.gameObject.scene == activeScene
+ && secondClientInstance.gameObject.scene == secondClient.SceneManager.DontDestroyOnLoadScene);
+ AssertOnTimeout($"The observing clients did not migrate their instances! " +
+ $"[Client-{firstClient.LocalClientId}] {firstClientInstance.gameObject.scene.name}, [Client-{secondClient.LocalClientId}] {secondClientInstance.gameObject.scene.name}");
+
+ Assert.False(firstClient.SpawnManager.SpawnedObjects.ContainsKey(secondObject.NetworkObjectId), $"[Client-{firstClient.LocalClientId}] Spawned {secondObject.name} without observing it!");
+ Assert.False(secondClient.SpawnManager.SpawnedObjects.ContainsKey(firstObject.NetworkObjectId), $"[Client-{secondClient.LocalClientId}] Spawned {firstObject.name} without observing it!");
+ LogAssert.NoUnexpectedReceived();
+ }
+
+ ///
+ /// An object that migrates while no client observes it sends no migration.
+ ///
+ [UnityTest]
+ public IEnumerator SceneMigrationWithNoObserversIsNotSent()
+ {
+ var authority = GetAuthorityNetworkManager();
+
+ var networkObject = SpawnObject(m_PrefabNoObserversSpawn, authority).GetComponent();
+ yield return s_DefaultWaitForTick;
+ Object.DontDestroyOnLoad(networkObject.gameObject);
+
+ // Wait long enough for a migration to have been sent and processed
+ yield return new WaitForSeconds(0.25f);
+ LogAssert.NoUnexpectedReceived();
+ }
+
+ ///
+ /// An object shown to a client after it migrated while hidden from that client spawns in the authority's scene.
+ ///
+ [UnityTest]
+ public IEnumerator NetworkShowAfterSceneMigrationSpawnsInAuthorityScene()
+ {
+ var authority = GetAuthorityNetworkManager();
+ var client = GetNonAuthorityNetworkManager();
+
+ var networkObject = SpawnObject(m_PrefabNoObserversSpawn, authority).GetComponent();
+ yield return s_DefaultWaitForTick;
+ Object.DontDestroyOnLoad(networkObject.gameObject);
+ yield return s_DefaultWaitForTick;
+
+ networkObject.NetworkShow(client.LocalClientId);
+ yield return WaitForSpawnedOn(client, networkObject);
+
+ var clientInstance = client.SpawnManager.SpawnedObjects[networkObject.NetworkObjectId];
+ Assert.AreEqual(client.SceneManager.DontDestroyOnLoadScene, clientInstance.gameObject.scene, $"[Client-{client.LocalClientId}] {networkObject.name} did not spawn in the authority's scene!");
+ }
+
+ ///
+ /// A late joining client synchronizes an object that is not in the active scene into the authority's scene.
+ ///
+ [UnityTest]
+ public IEnumerator LateJoinSynchronizesObjectIntoAuthorityScene()
+ {
+ var authority = GetAuthorityNetworkManager();
+ var networkObject = SpawnObject(m_PrefabWithObservers, authority).GetComponent();
+ yield return WaitForSpawnedOnAllOrTimeOut(networkObject);
+ AssertOnTimeout($"Failed to spawn {networkObject.name} on all clients!");
+ Object.DontDestroyOnLoad(networkObject.gameObject);
+ yield return s_DefaultWaitForTick;
+
+ yield return CreateAndStartNewClient();
+ var lateJoinClient = m_ClientNetworkManagers[m_ClientNetworkManagers.Length - 1];
+ yield return WaitForSpawnedOn(lateJoinClient, networkObject);
+
+ var clientInstance = lateJoinClient.SpawnManager.SpawnedObjects[networkObject.NetworkObjectId];
+ Assert.AreEqual(lateJoinClient.SceneManager.DontDestroyOnLoadScene, clientInstance.gameObject.scene, $"[Client-{lateJoinClient.LocalClientId}] {networkObject.name} did not spawn in the authority's scene!");
+ }
+ }
+}
diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs.meta
new file mode 100644
index 0000000000..94364e42d7
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/NetworkObjectSceneMigrationObserverTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 4a1fe891864e4ae7867dd6f8dda3492c
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant: