Esta postagem é a terceira parte de uma série que aborda DOTS (Jobs + ECS + Burst), desta vez falando um pouco sobre ECS. Se você não viu as últimas duas postagens, poderá acompanhá-las através dos links abaixo:

Esta postagem é fortemente baseada na documentação do Unity ECS que se encontra na versão 0.5 (Preview) na data de escrita deste post.

Um Breve resumo sobre ECS

O ECS (Entity Component System) é o coração do conceito DOTS. Como o nome sugere, ECS é composto por três elementos básicos, estes elementos estão descritos abaixo:

Entidades:

Basicamente, uma entidade é um identificador no mundo ECS. Ela serve como uma ID, um ponteiro. Podemos pensar nelas como um GameObject super leve que não possui nem mesmo um nome. Esta id pode ser usada armazenar uma referência a outras entidades ou componentes. Uma entidade filha pode precisar referenciar sua entidade pai em uma hierarquia, por exemplo. 

A medida que criamos entidades, geralmente adicionamos componentes a elas, e é isso que define como as entidades serão organizadas no sistema ECS. O responsável por esse gerenciamento é o EntityManager.

O ECS agrupa todas as entidades que possuem exatamente os mesmos componentes juntos na memória. Este conjunto de componentes é chamado de Archetype.

Com este tipo de layout de dados, trabalhamos de forma muito mais eficaz, pois iteramos e trabalhamos exatamente no que nos interessa.

Para qualquer iteração nas entidades, é necessário especificar exatamente quais tipos de componentes elas terão que ter. Se ainda não ficou claro, isso fará mais sentido ao longo deste post, eu prometo! 

Neste diagrama, as entidades A e B compartilham o arquétipo M, enquanto a entidade C possui o arquétipo N.
Você pode alterar fluidamente o arquétipo de uma entidade adicionando ou removendo componentes em tempo de execução. Por exemplo, se você remover o componente Renderer da entidade B, B será movido para o arquétipo N.

Componentes:

As entidades são identificadores que indexam os componentes no ECS.

Os componentes são as estruturas que representam os dados de seu jogo ou programa.

Para a utilização dos componentes, os structs que compõem os dados devem fazer parte de uma das seguintes estruturas: (Não se preocupe, veremos mais sobre elas adiante.)

  • IComponentData
    Componentes de uso geral e Chunk;
  • IBufferElementData
    Utilizada para associar buffers dinâmicos a entidades;
  • ISharedComponentData
    Utilizado para categorizar e/ou agrupar entidades por valor de componentes dentro de um Archetype.
  • BlobAssets
    Tecnicamente não é um componente. BlobAssets são utilizados para associar uma estrutura que contém dados imutáveis a entidades. Por exemplo, pode ser utilizado para armazenar dados de configurações que nunca mudam em entidades de personagens.

Chunks

Como vimos anteriormente, o EntityManager agrupa todas as entidades que possuem o mesmo tipo de componentes em Archetypes. Chunks são os blocos de memória onde residem todas as entidades com o mesmo  Archetype. Abaixo uma ilustração da documentação da unity:

Sistemas

Sistemas são a lógica de nosso jogo em si. São eles quem alteram os dados de nossos componentes. São os sistemas que, por exemplo, modificam a posição/rotação de uma entidade na cena.Os principais tipos de sistemas são  EntityComponentSystem e JobComponentSystem. Eles facilitam a seleção/iteração das entidades com base nos componentes associados a elas.

Os sistemas possuem métodos como OnCreate() e OnUpdate(), que a grosso modo, correspondem aos “velhos” Start() e Update(). Estes métodos são chamados no Thread principal, mas podem (e devem) ser utilizados para agendar trabalhos.

Em geral, sistemas  JobComponentSystem tem um melhor desempenho, pois utilizam o JobSystem da Unity.

