Left

BLOG

Displaying in-game messages using Unity Particle System

April 17, 2020

The objective

Developing our game The Unliving, we decided to seek for the possibility to display in-game messages with numerical values such as damage inflicted, reward value, the number of health points restored and others, using the Particle System. We decided to do so in order to get more opportunities to customize the effects of the appearance and further behavior of such messages in future. Also, it was difficult to implement this solution using standard elements of Unity's UI-system.

Moreover, this kind of approach implies using only one instance of the Particle System for each type of the message, which provides a huge increase in productivity compared to the output of the same messages using Unity UI.

Solution Algorithm

Using the shader, we display the pre-prepared texture using the correct UV coordinates. Information containing UV-coordinates is transmitted by two vertex streams to ParticleSystem using ParticleSystem.SetCustomParticleData as a List<vector4>.</vector4>

Our implementation presumes using the texture containing 10 rows and 10 columns of symbols. You can use any monospace font. This is to avoid different spacing between message characters.

Original Texture in PSD: monospaced_font.psd

Step-by-step implementation

Creating Vector4 for transmition to Vertex Stream

To describe the character set, we will use the structure SymbolsTextureData.

The chars array must be filled manually, by adding all the font texture symbols to it in the order starting from the top left corner.

[Serializable]
public struct SymbolsTextureData
{
    //Link to the font atlas
    public Texture texture;
    //An array of character sets in order starting from the top left
    public char[] chars;

    //Dictionary with the coordinates of each character – a row and a column number
    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;
	    //Calculation of the coordinates of the symbol, 
	    //we transform the serial number of the symbol
	    //into the row and column number, knowing that the row length is 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;
    }
}

As a result, we get a class TextRendererParticleSystem. When calling the public SpawnParticle method, one particle of the Particle System will spawn to the desired position, with the desired value, colour and size.

[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)
        {
            //The body of the method
        }
    }

Particle System in Unity allows you to transfer custom data in the form of two streams List<vector4>:</vector4>

Unity particle system guide for game developers

We intentionally added an extra stream with UV2 to avoid a shift in the coordinates of the streams. If this is not done, then the X and Y coordinates Custom1-vector in C# will correspond Z and W TEXCOORD0 shaders. And consecutively, Custom1.z = TEXCOORD1.x, Custom1.w = TEXCOORD1.y, which will cause a lot of inconveniences in the future.

Detailed technical diagram with annotations and various engineering components.

As described earlier, we will use two Vector4 to convey the message length and UV-coordinates of the symbols. Since Vector4 contains 4 "float" type elements, then by default we can pack 4 * 4 = 16 bytes of data into it. Because our message will contain only the length of the message (two-digit number) and the coordinates of the symbols (a two-digit number for each symbol), then the "byte" type of the range (0-255) is redundant for us. Whereas using decimal places will do just fine.

"Float" precision is 6-9 symbols, which means that we can safely use 6 digits of each coordinate Vector4 and don't worry for their integrity and the accuracy of the data. Actually, we tried to pack 7, 8 and 9 characters, but "float" accuracy was not enough.

It turns out that using decimal places we will pack as many as 6 digits in each "float", unlike the standard four-byte version. Thus one Vector4 will contain 24 single-digit numbers in total.

We can transfer 2 vectors in one stream, so we will use both to transmit messages up to 23 characters long:

Custom1.xyzw - first 12 symbols of the message.

Custom2.xyzw - another 11 symbols of the message + the length of the message (the last two symbols).

For example, "Hello" message will look like this:

Detailed schematic of a mechanical device with labels.

The coordinates of the symbols correspond to the column number and the row of the symbol's position in the texture.

3D rendering of an intricate digital sculpture with fine details.

In the code, packing a row into two Vector4 will look like this:

//Vector2 array packing function with symbols' coordinates in "float" 
public float PackFloat(Vector2[] vecs)
{
    if (vecs == null || vecs.Length == 0) return 0;            
    //Bitwise adding the coordinates of the vectors in 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;
}

//Create Vector4 function for the stream with 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;
}

//Supplementing the body of the particle spawn method
public void SpawnParticle(Vector3 position, string message, Color color, float? startSize = null)
{
	var texCords = new Vector2[24]; 
        //an array of 24 elements - 23 symbols + the length of the mesage
	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;
	    //Calling the method GetTextureCoordinates() from SymbolsTextureData to obtain the symbol's position
	    texCords[i] = textureData.GetTextureCoordinates(message[i]);
	}
		
	var custom1Data = CreateCustomData(texCords);
	var custom2Data = CreateCustomData(texCords, 12);
}

The vectors with CustomData are ready. It's time to spawn the new particle manually with needed parameters.

Spawn of the particle

The first thing we need to do is to make sure that CustomData streams are activated in the particle system's Renderer settings:

To create a particle, we use the Emit() method of the ParticleSystem class.

//Initializing emission parameters
//The color and position are obtained from the method parameters
//Set startSize3D to X so that the characters are not stretched or compressed
//when changing the length of the message
var emitParams = new ParticleSystem.EmitParams
{
    startColor = color,
    position = position,
    applyShapeToPosition = true,
    startSize3D = new Vector3(messageLenght, 1, 1)
};
//If we want to create particles of different sizes, then in the parameters of SpawnParticle it is necessary
//to transfer the desired startSize value
if (startSize.HasValue) emitParams.startSize3D *= startSize.Value * particleSystem.main.startSizeMultiplier;
//Directly the spawn of the particles
particleSystem.Emit(emitParams, 1);

//Transferring the custom data to the needed streams
var customData = new List<Vector4>();
//Getting the stream ParticleSystemCustomData.Custom1 from ParticleSystem
particleSystem.GetCustomParticleData(customData, ParticleSystemCustomData.Custom1);
//Changing the data of the last element, i.e. the particle, that we have just created
customData[customData.Count - 1] = custom1Data;
//Returning the data to ParticleSystem
particleSystem.SetCustomParticleData(customData, ParticleSystemCustomData.Custom1);

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

Adding both blocks to the method SpawnParticle(), and C# part is ready: the message is packed and sent to the GPU in the form of two Vector4 in Vertex Stream. The rest is the most interesting thing: to accept this data and display it correctly.

Shader Code

Shader "Custom/TextParticles"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
	//The number of rows and columns in theory can be less than 10, but definitely not more
        _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;
		//Those very vectors with 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;
		//Why is the message length transmitted in the last bits of the w-coordinate of the vector?
		//This is the easiest way to get this length inside the shader.
		//It is enough to get the remainder of dividing by 100.
                float textLength = ceil(fmod(v.customData2.w, 100));

                o.vertex = UnityObjectToClipPos(v.vertex);
		//Getting the size of the UV-texture based on the number of rows and columns
                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;
		//Symbol's index in the message
                uint ind = floor(uv.x * _Cols);

                uint x = 0;
                uint y = 0;

		//Vector coordinate index containing this element
		//0-3 - customData1
		//4-7 - customData2
                uint dataInd = ind / 3;
		//We get the value of all 6 bits packed in the desired "float"
                uint sum = dataInd < 4 ? v.customData1[dataInd] : v.customData2[dataInd - 4];

		//Directly unpacking of the "float" and getting the row and the column character
                for(int i = 0; i < 3; ++i)
                {
                    if (dataInd > 3 & i == 3) break;
                    //rounding to a larger value, otherwise we will get 10^2 = 99 etc.
                    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;
		//Shifting the UV-coordinates using the number of rows, columns, index and
		//row and column number of the element
                uv.x += x * cols - ind * rows;
                uv.y += y * rows;
                
                return tex2D(_MainTex, uv.xy) * v.color;
            }
            ENDCG
        }
    }
}

Unity Editor

We create the material and assign our shader to it. On the scene, we create an object with the ParticleSystem component and assign the created material. Then we adjust the particle behaviour and turn off the Play On Awake parameter. Then calling the method RendererParticleSystem.SpawnParticle() from any class or using the debugging method:

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

Source code, resources, and usage examples are available here.

That's it! Displaying messages using Particle System is done. We hope this solution will benefit Unity game developers. Don't forget to check out our game The Unliving where we implement this system in action!

See also:

Create with us
Contact us, and we'll craft the perfect game art for your project
Get In Touch
See also:
Up