paint-brush
Explorando Unity DOTS y ECS: ¿es un cambio de juego?por@deniskondratev
3,433 lecturas
3,433 lecturas

Explorando Unity DOTS y ECS: ¿es un cambio de juego?

por Denis Kondratev12m2023/07/18
Read on Terminal Reader

Demasiado Largo; Para Leer

Este artículo profundiza en la pila de tecnología orientada a datos (DOTS) y el sistema de componentes de entidad (ECS) de Unity, que optimizan el desarrollo de juegos a través de una arquitectura simple e intuitiva. Estas tecnologías, junto con los paquetes adicionales de Unity, permiten la creación de juegos eficientes y de alto rendimiento. Las ventajas de estos sistemas se demuestran a través de un ejemplo de algoritmo del Juego de la vida de Conway.
featured image - Explorando Unity DOTS y ECS: ¿es un cambio de juego?
Denis Kondratev HackerNoon profile picture
0-item
1-item
2-item

Unity DOTS permite a los desarrolladores utilizar todo el potencial de los procesadores modernos y ofrecer juegos altamente optimizados y eficientes, y creemos que vale la pena prestarle atención.


Han pasado más de cinco años desde que Unity anunció por primera vez el desarrollo de su pila de tecnología orientada a datos (DOTS). Ahora, con el lanzamiento de la versión de soporte a largo plazo (LTS), Unity 2023.3.0f1, finalmente hemos visto un lanzamiento oficial. Pero, ¿por qué Unity DOTS es tan importante para la industria del desarrollo de juegos y qué ventajas ofrece esta tecnología?


¡Hola a todos! Mi nombre es Denis Kondratev y soy desarrollador de Unity en MY.GAMES. Si ha estado ansioso por comprender qué es Unity DOTS y si vale la pena explorarlo, esta es la oportunidad perfecta para profundizar en este tema fascinante, y en este artículo, haremos precisamente eso.


¿Qué es el Sistema de Componentes de Entidad (ECS)?

En esencia, DOTS implementa el patrón arquitectónico del Sistema de Componentes de Entidad (ECS). Para simplificar este concepto, describámoslo así: ECS se basa en tres elementos fundamentales: Entidades, Componentes y Sistemas.


Las entidades , por sí solas, carecen de cualquier funcionalidad o descripción inherente. En cambio, sirven como contenedores para varios componentes, lo que les otorga características específicas para la lógica del juego, la representación de objetos, los efectos de sonido y más.


Los componentes , a su vez, vienen en diferentes tipos y solo almacenan datos sin capacidades de procesamiento independientes propias.


Completando el marco ECS están los Sistemas , que procesan Componentes, manejan la creación y destrucción de Entidades y administran la adición o eliminación de Componentes.


Por ejemplo, al crear un juego de "Disparos en el espacio", el área de juegos contará con múltiples objetos: la nave espacial del jugador, enemigos, asteroides, botín, lo que sea.



Todos estos objetos se consideran entidades por derecho propio, desprovistos de características distintivas. Sin embargo, al asignarles diferentes componentes, podemos imbuirlos de atributos únicos.


Para demostrar, considerando que todos estos objetos poseen posiciones en el campo de juego, podemos crear un componente de posición que contenga las coordenadas del objeto. Además, para la nave espacial, los enemigos y los asteroides del jugador, podemos incorporar componentes de salud; el sistema responsable de manejar las colisiones de objetos regirá la salud de estas entidades. Además, podemos adjuntar un componente de tipo de enemigo a los enemigos, lo que permite que el sistema de control de enemigos gobierne su comportamiento en función de su tipo asignado.


Si bien esta explicación proporciona una visión general simplista y rudimentaria, la realidad es algo más compleja. No obstante, confío en que el concepto fundamental de ECS sea claro. Con eso fuera del camino, profundicemos en las ventajas de este enfoque.

Los beneficios del Sistema de Componentes de Entidad

Una de las principales ventajas del enfoque de Entity Component System (ECS) es el diseño arquitectónico que promueve. La programación orientada a objetos (POO) tiene un legado importante con patrones como la herencia y la encapsulación, e incluso los programadores experimentados pueden cometer errores de arquitectura en pleno desarrollo, lo que lleva a la refactorización o la lógica enredada en proyectos a largo plazo.


En contraste, ECS proporciona una arquitectura simple e intuitiva. Todo cae naturalmente en componentes y sistemas aislados, lo que facilita su comprensión y desarrollo utilizando este enfoque; incluso los desarrolladores novatos captan rápidamente este enfoque con errores mínimos.


ECS sigue un enfoque compuesto, en el que se crean componentes y sistemas de comportamiento aislados en lugar de jerarquías de herencia complejas. Estos componentes y sistemas se pueden agregar o eliminar fácilmente, lo que permite cambios flexibles en las características y el comportamiento de la entidad; este enfoque mejora en gran medida la reutilización del código.


Otra ventaja clave de ECS es la optimización del rendimiento. En ECS, los datos se almacenan en la memoria de manera contigua y optimizada, con tipos de datos idénticos ubicados uno cerca del otro. Esto optimiza el acceso a los datos, reduce los errores de caché y mejora los patrones de acceso a la memoria. Además, los sistemas compuestos por bloques de datos separados son más fáciles de paralelizar en diferentes procesos, lo que genera ganancias de rendimiento excepcionales en comparación con los enfoques tradicionales.

Explorando los paquetes de Unity DOTS

Unity DOTS abarca un conjunto de tecnologías proporcionadas por Unity Technologies que implementan el concepto ECS en Unity. Incluye varios paquetes diseñados para mejorar diferentes aspectos del desarrollo de juegos; cubramos algunos de ellos ahora.


El núcleo de DOTS es el paquete Entities , que facilita la transición de MonoBehaviours y GameObjects familiares al enfoque de Entity Component System. Este paquete constituye la base del desarrollo basado en DOTS.


El paquete Unity Physics presenta un nuevo enfoque para manejar la física en los juegos, logrando una velocidad notable a través de cálculos en paralelo.


Además, el paquete Havok Physics for Unity permite la integración con el moderno motor Havok Physics. Este motor ofrece detección de colisiones y simulación física de alto rendimiento, lo que impulsa juegos populares como The Legend of Zelda: Breath of the Wild, Doom Eternal, Death Stranding, Mortal Kombat 11 y más.


Death Stranding, como muchos otros videojuegos, utiliza el popular motor Havok Physics.


El paquete Entities Graphics se centra en la representación en DOTS. Permite la recopilación eficiente de datos de renderizado y funciona a la perfección con canalizaciones de renderización existentes, como la canalización de renderización universal (URP) o la canalización de renderización de alta definición (HDRP).


Una cosa más, Unity también ha estado desarrollando activamente una tecnología de red llamada Netcode. Incluye paquetes como Unity Transport para el desarrollo de juegos multijugador de bajo nivel, Netcode para GameObjects para enfoques tradicionales y el notable paquete Unity Netcode for Entities , que se alinea con los principios DOTS. Estos paquetes son relativamente nuevos y seguirán evolucionando en el futuro.

Mejorar el rendimiento en Unity DOTS y más allá

Varias tecnologías estrechamente relacionadas con DOTS se pueden utilizar dentro del marco DOTS y más allá. El paquete Job System proporciona una manera conveniente de escribir código con cálculos paralelos. Gira en torno a dividir el trabajo en pequeños fragmentos llamados trabajos, que realizan cálculos en sus propios datos. El sistema de trabajos distribuye uniformemente estos trabajos entre subprocesos para una ejecución eficiente.


Para garantizar la seguridad del código, Job System admite el procesamiento de tipos de datos que se pueden dividir. Los tipos de datos Blittable tienen la misma representación en la memoria administrada y no administrada y no requieren conversión cuando se pasan entre código administrado y no administrado. Los ejemplos de tipos blittables incluyen byte, sbyte, short, ushort, int, uint, long, ulong, float, double, IntPtr y UIntPtr. Los arreglos unidimensionales de estructuras y tipos primitivos que se pueden dividir en blit que contienen exclusivamente tipos que se pueden dividir en blit también se consideran blittable.


