В какой-то момент во время блужданий по просторам всемирной паутины на одном из сайтов я обнаружил интерактивный JS-элемент — картинку, составленную из частиц, разлетающихся при приближении курсора мыши. Возникло желание запрограммировать симуляцию подобного поведения на Unity и проверить, какую производительность можно выжать из движка. Техническими решениями и своими наблюдениями, полученными в процессе реализации, делюсь с вами.

image

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

Анимация


Для того, чтобы его добиться, нужно решить следующие проблемы:

  • описать физику частиц;
  • обеспечить оптимальную скорость расчета позиций на каждом кадре;
  • выбрать способ быстрой отрисовки большого числа частиц на экране.

Физика


Для описания желаемого поведения частиц нам потребуется сформулировать всего три принципа: стремление сократить расстояние до исходной точки, стремление «улететь» от курсора мыши и затухание движения. Нам не потребуются точные физические взаимодействия и формулы, необходимы лишь общие принципы, двигаясь в соответствии с которыми частица будет вести себя задуманным образом. Для простоты, из всех формул исключена масса — условимся считать её единичной.

Сокращение расстояния до начального положения


Для того, чтобы частица стремилась вернуться в исходную позицию, нам подойдет закон Гука: сила, направленная в сторону исходной позиции, будет линейно пропорциональна расстоянию до нее. В два раза дальше улетела частица от начальной позиции — в два раза сильнее её «тянет» назад, всё просто.

Отдаление от курсора


Чтобы было интересно, частицы должны каким-либо образом взаимодействовать с курсором, позволять себя «отодвигать» из исходного положения. За основу такого взаимодействия я взял гравитационное с обратным знаком: частицы будет отталкивать сила, обратно пропорциональная квадрату расстояния между положением курсора мыши и текущей позицией частицы и направленная по вектору от курсора к частице. $F = -C/r^2$, где $C$ — некая константа, регулирующая взаимодействие.

Затухание


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

Результат


В итоге получаем формулу силы, действующей на частицу в произвольный момент времени:

$\vec{F}_s=C_a*(\vec{x}-\vec{x}_0)-\frac{C_r}{||\vec{x}-\vec{x}_r||^2}*\frac{\vec{x}-\vec{x}_r}{||\vec{x}-\vec{x}_r||}-C_d*\vec{v},$


где $x, v$ — текущая позиция и скорость частицы, $x_0$ — начальное положение, $x_r$ — позиция курсора, $C_a, C_r, C_d$ — коэффициенты притяжения, отталкивания и затухания соответственно.

Еще одна анимация


Программирование поведения


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

DOTS — относительно недавнее развитие движка Unity, стек технологий, ориентированный на написание высокопроизводительного многопоточного кода. На хабре есть перевод вводной статьи в DOTS. Благодаря использованию архитектурного паттерна ECS, Jobs System и компилятора Burst, DOTS дает возможность писать быстрый многопоточный код, при этом снижая вероятность выстрелить себе в ногу.

Суть написания программы в рамках ECS сводится к разделению кода на компоненты (Components), описывающие состояние, системы (Systems), описывающие поведение и взаимодействие компонентов, и сущности (Entities) — объекты, содержащие набор компонентов.

Компоненты


Нам потребуются следующие компоненты:

// Начальное положение частицы (её аттрактор)
public struct AttractorPosData : IComponentData
{
   public float2 Value;
}

// Скорость частицы
public struct VelocityData : IComponentData
{
   public float2 Value;
} 

// Ускорение частицы
public struct AccelerationData : IComponentData
{
   public float2 Value;
}

// Данные для рендера
public struct ParticleData : IComponentData 
{
    public Color color;
    public Matrix4x4 matrix;
}

С компонентами всё достаточно просто, переходим к системам:

// Система обновления скорости
[UpdateBefore(typeof(MoveSystem))]
public class VelocityUpdateSystem : SystemBase
{
    protected override void OnUpdate()
    {
        var time = Time.DeltaTime;
        Entities.ForEach((ref VelocityData velocity, 
                in AccelerationData acceleration) =>
            {
                velocity.Value += time * acceleration.Value;
            })
            .ScheduleParallel();
    }
}

// Система изменения положения
public class MoveSystem : SystemBase
{
    protected override void OnUpdate()
    {
        var time = Time.DeltaTime;
        Entities.ForEach((ref Translation t, in VelocityData velocity) =>
            {
                t.Value.xy += time * velocity.Value;
            })
            .ScheduleParallel();
    }
}

Контролировать очередность работы систем можно с помощью атрибутов

[UpdateBeforeAttribute]
[UpdateAfterAttribute]

Самой масштабной из систем, описывающих поведение, будет система обновления ускорения, действующая в соответствии с выведенной формулой:

[UpdateBefore(typeof(VelocityUpdateSystem))]
public class AccelerationSystem : SystemBase
{
    protected override void OnUpdate()
    {
        float2 repulsorPos = Globals.Repulsor.Position;

        // Коэффициент притяжения 
        var attractionPower = Globals.SettingsHolder.SettingsModel.Attraction;
        // Коэффициент отталкивания
        var repulsionPower = Globals.SettingsHolder.SettingsModel.Repulsion;
        // Коэффициент затухания
        var dampingPower = Globals.SettingsHolder.SettingsModel.Damping;
        Entities.ForEach((
                ref AccelerationData acceleration, 
                in AttractorPosData attractorPos, 
                in Translation t,
                in VelocityData velocity) =>
            {
                // Сила притяжения
                var attraction = (attractorPos.Value - t.Value.xy) * attractionPower;

                var distSqr = math.distancesq(repulsorPos, t.Value.xy);
                // Сила отталкивания
                var repulsion = -math.normalizesafe(repulsorPos - t.Value.xy) 
                    / distSqr * repulsionPower;

                // Сила затухания
                var damping = -velocity.Value * dampingPower;

                acceleration.Value = attraction + repulsion + damping;
            })
            .ScheduleParallel();
    }
}

Рендер


Несмотря на потенциально огромное количество частиц, которые нужно отобразить, для всех можно использовать один и тот же квадратный mesh из двух полигонов, а так же один и тот же материал, что позволило бы отрендерить их все за один draw call, если бы не одно «но»: у всех частиц, в общем случае, разные цвета, иначе получившаяся картинка будет очень скучной.

Стандартный Unity-шейдер «Sprites/Default» умеет использовать GPU Instancing для оптимизации рендера объектов с неодинаковыми спрайтами и цветами, объединяя их в один draw call, но в его случае ссылка на текстуру и цвет для каждого конкретного объекта должна задаваться из скрипта, к чему из ECS у нас доступа нет.

В качестве решения может выступить метод Graphics.DrawMeshInstanced, позволяющий отрисовать один mesh несколько раз за один draw call с разными параметрами материала, используя тот же GPU Instancing. Выглядит это следующим образом:

[UpdateAfter(typeof(TrsMatrixCalculationSystem))]
public class SpriteSheetRenderer : SystemBase
{
    // DrawMeshInstanced позволяет рендерить не более 1023 объектов за один вызов
    private const int BatchCount = 1023;
    // Список матриц перехода
    private readonly List<Matrix4x4> _matrixList = new List<Matrix4x4>(BatchCount);
    // Список цветов
    private readonly List<Vector4> _colorList = new List<Vector4>(BatchCount);

    protected override void OnUpdate()
    {
        var materialPropertyBlock = new MaterialPropertyBlock();
        var quadMesh = Globals.Quad;
        var material = Globals.ParticleMaterial;

        // Хэширование доступа к свойству материала по строке 
        // для большей производительности
        var shaderPropertyId = Shader.PropertyToID("_Color");

        var entityQuery = GetEntityQuery(typeof(ParticleData));
        // Получение списка сущностей, содержащих компонент ParticleData
        var animationData = 
                   entityQuery.ToComponentDataArray<ParticleData>(Allocator.TempJob);
        var layer = LayerMask.NameToLayer("Particles");

        for (int meshCount = 0; 
              meshCount < animationData.Length; 
              meshCount += BatchCount)
        {
            var batchSize = math.min(BatchCount, animationData.Length - meshCount);
            _matrixList.Clear();
            _colorList.Clear();

            for (var i = meshCount; i < meshCount + batchSize; i++)
            {
                _matrixList.Add(animationData[i].matrix);
                _colorList.Add(animationData[i].color);
            }

            materialPropertyBlock.SetVectorArray(shaderPropertyId, _colorList);
            Graphics.DrawMeshInstanced(
                quadMesh,
                0,
                material,
                _matrixList,
                materialPropertyBlock,
                ShadowCastingMode.Off, false, layer);
        }

        animationData.Dispose();
    }
}

