ECS y rendimiento

En este artículo explicaremos cómo la organización de datos y el acceso a la memoria pueden mejorar el rendimiento de la arquitectura ECS. De esta forma entenderemos mejor cuándo este patrón aporta mejoras reales y cuándo apenas marcará diferencias. Personalmente, el rendimiento siempre ha sido una de mis mayores obsesiones al desarrollar videojuegos, por lo que este es probablemente uno de los aspectos más interesantes de toda la arquitectura ECS.

Entre los argumentos más habituales a favor de ECS se encuentra el de tener potencial para mejorar el rendimiento de un videojuego. De hecho, muchos desarrolladores descubren esta arquitectura precisamente al investigar técnicas de optimización utilizadas en motores modernos.

Sin embargo, existe cierta confusión sobre este tema. ECS no es una solución mágica que acelere automáticamente cualquier proyecto. Sus ventajas aparecen principalmente cuando la organización de los datos permite aprovechar mejor la memoria, reducir accesos innecesarios y procesar grandes cantidades de componentes (datos) de forma eficiente.

ECS y rendimiento cache

Durante años, gran parte de la programación de videojuegos se construyó alrededor de objetos tradicionales (POO) donde se almacenaban tanto los datos como la lógica. Este enfoque resulta muy intuitivo, pero cuando el número de elementos aumenta considerablemente comienzan a aparecer ciertas limitaciones.

Imaginemos miles de enemigos almacenados como instancias de objetos independientes en la memoria, cuando hacemos una operación como:

enemy.position.x += enemy.velocity.vx;

Desde el punto de vista del código parece una operación sencilla, sin embargo en POO cada objeto suele estar disperso en direcciones de memoria no contiguas. Para el procesador, saltar de un enemigo a otro implica buscar constantemente en zonas alejadas de la memoria principal. Esto cuando trabajamos con pocos objetos no supone ningún problema, pero cuando manejamos decenas de miles, la situación cambia.

Los procesadores modernos son extremadamente rápidos haciendo cálculos, pero acceder a la memoria RAM principal sigue siendo un cuello de botella. Por este motivo, el hardware utiliza memorias caché (L1, L2, L3) para almacenar temporalmente los datos contiguos más utilizados. Aquí es donde destaca una arquitectura ECS pura: al separar Entidades, Componentes y Sistemas, permite organizar los datos en arreglos contiguos de memoria, maximizando la eficiencia de la caché y el rendimiento.

ECS orientado a datos

En un ECS puro, las Entidades y los Componentes se organizan de forma que los elementos utilizados conjuntamente se encuentren próximos en memoria. Por ejemplo:

// Entidades (IDs únicos)
let entities = [1, 100, 4057];

// Componentes (Almacenados en arrays contiguos)
let positions = [
    { x: 10, y: 20 },
    { x: 50, y: 80 },
    { x: 90, y: 40 }
];
let velocities = [
    { vx: 1, vy: 1 },
    { vx: 2, vy: 2 },
    { vx: 3, vy: 3 }
];

// Sistemas (Lógica pura que procesa los arrays secuencialmente)
for (let i = 0; i < positions.length; i++) {
    positions[i].x += velocities[i].vx;
    positions[i].y += velocities[i].vy;
}

Al recorrer los arrays secuencialmente, la CPU puede cargar una línea de caché entera con múltiples componentes a la vez, minimizando los tiempos de espera. Para que todo esto funcione de forma eficiente, los frameworks de ECS buscan la manera de mapear internamente cada entidad con su respectivo índice dentro de estos listados contiguos, intentando evitar dejar huecos vacíos en la memoria (fragmentación).

Aquí es donde un ECS puro conecta directamente con el concepto de Diseño Orientado a Datos (DOD) y el rendimiento. En lugar de organizar el desarrollo alrededor de conceptos u objetos del mundo real, organizamos la memoria según la forma exacta en la que los datos van a ser procesados por el hardware.

ECS: rendimiento o simplicidad

Aunque las ventajas de rendimiento de ECS resultan muy atractivas, toda optimización tiene un coste. En una arquitectura orientada puramente a datos (DOD) surgen problemas estructurales que chocan directamente con la intuición de la programación tradicional.

Siguiendo con el código anterior, surge una duda inevitable. ¿Qué pasa si la entidad 100 necesita el componente positions, pero no el componente velocities porque es un elemento estático? Esto puede alterar el orden lineal en los distintos listados de componentes.

Los motores comerciales utilizan sistemas de gestión hipercomplejos (como Arquetipos o Sparse Sets) para mover los datos de sitio dinámicamente. Sin embargo, para un programador que busca implementar su propio ECS de forma sencilla sin complicarse la vida, la solución más directa para mantener los arrays alineados por el mismo índice es rellenar los huecos con datos «neutros»:

// Entidades
let entities = [1, 100, 4057];

