t1k:cocos:playable:object-pool
| Field | Value |
|---|---|
| Module | playable |
| Version | 2.14.4 |
| Effort | high |
| Tools | — |
Keywords: object pool, pooling, prefab, spawn
How to invoke
Section titled “How to invoke”/t1k:cocos:playable:object-poolObjectPoolManager
Section titled “ObjectPoolManager”Static singleton for reusing Node instances. Avoids runtime instantiate()/destroy() overhead critical for playable ad performance. See t1k-cocos-playable-asset-management for AssetsManager used by LoadAsync.
Architecture
Section titled “Architecture”ObjectPools (scene root Node)└── Pool_Enemies (category Node, key="Enemies/Goblin") └── Goblin_Pool ├── [inactive nodes] ← pooledObjects[] └── [active nodes] ← spawnedObjects Set- Category extracted from key prefix:
"Enemies/Goblin"→ category"Enemies" - Keys without
/go into"Default"category resetNode()resets position/rotation/scale and callsReset()on all components that implement it
Key APIs
Section titled “Key APIs”const pool = ObjectPoolManager.instance;
// --- Initialization ---// From prefab reference (sync)pool.Load("Effects/Coin", coinPrefab, 20);pool.Load(prefabRef); // key = prefab.name, count = 10
// From resources/ folder (async, tries multiple path variants)await pool.LoadAsync("Effects/Coin", 20);
// --- Spawn ---const node = pool.Spawn("Effects/Coin");const node = pool.Spawn("Effects/Coin", position, rotation, parentNode);
// --- Recycle ---pool.Recycle(node); // auto-finds pool by node membershippool.Recycle("Effects/Coin", node); // explicit key (faster)
// --- Batch recycle ---pool.RecycleAll("Effects/Coin");
// --- Cleanup (trim idle objects, keep retainCount) ---pool.Cleanup("Effects/Coin", 5); // destroy all but 5 idle instances
// --- Teardown ---pool.Unload("Effects/Coin"); // destroy pool + container nodepool.UnloadAll(); // destroy all pools
// --- Monitoring ---const info = pool.GetPoolInfo("Effects/Coin");// { available: 8, active: 2, total: 10 }pool.HasPool("Effects/Coin"); // booleanpool.GetAllPoolKeys(); // string[]Reset() Callback
Section titled “Reset() Callback”Components on pooled nodes can implement Reset() to clean up state on recycle:
export class CoinFX extends Component { private _velocity: Vec3 = new Vec3();
public Reset(): void { // Called automatically by ObjectPoolManager.Recycle() this._velocity.set(0, 0, 0); this.node.setOpacity(255); }}Workflow: Common Tasks
Section titled “Workflow: Common Tasks”Worked snippets for one-time pool setup, async resources-folder load, recycle-on-animation-complete,
the FlyingAnimationController pattern, and the AudioService pooling pattern (don’t manually pool
audio nodes — it already does): references/common-task-examples.md.
Common Mistakes
Section titled “Common Mistakes”- Calling
Spawn()beforeLoad()/LoadAsync()— returnsnullwith a console warning - Destroying a pooled node manually instead of calling
Recycle()— leaves dangling entry inspawnedObjects - Using the same key string for different prefabs — pools are keyed by string, collisions corrupt the pool
- Not implementing
Reset()— node retains state (position, opacity, tween callbacks) from previous spawn - Calling
LoadAsync()twice for the same key concurrently — safe (second call skips if pool exists), but wasteful - Self-recycling pooled objects leak on owning-view teardown. If a pooled node recycles itself from its OWN
update()(e.g. “despawn when off-screen” / lifetime cap), deactivating the owning view stops thatupdate()mid-flight — the node never reachesRecycle(), so it stays inspawnedObjects(hidden, frozen) and resumes from a stale position on the next replay. The owning controller MUST drain live objects in itsonDisable():
onDisable() { // ... unsubscribe / unschedule ... ObjectPoolManager.instance.RecycleAll(POOL_KEY); // drains spawned (incl. inactive) nodes}Gotchas
Section titled “Gotchas”- A mesh assigned to a DISABLED
MeshRendereron a reused pooled node keeps drawing the previous life’s geometry.MeshRenderer._updateModels()returns early when the renderer is notenabledInHierarchy, andonEnable()only rebuilds the model when there is none yet. So on a pooled node whose renderer already carries a model from an earlier spawn,renderer.mesh = newMeshwhilerenderer.enabled === false(or while the node is inactive) updates the.meshproperty and nothing else: the model still holds the OLD submeshes and the OLD bounds, while the newsharedMaterialsDO land on it. Symptom (FairShot, 2026-09-03): a metal can’s staged dent renderer — parked disabled between lives — rendered a stone shard from the node’s previous life wearing the can’s texture, so tall cans “dented” into a tiny lump and small ones looked untouched;mesh.structAABB maths,readAttribute, and every unit test agreed the dent was correct because they all read the property, not the model. Diagnose withrenderer.model.modelBoundsvsrenderer.mesh.struct.minPosition/maxPosition. Fix: make the renderer enabled and its node active BEFORE assigningmesh(then disable it again if it is meant to stay hidden), or assign the mesh only on enabled renderers.ObstacleDebrisalready activates a chunk beforechunk.renderer.mesh = …for this reason. - The auto recycle-reset hook is named
Reset(), notonRecycle().resetNode()callsReset()on every component that implements it — a component naming its cleanup methodonRecycle()(a plausible, intuitive name) gets no automatic call at all, and silently keeps stale state across recycles. Real symptom: a rotating obstacle keeps spinning after being recycled and re-spawned, with no error anywhere. Name the hook exactlyReset(), or call your cleanup explicitly beforeRecycle()— never rely on a differently-named hook being auto-invoked. - A pooled clone’s
onLoad()is not guaranteed to have run by the timeSpawn()returns. A pooled clone is pre-instantiated INACTIVE atLoad()time, so itsonLoad()has not fired yet;Spawn()activates it, but in Cocos 3.8 that activation is not guaranteed to runonLoadsynchronously beforeSpawn()returns to the caller. A component that builds node structure (child nodes, colliders, graphics) inonLoad()and then mutates that structure in a same-callconfigure()right afterSpawn()hits a first-spawn NRE — the structureconfigure()reaches for does not exist yet. Build structure lazily and idempotently instead: anensureBuilt()guard called from BOTHonLoadandconfigure, not solely fromonLoad:
private _built = false;
private ensureBuilt(): void { if (this._built) return; this._built = true; // create child nodes / colliders / graphics here}
protected onLoad(): void { this.ensureBuilt();}
public configure(data: ObstacleData): void { this.ensureBuilt(); // covers the case onLoad hasn't run yet on first Spawn() // ... mutate structure using data ...}Recycle()on a node the pool no longer tracks DESTROYS it.Recycle(node)finds the owning pool by scanning forpool.spawnedObjects.has(node). A secondRecycle()of the same node misses (the first call already deleted it from that set), so the manager treats it as foreign: it logsNode does not belong to any pool, destroying itand callsnode.destroy()— while that same node is already sitting inpooledObjects. The pool now hands out destroyed nodes on the nextSpawn(). AnyrecycleSelf()-style method on a pooled view must be idempotent:
private _recycled = false; // reset in EVERY configure*/spawn entry point
public recycleSelf(): void { if (this._recycled) return; this._recycled = true; ObjectPoolManager.instance.Recycle(this.node);}This bites whenever gameplay recycles a view early (collect, despawn) while a placement service still holds it in a _spawned[] list that a later teardown iterates. Symptom: level 1 is fine, level 2+ spawns fewer objects than the data declares, with no error.
- Nodes spawned into a SHARED layer are not reclaimed by a service’s
recycleAll(). A service that tracks only the objects it spawned will not free their side-effect nodes (e.g. a checkmark parented to a shared overlay, not to the object). Either recycle the side-effect node from the owner’srecycleSelf(), or drain its pool withRecycleAll(SIDE_EFFECT_KEY)in teardown. Auditing this is cheap; the leak is invisible until a shorter next level leaves stale nodes on screen. - Pool prewarm must happen before first acquire — first acquire on cold pool stutters; prewarm in
onLoad. - Returned objects need state reset — pooling a node with active tweens or active children leaks residual state into the next user.
- Pool size cap is mandatory — uncapped pools grow until OOM on long playable sessions.
Spawn()activates then reparents — breaks lifecycle-armed controllers —Spawn(key, pos, rot, parent)setsnode.active = trueand THEN reparents the node to the passedparent. Reparenting an already-active node firesonDisable → onEnableon it. Any component that arms itself inonEnable(binds node-local events, subscribes signals, registers) and whose setup must be followed by a separateconfigure()/init call will have its arming churned by that reparent cycle — so the follow-upconfigure()runs against a half-armed controller and the object silently fails to drive/animate. Real case:AttackingAnimalController._arm()bindsTargetHealthEvent.DEATHinonEnableand_disarm()unbinds inonDisable; spawning a raider via the pool left it un-driven. Fix: for spawns that needconfigure()after activation, use the direct-instantiate pattern (mirrorsAmbientFoxSpawner): instantiate once,node.active=false, parent once while inactive, then on spawn dosetWorldPosition(anchor) → node.active = true → configure(...)on the already-parented node — never activate-then-reparent. See alsot1k-cocos-playable-animation-corefor animation controller lifecycle patterns.- A pooled body whose
RigidBodyis enabled BEFORE itsCollidernever joins the physics world. Bullet’s shared body refuses to join when it has no shapes and is not dynamic:
set bodyEnabled (v) { if (v) { if (this.bodyIndex < 0) { if (this.bodyStruct.wrappedShapes.length === 0) { if (!this.wrappedBody) return; if (!this.wrappedBody.rigidBody.isDynamic) return; // ← silentA STATIC pooled body hits that return, and nothing re-triggers it when the colliders come up afterwards. What actually admits the body is addShape, whose last line is this.bodyEnabled = true — by then there IS a shape, so the guard passes. Enable colliders first, rigid bodies second; reverse it on the way down, because isRemoveBody requires the rigid body to be gone before it will pull the body back out. More generally: prefer leaving a re-spawned object’s activation and component state exactly as the first spawn leaves it over suspending and resuming it. Every deviation below was found the hard way, one per fix, while making a rebuilt scene behave like a freshly-booted one.
- Component-side reads prove NOTHING about world membership — only a raycast does.
RigidBody.getGroup()/getMask()return JS-side fields on the shared body, so a body that never reached the physics world still prints a correct group, mask,enabled,enabledInHierarchy, world scale and world position. Everything looks healthy while shots pass straight through, and static bodies do not even fall, so nothing moves to give it away. When collisions “stop working”, the only measurement that separates shape missing from the world from filter wrong isPhysicsSystem.instance.raycastClosest(ray, 0xffffffff, …)— cast it at a scene object you never touched as a control, not only at the suspect. - Setting
RigidBody.groupdoes not recalculate the mask. Cocos snapshots the mask when it creates the backend body, and a pooled node is activated in group DEFAULT before your code names its real group — sogetGroup()reports the right group while the body filters against DEFAULT’s mask. Push the matrix mask yourself one frame later:PhysicsSystem.instance.collisionMatrix[body.group], keyed by group value (1,2,4,8), not by the index shown in the editor. It must be a frame later because there is no backend body on the activation frame and writes made then are dropped silently — and it must run from an ACTIVE node, since a component on an inactive node never runs its scheduler at all. - Never scale a pooled collider’s parent to exactly 0. Cocos sizes a collider through
minVolumeSize / worldScale(minVolumeSizeis1e-5), so a world scale of 0 divides by zero and hands Bullet an infinite half-extent. Use a small epsilon (≈0.02) as the collapsed value in any grow/shrink transition.