Recentemente, eu precisava resolver um problema bastante comum em muitos jogos com uma vista superior: renderizar na tela um monte de barras de saúde inimigas. Algo assim:
Obviamente, eu queria fazer isso da maneira mais eficiente possível, de preferência em uma chamada de empate. Como sempre, antes de começar o trabalho, fiz uma pequena pesquisa on-line sobre as decisões de outras pessoas, e os resultados foram muito diferentes.
Não vou envergonhar ninguém pelo código, mas basta dizer que algumas das soluções não foram totalmente brilhantes; por exemplo, alguém adicionou um objeto Canvas a cada inimigo (o que é muito ineficiente).
O método que cheguei como resultado é um pouco diferente de tudo que vi com outras pessoas e não usa nenhuma classe de interface do usuário (incluindo o Canvas), então decidi documentá-lo para o público. E para quem quer aprender o código-fonte,
publiquei no Github .
Por que não usar o Canvas?
Uma tela para cada inimigo é obviamente uma má decisão, mas eu poderia usar uma tela comum para todos os inimigos; um único Canvas também levaria à renderização de lotes de chamadas.
No entanto, não gosto da quantidade de trabalho realizado em cada quadro relacionado a essa abordagem. Se você usa o Canvas, em cada quadro você deve executar as seguintes operações:
- Determine quais dos inimigos estão na tela e selecione cada um deles na faixa da interface do usuário do pool.
- Projete a posição do inimigo na câmera para posicionar a tira.
- Redimensione a parte "fill" da faixa, provavelmente como Image.
- É mais provável que mude o tamanho das tiras de acordo com o tipo de inimigo; por exemplo, inimigos grandes devem ter faixas grandes para que não pareçam bobos.
De qualquer forma, tudo isso contaminaria os buffers da geometria do Canvas e levaria a uma reconstrução de todos os dados de vértices no processador. Eu não queria que tudo isso fosse feito para um elemento tão simples.
Brevemente sobre minha decisão
Uma breve descrição do meu processo de trabalho:
- Anexamos objetos de tiras de energia aos inimigos em 3D.
- Isso permite organizar e aparar tiras automaticamente.
- A posição / tamanho da faixa pode ser ajustada de acordo com o tipo de inimigo.
- Direcionaremos as faixas para a câmera no código usando a transformação, que ainda está lá.
- O shader garante que eles sempre sejam renderizados em cima de tudo.
- Usamos Instanciamento para renderizar todas as faixas em uma única chamada de empate.
- Usamos coordenadas UV processuais simples para exibir o nível de plenitude da tira.
Agora vamos ver a solução com mais detalhes.
O que é Instanciamento?
Ao trabalhar com gráficos, a técnica padrão tem sido usada há muito tempo: vários objetos são combinados para que eles tenham dados e materiais de vértice comuns e possam ser renderizados em uma chamada de desenho. É exatamente disso que precisamos, porque cada chamada de empate é uma carga extra na CPU e na GPU. Em vez de fazer uma única chamada de desenho para cada objeto, renderizamos todos ao mesmo tempo e usamos um sombreador para adicionar variabilidade a cada cópia.
Você pode fazer isso manualmente duplicando os dados do vértice da malha X vezes em um buffer, em que X é o número máximo de cópias que podem ser renderizadas e, em seguida, usando a matriz de parâmetros do shader para converter / colorir / variar cada cópia. Cada cópia deve armazenar conhecimento sobre qual é a instância numerada, para usar esse valor como um índice da matriz. Em seguida, podemos usar uma chamada de renderização indexada que ordena “renderizar apenas para N”, onde N é o número de instâncias
realmente necessárias no quadro atual, menor que o número máximo de X.
As APIs mais modernas já possuem código para isso, portanto, você não precisa fazer isso manualmente. Esta operação é chamada "Instanciamento"; de fato, ele automatiza o processo descrito acima com restrições predefinidas.
O mecanismo do Unity também suporta instâncias , possui sua própria API e um conjunto de macros de sombreador que ajudam na sua implementação. Ele usa certas suposições, por exemplo, de que cada instância requer uma transformação 3D completa. A rigor, para tiras 2D, isso não é necessário completamente - podemos fazer simplificações, mas, como são, as usaremos. Isso simplificará nosso sombreador e também fornecerá a capacidade de usar indicadores 3D, por exemplo, círculos ou arcos.
Classe danificável
Nossos inimigos terão um componente chamado
Damageable
, dando a eles saúde e permitindo que eles
Damageable
danos causados por colisões. No nosso exemplo, é bastante simples:
public class Damageable : MonoBehaviour { public int MaxHealth; public float DamageForceThreshold = 1f; public float DamageForceScale = 5f; public int CurrentHealth { get; private set; } private void Start() { CurrentHealth = MaxHealth; } private void OnCollisionEnter(Collision other) {
HealthBar Object: Posição / Turno
O objeto da barra de saúde é muito simples: na verdade, é apenas um Quad anexado ao inimigo.

Usamos a
escala deste objeto para tornar a tira longa e fina, e a colocamos diretamente acima do inimigo. Não se preocupe com sua rotação, vamos corrigi-lo usando o código anexado ao objeto no
HealthBar.cs
:
private void AlignCamera() { if (mainCamera != null) { var camXform = mainCamera.transform; var forward = transform.position - camXform.position; forward.Normalize(); var up = Vector3.Cross(forward, camXform.right); transform.rotation = Quaternion.LookRotation(forward, up); } }
Esse código sempre direciona o quad para a câmera. Podemos realizar redimensionamento e rotação no sombreador, mas eu os implemento aqui por dois motivos.
Primeiro, o instanciamento do Unity sempre usa a transformação completa de cada objeto e, como transferimos todos os dados de qualquer maneira, você pode usá-lo. Em segundo lugar, definir aqui a escala / rotação garante que o paralelogramo delimitador para aparar a tira sempre seja verdadeiro. Se tornarmos a tarefa de tamanho e rotação a responsabilidade do shader, o Unity poderá truncar as tiras que devem estar visíveis quando estiverem próximas às bordas da tela, porque o tamanho e a rotação do paralelogramo delimitador não corresponderão ao que vamos renderizar. Obviamente, poderíamos implementar nosso próprio método de truncamento, mas geralmente é melhor usar o que temos, se possível (o código do Unity é nativo e tem acesso a mais dados espaciais do que nós).
Vou explicar como a tira é renderizada depois de olharmos para o shader.
Shader HealthBar
Nesta versão, criaremos uma simples faixa verde-vermelha clássica.
Eu uso uma textura 2x1 com um pixel verde à esquerda e um vermelho à direita. Naturalmente, desliguei o mipmapping, a filtragem e a compactação e defino o parâmetro do modo de endereçamento como Clamp, o que significa que os pixels da nossa faixa sempre serão perfeitamente verdes ou vermelhos e não se espalharão pelas bordas. Isso nos permitirá alterar as coordenadas da textura no sombreador para mudar a linha que divide os pixels vermelho e verde para baixo e para cima na faixa.
(Como existem apenas duas cores aqui, eu poderia usar a função step no shader para retornar ao ponto de uma ou outra. No entanto, esse método é conveniente porque você pode usar uma textura mais complexa, se desejar, e isso funcionará da mesma forma durante a transição. textura média.)Primeiro, declararemos as propriedades necessárias:
Shader "UI/HealthBar" { Properties { _MainTex ("Texture", 2D) = "white" {} _Fill ("Fill", float) = 0 }
_MainTex
é uma textura vermelho-verde e
_Fill
é um valor de 0 a 1, em que 1 tem vida útil completa.
Em seguida, precisamos solicitar que a faixa seja renderizada na fila de sobreposição, o que significa ignorar toda a profundidade da cena e renderizar sobre tudo:
SubShader { Tags { "Queue"="Overlay" } Pass { ZTest Off
A próxima parte é o próprio código do sombreador. Como escrevemos um sombreador sem iluminação (apagado), não precisamos nos preocupar com a integração com vários sombreadores de superfície do Unity; são apenas alguns sombreadores de vértice / fragmento. Primeiro, escreva bootstrap:
CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_instancing #include "UnityCG.cginc"
Na maioria das vezes, esse é o bootstrap padrão, com exceção do
#pragma multi_compile_instancing
, que informa ao compilador Unity o que precisa ser compilado para o Instanciamento.
A estrutura do vértice deve incluir dados da instância; portanto, faremos o seguinte:
struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID };
Também precisamos especificar o que exatamente estará nos dados das instâncias, além do que o Unity (transformação) processa para nós:
UNITY_INSTANCING_BUFFER_START(Props) UNITY_DEFINE_INSTANCED_PROP(float, _Fill) UNITY_INSTANCING_BUFFER_END(Props)
Portanto, estamos relatando que o Unity deve criar um buffer chamado "Props" para armazenar os dados de cada instância e, dentro dele, usaremos um float por instância para uma propriedade chamada
_Fill
.
Você pode usar vários buffers; vale a pena fazer se você tiver várias propriedades atualizadas em diferentes frequências; Ao dividi-los, você não pode, por exemplo, alterar um buffer ao alterar outro, o que é mais eficiente. Mas não precisamos disso.
Nosso shader de vértice quase completamente faz o trabalho padrão, porque tamanho, posição e rotação já são transferidos para transformação. Isso é implementado usando o
UnityObjectToClipPos
, que usa automaticamente a transformação de cada instância. Pode-se imaginar que, sem instanciar, isso normalmente seria simples usando uma única propriedade da matriz. mas ao usar instanciamento dentro do mecanismo, ele se parece com uma matriz de matrizes e o Unity seleciona independentemente uma matriz adequada para essa instância.
Além disso, você precisa alterar os UV para alterar o local do ponto de transição de vermelho para verde, de acordo com a propriedade
_Fill
. Aqui está o trecho de código relevante:
UNITY_SETUP_INSTANCE_ID(v); float fill = UNITY_ACCESS_INSTANCED_PROP(Props, _Fill);
UNITY_SETUP_INSTANCE_ID
e
UNITY_ACCESS_INSTANCED_PROP
fazem toda a mágica acessando a versão correta da propriedade
_Fill
no buffer constante desta instância.
Sabemos que, no estado normal, as coordenadas UV de um quadrilátero cobrem todo o intervalo de textura e que a linha divisória da faixa está no meio da textura horizontalmente. Portanto, pequenos cálculos matemáticos deslocam horizontalmente a faixa para a esquerda ou direita, e o valor de Fixação da textura garante o preenchimento da peça restante.
O shader de fragmento não poderia ser mais simples porque todo o trabalho já foi realizado:
return tex2D(_MainTex, i.uv);
O código completo do shader de comentários está disponível no
repositório GitHub .
Healthbar Material
Então tudo é simples - precisamos atribuir à nossa tira o material que esse shader usa. Quase nada mais precisa ser feito, basta selecionar o sombreador desejado na parte superior, atribuir uma textura vermelho-verde e, mais importante,
marcar a caixa "Ativar instância de GPU" .

Atualização da propriedade de preenchimento HealthBar
Portanto, como temos o objeto da barra de integridade, o sombreador e o material a ser renderizado, agora precisamos definir a propriedade
_Fill
para cada instância. Fazemos isso no
HealthBar.cs
seguinte maneira:
private void UpdateParams() { meshRenderer.GetPropertyBlock(matBlock); matBlock.SetFloat("_Fill", damageable.CurrentHealth / (float)damageable.MaxHealth); meshRenderer.SetPropertyBlock(matBlock); }
CurrentHealth
classe
CurrentHealth
em um valor de 0 a 1, dividindo-o pelo
MaxHealth
. Em seguida, passamos para a propriedade
_Fill
usando
MaterialPropertyBlock
.
Se você não usou o
MaterialPropertyBlock
para transferir dados para shaders, mesmo sem instanciar, precisará estudá-los. Isso não está bem explicado na documentação do Unity, mas é a maneira mais eficiente de transferir dados de cada objeto para shaders.
No nosso caso, quando o instanciamento é usado, os valores para todas as barras de integridade são compactados em um buffer constante, para que possam ser transferidos todos juntos e desenhados ao mesmo tempo.
Não há quase nada aqui, exceto um padrão para definir variáveis, e o código é bastante chato; veja
o repositório GitHub para detalhes.
Demo
O
repositório do GitHub tem uma demonstração de teste na qual um monte de cubos azuis malignos são destruídos por esferas vermelhas heróicas (hurra!), Sofrendo os danos exibidos pelas faixas descritas no artigo. Demonstração escrita em Unity 2018.3.6f1.
O efeito do uso de instanciamento pode ser observado de duas maneiras:
Painel de Estatísticas
Depois de clicar em Reproduzir, clique no botão Estatísticas acima do painel Jogo. Aqui você pode ver quantas chamadas de empate foram salvas graças à instância:

Após o lançamento do jogo, você pode clicar no material HealthBar e
desmarcar a caixa de
seleção "Ativar instância de GPU", após o qual o número de chamadas salvas será reduzido a zero.
Depurador de quadros
Depois de iniciar o jogo, vá para Janela> Análise> Depurador de quadros e clique em "Ativar" na janela que aparece.
No canto inferior esquerdo, você verá todas as operações de renderização executadas. Observe que, embora existam muitos desafios separados para inimigos e projéteis (se desejar, você também pode implementar instâncias para eles). Se você rolar para a parte inferior, verá o item "Barra de saúde Draw Mesh (instanced)".
Essa chamada única renderiza todas as tiras. Se você clicar nessa operação e depois na operação, verá que todas as tiras desaparecem, porque são sorteadas em uma chamada. Se estiver no Depurador de quadros, desmarque a caixa de seleção Ativar instância de GPU do material, verá que uma linha se transformou em várias e depois de definir o sinalizador novamente em uma.
Como expandir este sistema
Como eu disse antes, como essas barras de saúde são objetos reais, não há nada que o impeça de transformar simples barras 2D em algo mais complexo. Eles podem ser semicírculos sob inimigos que diminuem em um arco ou losangos rotativos acima de suas cabeças. Usando a mesma abordagem, você ainda pode renderizá-los todos em uma chamada.