Afficher des messages dans le jeu à l'aide du système de particules

image

Tâche


Lors du développement de notre jeu The Unliving, nous nous sommes fixé pour tâche d'afficher divers messages, tels que les dommages causés, le manque de santé ou d'énergie, la valeur de récompense, le nombre de points de vie restaurés, etc., en utilisant le système de particules. Il a été décidé de le faire afin d'avoir plus d'opportunités pour personnaliser les effets de l'apparence et du comportement ultérieur de ces messages, ce qui est problématique lors de l'utilisation d'éléments standard du système Unity UI.

De plus, cette approche implique l'utilisation d'une seule instance de système de particules pour chaque type de message, ce qui donne une énorme augmentation de la productivité par rapport à la sortie des mêmes messages à l'aide de Unity UI.

Rapport de dommages

image

Message texte de pénurie

image

Algorithme de décision


En utilisant le shader, nous affichons la texture pré-préparée en utilisant les coordonnées UV correctes. Les informations avec les coordonnées UV sont transmises par deux flux (flux de vertex) à ParticleSystem à l'aide de ParticleSystem.SetCustomParticleData sous la forme d'une liste Vector4.

Notre implémentation implique l'utilisation de textures contenant 10 lignes et 10 colonnes de caractères. Toute police monospace peut être utilisée comme police. Ceci afin d'éviter un espacement différent entre les caractères du message.

Source de texture dans PSD

Mise en œuvre étape par étape


Création de Vector4 pour le transfert vers Vertex Stream

Pour décrire le jeu de caractères, nous utiliserons la structure SymbolsTextureData.

Le tableau de caractères doit être rempli manuellement, dans l'ordre en y ajoutant tous les symboles de texture de police à partir du coin supérieur gauche.

[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;
    }
}

Par conséquent, nous obtenons la classe TextRendererParticleSystem. Lorsque vous appelez la méthode publique SpawnParticle, une particule du système de particules apparaîtra à la position souhaitée, avec la valeur, la couleur et la taille souhaitées.

[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)
    {
        //  
     }
}

Le système de particules dans Unity vous permet de transférer des données personnalisées sous la forme de 2 flux Vector4.



Nous avons intentionnellement ajouté un flux supplémentaire avec UV2 pour éviter un décalage dans les coordonnées des flux. Si cela n'est pas fait, les coordonnées X et Y du vecteur Custom1 en C # correspondront aux shaders Z et W TEXCOORD0. Et en conséquence, Custom1.z = TEXCOORD1.x, Custom1.w = TEXCOORD1.y. Ce qui causera beaucoup de désagréments à l'avenir.


Comme décrit précédemment, nous utiliserons deux Vector4 pour transmettre la longueur du message et les coordonnées UV des caractères. Puisque Vector4 contient 4 éléments de type float, par défaut, nous pouvons y inclure 4 * 4 = 16 octets de données. Parce que Étant donné que notre message ne contiendra que la longueur du message (numéro à deux chiffres) et les coordonnées des caractères (numéro à deux chiffres pour chaque caractère), la plage d'octets de type (0-255) est redondante pour nous. L'utilisation de décimales fera l'affaire.

La précision du flottant est de 6 à 9 caractères, ce qui signifie que nous pouvons utiliser en toute sécurité 6 bits de chaque coordonnée Vector4 et ne pas nous soucier de l'intégrité et de la précision des données. En fait, nous avons essayé de compresser 7, 8 et 9 caractères, mais la précision du flottement n'est pas suffisante.

Il s'avère que dans chaque flottant, en utilisant des décimales, nous emballerons jusqu'à 6 chiffres, contrairement à la version standard à quatre octets. Au total, un Vector4 contiendra 24 numéros à un chiffre.

Nous pouvons transférer 2 vecteurs dans le flux, nous utiliserons donc les deux pour transmettre des messages jusqu'à 23 caractères:

Custom1.xyzw - les 12 premiers caractères du message.
Custom2.xyzw - 11 autres caractères du message + longueur du message (2 derniers caractères).

Par exemple, le message «Bonjour» ressemblerait à ceci.


Les coordonnées des caractères correspondent au numéro de colonne et à la ligne de position des caractères dans la texture.


Dans le code, encapsuler une chaîne dans deux Vector4 ressemblera à ceci:

//   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);
}

Vecteur avec CustomData prêt. Il est temps de générer manuellement une nouvelle particule avec les paramètres souhaités.

Apparition de particules

La première chose que nous devons faire est de nous assurer que les flux CustomData sont activés dans les paramètres de rendu du système de particules:

//   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);
}

Pour créer une particule, nous utilisons la méthode Emit () de la 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);

Ajoutez les deux blocs à la méthode SpawnParticle () et la partie C # est prête: le message est compressé et transmis par le GPU sous la forme de deux Vector4 dans Vertex Stream. La chose la plus intéressante qui reste est d'accepter ces données et de les afficher correctement.

Code de shader

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
        }
    }
}

Éditeur Unity

Créez du matériel et affectez-le à notre shader. Sur la scène, créez un objet avec le composant ParticleSystem, affectez le matériau créé. Ensuite, nous ajustons le comportement des particules et désactivons le paramètre Play On Awake. À partir de n'importe quelle classe, nous appelons la méthode RendererParticleSystem.SpawnParticle () ou utilisons la méthode de négociation.

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

Le code source, les ressources et les exemples d'utilisation peuvent être trouvés ici .

Système de messagerie en action

image

C'est tout. Sortie de message à l'aide du système de particules prêt! Nous espérons que cette solution bénéficiera aux développeurs de jeux Unity.

UPD: Performances de la solution proposée
Dans les commentaires de plusieurs personnes, une question s'est posée sur les performances de cette méthode. Mesures spécialement réalisées par le profileur d'unité. Les conditions sont les mêmes: 1 000 objets en mouvement et changeant de couleur.

Résultat de l'utilisation de l'interface utilisateur standard (le seul canevas sur lequel il n'y a que 1000 objets texte d'interface utilisateur):

La durée totale de la trame est d'au moins 50 ms, dont 40 ms sont consacrés à la mise à jour du canevas. En même temps, les objets n'apparaissent même pas, mais se déplacent simplement.

Le résultat d'un spawn de 1000 particules utilisant notre solution:

Toute la magie opère au niveau du GPU, même au moment du frai de 1000 particules, la trame est calculée en moins de 5ms.

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


All Articles