paint-brush
Explorando Unity DOTS e ECS: é uma virada de jogo?por@deniskondratev
3,404 leituras
3,404 leituras

Explorando Unity DOTS e ECS: é uma virada de jogo?

por Denis Kondratev12m2023/07/18
Read on Terminal Reader

Muito longo; Para ler

Este artigo investiga o Data-Oriented Technology Stack (DOTS) e o Entity Component System (ECS) do Unity, que otimizam o desenvolvimento de jogos por meio de uma arquitetura simples e intuitiva. Essas tecnologias, juntamente com pacotes Unity adicionais, permitem a criação de jogos eficientes e de alto desempenho. As vantagens desses sistemas são demonstradas por meio de um exemplo de algoritmo do Jogo da Vida de Conway.
featured image - Explorando Unity DOTS e ECS: é uma virada de jogo?
Denis Kondratev HackerNoon profile picture
0-item
1-item
2-item

O Unity DOTS permite que os desenvolvedores usem todo o potencial dos processadores modernos e entreguem jogos altamente otimizados e eficientes – e achamos que vale a pena prestar atenção.


Já se passaram mais de cinco anos desde que a Unity anunciou o desenvolvimento de sua pilha de tecnologia orientada a dados (DOTS). Agora, com o lançamento da versão de suporte de longo prazo (LTS), Unity 2023.3.0f1, finalmente vimos um lançamento oficial. Mas por que o Unity DOTS é tão importante para a indústria de desenvolvimento de jogos e quais vantagens essa tecnologia oferece?


Olá pessoal! Meu nome é Denis Kondratev e sou desenvolvedor Unity na MY.GAMES. Se você está ansioso para entender o que é o Unity DOTS e se vale a pena explorar, esta é a oportunidade perfeita para se aprofundar neste tópico fascinante e, neste artigo, faremos exatamente isso.


O que é o Sistema de Componentes de Entidade (ECS)?

Em sua essência, o DOTS implementa o padrão de arquitetura Entity Component System (ECS). Para simplificar este conceito, vamos descrevê-lo assim: ECS é construído sobre três elementos fundamentais: Entidades, Componentes e Sistemas.


As entidades , por si só, carecem de qualquer funcionalidade ou descrição inerente. Em vez disso, eles servem como contêineres para vários Componentes, que os conferem características específicas para lógica de jogo, renderização de objetos, efeitos sonoros e muito mais.


Os componentes , por sua vez, vêm em tipos diferentes e apenas armazenam dados sem recursos próprios de processamento independentes.


Completando a estrutura ECS estão os Sistemas , que processam Componentes, lidam com a criação e destruição de Entidades e gerenciam a adição ou remoção de Componentes.


Por exemplo, ao criar um jogo "Space Shooter", o playground apresentará vários objetos: a nave espacial do jogador, inimigos, asteróides, pilhagem, etc.



Todos esses objetos são considerados entidades por direito próprio, desprovidos de quaisquer características distintas. No entanto, ao atribuir diferentes componentes a eles, podemos imbuí-los de atributos únicos.


Para demonstrar, considerando que todos esses objetos possuem posições no campo de jogo, podemos criar um componente de posição que contenha as coordenadas do objeto. Além disso, para a nave espacial, inimigos e asteróides do jogador, podemos incorporar componentes de saúde; o sistema responsável por lidar com colisões de objetos controlará a integridade dessas entidades. Além disso, podemos anexar um componente de tipo de inimigo aos inimigos, permitindo que o sistema de controle do inimigo governe seu comportamento com base no tipo atribuído.


Embora essa explicação forneça uma visão geral simplista e rudimentar, a realidade é um pouco mais complexa. No entanto, acredito que o conceito fundamental de ECS é claro. Com isso fora do caminho, vamos nos aprofundar nas vantagens dessa abordagem.

Os benefícios do Sistema de Componentes de Entidade

Uma das principais vantagens da abordagem Entity Component System (ECS) é o projeto arquitetônico que ela promove. A programação orientada a objetos (OOP) carrega um legado significativo com padrões como herança e encapsulamento, e mesmo programadores experientes podem cometer erros de arquitetura no calor do desenvolvimento, levando à refatoração ou à lógica emaranhada em projetos de longo prazo.


Por outro lado, o ECS oferece uma arquitetura simples e intuitiva. Tudo cai naturalmente em componentes e sistemas isolados, facilitando o entendimento e o desenvolvimento por meio dessa abordagem; até mesmo desenvolvedores iniciantes compreendem rapidamente essa abordagem com o mínimo de erros.


