ArtPlayer Games https://artplayergames.com Artigos, tutoriais e novidades relacionadas a Unity Engine Fri, 18 Sep 2020 23:23:29 +0000 pt-BR hourly 1 https://wordpress.org/?v=5.5.1 https://artplayergames.com/wp-content/uploads/2020/01/cropped-Logo_tr-32x32.png ArtPlayer Games https://artplayergames.com 32 32 171729667 Unity DOTS – Sistema de Spawn MultiThreading com ECS https://artplayergames.com/unity-dots-sistema-de-spawn-multithreading-com-ec/ https://artplayergames.com/unity-dots-sistema-de-spawn-multithreading-com-ec/#respond Fri, 07 Feb 2020 14:59:54 +0000 https://artplayergames.com/?p=332 Esta postagem faz parte de uma série sobre ECS. Se você não acompanhou as postagens anteriores, poderá começar a partir daqui. Utilização de MultiThreading em sistemas de spawn Como vimos...

O post Unity DOTS – Sistema de Spawn MultiThreading com ECS apareceu primeiro em ArtPlayer Games.

]]>

Esta postagem faz parte de uma série sobre ECS. Se você não acompanhou as postagens anteriores, poderá começar a partir daqui.

Utilização de MultiThreading em sistemas de spawn

Como vimos no post anterior, criar entidades a partir de MonoBehaviours é decente e funciona. Mas pense em um cenário em que temos um grande número de entidades sendo instanciadas ao mesmo tempo.

Se assim como eu, você já teve que enfrentar este tipo de cenário, deve ter percebido um gargalo no desempenho e provavelmente fez uso de co-rotinas para amenizar o problema.

Instanciar as entidades utilizando um sistema MultiThreading faz todo sentido neste tipo de cenário e, particularmente eu acredito que na maioria dos outros cenários também. Por que não?

Antes de começarmos a espalhar nossas entidades pelo mundo, há algumas coisas que precisamos entender.

EntityCommandBuffer, lembre-se dele!

Quando alteramos dados estruturais no mundo ECS como por exemplo adicionar ou remover componentes, criar ou destruir entidades, corremos sérios riscos de criar uma condição de corrida se estivermos trabalhando em um sistema MultiThreading, pois outros threads ou até mesmo o thread principal podem estar lendo/gravando estes dados. Além disso, em um job não temos acesso ao nosso querido EntityManager.

Basicamente um EntityCommandBuffer (ou Buffer de comando de Entidade) como o nome sugere, é um buffer de trabalhos. Os trabalhos neste buffer são enfileirados e executados somente após todos os jobs anteriores estarem completos no quadro atual, resolvendo as questões de condições de corrida. No entanto, os mundos no sistema ECS possuem uma hierarquia de atualização que devemos respeitar e é preciso definir em que ordem os nossos ECB’s serão executados. Isso será mostrado abaixo.

Por favor, não ignore a leitura abaixo!

Talvez pareça um exagero, mas entender a ordem de atualização dos sistemas, ajuda a resolver grande parte dos problemas encontrados no desenvolvimento com DOTS. E saber a ordem em que as coisas funcionam, realmente faz diferença!

Ordem de atualização de sistemas

Cada SystemComponentGroup ( ou Grupo de Sistema de componentes ) especifica a ordem de atualização dos sistemas no mundo ECS. Nós podemos colocar um sistema em um grupo utilizando o atributo [UpdateInGroup] na classe do sistema e também utilizar [UpdateBefore] e [UpdateAfter] para especificar em que ordem de atualização dentro do grupo nosso sistema será executado.

Atributos utilizados com SystemComponentGroup

Na lista abaixo veremos alguns dos atributos utilizados na declaração de um sistema:

  • [UpdateInGroup] – Especifica um ComponentSystemGroup  ao qual este sistema deverá ser membro. Se esse atributo for omitido, o sistema será automaticamente adicionado ao grupo SimulationSystem padrão do mundo (veja abaixo).
  • [UpdateBefore] e [UpdateAfter] – Utilizados para ordenar a ordem de atualização dos sistemas. Os sistemas especificados serão ordenados dentro do grupo ao qual pertencem.

Grupos de sistema padrão

A lista abaixo mostra os grupos de sistema presentes no mundo padrão e uma breve descrição de cada um:

  • InitializationSystemGroup – Atualizado no final da inicialização do Player Loop.
  • SimulationSystemGroup – Atualizado no final de cada update.
  • PresentationSystemGroup – Atualizado no final de cada PreLateUpdate.

Você pode encontrar a árvore completa dos grupos de sistema AQUI.

Enfim, instanciando nossas entidades!

Então, depois de tudo esclarecido (eu espero que sim!), chegou a hora de vermos como instanciar nossas entidades utilizando EntityCommandBuffers.

Iremos criar uma entidade com um componente contendo os dados necessários para a criação das entidades que serão spawnadas e um sistema que irá processar estes dados e colocá-los em um trabalho com a utilização de um entityCommandBuffer que será responsável pela criação das entidades referidas.

SpawnnerComponent

Crie um script chamado SpawnnerComponent.cs com o conteúdo abaixo:

using Unity.Entities;
using Unity.Mathematics;

public struct SpawnnerComponent : IComponentData
{
    public Entity Prefab;
    public int SpawnNumber;
    public int divisions;
}

Nada de novo aqui, apenas a struct que compõe nosso componente.

SpawnnerAuthoring

Abaixo, o conteúdo do script SpawnnerAuthoring.cs:

using System.Collections.Generic;
using Unity.Entities;
using UnityEngine;
using Unity.Mathematics;
/// <summary>
/// Este script de conversão é semelhante aos vistos anteriormente.
/// A novidade aqui é o uso da interface IDeclareReferencedPrefabs.
/// </summary>

[RequiresEntityConversion]
public class SpawnnerAuthoring : MonoBehaviour, IDeclareReferencedPrefabs, IConvertGameObjectToEntity
{
    public GameObject Prefab;
    public int spawnNumber;
    public int divisions;

    //Os prefabs devem ser referenciados para que o sistema de conversão os conheça com antecedência.
    public void DeclareReferencedPrefabs(List<GameObject> referencedPrefabs)
    {
        referencedPrefabs.Add(Prefab);
    }

