Skip to main content

Cómo Usar Object Pooling

Este tutorial fue escrito en febrero de 2026, para la v2 del motor.

Los juegos que generan muchas cosas de vida corta — balas, partículas, explosiones, enemigos — comparten el mismo problema. En cada frame estás creando objetos nuevos y descartando los viejos. JavaScript maneja esto bien a pequeña escala, pero el patrón tiene un costo: cada new u objeto literal reserva memoria, y el recolector de basura tiene que recuperarla eventualmente. A escala, eso significa picos impredecibles en el tiempo de frame cuando el GC decide ejecutarse.

Object pooling soluciona esto invirtiendo el enfoque. En lugar de crear y destruir, pre-asignamos un conjunto fijo de objetos desde el principio y los reciclamos. ¿Necesitas una partícula? Toma una inactiva del pool. ¿La partícula muere? Márcala como inactiva en vez de eliminarla. Sin asignaciones, sin basura, sin sorpresas.

Vamos a construir una fuente de partículas para aprender el patrón — un emisor que rebota por la pantalla lanzando partículas constantemente. Sin mecánicas de juego, sin input, solo muchos objetos de vida corta que necesitan gestionarse eficientemente.

Una Fuente de Partículas

Esta es la versión ingenua. Un emisor rebota por la pantalla, y en cada frame empujamos un montón de objetos partícula nuevos a un array. Cada partícula tiene posición, velocidad, tiempo de vida y color. Cuando la vida de una partícula llega a cero, la filtramos:

engine.scope((scope) => {
    const { start, cls, circfill, text, rnd, randomIntegerBetween } = scope;

    let particles = [];
    let emitterX = 64;
    let emitterY = 64;
    let emitterVx = 1.5;
    let emitterVy = 1.2;

    function update() {
        emitterX += emitterVx;
        emitterY += emitterVy;
        if (emitterX < 0 || emitterX > 128) emitterVx *= -1;
        if (emitterY < 0 || emitterY > 128) emitterVy *= -1;

        for (let i = 0; i < 80; i++) {
            particles.push({
                x: emitterX,
                y: emitterY,
                vx: (rnd(200) - 100) / 100,
                vy: (rnd(200) - 100) / 100,
                life: 60 + rnd(90),
                color: randomIntegerBetween(8, 14),
            });
        }

        for (let i = 0; i < particles.length; i++) {
            particles[i].x += particles[i].vx;
            particles[i].y += particles[i].vy;
            particles[i].life--;
        }

        particles = particles.filter((p) => p.life > 0);
    }

    function draw() {
        cls(0);
        for (let i = 0; i < particles.length; i++) {
            circfill(particles[i].x, particles[i].y, 1, particles[i].color);
        }
        text('FPS: ' + scope.currentFps, 2, 2, 7);
        text('PARTICLES: ' + particles.length, 2, 10, 7);
    }

    start({ sprites: {}, sounds: {}, update, draw, target });
});

Esto funciona. Las partículas se ven bien y el código es limpio. Pero hay tres cosas ocurriendo en cada frame que se acumulan a escala.

Dónde Se Va el Tiempo

Tres costos se acumulan en el enfoque ingenuo:

Asignación. Cada frame crea 80 objetos literales nuevos. Cada uno necesita memoria del runtime. A 60 FPS son 4,800 objetos por segundo — cada uno con seis propiedades que configurar.

Filtrado. particles.filter(p => p.life > 0) construye un array completamente nuevo en cada frame. Si hay 5,000 partículas activas, filter recorre las 5,000, copia las sobrevivientes en un array nuevo, y el viejo se convierte en basura. Una copia completa, cada frame.

Recolección de basura. Todas esas partículas muertas y arrays descartados se acumulan. El recolector de basura de JavaScript los recupera eventualmente, pero se ejecuta según su propio calendario. Cuando se activa, puede pausar la ejecución por unos milisegundos — suficiente para que un frame tartamudee. No verás una caída constante de FPS. Verás tirones periódicos que se sienten como lag.

A escala de fuente de partículas esto probablemente no importa mucho. Pero un juego estilo survivors con cientos de balas, enemigos, números de daño y efectos de partículas? Cada uno de esos objetos sigue el mismo ciclo de crear-usar-destruir. La presión del GC se acumula entre todos, y los tirones empeoran.

Pre-asignación con un Pool

La solución es asignar todos los objetos una vez, desde el principio, y reutilizarlos. Esta es la estructura de datos — un array plano donde cada entrada tiene un flag active:

const MAX_PARTICLES = 8000;
let pool = [];
for (let i = 0; i < MAX_PARTICLES; i++) {
    pool.push({
        active: false,
        x: 0,
        y: 0,
        vx: 0,
        vy: 0,
        life: 0,
        color: 0,
    });
}

Las 8,000 partículas existen desde el inicio. Ninguna está activa — solo están en memoria esperando ser usadas.

Para generar una partícula, buscamos la primera entrada inactiva y la marcamos como activa:

let activeCount = 0;

function acquire() {
    for (let i = 0; i < pool.length; i++) {
        if (!pool[i].active) {
            pool[i].active = true;
            activeCount++;
            return pool[i];
        }
    }
    return null;
}

acquire devuelve una referencia al objeto. Configuramos sus propiedades — posición, velocidad, vida, color — y está listo. Si el pool está lleno devuelve null y nos saltamos ese spawn. No hubo asignación de memoria.

Cuando una partícula muere, la liberamos:

function release(p) {
    p.active = false;
    activeCount--;
}

Eso es todo. active = false hace que el slot esté disponible para la siguiente llamada a acquire. Sin eliminación, sin basura, sin arrays nuevos.

El patrón de iteración también cambia. En lugar de filtrar, recorremos todo el pool y nos saltamos las entradas inactivas:

for (let i = 0; i < pool.length; i++) {
    if (!pool[i].active) continue;
    // actualizar esta partícula...
}

Esto recorre más entradas que el array ingenuo (el pool incluye slots inactivos), pero nunca asigna ni copia nada. Vale la pena — un bucle predecible sobre memoria pre-asignada le gana a pausas impredecibles del GC siempre.

Reciclando Partículas

Apliquemos el pool a nuestra fuente de partículas. Mismo visual, mismo emisor, misma cantidad de partículas — pero sin push, sin filter, sin new:

engine.scope((scope) => {
    const { start, cls, circfill, text, rnd, randomIntegerBetween } = scope;

    const MAX_PARTICLES = 8000;
    let pool = [];
    for (let i = 0; i < MAX_PARTICLES; i++) {
        pool.push({
            active: false,
            x: 0,
            y: 0,
            vx: 0,
            vy: 0,
            life: 0,
            color: 0,
        });
    }

    let activeCount = 0;
    let emitterX = 64;
    let emitterY = 64;
    let emitterVx = 1.5;
    let emitterVy = 1.2;

    function acquire() {
        for (let i = 0; i < pool.length; i++) {
            if (!pool[i].active) {
                pool[i].active = true;
                activeCount++;
                return pool[i];
            }
        }
        return null;
    }

    function release(p) {
        p.active = false;
        activeCount--;
    }

    function update() {
        emitterX += emitterVx;
        emitterY += emitterVy;
        if (emitterX < 0 || emitterX > 128) emitterVx *= -1;
        if (emitterY < 0 || emitterY > 128) emitterVy *= -1;

        for (let i = 0; i < 80; i++) {
            let p = acquire();
            if (!p) break;
            p.x = emitterX;
            p.y = emitterY;
            p.vx = (rnd(200) - 100) / 100;
            p.vy = (rnd(200) - 100) / 100;
            p.life = 60 + rnd(90);
            p.color = randomIntegerBetween(8, 14);
        }

        for (let i = 0; i < pool.length; i++) {
            if (!pool[i].active) continue;
            pool[i].x += pool[i].vx;
            pool[i].y += pool[i].vy;
            pool[i].life--;
            if (pool[i].life <= 0) release(pool[i]);
        }
    }

    function draw() {
        cls(0);
        for (let i = 0; i < pool.length; i++) {
            if (!pool[i].active) continue;
            circfill(pool[i].x, pool[i].y, 1, pool[i].color);
        }
        text('FPS: ' + scope.currentFps, 2, 2, 7);
        text('PARTICLES: ' + activeCount, 2, 10, 7);
    }

    start({ sprites: {}, sounds: {}, update, draw, target });
});
Object Pooling: With Pooling
Una fuente de partículas corriendo sobre un pool pre-asignado

El bucle de generación llama a acquire() en lugar de empujar un objeto nuevo. Si el pool se agota, sale del bucle — sin crash, solo menos partículas ese frame. El bucle de actualización recorre todo el pool, se salta las entradas inactivas, y llama a release() en vez de depender de filter().

Después de la asignación inicial, este código crea cero objetos nuevos. La cantidad de partículas se mantiene alta, los FPS se mantienen estables, y el recolector de basura no tiene nada que hacer.

Ejemplo Completo

