Saltar al contenido principal

Guía Estandarización ECS Entity Component System

Descripción General

Con la integración de Unity Physics y Havok, así como la nueva sintaxis para utilizar entidades en Unity se abren nuevas posibilidades para el desarrollo con DOTS.

Esto resulta especialmente útil para la creación de grandes escenarios con innumerables objetos, convirtiendo cada objeto que compone la escena en una entidad se gana bastante rendimiento, algo que se agradece bastante en desarrollo para móviles o VR standalone.

Guía de trabajo

Paso 1: Convirtiendo un Game Object a Entity.

Con las últimas actualizaciones de ECS ya es posible convertir un objeto 3D tan sólo añadiendo un script al mismo, para ello bastará con dar clic en "Add Component" en el inspector y buscar el script "Convert To Entity".

Recuerda que puedes editar tu objeto como de costumbre (Excepto en modo Play pues dejará de ser seleccionable al ser una entidad) y este conservará todas sus propiedades.

Paso 2: Ubicar una entidad en modo Play.

Como se mencionó anteriormente, al dejar de ser un GameObject, nuestro objeto no podrá ser seleccionado ni editado en modo Play.

Podemos encontrar todas las entidades que creemos en la ventana Entity Debuger y así ver sus propiedades en tiempo de ejecución, recuerda que al ser este un sistema híbrido, tanto Game Objects como Entities pueden coexistir en el mismo mundo, es decir, podemos hacer uso de tanto de MonoBehaviour como de DOTS:

Paso 3: Dotar de comportamiento a una Entidad.

Para hacer uso de DOTS (Data-Oriented Technology Stack) comprendamos éste que se trata de la union de otros 3 grandes sistemas de Unity: Job System, Entity Component System y Burst Compiler, por lo que una correcta implementación del mismo comprende de todos ellos en conjunto.

3.1: Data Authoring Components.

Comencemos con la programación orientada a datos, en este paradigma las declaraciones de un programa describen los datos a comparar y el procesamiento requerido en lugar de definir una secuencia de pasos a seguir, por lo que los métodos están aislados de los datos y se organizan en una estructura aparte de manera jerarquizada.

Haciendo uso de este principio pasaremos a crear una clase que defina únicamente los datos de nuestra entidad y otra de se encargue de definir los procesos que cada entidad realizará.

Es aquí donde entran los Data Authoring Components, componentes que definen las propiedades de las que dispondrá una entidad, en el siguiente ejemplo tomaremos una entrada del usuario, por lo que necesitaremos crear un Data Authoring Component que defina las teclas que el usuario podrá presionar:

Data Authoring Component

using Unity.Entities;
using UnityEngine;

[GenerateAuthoringComponent]
public struct PaddleInputData : IComponentData
{
public KeyCode upKey;
public KeyCode downKey;
}

También necesitaremos un Data Authoring Component que contenga las propiedades con las que cuenta nuestra Entity, mismas que serán necesarias para que adquiera un comportamiento en el futuro, en este caso únicamente requeriremos de 2 datos para su desplazamiento, "direction" y "speed".

Data Authoring Component

using Unity.Entities;

[GenerateAuthoringComponent]
public struct PaddleMovementData : IComponentData
{
public int direction;
public float speed;
}

Para generar un Data Authoring Component se utiliza la etiqueta [GenerateAuthoringComponent] antes de definir la clase, misma que deberá heredar de IComponentData.

Nota: Los Data Authoring Components, a pesar de no heredar de MonoBehaviour, deben agregarse a nuestro objeto desde el inspector como cualquier otro componente.

3.2: Systems.

Una vez definidos los datos, ahora toca hacer uso de estos para asignarle un comportamiento a nuestra entidad, esto se logra mediante un "System" que definirá los procesos o "Jobs" que se realizarán con los datos.

Continuando con el ejemplo anterior podemos implementar un System que realice cierto comportamiento cuando el usuario presione alguna de las teclas que fueron definidas anteriormente:

System

