Crie um jogo de tiro em zumbi na terceira pessoa com DOTS

Saudação, Khabrovsk. Como já escrevemos, janeiro é rico em novos lançamentos e hoje anunciamos o OTUS para um novo curso - "Game Developer for Unity" . Antecipando o início do curso, estamos compartilhando com você a tradução de material interessante.




Estamos reconstruindo o núcleo do Unity com nossa pilha de tecnologia orientada a dados . Como muitos estúdios de jogos, também vemos grandes vantagens no uso do ECS (Entity Component System), do C # Task System (C # Job System) e do Burst Compiler. No Unite Copenhagen, tivemos a oportunidade de conversar com a Far North Entertainment e investigar como eles implementam essa funcionalidade DOTS em projetos tradicionais do Unity.
A Far North Entertainment é um estúdio sueco que pertence a cinco amigos de engenharia. Desde o lançamento de Down to Dungeon para Gear VR no início de 2018, a empresa trabalha em um jogo que pertence ao gênero clássico de jogos para PC, ou seja, um jogo pós-apocalíptico no modo de sobrevivência zumbi. O que diferencia o projeto dos outros é o número de zumbis que estão perseguindo você. A visão da equipe nesse sentido atraiu milhares de zumbis famintos seguindo você em enormes hordas.

No entanto, eles rapidamente enfrentaram muitos problemas de desempenho já no estágio de prototipagem. Criar, morrer, atualizar e animar todo esse número de inimigos continuava sendo o principal gargalo, mesmo depois que a equipe tentou resolver o problema com o agrupamento de objetos e instâncias de nimação .

Isso forçou o diretor técnico do estúdio, Andres Ericsson, a voltar sua atenção para o DOTS e mudar a mentalidade de orientada a objetos para orientada a dados. "A idéia principal que ajudou a provocar essa mudança foi que você tinha que parar de pensar em objetos e hierarquias de objetos e começar a pensar em dados, como eles estão sendo transformados e como acessá-los", disse ele. . Suas palavras significam que não é necessário construir uma arquitetura de código de olho nos objetos da vida real, de maneira que resolva o problema mais geral e abstrato. Ele tem muitas dicas para quem, como ele, enfrenta uma mudança na visão de mundo:

“Pergunte a si mesmo qual é o verdadeiro problema que você está tentando resolver e quais dados são importantes para obter uma solução. Você converterá o mesmo conjunto de dados da mesma maneira repetidamente? Quantos dados úteis você pode caber em uma linha do cache do processador? Se você fizer alterações no código existente, avalie a quantidade de dados indesejados que você adiciona à linha de cache. É possível dividir os cálculos em vários segmentos ou preciso usar um único fluxo de comando? ”


A equipe entendeu que as entidades no Unity Component System são apenas identificadores de pesquisa nos fluxos de componentes. Os componentes são apenas dados, enquanto os sistemas contêm toda a lógica e filtram as entidades com uma assinatura específica, conhecida como arquétipos. “Acho que uma das idéias que nos ajudaram a visualizar nossas idéias foi a introdução do ECS como um banco de dados SQL. Cada arquétipo é uma tabela na qual cada coluna é um componente e cada linha é uma entidade única. Em essência, você usa sistemas para criar consultas para essas tabelas de arquétipos e executar operações em entidades ”, diz Anders.

Apresentando DOTS


Para chegar a esse entendimento, ele estudou a documentação do sistema de componentes de entidades , exemplos de ECS e um exemplo que fizemos juntos com a Nordeus e apresentamos na Unite Austin. Informações gerais sobre arquitetura orientada a dados também foram muito úteis para a equipe. “O relatório de Mike Acton sobre arquitetura orientada a dados com o CppCon 2014 é exatamente o que primeiro abriu nossos olhos para esse modo de programação.”

A equipe do Extremo Norte publicou o que aprendeu em seu Dev Blog , em setembro deste ano, veio a Copenhague para falar sobre suas experiências com a transição para uma abordagem orientada a dados no Unity.


Este artigo é baseado em um relatório, explica mais detalhadamente as especificidades de sua implementação do ECS, do C # Task System e do compilador Burst. A Far North também compartilhou muitos exemplos de código de seu projeto.

