Привет! Эта статья о создании собственных визуальных компонентов в UI на примере компонента для визуализации системы частиц в Canvas’e.
Данная информация будет полезна для реализации различных эффектов в пользовательском интерфейсе, а также может использоваться для генерации меша или его оптимизации.
Основой для UI в Unity является Canvas. Именно он используется системой рендера для отображения “многослойной” геометрии, в соответствии с внутренней иерархией UI-элементов.
Любой визуальный компонент пользовательского интерфейса должен наследоваться от класса Graphic (или его производного класса MaskableGraphic), который передаёт компоненту CanvasRenderer все необходимые данные для его отрисовки. Создание данных происходит в методе OnPopulateMesh, который вызывается каждый раз, когда компоненту необходимо обновить свою геометрию (например при изменении размеров элемента). В качестве параметра передаётся VertexHelper, который помогает в генерации меша для UI.
Начнём реализацию с создания скрипта UIParticleSystem, наследуемого от класса MaskableGraphic. MaskableGraphic является производным класса Graphic и в дополнение обеспечивает работу с масками. Переопределим метод OnPopulateMesh. Основа работы с VertexHelper для генерации вершин меша системы частиц будет выглядеть следующим образом:
Сначала необходимо очистить VertextHelper от имеющихся данных вызовом метода Clear. После этого можно приступать к его заполнению новыми данными о вершинах. Для этого будет использован метод AddUIVertexQuad, который позволяет добавлять информацию сразу о 4-х вершинах. Этот метод выбран из-за удобства использования, т.к. каждая частица представляет собой прямоугольник. Каждая вершина описывается объектом UIVertex. Из всех параметров нам потребуется заполнить только позицию, цвет и одни координаты uv развертки.
Так как каждый кадр положение частиц, их цвет и другие параметры будут меняться, меш для их отрисовки должен также обновляться.
Для этого каждый кадр будем вызывать метод SetVerticesDirty, который установит флаг о необходимости пересчитать новые данные, что приведёт к вызову метода OnPopulateMesh. Аналогично и для материала, если его свойства будут меняться, то нужно вызывать метод SetMaterialDirty.
Переопределим свойство mainTexture. Оно указывает какая текстура будет передаваться CanvasRenderer и использоваться в материале, свойстве шейдера _MainTex. Для этого создадим поле ParticleImage, которое будет возвращаться свойством mainTexture.
Данные для генерации вершин меша будем брать от компонента ParticleSystem, занимающегося всеми расчетами по расположению частиц, их размеру, цвету и т.д.
Отрисовкой частиц занимается компонент ParticleSystemRenderer, который будет необходимо отключить, так за созданием меша и его отрисовку в UI будут отвечать уже другие компоненты — UIParticleSystem и CanvasRenderer.
Создадим необходимые для работы поля и проинициализируем их в методе Awake.
Поле _particles будет использоваться для хранения частиц ParticleSystem, а
_main используется для удобства работы с модулем MainModule.
Допишем метод OnPopulateMesh, взяв все необходимые данные непосредственно из системы частиц. Создадим вспомогательные переменные Vector3[] _quadCorners и Vector2[] _simpleUV.
_quadCorners содержит координаты 4-х углов прямоугольника, относительно центра частицы. Изначальный размер каждой частицы считаем как квадрат со сторонами 1х1.
_simpleUV — координаты uv развертки, в данном случае все частицы используют одинаковую текстуру без каких либо смещений.
Теперь создадим для теста простой UI, с использованием стандартных компонентов.
К компоненту ParticleSystem добавим UIParticleSystem
Запустим сцену и проверим результат работы компонента.
Частицы отображаются в соответствии с их положением в иерархии и учитывают используемые маски. При изменении разрешения экрана и его пропорций, а так же при изменение свойства Rendere Mode у Canvas, частицы ведут себя аналогично любому другому визуальному компоненту в Canvas и отображаются только в нём.
Т.к. мы размещаем систему частиц внутри UI, возникает проблема с параметром SimulationSpace. При симуляции в мировом пространстве частицы отображаются не там, где должны. Поэтому добавим расчет положения частицы в зависимости от значения параметра.
Теперь реализуем часть функционала ParticleSystemRenderer. А именно свойства RenderMode, SortMode, Pivot.
Ограничимся тем, что частицы всегда будут находится только в плоскости холста. Поэтому реализуем только два значения: Billboard и StretchedBillboard.
Создадим для этого своё перечисление CanvasParticleSystemRenderMode.
При выборе параметра StretchedBillboard, размер частицы будет зависеть от параметров LengthScale и SpeedScale, а её поворот будет направлен только в сторону движения.
Аналогично создадим перечисление CanvasParticlesSortMode. и реализуем только сортировку по времени жизни частиц.
Для сортировки нам понадобится хранить данные о времени жизни частицы, которые будут хранится в переменной _particleElapsedLifetime. Саму сортировку реализуем с помощью метода Array.Sort.
Создадим поле Pivot, для смещения центральной точки частицы.
И при расчете позиции вершины добавляем это значение.
Если элемент, к которому прикреплена система частиц, не имеет фиксированных размеров или они могут меняться во время выполнения, то было бы неплохо адаптировать и размеры системы частиц. Сделаем так, чтобы источник — shape был пропорционален размерам элемента.
Метод OnRectTransformDimensionsChange вызывается при изменении размеров компонента RectTransform. Переопределим этот метод, реализовав изменение масштаба shape в соответствии с размерами RectTransform.
Предварительно создадим переменные для компонента RectTransform и модуля ShapeModule. Для возможности отключения масштабирования shape создадим переменную ScaleShapeByRectTransform.
Также масштабирование стоит выполнять и при активации компонента, чтобы задать ему начальный масштаб.
При расчета стоит учесть поворот Shape’а. Значения конечного результата нужно взять по модулю, так как они могут получиться отрицательным, что скажется на направлении движения частиц.
Для проверки работы запустим анимацию изменения размеров RectTransform, с прикрепленной к нему системой частиц.
Чтобы скрипт корректно выполнялся в редакторе и избежать ошибок при вызове метода OnRectTransformDimensionsChange, вынесем в отдельный метод инициализацию переменных. И добавим его вызов в методы OnPopulateMesh и OnRectTransformDimensionsChange.
Метод OnRectTransformDimensionsChange может вызваться раньше Awake. Поэтому при каждом его вызове необходимо провести инициализацию переменных.
Такой рендер частиц немного затратней, нежели использование ParticleSystemRenderer, что требует более расчетливого использования, в частности на мобильных устройствах.
Также стоит отметить, что если хотя бы один из элементов Canvas помечен как Dirty, то это приведёт к перерасчету всей геометрии Canvas и генерации новых команд отрисовки. Если UI содержит много сложной геометрии и её расчетов, то стоит разбить его на несколько вложенных холстов.
P.S.: Весь исходный код и демо-сцены ссылка на git.
Заготовка для статьи была начата почти год назад, после того как потребовалось использовать ParticleSystem в UI. На тот момент аналогичного решения мной не было найдено, а имеющиеся были не оптимальными для текущей задачи. Но за пару дней до публикации этой статьи, собирая материал, случайно нашёл аналогичное решение с использованием метода Graphic.OnPopulateMesh. Поэтому считаю необходимым указать ссылку на репозиторий.
Данная информация будет полезна для реализации различных эффектов в пользовательском интерфейсе, а также может использоваться для генерации меша или его оптимизации.
Немного теории или с чего начать создание компонента
Основой для UI в Unity является Canvas. Именно он используется системой рендера для отображения “многослойной” геометрии, в соответствии с внутренней иерархией UI-элементов.
Любой визуальный компонент пользовательского интерфейса должен наследоваться от класса Graphic (или его производного класса MaskableGraphic), который передаёт компоненту CanvasRenderer все необходимые данные для его отрисовки. Создание данных происходит в методе OnPopulateMesh, который вызывается каждый раз, когда компоненту необходимо обновить свою геометрию (например при изменении размеров элемента). В качестве параметра передаётся VertexHelper, который помогает в генерации меша для UI.
Создание компонента
Основа
Начнём реализацию с создания скрипта UIParticleSystem, наследуемого от класса MaskableGraphic. MaskableGraphic является производным класса Graphic и в дополнение обеспечивает работу с масками. Переопределим метод OnPopulateMesh. Основа работы с VertexHelper для генерации вершин меша системы частиц будет выглядеть следующим образом:
public class UIParticleSystem : MaskableGraphic
{
protected override void OnPopulateMesh(VertexHelper vh)
{
vh.Clear();
...
int particlesCount = ... ;
for (int i = 0; i < particlesCount; i++)
{
Color vertexColor = ... ;
Vector2[] vertexUV = ... ;
UIVertex[] quadVerts = new UIVertex[4];
for (int j = 0; j < 4; j++)
{
Vector3 vertixPosition = ... ;
quadVerts[j] = new UIVertex()
{
position = vertixPosition,
color = vertexColor,
uv0 = vertexUV
};
}
vh.AddUIVertexQuad(quadVerts);
}
}
}
Сначала необходимо очистить VertextHelper от имеющихся данных вызовом метода Clear. После этого можно приступать к его заполнению новыми данными о вершинах. Для этого будет использован метод AddUIVertexQuad, который позволяет добавлять информацию сразу о 4-х вершинах. Этот метод выбран из-за удобства использования, т.к. каждая частица представляет собой прямоугольник. Каждая вершина описывается объектом UIVertex. Из всех параметров нам потребуется заполнить только позицию, цвет и одни координаты uv развертки.
VertexHelper
VertexHelper располагает целым набором методов для добавления информации о вершинах, а так же парой для получения текущих данных. Для более сложной геометрии лучшем решением будет выбор метода AddUIVertexStream, принимающий список вершин и список индексов.
Так как каждый кадр положение частиц, их цвет и другие параметры будут меняться, меш для их отрисовки должен также обновляться.
Для этого каждый кадр будем вызывать метод SetVerticesDirty, который установит флаг о необходимости пересчитать новые данные, что приведёт к вызову метода OnPopulateMesh. Аналогично и для материала, если его свойства будут меняться, то нужно вызывать метод SetMaterialDirty.
protected void Update()
{
SetVerticesDirty();
}
Переопределим свойство mainTexture. Оно указывает какая текстура будет передаваться CanvasRenderer и использоваться в материале, свойстве шейдера _MainTex. Для этого создадим поле ParticleImage, которое будет возвращаться свойством mainTexture.
public Texture ParticleImage;
public override Texture mainTexture
{
get { return ParticleImage; }
}
Система частиц
Данные для генерации вершин меша будем брать от компонента ParticleSystem, занимающегося всеми расчетами по расположению частиц, их размеру, цвету и т.д.
Отрисовкой частиц занимается компонент ParticleSystemRenderer, который будет необходимо отключить, так за созданием меша и его отрисовку в UI будут отвечать уже другие компоненты — UIParticleSystem и CanvasRenderer.
Создадим необходимые для работы поля и проинициализируем их в методе Awake.
UIBehaviour
Awake, как и большинство методов, здесь необходимо переопределять, так как они указаны как виртуальные в UIBehaviour. Сам класс UIBehaviour абстрактный и практически не содержит никакой рабочей логики, но является базовым для класса Graphic.
private ParticleSystem _particleSystem;
private ParticleSystemRenderer _particleSystemRenderer;
private ParticleSystem.MainModule _main;
private ParticleSystem.Particle[] _particles;
protected override void Awake()
{
base.Awake();
_particleSystem = GetComponent<ParticleSystem>();
_main = _particleSystem.main;
_particleSystemRenderer = GetComponent<ParticleSystemRenderer>();
_particleSystemRenderer.enabled = false;
int maxCount = _main.maxParticles;
_particles = new ParticleSystem.Particle[maxCount];
}
Поле _particles будет использоваться для хранения частиц ParticleSystem, а
_main используется для удобства работы с модулем MainModule.
Допишем метод OnPopulateMesh, взяв все необходимые данные непосредственно из системы частиц. Создадим вспомогательные переменные Vector3[] _quadCorners и Vector2[] _simpleUV.
_quadCorners содержит координаты 4-х углов прямоугольника, относительно центра частицы. Изначальный размер каждой частицы считаем как квадрат со сторонами 1х1.
_simpleUV — координаты uv развертки, в данном случае все частицы используют одинаковую текстуру без каких либо смещений.
private Vector3[] _quadCorners = new Vector3[]
{
new Vector3(-.5f, -.5f, 0),
new Vector3(-.5f, .5f, 0),
new Vector3(.5f, .5f, 0),
new Vector3(.5f, -.5f, 0)
};
private Vector2[] _simpleUV = new Vector2[]
{
new Vector2(0,0),
new Vector2(0,1),
new Vector2(1,1),
new Vector2(1,0),
};
protected override void OnPopulateMesh(VertexHelper vh)
{
vh.Clear();
int particlesCount = _particleSystem.GetParticles(_particles);
for (int i = 0; i < particlesCount; i++)
{
var particle = _particles[i];
Vector3 particlePosition = particle.position;
Color vertexColor = particle.GetCurrentColor(_particleSystem) * color;
Vector3 particleSize = particle.GetCurrentSize3D(_particleSystem);
Vector2[] vertexUV = _simpleUV;
Quaternion rotation = Quaternion.AngleAxis(particle.rotation, Vector3.forward);
UIVertex[]quadVerts = new UIVertex[4];
for (int j = 0; j < 4; j++)
{
Vector3 cornerPosition = rotation * Vector3.Scale(particleSize, _quadCorners[j]);
Vector3 vertexPosition = cornerPosition + particlePosition;
vertexPosition.z = 0;
quadVerts[j] = new UIVertex();
quadVerts[j].color = vertexColor;
quadVerts[j].uv0 = vertexUV[j];
quadVerts[j].position = vertexPosition;
}
vh.AddUIVertexQuad(quadVerts);
}
}
vertexPosition
Сначала рассчитывается локальная позиция вершины относительно центра частицы, учитывая её размер (операция Vector3.Scale(particleSize, _quadCorners[j])) и вращение (умножение кватерниона rotation на вектор). После к результату добавляется позиция самой частицы
Теперь создадим для теста простой UI, с использованием стандартных компонентов.
К компоненту ParticleSystem добавим UIParticleSystem
Запустим сцену и проверим результат работы компонента.
Частицы отображаются в соответствии с их положением в иерархии и учитывают используемые маски. При изменении разрешения экрана и его пропорций, а так же при изменение свойства Rendere Mode у Canvas, частицы ведут себя аналогично любому другому визуальному компоненту в Canvas и отображаются только в нём.
SimulationSpace
Т.к. мы размещаем систему частиц внутри UI, возникает проблема с параметром SimulationSpace. При симуляции в мировом пространстве частицы отображаются не там, где должны. Поэтому добавим расчет положения частицы в зависимости от значения параметра.
protected override void OnPopulateMesh(VertexHelper vh)
{
...
Vector3 particlePosition;
switch (_main.simulationSpace)
{
case ParticleSystemSimulationSpace.World:
particlePosition = _rectTransform.InverseTransformPoint(particle.position);
break;
case ParticleSystemSimulationSpace.Local:
particlePosition = particle.position;
break;
case ParticleSystemSimulationSpace.Custom:
if (_main.customSimulationSpace != null)
particlePosition = _rectTransform.InverseTransformPoint( _main.customSimulationSpace.TransformPoint(particle.position) );
else
particlePosition = particle.position;
break;
default:
particlePosition = particle.position;
break;
}
...
}
Имитируем свойства ParticleSystemRenderer
Теперь реализуем часть функционала ParticleSystemRenderer. А именно свойства RenderMode, SortMode, Pivot.
RenderMode
Ограничимся тем, что частицы всегда будут находится только в плоскости холста. Поэтому реализуем только два значения: Billboard и StretchedBillboard.
Создадим для этого своё перечисление CanvasParticleSystemRenderMode.
public enum CanvasParticleSystemRenderMode
{
Billboard = 0,
StretchedBillboard = 1
}
public CanvasParticleSystemRenderMode RenderMode;
public float SpeedScale = 0f;
public float LengthScale = 1f;
protected override void OnPopulateMesh(VertexHelper vh)
{
...
Quaternion rotation;
switch (RenderMode)
{
case CanvasParticleSystemRenderMode.Billboard:
rotation = Quaternion.AngleAxis(particle.rotation, Vector3.forward);
break;
case CanvasParticleSystemRenderMode.StretchedBillboard:
rotation = Quaternion.LookRotation(Vector3.forward, particle.totalVelocity);
float speed = particle.totalVelocity.magnitude;
particleSize = Vector3.Scale(particleSize, new Vector3(LengthScale + speed * SpeedScale, 1f, 1f));
rotation *= Quaternion.AngleAxis(90, Vector3.forward);
break;
default:
rotation = Quaternion.AngleAxis(particle.rotation, Vector3.forward);
break;
}
...
}
При выборе параметра StretchedBillboard, размер частицы будет зависеть от параметров LengthScale и SpeedScale, а её поворот будет направлен только в сторону движения.
SortMode
Аналогично создадим перечисление CanvasParticlesSortMode. и реализуем только сортировку по времени жизни частиц.
public enum CanvasParticlesSortMode
{
None = 0,
OldestInFront = 1,
YoungestInFront = 2
}
public CanvasParticlesSortMode SortMode;
Для сортировки нам понадобится хранить данные о времени жизни частицы, которые будут хранится в переменной _particleElapsedLifetime. Саму сортировку реализуем с помощью метода Array.Sort.
private float[] _particleElapsedLifetime;
protected override void Awake()
{
...
_particles = new ParticleSystem.Particle[maxCount];
_particleElapsedLifetime = new float[maxCount];
}
protected override void OnPopulateMesh(VertexHelper vh)
{
vh.Clear();
int particlesCount = _particleSystem.GetParticles(_particles);
for (int i = 0; i < particlesCount; i++)
_particleElapsedLifetime[i] = _particles[i].startLifetime - _particles[i].remainingLifetime;
switch (SortMode)
{
case CanvasParticlesSortMode.None: break;
case CanvasParticlesSortMode.OldestInFront:
Array.Sort(_particleElapsedLifetime, _particles, 0, particlesCount,Comparer<float>.Default);
Array.Reverse(_particles, 0, particlesCount);
break;
case CanvasParticlesSortMode.YoungestInFront:
Array.Sort(_particleElapsedLifetime, _particles, 0, particlesCount, Comparer<float>.Default);
break;
}
...
}
Pivot
Создадим поле Pivot, для смещения центральной точки частицы.
public Vector3 Pivot = Vector3.zero;
И при расчете позиции вершины добавляем это значение.
Vector3 cornerPosition = Vector3.Scale(particleSize, _quadCorners[j] + Pivot);
Vector3 vertexPosition = rotation * cornerPosition + particlePosition;
vertexPosition.z = 0;
Регулируем размер
Если элемент, к которому прикреплена система частиц, не имеет фиксированных размеров или они могут меняться во время выполнения, то было бы неплохо адаптировать и размеры системы частиц. Сделаем так, чтобы источник — shape был пропорционален размерам элемента.
Метод OnRectTransformDimensionsChange вызывается при изменении размеров компонента RectTransform. Переопределим этот метод, реализовав изменение масштаба shape в соответствии с размерами RectTransform.
Предварительно создадим переменные для компонента RectTransform и модуля ShapeModule. Для возможности отключения масштабирования shape создадим переменную ScaleShapeByRectTransform.
Также масштабирование стоит выполнять и при активации компонента, чтобы задать ему начальный масштаб.
private RectTransform _rectTransform;
private ParticleSystem.ShapeModule _shape;
public bool ScaleShapeByRectTransform;
protected override void Awake()
{
...
_rectTransform = GetComponent<RectTransform>();
_shape = _particleSystem.shape;
...
}
protected override void OnEnable()
{
base.OnEnable();
ScaleShape();
}
protected override void OnRectTransformDimensionsChange()
{
base.OnRectTransformDimensionsChange();
ScaleShape();
}
protected void ScaleShape()
{
if (!ScaleShapeByRectTransform)
return;
Rect rect = _rectTransform.rect;
var scale = Quaternion.Euler(_shape.rotation) * new Vector3(rect.width, rect.height, 0);
scale = new Vector3(Mathf.Abs(scale.x), Mathf.Abs(scale.y), Mathf.Abs(scale.z));
_shape.scale = scale;
}
При расчета стоит учесть поворот Shape’а. Значения конечного результата нужно взять по модулю, так как они могут получиться отрицательным, что скажется на направлении движения частиц.
Для проверки работы запустим анимацию изменения размеров RectTransform, с прикрепленной к нему системой частиц.
Инициализация
Чтобы скрипт корректно выполнялся в редакторе и избежать ошибок при вызове метода OnRectTransformDimensionsChange, вынесем в отдельный метод инициализацию переменных. И добавим его вызов в методы OnPopulateMesh и OnRectTransformDimensionsChange.
ExecuteInEditMode
Указывать атрибут ExecuteInEditMode не нужно, т.к. Graphic уже реализует это поведение и скрипт выполняется в редакторе.
private bool _initialized;
protected void Initialize()
{
if (_initialized)
return;
_initialized = true;
_rectTransform = GetComponent<RectTransform>();
_particleSystem = GetComponent<ParticleSystem>();
_main = _particleSystem.main;
_textureSheetAnimation = _particleSystem.textureSheetAnimation;
_shape = _particleSystem.shape;
_particleSystemRenderer = GetComponent<ParticleSystemRenderer>();
_particleSystemRenderer.enabled = false;
_particleSystemRenderer.material = null;
var maxCount = _main.maxParticles;
_particles = new ParticleSystem.Particle[maxCount];
_particlesLifeProgress = new float[maxCount];
_particleRemainingLifetime = new float[maxCount];
}
protected override void Awake()
{
base.Awake();
Initialize();
}
protected override void OnPopulateMesh(VertexHelper vh)
{
Initialize();
...
}
protected override void OnRectTransformDimensionsChange()
{
#if UNITY_EDITOR
Initialize();
#endif
...
}
Метод OnRectTransformDimensionsChange может вызваться раньше Awake. Поэтому при каждом его вызове необходимо провести инициализацию переменных.
Производительность и оптимизация
Такой рендер частиц немного затратней, нежели использование ParticleSystemRenderer, что требует более расчетливого использования, в частности на мобильных устройствах.
Также стоит отметить, что если хотя бы один из элементов Canvas помечен как Dirty, то это приведёт к перерасчету всей геометрии Canvas и генерации новых команд отрисовки. Если UI содержит много сложной геометрии и её расчетов, то стоит разбить его на несколько вложенных холстов.
P.S.: Весь исходный код и демо-сцены ссылка на git.
Заготовка для статьи была начата почти год назад, после того как потребовалось использовать ParticleSystem в UI. На тот момент аналогичного решения мной не было найдено, а имеющиеся были не оптимальными для текущей задачи. Но за пару дней до публикации этой статьи, собирая материал, случайно нашёл аналогичное решение с использованием метода Graphic.OnPopulateMesh. Поэтому считаю необходимым указать ссылку на репозиторий.