Как стать автором
Обновить

Вывод внутриигровых сообщений с помощью Particle System

Время на прочтение8 мин
Количество просмотров8.7K
image

Задача


При разработке нашей игры The Unliving, мы поставили перед собой задачу по отображению различных сообщений, таких, как нанесенный урон, нехватка здоровья или энергии, величина награды, количество восстановленных очков здоровья и т.д., с помощью Particle System. Это было решено сделать для того, чтобы получить больше возможностей для кастомизации эффектов появления и дальнейшего поведения таких сообщений, что проблематично при использовании стандартных элементов UI-системы Unity.

Кроме того, данный подход подразумевает использование всего лишь одного инстанса Particle System для каждого типа сообщений, что дает огромный прирост в производительности по сравнению с выводом этих же сообщений с помощью Unity UI.

Сообщение о величине урона

image

Текстовое сообщение о нехватке здоровья

image

Алгоритм решения


С помощью шейдера отображаем заранее подготовленную текстуру, используя правильные UV-координаты. Информацию с UV-координатами передаем двумя потоками (vertex streams) в ParticleSystem с помощью ParticleSystem.SetCustomParticleData в виде списка Vector4.

Наша реализация предполагает использование текстуры содержащей 10 строк и 10 столбцов символов. В качестве шрифта может быть использован любой моноширинный шрифт. Это необходимо во избежание разных интервалов между символами сообщения.

Исходник текстуры в PSD

Пошаговая реализация


Создание Vector4 для передачи в Vertex Stream

Для описания набора символов будем использовать структуру SymbolsTextureData.

Массив chars необходим заполнить вручную, по порядку добавив в него все символы текстуры шрифта начиная с левого-верхнего угла.

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

В результате мы получим класс TextRendererParticleSystem. При вызове публичного метода SpawnParticle, будет происходить спаун одной частицы Particle System в нужную позицию, с нужным значением, цветом и размером.

[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)
    {
        //Тело метода 
     }
}

Particle System в Unity позволяет передать кастомные данные в виде 2х потоков Vector4.



Мы намеренно добавили лишний поток с UV2, чтобы избежать сдвига по координатам потоков. Если этого не сделать, то координатам X и Y Custom1-вектора в C# будут соответствовать Z и W TEXCOORD0 шейдера. И соответственно, Custom1.z = TEXCOORD1.x, Custom1.w = TEXCOORD1.y. Что доставит много неудобств в дальнейшем.


Как было описано ранее, для передачи длины сообщения и UV-координат символов мы будем использовать два Vector4. Так как Vector4 содержит 4 элемента типа float, то по умолчанию мы можем упаковать в него 4 * 4 = 16 байт данных. Т.к. наше сообщение будет содержать только длину сообщения (двузначное число) и координаты символов (двузначное число для каждого символа), то диапазон типа byte (0-255) для нас избыточен. В то время как использование десятичных разрядов подойдет отлично.

Точность float составляет 6-9 символов, значит мы смело можем использовать 6 разрядов каждой координаты Vector4 и не переживать за целостность и точность данных. На самом деле, мы пробовали паковать 7, 8 и 9 символов, но точности float не хватает.

Получается, что в каждый float, используя десятичные разряды, мы упакуем целых 6 цифр, в отличии от стандартного варианта с четырьмя байтами. Итого, один Vector4 будет содержать 24 однозначных числа.

Мы можем передать в потоке 2 вектора, поэтому будем использовать оба для передачи сообщения длиной до 23 символов:

Custom1.xyzw — первые 12 символов сообщения.
Custom2.xyzw — еще 11 символов сообщения + длина сообщения (последние 2 символа).

Например, сообщение «Hello» будет выглядеть следующим образом.


Координатам символов соответствуют номер столбца и строка положения символа в текстуре.


В коде упаковка строки в два Vector4 будет выглядеть следующим образом:

//Функция упаковки массива 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);
}

Вектора с CustomData готовы. Пришло время вручную заспаунить новую частицу с нужными параметрами.

Спаун частицы

Первое, что мы должны сделать, убедиться, что CustomData потоки активированы в настройках Renderer системы частиц:

//Кэшируем ссылку на 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);
}

Для создания частицы воспользуемся методом Emit() класса 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);

Добавим оба блока в метод SpawnParticle() и C# часть готова: сообщение упаковано и передано GPU в виде двух Vector4 в Vertex Stream. Осталось самое интересное — принять эти данные и правильно отобразить.

Код шейдера

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

Создаем материал и назначаем ему наш шейдер. На сцене создаем объект с компонентом ParticleSystem, назначаем созданный материал. Затем настраиваем поведение частиц и отключаем параметр Play On Awake. Из любого класса вызываем метод RendererParticleSystem.SpawnParticle() или используем дебажный метод.

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

С исходным кодом, ресурсами и примером использования можно ознакомиться здесь.

Система вывода сообщений в действии

image

Вот и все. Вывод сообщений с помощью Particle System готов! Надеемся, это решение принесет пользу разработчикам игр на Unity.

UPD: Производительность предлагаемого решения
В комментариях у нескольких человек возник вопрос насчет производительности данного способа. Специально сделал замеры профайлером юнити. Условия одинаковые — 1000 движущихся, меняющих цвет объектов.

Результат использования стандартного UI (единственный канвас, на котором находятся только 1000 UI Text объектов):

Общее время построения кадра не менее 50мс, из них 40мс уходит на обновление канваса. При этом объекты даже не спаунятся, а просто движутся.

Результат спауна 1000 партиклов с помощью нашего решения:

Вся магия происходит на ГПУ, даже в момент спауна 1000 частиц кадр рассчитывается менее чем за 5мс.
Теги:
Хабы:
Всего голосов 21: ↑21 и ↓0+21
Комментарии17

Публикации

Истории

Работа

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань