Como eu fiz sombras 2D no Unity

O que vem à mente de um desenvolvedor de jogos independente quando se depara com a necessidade de adicionar um recurso que ele não tem idéia sobre a implementação? Obviamente, ele procurará traços daqueles que já percorreram esse caminho e se deram ao trabalho de anotar suas experiências. Então fiz isso há algum tempo, começando a criar sombras no meu jogo. Encontrar a informação certa - na forma de artigos, lições e guias - não foi difícil. No entanto, para minha surpresa, descobri que nenhuma das soluções descritas me convinha. Portanto, tendo realizado o meu, decidi contar ao mundo.

Vale a pena avisar com antecedência que este texto não pretende ser uma espécie de guia de ultimato ou master class. O método que usei pode não ser universal, longe de ser o mais eficaz e não cobre a tarefa de criar sombras bidimensionais por completo. É mais uma história sobre quais truques um desenvolvedor inexperiente na minha cara teve que recorrer para alcançar um resultado que satisfizesse seus requisitos.

O resultado em si está diante de você:



E os detalhes do caminho para sua conquista estão esperando por você.

Um pouco sobre o jogo em si
O Dwarfinator é um shooter bidimensional de defesa de base / rolagem lateral desenvolvido de olho nos segmentos móvel e de desktop. A jogabilidade consiste na destruição sistemática das ondas inimigas em dois modos alternados - defesa e perseguição. A progressão de um jogador envolve bombear um "tanque" melhorando e substituindo vários elementos dele, como armas, motores e rodas, bem como elevando o nível e aprendendo habilidades ativas e passivas. A progressão do ambiente envolve um aumento constante no número de monstros na onda, a adição de novos tipos de inimigos à onda à medida que progridem no local e a mudança sucessiva de vários locais, cada um com seu próprio conjunto de oponentes.

Declaração do problema


Então, no momento da decisão de adicionar sombras ao jogo, eu tinha:

  • localização na forma de dois sprites, um para exibir atrás de mobs e outras entidades, o segundo para exibir na frente deles;



  • mobs e objetos estáticos destrutíveis, constantemente animados e consistindo de sprites separados em uma quantidade de algumas a algumas dúzias;



  • conchas próprias e inimigas, representadas na maioria dos casos por um sprite ou por um sistema de partículas; neste último caso, nenhuma sombra era necessária;



  • um tanque constituído por várias partes montadas de acordo com o mesmo esquema que os mobs;



  • paredes com vários estados fixos, que, novamente, são um conjunto de sprites separados.



Por tudo isso, as sombras mais simples eram necessárias, repetindo os contornos do objeto e projetadas a partir de uma única fonte de luz fixa.

Ao mesmo tempo, deve-se ter uma atitude aguçada em relação à produtividade. Devido às especificidades do gênero e às peculiaridades de sua implementação, a maioria dos objetos que projetam sombras estão localizados diretamente na tela a qualquer momento. E o número total deles pode ser superior a cem, se falamos de entidades de jogos, e alguns milhares, se falamos de sprites individuais.

Implementação


Na verdade, o principal problema foi que Dwarfinator, grosso modo, é um jogo 2.5D. A grande maioria dos objetos existe no espaço bidimensional com os eixos X e Y, e o eixo Z é usado raramente. Visualmente e parcialmente em jogabilidade, o eixo Y é usado para exibir altura e profundidade, dividindo-se da mesma maneira nos eixos virtual Y e Z. Não foi possível usar as ferramentas padrão do Unity nessa situação para criar sombras.

Mas, na verdade, eu não precisava de iluminação honesta, bastava criar manualmente uma sombra para cada objeto. Portanto, a coisa mais simples que me ocorreu foi simplesmente colocar uma cópia dela atrás de cada entidade, girada no espaço tridimensional, de modo a simular um local na superfície. Todos os sprites dessa pseudo-sombra foram definidos como pretos, enquanto a estrutura hierárquica do proprietário da sombra foi preservada, o que permitiu que ela fosse animada em sincronização com o proprietário pelo mesmo animador.

Essa animação síncrona se parecia com isso:



No entanto, a sombra exigia transparência. A solução mais simples foi configurá-lo para cada sprite de sombra. Mas essa implementação não parecia satisfatória - os sprites se sobrepunham, formando áreas menos transparentes no local da sobreposição.

A captura de tela abaixo mostra como é a sombra de vários segmentos translúcidos. Os parâmetros de distorção de sombra usados ​​também são visíveis: a rotação no eixo X em -50 graus, a rotação no eixo Y em -140 graus e a escala no eixo X aumentaram 1,3 vezes em relação ao objeto pai.



Tornou-se óbvio que a transparência deveria ser imposta à sombra como um objeto sólido. O primeiro experimento sobre esse tópico foi suspenso na sombra da câmera, renderizando essa sombra no RenderTexture, que foi usado como material anexado ao pai da sombra do plano. Ele já podia definir a transparência sem problemas. As sombras estavam fora do quadro para evitar a sobreposição de áreas de captura da câmera. A abordagem funcionou, mas acabou que algumas dúzias de sombras causavam sérios problemas de desempenho, principalmente por causa do número de câmeras no palco. Além disso, várias animações assumiram um movimento significativo de sprites individuais de mob dentro da estrutura de seu objeto raiz, devido ao qual deveria ser localizada uma área de câmera que excederia significativamente o tamanho da imagem real em um determinado momento.

A solução foi encontrada rapidamente - se você não pode desenhar cada sombra com uma câmera separada - por que não desenhar todas as sombras com uma câmera? Tudo o que precisava ser feito era colocar uma área separada da cena sob a sombra, um pouco mais alta que o campo de visão da câmera principal, enviar uma câmera adicional para essa área e exibir sua saída entre o local e outras entidades.

Abaixo você pode ver um exemplo da saída desta câmera:



A produtividade dessa implementação sofreu muito menos; portanto, a solução foi considerada funcionando e aplicada a todos os mobs, objetos estáticos e shells. Isso foi seguido pela localização do sprite. Era impossível usar um sprite em todos os objetos, como foi implementado anteriormente. O uso de uma cópia de um objeto como sombra só funciona bem desde que o objeto seja completamente plano. Mesmo ao criar sombras para mobs, era notável que os pontos de contato com a superfície espaçada ao longo da terceira coordenada violam a correção da sombra em relação a esses pontos.

A captura de tela a seguir mostra um exemplo dessa violação. O calcanhar da multidão é considerado o ponto de contato com a superfície, mas as sombras dos pés já estão além dos próprios pés.



E se no caso das pernas do ogro você ainda pode mudar ligeiramente a posição da sombra e mascarar o problema, então para várias dezenas de troncos de árvores não há chance. Todos os objetos de localização que deveriam projetar uma sombra devem ser separados em GameObject. Foi exatamente isso que fiz colocando cópias dos objetos destrutíveis correspondentes na pré-fabricada local e desativando os scripts que não são usados ​​nessa posição. Ao mesmo tempo, graças a isso, tornou-se possível incluí-los na classificação geral dos objetos da cena, e as conchas que voavam fora do local não eram mais desenhadas estritamente em cima de todos os objetos, mas voavam entre eles. Além disso, tornou-se possível animar os objetos.

Mas então um novo problema me esperava. Com sombras e dezenas de novos objetos, o número máximo de GameObjects simultaneamente no palco e, com eles, os componentes Animator e SpriteRenderer, mais que dobraram. Quando soltei toda a onda de multidões no local, que totalizava cerca de 150 peças, o Profiler me mostrou reprovadoramente cerca de 40ms, que foram embora apenas para renderização e animação, e a taxa de quadros geralmente variava em torno de 10. Otimizei desesperadamente meus próprios scripts, lutando a cada milissegundo, mas isso não foi suficiente.

Em busca de ferramentas de otimização adicionais, me deparei com a vasta documentação e guias para lotes dinâmicos.

Um pouco mais sobre lotes
Em resumo, o lote é um mecanismo para minimizar o número de chamadas de empate e, com ele, o tempo gasto no momento de renderizar o quadro na interação entre a CPU e a GPU. Quando usado em vez de enviar cada elemento individualmente para renderização, elementos semelhantes são agrupados e desenhados juntos por vez. No caso do Unity, o próprio mecanismo tenta fazer o máximo uso desse mecanismo e quase nenhuma ação adicional é necessária ao desenvolvedor.

O Frame Debugger mostrou que eu tenho, na melhor das hipóteses, os detalhes de cada objeto ou mob separadamente. Tendo criado sprites para o primeiro e o segundo no atlas, consegui sombrear as sombras com apenas algumas chamadas, mas os donos dessas sombras se recusaram teimosamente a lutar contra si mesmos.

Experimentos em uma cena separada mostraram que o lote dinâmico é interrompido quando os objetos têm um componente SortingGroup, que eu usei para classificar a exibição de entidades na tela. Era possível ficar sem ele, em teoria, no entanto, definir os valores de classificação para cada sprite e sistema de partículas em um objeto separadamente poderia ser ainda mais caro do que a falta de lotes.

Mas algo me assombrou. O objeto de sombra, sendo um descendente do objeto host na cena real, tecnicamente pertencia ao mesmo SortingGroup, no entanto, não havia problemas com os objetos de sombra dinâmicos sombreados. A única diferença era que os objetos host foram desenhados diretamente na tela pela câmera principal e os objetos shadow foram renderizados primeiro no RenderTexture.

Essa foi a pegadinha. O motivo exato desse comportamento é desconhecido para a Internet, mas ao renderizar as imagens da câmera no RenderTexture, o SortingGroup não interrompe mais o lote. A decisão parecia muito estranha, ilógica e, em geral, a mais muleta. Mas, ao implementar a renderização de entidades usando o mesmo método que a renderização de sombras, e assim obtendo, além da camada de sombra, uma camada de entidade, eu já atingi valores de desempenho bastante aceitáveis.

A captura de tela abaixo mostra um exemplo de renderização de uma camada de entidade.



Portanto, em geral, renderizar uma certa entidade na coordenada Y se parece com isso:

  1. A entidade é colocada em Y-20;
  2. Uma entidade é renderizada por uma câmera observando essa coordenada em uma RenderTexture para entidades;
  3. A sombra da entidade é colocada em Y + 20;
  4. A sombra de uma entidade é desenhada por uma câmera observando essa coordenada em uma RenderTexture para sombras;
  5. A câmera principal desenha o sprite de localização principal na tela - o único elemento atualmente sendo renderizado diretamente na tela;
  6. A câmera principal desenha um plano na tela com sombras RenderTexture como material;
  7. A câmera principal desenha um plano na tela com uma RenderTexture de entidades como material.

Um bolo de camada.

Na captura de tela abaixo, a câmera do editor está configurada para o modo tridimensional para demonstrar a localização das camadas entre si.



Nuances


Porém, como ocorreu durante o processo de replicação da decisão para outras entidades, o caso geral não abrangeu todos os cenários possíveis. Por exemplo, havia entidades que estavam em alguma altura em relação à superfície, em particular, conchas e alguns caracteres de cena. Além disso, as conchas também tinham a capacidade de girar dependendo da direção do movimento na tela, pelo que, além de definir o ponto de intersecção do objeto e sua sombra, era necessário selecionar a parte rotativa como um objeto filho separado, para corrigir a lógica de rotação do projétil e sua animação.

A captura de tela a seguir mostra um exemplo da rotação de conchas e suas sombras.



Personagens voadores, bem como mobs voadores planejados, também podem se mover dentro de suas coordenadas Y virtuais, o que exigiu a criação de um mecanismo para calcular a posição da sombra a partir da posição de seu proprietário no eixo Y virtual.

O GIF abaixo mostra um exemplo de movimentação de um objeto em altura.



Outro caso que saiu do conceito geral foi um tanque. Ao contrário de todas as outras entidades, o tanque tem um tamanho muito substancial ao longo do eixo Z virtual, e a implementação geral das sombras, como já mencionado, exige que o objeto seja quase plano. A maneira mais fácil de contornar isso era desenhar manualmente formas de sombra para partes individuais do tanque, pois você poderia colocar qualquer coisa na camada de sombra.

Para a construção correta de sombras desenhadas à mão, tive que montar um design de linhas com base na captura de tela de uma sombra existente, que pode ser vista na captura de tela abaixo.



Se você dimensionar e posicionar essa estrutura de forma que a parte superior esteja em algum ponto do objeto pai e a parte inferior no ponto de contato com a superfície, o canto direito da estrutura mostrará o local onde o ponto de sombra correspondente deve estar. Tendo projetado vários pontos-chave dessa maneira, não é difícil construir toda a sombra sobre eles.

Além disso, partes individuais do tanque podem ter alturas diferentes para anexar partes menores, o que, como no caso de personagens voadores e monstros, requeria o ajuste da posição da sombra de cada parte específica.

A captura de tela abaixo mostra o tanque, seu conjunto de sombras e também na forma de peças separadas.



Sombras das paredes acabaram sendo uma dor separada. No início do trabalho nas sombras, as paredes eram da mesma natureza que os detalhes do tanque - um objeto de várias dúzias de sprites separados. No entanto, as paredes tinham vários estados controlados pelo animador.

Pensando muito sobre o que fazer com eles, cheguei à conclusão de que o conceito de paredes precisa ser mudado. Como resultado, as paredes foram divididas em seções, cada uma com seu próprio conjunto de estados, seu próprio animador e sua própria sombra. Isso nos permitiu usar a mesma abordagem para criar sombras para as seções paralelas do eixo X que as mobs, e para as seções que não se encaixavam nessa regra, tivemos que criar algo próprio. Em alguns casos, tive que criar meu próprio animador para a sombra de seção e definir manualmente a posição dos sprites.

Por exemplo, no caso da seção mostrada na captura de tela abaixo, a sombra é feita aplicando distorção para cada log individual em vez da seção inteira.



Conclusão


Isso, de fato, é tudo. Apesar de todas as nuances acima, a tarefa original foi concluída na íntegra, e agora meu projeto pode apresentar sombras bastante decentes, embora de origem um tanto duvidosa. Espero que, graças a este artigo, para o próximo desenvolvedor independente que fez uma pergunta semelhante a mim, a Internet se torne um pouco mais útil, se não como um exemplo a seguir, pelo menos como o erro de outra pessoa em seu próprio aprendizado.

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


All Articles