Métodos comuns dos Sistemas:

  • OnCreate():
    Chamado quando o sistema é criado.
  • OnStartRunning():
    Chamado antes do primeiro OnUpdate().
  • OnUpdate():
    Chamado em todos os quadros, quando a iteração tem algum resultado.
  • OnStopRunning():
    É chamado sempre que que o sistema parar de atualizar porque não encontra entidades correspondentes à consulta. Também é chamado antes de OnDetroy.
  • OnDestroy():
    É chamado quando o sistema é destruído.

Lembre-se: Todos os métodos acima são chamados no Thread principal.

Tipos de iterações

  • JobComponentSystem.Entities.ForEach:
    É a maneira mais simples de iterar/processar os dados de entidades.
  • IJobForEach:
    Use um struct de um Job para iterar entre as entidades de forma mais eficiente.
  • IJobForEachWithEntity:
    Parecido com IJobForEach, mas dá acesso aos índices da entidade e da matriz da entidade que você está processando.
  • IJobChunk:
    Itera diretamente nos blocos de memória (Chunks). Pode ser utilizado para tarefas mais complexas em que os tipos anteriores não cobrem.
  • ComponentSystem:
    Fornece delegates de Entities.ForEach, porém, é executado apenas no Thread principal.
  • EntityQuery:
    Era mais utilizado antes de os métodos anteriores serem lançados, mas ainda pode ser utilizado para criar consultas mais específicas. EntityQuery é a base da maioria das iterações mostradas anteriormente. 

Assim como o Unity Jobs otimiza o desempenho distribuindo a lógica entre os núcleos do processador de maneira eficiente, o ECS otimiza como os dados são armazenados na memória e também a maneira com que esses dados são processados.

Exemplos

Para ilustrar o conteúdo deste post, alguns exemplos serão criados e pretendo cobrir a maioria do que foi mostrado até aqui.

Devido a quantidade de conteúdo e, para não tornar a leitura massante, os exemplos não se limitam apenas aos mostrados aqui, podendo ter outras postagens contendo casos de uso.

Antes de mais nada, preciso mostrar aqui as versões dos pacotes instalados. Na data de escrita deste post, estou utilizando a Unity 2019.3.0f5 e a lista abaixo mostra os pacotes instalados e suas versões:

  • Entities preview11-0.5.1;
  • Hybrid Renderer Preview11-0.3.3;
  • Burst 1.2.1;
  • Mathematics 1.1.0;
  • Jobs 0.2.4-preview11

Os demais pacotes são dependências dos pacotes listados acima. Então, se você pretende reproduzir os exemplos a seguir, certifique-se de instalar os devidos pacotes.

Criando e debugando entidades

Componente Convert To Entity

Como entidades são apenas “ponteiros” para componentes, ao criar uma entidade, precisamos atribuir à entidade todos os componentes relativos a ela incluindo o Transform, Renderer, etc. Graças ao trabalho da equipe da Unity e a evolução do ECS, existe o componente Convert to Entity, que facilita este trabalho, copiando e convertendo os componentes compatíveis para nós.

Para o nosso primeiro teste, vamos apenas instanciar um cubo em nossa cena convertendo-o para uma entidade.

  1. Crie um Cubo em sua cena;
  2. Com o cubo selecionado, vá em Add Component>DOTS>Convert To Entity;
  3. Remova o componente BoxCollider: Elementos de física padrão da Unity não são compatíveis com ECS e não possuem componentes equivalentes para conversão. Pra isso existem Unity Physics e Havok Physics for Unity.
Unity ECS - Convert to Entity

Olhando para o script Convert To Entity, temos duas opções:

  • Convert And Destroy:
    Converte o GameObject em uma entidade contendo todos os componentes necessários e em seguida, destrói o GameObject de “referência”.
  • Convert And Inject Game Object:
    Cria uma entidade e adiciona os componentes compatíveis, mas não destrói o GameObject em questão. Isso é útil para casos de uso em uma abordagem Híbrida onde precisamos manipular componentes não compatíveis com o ECS como SkinnedMeshRenderer, por exemplo.
    *(A abordagem Híbrida será abordada em futuros posts.)