Sin embargo, los tipos que contienen una matriz variable de tipos blittables no se consideran blittables en sí mismos. Para abordar esta limitación, Unity ha desarrollado el paquete Collections , que proporciona un conjunto de estructuras de datos no administradas para usar en trabajos. Estas colecciones están estructuradas y almacenan datos en memoria no administrada utilizando mecanismos de Unity. Es responsabilidad del desarrollador desasignar estas colecciones usando el método Disposal().


Otro paquete importante es Burst Compiler , que se puede usar con Job System para generar código altamente optimizado. Aunque viene con ciertas limitaciones de uso de código, el compilador Burst proporciona un aumento significativo del rendimiento.

Medición del rendimiento con Job System y Burst Compile

Como se mencionó, Job System y Burst Compiler no son componentes directos de DOTS, pero brindan una valiosa ayuda para programar cálculos paralelos eficientes y rápidos. Probemos sus capacidades usando un ejemplo práctico: implementar Algoritmo del juego de la vida de Conway . En este algoritmo, un campo se divide en celdas, cada una de las cuales puede estar viva o muerta. Durante cada turno, verificamos la cantidad de vecinos vivos para cada celda y sus estados se actualizan de acuerdo con reglas específicas.



Aquí está la implementación de este algoritmo utilizando el enfoque tradicional:


 private void SimulateStep() { Profiler.BeginSample(nameof(SimulateStep)); for (var i = 0; i < width; i++) { for (var j = 0; j < height; j++) { var aliveNeighbours = CountAliveNeighbours(i, j); var index = i * height + j; var isAlive = aliveNeighbours switch { 2 => _cellStates[index], 3 => true, _ => false }; _tempResults[index] = isAlive; } } _tempResults.CopyTo(_cellStates); Profiler.EndSample(); } private int CountAliveNeighbours(int x, int y) { var count = 0; for (var i = x - 1; i <= x + 1; i++) { if (i < 0 || i >= width) continue; for (var j = y - 1; j <= y + 1; j++) { if (j < 0 || j >= height) continue; if (_cellStates[i * width + j]) { count++; } } } return count; }


He agregado marcadores a Profiler para medir el tiempo necesario para los cálculos. Los estados de las celdas se almacenan en una matriz unidimensional llamada _cellStates . Inicialmente escribimos los resultados temporales en _tempResults y luego los copiamos nuevamente en _cellStates al completar los cálculos. Este enfoque es necesario porque escribir el resultado final directamente en _cellStates afectaría los cálculos posteriores.


Creé un campo de 1000x1000 celdas y ejecuté el programa para medir el rendimiento. Aquí están los resultados:



Como se ve en los resultados, los cálculos tomaron 380 ms.


Ahora apliquemos Job System y Burst Compiler para mejorar el rendimiento. Primero, crearemos el Trabajo responsable de ejecutar el algoritmo del Juego de la Vida de Conway.


 public struct SimulationJob : IJobParallelFor { public int Width; public int Height; [ReadOnly] public NativeArray<bool> CellStates; [WriteOnly] public NativeArray<bool> TempResults; public void Execute(int index) { var i = index / Height; var j = index % Height; var aliveNeighbours = CountAliveNeighbours(i, j); var isAlive = aliveNeighbours switch { 2 => CellStates[index], 3 => true, _ => false }; TempResults[index] = isAlive; } private int CountAliveNeighbours(int x, int y) { var count = 0; for (var i = x - 1; i <= x + 1; i++) { if (i < 0 || i >= Width) continue; for (var j = y - 1; j <= y + 1; j++) { if (j < 0 || j >= Height) continue; if (CellStates[i * Width + j]) { count++; } } } return count; } }


He asignado el atributo [ReadOnly] al campo CellStates , lo que permite el acceso sin restricciones a todos los valores de la matriz desde cualquier subproceso. Sin embargo, para el campo TempResults , que tiene el atributo [WriteOnly] , la escritura solo se puede realizar a través del índice especificado en el método Execute(int index) . Intentar escribir un valor en un índice diferente generará una advertencia. Esto garantiza la seguridad de los datos cuando se trabaja en un modo de subprocesos múltiples.


