Skip to content

t1k:cocos:playable:object-pool

FieldValue
Moduleplayable
Version2.14.4
Efforthigh
Tools—

Keywords: object pool, pooling, prefab, spawn

/t1k:cocos:playable:object-pool

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.

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 calls Reset() on all components that implement it
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 membership
pool.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 node
pool.UnloadAll(); // destroy all pools
// --- Monitoring ---
const info = pool.GetPoolInfo("Effects/Coin");
// { available: 8, active: 2, total: 10 }
pool.HasPool("Effects/Coin"); // boolean
pool.GetAllPoolKeys(); // string[]

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);
}
}

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.

  • Calling Spawn() before Load()/LoadAsync() — returns null with a console warning
  • Destroying a pooled node manually instead of calling Recycle() — leaves dangling entry in spawnedObjects
  • 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 that update() mid-flight — the node never reaches Recycle(), so it stays in spawnedObjects (hidden, frozen) and resumes from a stale position on the next replay. The owning controller MUST drain live objects in its onDisable():
onDisable() {
// ... unsubscribe / unschedule ...
ObjectPoolManager.instance.RecycleAll(POOL_KEY); // drains spawned (incl. inactive) nodes
}
  • A mesh assigned to a DISABLED MeshRenderer on a reused pooled node keeps drawing the previous life’s geometry. MeshRenderer._updateModels() returns early when the renderer is not enabledInHierarchy, and onEnable() 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 = newMesh while renderer.enabled === false (or while the node is inactive) updates the .mesh property and nothing else: the model still holds the OLD submeshes and the OLD bounds, while the new sharedMaterials DO 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.struct AABB maths, readAttribute, and every unit test agreed the dent was correct because they all read the property, not the model. Diagnose with renderer.model.modelBounds vs renderer.mesh.struct.minPosition/maxPosition. Fix: make the renderer enabled and its node active BEFORE assigning mesh (then disable it again if it is meant to stay hidden), or assign the mesh only on enabled renderers. ObstacleDebris already activates a chunk before chunk.renderer.mesh = … for this reason.
  • The auto recycle-reset hook is named Reset(), not onRecycle(). resetNode() calls Reset() on every component that implements it — a component naming its cleanup method onRecycle() (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 exactly Reset(), or call your cleanup explicitly before Recycle() — never rely on a differently-named hook being auto-invoked.
  • A pooled clone’s onLoad() is not guaranteed to have run by the time Spawn() returns. A pooled clone is pre-instantiated INACTIVE at Load() time, so its onLoad() has not fired yet; Spawn() activates it, but in Cocos 3.8 that activation is not guaranteed to run onLoad synchronously before Spawn() returns to the caller. A component that builds node structure (child nodes, colliders, graphics) in onLoad() and then mutates that structure in a same-call configure() right after Spawn() hits a first-spawn NRE — the structure configure() reaches for does not exist yet. Build structure lazily and idempotently instead: an ensureBuilt() guard called from BOTH onLoad and configure, not solely from onLoad:
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 for pool.spawnedObjects.has(node). A second Recycle() of the same node misses (the first call already deleted it from that set), so the manager treats it as foreign: it logs Node does not belong to any pool, destroying it and calls node.destroy() — while that same node is already sitting in pooledObjects. The pool now hands out destroyed nodes on the next Spawn(). Any recycleSelf()-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’s recycleSelf(), or drain its pool with RecycleAll(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) sets node.active = true and THEN reparents the node to the passed parent. Reparenting an already-active node fires onDisable → onEnable on it. Any component that arms itself in onEnable (binds node-local events, subscribes signals, registers) and whose setup must be followed by a separate configure()/init call will have its arming churned by that reparent cycle — so the follow-up configure() runs against a half-armed controller and the object silently fails to drive/animate. Real case: AttackingAnimalController._arm() binds TargetHealthEvent.DEATH in onEnable and _disarm() unbinds in onDisable; spawning a raider via the pool left it un-driven. Fix: for spawns that need configure() after activation, use the direct-instantiate pattern (mirrors AmbientFoxSpawner): instantiate once, node.active=false, parent once while inactive, then on spawn do setWorldPosition(anchor) → node.active = true → configure(...) on the already-parented node — never activate-then-reparent. See also t1k-cocos-playable-animation-core for animation controller lifecycle patterns.
  • A pooled body whose RigidBody is enabled BEFORE its Collider never joins the physics world. Bullet’s shared body refuses to join when it has no shapes and is not dynamic:
bullet-shared-body.ts
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; // ← silent

A 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 is PhysicsSystem.instance.raycastClosest(ray, 0xffffffff, …) — cast it at a scene object you never touched as a control, not only at the suspect.
  • Setting RigidBody.group does 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 — so getGroup() 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 (minVolumeSize is 1e-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.