    public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
    {
        //Adicionamos SpawnnerComponent e setamos suas variáveis de forma apropriada.
        SpawnnerComponent m_spawnnerComponent = new SpawnnerComponent
        {
            Prefab = conversionSystem.GetPrimaryEntity(Prefab),
            SpawnNumber = spawnNumber,
            divisions = divisions
        };

        dstManager.AddComponentData(entity, m_spawnnerComponent);
    }
}

É semelhante aos scripts de conversão vistos anteriormente. A novidade é o uso da interface IDeclareReferencedPrefabs que está comentada no script.

SpawnnerSystem

Agora, vamos criar o script que contém o nosso sistema de spawn:

using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Transforms;
using Unity.Mathematics;

//Colocamos o nosso sistema dentro do grupo SimulationSystemGroup (Que é atualizado ao final do ciclo Update).
[UpdateInGroup(typeof(SimulationSystemGroup))]
public class SpawnnerSystem : JobComponentSystem
{
    //Utilizamos BeginSimulationEntityCommandBufferSystem, pois ele é atualizado antes de TransformSystemGroup.
    BeginInitializationEntityCommandBufferSystem m_EntityCommandBufferSystem;

    protected override void OnCreate()
    {
        // Setamos BeginInitializationEntityCommandBufferSystem para não precisar referenciá-la em cada quadro.
        m_EntityCommandBufferSystem = World.GetOrCreateSystem<BeginInitializationEntityCommandBufferSystem>();
    }

    /// <summary>
    /// IJobForEachWithEntity é semelhante a IJobForEach, mas dá acesso ao índice da entidade processada.
    /// Neste trabalho, varremos todas as entidades que contém o componente SpawnnerComponent e de acordo com
    /// os seus dados, "spawnamos" as entidades correspondentes.
    /// </summary>
    [BurstCompile]
    public struct SpawnnerJob : IJobForEachWithEntity<SpawnnerComponent>
    {
        //Referência ao nosso commandBuffer.
        public EntityCommandBuffer.Concurrent commandBuffer;

        public void Execute(Entity entity, int entityIndex, ref SpawnnerComponent m_SpawnnerComonent)
        {
            /* Instanciamos nossas entidades com algum espaço entre elas.
             * Repare que utilizamos o nosso commandBuffer ao invés de EntityManager.Instantiate().
             * Após criar a entidade, setamos o valor do componente Translation para posicionar no local desejado.
             */
            float3 spawnPosition = float3.zero;
            int divisions = 0;

            for (int i = 0; i < m_SpawnnerComonent.SpawnNumber; i++)
            {
                Entity instance = commandBuffer.Instantiate(entityIndex, m_SpawnnerComonent.Prefab);
                commandBuffer.SetComponent(entityIndex, instance, new Translation {Value = spawnPosition});
                spawnPosition.x += 1.25f;
                divisions++;

                if (divisions == m_SpawnnerComonent.divisions)
                {
                    spawnPosition.y += 1.25f;
                    divisions = 0;
                    spawnPosition.x = 0;
                }
            }

            //Destruímos a entidade que contém os dados.
            commandBuffer.DestroyEntity(entityIndex, entity);
        }
    }

    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        JobHandle handle = new SpawnnerJob{
            //Criamos e setamos nosso commandBuffer e agendamos nosso trabalho.
            commandBuffer = m_EntityCommandBufferSystem.CreateCommandBuffer().ToConcurrent()
        }.Schedule(this, inputDeps);

        // Precisamos dizer ao sistema qual trabalho ele precisa concluir antes que ele possa reproduzir os comandos.
        m_EntityCommandBufferSystem.AddJobHandleForProducer(handle);

        return handle;


    }
}

Iteramos todas as entidades com o componente SpawnnerComponent utilizando IJobForEachWithEntity que nos dá acesso ao índice da entidade (necessário para que tenhamos uma referência na hora de destruí-la).

Ao processar estes dados, criamos e posicionamos as entidades resultantes utilizando um EntityCommandBuffer. Estes dados são processados em um trabalho.

Conclusão

Para testar nosso sistema, crie um um objeto vazio na cena e adicione os scripts ConvertToEntity e SpawnnerAuthoring. Não esqueça de marcar a opção Convert and Destroy no script ConvertToEntity.

Crie um prefab e defina ele na variável Prefab de SpawnnerAuthoring. Defina também as variáveis Division e SpawnNumber.

No meu caso, utilizei o prefab de uma esfera, defini o número de objetos a serem instanciados como 10.000 com 100 divisões. O tempo para a criação das entidades foi praticamente instantâneo:

Dica:

Para um melhor desempenho, crie um material para o prefab a ser instanciado e marque a opção “Enable GPU Instancing”.

Apesar de ser um pouco mais complexo, um sistema de spawn utilizando Jobs faz sentido em cenários em que é preciso criar grandes quantidades de entidades.

O post Unity DOTS – Sistema de Spawn MultiThreading com ECS apareceu primeiro em ArtPlayer Games.

]]>
https://artplayergames.com/unity-dots-sistema-de-spawn-multithreading-com-ec/feed/ 0 332
Instanciando entidades a partir de um Mono Behaviour com ECS https://artplayergames.com/instanciando-entidades-a-partir-de-um-mono-behaviour-com/ https://artplayergames.com/instanciando-entidades-a-partir-de-um-mono-behaviour-com/#respond Tue, 04 Feb 2020 18:08:07 +0000 https://artplayergames.com/?p=292 No post anterior, pudemos ver um resumo geral sobre ECS. Desta vez, veremos como “instanciar” entidades e definir seus componentes em tempo de execução a partir de um MonoBehaviour. Em...

O post Instanciando entidades a partir de um Mono Behaviour com ECS apareceu primeiro em ArtPlayer Games.

]]>
No post anterior, pudemos ver um resumo geral sobre ECS. Desta vez, veremos como “instanciar” entidades e definir seus componentes em tempo de execução a partir de um MonoBehaviour.

Em jogos reais é comum termos sistemas de Spawn, o exemplo abaixo é baseado nas amostras da Unity no GitHub e ilustra este caso de uso com ECS.

