Unity3D ECS y sistema de trabajo

Con Unity3D, con el lanzamiento de la versión 2018, se hizo posible utilizar el sistema ECS nativo (para Unity), aromatizado con subprocesos múltiples en forma de un sistema de trabajo. No hay muchos materiales en Internet (un par de proyectos de las propias Unity Technologies y un par de videos de capacitación en YouTube). Traté de darme cuenta de la escala y la conveniencia de ECS, haciendo un pequeño proyecto que no fuera de cubos y botones. Antes de eso, no tenía experiencia en el diseño de ECS, por lo que me llevó dos días estudiar materiales y reconstruir el pensamiento con OOP, un día para deleitarme con el enfoque y uno o dos días para desarrollar un proyecto, luchar contra Unity, sacar muestras de cabello y fumar. . El artículo contiene un poco de teoría y un pequeño proyecto de ejemplo.


El significado de ECS es bastante simple: una entidad ( Entidad ) con sus componentes ( Componente ), que son procesados ​​por el sistema ( Sistema ).

Esencia


La entidad no tiene lógica y solo almacena componentes (muy similar a GameObject en el antiguo enfoque de CPC). En Unity ECS, la clase Entity existe para esto.

Componente


Los componentes almacenan solo datos y, a veces, no contienen nada en absoluto y son un marcador simple para que el sistema los procese. Pero no tienen ninguna lógica. Heredado de ComponentDataWrapper. Puede ser procesado por otro hilo (pero hay un matiz).

El sistema


Los sistemas son responsables de procesar los componentes. En la entrada, reciben de Unity una lista de componentes procesados ​​para los tipos dados, y en los métodos sobrecargados (análogos de Update, Start, OnDestroy) ocurre la magia de la mecánica del juego. Heredado de ComponentSystem o JobComponentSystem.

Sistema de trabajo


La mecánica de los sistemas que permite el procesamiento paralelo de componentes. En el sistema OnUpdate, se crea una estructura de trabajo y se agrega al procesamiento. En un momento de aburrimiento y recursos gratuitos, Unity procesará y aplicará los resultados a los componentes.

Multithreading y Unity 2018


Todo el trabajo del sistema de trabajo se lleva a cabo en otros subprocesos, y los componentes estándar (Transformar, Cuerpo rígido, etc.) no se pueden cambiar en ningún subproceso, excepto en el principal. Por lo tanto, en el paquete estándar hay componentes compatibles de "reemplazo": Componente de posición, Componente de rotación, Componente de representación de instancia de malla.

Lo mismo se aplica a estructuras estándar como Vector3 o Quaternion. Los componentes para la paralelización usan solo los tipos de datos más simples (float3, float4, eso es todo, los programadores gráficos estarán contentos), agregados en el espacio de nombres Unity.Mathematics, también hay una clase matemática para procesarlos. Sin cadenas, sin tipos de referencia, solo hardcore.

"Muéstrame el código"


Entonces, ¡es hora de mover algo!

Cree un componente que almacene el valor de la velocidad y que también sea uno de los marcadores del sistema que mueve objetos. El atributo serializable le permite establecer y rastrear el valor en el inspector.

Componente de velocidad
[Serializable] public struct SpeedData : IComponentData { public int Value; } public class SpeedComponent : ComponentDataWrapper<SpeedData> {} 


Usando el atributo Inject, el sistema obtiene una estructura que contiene componentes de solo aquellas entidades en las que están presentes los tres componentes. Entonces, si alguna entidad tiene los componentes PositionComponent y SpeedComponent, pero no RotationComponent, esta entidad no se agregará a la estructura que ingresa al sistema. Por lo tanto, es posible filtrar entidades por la presencia de un componente.

Sistema de movimiento
 public class MovementSystem : ComponentSystem { public struct ShipsPositions { public int Length; public ComponentDataArray<Position> Positions; public ComponentDataArray<Rotation> Rotations; public ComponentDataArray<SpeedData> Speeds; } [Inject] ShipsPositions _shipsMovementData; protected override void OnUpdate() { for(int i = 0; i < _shipsMovementData.Length; i++) { _shipsMovementData.Positions[i] = new Position(_shipsMovementData.Positions[i].Value + math.forward(_shipsMovementData.Rotations[i].Value) * Time.deltaTime * _shipsMovementData.Speeds[i].Value); } } } 


