Entity/object pooling for spawn/despawn (avoid per-spawn allocation churn) #80

Closed
opened 2026-09-02 04:52:40 +02:00 by orkun · 1 comment
Owner

The shooter projectile pool is internal, but there is no general spawn/despawn pooling for game entities. Bullet-hell / horde gameplay spawns and despawns many entities per frame; per-spawn allocation and world-table churn can cause hitches and fragmentation.

Proposal: engine-level entity pooling / freelist reuse for spawn/despawn (opt-in per model) so recycled entity slots avoid reallocation; expose pool stats. Motivated by a roguelite with many projectiles + enemies.

The shooter projectile pool is internal, but there is no general spawn/despawn pooling for game entities. Bullet-hell / horde gameplay spawns and despawns many entities per frame; per-spawn allocation and world-table churn can cause hitches and fragmentation. Proposal: engine-level entity pooling / freelist reuse for spawn/despawn (opt-in per model) so recycled entity slots avoid reallocation; expose pool stats. Motivated by a roguelite with many projectiles + enemies.
Author
Owner

Shipped in 347352c (full suite 118/0, goldens byte-identical, fixpoint intact).

Investigating this turned up good news: Ludic's ECS is already pool-based, so the churn/fragmentation this issue worried about doesn't actually happen:

  • Entity slots are recycled through a freelist — despawn pushes the slot onto @L_freen, and spawn (L_alloc) pops from the freelist before growing @L_entc. So a recycled slot costs no allocation and the high-water only rises when the freelist is empty.
  • Component storage is fixed per-entity arrays (@S_<Comp> indexed by entity id), not per-spawn malloc — so there's nothing to fragment.

That means bullet-hell / horde spawn+despawn already reuses slots with no per-spawn heap allocation. The real gap was visibility, so I added a Pool.* namespace (zero-cost inline reads of the allocator counters):

  • Pool.live() — entities alive now
  • Pool.free() — freed slots on the freelist waiting to be reused (the pool depth)
  • Pool.reserved() — high-water: slots ever allocated; stays flat across a steady spawn/despawn loop, which is the proof that slots are pooled, not reallocated
  • Pool.capacity() — the fixed entity cap to budget against

On 'opt-in per model': the freelist is global and automatic (any model reuses any freed slot), which is strictly better than per-model pools — no per-model configuration, and a despawned enemy's slot can be reused by a bullet. Verified by examples/library/pool.ludic: spawn 3, despawn 1 (free -> 1), respawn 1, and Pool.reserved() stays 3 — the freed slot was reused, not reallocated — prints 0 0 3 3 0 2 1 3 0 3 1. 4 docs pages document the pooling behaviour.

Shipped in 347352c (full suite 118/0, goldens byte-identical, fixpoint intact). Investigating this turned up good news: **Ludic's ECS is already pool-based**, so the churn/fragmentation this issue worried about doesn't actually happen: - **Entity slots are recycled through a freelist** — `despawn` pushes the slot onto @L_freen, and `spawn` (`L_alloc`) pops from the freelist *before* growing @L_entc. So a recycled slot costs no allocation and the high-water only rises when the freelist is empty. - **Component storage is fixed per-entity arrays** (`@S_<Comp>` indexed by entity id), not per-spawn `malloc` — so there's nothing to fragment. That means bullet-hell / horde spawn+despawn already reuses slots with no per-spawn heap allocation. The real gap was **visibility**, so I added a `Pool.*` namespace (zero-cost inline reads of the allocator counters): - `Pool.live()` — entities alive now - `Pool.free()` — freed slots on the freelist waiting to be reused (the pool depth) - `Pool.reserved()` — high-water: slots ever allocated; **stays flat across a steady spawn/despawn loop**, which is the proof that slots are pooled, not reallocated - `Pool.capacity()` — the fixed entity cap to budget against On 'opt-in per model': the freelist is global and automatic (any model reuses any freed slot), which is strictly better than per-model pools — no per-model configuration, and a despawned enemy's slot can be reused by a bullet. Verified by `examples/library/pool.ludic`: spawn 3, despawn 1 (free -> 1), respawn 1, and **Pool.reserved() stays 3** — the freed slot was reused, not reallocated — prints `0 0 3 3 0 2 1 3 0 3 1`. 4 docs pages document the pooling behaviour.
orkun closed this issue 2026-09-02 07:24:15 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: workshopsoft/ludic#80
No description provided.