Ahora, desde el código regular, iniciemos nuestro Trabajo:


 private void SimulateStepWithJob() { Profiler.BeginSample(nameof(SimulateStepWithJob)); var job = new SimulationJob { Width = width, Height = height, CellStates = _cellStates, TempResults = _tempResults }; var jobHandler = job.Schedule(width * height, 4); jobHandler.Complete(); job.TempResults.CopyTo(_cellStates); Profiler.EndSample(); }


Después de copiar todos los datos necesarios, programamos la ejecución del trabajo utilizando el método Schedule() . Es importante tener en cuenta que esta programación no ejecuta los cálculos de inmediato: estas acciones se inician desde el hilo principal y la ejecución ocurre a través de trabajadores distribuidos entre diferentes hilos. Para esperar a que se complete el trabajo, usamos jobHandler.Complete() . Solo entonces podemos volver a copiar el resultado obtenido en _cellStates .


Medimos la velocidad:



La velocidad de ejecución se ha multiplicado casi por diez y el tiempo de ejecución es ahora de aproximadamente 42 ms. En la ventana de Profiler, podemos ver que la carga de trabajo se distribuyó entre 17 trabajadores. Este número es ligeramente inferior al número de subprocesos del procesador en el entorno de prueba, que es un Intel® Core™ i9-10900 con 10 núcleos y 20 subprocesos. Si bien los resultados pueden variar en los procesadores con menos núcleos, podemos garantizar la plena utilización de la potencia del procesador.


Pero eso no es todo: es hora de utilizar Burst Compiler, que proporciona una importante optimización del código pero tiene ciertas restricciones. Para habilitar Burst Compiler, simplemente agregue el atributo [BurstCompile] a SimulationJob .


 [BurstCompile] public struct SimulationJob : IJobParallelFor { ... }


Medimos de nuevo:



Los resultados superan incluso las expectativas más optimistas: la velocidad ha aumentado casi 200 veces en comparación con el resultado inicial. Ahora, el tiempo de cálculo para 1 millón de celdas no es más de 2 ms. En Profiler, las partes ejecutadas por el código compilado con Burst Compiler están resaltadas en verde.

Conclusión

Si bien el uso de cálculos de subprocesos múltiples puede no ser siempre necesario, y la utilización de Burst Compiler puede no ser siempre posible, podemos observar una tendencia común en el desarrollo de procesadores hacia arquitecturas de múltiples núcleos. Esto significa que debemos estar preparados para aprovechar todo su poder. ECS, y específicamente Unity DOTS, se alinean perfectamente con este paradigma.


Creo que Unity DOTS merece atención, como mínimo. Si bien puede que no sea la mejor solución para todos los casos, ECS puede demostrar su valor en muchos juegos.


El marco DOTS de Unity, con su enfoque multiproceso y orientado a datos, ofrece un gran potencial para optimizar el rendimiento en los juegos de Unity. Al adoptar la arquitectura de Entity Component System y aprovechar tecnologías como Job System y Burst Compiler, los desarrolladores pueden desbloquear nuevos niveles de rendimiento y escalabilidad.


A medida que el desarrollo de juegos continúa evolucionando y el hardware avanza, adoptar Unity DOTS se vuelve cada vez más valioso. Permite a los desarrolladores aprovechar todo el potencial de los procesadores modernos y ofrecer juegos altamente optimizados y eficientes. Si bien Unity DOTS puede no ser la solución ideal para todos los proyectos, sin duda es una gran promesa para aquellos que buscan escalabilidad y un desarrollo basado en el rendimiento.


Unity DOTS es un marco poderoso que puede beneficiar significativamente a los desarrolladores de juegos al mejorar el rendimiento, permitir cálculos paralelos y adoptar el futuro del procesamiento de múltiples núcleos. Vale la pena explorar y considerar su adopción para aprovechar al máximo el hardware moderno y optimizar el rendimiento de los juegos de Unity.