Ahora todos los objetos que contienen estos tres componentes avanzarán a una velocidad determinada.

Wiiiii


Fue facil Aunque me llevó un día pensar en ECS.

Pero detente. ¿Dónde está el sistema de trabajo aquí?

El hecho es que nada está lo suficientemente roto como para usar subprocesos múltiples. ¡Hora de romper!

Saqué de las muestras el sistema que da origen a los prefabricados. De interesante: aquí hay un fragmento de código:

Engendrador
 EntityManager.Instantiate(prefab, entities); for (int i = 0; i < count; i++) { var position = new Position { Value = spawnPositions[i] }; EntityManager.SetComponentData(entities[i], position); EntityManager.SetComponentData(entities[i], new SpeedData { Value = Random.Range(15, 25) }); } 


Entonces, pongamos 1000 objetos. Todavía es demasiado bueno para crear instancias de mallas en la GPU. 5000 - también aprox. Mostraré lo que sucede con 50,000 objetos.

El depurador de entidades ha aparecido en Unity, que muestra cuántos ms tarda cada sistema. Los sistemas se pueden activar / desactivar directamente en tiempo de ejecución, para ver qué objetos procesan, en general, algo irremplazable.

Consigue una bola de nave espacial


La herramienta graba a una velocidad de 15 fps, por lo que todo el punto está en los números en la lista de sistemas. El nuestro, MovementSystem, intenta mover los 50,000 objetos en cada cuadro, y lo hace en promedio en 60 ms. Entonces, ahora el juego está lo suficientemente roto para la optimización.
Fijamos el JobSystem al sistema de movimiento.

Sistema de movimiento modificado
 public class MovementSystem : JobComponentSystem { [ComputeJobOptimization] struct MoveShipJob : IJobProcessComponentData<Position, Rotation, SpeedData> { public float dt; public void Execute(ref Position position, ref Rotation rotation, ref SpeedData speed) { position.Value += math.forward(rotation.Value) * dt * speed.Value; } } protected override JobHandle OnUpdate(JobHandle inputDeps) { var job = new MoveShipJob { dt = Time.deltaTime }; return job.Schedule(this, 1, inputDeps); } } 


Ahora el sistema hereda de JobComponentSystem y en cada marco crea un controlador especial al que Unity transfiere los mismos 3 componentes y deltaTime del sistema.

Lanza la nave espacial nuevamente


¡0.15 ms (0.4 en el pico, sí) versus 50-70! ¡50 mil objetos! Ingresé estos números en la calculadora, en respuesta mostró una cara feliz.

Gestión


Puedes mirar sin parar una bola voladora, o puedes volar entre las naves.
Necesita un sistema de rodaje.

El componente Rotación ya está en el prefabricado, cree un componente para almacenar controles.

Componente de control
 [Serializable] public struct RotationControlData : IComponentData { public float roll; public float pitch; public float yaw; } public class ControlComponent : ComponentDataWrapper<RotationControlData>{} 


También necesitamos un componente de jugador (aunque no es un problema dirigir todos los barcos de 50k a la vez)

PlayerComponent
 public struct PlayerData : IComponentData { } public class PlayerComponent : ComponentDataWrapper<PlayerData> { } 


Y de inmediato, un lector de entrada de usuario.

UserControlSystem
 public class UserControlSystem : ComponentSystem { public struct InputPlayerData { public int Length; [ReadOnly] public ComponentDataArray<PlayerData> Data; public ComponentDataArray<RotationControlData> Controls; } [Inject] InputPlayerData _playerData; protected override void OnUpdate() { for (int i = 0; i < _playerData.Length; i++) { _playerData.Controls[i] = new RotationControlData { roll = Input.GetAxis("Horizontal"), pitch = Input.GetAxis("Vertical"), yaw = Input.GetKey(KeyCode.Q) ? -1 : Input.GetKey(KeyCode.E) ? 1 : 0 }; } } } 


