From 7908bff5593e69e233a12654db4b162c119c7273 Mon Sep 17 00:00:00 2001 From: Tungdv Date: Tue, 28 Apr 2026 15:55:45 +0700 Subject: [PATCH 1/2] feat: add pooling manager. --- Assets/AddressablesPoolingBlueprint.md | 63 +++++ Assets/AddressablesPoolingBlueprint.md.meta | 7 + .../PerfectWorld/Scripts/PoolingManager.meta | 8 + .../Scripts/PoolingManager/IPoolable.cs | 11 + .../Scripts/PoolingManager/IPoolable.cs.meta | 2 + .../Scripts/PoolingManager/ObjectPool.cs | 260 ++++++++++++++++++ .../Scripts/PoolingManager/ObjectPool.cs.meta | 2 + .../Scripts/PoolingManager/PoolManager.cs | 233 ++++++++++++++++ .../PoolingManager/PoolManager.cs.meta | 2 + .../Scripts/PoolingManager/poolingManager.md | 68 +++++ .../PoolingManager/poolingManager.md.meta | 7 + 11 files changed, 663 insertions(+) create mode 100644 Assets/AddressablesPoolingBlueprint.md create mode 100644 Assets/AddressablesPoolingBlueprint.md.meta create mode 100644 Assets/PerfectWorld/Scripts/PoolingManager.meta create mode 100644 Assets/PerfectWorld/Scripts/PoolingManager/IPoolable.cs create mode 100644 Assets/PerfectWorld/Scripts/PoolingManager/IPoolable.cs.meta create mode 100644 Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs create mode 100644 Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs.meta create mode 100644 Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs create mode 100644 Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs.meta create mode 100644 Assets/PerfectWorld/Scripts/PoolingManager/poolingManager.md create mode 100644 Assets/PerfectWorld/Scripts/PoolingManager/poolingManager.md.meta diff --git a/Assets/AddressablesPoolingBlueprint.md b/Assets/AddressablesPoolingBlueprint.md new file mode 100644 index 0000000000..f71fc550e5 --- /dev/null +++ b/Assets/AddressablesPoolingBlueprint.md @@ -0,0 +1,63 @@ +# Unity Addressables Object Pooling System Blueprint + +## 1. System Context & Requirements +- **Environment:** Unity 3D, C#. +- **Genre:** Mobile MMORPG (Android, iOS). +- **Core Technologies:** Unity Addressables, Custom Object Pooling. +- **Problem Statement:** The current system uses direct `Instantiate` and `Destroy` with Addressables, causing CPU spikes, GC Allocation overhead, and FPS drops when handling many objects (e.g., from server packets). Memory management is tricky; releasing Addressables assets while objects are still alive causes missing asset errors, but not releasing them causes Out Of Memory (OOM) crashes. +- **Solution:** A parameter-driven Object Pooling system that recycles GameObjects and manages Addressables memory using a dynamic Time-To-Live (TTL) Reference Counting mechanism. No external config files (ScriptableObjects) are used; behaviors are dictated directly via parameters when calling the `Spawn` method. + +--- + +## 2. Core Architecture +The system consists of three main components: +1. **`IPoolable` (Interface):** Attached to prefab scripts. Handles internal state resets when an object is reused or returned. +2. **`ObjectPool` (Class):** Manages instances of a specific Addressables Asset. Tracks active instances, idle instances, and handles the Memory Release TTL countdown. +3. **`PoolManager` (Singleton/Service):** The centralized API for the Game/Server to request (`Spawn`) and return (`Despawn`) objects. + +--- + +## 3. Processing Flows + +### A. Spawn Flow +1. Caller requests an object via `PoolManager.Spawn(addressableKey, position, rotation, memoryReleaseTTL, autoDespawnTime)`. +2. `PoolManager` checks if an `ObjectPool` exists for the given `addressableKey`. If not, creates one. +3. `PoolManager` updates the pool's internal TTL value with the newly provided `memoryReleaseTTL`. +4. `ObjectPool` attempts to retrieve an idle GameObject: + - **If idle list is empty:** Loads the asset via `Addressables.LoadAssetAsync`, Instantiates it, and increments the Active Count. + - **If idle list has items:** Pops the last item, sets it Active, and increments the Active Count. +5. If the memory release TTL timer is currently running (because Active Count was previously 0), **Cancel the timer**. +6. The GameObject's `IPoolable.OnSpawn()` is called. +7. If `autoDespawnTime > 0`, `PoolManager` starts a Coroutine to automatically Despawn the object after the specified seconds. +8. Returns the GameObject to the caller. + +### B. Despawn Flow +1. Caller requests to return an object via `PoolManager.Despawn(addressableKey, gameObject)`. +2. `PoolManager` locates the corresponding `ObjectPool`. +3. The GameObject's `IPoolable.OnDespawn()` is called to reset states (e.g., clear trails, reset health). +4. GameObject is deactivated (`SetActive(false)`). +5. `ObjectPool` adds the GameObject to the idle list and decrements the Active Count. +6. **Trigger TTL Check:** If `Active Count == 0`, `ObjectPool` starts the Memory Release Timer using its current `memoryReleaseTTL` value. + +### C. Memory Release Flow (Garbage Collection) +1. When Active Count reaches 0, a Coroutine (`ReleaseMemoryCountdown`) starts. +2. The Coroutine waits for `memoryReleaseTTL` seconds. +3. **Interruption:** If a new `Spawn` request for this key comes in during the wait, the Coroutine is stopped (Asset is kept in RAM). +4. **Completion:** If the Coroutine finishes and Active Count is still 0: + - Destroy all GameObjects in the idle list. + - Call `Addressables.ReleaseInstance()` or `Addressables.Release()` to free the RAM. + - Remove the `ObjectPool` from the `PoolManager`'s dictionary. + +--- + +## 4. C# Code Models (Definitions & Stubs) + +*Note for AI Code Generator: Use these class signatures to implement the full functional code.* + +### I. IPoolable.cs +```csharp +public interface IPoolable +{ + void OnSpawn(); + void OnDespawn(); +} \ No newline at end of file diff --git a/Assets/AddressablesPoolingBlueprint.md.meta b/Assets/AddressablesPoolingBlueprint.md.meta new file mode 100644 index 0000000000..0e3544419c --- /dev/null +++ b/Assets/AddressablesPoolingBlueprint.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6babe48248f2d2e4486f19a1908cc004 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/PerfectWorld/Scripts/PoolingManager.meta b/Assets/PerfectWorld/Scripts/PoolingManager.meta new file mode 100644 index 0000000000..6d3bf283d0 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/PoolingManager.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a86dd2e474fb3c34cb8214c766f0b941 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/IPoolable.cs b/Assets/PerfectWorld/Scripts/PoolingManager/IPoolable.cs new file mode 100644 index 0000000000..93f9d4a3a2 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/PoolingManager/IPoolable.cs @@ -0,0 +1,11 @@ +namespace BrewMonster.Scripts +{ + /// + /// Implement this on pooled prefab components that need to reset state between uses. + /// + public interface IPoolable + { + void OnSpawn(); + void OnDespawn(); + } +} diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/IPoolable.cs.meta b/Assets/PerfectWorld/Scripts/PoolingManager/IPoolable.cs.meta new file mode 100644 index 0000000000..c9dbfb1b97 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/PoolingManager/IPoolable.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7c09b2b8b565f2946bb4e9f09e2966af \ No newline at end of file diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs b/Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs new file mode 100644 index 0000000000..20ba26d011 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs @@ -0,0 +1,260 @@ +using System.Collections; +using System.Collections.Generic; +using System.Threading.Tasks; +using UnityEngine; +using UnityEngine.AddressableAssets; +using UnityEngine.ResourceManagement.AsyncOperations; + +namespace BrewMonster.Scripts +{ + internal sealed class ObjectPool + { + private readonly string _addressableKey; + private readonly PoolManager _owner; + private readonly Transform _poolRoot; + private readonly Stack _idleInstances = new(); + private readonly HashSet _activeInstances = new(); + private readonly HashSet _knownInstances = new(); + private readonly Dictionary _spawnVersions = new(); + + private AsyncOperationHandle _prefabHandle; + private GameObject _prefab; + private Task _loadTask; + private Coroutine _releaseCoroutine; + private float _memoryReleaseTTL; + + public ObjectPool(string addressableKey, PoolManager owner, Transform poolRoot, float memoryReleaseTTL) + { + _addressableKey = addressableKey; + _owner = owner; + _poolRoot = poolRoot; + _memoryReleaseTTL = Mathf.Max(0f, memoryReleaseTTL); + } + + public string AddressableKey => _addressableKey; + public int ActiveCount => _activeInstances.Count; + public int IdleCount => _idleInstances.Count; + + public void UpdateMemoryReleaseTTL(float memoryReleaseTTL) + { + _memoryReleaseTTL = Mathf.Max(0f, memoryReleaseTTL); + } + + public async Task SpawnAsync(Vector3 position, Quaternion rotation, Transform parent) + { + CancelReleaseCountdown(); + + GameObject instance = GetIdleInstance(); + if (instance == null) + { + GameObject prefab = await LoadPrefabAsync(); + if (prefab == null) + { + return null; + } + + instance = Object.Instantiate(prefab); + _knownInstances.Add(instance); + _spawnVersions[instance] = 0; + } + + _activeInstances.Add(instance); + _spawnVersions[instance]++; + instance.transform.SetParent(parent, true); + instance.transform.SetPositionAndRotation(position, rotation); + instance.SetActive(true); + NotifyPoolablesSpawned(instance); + + return instance; + } + + public bool Despawn(GameObject instance) + { + if (instance == null || !_activeInstances.Remove(instance)) + { + return false; + } + + NotifyPoolablesDespawned(instance); + _spawnVersions[instance]++; + instance.SetActive(false); + instance.transform.SetParent(_poolRoot, false); + _idleInstances.Push(instance); + + if (_activeInstances.Count == 0) + { + StartReleaseCountdown(); + } + + return true; + } + + public bool IsActiveInstance(GameObject instance, int spawnVersion) + { + return instance != null + && _activeInstances.Contains(instance) + && _spawnVersions.TryGetValue(instance, out int currentVersion) + && currentVersion == spawnVersion; + } + + public int GetSpawnVersion(GameObject instance) + { + return instance != null && _spawnVersions.TryGetValue(instance, out int version) ? version : -1; + } + + public IEnumerable GetKnownInstances() + { + return _knownInstances; + } + + public void ReleaseNow() + { + CancelReleaseCountdown(); + DestroyAllInstances(); + ReleasePrefabHandle(); + } + + private GameObject GetIdleInstance() + { + while (_idleInstances.Count > 0) + { + GameObject instance = _idleInstances.Pop(); + if (instance != null) + { + return instance; + } + } + + return null; + } + + private async Task LoadPrefabAsync() + { + if (_prefab != null) + { + return _prefab; + } + + if (_loadTask != null) + { + return await _loadTask; + } + + _loadTask = LoadPrefabInternalAsync(); + GameObject prefab = await _loadTask; + if (prefab == null) + { + _loadTask = null; + } + + return prefab; + } + + private async Task LoadPrefabInternalAsync() + { + _prefabHandle = Addressables.LoadAssetAsync(_addressableKey); + await _prefabHandle.Task; + + if (_prefabHandle.Status != AsyncOperationStatus.Succeeded || _prefabHandle.Result == null) + { + Debug.LogError($"ObjectPool: Failed to load Addressable prefab '{_addressableKey}'."); + ReleasePrefabHandle(); + return null; + } + + _prefab = _prefabHandle.Result; + return _prefab; + } + + private void StartReleaseCountdown() + { + CancelReleaseCountdown(); + _releaseCoroutine = _owner.StartCoroutine(ReleaseMemoryCountdown()); + } + + private void CancelReleaseCountdown() + { + if (_releaseCoroutine == null) + { + return; + } + + _owner.StopCoroutine(_releaseCoroutine); + _releaseCoroutine = null; + } + + private IEnumerator ReleaseMemoryCountdown() + { + if (_memoryReleaseTTL > 0f) + { + yield return new WaitForSeconds(_memoryReleaseTTL); + } + + _releaseCoroutine = null; + if (_activeInstances.Count > 0) + { + yield break; + } + + _owner.RemovePool(this); + DestroyAllInstances(); + ReleasePrefabHandle(); + } + + private void DestroyAllInstances() + { + foreach (GameObject instance in _knownInstances) + { + if (instance != null) + { + if (_activeInstances.Contains(instance)) + { + NotifyPoolablesDespawned(instance); + } + + Object.Destroy(instance); + } + } + + _idleInstances.Clear(); + _activeInstances.Clear(); + _knownInstances.Clear(); + _spawnVersions.Clear(); + + if (_poolRoot != null) + { + Object.Destroy(_poolRoot.gameObject); + } + } + + private void ReleasePrefabHandle() + { + if (_prefabHandle.IsValid()) + { + Addressables.Release(_prefabHandle); + } + + _prefabHandle = default; + _prefab = null; + _loadTask = null; + } + + private static void NotifyPoolablesSpawned(GameObject instance) + { + IPoolable[] poolables = instance.GetComponentsInChildren(true); + for (int i = 0; i < poolables.Length; i++) + { + poolables[i].OnSpawn(); + } + } + + private static void NotifyPoolablesDespawned(GameObject instance) + { + IPoolable[] poolables = instance.GetComponentsInChildren(true); + for (int i = 0; i < poolables.Length; i++) + { + poolables[i].OnDespawn(); + } + } + } +} diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs.meta b/Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs.meta new file mode 100644 index 0000000000..3054953278 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8fc34059fd203384d9c5aa022301e68f \ No newline at end of file diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs b/Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs new file mode 100644 index 0000000000..5b2646be62 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading.Tasks; +using UnityEngine; + +namespace BrewMonster.Scripts +{ + public sealed class PoolManager : MonoSingleton + { + private readonly Dictionary _pools = new(); + private readonly Dictionary _instanceToPool = new(); + private Transform _poolContainer; + + protected override void Initialize() + { + base.Initialize(); + _poolContainer = new GameObject("Addressables Object Pools").transform; + _poolContainer.SetParent(transform, false); + } + + /// + /// Spawns an Addressables prefab from its pool. The returned task completes after the prefab is loaded if needed. + /// + public async Task SpawnAsync( + string addressableKey, + Vector3 position, + Quaternion rotation, + float memoryReleaseTTL, + float autoDespawnTime = 0f, + Transform parent = null) + { + if (string.IsNullOrEmpty(addressableKey)) + { + Debug.LogError("PoolManager: Cannot spawn with a null or empty Addressables key."); + return null; + } + + ObjectPool pool = GetOrCreatePool(addressableKey, memoryReleaseTTL); + pool.UpdateMemoryReleaseTTL(memoryReleaseTTL); + + GameObject instance = await pool.SpawnAsync(position, rotation, parent); + if (instance == null) + { + return null; + } + + _instanceToPool[instance] = pool; + + if (autoDespawnTime > 0f) + { + StartCoroutine(AutoDespawnAfter(pool, instance, pool.GetSpawnVersion(instance), autoDespawnTime)); + } + + return instance; + } + + /// + /// Coroutine-friendly spawn API for callers that do not use async/await. + /// + public void Spawn( + string addressableKey, + Vector3 position, + Quaternion rotation, + float memoryReleaseTTL, + float autoDespawnTime, + Action onComplete, + Transform parent = null) + { + StartCoroutine(SpawnRoutine(addressableKey, position, rotation, memoryReleaseTTL, autoDespawnTime, onComplete, parent)); + } + + public bool Despawn(string addressableKey, GameObject instance) + { + if (string.IsNullOrEmpty(addressableKey) || instance == null) + { + return false; + } + + if (!_pools.TryGetValue(addressableKey, out ObjectPool pool)) + { + return false; + } + + bool despawned = pool.Despawn(instance); + if (despawned) + { + _instanceToPool[instance] = pool; + } + + return despawned; + } + + public bool Despawn(GameObject instance) + { + if (instance == null || !_instanceToPool.TryGetValue(instance, out ObjectPool pool)) + { + return false; + } + + return pool.Despawn(instance); + } + + public bool TryGetPoolCounts(string addressableKey, out int activeCount, out int idleCount) + { + activeCount = 0; + idleCount = 0; + + if (!_pools.TryGetValue(addressableKey, out ObjectPool pool)) + { + return false; + } + + activeCount = pool.ActiveCount; + idleCount = pool.IdleCount; + return true; + } + + public void ReleasePool(string addressableKey) + { + if (!_pools.TryGetValue(addressableKey, out ObjectPool pool)) + { + return; + } + + UnregisterPoolInstances(pool); + pool.ReleaseNow(); + _pools.Remove(addressableKey); + } + + public void ReleaseAllPools() + { + List pools = new(_pools.Values); + for (int i = 0; i < pools.Count; i++) + { + pools[i].ReleaseNow(); + } + + _pools.Clear(); + _instanceToPool.Clear(); + } + + internal void RemovePool(ObjectPool pool) + { + if (pool == null) + { + return; + } + + UnregisterPoolInstances(pool); + _pools.Remove(pool.AddressableKey); + } + + private IEnumerator SpawnRoutine( + string addressableKey, + Vector3 position, + Quaternion rotation, + float memoryReleaseTTL, + float autoDespawnTime, + Action onComplete, + Transform parent) + { + Task spawnTask = SpawnAsync(addressableKey, position, rotation, memoryReleaseTTL, autoDespawnTime, parent); + while (!spawnTask.IsCompleted) + { + yield return null; + } + + if (spawnTask.Exception != null) + { + Debug.LogException(spawnTask.Exception); + onComplete?.Invoke(null); + yield break; + } + + onComplete?.Invoke(spawnTask.Result); + } + + private IEnumerator AutoDespawnAfter(ObjectPool pool, GameObject instance, int spawnVersion, float autoDespawnTime) + { + yield return new WaitForSeconds(autoDespawnTime); + + if (pool != null && pool.IsActiveInstance(instance, spawnVersion)) + { + Despawn(instance); + } + } + + private ObjectPool GetOrCreatePool(string addressableKey, float memoryReleaseTTL) + { + if (_pools.TryGetValue(addressableKey, out ObjectPool pool)) + { + return pool; + } + + Transform poolRoot = new GameObject(GetPoolRootName(addressableKey)).transform; + poolRoot.SetParent(_poolContainer, false); + + pool = new ObjectPool(addressableKey, this, poolRoot, memoryReleaseTTL); + _pools[addressableKey] = pool; + return pool; + } + + private void UnregisterPoolInstances(ObjectPool pool) + { + foreach (GameObject instance in pool.GetKnownInstances()) + { + if (instance != null) + { + _instanceToPool.Remove(instance); + } + } + } + + private static string GetPoolRootName(string addressableKey) + { + string name = addressableKey.Replace('\\', '/'); + int lastSlash = name.LastIndexOf('/'); + if (lastSlash >= 0 && lastSlash < name.Length - 1) + { + name = name.Substring(lastSlash + 1); + } + + return $"Pool - {name}"; + } + + protected override void OnDestroy() + { + ReleaseAllPools(); + base.OnDestroy(); + } + } +} diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs.meta b/Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs.meta new file mode 100644 index 0000000000..f4c76c06ac --- /dev/null +++ b/Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b8e790a183861dd40bd580421c793038 \ No newline at end of file diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/poolingManager.md b/Assets/PerfectWorld/Scripts/PoolingManager/poolingManager.md new file mode 100644 index 0000000000..c3297c23ae --- /dev/null +++ b/Assets/PerfectWorld/Scripts/PoolingManager/poolingManager.md @@ -0,0 +1,68 @@ +# Pooling Manager Execution Flow + +## Purpose + +The pooling system recycles Addressables-backed `GameObject` instances instead of repeatedly calling `Instantiate` and `Destroy`. Each Addressables key owns one runtime pool that tracks active objects, idle objects, and a memory-release timer. + +## Runtime Files + +- `IPoolable.cs`: prefab-side lifecycle interface. +- `ObjectPool.cs`: owns instances and the loaded Addressables prefab handle for one key. +- `PoolManager.cs`: singleton service used by gameplay, server packet handlers, and UI code. + +## Spawn Flow + +1. Call `PoolManager.Instance.SpawnAsync(...)` or `PoolManager.Instance.Spawn(...)`. +2. `PoolManager` finds or creates an `ObjectPool` for the Addressables key. +3. The pool updates its `memoryReleaseTTL` from the spawn parameter. +4. If a memory release countdown is running, the pool cancels it. +5. The pool reuses an idle object when available. +6. If no idle object exists, the pool loads the prefab with `Addressables.LoadAssetAsync()` and instantiates it. +7. The instance is parented, positioned, rotated, activated, and all `IPoolable.OnSpawn()` hooks are called. +8. If `autoDespawnTime > 0`, `PoolManager` starts a version-checked auto despawn coroutine. +9. The spawned `GameObject` is returned to the caller. + +## Despawn Flow + +1. Call `PoolManager.Instance.Despawn(gameObject)` or `PoolManager.Instance.Despawn(addressableKey, gameObject)`. +2. The target pool validates that the object is currently active. +3. All `IPoolable.OnDespawn()` hooks are called. +4. The object is deactivated, parented under the pool root, and pushed into the idle stack. +5. When the active count reaches zero, the pool starts the memory release countdown. + +## Memory Release Flow + +1. The countdown waits for the latest `memoryReleaseTTL` value supplied by spawn calls for that key. +2. A new spawn for the same key cancels the countdown and keeps the prefab handle in memory. +3. If the countdown completes while active count is still zero, the pool: + - unregisters itself from `PoolManager`; + - destroys all pooled instances; + - releases the Addressables prefab handle with `Addressables.Release()`; + - destroys the pool root object. + +## Auto Despawn Safety + +Auto despawn stores the instance spawn version when the coroutine starts. If the object is manually despawned and reused before the timer completes, the version changes and the old coroutine will not despawn the new lifecycle. + +## Example + +```csharp +GameObject fx = await PoolManager.Instance.SpawnAsync( + "effects/fireball.prefab", + hitPosition, + Quaternion.identity, + memoryReleaseTTL: 15f, + autoDespawnTime: 2f); +``` + +For coroutine-based callers: + +```csharp +PoolManager.Instance.Spawn( + "effects/fireball.prefab", + hitPosition, + Quaternion.identity, + memoryReleaseTTL: 15f, + autoDespawnTime: 2f, + onComplete: spawned => { /* use spawned */ }); +``` diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/poolingManager.md.meta b/Assets/PerfectWorld/Scripts/PoolingManager/poolingManager.md.meta new file mode 100644 index 0000000000..1a41a199c1 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/PoolingManager/poolingManager.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 191a414af7b325d459e32ac5254c011d +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 46e3154db971914b4c6f8da66cf6e182a98677dd Mon Sep 17 00:00:00 2001 From: Tungdv Date: Mon, 4 May 2026 16:16:58 +0700 Subject: [PATCH 2/2] fix: update pooling manager. --- Assets/AddressablesPoolingBlueprint.md | 63 ------ Assets/AddressablesPoolingBlueprint.md.meta | 7 - .../Scripts/Managers/EC_ManMatter.cs | 6 +- .../PerfectWorld/Scripts/Objet/CECMatter.cs | 7 +- .../Scripts/PoolingManager/IPoolable.cs.meta | 2 +- .../Scripts/PoolingManager/ObjectPool.cs | 60 +++--- .../Scripts/PoolingManager/ObjectPool.cs.meta | 2 +- .../Scripts/PoolingManager/PoolManager.cs | 17 +- .../PoolingManager/PoolManager.cs.meta | 2 +- .../PoolingManager/poolingManager.md.meta | 7 - Documentation/addressable-manager.md | 201 ++++++++++++++++++ .../poolingManager.md | 18 +- 12 files changed, 273 insertions(+), 119 deletions(-) delete mode 100644 Assets/AddressablesPoolingBlueprint.md delete mode 100644 Assets/AddressablesPoolingBlueprint.md.meta delete mode 100644 Assets/PerfectWorld/Scripts/PoolingManager/poolingManager.md.meta create mode 100644 Documentation/addressable-manager.md rename {Assets/PerfectWorld/Scripts/PoolingManager => Documentation}/poolingManager.md (71%) diff --git a/Assets/AddressablesPoolingBlueprint.md b/Assets/AddressablesPoolingBlueprint.md deleted file mode 100644 index f71fc550e5..0000000000 --- a/Assets/AddressablesPoolingBlueprint.md +++ /dev/null @@ -1,63 +0,0 @@ -# Unity Addressables Object Pooling System Blueprint - -## 1. System Context & Requirements -- **Environment:** Unity 3D, C#. -- **Genre:** Mobile MMORPG (Android, iOS). -- **Core Technologies:** Unity Addressables, Custom Object Pooling. -- **Problem Statement:** The current system uses direct `Instantiate` and `Destroy` with Addressables, causing CPU spikes, GC Allocation overhead, and FPS drops when handling many objects (e.g., from server packets). Memory management is tricky; releasing Addressables assets while objects are still alive causes missing asset errors, but not releasing them causes Out Of Memory (OOM) crashes. -- **Solution:** A parameter-driven Object Pooling system that recycles GameObjects and manages Addressables memory using a dynamic Time-To-Live (TTL) Reference Counting mechanism. No external config files (ScriptableObjects) are used; behaviors are dictated directly via parameters when calling the `Spawn` method. - ---- - -## 2. Core Architecture -The system consists of three main components: -1. **`IPoolable` (Interface):** Attached to prefab scripts. Handles internal state resets when an object is reused or returned. -2. **`ObjectPool` (Class):** Manages instances of a specific Addressables Asset. Tracks active instances, idle instances, and handles the Memory Release TTL countdown. -3. **`PoolManager` (Singleton/Service):** The centralized API for the Game/Server to request (`Spawn`) and return (`Despawn`) objects. - ---- - -## 3. Processing Flows - -### A. Spawn Flow -1. Caller requests an object via `PoolManager.Spawn(addressableKey, position, rotation, memoryReleaseTTL, autoDespawnTime)`. -2. `PoolManager` checks if an `ObjectPool` exists for the given `addressableKey`. If not, creates one. -3. `PoolManager` updates the pool's internal TTL value with the newly provided `memoryReleaseTTL`. -4. `ObjectPool` attempts to retrieve an idle GameObject: - - **If idle list is empty:** Loads the asset via `Addressables.LoadAssetAsync`, Instantiates it, and increments the Active Count. - - **If idle list has items:** Pops the last item, sets it Active, and increments the Active Count. -5. If the memory release TTL timer is currently running (because Active Count was previously 0), **Cancel the timer**. -6. The GameObject's `IPoolable.OnSpawn()` is called. -7. If `autoDespawnTime > 0`, `PoolManager` starts a Coroutine to automatically Despawn the object after the specified seconds. -8. Returns the GameObject to the caller. - -### B. Despawn Flow -1. Caller requests to return an object via `PoolManager.Despawn(addressableKey, gameObject)`. -2. `PoolManager` locates the corresponding `ObjectPool`. -3. The GameObject's `IPoolable.OnDespawn()` is called to reset states (e.g., clear trails, reset health). -4. GameObject is deactivated (`SetActive(false)`). -5. `ObjectPool` adds the GameObject to the idle list and decrements the Active Count. -6. **Trigger TTL Check:** If `Active Count == 0`, `ObjectPool` starts the Memory Release Timer using its current `memoryReleaseTTL` value. - -### C. Memory Release Flow (Garbage Collection) -1. When Active Count reaches 0, a Coroutine (`ReleaseMemoryCountdown`) starts. -2. The Coroutine waits for `memoryReleaseTTL` seconds. -3. **Interruption:** If a new `Spawn` request for this key comes in during the wait, the Coroutine is stopped (Asset is kept in RAM). -4. **Completion:** If the Coroutine finishes and Active Count is still 0: - - Destroy all GameObjects in the idle list. - - Call `Addressables.ReleaseInstance()` or `Addressables.Release()` to free the RAM. - - Remove the `ObjectPool` from the `PoolManager`'s dictionary. - ---- - -## 4. C# Code Models (Definitions & Stubs) - -*Note for AI Code Generator: Use these class signatures to implement the full functional code.* - -### I. IPoolable.cs -```csharp -public interface IPoolable -{ - void OnSpawn(); - void OnDespawn(); -} \ No newline at end of file diff --git a/Assets/AddressablesPoolingBlueprint.md.meta b/Assets/AddressablesPoolingBlueprint.md.meta deleted file mode 100644 index 0e3544419c..0000000000 --- a/Assets/AddressablesPoolingBlueprint.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 6babe48248f2d2e4486f19a1908cc004 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/PerfectWorld/Scripts/Managers/EC_ManMatter.cs b/Assets/PerfectWorld/Scripts/Managers/EC_ManMatter.cs index 6860f41640..aff1af0f7e 100644 --- a/Assets/PerfectWorld/Scripts/Managers/EC_ManMatter.cs +++ b/Assets/PerfectWorld/Scripts/Managers/EC_ManMatter.cs @@ -1,5 +1,6 @@ using BrewMonster; using BrewMonster.Network; +using BrewMonster.Scripts; using BrewMonster.Scripts.World; using CSNetwork; using CSNetwork.GPDataType; @@ -260,7 +261,8 @@ namespace PerfectWorld.Scripts.Managers CECMatter pMatter = GetMatter(mid); if (pMatter != null) { - UnityEngine.Object.Destroy(pMatter.gameObject); + //UnityEngine.Object.Destroy(pMatter.gameObject); + PoolManager.Instance.Despawn(pMatter.gameObject); m_MatterTab.Remove(mid); } //TODO: Might need to implement later @@ -345,4 +347,4 @@ namespace PerfectWorld.Scripts.Managers return null; } } -} \ No newline at end of file +} diff --git a/Assets/PerfectWorld/Scripts/Objet/CECMatter.cs b/Assets/PerfectWorld/Scripts/Objet/CECMatter.cs index b7b313e319..dbdb6bf855 100644 --- a/Assets/PerfectWorld/Scripts/Objet/CECMatter.cs +++ b/Assets/PerfectWorld/Scripts/Objet/CECMatter.cs @@ -161,10 +161,11 @@ namespace PerfectWorld.Scripts var fileMatterValue = fileMatterField.GetValue(matterData); string filePath = ByteToStringUtils.ByteArrayToCP936String((byte[])fileMatterValue); - var matterPrefab = await AddressableManager.Instance.LoadPrefabAsync(AFile.NormalizePath(filePath.ToLower(), true)); - if (matterPrefab != null) + //var matterPrefab = await AddressableManager.Instance.LoadPrefabAsync(AFile.NormalizePath(filePath.ToLower(), true)); + var matterObject = await PoolManager.Instance.SpawnAsync(AFile.NormalizePath(filePath.ToLower(), true), Vector3.zero, Quaternion.identity, 15f); + if (matterObject != null) { - var matterObject = Instantiate(matterPrefab); + //var matterObject = Instantiate(matterPrefab); matterObject.name = $"Matter {matterObject.name} {matterInfo.tid} {matterInfo.mid}"; matterObject.transform.position = new Vector3(Info.pos.x, Info.pos.y, Info.pos.z); matterObject.transform.localScale = new Vector3(1f, 1f, 1f); diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/IPoolable.cs.meta b/Assets/PerfectWorld/Scripts/PoolingManager/IPoolable.cs.meta index c9dbfb1b97..0c0f5b2b61 100644 --- a/Assets/PerfectWorld/Scripts/PoolingManager/IPoolable.cs.meta +++ b/Assets/PerfectWorld/Scripts/PoolingManager/IPoolable.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: 7c09b2b8b565f2946bb4e9f09e2966af \ No newline at end of file +guid: 7c09b2b8b565f2946bb4e9f09e2966af diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs b/Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs index 20ba26d011..2423b5c286 100644 --- a/Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs +++ b/Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs @@ -2,8 +2,6 @@ using System.Collections; using System.Collections.Generic; using System.Threading.Tasks; using UnityEngine; -using UnityEngine.AddressableAssets; -using UnityEngine.ResourceManagement.AsyncOperations; namespace BrewMonster.Scripts { @@ -11,22 +9,28 @@ namespace BrewMonster.Scripts { private readonly string _addressableKey; private readonly PoolManager _owner; + private readonly AddressableManager _addressableManager; private readonly Transform _poolRoot; private readonly Stack _idleInstances = new(); private readonly HashSet _activeInstances = new(); private readonly HashSet _knownInstances = new(); private readonly Dictionary _spawnVersions = new(); - private AsyncOperationHandle _prefabHandle; private GameObject _prefab; private Task _loadTask; private Coroutine _releaseCoroutine; private float _memoryReleaseTTL; - public ObjectPool(string addressableKey, PoolManager owner, Transform poolRoot, float memoryReleaseTTL) + public ObjectPool( + string addressableKey, + PoolManager owner, + AddressableManager addressableManager, + Transform poolRoot, + float memoryReleaseTTL) { _addressableKey = addressableKey; _owner = owner; + _addressableManager = addressableManager; _poolRoot = poolRoot; _memoryReleaseTTL = Mathf.Max(0f, memoryReleaseTTL); } @@ -111,7 +115,7 @@ namespace BrewMonster.Scripts { CancelReleaseCountdown(); DestroyAllInstances(); - ReleasePrefabHandle(); + ReleasePrefabAsset(); } private GameObject GetIdleInstance() @@ -152,17 +156,22 @@ namespace BrewMonster.Scripts private async Task LoadPrefabInternalAsync() { - _prefabHandle = Addressables.LoadAssetAsync(_addressableKey); - await _prefabHandle.Task; - - if (_prefabHandle.Status != AsyncOperationStatus.Succeeded || _prefabHandle.Result == null) + if (_addressableManager == null) { - Debug.LogError($"ObjectPool: Failed to load Addressable prefab '{_addressableKey}'."); - ReleasePrefabHandle(); + BMLogger.LogError($"ObjectPool: AddressableManager is not available for '{_addressableKey}'."); return null; } - _prefab = _prefabHandle.Result; + await _addressableManager.WaitUntilInitializedAsync(); + + GameObject prefab = await _addressableManager.LoadPrefabAsync(_addressableKey); + if (prefab == null) + { + BMLogger.LogError($"ObjectPool: Failed to load Addressable prefab '{_addressableKey}'."); + return null; + } + + _prefab = prefab; return _prefab; } @@ -187,7 +196,7 @@ namespace BrewMonster.Scripts { if (_memoryReleaseTTL > 0f) { - yield return new WaitForSeconds(_memoryReleaseTTL); + yield return new WaitForSecondsRealtime(_memoryReleaseTTL); } _releaseCoroutine = null; @@ -198,22 +207,24 @@ namespace BrewMonster.Scripts _owner.RemovePool(this); DestroyAllInstances(); - ReleasePrefabHandle(); + ReleasePrefabAsset(); } private void DestroyAllInstances() { foreach (GameObject instance in _knownInstances) { - if (instance != null) + if (instance == null) { - if (_activeInstances.Contains(instance)) - { - NotifyPoolablesDespawned(instance); - } - - Object.Destroy(instance); + continue; } + + if (_activeInstances.Contains(instance)) + { + NotifyPoolablesDespawned(instance); + } + + Object.Destroy(instance); } _idleInstances.Clear(); @@ -227,14 +238,13 @@ namespace BrewMonster.Scripts } } - private void ReleasePrefabHandle() + private void ReleasePrefabAsset() { - if (_prefabHandle.IsValid()) + if (_prefab != null && _addressableManager != null) { - Addressables.Release(_prefabHandle); + _addressableManager.ReleaseAsset(_addressableKey); } - _prefabHandle = default; _prefab = null; _loadTask = null; } diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs.meta b/Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs.meta index 3054953278..691cec0bfa 100644 --- a/Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs.meta +++ b/Assets/PerfectWorld/Scripts/PoolingManager/ObjectPool.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: 8fc34059fd203384d9c5aa022301e68f \ No newline at end of file +guid: 8fc34059fd203384d9c5aa022301e68f diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs b/Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs index 5b2646be62..4ff5714461 100644 --- a/Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs +++ b/Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs @@ -10,11 +10,17 @@ namespace BrewMonster.Scripts { private readonly Dictionary _pools = new(); private readonly Dictionary _instanceToPool = new(); + + private AddressableManager _addressableManager; private Transform _poolContainer; protected override void Initialize() { base.Initialize(); + + _addressableManager = AddressableManager.Instance; + _addressableManager.OnDispose += ReleaseAllPools; + _poolContainer = new GameObject("Addressables Object Pools").transform; _poolContainer.SetParent(transform, false); } @@ -32,7 +38,7 @@ namespace BrewMonster.Scripts { if (string.IsNullOrEmpty(addressableKey)) { - Debug.LogError("PoolManager: Cannot spawn with a null or empty Addressables key."); + BMLogger.LogError("PoolManager: Cannot spawn with a null or empty Addressables key."); return null; } @@ -168,7 +174,7 @@ namespace BrewMonster.Scripts if (spawnTask.Exception != null) { - Debug.LogException(spawnTask.Exception); + BMLogger.LogError($"PoolManager: Spawn failed for '{addressableKey}': {spawnTask.Exception}"); onComplete?.Invoke(null); yield break; } @@ -196,7 +202,7 @@ namespace BrewMonster.Scripts Transform poolRoot = new GameObject(GetPoolRootName(addressableKey)).transform; poolRoot.SetParent(_poolContainer, false); - pool = new ObjectPool(addressableKey, this, poolRoot, memoryReleaseTTL); + pool = new ObjectPool(addressableKey, this, _addressableManager, poolRoot, memoryReleaseTTL); _pools[addressableKey] = pool; return pool; } @@ -226,6 +232,11 @@ namespace BrewMonster.Scripts protected override void OnDestroy() { + if (_addressableManager != null) + { + _addressableManager.OnDispose -= ReleaseAllPools; + } + ReleaseAllPools(); base.OnDestroy(); } diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs.meta b/Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs.meta index f4c76c06ac..b215665d99 100644 --- a/Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs.meta +++ b/Assets/PerfectWorld/Scripts/PoolingManager/PoolManager.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: b8e790a183861dd40bd580421c793038 \ No newline at end of file +guid: b8e790a183861dd40bd580421c793038 diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/poolingManager.md.meta b/Assets/PerfectWorld/Scripts/PoolingManager/poolingManager.md.meta deleted file mode 100644 index 1a41a199c1..0000000000 --- a/Assets/PerfectWorld/Scripts/PoolingManager/poolingManager.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 191a414af7b325d459e32ac5254c011d -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Documentation/addressable-manager.md b/Documentation/addressable-manager.md new file mode 100644 index 0000000000..3e331550f4 --- /dev/null +++ b/Documentation/addressable-manager.md @@ -0,0 +1,201 @@ +# AddressableManager Public API + +`AddressableManager` is a Unity `MonoSingleton` wrapper around Unity Addressables. It initializes the Addressables system, loads selected asset types, caches loaded handles, and exposes methods for delayed or immediate release. + +This document lists only the public functions from `Assets/PerfectWorld/Scripts/Addressable/AddressableManager.cs`. + +## `bool IsInitialized()` + +Returns whether the Addressables initialization callback has completed successfully. + +Use this when another system needs to quickly check if `AddressableManager` is ready before requesting assets. It returns `true` only after `Addressables.InitializeAsync()` finishes with `AsyncOperationStatus.Succeeded`. + +If initialization fails, this method remains `false`. + +## `UniTask WaitUntilInitializedAsync()` + +Waits until `AddressableManager` has finished initializing Unity Addressables. + +If initialization has already completed, this returns `UniTask.CompletedTask` immediately. Otherwise, it waits on the internal initialization completion source that is resolved by the Addressables initialization callback. + +Call this before loading assets from systems that may start before Addressables is ready. + +Example usage: + +```csharp +await AddressableManager.Instance.WaitUntilInitializedAsync(); +``` + +## `Task LoadTextAssetAsync(string assetPath)` + +Loads a `TextAsset` asynchronously through Unity Addressables. + +`assetPath` must match a valid Addressables key, normally the asset address. The method first increases the internal reference count for this path and removes the path from the delayed-release list so the asset will not be released while it is being used again. + +If the text asset was already loaded and the cached handle is still valid, the method returns the cached `TextAsset` immediately. If no valid cached handle exists, it calls: + +```csharp +Addressables.LoadAssetAsync(assetPath) +``` + +After loading completes, the handle is stored in the text asset cache and the loaded `TextAsset` is returned. + +If loading throws an exception, the method logs an error and returns `null`. + +Important behavior: + +- The loaded asset is cached by `assetPath`. +- Repeated calls for the same valid path reuse the cached handle. +- The method may return `null` when the asset cannot be loaded. +- Release is not automatic after loading. Call a release method when the asset is no longer needed. + +## `Task LoadPrefabAsync(string assetPath)` + +Loads a prefab asynchronously through Unity Addressables and returns it as a `GameObject`. + +`assetPath` must match a valid Addressables key for a `GameObject` prefab. Before loading, the method removes the path from the delayed-release list. It then calls `KeyExists(assetPath, typeof(GameObject))`; if the key is considered missing, it logs a warning and returns `null`. + +If the prefab is already cached and the cached handle is valid, the method returns the cached prefab immediately. Otherwise, it calls: + +```csharp +Addressables.LoadAssetAsync(assetPath) +``` + +After loading completes successfully, the handle is stored in the prefab cache and the prefab `GameObject` is returned. + +If the Addressables operation reports an exception, or if an exception is thrown while loading, the method logs the failure and returns `null`. In the Unity Editor, failed paths are added to an invalid-path cache so later `KeyExists` checks can reject them quickly. + +Important behavior: + +- This loads the prefab asset itself, not an instantiated scene object. +- Callers that need an instance should instantiate the returned prefab separately. +- The loaded prefab handle is cached by `assetPath`. +- The method may return `null` when the key does not exist or loading fails. + +## `Task LoadAudioClipAsync(string assetPath)` + +Loads an `AudioClip` asynchronously through Unity Addressables. + +`assetPath` must match a valid Addressables key for an audio asset. The method removes the path from the delayed-release list before checking the cache. + +If the audio clip is already cached and the cached handle is valid, the method returns the cached `AudioClip` immediately. Otherwise, it calls: + +```csharp +Addressables.LoadAssetAsync(assetPath.Trim()) +``` + +After loading completes successfully, the handle is stored in the audio cache and the loaded `AudioClip` is returned. + +If the Addressables operation reports an exception, or if an exception is thrown while loading, the method logs the failure and returns `null`. + +Important behavior: + +- The Addressables load uses `assetPath.Trim()`. +- The cache key is the original `assetPath` string. +- Repeated calls for the same cached path can return immediately. +- The method may return `null` when loading fails. + +## `bool TryGetCachedAudioClip(string assetPath, out AudioClip clip)` + +Attempts to retrieve an already loaded `AudioClip` from the internal audio cache without starting a new Addressables load. + +If `assetPath` is null or empty, the method returns `false` and sets `clip` to `null`. + +If the audio cache contains a valid handle for `assetPath` and the handle has a non-null result, the method assigns that cached `AudioClip` to `clip` and returns `true`. + +If no valid cached audio clip exists, the method returns `false` and leaves `clip` as `null`. + +Use this when a caller wants to play audio immediately if it is already loaded, while avoiding an async load path. + +## `void ReleaseAsset(string assetPath)` + +Marks an asset path for delayed release. + +This method does not immediately call `Addressables.Release`. Instead, it records a timestamp in the delayed-release dictionary. During `Update`, once the configured timeout has elapsed, the manager force releases the asset by path. + +If the same path is released multiple times before it is force released, the earliest release timestamp is kept. + +Important behavior: + +- This is a delayed release request. +- The asset remains cached until the release timeout expires. +- If the asset is loaded again before timeout, load methods remove it from the delayed-release list. +- The path must match the same key used to cache the asset. + +## `void ForceReleaseAsset(string assetPath)` + +Immediately releases a cached asset by path. + +The method checks the prefab cache first, then the text asset cache, then the audio cache. If the path exists in one of those caches and the stored handle is valid, it calls: + +```csharp +Addressables.Release(handle) +``` + +After releasing, the method removes the path from the matching cache. For audio assets, it also logs that the audio asset was force released. + +Important behavior: + +- This releases immediately, unlike `ReleaseAsset(string assetPath)`. +- Only one matching cache entry is released because the method uses an `if / else if` chain. +- If the path is not found in any cache, the method does nothing. + +## `void ReleaseAsset(AsyncOperationHandle handle)` + +Releases a specific `GameObject` Addressables operation handle directly. + +If the provided handle is valid, the method calls: + +```csharp +Addressables.Release(handle) +``` + +This overload does not remove entries from `AddressableManager`'s internal prefab cache because it does not know which `assetPath` the handle belongs to. Prefer releasing by `assetPath` when the asset was loaded through `AddressableManager` and should also be removed from its cache. + +## `void ReleaseAllAssets()` + +Immediately releases all cached prefab and audio assets. + +The method iterates through the prefab cache and releases each valid prefab handle. It then clears the prefab cache. After that, it iterates through the audio cache, releases each valid audio handle, and clears the audio cache. + +Important behavior: + +- This releases prefab and audio caches. +- It does not currently release cached text assets from `_loadedTextAssets`. +- It logs `"AddressableManager: Released all assets"` after completing. +- `OnDestroy` calls this method automatically. + +## `bool IsAssetLoaded(string assetPath)` + +Checks whether a prefab asset path is currently loaded in the prefab cache. + +The method returns `true` only when `_loadedPrefabAssets` contains `assetPath` and the stored handle is valid. + +Important behavior: + +- This checks only loaded prefab assets. +- It does not check cached text assets. +- It does not check cached audio assets. + +## `static bool KeyExists(object key, System.Type type = null)` + +Checks whether an Addressables key exists. + +In non-editor builds, the method iterates through all loaded Addressables resource locators and calls: + +```csharp +locator.Locate(key, type, out var locations) +``` + +It returns `true` when a locator finds at least one matching resource location. If no locator finds a match, it returns `false`. + +The optional `type` parameter can restrict the lookup to a specific asset type, such as `typeof(GameObject)`. + +In the Unity Editor, this method currently returns `true` unless the key string exists in the manager's `_invalidAssetPaths` set. That means editor behavior is based on paths that previously failed to load, not a full catalog lookup. + +Important behavior: + +- Runtime builds perform a locator-based catalog lookup. +- Editor builds use the invalid-path cache. +- `LoadPrefabAsync` uses this method before attempting to load a prefab. +- Passing a type helps avoid matching an address that exists for a different asset kind. diff --git a/Assets/PerfectWorld/Scripts/PoolingManager/poolingManager.md b/Documentation/poolingManager.md similarity index 71% rename from Assets/PerfectWorld/Scripts/PoolingManager/poolingManager.md rename to Documentation/poolingManager.md index c3297c23ae..a586763d7e 100644 --- a/Assets/PerfectWorld/Scripts/PoolingManager/poolingManager.md +++ b/Documentation/poolingManager.md @@ -4,11 +4,13 @@ The pooling system recycles Addressables-backed `GameObject` instances instead of repeatedly calling `Instantiate` and `Destroy`. Each Addressables key owns one runtime pool that tracks active objects, idle objects, and a memory-release timer. +`PoolManager` loads prefab assets through `AddressableManager`, so it uses the same Addressables initialization path and loaded-prefab cache as the rest of the project. Runtime instances are ordinary instantiated `GameObject`s owned by the pool. + ## Runtime Files - `IPoolable.cs`: prefab-side lifecycle interface. -- `ObjectPool.cs`: owns instances and the loaded Addressables prefab handle for one key. -- `PoolManager.cs`: singleton service used by gameplay, server packet handlers, and UI code. +- `ObjectPool.cs`: owns instances and the loaded prefab reference for one Addressables key. +- `PoolManager.cs`: singleton service used by gameplay, server packet handlers, UI code, and effects systems. ## Spawn Flow @@ -17,9 +19,9 @@ The pooling system recycles Addressables-backed `GameObject` instances instead o 3. The pool updates its `memoryReleaseTTL` from the spawn parameter. 4. If a memory release countdown is running, the pool cancels it. 5. The pool reuses an idle object when available. -6. If no idle object exists, the pool loads the prefab with `Addressables.LoadAssetAsync()` and instantiates it. +6. If no idle object exists, the pool waits for `AddressableManager` initialization, loads the prefab with `AddressableManager.LoadPrefabAsync()`, then instantiates it. 7. The instance is parented, positioned, rotated, activated, and all `IPoolable.OnSpawn()` hooks are called. -8. If `autoDespawnTime > 0`, `PoolManager` starts a version-checked auto despawn coroutine. +8. If `autoDespawnTime > 0`, `PoolManager` starts a version-checked auto-despawn coroutine. 9. The spawned `GameObject` is returned to the caller. ## Despawn Flow @@ -33,17 +35,21 @@ The pooling system recycles Addressables-backed `GameObject` instances instead o ## Memory Release Flow 1. The countdown waits for the latest `memoryReleaseTTL` value supplied by spawn calls for that key. -2. A new spawn for the same key cancels the countdown and keeps the prefab handle in memory. +2. A new spawn for the same key cancels the countdown and keeps the prefab plus idle instances available. 3. If the countdown completes while active count is still zero, the pool: - unregisters itself from `PoolManager`; - destroys all pooled instances; - - releases the Addressables prefab handle with `Addressables.Release()`; + - calls `AddressableManager.ForceReleaseAsset(addressableKey)` to release the cached prefab asset; - destroys the pool root object. ## Auto Despawn Safety Auto despawn stores the instance spawn version when the coroutine starts. If the object is manually despawned and reused before the timer completes, the version changes and the old coroutine will not despawn the new lifecycle. +## Shutdown Flow + +`PoolManager` subscribes to `AddressableManager.OnDispose`. When the Addressables manager is disposed, all pools release their instances and prefab assets before `AddressableManager.ReleaseAllAssets()` runs. `PoolManager.OnDestroy()` also releases all pools as a fallback. + ## Example ```csharp