Deixe a opção Convert And Destroy marcada e entre no modo de reprodução. Em seguida, vá para Window>Analisys>Entity Debugger:

Unity ECS - Entity Debugger menu
Unity ECS - Entity Debugger Window

No lado esquerdo da imagem acima vemos a nossa entidade e no direito, vemos todos os componentes referentes a ela.
A maioria destes componentes fazem parte do pacote Hybrid Renderer.
Quando um GameObject é convertido em uma entidade, o sistema de conversão procura os componentes MeshRenderer e MeshFilter, e os converte no componente RenderMesh na entidade.
Também são adicionados os componentes LocalToWorld, Translation e Rotation com base no Transform do GameObject.

Como pudemos ver, o sistema de conversão é uma “Mão na roda“!

“Instanciando” Entidades

Bem, eu não gosto do termo “instanciar” para entidades, uma vez que elas são identificadores para componentes. Mas acredito que isso seja um facilitador para o cérebro de quem está migrando de POO e não consegui pensar em nada mais curto que isso, então, ele será utilizado. Seguindo em frente…

Nos exemplos abaixo, iremos instanciar alguns cubos de duas maneiras diferentes. Também criaremos um componente e um sistema para eles.
Os cubos terão uma quantidade de vida que será decrementada conforme o tempo.

Criando o primeiro componente

Crie um script Chamado CubeComponent com o seguinte conteúdo:

using System;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;

[Serializable]
public struct CubeComponent : IComponentData
{
    public float health;
}

Nota: Como nossas instâncias de componentes são criadas/modificadas a partir de outros scripts, todos os campos devem ser serializáveis e/ou públicos.

Atribuindo nosso componente a entidades

Com o nosso componente criado, vamos dizer ao sistema de conversão que ele deve atribuir este componente às entidades criadas. Nós também podemos atribuir e remover componentes em tempo de execução mas, por hora vamos nos ater ao CubeComponent.
Crie um script chamado CubeAuthoring.cs com o código abaixo. O código está comentado e é auto-descritivo.

using Unity.Entities;
using Unity.Mathematics;
using UnityEngine;

/// <summary>
/// Classe responsável pela conversão de um GameObject em uma entidade.
/// Este script deve ser adicionao ao GameObject que será conertido em entidade.
/// Basicamente, sua função é adicionar componentes a entidades.
/// </summary>
public class CubeAuthoring : MonoBehaviour, IConvertGameObjectToEntity
{
    //Variáveis de referência podem ser utilizadas aqui, uma vez que a classe é derivada de monoBehaviour.
    public float startHealth = 100f; 
    
    
    /// <summary>
    /// Método responsável pela conversão propriamente dita.
    /// Durante a conversão, podemos adicionar mais de um componente às entidades criadas.
    /// </summary>
    public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
    {
        //Adicionamos o componente "CubeComponent" à entidade recém-criada.
        dstManager.AddComponentData(entity, new CubeComponent
        {
            health = startHealth
        });
    }
}

Atribuindo componentes automaticamente a entidades

Alternativamente, também podemos fazer com que os componentes sejam atribuídos automaticamente às nossas entidades.

Para que isso aconteça, basta marcar a estrutura que compõe o componente com a flag. Vejamos como isso funciona na prática. Marque a estrutura de CubeComponent com a flag [GenerateAuthoringComponent]:

using System;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;

[Serializable]
[GenerateAuthoringComponent]
public struct CubeComponent : IComponentData
{
    public float health;
}

Agora remova o script CubeAuthoring do cubo e adicione diretamente o script que contém a estrutura de nosso componente a ele (CubeComponent.cs).
Você poderá notar que um script chamado CubeComponentAuthoring será adicionado a ele automaticamente.

O uso da flag gera automaticamente um monobehaviour de “autoração” de nosso componente.

Você pode adicionar quantos componentes quiser marcados com esta flag, mas tenha em mente que isso não funcionará se você escrever mais de um componente no mesmo script.