Для того, чтобы отрендерить группу объектов, используя данный метод, нужно собрать массив матриц перехода и тех свойств материала, которые предполагается варьировать. Система рендера, как можно заметить, выполняется после TrsMatrixCalculationSystem — вспомогательной системы, рассчитывающей матрицу перехода для каждой из частиц, которая выглядит очень просто:

[UpdateAfter(typeof(MoveSystem))]
public class TrsMatrixCalculationSystem : SystemBase
{
    protected override void OnUpdate()
    {
        // Получение значение scale из глобального состояния
        var scale = Globals.SettingsHolder.SettingsModel.ParticlesScale;
        Entities.ForEach((ref ParticleData data, in Translation translation) =>
            {
                data.matrix = Matrix4x4.TRS(translation.Value, Quaternion.identity,
                    new Vector3(scale, scale, scale));
            })
            .ScheduleParallel();
    }
}

Анимация 46.000 частиц


Производительность


Пришло время поговорить о том, для чего (помимо, конечно, эстетического удовлетворения) это всё затевалось. В Unity, на данный момент, есть возможность выбрать из двух Scripting Backend (реализаций CLR): старая-добрая Mono и более современное решение от разработчиков Unity — IL2CPP.

Сравним производительность билдов для этих двух реализаций рантайма:
Количество частиц на экране Average frame rate, Mono Average frame rate, IL2CPP
50000 128 255
100000 66 130
200000 31 57
Спецификации ПК:
QHD 2560x1440
Intel Core i5-9600K
16GB RAM
MSI GeForce RTX 2080 SUPER VENTUS XS OC 8.0 GB


160.000 частиц

Заметно, что, в рамках данного сферического в вакууме эксперимента, IL2CPP выигрывает у Mono примерно в два раза.

Согласно профилировщику, буквально все время, затрачиваемое на кадр, уходит на систему рендера, расчеты остальных систем обходятся практически «бесплатно»:

Unity Editor


Build

Заметно, что большая часть времени уходит на процедуры преобразования цвета из Color в Vector4 и добавления в список List.Add(). Избавиться от первой мы можем легко — перенесём преобразование на момент генерации частиц, так как цвет в процессе не меняется:

// Заменим
public struct ParticleData : IComponentData
{
    public Color color;
    public Matrix4x4 matrix;
}
// На
public struct ParticleData : IComponentData
{
    public Vector4 color;
    public Matrix4x4 matrix;
}

Это изменение позволило полностью избавиться от дорогой операции преобразования:

Количество кадров в секунду для 200.000 частиц после него выросло до 61.

Возможно, можно оптимизировать заполнение списков, например, хранить все массивы цветов, генерируя их единожды, но такое решение не кажется мне изящным, поэтому буду рад конструктивным предложениям в комментариях.

Заключение


ECS в целом и Unity DOTS в частности — отличные инструменты для определенных сценариев и классов задач. Свою роль в эффективной обработке огромного количества данных они исполняют великолепно и позволяют создавать симуляции, на которые в их отсутствие ушло бы значительно больше усилий. Не стоит, однако, считать DOTS «серебряной пулей» и кидаться камнями в разработчиков, придерживающихся традиционных для Unity концепций в новых проектах — DOTS подходит далеко не каждому и далеко не для каждой задачи.

P.S.


Каждый желающий может ознакомиться с проектом в моем github-репозитории, а как же установить билд как обои рабочего стола в Wallpaper Engine: ссылка.