// Componentes (Alineados estrictamente por el mismo índice)
let positions = [
    { x: 10, y: 20 },
    { x: 50, y: 80 }, // Entidad 100 (Estática)
    { x: 90, y: 40 }
];
let velocities = [
    { vx: 1, vy: 1 },
    { vx: 0, vy: 0 }, // Dato "neutro" inyectado para no romper el índice
    { vx: 3, vy: 3 }
];

// Sistemas
for (let i = 0; i < positions.length; i++) {
    positions[i].x += velocities[i].vx; // La CPU procesa el cero, manteniendo el bucle secuencial
    positions[i].y += velocities[i].vy;
}

En este caso sacrificamos un poco de memoria y ciclos de CPU procesando datos «neutros», pero a cambio solucionamos el problema de forma simple. No obstante, a medida que el juego crece y unas entidades tienen unos componentes y otras no, intentar mantener todos los arrays alineados secuencialmente se convierte en un auténtico quebradero de cabeza.

Por este motivo, numerosos desarrolladores independientes optan por soluciones híbridas que combinan la flexibilidad de la POO con estructuras de datos más ordenadas. Personalmente, creo que en la gran mayoría de proyectos medianos o pequeños, resulta mucho más inteligente encontrar un equilibrio saludable entre ECS, rendimiento y mantenibilidad, en lugar de obsesionarse con perseguir la máxima optimización teórica a costa de destruir la arquitectura del código.

ECS híbrido

La arquitectura ECS orientada a datos ofrece mayor rendimiento cuando trabajamos con miles o incluso decenas de miles de entidades. Por este motivo son ampliamente utilizadas en motores modernos y sistemas de simulación masiva. Sin embargo, muchos desarrolladores independientes trabajamos en proyectos de menor escala, donde la simplicidad del código, la facilidad de mantenimiento y la velocidad de desarrollo son prioritarias.

En lugar de perseguir una optimización extrema e impracticable, suele resultar más interesante buscar un equilibrio. Por ello, un enfoque utilizado en videojuegos como alternativa sumamente práctica y común es combinado un sistema centralizado de tipos o un diseño basado en arquetipos manuales orientados a objetos.

En este enfoque, las entidades son objetos de datos puros que contienen sus componentes y un identificador de tipo (ID). La lógica se extrae por completo a una clase controladora (Sistema) que gestiona todas las entidades y decide qué hacer con ellas mediante una estructura de control (switch). Veámoslo con un ejemplo de implementación limpio y funcional:

// 1. DEFINICIÓN DE LAS ENTIDADES (solo datos)
const flyEntity = {
    ID: "fly", // Identificador del tipo de entidad
    position: { x: 10, y: 20 },
    velocity: { vx: 2, vy: 3 },
    health: 10
};

const turretEntity = {
    ID: "turret",
    position: { x: 50, y: 80 },
    // Omitimos 'velocity' porque es estática, pero tiene rango de ataque
    shootingRange: 15, 
    health: 200
};

// 2. EL SISTEMA GESTOR (Lógica separada)
class EnemySystem {
    constructor() {
        this.entities = []; // Listado que almacena todas las entidades del juego
    }

    // Método para registrar enemigos en el sistema
    addEntity(entity) {
        this.entities.push(entity);
    }

    // Métodos específicos que actúan como "sub-sistemas" de lógica pura
    applyMovement(entity) {
        entity.position.x += entity.velocity.vx;
        entity.position.y += entity.velocity.vy;
    }

    processShooting(entity) {
        // Lógica para buscar objetivos y disparar
        console.log(`Torreta en [${entity.position.x}, ${entity.position.y}] buscando objetivos...`);
    }

    // El núcleo del sistema: filtra la lógica según el tipo de entidad
    update() {
        for (let i = 0; i < this.entities.length; i++) {
            const entity = this.entities[i];

            // Evaluamos el tipo de entidad para aplicar la lógica correspondiente
            switch (entity.ID) {
                case "fly":
                    this.applyMovement(entity);
                    break;

                case "turret":
                    this.processShooting(entity);
                    break;

                default:
                    console.warn(`Tipo de entidad desconocido: ${entity.ID}`);
                    break;
            }
        }
    }
}

// 3. EJECUCIÓN EN EL BUCLE DE JUEGO (Game Loop)
const gameSystem = new EnemySystem();
gameSystem.addEntity(flyEntity);
gameSystem.addEntity(turretEntity);

// En cada frame del juego:
gameSystem.update();

Este diseño es un punto intermedio fantástico para desarrolladores independientes por varias razones:

Ventajas

  • Legibilidad y simplicidad máximas: El código es sumamente intuitivo. Cualquier desarrollador que lea el archivo entiende qué hace cada entidad en segundos sin lidiar con complejas estructuras de datos cruzadas.
  • Cero desperdicio de memoria: No hay arrays con huecos vacíos (null) ni diccionarios intermedios de mapeo. Si la torreta no se mueve, simplemente no tiene la propiedad velocity.
  • Centralización de la lógica: Cumple con la regla de oro del ECS de separar los datos (entidades) de la lógica (EnemySystem). Las entidades siguen siendo contenedores puros de información.
  • Facilidad de depuración: Inspeccionar una entidad en la consola del navegador te muestra toda su información agrupada en un solo objeto (posición, vida, velocidad). A diferencia de un ECS puro donde su información está fragmentada en múltiples arrays inconexos.