En lugar de la entrada estándar, puede haber cualquier bicicleta o IA favorita.

Y finalmente, los controles de procesamiento y el giro en sí. Me enfrenté al hecho de que math.euler aún no se había implementado, por lo que una incursión rápida en Wikipedia me salvó de la conversión de los rincones de Euler al cuaternión.

ProcessRotationInputSystem
 public class ProcessRotationInputSystem : JobComponentSystem { struct LocalRotationSpeedGroup { public ComponentDataArray<Rotation> rotations; [ReadOnly] public ComponentDataArray<RotationSpeedData> rotationSpeeds; [ReadOnly] public ComponentDataArray<RotationControlData> controlData; public int Length; } [Inject] private LocalRotationSpeedGroup _rotationGroup; [ComputeJobOptimization] struct RotateJob : IJobParallelFor { public ComponentDataArray<Rotation> rotations; [ReadOnly] public ComponentDataArray<RotationSpeedData> rotationSpeeds; [ReadOnly] public ComponentDataArray<RotationControlData> controlData; public float dt; public void Execute(int i) { var speed = rotationSpeeds[i].Value; if (speed > 0.0f) { quaternion nRotation = math.normalize(rotations[i].Value); float yaw = controlData[i].yaw * speed * dt; float pitch = controlData[i].pitch * speed * dt; float roll = -controlData[i].roll * speed * dt; quaternion result = math.mul(nRotation, Euler(pitch, roll, yaw)); rotations[i] = new Rotation { Value = result }; } } quaternion Euler(float roll, float yaw, float pitch) { float cy = math.cos(yaw * 0.5f); float sy = math.sin(yaw * 0.5f); float cr = math.cos(roll * 0.5f); float sr = math.sin(roll * 0.5f); float cp = math.cos(pitch * 0.5f); float sp = math.sin(pitch * 0.5f); float qw = cy * cr * cp + sy * sr * sp; float qx = cy * sr * cp - sy * cr * sp; float qy = cy * cr * sp + sy * sr * cp; float qz = sy * cr * cp - cy * sr * sp; return new quaternion(qx, qy, qz, qw); } } protected override JobHandle OnUpdate(JobHandle inputDeps) { var job = new RotateJob { rotations = _rotationGroup.rotations, rotationSpeeds = _rotationGroup.rotationSpeeds, controlData = _rotationGroup.controlData, dt = Time.deltaTime }; return job.Schedule(_rotationGroup.Length, 64, inputDeps); } } 


Probablemente se preguntará por qué no puede simplemente pasar 3 componentes a la vez a Job, como en MovementSystem. Porque Luché con esto durante mucho tiempo, pero no sé por qué no funciona así. En las muestras, los giros se implementan a través de ComponentDataArray, pero no retrocedemos de los cánones.

¡Lanzamos el prefabricado al escenario, colgamos los componentes, atamos la cámara, colocamos fondos de pantalla aburridos y listo!



Conclusión


Los chicos de Unity Technologies se han movido en la dirección correcta de multihilo. El sistema de trabajo en sí todavía está húmedo (la versión alfa es, después de todo), pero es bastante utilizable y se está acelerando ahora. Desafortunadamente, los componentes estándar no son compatibles con el Sistema de trabajo (¡pero no con el ECS por separado!), Por lo que debe esculpir muletas para evitar esto. Por ejemplo, una persona del foro de Unity implementa su sistema físico para la GPU y, al igual, avanza.
ECS with Unity se usaba antes, hay varios análogos prósperos, por ejemplo, un artículo con una descripción general de los más famosos. También describe los pros y los contras de este enfoque de la arquitectura.

Por mi parte, puedo agregar una ventaja como la pureza del código. Comencé tratando de implementar el movimiento en un sistema. El número de componentes de dependencia creció rápidamente, y tuve que dividir el código en sistemas pequeños y convenientes. Y se pueden reutilizar fácilmente en otro proyecto.

El código del proyecto está aquí: GitHub

Source: https://habr.com/ru/post/es413897/


All Articles