Se você acompanhou a postagem anterior a essa, já deve ter seu componente e sistema criados.

Crie um script chamado SpawnFromMonoBehaviour.cs com o seguinte conteúdo:

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

public class SpawnFromMonoBehaviour : MonoBehaviour
{
    #region Variables
    //Prefab a ser instanciado e convertido.
    public GameObject Prefab;
    #endregion

    #region Main Methods
    private void Start()
    {
        //GameObjectConversionSettings é a ferramenta de conversão de entidades.
        //Dizemos à ferramenta de conversão que ela utilizará o mundo padrão no ECS.
        GameObjectConversionSettings settings = GameObjectConversionSettings.FromWorld(World.DefaultGameObjectInjectionWorld, null);
        //Nosso EntityManager.
        EntityManager entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
        //Criamos uma entidade de referência ao prefab que será instanciado.
        Entity entityPrefab = GameObjectConversionUtility.ConvertGameObjectHierarchy(Prefab, settings);
        //Nomeamos o prefab de REFERÊNCIA para melhor compreensão no EntityDebugger.
        entityManager.SetName(entityPrefab, "ReferenceEntityPrefab");

        //Posição inicial.
        float3 position = float3.zero;
        //Rotação Inicial.
        quaternion rotation = quaternion.identity;

        //Instanciamos nosso objeto algumas vezes em um laço simples.
        for (int i = 0; i < 10; i++)
        {
            Entity entityInstance = entityManager.Instantiate(entityPrefab);
            //Setamos os valores dos componentes Translation e Rotation que são adicionados automaticamente durante a conversão.
            entityManager.SetComponentData(entityInstance, new Translation { Value = position });
            entityManager.SetComponentData(entityInstance, new Rotation { Value = rotation });
            //Nomeamos nossa entidade para melhorar a compreensão no EntityDebugger.
            entityManager.SetName(entityInstance, "InstantiatedEntity["+i+"]");

            //Adicionamos algum espaço no eixo X entre os objetos instanciados.
            position.x += 1.25f;
        }

    }
    #endregion
}

O código está comentado e é auto-explicativo.

Adicione o script a um GameObject vazio na cena e adicione nosso script a ele. Também defina o prefab a ser instanciado.

O script ConvertToEntity não é necessário aqui. Apenas o Script de autoração, caso deseje usar algum componente.

Unity ECS - Instanciando entidades a partir de um MonoBehaviour.

Observações:

Devo reforçar que a variável entityPrefab é uma entidade de referência criada na memória. Assim o instanciamento se torna muito mais rápido se precisarmos instanciar este prefab mais de uma vez.

Em um cenário com diversas entidades a serem instanciados, você pode criar sua própria lógica como por exemplo criar entidades de referência de todos os prefabs disponíveis, armazenar estas referências em uma lista ou dicionário e instanciá-los a partir de seu id / key.

Lembre-se:

Este código será executado apenas no thread principal, então use com moderação.

O post Instanciando entidades a partir de um Mono Behaviour com ECS apareceu primeiro em ArtPlayer Games.

]]>
https://artplayergames.com/instanciando-entidades-a-partir-de-um-mono-behaviour-com/feed/ 0 292
DOTS 3#1 – ECS – Otimizando a memória (E a mente) https://artplayergames.com/dots-ecs-otimizando-a-memoria-e-a-mente/ https://artplayergames.com/dots-ecs-otimizando-a-memoria-e-a-mente/#respond Fri, 31 Jan 2020 16:27:01 +0000 https://artplayergames.com/?p=198 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...

O post DOTS 3#1 – ECS – Otimizando a memória (E a mente) apareceu primeiro em ArtPlayer Games.

]]>
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

O post DOTS 3#1 – ECS – Otimizando a memória (E a mente) apareceu primeiro em ArtPlayer Games.

]]>
https://artplayergames.com/dots-ecs-otimizando-a-memoria-e-a-mente/feed/ 0 198
Unity – Novo NetCode https://artplayergames.com/unity-novo-netcode/ https://artplayergames.com/unity-novo-netcode/#comments Sat, 25 Jan 2020 16:32:34 +0000 http://34.68.93.28/?p=188 O post Unity – Novo NetCode apareceu primeiro em ArtPlayer Games.

]]>

Um vídeo fantástico que fala um pouco sobre DOTS e mostra o novo netcode da unity (Baseado em DOTS) juntamente com o LiveLink. Vale muito a pena assistir até o final!

Lembrando que o pacote Unity NetCode já está disponível em preview a partir da Unity 2019.3 .

Se esse assunto te interessa, não deixe de dar uma olhada neste post.

 

O post Unity – Novo NetCode apareceu primeiro em ArtPlayer Games.

]]>
https://artplayergames.com/unity-novo-netcode/feed/ 2 188
DOTS Parte 2 – Unity Job System https://artplayergames.com/dots-parte-2-unity-job-system/ https://artplayergames.com/dots-parte-2-unity-job-system/#comments Thu, 23 Jan 2020 19:28:04 +0000 http://34.68.93.28/?p=87 Este post faz parte da série que fala sobre unity DOTS. O primeiro post está disponível a partir deste link.Desta vez, conheceremos o Unity Job System . Tabela de conteúdo:...

O post DOTS Parte 2 – Unity Job System apareceu primeiro em ArtPlayer Games.

]]>
Este post faz parte da série que fala sobre unity DOTS. O primeiro post está disponível a partir deste link.
Desta vez, conheceremos o Unity Job System .

Tabela de conteúdo:

Neste post, você verá o seguinte conteúdo:

Sobre Multi-Threading e o Job System da Unity:

O principal objetivo do Unity Job Systems é facilitar a escrita de código multi-thread. Em um cenário comum, nosso código é executado de maneira encadeada na thread principal, ou seja: Um bloco só é executado quando o anterior terminou.

Como a grande maioria dos processadores hoje em dia possui mais de um núcleo, não faz muito sentido que tudo seja executado no thread principal enquanto os outros ficam ociosos.

O Job Systems divide nosso código e faz com que ele seja multi-encadeado, ou seja, executa vários pedaços do nosso código ao mesmo tempo em diferentes threads. 