Zombie Data Organization


"O problema que enfrentamos foi interpolar os deslocamentos e as rotações de milhares de objetos no lado do cliente", diz Anders. Sua abordagem inicial orientada a objetos foi criar um script abstrato do ZombieView que herdou a classe pai genérica do EntityView . EntityView é um MonoBehaviour anexado a um GameObject . Ele atua como uma representação visual do modelo de jogo. Cada ZombieView era responsável por lidar com seu próprio movimento e interpolação de rotação em sua função Atualizar .

Isso parece normal, até você entender que cada entidade está localizada na memória em um local arbitrário. Isso significa que, se você estiver acessando milhares de objetos, a CPU deverá tirá-los da memória, um de cada vez, e isso acontece muito lentamente. Se você colocar seus dados em blocos organizados em série, o processador poderá armazenar em cache todo um conjunto de dados ao mesmo tempo. A maioria dos processadores modernos pode receber cerca de 128 ou 256 bits do cache em um ciclo.

A equipe decidiu converter inimigos em DOTS, na esperança de resolver problemas de desempenho do lado do cliente. O primeiro da fila foi a função Atualizar no ZombieView . A equipe determinou quais partes devem ser divididas em diferentes sistemas e determinou os dados necessários. A primeira e mais óbvia foi a interpolação de posições e curvas, já que o mundo do jogo é uma grade bidimensional. Duas variáveis ​​de flutuação são responsáveis ​​por onde os zumbis estão indo, e o último componente é a posição de destino, ele rastreia a posição do servidor para o inimigo.

[Serializable] public struct PositionData2D : IComponentData { public float2 Position; } [Serializable] public struct HeadingData2D : IComponentData { public float2 Heading; } [Serializable] public struct TargetPositionData : IComponentData { public float2 TargetPosition; } 

O próximo passo foi criar um arquétipo para os inimigos. O arquétipo é um conjunto de componentes que pertencem a uma determinada entidade, ou seja, é a assinatura do componente.

O projeto usa prefabs para determinar arquétipos, pois os inimigos exigem mais componentes, e alguns deles precisam de links para o GameObject . Ele funciona para que você possa agrupar os dados do seu componente no ComponentDataProxy , que o transformará em MonoBehaviour , que por sua vez pode ser anexado à pré-fabricada. Quando você cria uma instância usando o EntityManager e passa a pré-fabricada, ela cria uma entidade com todos os dados dos componentes anexados à pré-fabricada. Todos os dados do componente são armazenados em pedaços de memória de 16 kilobytes chamados ArchetypeChunk .

Aqui está uma visualização de como os fluxos de componentes serão organizados em nosso bloco de arquétipos:



"Uma das principais vantagens dos blocos de arquétipos é que muitas vezes você não precisa realocar um monte ao criar novos objetos, pois a memória já foi alocada previamente. Isso significa que a criação de entidades está gravando dados no final dos fluxos de componentes dentro dos blocos de arquétipos. O único caso em que é necessário executar novamente a alocação de heap é ao criar uma entidade que não se encaixa nas bordas do pedaço. Nesse caso, a alocação de um novo bloco de um arquétipo de 16 KB de tamanho será iniciada ou, se houver um fragmento vazio do mesmo arquétipo, ele poderá ser reutilizado. Em seguida, os dados para os novos objetos serão registrados nos fluxos de componentes do novo bloco ”, explica Anders.

O multithreading de seus zumbis


Agora que os dados foram compactados e armazenados na memória de maneira conveniente para o armazenamento em cache, a equipe pode usar facilmente o sistema de tarefas C # para executar seu código em vários núcleos da CPU em paralelo.

A próxima etapa foi criar um sistema que filtrasse todas as entidades de todos os blocos de arquétipos que tivessem componentes PositionData2D , HeadingData2D e TargetPositionData .

Para fazer isso, Anders e sua equipe criaram o JobComponentSystem e construíram sua solicitação na função OnCreate . Parece algo como isto:

 private EntityQuery m_Group; protected override void OnCreate() { base.OnCreate(); var query = new EntityQueryDesc { All = new [] { ComponentType.ReadWrite<PositionData2D>(), ComponentType.ReadWrite<HeadingData2D>(), ComponentType.ReadOnly<TargetPositionData>() }, }; m_Group = GetEntityQuery(query); } 

O código anuncia uma solicitação que filtra todos os objetos no mundo que têm uma posição, direção e propósito. Em seguida, eles queriam agendar tarefas para cada quadro usando o sistema de tarefas C # para distribuir os cálculos em vários fluxos de trabalho.

"O mais legal do sistema de tarefas C # é que ele é o mesmo sistema que o Unity usa em seu código, portanto, não precisamos nos preocupar com threads executáveis ​​bloqueando um ao outro, exigindo os mesmos núcleos de processador e causando problemas de desempenho" . Diz Anders.

A equipe decidiu usar o IJobChunk , porque milhares de inimigos implicavam na presença de um grande número de blocos de arquétipos que deveriam corresponder à solicitação no tempo de execução. O IJobChunk distribui os pedaços corretos em vários fluxos de trabalho.

Cada quadro, uma nova tarefa UpdatePositionAndHeadingJob, é responsável por lidar com a interpolação de posições e turnos dos inimigos no jogo.

O código para agendar tarefas é o seguinte:

 protected override JobHandle OnUpdate(JobHandle inputDeps) { var positionDataType = GetArchetypeChunkComponentType<PositionData2D>(); var headingDataType = GetArchetypeChunkComponentType<HeadingData2D>(); var targetPositionDataType = GetArchetypeChunkComponentType<TargetPositionData>(true); var updatePosAndHeadingJob = new UpdatePositionAndHeadingJob { PositionDataType = positionDataType, HeadingDataType = headingDataType, TargetPositionDataType = targetPositionDataType, DeltaTime = Time.deltaTime, RotationLerpSpeed = 2.0f, MovementLerpSpeed = 4.0f, }; return updatePosAndHeadingJob.Schedule(m_Group, inputDeps); } 

É assim que a tarefa se parece:

 public struct UpdatePositionAndHeadingJob : IJobChunk { public ArchetypeChunkComponentType<PositionData2D> PositionDataType; public ArchetypeChunkComponentType<HeadingData2D> HeadingDataType; [ReadOnly] public ArchetypeChunkComponentType<TargetPositionData> TargetPositionDataType; [ReadOnly] public float DeltaTime; [ReadOnly] public float RotationLerpSpeed; [ReadOnly] public float MovementLerpSpeed; } 

Quando um segmento de trabalho recupera uma tarefa de sua fila, ele invoca o núcleo da tarefa.

Aqui está a aparência do núcleo de execução:

 public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex) { var chunkPositionData = chunk.GetNativeArray(PositionDataType); var chunkHeadingData = chunk.GetNativeArray(HeadingDataType); var chunkTargetPositionData = chunk.GetNativeArray(TargetPositionDataType); for (int i = 0; i < chunk.Count; i++) { var target = chunkTargetPositionData[i]; var positionData = chunkPositionData[i]; var headingData = chunkHeadingData[i]; float2 toTarget = target.TargetPosition - positionData.Position; float distance = math.length(toTarget); headingData.Heading = math.select( headingData.Heading, math.lerp(headingData.Heading, math.normalize(toTarget), math.mul(DeltaTime, RotationLerpSpeed)), distance > 0.008 ); positionData.Position = math.select( target.TargetPosition, math.lerp( positionData.Position, target.TargetPosition, math.mul(DeltaTime, MovementLerpSpeed)), distance <= 1 ); chunkPositionData[i] = positionData; chunkHeadingData[i] = headingData; } } 

“Você pode perceber que usamos seleção em vez de ramificação, isso nos permite eliminar o efeito chamado previsão incorreta de ramificação. A função de seleção avaliará ambas as expressões e selecionará a que corresponder à condição. Se suas expressões não forem tão difíceis de calcular, eu recomendaria o uso de seleção, porque geralmente é mais barato do que esperar que a CPU se recupere de uma previsão de ramificação incorreta. ” Anders.

Aumente a produtividade com a explosão


O passo final na conversão de DOTS para posição inimiga e interpolação de rumo é habilitar o compilador Burst. A tarefa parecia bastante simples para Anders: "Como os dados estão localizados em matrizes adjacentes e como usamos a nova biblioteca de matemática da Unity, tudo o que precisamos fazer foi adicionar o atributo BurstCompile à nossa tarefa".

 [BurstCompile] public struct UpdatePositionAndHeadingJob : IJobChunk { public ArchetypeChunkComponentType<PositionData2D> PositionDataType; public ArchetypeChunkComponentType<HeadingData2D> HeadingDataType; [ReadOnly] public ArchetypeChunkComponentType<TargetPositionData> TargetPositionDataType; [ReadOnly] public float DeltaTime; [ReadOnly] public float RotationLerpSpeed; [ReadOnly] public float MovementLerpSpeed; } 

O compilador Burst nos fornece dados múltiplos de instrução única (SIMD); instruções da máquina que podem trabalhar com vários conjuntos de dados de entrada e criar vários conjuntos de dados de saída com apenas uma instrução. Isso nos ajuda a preencher mais lugares no barramento de cache de 128 bits com os dados corretos. O compilador Burst, combinado com uma composição de dados e um sistema de tarefas amigáveis ​​ao cache, permitiu à equipe aumentar significativamente a produtividade. Aqui está a tabela que eles compilaram, medindo o desempenho após cada etapa de conversão.



Isso significava que Far North se livrou completamente dos problemas associados à interpolação da posição no lado do cliente e à direção dos zumbis. Seus dados agora são armazenados em um formulário conveniente para armazenamento em cache, e as linhas de cache são preenchidas apenas com dados úteis. A carga é distribuída para todos os núcleos da CPU, e o compilador Burst produz código de máquina altamente otimizado com instruções SIMD.

Far North Entertainment DOTS Dicas e Truques


  • Comece a pensar em termos de fluxos de dados, porque no ECS, as entidades são simplesmente índices de pesquisa em fluxos de dados de componentes paralelos.
  • Imagine o ECS como um banco de dados relacional no qual arquétipos são tabelas, componentes são colunas e entidades são índices em uma tabela (linha).
  • Organize seus dados em matrizes sequenciais para usar o cache do processador e a pré-busca do hardware.
  • Esqueça de querer criar hierarquias de objetos e tentar encontrar uma solução comum antes de entender o problema real que você está tentando resolver.
  • Pense em coleta de lixo. Evite a alocação excessiva de pilhas em áreas críticas ao desempenho. Use os novos contêineres nativos do Unity. Mas tenha cuidado, você tem que lidar com a limpeza manual.
  • Reconheça o valor de suas abstrações, cuidado com a sobrecarga de invocar funções virtuais.
  • Use todos os núcleos da CPU com o sistema de tarefas C #.
  • Analise o nível de hardware. O compilador Burst realmente gera instruções SIMD? Use o Burst Inspector para análise.
  • Pare de desperdiçar linhas de cache em vazio. Pense em empacotar dados em linhas de cache como empacotar dados em pacotes UDP.

O conselho principal que Anders Ericsson deseja compartilhar é um conselho mais geral para aqueles cujo projeto já está em desenvolvimento: “Tente identificar áreas específicas no seu jogo em que você tenha problemas de desempenho e veja se você pode aplicar o DOTS especificamente em esta área isolada. Você não precisa alterar toda a base de código! ”

Planos futuros


“Queremos usar o DOTS em outras áreas do jogo e ficamos encantados com os anúncios no Unite sobre animações do DOTS, Unity Physics e Live Link. Gostaríamos de aprender como converter mais objetos de jogo em objetos de ECS, e parece que o Unity fez um progresso significativo na implementação disso ”, conclui Anders.

Se você tiver perguntas adicionais para a equipe do Extremo Norte, recomendamos que você se junte ao Discord !
Confira a lista de reprodução Unite Copenhagen DOTS para descobrir como outros estúdios de jogos modernos usam o DOTS para criar ótimos jogos de alto desempenho e como componentes baseados em DOTS como DOTS Physics, o novo Conversion Workflow e o compilador Burst trabalham juntos.

A tradução chegou ao fim e convidamos você a participar de um seminário on-line gratuito , no qual informaremos como criar seu próprio atirador de zumbis em uma hora .

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


All Articles