From 5ef0bfdeda3cc0701d300bd78e43cd4e71dd415c Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Mon, 9 Nov 2026 14:46:50 -0600 Subject: [PATCH 1/2] feat: object pooling for hybrid prefab instances Server: a hybrid instance handed to an INetworkPrefabInstanceHandler now has its ghost despawned (GhostObject.Despawn), so reusing it spawns a new ghost. NetworkRigidbodyBase restores the authored kinematic state on despawn; the ghost captured the despawn-time kinematic state on respawn and replicated it. Client: N4E instantiates and destroys client ghost GameObjects itself, so two NGO systems run ahead of GhostGameObjectSpawnSystem and GhostGameObjectDespawnManagedSystem and route prefabs with a registered handler through it instead. --- .../Components/NetworkRigidBodyBase.cs | 11 ++ .../Runtime/Core/NetworkObject.cs | 6 +- .../Spawning/HybridPrefabHandlerSystems.cs | 171 ++++++++++++++++++ .../HybridPrefabHandlerSystems.cs.meta | 2 + .../Runtime/Spawning/NetworkSpawnManager.cs | 20 ++ 5 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 com.unity.netcode.gameobjects/Runtime/Spawning/HybridPrefabHandlerSystems.cs create mode 100644 com.unity.netcode.gameobjects/Runtime/Spawning/HybridPrefabHandlerSystems.cs.meta diff --git a/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs b/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs index cbc4b3a5ad..1b16f35740 100644 --- a/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs +++ b/com.unity.netcode.gameobjects/Runtime/Components/NetworkRigidBodyBase.cs @@ -1164,6 +1164,17 @@ public override void OnNetworkSpawn() /// public override void OnNetworkDespawn() { +#if UNIFIED_NETCODE + // The ghost replicates the kinematic state it reads when spawned, so a pooled server instance needs its authored state back. + if (NetworkObject.HasGhost) + { + if (m_LocalNetworkManager.IsServer) + { + SetIsKinematic(m_OriginalKinematicState); + } + return; + } +#endif if (UseRigidBodyForMotion && HasAuthority) { DetachFromFixedJoint(); diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs index 028e508982..5760614785 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkObject.cs @@ -4241,9 +4241,11 @@ private void InitGhost() { Debug.Log($"[{nameof(NetworkObject)}] GhostBridge {name} detected and instantiated."); } - if (GhostObject.WasInitialized && NetworkObjectBridge.NetworkObjectId.Value != 0) + var networkObjectId = NetworkObjectBridge.NetworkObjectId.Value; + // A prefab handler instance is registered by HybridPrefabHandlerSpawnSystem before its Start runs. + if (GhostObject.WasInitialized && networkObjectId != 0 && !IsSpawned && !NetworkManager.SpawnManager.GhostSpawnManager.IsGhostPendingSpawn(networkObjectId)) { - NetworkManager.SpawnManager.GhostSpawnManager.RegisterGhostBridge(NetworkObjectBridge.NetworkObjectId.Value, this); + NetworkManager.SpawnManager.GhostSpawnManager.RegisterGhostBridge(networkObjectId, this); } } #endif diff --git a/com.unity.netcode.gameobjects/Runtime/Spawning/HybridPrefabHandlerSystems.cs b/com.unity.netcode.gameobjects/Runtime/Spawning/HybridPrefabHandlerSystems.cs new file mode 100644 index 0000000000..cf29cb4352 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Spawning/HybridPrefabHandlerSystems.cs @@ -0,0 +1,171 @@ +#if UNIFIED_NETCODE +using Unity.Collections; +using Unity.Entities; +using Unity.Netcode.Components; +using Unity.Transforms; +using UnityEngine; + +namespace Unity.Netcode +{ + /// + /// Gives a client the instance of a hybrid prefab from its instead of letting N4E instantiate it. + /// + /// + /// The ghost arrives before NGO's spawn message, so the handler receives as the owner. + /// + [UpdateInGroup(typeof(GhostSimulationSystemGroup))] + [UpdateAfter(typeof(GhostUpdateSystem))] + [UpdateBefore(typeof(GhostGameObjectSpawnSystem))] + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + internal partial class HybridPrefabHandlerSpawnSystem : SystemBase + { + private EntityQuery m_PendingSpawnQuery; + private EntityQuery m_HybridNetworkManagerQuery; + + protected override void OnCreate() + { + if (World.IsHost()) + { + Enabled = false; + return; + } + m_PendingSpawnQuery = GetEntityQuery(ComponentType.ReadOnly(), ComponentType.ReadOnly()); + m_HybridNetworkManagerQuery = GetEntityQuery(ComponentType.ReadOnly()); + RequireForUpdate(m_PendingSpawnQuery); + RequireForUpdate(m_HybridNetworkManagerQuery); + } + + protected override void OnUpdate() + { + var networkManager = m_HybridNetworkManagerQuery.GetSingleton().NetworkManager.Value; + if (!networkManager || networkManager.ShutdownInProgress) + { + return; + } + + using var pendingEntities = m_PendingSpawnQuery.ToEntityArray(Allocator.Temp); + using var ghostInstances = m_PendingSpawnQuery.ToComponentDataArray(Allocator.Temp); + var prefabs = EntityManager.GetBuffer(SystemAPI.GetSingletonEntity()).ToNativeArray(Allocator.Temp); + + for (int i = 0; i < pendingEntities.Length; i++) + { + var entity = pendingEntities[i]; + var prefabEntity = prefabs[ghostInstances[i].ghostType].GhostPrefab; + var prefabId = EntityManager.GetComponentData(prefabEntity).AssociatedGameObject; + var prefab = Resources.EntityIdToObject(prefabId) as GameObject; + if (!prefab || !prefab.TryGetComponent(out var prefabNetworkObject) || !networkManager.PrefabHandler.ContainsHandler(prefabNetworkObject.GlobalObjectIdHash)) + { + continue; + } + + var localTransform = EntityManager.GetComponentData(entity); + var networkObject = networkManager.PrefabHandler.HandleNetworkPrefabSpawn(prefabNetworkObject.GlobalObjectIdHash, NetworkManager.ServerClientId, localTransform.Position, localTransform.Rotation); + if (!networkObject || !networkObject.HasGhost || networkObject.GhostObject.IsSpawned) + { + Debug.LogError($"[{nameof(INetworkPrefabInstanceHandler)}] The handler for {prefab.name} did not return an unspawned instance of it. N4E instantiates this ghost instead."); + continue; + } + + var gameObject = networkObject.gameObject; + var link = new GhostGameObjectLink(gameObject.GetEntityId(), gameObject.transform.GetEntityId()); + // Released by GhostObject.Despawn in HybridPrefabHandlerDespawnSystem. + var entityLink = GhostEntityMapping.AcquireEntityReferenceGameObject(link.AssociatedGameObject, link.AssociatedTransform, prefabId, World.Unmanaged, entity); + EntityManager.SetComponentData(entity, link); + EntityManager.RemoveComponent(entity); + networkObject.GhostObject.ClientRuntimeInitialize(entityLink); + + // A reused instance does not run Start again, so it registers here. + networkManager.SpawnManager.GhostSpawnManager.RegisterGhostBridge(networkObject.NetworkObjectBridge.NetworkObjectId.Value, networkObject); + } + } + } + + /// + /// Returns a client's hybrid instance to its when N4E despawns its ghost, instead of letting N4E destroy it. + /// + [UpdateInGroup(typeof(GhostSimulationSystemGroup))] + [UpdateAfter(typeof(GhostDespawnSystem))] + [UpdateAfter(typeof(PredictedGhostDespawnSystem))] + [UpdateAfter(typeof(GhostReceiveSystem))] + [UpdateBefore(typeof(GhostGameObjectDespawnManagedSystem))] + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + internal partial class HybridPrefabHandlerDespawnSystem : SystemBase + { + private EntityQuery m_DespawnTrackingQuery; + private EntityQuery m_HybridNetworkManagerQuery; + + protected override void OnCreate() + { + if (World.IsHost()) + { + Enabled = false; + return; + } + m_DespawnTrackingQuery = GetEntityQuery(typeof(GameObjectDespawnTracking)); + m_HybridNetworkManagerQuery = GetEntityQuery(ComponentType.ReadOnly()); + RequireForUpdate(m_DespawnTrackingQuery); + RequireForUpdate(m_HybridNetworkManagerQuery); + } + + protected override void OnUpdate() + { + var networkManager = m_HybridNetworkManagerQuery.GetSingleton().NetworkManager.Value; + if (!networkManager) + { + return; + } + + var trackingEntity = m_DespawnTrackingQuery.GetSingletonEntity(); + // Copied out, as GhostGameObjectDespawnManagedSystem does, because of a DynamicBuffer safety handle bug. + var despawns = EntityManager.GetBuffer(trackingEntity, isReadOnly: true).ToNativeArray(Allocator.Temp); + if (despawns.Length == 0) + { + return; + } + + EntityManager.CompleteDependencyBeforeRW(); + var spawnedGhostMap = SystemAPI.GetSingletonRW().ValueRO.SpawnedGhostMapRW; + var spawnManager = networkManager.SpawnManager; + var handled = new NativeList(despawns.Length, Allocator.Temp); + + for (int i = 0; i < despawns.Length; i++) + { + var despawn = despawns[i].oneDespawn; + var gameObjectId = EntityManager.GetComponentData(despawn.entity).AssociatedGameObject; + var gameObject = Resources.EntityIdToObject(gameObjectId) as GameObject; + if (!gameObject || !gameObject.TryGetComponent(out var networkObject) || !networkObject.HasGhost || !networkManager.PrefabHandler.ContainsHandler(networkObject)) + { + continue; + } + + if (spawnedGhostMap.TryGetValue(despawn.ghost, out var mapped) && mapped == despawn.entity) + { + spawnedGhostMap.Remove(despawn.ghost); + } + + var networkObjectId = networkObject.NetworkObjectId; + // The ghost can despawn before NGO's destroy message arrives, the same case NetworkObject.OnDestroy handles. + if (networkObject.IsSpawned && spawnManager.SpawnedObjects.TryGetValue(networkObjectId, out var spawned) && spawned == networkObject) + { + spawnManager.OnDespawnObject(networkObject, false); + } + spawnManager.GhostSpawnManager.MarkNetworkObjectAsDestroying(networkObjectId); + + NetworkSpawnManager.DespawnGhostForPrefabHandler(networkObject); + networkManager.PrefabHandler.HandleNetworkPrefabDestroy(networkObject); + handled.Add(i); + } + + if (handled.Length == 0) + { + return; + } + var buffer = EntityManager.GetBuffer(trackingEntity); + for (int i = handled.Length - 1; i >= 0; i--) + { + buffer.RemoveAt(handled[i]); + } + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Runtime/Spawning/HybridPrefabHandlerSystems.cs.meta b/com.unity.netcode.gameobjects/Runtime/Spawning/HybridPrefabHandlerSystems.cs.meta new file mode 100644 index 0000000000..ea45da293c --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Spawning/HybridPrefabHandlerSystems.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 96f7476d9ad641b48612415fc00f12ce diff --git a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs index 53ade03e9e..1215a6ffb7 100644 --- a/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Spawning/NetworkSpawnManager.cs @@ -997,6 +997,19 @@ private void SpawnGhost(NetworkObject networkObject) entitiesNetcode.m_ActiveWorld = previousWorld; } } + + /// + /// Despawns the ghost of a hybrid instance handed to an , which may keep the + /// GameObject for reuse. spawns a new ghost when the instance is spawned again. + /// + internal static void DespawnGhostForPrefabHandler(NetworkObject networkObject) + { + var ghostObject = networkObject.GhostObject; + if (networkObject.HasGhost && ghostObject != null && ghostObject.IsSpawned && ghostObject.DelaySpawning) + { + ghostObject.Despawn(); + } + } #endif /// @@ -1601,6 +1614,10 @@ internal void DespawnAndDestroyNetworkObjects() if (NetworkManager.PrefabHandler.ContainsHandler(networkObject)) { OnDespawnObject(networkObject, false); +#if UNIFIED_NETCODE + // The world is disposed after this, so a pooled instance must not keep a ghost linked to it. + DespawnGhostForPrefabHandler(networkObject); +#endif // Leave destruction up to the handler NetworkManager.PrefabHandler.HandleNetworkPrefabDestroy(networkObject); } @@ -1972,6 +1989,9 @@ internal void OnDespawnObject([NotNull] NetworkObject networkObject, bool destro { if (NetworkManager.PrefabHandler.ContainsHandler(networkObject)) { +#if UNIFIED_NETCODE + DespawnGhostForPrefabHandler(networkObject); +#endif NetworkManager.PrefabHandler.HandleNetworkPrefabDestroy(networkObject); } else From 7b252567f394d9d561be844200167471920a5536 Mon Sep 17 00:00:00 2001 From: Noel Stephens Date: Mon, 9 Nov 2026 15:25:19 -0600 Subject: [PATCH 2/2] test: hybrid prefab pooling through INetworkPrefabInstanceHandler Two spawn/despawn cycles on UnifiedHost and UnifiedServer: the client takes and returns instances through its handler, the server reuses its pooled instances, and the server body stays dynamic after a pooled respawn. --- .../HybridPrefabHandlerPoolingTests.cs | 153 ++++++++++++++++++ .../HybridPrefabHandlerPoolingTests.cs.meta | 2 + 2 files changed, 155 insertions(+) create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridPrefabHandlerPoolingTests.cs create mode 100644 com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridPrefabHandlerPoolingTests.cs.meta diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridPrefabHandlerPoolingTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridPrefabHandlerPoolingTests.cs new file mode 100644 index 0000000000..b450493ab4 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridPrefabHandlerPoolingTests.cs @@ -0,0 +1,153 @@ +#if UNIFIED_NETCODE && COM_UNITY_MODULES_PHYSICS +using System.Collections; +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Netcode.Components; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Validates that an pools hybrid instances on the server and on clients. + /// + [TestFixture(HostOrServer.UnifiedHost)] + [TestFixture(HostOrServer.UnifiedServer)] + internal class HybridPrefabHandlerPoolingTests : NetcodeIntegrationTest + { + private const int k_InstanceCount = 3; + private const int k_SpawnCycles = 2; + + protected override int NumberOfClients => 1; + + private GameObject m_HybridPrefab; + private PoolHandler m_AuthorityPool; + private PoolHandler m_NonAuthorityPool; + + public HybridPrefabHandlerPoolingTests(HostOrServer hostOrServer) : base(hostOrServer) { } + + protected override bool UseUnifiedTests() + { + return true; + } + + private class PoolHandler : INetworkPrefabInstanceHandler + { + public readonly List Created = new List(); + public int Reused; + public int Returned; + + private readonly GameObject m_Prefab; + private readonly Stack m_Available = new Stack(); + + public PoolHandler(GameObject prefab) + { + m_Prefab = prefab; + } + + public NetworkObject Instantiate(ulong ownerClientId, Vector3 position, Quaternion rotation) + { + if (m_Available.Count > 0) + { + var instance = m_Available.Pop(); + instance.transform.SetPositionAndRotation(position, rotation); + instance.gameObject.SetActive(true); + Reused++; + return instance; + } + var created = Object.Instantiate(m_Prefab, position, rotation).GetComponent(); + Created.Add(created); + return created; + } + + public void Destroy(NetworkObject networkObject) + { + networkObject.gameObject.SetActive(false); + m_Available.Push(networkObject); + Returned++; + } + + public void DestroyPooled() + { + while (m_Available.Count > 0) + { + var instance = m_Available.Pop(); + if (instance) + { + Object.Destroy(instance.gameObject); + } + } + } + } + + protected override void OnServerAndClientsCreated() + { + m_HybridPrefab = CreateNetworkObjectPrefab("HybridPooled"); + m_HybridPrefab.AddComponent(); + m_HybridPrefab.AddComponent().isKinematic = false; + m_HybridPrefab.AddComponent(); + + m_AuthorityPool = new PoolHandler(m_HybridPrefab); + m_NonAuthorityPool = new PoolHandler(m_HybridPrefab); + m_ServerNetworkManager.PrefabHandler.AddHandler(m_HybridPrefab, m_AuthorityPool); + m_ClientNetworkManagers[0].PrefabHandler.AddHandler(m_HybridPrefab, m_NonAuthorityPool); + base.OnServerAndClientsCreated(); + } + + protected override IEnumerator OnTearDown() + { + // Without a handler, shutdown destroys what is still spawned instead of pooling it where nothing cleans it up. + m_ServerNetworkManager.PrefabHandler.RemoveHandler(m_HybridPrefab); + m_ClientNetworkManagers[0].PrefabHandler.RemoveHandler(m_HybridPrefab); + m_AuthorityPool.DestroyPooled(); + m_NonAuthorityPool.DestroyPooled(); + yield return base.OnTearDown(); + } + + [UnityTest] + public IEnumerator SpawnDespawnRespawnUsesPools() + { + var authority = GetAuthorityNetworkManager(); + var nonAuthority = GetNonAuthorityNetworkManager(); + var authorityInstances = new NetworkObject[k_InstanceCount]; + + for (int cycle = 0; cycle < k_SpawnCycles; cycle++) + { + for (int i = 0; i < k_InstanceCount; i++) + { + var instance = m_AuthorityPool.Instantiate(authority.LocalClientId, new Vector3(i * 2.0f, 0.0f, 0.0f), Quaternion.identity); + instance.NetworkManagerOwner = authority; + instance.Spawn(); + authorityInstances[i] = instance; + } + foreach (var instance in authorityInstances) + { + yield return WaitForSpawnedOnAllOrTimeOut(instance); + AssertOnTimeout($"[Cycle {cycle}] {instance.name} was not spawned on all instances!"); + } + + Assert.AreEqual(k_InstanceCount, m_NonAuthorityPool.Created.Count, $"[Cycle {cycle}] Client-{nonAuthority.LocalClientId} did not take its instances from its handler!"); + Assert.AreEqual(cycle * k_InstanceCount, m_NonAuthorityPool.Reused, $"[Cycle {cycle}] Client-{nonAuthority.LocalClientId} did not reuse its pooled instances!"); + foreach (var instance in authorityInstances) + { + var clientInstance = nonAuthority.SpawnManager.SpawnedObjects[instance.NetworkObjectId]; + Assert.IsTrue(m_NonAuthorityPool.Created.Contains(clientInstance), $"[Cycle {cycle}] Client-{nonAuthority.LocalClientId} spawned {clientInstance.name}, which its handler did not provide!"); + Assert.IsFalse(instance.GetComponent().isKinematic, $"[Cycle {cycle}] {instance.name} is kinematic on the server!"); + Assert.IsTrue(clientInstance.GetComponent().isKinematic, $"[Cycle {cycle}] {clientInstance.name} is not kinematic on Client-{nonAuthority.LocalClientId}!"); + } + + foreach (var instance in authorityInstances) + { + instance.Despawn(); + } + Assert.AreEqual((cycle + 1) * k_InstanceCount, m_AuthorityPool.Returned, $"[Cycle {cycle}] The server did not return its instances to its handler!"); + yield return WaitForConditionOrTimeOut(() => m_NonAuthorityPool.Returned == (cycle + 1) * k_InstanceCount); + AssertOnTimeout($"[Cycle {cycle}] Client-{nonAuthority.LocalClientId} returned {m_NonAuthorityPool.Returned} instances to its handler!"); + } + + Assert.AreEqual(k_InstanceCount, m_AuthorityPool.Created.Count, "The server created instances instead of reusing its pooled ones!"); + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridPrefabHandlerPoolingTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridPrefabHandlerPoolingTests.cs.meta new file mode 100644 index 0000000000..99a1be8cbd --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/NetworkObject/HybridPrefabHandlerPoolingTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2a0b8c80c8824b01af467a3c20458f65