Escrever códigos Multi-thread é uma tarefa difícil e muito propensa a erros, produzindo problemas que são muito difíceis de depurar. 

O Job Systems faz todo o trabalho pesado que inclui a criação e gerenciamento dos trheads, e nossa única tarefa é criar os Workers e agendá-los. 

Existem vários benefícios em escrever um código multi-thread, como por exemplo aumentar o desempenho de seu jogo e, consequentemente a taxa de quadros, prolongar a vida útil de baterias em caso de dispositivos móveis e etc.

Outro aspecto do Job System da Unity é que todo o código compartilha internamente os threads de trabalho, evitando automáticamente a criação de mais threads que a quantidade de núcleos da CPU, o que causaria  a contenção nos recursos da CPU.

Agendamento de trabalhos e suas dependências

O sistema de trabalhos ou Job System, é responsável por agendar e gerenciar nosso código em vários núcleos. Ele coloca todos os nossos trabalhos em uma fila e o sistema de tarefas pega itens desta fila e os executa na ordem apropriada respeitando suas dependências caso existam.

Geralmente em jogos, um trabalho (job) está preparando os dados para um próximo trabalho. Se o trabalho A depender dos dados do trabalho B para ser executado, o sistema de tarefas irá garantir que o trabalho A não seja executado até que o trabalho B seja concluído.

Condições de corrida – O fantasma do código Multi-Thread

Se você já programou um sistema multi-thread, provavelmente já sofreu com condições de corrida. 

Uma condição de corrida acontece quando o resultado de uma operação depende do tempo de outro processo não controlado. Geralmente é difícil depurar uma condição de corrida já que ela depende do tempo não controlado e sua reprodução dificilmente pode ser executada.

Por exemplo: 

Se o sistema de trabalhos está enviando uma referência aos dados do seu código no thread principal para um trabalho, não é possível verificar se o thread principal está lendo estes dados enquanto o nosso job grava neles. Isto geraria uma condição de corrida, pois não saberíamos se os dados estão sendo alterados durante sua leitura.

O Job Systems resolve este problema enviando para cada trabalho uma cópia dos dados que ele precisa para operar em seu agendamento, ao invés de uma referência no thread principal. Assim, os dados são isolados e uma possível condição de corrida é eliminada. A Unity utiliza memcopy para colocar os dados na memória nativa, isso significa que estes dados podem ser apenas tipos bittable.

Native Containers

A desvantagem de trabalhar com cópias de dados para resolver o problema das condições de corrida é que os resultados do trabalho também são isolados nesta cópia. Felizmente, para contornar isso existem os Native Containers.

Pense nos Native Containers como um ponteiro para a alocação de memória destes dados, isso permite que trabalhemos com dados reais compartilhados com o thread principal em nossos trabalhos, ao invés de cópias de dados. Mas isso tem suas particularidades e deve ser utilizado de maneira correta.

Observação: Native Containers fazem parte do namespace unity.collections que deverá ser instalado através do package manager da unity para ser utilizado.

Os tipos de Native Containers são:

  • NativeList:
    Semelhante a listas C#, são NativeArrays redimensionáves;
  • Native Arrays:
    Semelhante aos C# arrays.
  • NativeHashMap:
    Pares de chave e valor. Semelhante ao C# Dictionary.
  • NativeMultiHashMap:
    Múltiplos valores por chave. Pense em um dicionário C# mas com múltiplos valores por chave.
  • NativeQueue:
    Fila FIFO ( Primeiro a entrar, primeiro a sair ).

O sistema de segurança está incluído em todos os tipos de Native Containers e verifica quem está lendo ou gravando neles.

Por padrão, quando um job tem acesso a um Native Container, ele tem acesso a leitura e gravação e isso pode diminuir o desempenho. 

O Unity Jobs também não permite que que um trabalho tenha acesso a gravação de um Native Container que está sendo gravado por outro trabalho. Isso geraria uma exceção.

Se um job não precisar alterar os dados de um container, é altamente recomendável utilizar o atributo [ReadyOnly]. Desta maneira, outros trabalhos poderão ter acesso somente leitura ao mesmo tempo que o primeiro. Exemplo:

[ReadOnly]
public NativeArray<int> input;

Ao instanciar um Native Container, você precisa especificar o seu tipo de alocação dependendo do caso de uso. Lembre-se de que estes dados são alocados na memória, logo eles também precisam ser liberados após seu uso ou a memória ficará cheia rapidamente.

Existem três tipos de alocação de Native Containers:

Allocator.Temp:


É o tipo de alocação mais rápido e a vida útil dos dados é de um quadro ou menos. Este tipo de alocação NÃO deve ser utilizado em um job, mas ainda precisa ser liberado após seu uso utilizando o método Dispose().

Allocator.TempJob:


É o tipo de alocação mais comum e mais utilizado. Apesar de ser um pouco mais lento que Allocator.Temp é seguro utilizá-lo em um job. Sua vida útil é de 4 quadros e se o método Dispose não for chamado neste período, uma exceção será gerada.

Allocator.Persistent:


É a alocação mais lenta, mas pode durar a vida útil de todo o aplicativo. De acordo com a documentação da Unity, não deve ser utilizado onde o desempenho é cruscial.

Exemplo de alocação de um NativeArray:

NativeArray<float> result = new NativeArray<float>(1, Allocator.TempJob);

Tipos de Trabalhos

Antes de criarmos o nosso primeiro job, vou falar sobre os dois tipos de trabalho (interfaces) mais importantes. Existem outros, mas são utilizados em situações mais específicas.

  • IJob:
    Permite agendar um trabalho único em paralelo ao thread princial e a outros threads. O método Execute será chamado em um Thread de trabalho.
  • IJobParallelFor:
    Imagine que você precisa executar um trabalho várias vezes em objetos diferentes percorrendo um laço for ou foreach, um cenário comum em um jogo. IJobParallelFor utiliza um NativeArray para fazer isso, executando o mesmo trabalho várias vezes em vários núcleos para cada elemento.
    Neste caso, o método execute é executado uma uma vez em cada elemento do NativeArray passado. Não se preocupe se parecer complicado agora, pois não é.

