Задача
При разработке нашей игры The Unliving, мы поставили перед собой задачу по отображению различных сообщений, таких, как нанесенный урон, нехватка здоровья или энергии, величина награды, количество восстановленных очков здоровья и т.д., с помощью Particle System. Это было решено сделать для того, чтобы получить больше возможностей для кастомизации эффектов появления и дальнейшего поведения таких сообщений, что проблематично при использовании стандартных элементов UI-системы Unity.
Кроме того, данный подход подразумевает использование всего лишь одного инстанса Particle System для каждого типа сообщений, что дает огромный прирост в производительности по сравнению с выводом этих же сообщений с помощью Unity UI.
Сообщение о величине урона
Текстовое сообщение о нехватке здоровья
Алгоритм решения
С помощью шейдера отображаем заранее подготовленную текстуру, используя правильные 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);
}
С исходным кодом, ресурсами и примером использования можно ознакомиться здесь.
Система вывода сообщений в действии
Вот и все. Вывод сообщений с помощью Particle System готов! Надеемся, это решение принесет пользу разработчикам игр на Unity.
UPD: Производительность предлагаемого решения
В комментариях у нескольких человек возник вопрос насчет производительности данного способа. Специально сделал замеры профайлером юнити. Условия одинаковые — 1000 движущихся, меняющих цвет объектов.
Результат использования стандартного UI (единственный канвас, на котором находятся только 1000 UI Text объектов):
Общее время построения кадра не менее 50мс, из них 40мс уходит на обновление канваса. При этом объекты даже не спаунятся, а просто движутся.
Результат спауна 1000 партиклов с помощью нашего решения:
Вся магия происходит на ГПУ, даже в момент спауна 1000 частиц кадр рассчитывается менее чем за 5мс.