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

Реализовываем показ урона, нехватки здоровья и другой важной для игрока информации на примере нашей игры The Unliving

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

Задача

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

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

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

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

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

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

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

Создание 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.

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

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

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

Как было описано ранее, для передачи длины сообщения и 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» будет выглядеть следующим образом.
​Например, сообщение «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.

Следить за разработкой нашей игры можно в ВК и в Стиме.

140140
32 комментария

Знатно заморочились, приятно видеть такое на DTF. Но я пожалуй продолжу делать подобные вещи на UI, это проще и выглядит как-то гибче всё же.

14
Ответить

А если попробовать эти надписи локализовать еще, ух!

10
Ответить

Отличная статья уровня хабра в техническом плане)Спасибо

7
Ответить

Комментарий недоступен

6
Ответить

Она кстати сперва там появилась)

2
Ответить

Учитывая, что уровень нынешнего хабра это двадцатая за полгода статья на тему "посмотрите на список 10 новых возможностей JS (за 2017 год)". Весьма тонко.

2
Ответить

Комментарий недоступен

2
Ответить