O ECS segue uma abordagem composta, em que componentes isolados e sistemas de comportamento são criados em vez de hierarquias de herança complexas. Esses componentes e sistemas podem ser facilmente adicionados ou removidos, permitindo mudanças flexíveis nas características e comportamento da entidade – essa abordagem aumenta muito a reutilização do código.


Outra vantagem importante do ECS é a otimização do desempenho. No ECS, os dados são armazenados na memória de forma contígua e otimizada, com tipos de dados idênticos colocados próximos uns dos outros. Isso otimiza o acesso aos dados, reduz erros de cache e melhora os padrões de acesso à memória. Além disso, os sistemas compostos por blocos de dados separados são mais fáceis de paralelizar em diferentes processos, levando a ganhos de desempenho excepcionais em comparação com as abordagens tradicionais.

Explorando os pacotes do Unity DOTS

O Unity DOTS engloba um conjunto de tecnologias fornecidas pela Unity Technologies que implementam o conceito ECS no Unity. Inclui vários pacotes projetados para aprimorar diferentes aspectos do desenvolvimento de jogos; vamos cobrir alguns deles agora.


O núcleo do DOTS é o pacote Entities , que facilita a transição de MonoBehaviours e GameObjects familiares para a abordagem Entity Component System. Este pacote forma a base do desenvolvimento baseado em DOTS.


O pacote Unity Physics apresenta uma nova abordagem para lidar com a física em jogos, alcançando uma velocidade notável por meio de cálculos paralelizados.


Além disso, o pacote Havok Physics for Unity permite a integração com o moderno mecanismo Havok Physics. Este mecanismo oferece detecção de colisão de alto desempenho e simulação física, alimentando jogos populares como The Legend of Zelda: Breath of the Wild, Doom Eternal, Death Stranding, Mortal Kombat 11 e muito mais.


Death Stranding, como muitos outros videogames, usa o popular mecanismo Havok Physics.


O pacote Entities Graphics foca na renderização em DOTS. Ele permite a coleta eficiente de dados de renderização e funciona perfeitamente com os pipelines de renderização existentes, como o Pipeline de renderização universal (URP) ou o Pipeline de renderização de alta definição (HDRP).


Mais uma coisa, Unity também tem desenvolvido ativamente uma tecnologia de rede chamada Netcode. Ele inclui pacotes como Unity Transport para desenvolvimento de jogos multijogador de baixo nível, Netcode para GameObjects para abordagens tradicionais e o notável pacote Unity Netcode para Entidades , que se alinha aos princípios DOTS. Esses pacotes são relativamente novos e continuarão a evoluir no futuro.

Melhorando o desempenho no Unity DOTS e além

Várias tecnologias intimamente relacionadas ao DOTS podem ser usadas dentro da estrutura do DOTS e além. O pacote Job System fornece uma maneira conveniente de escrever código com cálculos paralelos. Ele gira em torno da divisão do trabalho em pequenos pedaços chamados trabalhos, que executam cálculos em seus próprios dados. O Job System distribui uniformemente esses trabalhos entre threads para uma execução eficiente.


Para garantir a segurança do código, o Job System suporta o processamento de tipos de dados blittable. Os tipos de dados Blittable têm a mesma representação na memória gerenciada e não gerenciada e não requerem conversão quando transmitidos entre código gerenciado e não gerenciado. Exemplos de tipos blittable incluem byte, sbyte, short, ushort, int, uint, long, ulong, float, double, IntPtr e UIntPtr. Matrizes unidimensionais de tipos primitivos blittable e estruturas contendo exclusivamente tipos blittable também são consideradas blittable.


No entanto, os tipos que contêm uma matriz variável de tipos blittable não são considerados blittable. Para lidar com essa limitação, o Unity desenvolveu o pacote Collections , que fornece um conjunto de estruturas de dados não gerenciadas para uso em trabalhos. Essas coleções são estruturadas e armazenam dados em memória não gerenciada usando mecanismos do Unity. É responsabilidade do desenvolvedor desalocar essas coleções usando o método Disposal().


Outro pacote importante é o Burst Compiler , que pode ser usado com o Job System para gerar código altamente otimizado. Embora venha com certas limitações de uso de código, o compilador Burst fornece um aumento significativo de desempenho.

Medindo o desempenho com Job System e Burst Compile

Como mencionado, Job System e Burst Compiler não são componentes diretos do DOTS, mas fornecem assistência valiosa na programação de computações paralelas rápidas e eficientes. Vamos testar suas capacidades usando um exemplo prático: implementar Algoritmo do Jogo da Vida de Conway . Nesse algoritmo, um campo é dividido em células, cada uma das quais pode estar viva ou morta. Durante cada turno, verificamos o número de vizinhos vivos para cada célula, e seus estados são atualizados de acordo com regras específicas.



Aqui está a implementação deste algoritmo usando a abordagem tradicional:


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


Adicionei marcadores ao Profiler para medir o tempo necessário para os cálculos. Os estados das células são armazenados em uma matriz unidimensional chamada _cellStates . Inicialmente, gravamos os resultados temporários em _tempResults e depois os copiamos de volta para _cellStates ao concluir os cálculos. Essa abordagem é necessária porque gravar o resultado final diretamente em _cellStates afetaria os cálculos subsequentes.


Criei um campo de 1000x1000 células e executei o programa para medir o desempenho. Aqui estão os resultados:



Como visto nos resultados, os cálculos levaram 380 ms.


Agora vamos aplicar o Job System e Burst Compiler para melhorar o desempenho. Primeiramente, criaremos o Job responsável por executar o algoritmo Conway's Game of Life.


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


Atribuí o atributo [ReadOnly] ao campo CellStates , permitindo acesso irrestrito a todos os valores do array de qualquer thread. Porém, para o campo TempResults , que possui o atributo [WriteOnly] , a escrita só pode ser feita através do índice especificado no método Execute(int index) . A tentativa de gravar um valor em um índice diferente gerará um aviso. Isso garante a segurança dos dados ao trabalhar em um modo multithread.


Agora, a partir do código normal, vamos lançar nosso Job:


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


Após copiar todos os dados necessários, agendamos a execução do job utilizando o método Schedule() . É importante observar que esse escalonamento não executa imediatamente os cálculos: essas ações são iniciadas a partir da thread principal, e a execução ocorre por meio de workers distribuídos entre diferentes threads. Para aguardar a conclusão do trabalho, usamos jobHandler.Complete() . Só então podemos copiar o resultado obtido de volta para _cellStates .


Vamos medir a velocidade:



A velocidade de execução aumentou quase dez vezes e o tempo de execução agora é de aproximadamente 42 ms. Na janela Profiler, podemos ver que a carga de trabalho foi distribuída entre 17 trabalhadores. Esse número é um pouco menor que o número de threads do processador no ambiente de teste, que é um Intel® Core™ i9-10900 com 10 núcleos e 20 threads. Embora os resultados possam variar em processadores com menos núcleos, podemos garantir a utilização total da potência do processador.


Mas isso não é tudo - é hora de utilizar o Burst Compiler, que oferece otimização de código significativa, mas vem com certas restrições. Para ativar o Burst Compiler, basta adicionar o atributo [BurstCompile] ao SimulationJob .


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


Vamos medir novamente:



Os resultados superam até as expectativas mais otimistas: a velocidade aumentou quase 200 vezes em relação ao resultado inicial. Agora, o tempo de computação para 1 milhão de células não passa de 2 ms. No Profiler, as partes executadas pelo código compilado com o Burst Compiler são destacadas em verde.

Conclusão

Embora o uso de cálculos multithreaded nem sempre seja necessário e a utilização do Burst Compiler nem sempre seja possível, podemos observar uma tendência comum no desenvolvimento de processadores em direção a arquiteturas multi-core. Isso significa que devemos estar preparados para aproveitar todo o seu poder. O ECS, e especificamente o Unity DOTS, alinha-se perfeitamente com esse paradigma.


Acredito que o Unity DOTS merece atenção, no mínimo. Embora possa não ser a melhor solução para todos os casos, o ECS pode provar seu valor em muitos jogos.


A estrutura Unity DOTS, com sua abordagem multithread e orientada a dados, oferece um tremendo potencial para otimizar o desempenho em jogos Unity. Ao adotar a arquitetura do Entity Component System e alavancar tecnologias como o Job System e o Burst Compiler, os desenvolvedores podem desbloquear novos níveis de desempenho e escalabilidade.


À medida que o desenvolvimento de jogos continua a evoluir e o hardware avança, a adoção do Unity DOTS torna-se cada vez mais valiosa. Ele capacita os desenvolvedores a aproveitar todo o potencial dos processadores modernos e entregar jogos altamente otimizados e eficientes. Embora o Unity DOTS possa não ser a solução ideal para todos os projetos, sem dúvida ele é uma grande promessa para aqueles que buscam escalabilidade e desenvolvimento voltados para o desempenho.


O Unity DOTS é uma estrutura poderosa que pode beneficiar significativamente os desenvolvedores de jogos, aprimorando o desempenho, permitindo cálculos paralelos e adotando o futuro do processamento de vários núcleos. Vale a pena explorar e considerar sua adoção para aproveitar totalmente o hardware moderno e otimizar o desempenho dos jogos Unity.