Trending
Opinion: How will Project 2025 impact game developers?
The Heritage Foundation's manifesto for the possible next administration could do great harm to many, including large portions of the game development community.
Featured Blog | This community-written post highlights the best of what the game industry has to offer. Read more like it on the Game Developer Blogs or learn how to Submit Your Own Blog Post
A Unity gamedev guide by RocketBrush Studio programmer Timur Gadeev.
A Unity gamedev guide by RocketBrush Studio's programmer Timur Gadeev.
Developing our game The Unliving we decided to find 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.
Damage report display
Low health message
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>.
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
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>:
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.
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:
The coordinates of the symbols correspond to the column number and the row of the symbol's position in the texture.
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.
The first thing we need to do is to make sure that CustomData streams are activated in the particle system's Renderer settings:
//Caching link to ParticleSystem if (particleSystem == null) particleSystem = GetComponent<ParticleSystem>(); if (particleSystemRenderer == null) { //If the link is to ParticleSystemRenderer, cash it and make sure we have right streams; particleSystemRenderer = particleSystem.GetComponent<ParticleSystemRenderer>(); var streams = new List<ParticleSystemVertexStream>(); particleSystemRenderer.GetActiveVertexStreams(streams); //Adding additional stream to Vector2(UV2, SizeXY, etc.), so that the coordinates in the script match the coordinates in the shader 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); }
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 "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; //Symbols' 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 } } }
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 behavior 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.
Messages System in Action
That's it! Displaying messages using Unity Particle System is done. We hope this solution will benefit Unity game developers. Don't forget to check out our game where we implement this system in action!
Written by Timur Gadeev
Translated and edited by Artem Safarov for RocketBrush Studio blog
Read more about:
Featured BlogsYou May Also Like