Então, para cada componente adicionado a uma entidade utilizando [GenerateAuthoringComponent], um novo script deverá ser escrito e adicionado ao GameObject. Isso pode se tornar um problema rapidamente se tivermos muitos componentes por entidade.

É por isso que eu ainda prefiro a utilização de um script de “autoração” onde posso adicionar quantos componentes quiser sem poluir o inspector.

Criando o primeiro sistema

Agora, crie outro script Chamado CubeSystem.cs com o conteúdo abaixo:

/// <summary>
/// Cria um trabalho e o executa enquanto iteramos todas as entidades
/// que possuem o componente CubeComponent.
/// </summary>
public class CubeSystem : JobComponentSystem
{
    [BurstCompile]
    struct CubeSystemJob : IJobForEach<CubeComponent>
    {
        //Variável que receberá o valor de UnityEngine.Time.DeltaTime.
        //Lembre-se: Isto é um struct!
        public float deltaTime;

        //Decrementamos o valor da variável Health do CubeCOmponent processado.
        public void Execute(ref CubeComponent m_CubeComponent)
        {
            m_CubeComponent.health -= 0.5f * deltaTime;
        }
    }

    /// <summary>
    /// Agendadmos o nosso trabalho de fato.
    /// Lembra-se de que o agendamento de trabalhos é executado no Thread principal?
    /// </summary>
    protected override JobHandle OnUpdate(JobHandle inputDependencies)
    {
        var job = new CubeSystemJob();
        //Como o agendamento é executado no Thread principal, podemos utilizar variáveis de referência aqui.
        //Aproveitamos esse fato para setar qualquer variável do trabalho, desde que seja bittable.
        job.deltaTime = UnityEngine.Time.deltaTime;

        return job.Schedule(this, inputDependencies);
    }
}
  • Se você leu o post sobre Unity Jobs, deve estar familiarizado com o script acima.
  • O struct CubeSystemJob guarda os dados de nosso trabalho.
  • Repare que utilizamos a interface IJobForEach com o tipo CubeComponent. Resumidamente, o método Execute() será chamado durante a iteração em todas as entidades que tiverem o componente CubeComponent. Isto é obrigatório e determinístico no ECS.
  • O restante do código está comentado e é descritivo.

Com os passos acima concluídos, nosso cubo está pronto para ser convertido em entidade e já possui um componente destinado a ele. Também já temos um sistema pronto para trabalhar com o tipo CubeComponent de componente.

Atribua o script CubeAuthoring ao cubo criado e certifique-se de que ele tenha o script ConvertToEntity com a opção Convert And Destroy setada. Em seguida, entre no modo reprodução e dê uma olhada no Entity Debugger:

Unity ECS 3 - Devugando o primeiro sistema

Se tudo correu bem, ao clicar na entidade criada no debugger, você poderá notar health sendo decrementada no componente.

Neste ponto, poderíamos simplesmente criar um prefab do nosso cubo ( ou qualquer outro GameObject) com os scripts ConvertToEntity e CubeAuthoring e instanciá-los de maneira “convencional” e tudo deve funcionar corretamente.

Ter prefabs com o sistema de conversão é ok para pré-fabricados, mas restringe muito nossas opções. Em um cenário real como um sistema de spawn por exempo, as coisas precisam ser “automatizadas”.

Criando entidades em tempo de execução

Nós podemos fazer a conversão de GameObjects para entidades em tempo de execução utilizando MonoBehaviour’s ou a partir de uma entidade usando Jobs.

Mostrar estes métodos em apenas um post o tornaria longo e tedioso. Por isso os dois métodos serão mostrados em duas postagens separadas. Os respectivos links estão listados abaixo.

Instanciando a partir de um Mono Behaviour

Instanciando a partir de uma entidade

artplayer

Author artplayer

More posts by artplayer

Deixe uma resposta

Receber notícias

Digite seu endereço de e-mail para receber notificações de novas publicações por e-mail.
%d blogueiros gostam disto: