Exibir mensagens no jogo usando o Sistema de Partículas

imagem

Tarefa


Ao desenvolver o jogo The Unliving, nos propusemos a tarefa de exibir várias mensagens, como danos causados, falta de saúde ou energia, valor da recompensa, número de pontos de saúde restaurados etc. usando o Sistema de Partículas. Foi decidido fazer isso para obter mais oportunidades para personalizar os efeitos da aparência e do comportamento posterior dessas mensagens, o que é problemático ao usar elementos padrão do sistema de interface do usuário do Unity.

Além disso, essa abordagem implica o uso de apenas uma instância do Particle System para cada tipo de mensagem, o que proporciona um enorme aumento de produtividade em comparação com a saída das mesmas mensagens usando a interface do usuário do Unity.

Relatório de danos

imagem

Mensagem de texto em falta

imagem

Algoritmo de decisão


Usando o shader, exibimos a textura pré-preparada usando as coordenadas UV corretas. As informações com coordenadas UV são transmitidas por dois fluxos (fluxos de vértices) ao ParticleSystem usando ParticleSystem.SetCustomParticleData na forma de uma lista Vector4.

Nossa implementação envolve o uso de texturas contendo 10 linhas e 10 colunas de caracteres. Qualquer fonte monoespaçada pode ser usada como fonte. Isso evita espaçamento diferente entre os caracteres da mensagem.

Fonte de textura no PSD

Implementação passo a passo


Criando Vector4 para transferência para o Vertex Stream

Para descrever o conjunto de caracteres, usaremos a estrutura SymbolsTextureData.

A matriz de caracteres deve ser preenchida manualmente, adicionando todos os símbolos de textura da fonte a partir do canto superior esquerdo.

[Serializable]
public struct SymbolsTextureData
{
    //   
    public Texture texture;
    //    ,   - 
    public char[] chars;
    
    //Dictionary     -    
    private Dictionary<char, Vector2> charsDict;

    public void Initialize()
    {
        charsDict = new Dictionary<char, Vector2>();
        for (int i = 0; i < chars.Length; i++)
        {
            var c = char.ToLowerInvariant(chars[i]);
            if (charsDict.ContainsKey(c)) continue;
            //  ,    
            //    , ,     10.
            var uv = new Vector2(i % 10, 9 - i / 10);
            charsDict.Add(c, uv);
        }
    }

    public Vector2 GetTextureCoordinates(char c)
    {
        c = char.ToLowerInvariant(c);
        if (charsDict == null) Initialize();

        if (charsDict.TryGetValue(c, out Vector2 texCoord))
            return texCoord;
        return Vector2.zero;
    }
}

Como resultado, obtemos a classe TextRendererParticleSystem. Quando você chama o método público SpawnParticle, uma partícula do Sistema de Partículas aparece na posição desejada, com o valor, a cor e o tamanho desejados.

[RequireComponent(typeof(ParticleSystem))]
public class TextRendererParticleSystem : MonoBehaviour
{
    private ParticleSystemRenderer particleSystemRenderer;
    private new ParticleSystem particleSystem;
    public void SpawnParticle(Vector3 position, string message, Color color, float? startSize = null)
    {
        //  
     }
}

O Particle System no Unity permite transferir dados personalizados na forma de 2 fluxos Vector4.



Intencionalmente, adicionamos um fluxo extra com UV2 para evitar uma mudança nas coordenadas dos fluxos. Se isso não for feito, as coordenadas X e Y do vetor Custom1 em C # corresponderão aos shaders Z e W TEXCOORD0. E, portanto, Custom1.z = TEXCOORD1.x, Custom1.w = TEXCOORD1.y. O que causará muitos inconvenientes no futuro.


Conforme descrito anteriormente, usaremos dois Vector4s para transmitir o comprimento da mensagem e as coordenadas UV dos caracteres. Como o Vector4 contém 4 elementos do tipo float, por padrão, podemos incluir 4 * 4 = 16 bytes de dados nele. Porque Como nossa mensagem conterá apenas o tamanho da mensagem (número de dois dígitos) e as coordenadas dos caracteres (número de dois dígitos para cada caractere), o intervalo do tipo byte (0-255) é redundante para nós. Ao usar casas decimais, tudo bem.

A precisão da flutuação é de 6 a 9 caracteres, o que significa que podemos usar com segurança 6 bits de cada coordenada Vector4 e não nos preocupar com a integridade e a precisão dos dados. Na verdade, tentamos incluir 7, 8 e 9 caracteres, mas a precisão da flutuação não é suficiente.

Acontece que em cada carro alegórico, usando casas decimais, empacotaremos até 6 dígitos, em contraste com a versão padrão com quatro bytes. No total, um Vector4 conterá 24 números de um dígito.

Como podemos transferir 2 vetores no fluxo, usaremos ambos para transmitir mensagens com até 23 caracteres:

Custom1.xyzw - os 12 primeiros caracteres da mensagem.
Custom2.xyzw - outros 11 caracteres da mensagem + tamanho da mensagem (últimos 2 caracteres).

Por exemplo, a mensagem "Olá" ficaria assim.


As coordenadas do caractere correspondem ao número da coluna e à linha de posição do caractere na textura.


No código, agrupar uma string em dois Vector4 terá a seguinte aparência:

//   Vector2     float
public float PackFloat(Vector2[] vecs)
{
    if (vecs == null || vecs.Length == 0) return 0;            
    //      float
    var result = vecs[0].y * 10000 + vecs[0].x * 100000;
    if (vecs.Length > 1) result += vecs[1].y * 100 + vecs[1].x * 1000;
    if (vecs.Length > 2) result += vecs[2].y + vecs[2].x * 10;            
        return result;
}

//  Vector4    CustomData
private Vector4 CreateCustomData(Vector2[] texCoords, int offset = 0)
{
    var data = Vector4.zero;            
    for (int i = 0; i < 4; i++)
    {
        var vecs = new Vector2[3];                
        for (int j = 0; j < 3; j++)
        {
            var ind = i * 3 + j + offset;
            if (texCoords.Length > ind)
            {
                vecs[j] = texCoords[ind];
            }
            else
            {
                data[i] = PackFloat(vecs);
                i = 5; 
                break;
            }
        }
        if (i < 4) data[i] = PackFloat(vecs);
    }
    return data;
}

//    
public void SpawnParticle(Vector3 position, string message, Color color, float? startSize = null)
{
    var texCords = new Vector2[24]; //  24  - 23  +  
    var messageLenght = Mathf.Min(23, message.Length);
    texCords[texCords.Length - 1] = new Vector2(0, messageLenght);
    for (int i = 0; i < texCords.Length; i++)
    {
        if (i >= messageLenght) break;
        //  GetTextureCoordinates()  SymbolsTextureData    
        texCords[i] = textureData.GetTextureCoordinates(message[i]);
    }
		
    var custom1Data = CreateCustomData(texCords);
    var custom2Data = CreateCustomData(texCords, 12);
}

Vetor com CustomData pronto. É hora de gerar manualmente uma nova partícula com os parâmetros desejados. Geração de

partículas

A primeira coisa que devemos fazer é garantir que os fluxos CustomData sejam ativados nas configurações do Renderer do sistema de partículas:

//   ParticleSystem
if (particleSystem == null) particleSystem = GetComponent<ParticleSystem>();

if (particleSystemRenderer == null)
{
    //   ParticleSystemRenderer,       
    particleSystemRenderer = particleSystem.GetComponent<ParticleSystemRenderer>();
    var streams = new List<ParticleSystemVertexStream>();
    particleSystemRenderer.GetActiveVertexStreams(streams);
    //   Vector2(UV2, SizeXY, etc.),        
    if (!streams.Contains(ParticleSystemVertexStream.UV2)) streams.Add(ParticleSystemVertexStream.UV2);
    if (!streams.Contains(ParticleSystemVertexStream.Custom1XYZW)) streams.Add(ParticleSystemVertexStream.Custom1XYZW);
    if (!streams.Contains(ParticleSystemVertexStream.Custom2XYZW)) streams.Add(ParticleSystemVertexStream.Custom2XYZW);
    particleSystemRenderer.SetActiveVertexStreams(streams);
}

Para criar uma partícula, usamos o método Emit () da classe ParticleSystem.

//  
//      
// startSize3D  X,       
//   
var emitParams = new ParticleSystem.EmitParams
{
    startColor = color,
    position = position,
    applyShapeToPosition = true,
    startSize3D = new Vector3(messageLenght, 1, 1)
};
//      ,    SpawnParticle 
//   startSize
if (startSize.HasValue) emitParams.startSize3D *= startSize.Value * particleSystem.main.startSizeMultiplier;
//  
particleSystem.Emit(emitParams, 1);

//     
var customData = new List<Vector4>();
//  ParticleSystemCustomData.Custom1  ParticleSystem
particleSystem.GetCustomParticleData(customData, ParticleSystemCustomData.Custom1);
//   , ..  ,     
customData[customData.Count - 1] = custom1Data;
//   ParticleSystem
particleSystem.SetCustomParticleData(customData, ParticleSystemCustomData.Custom1);

//  ParticleSystemCustomData.Custom2
particleSystem.GetCustomParticleData(customData, ParticleSystemCustomData.Custom2);            
customData[customData.Count - 1] = custom2Data;
particleSystem.SetCustomParticleData(customData, ParticleSystemCustomData.Custom2);

Adicione os dois blocos ao método SpawnParticle () e a parte C # está pronta: a mensagem é compactada e transmitida pela GPU na forma de dois Vector4 no Vertex Stream. O mais interessante é aceitar esses dados e exibi-los corretamente.

Código de sombreador

Shader "Custom/TextParticles"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        //         10,    
        _Cols ("Columns Count", Int) = 10
        _Rows ("Rows Count", Int) = 10
    }
    SubShader
    {            
        Tags { "RenderType"="Opaque" "PreviewType"="Plane" "Queue" = "Transparent+1"}
        LOD 100
        ZWrite Off
        Blend SrcAlpha OneMinusSrcAlpha

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            
            #include "UnityCG.cginc"
            
            struct appdata
            {
                float4 vertex : POSITION;
                fixed4 color : COLOR;
                float4 uv : TEXCOORD0;
                //    customData
                float4 customData1 : TEXCOORD1;
                float4 customData2 : TEXCOORD2;
            };           

            struct v2f
            {
                float4 vertex : SV_POSITION;
                fixed4 color : COLOR;
                float4 uv : TEXCOORD0;
                float4 customData1 : TEXCOORD1;
                float4 customData2 : TEXCOORD2;
            };
            
            uniform sampler2D _MainTex;
            uniform uint _Cols;
            uniform uint _Rows;
            
            v2f vert (appdata v)
            {
                v2f o;
                //        w- ?
                //       .
                //      100.
                float textLength = ceil(fmod(v.customData2.w, 100));

                o.vertex = UnityObjectToClipPos(v.vertex);
                //  UV ,   -   
                o.uv.xy = v.uv.xy * fixed2(textLength / _Cols, 1.0 / _Rows);
                o.uv.zw = v.uv.zw;
                o.color = v.color;                
                o.customData1 = floor(v.customData1);
                o.customData2 = floor(v.customData2);
                return o;
            }
            
            fixed4 frag (v2f v) : SV_Target
            {
                fixed2 uv = v.uv.xy;
                //   
                uint ind = floor(uv.x * _Cols);

                uint x = 0;
                uint y = 0;

                //  ,   
                //0-3 - customData1
                //4-7 - customData2
                uint dataInd = ind / 3;
                //   6     float
                uint sum = dataInd < 4 ? v.customData1[dataInd] : v.customData2[dataInd - 4];

                //  float      
                for(int i = 0; i < 3; ++i)
                {
                    if (dataInd > 3 & i == 3) break;
                    //  ,   10^2 = 99  ..
                    uint val = ceil(pow(10, 5 - i * 2));
                    x = sum / val;
                    sum -= x * val;

                    val = ceil(pow(10, 4 - i * 2));
                    y = sum / val;
                    sum -= floor(y * val);

                    if (dataInd * 3 + i == ind) i = 3;
                }                

                float cols = 1.0 / _Cols;
                float rows = 1.0 / _Rows;
                // UV-,  - , , 
                //     
                uv.x += x * cols - ind * rows;
                uv.y += y * rows;
                
                return tex2D(_MainTex, uv.xy) * v.color;
            }
            ENDCG
        }
    }
}

Unity Editor

Crie material e atribua-o ao nosso shader. Na cena, crie um objeto com o componente ParticleSystem, atribua o material criado. Em seguida, ajustamos o comportamento das partículas e desativamos o parâmetro Play On Awake. De qualquer classe, chamamos o método RendererParticleSystem.SpawnParticle () ou usamos o método de barganha.

[ContextMenu("TestText")]
public void TestText()
{
    SpawnParticle(transform.position, "Hello world!", Color.red);
}

O código-fonte, os recursos e os exemplos de uso podem ser encontrados aqui .

Sistema de mensagens em ação

imagem

Isso é tudo. Saída de mensagem usando o sistema de partículas pronto! Esperamos que esta solução beneficie os desenvolvedores de jogos Unity.

UPD: Desempenho da solução proposta
Nos comentários de várias pessoas, surgiu uma pergunta sobre o desempenho desse método. Medições especialmente feitas pelo criador de perfil de unidade. As condições são as mesmas - 1.000 objetos em movimento e que mudam de cor.

O resultado do uso da interface do usuário padrão (a única tela na qual existem apenas 1000 objetos de texto da interface do usuário):

O tempo total do quadro é de pelo menos 50ms, dos quais 40ms são gastos na atualização da tela. Ao mesmo tempo, os objetos nem aparecem, mas simplesmente se movem.

O resultado de uma semente de 1000 partículas usando nossa solução:

Toda mágica acontece na GPU, mesmo no momento da geração de 1000 partículas, o quadro é calculado em menos de 5ms.

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


All Articles