using Unity.Entities;
using Unity.Jobs;
using UnityEngine;

public class PlayerInputSystem : JobComponentSystem
{
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
Entities.ForEach((ref PaddleMovementData moveData, in PaddleInputData inputData) =>
{
moveData.direction = 0;

moveData.direction += Input.GetKey(inputData.upKey) ? 1 : 0;
moveData.direction -= Input.GetKey(inputData.downKey) ? 1 : 0;
}).Run();

return default;
}
}

Una clase System deberá heredar de JobComponentSystem. El método *OnUpdate()*será nuestro semejante al método *Update()*de *MonoBehaviour,*por lo que en este caso, será aquí donde procesaremos el Input del usuario.

El método *Entities.ForEach()*nos servirá para ejecutar código referenciado a todas las entidades que contengan cierta definición de datos, en este caso, se realizarán cambios en la propiedad *"direction"*de todas las entidades que contengan un Data Authoring Component del tipo *PaddleMovementData,*dada la presión de las teclas definidas en el Data Authoring Component del tipo PaddleInputData de esta misma entidad.

Se define el argumento del método ForEach() con una expresión lambda separando los parámetros de la función como tal, y de este mismo se ejecuta el método *Run()*lo que ejecutará toda esta sección de código sobre el hilo principal de procesamiento, si en determinado caso se quisiera hacer la ejecución en un hilo distinto gestionado por el Job System podríamos hacer uso del método Schedule() con algunos cambios en el código de la siguiente manera:

Scheduled System

using Unity.Entities;
using Unity.Jobs;
using UnityEngine;

public class PlayerInputSystem : JobComponentSystem
{
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
JobHandle scheduledJob = Entities.ForEach((ref PaddleMovementData moveData, in PaddleInputData inputData) =>
{
moveData.direction = 0;

moveData.direction += Input.GetKey(inputData.upKey) ? 1 : 0;
moveData.direction -= Input.GetKey(inputData.downKey) ? 1 : 0;
}).Schedule(inputDeps);

return scheduledJob;
}
}

Las diferencias para indicar que se requiere de la ejecución de este código fuera del hilo principal serían, en primer lugar, guardar la salida del método Entities.ForEach() en una variable del tipo JobHandle, puesto que deberemos devolverla en el return al finalizar OnUpdate, y en segundo lugar, cambiar el método Run() por el método Schedule() que recibe como parámetro el mismo JobHandle que nos ofrece en entrada el método OnUpdate(), hablamos de las Input Dependencies.

Nota: Todos los Systems programados como JobComponentSystem se ejecutarán en la aplicación aun sin estar presentes dentro de la jerarquía de la escena.

Paso 4: Físicas para entidades, Havok y Unity Physics.

Actualmente, Unity ya provee de dos sistemas de físicas que dan soporte a DOTS, por un lado el poderoso motor de físicas de Havok que en colaboración con Unity se ha integrado de forma oficial, y por otro, el nuevo motor de físicas de Unity llamado "Unity Physics" especialmente diseñado para usarse con DOTS.

Para implementar cualquiera de estos 2 sistemas requeriremos siempre de 4 componentes clave:

  • Physics Body: Se trata del equivalente a un Rigid Body tradicional, en este componente podremos editar varios parámetros relacionados con las propiedades físicas de un cuerpo dependiendo del tipo de movimiento que deseemos que tenga.

  • Physics Shape: Es el componente que hace la función de un Collider sobre una entidad, nos provee de bastantes parámetros para configurar que abarcan desde la forma (Box, Capsule, Sphere, Cylinder, Plane, Convex Hull y Mesh) hasta el comportamiento del Physic Material.

  • Physics Step: Se trata del componente donde podremos seleccionar cuál motor de físicas utilizar, es el componente principal para la implementación de físicas en DOTS pues es de este de dónde el sistema toma los datos para inicializar y ejecutar el motor de físicas seleccionado.

  • Physics Debug Display: Nos servirá para configurar la visualización de elementos relacionados con el motor de físicas.

Paso 5: Detección de colisiones en DOTS, Trigger Events.

Es posible detectar colisiones de manera nativa en DOTS, para ello requerimos hacer una configuración previa de los componentes Physics Body y Physics Step en los respectivos objetos que vayan a interactuar entre sí dentro de la escena.

Lamentablemente aún no contamos con nueva sintaxis para la programación de físicas con DOTS, por lo que aun existen ciertas limitaciones al momento de programar comportamientos complejos, éste y otros módulos siguen aún en desarrollo y al ser una tecnología nueva en Unity se esperan muchos cambios a futuro. Debemos recordar que siempre que tengamos la necesidad de recurrir a componentes no nativos de DOTS tenemos la total libertad de hacerlo y ese es el propósito de que sea un sistema híbrido.

No obstante, podemos crear un Job de la manera tradicional que ejecute un Trigger Event al colisionar dos objetos como se mostrará en el siguiente ejemplo, pero antes entendamos un poco el funcionamiento detrás del nuevo sistema de físicas de DOTS.

DOTS facilita la implementación de físicas al tener una única representación de datos que puede usarse en cualquier back-end de simulación, es decir, nuestro código va a funcionar tanto con Unity Physics como con Havok pues estará escrito con un diseño de datos de editor unificado que es totalmente independiente del back-end de simulación de física.

En el siguiente diagrama se ilustra el flujo de datos que siguen las entidades al hacer uso de DOTS Physics.

Una vez entendiendo a groso modo el funcionamiento del nuevo sistema de físicas procedamos con el ejemplo, que consistirá en ejecutar un comportamiento sencillo al detectar que un objeto atraviese nuestro Trigger de colisión.

Para ello crearemos un nuevo System que defina no solamente el Job principal a ejecutar, sino también la estructura que defina las acciones a ejecutar cuando el evento de colisión ocurra:

Trigger Detection System

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Entities;
using Unity.Jobs;
using Unity.Physics;
using Unity.Physics.Systems;
using Unity.Mathematics;
using Unity.Burst;

public class TriggerDetection : JobComponentSystem {

[BurstCompile]
private struct TriggerJob : ITriggerEventsJob {
public ComponentDataFromEntity<PhysicsVelocity> physicsVelocityEntities;

public void Execute(TriggerEvent triggerEvent)
{
if (physicsVelocityEntities.HasComponent(triggerEvent.Entities.EntityA)) {
PhysicsVelocity physicsVelocity = physicsVelocityEntities[triggerEvent.Entities.EntityA];
physicsVelocity.Linear.y = 5f;
physicsVelocityEntities[triggerEvent.Entities.EntityA] = physicsVelocity;
}

if (physicsVelocityEntities.HasComponent(triggerEvent.Entities.EntityB))
{
PhysicsVelocity physicsVelocity = physicsVelocityEntities[triggerEvent.Entities.EntityB];
physicsVelocity.Linear.y = 5f;
physicsVelocityEntities[triggerEvent.Entities.EntityB] = physicsVelocity;
}
}

}

private BuildPhysicsWorld buildPhysicsWorld;
private StepPhysicsWorld stepPhysicsWorld;

protected override void OnCreate()
{
buildPhysicsWorld = World.GetOrCreateSystem<BuildPhysicsWorld>();
stepPhysicsWorld = World.GetOrCreateSystem<StepPhysicsWorld>();
}

protected override JobHandle OnUpdate(JobHandle inputDeps)
{
TriggerJob triggerJob = new TriggerJob
{
physicsVelocityEntities = GetComponentDataFromEntity<PhysicsVelocity>()
};
return triggerJob.Schedule(stepPhysicsWorld.Simulation, ref buildPhysicsWorld.PhysicsWorld,inputDeps);
}

}

No olvidemos de activar la casilla "Is Trigger" en el componente Physics Shape de el objeto donde queramos detectar las colisiones, así como también hay que recordar que los objetos que colisionen con este deberán tener un componente Physics Body para que sean detectados.

Al analizar el código anterior encontraremos que, la primera parte compilada con Burst, es una estructura que hereda de ITriggerEventsJob, esta clase nos servirá siempre que queramos utilizar Triggers en DOTS.

Lo primero que encontramos dentro de la estructura es una variable ComponentDataFromEntity<> que será una colección dónde se guardarán las referencias a todas las entidades que contengan el componente definido, en este caso se guardarán todas las entidades que contengan el componente PhysicsVelocity que pertenece a Unity.Physics, pero podemos definir cualquier Data Authoring Component que hayamos creado para diferenciar a nuestras entidades.

Los siguiente es implementar el método Execute() que se ejecuta automáticamente cuando el sistema detecta la colisión, que recibe como parámetro un TriggerEvent, es de éste de donde obtendremos los datos de las 2 entidades comprendidas dentro de la colisión descritas como EntityA y EntityB.

Dentro del método *Execute()*nos encontraremos un par de condicionales, una para cada una de las entidades mencionadas anteriormente, dentro de estas condicionales hacemos lo siguiente:

  • Obtenemos los datos de la entidad, A o B según necesitemos, a partir del grupo del tipo ComponentDataFromEntity que definimos al inicio.
  • Modificamos los datos del componente a nuestra voluntad, en este caso sólo sobreescribiremos la velocidad en Y.
  • Reasignaremos los datos de componente modificados a la entidad de la que los obtuvimos.

Una vez implementada la estructura debemos construir el Physics World como se marca en el diagrama que vimos previamente, para ello crearemos 2 variables que asignaremos dentro de OnCreate() haciendo uso del método World.GetOrCreateSystem<StepPhysicsWorld>(), necesitaremos una variable para referenciar al BuildPhysicsWorld y una variable StepPhysicsWorld para obtener el back-end de simulación.

Por último creamos el Job dentro del método OnUpdate(), aquí deberemos asignar cada una de las colecciones ComponentDataFromEntity<> de acuerdo a la estructura que definimos anteriormente mediante el uso del método GetComponentDataFromEntity<>(). Antes de finalizar debemos regresar en returnel JobHandleobtenido de nuestro Job, en este caso usaremos el método Schedule() para programar la tarea en un hilo distinto al principal, deberemos pasar los por parámetros la información que obtuvimos de las 2 variables que asignamos previamente en OnCreate() y las Input Dependencies.

El resultado que obtendremos de este ejemplo será una entidad que "salte" (con una velocidad lineal de 5 en su eje Y como definimos en el método Execute()) cada vez que se detecte una colisión con nuestro Trigger.

Consideraciones

Para comenzar a utilizar Entity Component System requerimos de la versión 2019 de Unity en adelante, así como de los paquetes de Entities, Jobs, Burst, DOTS Platforms, DOTS Editor, Hybrid Renderer, así como Unity Physics y Havok para implementar físicas directamente en DOTS.

Comentarios

Ventajas

  • La ganancia en rendimiento es brutal comparándola con una aplicación de Unity tradicional
  • Permite el uso de procesamiento multihilo
  • Ofrece un entorno híbrido dónde pueden existir GameObjects tradicionales y Entidades en la misma escena
  • Permite conservar las propiedades de un GameObject tradicional
  • Es posible convertir facilmente un GameObject existente en una Entidad
  • Evita el uso de componentes innecesarios por cada objeto existente en la escena
  • Ofrece una estructura ordenada dando prioridad a los datos
  • Permite el uso de motores de física avanzados como Havok
  • Puede convinarse programación de DOTS y MonoBehaviour mediante el acceso común a datos.

Desventajas

  • Es un paradigma de programación totalmente diferente
  • Hasta el momento no está estandarizada la gestión de colisiones e interacciones entre objetos
  • Hay elementos de Unity que no son compatibles con DOTS como lo son los Canvas
  • Implica mayor dificultad diseñar un sistema multihilo orientado a datos
  • No es compatible con versiones de Unity anteriores a 2019

Elaboró

Diego Alberto Tovar Razo