En un
artículo anterior, describí las tecnologías y los enfoques que utilizamos al desarrollar un nuevo tirador móvil de ritmo rápido. Porque fue una revisión e incluso un artículo superficial: hoy profundizaré y explicaré en detalle por qué decidimos escribir nuestro propio marco ECS y no utilizamos los existentes. Habrá ejemplos de código y una pequeña bonificación al final.

¿Qué es ECS como ejemplo?
Ya describí brevemente qué es Entity Component System, y hay artículos sobre Habré sobre ECS (básicamente, sin embargo, traducciones de artículos; vea mi revisión de los más interesantes al final del artículo, como un bono). Y hoy les diré cómo usamos ECS, utilizando nuestro ejemplo de código.
El diagrama anterior describe la esencia del
reproductor , sus componentes y sus datos, y los sistemas que funcionan con el reproductor y sus componentes. El objeto clave en el diagrama es el jugador:
- puede moverse en el espacio: componentes de transformación y movimiento , MoveSystem ;
- tiene algo de salud y puede morir: componente Salud , Daño , DamageSystem ;
- después de que aparece la muerte en el punto de reaparición: el componente Transformar para la posición, el RespawnSystem ;
- puede ser invulnerable - componente invencible .
Describimos esto con un código. Primero, obtengamos interfaces para componentes y sistemas. Los componentes pueden tener métodos auxiliares comunes, el sistema solo tiene un método de
ejecución , que recibe el estado del mundo en la entrada para el procesamiento:
public interface IComponent {
Para los componentes, creamos clases de código auxiliar que utiliza nuestro generador de código para convertirlas en código de componente realmente utilizado. Consigamos algunos espacios en blanco para
Salud ,
Daño e
Invencible (para el resto de los componentes será similar).
[Component] public class Health { [Max(1000)]
Los componentes determinan el estado del mundo, por lo tanto, contienen solo datos, sin métodos. Al mismo tiempo, no hay datos en
Invincible , se usa en lógica como un signo de invulnerabilidad: si la esencia del jugador tiene este componente, el jugador ahora es invulnerable.
El generador utiliza el atributo
Componente para buscar las clases en blanco para los componentes. Los
atributos Max y
DontSend son necesarios como pistas al serializar y reducir el tamaño del estado del mundo transmitido a través de la red o guardado en el disco. En este caso, el servidor no serializará el campo
Cantidad y lo enviará a través de la red (dado que los clientes no usan este parámetro, solo es necesario en el servidor). Y el campo
Hp puede estar bien empaquetado en varios bits, dado el valor máximo de salud.
También tenemos una clase
prefabricada de entidad , donde agregamos información sobre todos los componentes posibles de cualquier entidad, y el generador ya creará una clase real a partir de ella:
public class Entity { public Health Health; public Damage Damage; public Invincible Invincible;
Después de eso, nuestro generador creará el código de las clases de componentes
Salud ,
Daño e
Invencible , que ya se utilizarán en la lógica del juego:
public sealed class Health : IComponent { public int Hp; public void Reset() { Hp = default(int); }
Como puede ver, los datos permanecieron en las clases y se agregaron métodos, por ejemplo,
Restablecer . Es necesario para optimizar y reutilizar componentes en grupos. Otros métodos auxiliares no contienen lógica de negocios; no los daré por brevedad.
También se generará una clase para el estado del mundo, que contiene una lista de todos los componentes y entidades:
public sealed class GameState {
Y finalmente, el código generado para
Entity :
public sealed class Entity { public uint Id;
La clase de
entidad es esencialmente solo un identificador de componente. La referencia a los objetos del mundo
GameState se usa solo en métodos auxiliares para la conveniencia de escribir código de lógica de negocios. Conociendo el identificador de un componente, podemos usarlo para serializar relaciones entre entidades, implementar enlaces en componentes a otras entidades. Por ejemplo, el componente
Daño contiene una referencia a la entidad
Víctima para determinar quién resultó dañado.
Esto termina el código generado. En general, necesitamos un generador para no escribir métodos auxiliares cada vez. Solo describimos los componentes como datos, luego el generador hace todo el trabajo. Ejemplos de métodos auxiliares:
- crear / eliminar entidades;
- agregue / elimine / copie un componente, acceda si existe;
- compara dos estados del mundo;
- serializar el estado del mundo;
- compresión delta;
- código de una página web o ventana de Unity para mostrar el estado del mundo, entidades, componentes (ver detalles a continuación);
- y otros
Pasemos al código del sistema. Definen la lógica empresarial. Por ejemplo, escribamos el código de un sistema que calcula el daño a un jugador:
public sealed class DamageSystem : ISystem { void ISystem.Execute(GameState gs) { foreach (var damage in gs.Damages) { var invincible = damage.Victim.Invincible; if (invincible != null) continue; var health = damage.Victim.Health; if (health == null) continue; health.Hp -= damage.Amount; } } }
El sistema revisa todos los componentes de
Daño en el mundo y busca si hay un componente
Invencible en un jugador potencialmente dañado (
Víctima ). Si es así, el jugador es invulnerable, el daño no se acumula. Luego, obtenemos el componente de
Salud de la víctima y reducimos la salud del jugador por el tamaño del daño.
Considere las características clave de los sistemas:
- Un sistema suele ser una clase sin estado, no contiene ningún dato interno, no intenta guardarlo en algún lugar, excepto los datos sobre el mundo transmitidos desde el exterior.
- Los sistemas generalmente pasan por todos los componentes de un determinado tipo y trabajan con ellos. Por lo general, se les llama por el tipo de componente ( Damage → DamageSystem ) o por la acción que realizan ( RespawnSystem ).
- El sistema implementa una funcionalidad mínima. Por ejemplo, si vamos más allá, después de ejecutar DamageSystem , otro RemoveDamageSystem eliminará todos los componentes de Damage . En el siguiente tic, otro ApplyDamageSystem basado en los disparos del jugador puede colgar nuevamente el componente Daño con un nuevo daño. Y luego, PlayerDeathSystem verificará la salud del jugador ( Health.Hp ) y, si es menor o igual a 0, destruirá todos los componentes del jugador excepto Transformar y agregará el componente de bandera Muerta .
Total, obtenemos las siguientes clases y las relaciones entre ellas:

Algunos hechos sobre ECS
ECS tiene sus ventajas y desventajas como un enfoque para el desarrollo y una forma de representar el mundo del juego, por lo que todos deciden por sí mismos si lo usan o no. Comencemos con los profesionales:
- Composición versus herencia múltiple. En el caso de herencia múltiple, se puede heredar un montón de funcionalidades innecesarias. En el caso de ECS, la funcionalidad aparece / desaparece cuando se agrega / elimina un componente.
- Separación de lógica y datos. La capacidad de cambiar la lógica (cambiar sistemas, eliminar / agregar componentes) sin romper datos. Es decir puede deshabilitar el grupo de sistemas responsables de una determinada funcionalidad en cualquier momento, todo lo demás seguirá funcionando y esto no afectará los datos.
- El ciclo del juego se simplifica. Aparece una actualización y todo el ciclo se divide en sistemas. Los datos son procesados por el "flujo" en el sistema, independientemente del motor (no hay millones de llamadas de actualización , como en Unity).
- Una entidad no sabe qué clases le afectan (y no debería saberlo).
- Uso eficiente de la memoria . Depende de la implementación de ECS. Puede reutilizar objetos y componentes de entidad creados mediante agrupaciones; puede usar tipos de valores para datos y almacenarlos en la memoria uno al lado del otro ( localidad de datos ).
- Es más fácil probar cuando los datos están separados de la lógica. Especialmente cuando considera que la lógica es un sistema pequeño con varias líneas de código.
- Ver y editar el estado del mundo en tiempo real . Porque el estado del mundo son solo datos, escribimos una herramienta que muestra en la página web todo el estado del mundo en una coincidencia en el servidor (así como la escena de la coincidencia en 3D). Cualquier componente de cualquier entidad se puede ver, modificar, eliminar. Lo mismo se puede hacer en el editor de Unity para el cliente.

Y ahora los contras:
- Debe aprender a pensar, diseñar y escribir código de manera diferente . Piense en términos de entidades, componentes y sistemas. Muchos patrones de diseño en ECS se implementan de una manera completamente diferente (vea un ejemplo de implementación del patrón de Estado en uno de los artículos de revisión al final).
- Más código Debatable Por un lado, debido al hecho de que dividimos la lógica en sistemas pequeños, en lugar de describir toda la funcionalidad en una clase, hay más clases, pero no hay mucho más código.
- El orden en que se llaman los sistemas afecta el funcionamiento de todo el juego . Por lo general, los sistemas dependen unos de otros, el orden de su ejecución se establece en la lista y se ejecutan en este orden. Por ejemplo, primero DamageSystem considera el daño, luego RemoveDamageSystem elimina el componente Damage . Si accidentalmente cambia el orden, entonces todo funcionará de manera diferente. En general, esto también es cierto para el caso habitual de OOP, si cambia el orden de las llamadas a métodos, pero en ECS es más fácil cometer un error. Por ejemplo, si parte de la lógica se ejecuta en el cliente para la predicción, el orden debe ser el mismo que en el servidor.
- Necesitamos conectar de alguna manera los datos y eventos de la lógica con la vista . En el caso de Unity, tenemos MVP:
- Modelo - GameState de ECS;
- Ver: con nosotros, estas son clases de MonoBehavior Unity exclusivamente estándar ( Renderer , Texto , etc.) y prefabricados;
- Presenter usa GameState para determinar los eventos de aparición / desaparición de entidades, componentes, etc., crea objetos Unity a partir de prefabricados y los cambia de acuerdo con los cambios en el estado del mundo.
Sabías que:- ECS no se trata solo de la localidad de datos . Para mí, esto es más un paradigma de programación, un patrón, otra forma de diseñar el mundo del juego, llámalo como quieras. La localidad de datos es solo una optimización.
- ¡La unidad no tiene ECS! A menudo le preguntas a los candidatos en una entrevista de equipo: ¿qué sabes sobre ECS? Si no lo has escuchado, díselo, y ellos respondieron: "Ah, entonces es como en Unity, ¡entonces lo sé!". Pero no, no es como en el motor de Unity. Allí, los datos y la lógica se combinan en el componente MonoBehaviour , y GameObject (si se compara con una entidad en ECS) tiene datos adicionales: un nombre, un lugar en la jerarquía, etc. Los desarrolladores de Unity están trabajando actualmente en una implementación normal de ECS en el motor, y hasta ahora parece que será bueno. Contrataron especialistas en este campo, espero que resulte genial.
Nuestros criterios de selección para el marco ECS
Cuando decidimos hacer un juego en ECS, comenzamos a buscar una solución preparada y anotamos los requisitos para ella en función de la experiencia de uno de los desarrolladores. Y pintaron cómo las soluciones existentes satisfacen nuestros requisitos. Fue hace un año, por el momento, algo podría haber cambiado. Como soluciones, consideramos:
- Entitas
- Artemis C #
- Ash.net
- ECS es nuestra propia solución en el momento en que la concebimos. Es decir nuestras suposiciones y deseos, lo que podemos hacer nosotros mismos.
Compilamos una tabla para comparar, donde también incluí nuestra solución actual (designada como
ECS (ahora) ):
Color rojo: la solución no es compatible con nuestros requisitos, naranja: parcialmente compatible, verde: totalmente compatible.Para nosotros, la analogía de las operaciones para acceder a los componentes y buscar entidades en ECS fueron las operaciones en una base de datos SQL. Por lo tanto, utilizamos conceptos como tabla (tabla), unión (operación de unión), índices (índices), etc.
Describiremos nuestros requisitos y hasta qué punto las bibliotecas y marcos de terceros correspondieron a ellos:
- conjuntos de datos separados (historial, actual, visual, estático) : la capacidad de obtener y almacenar por separado estados mundiales (por ejemplo, el estado actual para el procesamiento, para la representación, el historial de estado, etc.). Todas las decisiones consideradas respaldaron este requisito .
- ID de entidad como entero : soporte para representar una entidad por su número de identificador. Es necesario para la transmisión a través de la red y la capacidad de conectar entidades en la historia de los estados. Ninguna de las soluciones consideradas compatibles. Por ejemplo, en Entitas, una entidad está representada por un objeto completo (como un GameObject en Unity).
- unirse por ID O (N + M) : soporte para muestreo relativamente rápido de dos tipos de componentes. Por ejemplo, cuando necesita obtener todas las entidades con componentes del tipo Daño (por ejemplo, sus N piezas) y Salud (piezas M) para calcular y causar daño. Hubo pleno apoyo en Artemisa; en Entitas y Ash.NET es más rápido que O (N²), pero más lento que O (N + M). No recuerdo la evaluación ahora.
- unirse por la referencia de identificación O (N + M) : lo mismo que arriba solo cuando un componente de una entidad tiene un enlace a otro, y este último necesita obtener otro componente (en nuestro ejemplo, el componente Daño en la entidad auxiliar se refiere a la entidad del jugador Víctima y desde allí necesitas obtener el componente de Salud ). No es compatible con ninguna de las soluciones consideradas.
- sin asignación de consulta : sin asignaciones de memoria adicionales al consultar componentes y entidades del estado del mundo. En Entitas, fue en ciertos casos, pero insignificante para nosotros.
- tablas de grupo : almacenamiento de datos mundiales en grupos, la capacidad de reutilizar memoria, asignación solo cuando el grupo está vacío. Hubo "algo" de soporte en Entitas y Artemis, una ausencia completa en Ash.NET.
- comparar por ID (add, del) : soporte integrado para eventos de creación / destrucción de entidades y componentes por ID. Es necesario que el nivel de visualización (Ver) muestre / oculte objetos, reproduzca animaciones, efectos. No es compatible con ninguna de las soluciones consideradas.
- Δ serialización (cuantización, omisión) : compresión delta incorporada para serializar el estado del mundo (por ejemplo, para reducir el tamaño de los datos enviados a través de la red). Out of the Box no fue compatible con ninguna de las soluciones.
- La interpolación es un mecanismo de interpolación incorporado entre los estados mundiales. Ninguna de las soluciones soportadas.
- reutilizar tipo de componente : la capacidad de usar una vez escrito el tipo de componente en diferentes tipos de entidades. Solo se admiten Entitas .
- orden explícito de sistemas : la capacidad de establecer sus propios sistemas de orden de llamadas. Todas las decisiones respaldadas.
- editor (unity / server) : soporte para visualizar y editar entidades en tiempo real, tanto para el cliente como para el servidor. Entitas solo admite la capacidad de ver y editar entidades y componentes en el editor de Unity.
- copia / reemplazo rápido : la capacidad de copiar / reemplazar datos de manera económica. Ninguna de las soluciones soportadas.
- componente como tipo de valor (estructura) : componentes como tipos de valor. En principio, quería lograr un buen rendimiento basado en esto. No se admitía un solo sistema; las clases de componentes estaban en todas partes.
Requisitos opcionales (
ninguna de las soluciones en ese momento los admitía ):
- índices : datos de indexación como en una base de datos.
- claves compuestas: claves complejas para un acceso rápido a los datos (como en la base de datos).
- comprobación de integridad : la capacidad de verificar la integridad de los datos en un estado del mundo. Útil para la depuración.
- La compresión de contenido es la mejor compresión de datos basada en el conocimiento de la naturaleza de los datos. Por ejemplo, si conocemos el tamaño máximo del mapa o el número máximo de objetos en el mundo.
- Límite de tipos / sistemas : restricción en el número de tipos de componentes o sistemas. En Artemis en ese momento era imposible crear más de 32 o 64 tipos de componentes y sistemas .
Como se puede ver en la tabla, por nuestra cuenta queríamos implementar todos los requisitos, excepto los opcionales. De hecho, por el momento no hemos hecho:
- unirse por ID O (N + M) y unirse por referencia ID O (N + M) : la selección de dos componentes diferentes todavía ocupa O (N²) (de hecho, un bucle for anidado). Por otro lado, no hay tantas entidades y componentes para una coincidencia.
- comparar por ID (add, del) : no es necesario a nivel de marco. Implementamos esto en un nivel superior en MVP.
- copia / reemplazo rápido y componente como tipo de valor (estructura) - en algún momento nos dimos cuenta de que trabajar con estructuras no sería tan conveniente como con las clases, y nos decidimos por las clases - preferimos la conveniencia de desarrollo en lugar de un mejor rendimiento. Por cierto, los desarrolladores de Entitas hicieron lo mismo al final .
Al mismo tiempo, sin embargo, nos dimos cuenta de uno de los requisitos que inicialmente eran opcionales en nuestra opinión:
- Compresión consciente del contenido : gracias a esto pudimos reducir significativamente (decenas de veces) el tamaño del paquete transmitido a través de la red. Para las redes de datos móviles, es muy importante ajustar el tamaño del paquete en la MTU para que no se “descomponga” en partes pequeñas que puedan perderse, ir en un orden diferente y luego deben ensamblarse en partes. Por ejemplo, en Photon, si el tamaño de los datos no cabe en la biblioteca MTU, divide los datos en paquetes y los envía como confiables (con entrega garantizada), incluso si los envía como "no confiables" desde arriba. Probado con dolor de primera mano.
Características de nuestro desarrollo en ECS
- Nosotros en ECS escribimos lógica de negocios exclusivamente . No trabajar con recursos, vistas, etc. Dado que el código lógico ECS se ejecuta simultáneamente en el cliente en Unity y en el servidor, debe ser lo más independiente posible de otros niveles y módulos.
- Intentamos minimizar los componentes y sistemas . Por lo general, para cada nueva tarea, comenzamos nuevos componentes y sistemas. Pero a veces sucede que modificamos los antiguos, agregamos nuevos datos a los componentes e "inflamos" los sistemas.
- En nuestra implementación de ECS, no puede agregar varios componentes del mismo tipo a una entidad . Por lo tanto, si un jugador fue golpeado varias veces en una marca (por ejemplo, varios oponentes), generalmente creamos una nueva entidad para cada daño y le agregamos un componente de Daño .
- A veces, la presentación no es suficiente de la información que se encuentra en GameState . Luego debe agregar componentes especiales o datos adicionales que no están involucrados en la lógica, pero que la vista necesita. Por ejemplo, el disparo es instantáneo en el servidor, se activa una marca y, visualmente, es más largo en el cliente. Por lo tanto, para el cliente, el disparo se agrega al parámetro "vida útil del disparo".
- Implementamos eventos / solicitudes creando componentes especiales . Por ejemplo, si un jugador ha muerto, le colgamos un componente sin datos muertos , que es un evento para otros sistemas y el nivel de Vista en el que el jugador ha muerto. O si necesitamos revivir al jugador en el punto nuevamente, creamos una entidad separada con el componente Respawn con información adicional sobre a quién revivir. Un RespawnSystem separado al comienzo del ciclo del juego pasa por estos componentes y ya crea la esencia del jugador. Es decir de hecho, la primera entidad es una solicitud para crear la segunda.
- Tenemos componentes / entidades especiales "singleton" . Por ejemplo, tenemos una entidad con ID = 1, en la que cuelgan componentes especiales: la configuración del juego.
Bono
— ECS — . , , , :
- Unity, ECS -- — ECS . mopsicus , ECS, . : Unity ECS , . . «» ECS Unity. ECS-, : LeoECS , BrokenBricksECS , Svelto.ECS .
- Unity3D ECS Job System — , ECS Unity. fstleo , Unity ECS, , - , JobSystem.
- Entity System Framework ? — Ash- ActionScript. , , OOP- ECS-.
- Ash Entity System — , FSM State ECS — , .
- Entity-Component-System — — ECS C++.