Trabalhando com com IJob

No exemplo abaixo, estamos criando e agendando um trabalho simples utilizando a Interface IJob. Como dito anteriormente, os trabalhos utilizam uma cópia de dados bittable para evitar condições de corrida e é exatamente por isso que utilizamos um struct para criar um trabalho.

Iremos criar um trabalho que soma dois números inteiros e armazena o resultado no primeiro elemento de um NativeArray. Em seguida, iremos agendar este trabalho através de um método normal em um MonoBehaviour e imprimir o resultado.

Certifique-se de estar utilizando os seguintes Namespaces:

using UnityEngine;
using Unity.Jobs;
using Unity.Collections;

Criamos a estrutura de nosso trabalho contendo os campos para os dados necessários e no método Execute(), realizamos a soma e armazenamos o resultado no primeiro elemento do nosso NativeArray:

public struct SumNumbers : IJob
    {
        public int numA;
        public int numB;

        public NativeArray<int> result;
        public void Execute()
        {
            result[0] = numA + numB;
        }
    }

Com o nosso trabalho criado, precisamos dizer ao JobSystem que ele deve ser agendado. O agendamento sempre é feito no thread principal.

Por questões de boas práticas ( e talvez um pouco de mania ) eu gosto de criar métodos para o agendamento de trabalhos. Assim eu posso reaproveitar o código, além de as coisas fazerem mais sentido para mim. 

Abaixo, o método responsável pelo agendamento. O código está comentado e é auto-explicativo:

public JobHandle ScheduleSimplejob(int numA, int numB, ref int resultOutPut)
    {
        //NativeContainer do tipo Nativearray responsável por armazenar o nosso resultado.
        NativeArray<int> m_result = new NativeArray<int>(1, Allocator.TempJob);

        //Cria uma instância do nosso trabalho (struct) contendo os dados necessários.
        SumNumbers jobData = new SumNumbers { 
            numA = numA, 
            numB = numB, 
            result = m_result
        };

        //Agenda o nosso trabalho.
        JobHandle handle = jobData.Schedule();

        // Aguarda até que o trabalho esteja completo.
        handle.Complete();

        //Seta a variável de saída com o resultado antes de liberá-la da memória.
        resultOutPut = m_result[0];
        //Libera o NativeArray da memória.
        m_result.Dispose();

        //Retorna o Handle para utilização caso necessário.
        return handle;
    }

Nota:
A razão de utilizarmos um NativeArray para armazenar o resultado está descrita no tópico Native Containers.

Com nosso trabalho e método para agendamento prontos, podemos chamar o método normalmente, Abaixo, chamamos o método através do método Start():

public class SimpleJob: MonoBehaviour
{
    //Armazenará o resultado de nosso trabalho.
    int JobResult;
    private void Start()
    {
            //Chamamos o método de agendamento do  nosso trabalho passando a variável JobResult que receberá o nosso resultado.
            JobHandle handle = ScheduleSimplejob(Random.Range(1, 1000), Random.Range(1, 1000), ref JobResult);

            Debug.Log(JobResult);
    }
[...]

O código completo deverá se parecer com isso:

using UnityEngine;
using Unity.Jobs;
using Unity.Collections;

public class SimpleJob: MonoBehaviour
{
    //Armazenará o resultado de nosso trabalho.
    int JobResult;
    private void Start()
    {
        //Chamamos o método de agendamento do  nosso trabalho passando a variável JobResult que receberá o nosso resultado.
        JobHandle handle = ScheduleSimplejob(Random.Range(1, 1000), Random.Range(1, 1000), ref JobResult);

        Debug.Log(JobResult);
        
    }


    public struct SumNumbers : IJob
    {
        public int numA;
        public int numB;

        public NativeArray<int> result;
        public void Execute()
        {
            result[0] = numA + numB;
        }
    }


    public JobHandle ScheduleSimplejob(int numA, int numB, ref int resultOutPut)
    {
        //NativeContainer do tipo Nativearray responsável por armazenar o nosso resultado.
        NativeArray<int> m_result = new NativeArray<int>(1, Allocator.TempJob);

        //Cria uma instância do nosso trabalho (struct) contendo os dados necessários.
        SumNumbers jobData = new SumNumbers { 
            numA = numA, 
            numB = numB, 
            result = m_result
        };

        //Agenda o nosso trabalho.
        JobHandle handle = jobData.Schedule();

        // Aguarda até que o trabalho esteja completo.
        handle.Complete();

        //Seta a variável de saída com o resultado antes de liberá-la da memória.
        resultOutPut = m_result[0];
        //Libera o NativeArray da memória.
        m_result.Dispose();

        //Retorna o Handle para utilização caso necessário.
        return handle;
    }
}

Se quiséssemos, poderíamos agendar nosso trabalho diversas vezes dentro do método Update(), por exemplo. Desta maneira, o agendador de trabalhos da Unity agendaria o trabalho várias vezes em vários threads:

private void Update()
    {
        for (int i = 0; i < 50; i++)
        {
            //Chamamos o método de agendamento do  nosso trabalho passando a variável JobResult que receberá o nosso resultado.
            JobHandle handle = ScheduleSimplejob(Random.Range(1, 1000), Random.Range(1, 1000), ref JobResult);

            Debug.Log(JobResult);
        }
    }

No exemplo acima, agendamos o nosso trabalho 50 vezes por quadro. O agendador de trabalhos da unity distribui os trabalhos entre os threads disponíveis automaticamente.

Resultado do Profile. (Os pontos azuis referem-se ao nosso SimpleJob:SumNumbers).

Trabalhando com com IJobParallelFor

Um IJobParallelFor se comporta de maneira semelhante a um IJob, mas ao invés de executar a tarefa uma única vez, ele executa o trabalho uma vez para cada índice do NativeArray passado.

Quando utilizamos IJobParallelFor, internamente a Unity divide os trabalhos em lotes e distribui estes trabalhos entre os núcleos do processador atômicamente. Para otimizar este processo, precisamos definir a quantidade de lotes que cada núcleo receberá.

Para o próximo exemplo, vamos criar um trabalho com três matrizes. O Job irá varrer as duas primeiras somando seus elementos em sequência e armazenando o resultado na terceira matriz. A diferença aqui, é que IJobParallelFor varrerá estas matrizes e “distribuirá” o método Execute entre os threads disponíveis paralelamente.
Criando o nosso trabalho:

public struct MyParallelJob : IJobParallelFor
{
    [ReadOnly]
    public NativeArray<float> a;
    [ReadOnly]
    public NativeArray<float> b;
    public NativeArray<float> result;

    public void Execute(int i)
    {
        result[i] = a[i] + b[i];
    }
}

Como de costume, vamos criar um método para agendar o nosso trabalho. O código está comentado e é auto-descritivo:

public JobHandle ScheduleParallelJob(int CalcSize, ref NativeArray<float> result)
    {
        NativeArray<float> elements_A = new NativeArray<float>(CalcSize, Allocator.TempJob);
        NativeArray<float> elements_B = new NativeArray<float>(CalcSize, Allocator.TempJob);
        NativeArray<float> m_result = new NativeArray<float>(CalcSize, Allocator.TempJob);

        //Preenche os arrays com os números a serem somados.
        for (int i = 0; i < CalcSize; i++)
        {
            elements_A[i] = Random.Range(1, 2000);
            elements_B[i] = Random.Range(1, 2000);
        }

        //Cria a instância de nosso IJobParallelFor com os dados necessários.
        MyParallelJob jobData = new MyParallelJob
        {
            a = elements_A,
            b = elements_B,
            result = m_result
        };

        //Agenda o nosso trabalho passando o tamanho da matriz e a quantidade de trabalhos por thread.
        JobHandle handle = jobData.Schedule(CalcSize, 1);
        //Aguarda até que os trabalhos estejam completos.
        handle.Complete();
        //Libera os NativeContainers da memória.
        elements_A.Dispose();
        elements_B.Dispose();
        //Copia a matriz de resultados para a nossa referência de saída.
        m_result.CopyTo(result);
        //Libera m_result da memória.
        m_result.Dispose();

        return handle;
    }

E por fim, podemos chamar o nosso método:

public class ParallelForTest : MonoBehaviour
{
    private void Start()
    {
        //Container que guardará nossos resultados temporáriamente.
        NativeArray<float> result = new NativeArray<float>(10, Allocator.Temp);
        ScheduleParallelJob(10, ref result);
        //Debugamos os nossos resultados.
        for (int i = 0; i < result.Length; i++)
            Debug.Log("Resultado [" + i + "]: " + result[i]);
        //Liberamos o nosso container temporário da memória.
        result.Dispose();
    }
[...]

O código completo deverá se parecer com isto:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Jobs;
using Unity.Collections;

public class ParallelForTest : MonoBehaviour
{
    private void Start()
    {
        //Container que guardará nossos resultados temporáriamente.
        NativeArray<float> result = new NativeArray<float>(10, Allocator.Temp);
        ScheduleParallelJob(10, ref result);
        //Debugamos os nossos resultados.
        for (int i = 0; i < result.Length; i++)
            Debug.Log("Resultado [" + i + "]: " + result[i]);
        //Liberamos o nosso container temporário da memória.
        result.Dispose();
    }

    public JobHandle ScheduleParallelJob(int CalcSize, ref NativeArray<float> result)
    {
        NativeArray<float> elements_A = new NativeArray<float>(CalcSize, Allocator.TempJob);
        NativeArray<float> elements_B = new NativeArray<float>(CalcSize, Allocator.TempJob);
        NativeArray<float> m_result = new NativeArray<float>(CalcSize, Allocator.TempJob);

        //Preenche os arrays com os números a serem somados.
        for (int i = 0; i < CalcSize; i++)
        {
            elements_A[i] = Random.Range(1, 2000);
            elements_B[i] = Random.Range(1, 2000);
        }

        //Cria a instância de nosso IJobParallelFor com os dados necessários.
        MyParallelJob jobData = new MyParallelJob
        {
            a = elements_A,
            b = elements_B,
            result = m_result
        };

        //Agenda o nosso trabalho passando o tamanho da matriz e a quantidade de trabalhos por thread.
        JobHandle handle = jobData.Schedule(CalcSize, 1);
        //Aguarda até que os trabalhos estejam completos.
        handle.Complete();
        //Libera os NativeContainers da memória.
        elements_A.Dispose();
        elements_B.Dispose();
        //Copia a matriz de resultados para a nossa referência de saída.
        m_result.CopyTo(result);
        //Libera m_result da memória.
        m_result.Dispose();

        return handle;
    }
}


public struct MyParallelJob : IJobParallelFor
{
    [ReadOnly]
    public NativeArray<float> a;
    [ReadOnly]
    public NativeArray<float> b;
    public NativeArray<float> result;

    public void Execute(int i)
    {
        result[i] = a[i] + b[i];
    }
}

Dependências entre Trabalhos:

Como dito anteriormente, quando agendamos um trabalho, podemos declarar outros trabalhos como dependências. Muito útil quando precisamos que um trabalho aguarde até que outro esteja completo. 

No exemplo a seguir, temos dois trabalhos onde o primeiro faz a soma de dois inteiros e o segundo adiciona 100 ao resultado. Para que isso seja possível, precisamos que o segundo trabalho saiba onde está o primeiro resultado na memória. Além disso, também ordenamos a execução deles fazendo com que Job2 tenha como dependência o Job1.

Nossos trabalhos:

public struct JobOne : IJob
    {
        [ReadOnly]
        public int numA;
        [ReadOnly]
        public int numB;
        [WriteOnly]
        public NativeArray<int> results;
        public void Execute()
        {
            results[0] = numA + numB;
        }
    }

 public struct JobTwo : IJob
    {
        public NativeArray<int> results;
        public void Execute()
        {
            results[0] = results[0] + 100;
        }
    }

Agendando os trabalhos ordenadamente e combinando suas dependências:
Mais uma vez, o código está comentado e é auto-descritivo.

void Start()
    {
        //Armazenará nosso resultado.
        NativeArray<int> m_results = new NativeArray<int>(1,Allocator.TempJob);

        //Criamos e agendamos nosso primeiro trabalho.
        JobOne job1 = new JobOne
        {
            numA = 25,
            numB = 25,
            results = m_results
        };

        JobHandle handleOne = job1.Schedule();
        
        
        //Criamos e agendamos o segundo Job passando como dependência o job1.
        JobTwo job2 = new JobTwo
        {
            results = m_results
        };

        JobHandle handleTwo = job2.Schedule(handleOne);
        

        //Criamos um NativeArray do tipo JobHandle para guardar os handles dos trabalhos que terão suas dependências combinadas.
        //Em nosso exemplo a dependência é o NativeArray results.
        NativeArray<JobHandle> JobsToExecute = new NativeArray<JobHandle>(2, Allocator.Temp);
        JobsToExecute[0] = handleOne;
        JobsToExecute[1] = handleTwo;

        //Combinamos as dependências de fato.
        JobHandle.CombineDependencies(JobsToExecute);
        //Faz com que o Thread principal aguarde até que nossos Jobs estejam completos.
        JobHandle.CompleteAll(JobsToExecute);

        Debug.Log("Resultado de JOB1 + resultado de JOB2 = " + m_results[0]);

        //Liberamos o container da memória.
        m_results.Dispose();
    }

Código completo:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Jobs;
using Unity.Collections;

public class JobDependencies : MonoBehaviour
{
    void Start()
    {
        //Armazenará nosso resultado.
        NativeArray<int> m_results = new NativeArray<int>(1,Allocator.TempJob);

        //Criamos e agendamos nosso primeiro trabalho.
        JobOne job1 = new JobOne
        {
            numA = 25,
            numB = 25,
            results = m_results
        };

        JobHandle handleOne = job1.Schedule();
        
        
        //Criamos e agendamos o segundo Job passando como dependência o job1.
        JobTwo job2 = new JobTwo
        {
            results = m_results
        };

        JobHandle handleTwo = job2.Schedule(handleOne);
        

        //Criamos um NativeArray do tipo JobHandle para guardar os handles dos trabalhos que terão suas dependências combinadas.
        //Em nosso exemplo a dependência é o NativeArray results.
        NativeArray<JobHandle> JobsToExecute = new NativeArray<JobHandle>(2, Allocator.Temp);
        JobsToExecute[0] = handleOne;
        JobsToExecute[1] = handleTwo;

        //Combinamos as dependências de fato.
        JobHandle.CombineDependencies(JobsToExecute);
        //Faz com que o Thread principal aguarde até que nossos Jobs estejam completos.
        JobHandle.CompleteAll(JobsToExecute);

        Debug.Log("Resultado de JOB1 + resultado de JOB2 = " + m_results[0]);

        //Liberamos o container da memória.
        m_results.Dispose();
    }


    public struct JobOne : IJob
    {
        [ReadOnly]
        public int numA;
        [ReadOnly]
        public int numB;
        [WriteOnly]
        public NativeArray<int> results;
        public void Execute()
        {
            results[0] = numA + numB;
        }
    }

    public struct JobTwo : IJob
    {
        public NativeArray<int> results;
        public void Execute()
        {
            results[0] = results[0] + 100;
        }
    }
}

Burst

Antes de começarmos a comparar o desempenho de nosso código MultiThread em relação ao Mono, devemos conhecer esta que na minha opinião é a cereja do bolo.
O Brust é um compilador de alto desempenho que foi projetado principalmente para melhorar a eficiência do Job System. Ele está disponível no gerenciador de pacotes da Unity, e obviamente é preciso instalá-lo para que seja utilizado.

O aumento drástico no desempenho se deve ao fato de o Brust converter o código em uma versão nativa e altamente otimizada utilizando LLVM.
Para utilizar o Burst basta marcar  as estruturas com a flag [BurstCompile]:

    [BurstCompile]
    public struct ReallyToughJob : IJob
    {
        [ReadOnly]
        public float calcs;
        public void Execute()
        {
            float value = 0f;
            for (int i = 0; i < calcs; i++)
            {
                value = math.exp10(math.sqrt(value));
            }
        }
    }

Também é necessário marcar a opção Enable Compilation no menu Jobs>Burst:

Mathematics

Mathematics é uma bibliteca matemática que fornece tipos de vetores e operações matemáticas. Seu grande diferencial é ser amigável ao compilador burst, fazendo assim com que seja possível tirar proveito de todos os seus recursos.

Para utilizá-la é necessário realizar sua instalação através do gerenciador de pacotes e fazer uso do namespace Unity.Mathematics.

Comparando o desempenho

É hora de compararmos o desempenho de um código MultiThread utilizando Burst+Mathematics em relação ao mesmo código SingleThread.

Para efetuarmos o teste, criaremos um trabalho e um método, ambos fazendo um cálculo complexo o mesmo número de vezes:

/// <summary>
    /// Executa um cálculo complexo determinada quantidade de vezes.
    /// </summary>
    private void ReallyToughTask()
    {
        float value = 0f;
        for (int i = 0; i < Calcs; i++)
        {
            value = math.exp10(math.sqrt(value));
        }
    }

    /// <summary>
    /// Nosso trabalho executa o mesmo cálculo de ReallyToughTask, mas utilizando 
    /// JobSystem e Burst.
    /// </summary>
    [BurstCompile]
    public struct ReallyToughJob : IJob
    {
        [ReadOnly]
        public float calcs;
        public void Execute()
        {
            float value = 0f;
            for (int i = 0; i < calcs; i++)
            {
                value = math.exp10(math.sqrt(value));
            }
        }
    }

Criamos uma variável booleana que determinará se estaremos utilizando o JobSystem e outra para determinar a quantidade de cálculos executados de cada vez:

public class PerformanceTest : MonoBehaviour
{
    //Determina se usaremos Jobs ou não.
    public bool UseJobs = false;
    //Número de cálculos a serem processados.
    public int Calcs;
   
    private void Update()
    {
        if (UseJobs)
        {
            //Cria e agenda o trabalho caso UseJobs seja verdadeiro.
            ReallyToughJob job = new ReallyToughJob { calcs = Calcs };
            JobHandle handle = job.Schedule();
            handle.Complete();
        }
        else
        {
            //Caso não use jobs, chama o método ReallyToughTask.
            ReallyToughTask();
        }
    }
[...]

O script completo deverá se parecer com este:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Jobs;
using Unity.Burst;
using Unity.Collections;
using Unity.Mathematics;

public class PerformanceTest : MonoBehaviour
{
    //Determina se usaremos Jobs ou não.
    public bool UseJobs = false;
    //Número de cálculos a serem processados.
    public int Calcs;
   
    private void Update()
    {
        if (UseJobs)
        {
            //Cria e agenda o trabalho caso UseJobs seja verdadeiro.
            ReallyToughJob job = new ReallyToughJob { calcs = Calcs };
            JobHandle handle = job.Schedule();
            handle.Complete();
        }
        else
        {
            //Caso não use jobs, chama o método ReallyToughTask.
            ReallyToughTask();
        }
    }

    /// <summary>
    /// Executa um cálculo complexo determinada quantidade de vezes.
    /// </summary>
    private void ReallyToughTask()
    {
        float value = 0f;
        for (int i = 0; i < Calcs; i++)
        {
            value = math.exp10(math.sqrt(value));
        }
    }

    /// <summary>
    /// Nosso trabalho executa o mesmo cálculo de ReallyToughTask, mas utilizando 
    /// JobSystem e Burst.
    /// </summary>
    [BurstCompile]
    public struct ReallyToughJob : IJob
    {
        [ReadOnly]
        public float calcs;
        public void Execute()
        {
            float value = 0f;
            for (int i = 0; i < calcs; i++)
            {
                value = math.exp10(math.sqrt(value));
            }
        }
    }
}

Arraste o script para um objeto vazio na cena e, defina a quantidade de cáculos de acordo com o seu processador. Em meu i5 velho de guerra, defini o valor para 300000:

O Resultado:

Saltamos de uma média de 57ms(17.8 FPS) para 2.6ms (385 FPS). É um aumento de desempenho considerável, e que ainda pode ser melhorado!

Considerações

Algumas bibliotecas utilizadas aqui ainda estão em modo preview e seu uso pode mudar até que elas sejam consideradas estáveis. Trabalhar com um sistema MultiThread já multiplica o desempenho do código por si só. Aliar isso a uma biblioteca matemática otimizada e ao compilador Burst aumenta drasticamente o desempenho do nosso código. 

Aqui nós vimos apenas algumas das otimizações  possíveis utilizando o JobSystem. Mais adiante, veremos como aumentar ainda mais o desempenho de nossos jogos aliando isso ao ECS.

O post DOTS Parte 2 – Unity Job System apareceu primeiro em ArtPlayer Games.

]]>
https://artplayergames.com/dots-parte-2-unity-job-system/feed/ 4 87
DOTS Parte 1 – Desempenho por padrão! https://artplayergames.com/unity-dots-parte-1-desempenho-por-padrao/ https://artplayergames.com/unity-dots-parte-1-desempenho-por-padrao/#respond Wed, 22 Jan 2020 09:54:49 +0000 http://34.68.93.28/?p=66 Eu tenho estudado DOTS há algum tempo e pelo menos nesta fase do desenvolvimento é muito difícil encontrar alguma documentação atualizada e/ou organizada. A documentação da unity não é muito...

O post DOTS Parte 1 – Desempenho por padrão! apareceu primeiro em ArtPlayer Games.

]]>
Eu tenho estudado DOTS há algum tempo e pelo menos nesta fase do desenvolvimento é muito difícil encontrar alguma documentação atualizada e/ou organizada. A documentação da unity não é muito clara ou intuitiva para quem está começando a estudar a nova tecnologia.

Outro ponto (pelo menos no meu caso), é a dificuldade em definir um plano de estudos estruturado, saber por onde começar a estudar, uma vez que o DOTS é composto por diversos elementos.

Minha intenção com esta série não é ser um guia mágico ou tutoriais extremamente práticos ( apesar de ter em seu conteúdo alguns exemplos de uso ). A intenção aqui é criar uma referência de estudos pessoal e para quem se interessar pelo assunto. A maioria do conteúdo é baseada na documentação da própria Unity e a ordem dos tópicos é baseada na minha opinião pessoal da melhor linha a ser seguida para melhor compreensão.

DOTS está em desenvolvimento e muita coisa descrita aqui pode ficar ultrapassada muito rápido. Vou me esforçar para atualizar as postagens sempre que possível.
Este conteúdo é direcionado para quem já possui noções de programação e tem uma certa familiaridade com a Unity e C#.
Talvez alguns posts pareçam massantes e com um certo exagero de detalhes , mas eu não encontrei outra maneira de mostrar as coisas.

O que é DOTS?

DOTS é uma abreviação para Data-Oriented Technology Stack.
Trata-se de um novo “conceito” onde programamos com orientação a dados ao invés de objetos e é composto por três elementos principais :

  • Job System
  • Entity Component System (ECS)
  • Compilador Burst

Instintivamente não gostamos de mudanças e, acredito que esta é a principal dificuldade na adaptação do novo método. Isso é natural depois de anos programando orientado a objetos, mas rompida esta berreira as coisas realmente fazem muito sentido.

Por que usar DOTS?

DOTS aumenta substancialmente o desempenho de nosso código e resolve de maneira eficaz os problemas de codificação MultiThread. Além de otimizar a utilização da memória e gerar um código de saída de altíssimo desempenho.

Resumidamente, a força de se trabalhar orientado a dados é que “O hardware agradece”, pois ele sabe exatamente onde os dados estão e como eles trabalham, enquanto que orientado a objetos, você trabalha com a força do seu cérebro, pois é mais fácil de visualizar, programar e abstrair esses detalhes.
Com DOTS nós separamos dados e sistemas, isso evita a repetição de código aumentando sua reutilização e fazendo assim com que escrevamos menos.

Estrutura de tópicos:

Os tópicos que não tiverem seus links funcionando, ainda não foram escritos, mas serão em breve.

O post DOTS Parte 1 – Desempenho por padrão! apareceu primeiro em ArtPlayer Games.

]]>
https://artplayergames.com/unity-dots-parte-1-desempenho-por-padrao/feed/ 0 66