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

  • Tutorial
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мс.
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 17

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


    Не совсем понял, где узкое место в реализации через UI? Один канвас + пул объектов с компонентом Text — должно все быть в порядке?
      0
      Канвас будет весь пересчитываться, перестраиваться и перерисовываться в каждом кадре, а это — обновление довольно высокополигональной геометрии, что означает затраты ЦПУ + мусор для GC
        0

        Ну можно же вынести в отдельный канвас именно динамику для всплывающих надписей.
        Высокополигональная геометрия — это тоже странное утверждение, ректы же, как и в партиклах.
        Мусор есть и тут, при сборке самих надписей, если их надо собирать динамически.

          0

          ну это же еще дополнительные screen->ui space преобразования, которых можно избежать.

            0
            Какбудто текущий шейдер делает меньше вычислений, а если понадобиться локализация, что прийдёться делать?
              0
              для вычислений шейдера у вас пару сотен ядер, а для цпу сколько?
              Локализация да, особенно китайская. Но не всем она нужна, да и не всегда надо цифры локализовывать.
                0
                Интересно было б сравнить с релиализацией в VFX Graph
          +2
          Я знаю, что он будет пересчитываться. Поэтому надо выносить в тот канвас, что будет перерисовываться в каждом кадре. Но в этом я проблемы не вижу.

          А мусор для GC откуда появляется, если используем пул?

          Но меня больше интересует — проводились ли тесты? Хотя бы профайлером?
            +1
            А мусор для GC откуда появляется, если используем пул?

            просто навскидку — уничтожение старой и создание новой геометрии. А еще посмотрите исходники Unity UI — сколько раз там при проходе по всяким лейаут группам используется GetComponent
              +2
              Сделал замер производительности профайлером Юнити. Добавил в конец статьи.
              Хотелось бы добавить, что это решение в первую очередь не про производительность, а про кастомизацию. Реализовать VFX для обычных UI-элементов юнити такое себе удовольствие, а партиклы настроить легко и просто.
              0
              А если текст выводить не в UI, а с помощью Text Mesh в world GameObjectах?
            0
            На самом деле, мы пробовали паковать 7, 8 и 9 символов, но точности float не хватает.
            Получается, что в каждый float, используя десятичные разряды, мы упакуем целых 6 цифр, в отличии от стандартного варианта с четырьмя байтами.

            А чем не устроил не менее стандартный вариант с восемью шестнадцатеричными цифрами? Скоростью извлечения отдельных цифр? Хотя не думаю, что она будет медленнее парсинга float-а...

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

              А производились ли какие-нибудь замеры производительности? Хотелось бы узнать, на сколько Ваш метод лучше «стандартного»
                +1
                Сделал замер производительности профайлером Юнити. Добавил в конец статьи.
                  0
                  А на каком железе производились замеры? Участвовали ли в тестах несколько конфигураций железа? И интересна еще нагрузка на GPU
                0
                Даже не знаю как относиться к этому материалу. С одной стороны, кастомный шейдер + партиклы и ограничение на только моноширинные шрифты, для решения задачи, которую можно было-бы очень легко решить средствами freetype, это какой-то треш. С другой стороны — это вся суть Unity3D: тупая лень разработчиков движка, приводящая к отсутствию интеропа к зависимостям движка и как результат: НЕОБХОДИМОСТЬ лепить такую дичь.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое