este es un análisis sobre la transición de una arquitectura de ecs basada en sparse sets/maps a una arquitectura basada en archetypes, detallando las implicaciones en el diseño de memoria y el rendimiento del procesador. está basada en la experiencia que tive aplicándolo a un juego/motor que estoy programando.
el mayor desafío de construir un motor de videojuegos eficiente radica en cómo organizamos los datos para maximizar el throughput del procesador. la evolución de un ecs suele pasar por tres fases críticas: la semántica, la distribución de memoria y la orquestación.
en una implementación inicial, el "mundo" actúa como un despachador de mapas. cada tipo de componente tiene su propio almacenamiento independiente.
entidades: [ id: 1 ] [ id: 2 ] [ id: 3 ]
componente posición (map):
{ 1: [x,y], 2: [x,y], 3: [x,y] } -> disperso en el heap
componente velocidad (map):
{ 2: [vx,vy] } -> disperso en el heap
problema técnico: cache misses
cuando un sistema de física itera sobre estas entidades, debe saltar entre diferentes regiones de la memoria ram para obtener la posición y luego la velocidad. en cpus modernas, un cache miss puede costar cientos de ciclos de reloj mientras se espera que los datos lleguen desde la memoria principal.
un arquetipo es una firma única que representa una combinación específica de componentes. si las entidades a y b tienen exactamente posición y velocidad, pertenecen al mismo arquetipo y se almacenan juntas en una tabla.
arquetipo a: [posición]
+-------+-------------------+
| entity| data (x, y) |
+-------+-------------------+
| id: 1 | [10.0, 20.0] |
| id: 3 | [15.0, 25.0] |
+-------+-------------------+
arquetipo b: [posición, velocidad]
+-------+-------------------+-------------------+
| entity| data (x, y) | data (vx, vy) |
+-------+-------------------+-------------------+
| id: 2 | [0.0, 0.0] | [1.0, 1.0] |
+-------+-------------------+-------------------+
ventaja: acceso secuencial al iterar sobre el arquetipo b, la cpu carga una cache line (típicamente 64 bytes) que contiene múltiples componentes contiguos. esto permite que el hardware prefetcher anticipe los siguientes datos, eliminando prácticamente la latencia de memoria.
para consultar qué tablas deben procesarse, el ecs utiliza un sistema de indexación. cada vez que se solicita una consulta (query) como has(posicion, velocidad), el motor no busca entidad por entidad, sino que busca en el registro de arquetipos.
query: <posicion, velocidad>
|
v
+-----------------------------+
| buscador de arquetipos |-----> [tabla: pos+vel] --(procesar)--> cpu
| (intersección de bitsets) |-----> [tabla: pos+vel+salud] --(procesar)--> cpu
+-----------------------------+
gestionar estas tablas de arquetipos (añadir columnas, rastrear qué id está en qué fila) es una tarea compleja de gestión de datos.
el mayor inconveniente técnico de los arquetipos es el cambio de estructura en tiempo de ejecución.
si llamamos a entity.addcomponent<velocidad>():
memcpy de los componentes existentes (posición).velocidad).diagrama de swap-and-pop:
antes de borrar id: 1 después de borrar id: 1
[ id: 1 ] [ id: 2 ] [ id: 3 ] [ id: 3 ] [ id: 2 ]
^ | ^
|___________________| | (el último ocupa el lugar del borrado)
la transición a arquetipos sacrifica la velocidad de modificación estructural (añadir/quitar componentes) a cambio de una velocidad de iteración casi imbatible. es la elección ideal para sistemas con miles de entidades activas donde el 90% del tiempo de cpu se dedica a transformar datos existentes. en el motor que estoy haciendo utilizaba la implementación sencilla y no notaba problemas con ella, pero al empezar a diseñar y probar escenarios reales me dí cuenta que algo olía chistoso. cuando spawneaba muchos enemigos de distintas capacidades, se iba todo al garete. en MI caso de uso, la nueva implementación es mejor en sobremanera.
mi motor no tiene repositorio ṕúblico aún. cuando lo tenga, actualizaré este post agregando bloques de código e imágenes. hasta entonces.