Desventajas

  • Acoplamiento por tipos: Esta es la mayor desviación del ECS puro. En ECS, a un sistema no le importa qué es la entidad (si es una mosca o una torreta), solo le importa qué componentes tiene. Al usar un switch (entity.ID), si mañana quieres que la torreta se mueva, tendrás que reestructurar el switch, perdiendo la flexibilidad de la composición dinámica.
  • El problema de la Cache: Al ser JavaScript un lenguaje de alto nivel, cada entidad { ... } es un objeto independiente en la memoria RAM. Cuando el bucle for recorre this.entities, la CPU tiene que saltar a direcciones de memoria aleatorias para leer los datos de cada objeto. No aprovecha la velocidad de la memoria contigua (cache locality) que sí ofrece un array puro de datos.
  • Mantenimiento del switch: A medida que añadas 20 o 30 tipos de enemigos diferentes (jefes, patrullas, kamikazes), el bloque switch crecerá masivamente. Se tendrá que modificar cada vez que se crea un nuevo enemigo.

ECS y rendimiento JavaScript

Si el problema original en POO era que cada objeto suele estar disperso en direcciones de memoria no contiguas generando un cuello de botella para el procesador, esta solución no parece haber resuelto el problema. Además, en la literatura teórica de ECS se suele decir que hay que evitar los switch para no romper la linealidad de la caché. Sin embargo, cuando desarrollamos videojuegos web basados en Canvas 2D e imágenes independientes (jpg), las reglas del juego cambian por completo y los benchmarks demuestran lo contrario.

Al renderizar con la API clásica de Canvas, el navegador nos obliga a realizar operaciones individuales de CPU como ctx.save(), ctx.translate(), ctx.rotate() y el dibujado del archivo de imagen con ctx.drawImage() para cada elemento en pantalla. Dado que este paso a paso es obligatorio por la propia naturaleza de la API para aplicar rotaciones y posiciones específicas a cada enemigo, utilizar un switch basado en el ID de la entidad es la solución más eficiente en JavaScript.

Motores JIT como V8 optimizan estos switch de forma impecable creando tablas de salto internas. Intentar desarmar esta estructura para forzar un diseño de chequeo dinámico de propiedades (usando múltiples if para ver si existe el componente) solo añadiría una penalización de rendimiento en JavaScript y una complejidad arquitectónica enorme.

Este es un ejemplo perfecto de por qué un benchmark real en la plataforma de destino siempre vale más que mil páginas de teoría pura.

ESC y rendimiento

En este artículo hemos visto cómo ECS y su Diseño Orientado a Datos (DOD) mejoran el rendimiento al alinearse con la memoria caché y la CPU. Las arquitecturas orientadas a los datos ofrecen ventajas importantes cuando trabajamos con miles o incluso decenas de miles de entidades, por este motivo son ampliamente utilizadas en motores modernos y sistemas de simulación masiva. Sin embargo, la realidad de un desarrollo independiente nos deja tres lecciones fundamentales para proyectos de menor escala, donde la simplicidad, el mantenimiento y la velocidad de desarrollo son los factores más importantes:

  • La optimización extrema tiene un coste arquitectónico real: Perseguir la pureza de un ECS basado en DOD introduce una complejidad técnica formidable para gestionar la indexación dinámica o los componentes ausentes. En proyectos medianos o pequeños, la dificultad organizativa y el rendimiento de un ECS puro rara vez compensa el tiempo invertido.
  • La plataforma y la API dictan las reglas: Mientras que en C++ o C# el alineamiento estricto es vital, la web tiene sus propias leyes. En JavaScript, al renderizar en Canvas 2D, las transformaciones obligan a procesar cada elemento de manera individual en la CPU. Forzar patrones teóricos (DOD) en una API que no los admite solo genera código contraproducente.
  • Métricas frente a dogmas: Tal y como demuestran los motores JIT modernos (como V8), una estructura híbrida basada en un switch resulta ser una solución increíblemente rápida, limpia y optimizada para el desarrollo de videojuegos en la web.

ECS no es rápido por el simple hecho de llamarse ECS, sino porque facilita organizar los datos de una forma que el procesador aprovecha mejor. Cuando esto no es posible, un diseño híbrido adaptado a las fortalezas de tu plataforma (en este caso JavaScript) siempre será una opción más inteligente que perseguir una teoría impracticable.

Al final, el verdadero rol de un programador no es conseguir el código más puro, sino resumir su filosofía en una sola frase: «Quiero que el juego vaya rápido, pero también quiero entender mi código dentro de seis meses».

¡ Espero que este artículo sea de vuestro interés !

Logotipo de Safe CreativeLogotipo de Creative Commons Attribution 4.0

Deja un comentario