Esta es la versión pulida. La fuente sigue funcionando, pero ahora podemos hacer clic en cualquier lugar para generar una ráfaga de 200 partículas en el cursor. La fuente cicla entre colores, y la cantidad activa se muestra junto a los FPS:

engine.scope((scope) => {
    const { start, cls, circfill, text, rnd, randomIntegerBetween, click, mouse } = scope;

    const MAX_PARTICLES = 10000;
    const COLORS = [8, 9, 10, 11, 12, 13, 14];
    let pool = [];
    for (let i = 0; i < MAX_PARTICLES; i++) {
        pool.push({
            active: false,
            x: 0,
            y: 0,
            vx: 0,
            vy: 0,
            life: 0,
            color: 0,
        });
    }

    let activeCount = 0;
    let emitterX = 64;
    let emitterY = 64;
    let emitterVx = 1.5;
    let emitterVy = 1.2;
    let colorIndex = 0;

    function acquire() {
        for (let i = 0; i < pool.length; i++) {
            if (!pool[i].active) {
                pool[i].active = true;
                activeCount++;
                return pool[i];
            }
        }
        return null;
    }

    function release(p) {
        p.active = false;
        activeCount--;
    }

    function spawnBurst(x, y, count) {
        for (let i = 0; i < count; i++) {
            let p = acquire();
            if (!p) break;
            p.x = x;
            p.y = y;
            let angle = (rnd(360) * Math.PI) / 180;
            let speed = 0.5 + rnd(200) / 100;
            p.vx = Math.cos(angle) * speed;
            p.vy = Math.sin(angle) * speed;
            p.life = 20 + rnd(40);
            p.color = COLORS[randomIntegerBetween(0, COLORS.length - 1)];
        }
    }

    function update() {
        emitterX += emitterVx;
        emitterY += emitterVy;
        if (emitterX < 0 || emitterX > 128) emitterVx *= -1;
        if (emitterY < 0 || emitterY > 128) emitterVy *= -1;

        colorIndex = (colorIndex + 1) % COLORS.length;
        for (let i = 0; i < 80; i++) {
            let p = acquire();
            if (!p) break;
            p.x = emitterX;
            p.y = emitterY;
            p.vx = (rnd(200) - 100) / 100;
            p.vy = (rnd(200) - 100) / 100;
            p.life = 60 + rnd(90);
            p.color = COLORS[(colorIndex + i) % COLORS.length];
        }

        if (click()) {
            let m = mouse();
            spawnBurst(m.x, m.y, 200);
        }

        for (let i = 0; i < pool.length; i++) {
            if (!pool[i].active) continue;
            pool[i].x += pool[i].vx;
            pool[i].y += pool[i].vy;
            pool[i].life--;
            if (pool[i].life <= 0) release(pool[i]);
        }
    }

    function draw() {
        cls(0);
        for (let i = 0; i < pool.length; i++) {
            if (!pool[i].active) continue;
            circfill(pool[i].x, pool[i].y, 1, pool[i].color);
        }
        text('FPS: ' + scope.currentFps, 2, 2, 7);
        text('PARTICLES: ' + activeCount, 2, 10, 7);
    }

    start({ sprites: {}, sounds: {}, update, draw, target });
});
Object Pooling: Complete Example
Haz clic en cualquier lugar para generar una ráfaga de partículas

spawnBurst usa el mismo patrón acquire/release que la fuente. Elige un ángulo y velocidad aleatorios para cada partícula para que vuelen hacia afuera en círculo. El pool maneja tanto la fuente como las ráfagas — cuando hacemos clic, 200 partículas se adquieren del mismo pool. Si el pool se queda corto, la ráfaga simplemente genera menos. Sin crash, sin manejo especial.

Para Seguir Explorando

  • Clase Pool reutilizable — envuelve acquire, release y el bucle de iteración en una clase para poder crear pools para diferentes tipos de objetos sin repetir el patrón
  • Pools que crecen automáticamente — cuando acquire no encuentra entradas inactivas, empuja un objeto nuevo en vez de devolver null. Pierdes la garantía de tamaño fijo pero nunca pierdes spawns
  • Múltiples pools — pools separados para balas, partículas, enemigos y números de daño, cada uno dimensionado para el pico esperado de su tipo de objeto
  • Frame de calentamiento — pre-activa e inmediatamente libera un lote de objetos en el primer frame para evitar costos de asignación por primer uso del runtime
  • Lista libre — en lugar de escanear todo el pool buscando una entrada inactiva, mantén una pila de índices libres. acquire saca un índice, release lo devuelve. O(1) en vez de O(n)