В Unity3D с выходом версии 2018 появилась возможность использовать нативную (для Unity) ECS систему, сдобренную многопоточностью в виде Job System. Материалов в интернете не особо много (пара проектов от самих Unity Technologies да пара обучающих видео на ютубе). Я попробовал осознать масштаб и удобность ECS, сделав небольшой проект не из кубов и кнопок. До этого у меня не было опыта проектирования ECS, так что два дня ушло на изучение материалов и перестроение мышления с ООП, день ушел на восхищение подходом, и еще один-два дня — на разработку проекта, борьбу с Unity, выдергивание волос и курение семплов. В статье содержится немного теории и небольшой пример проекта.


Смысл ECS довольно прост — сущность (Entity) с ее компонентами (Component), обработкой которых занимается система (System).

Сущность


Сущность не имеет никакой логики и хранит только компоненты (очень похоже на GameObject в старом КОП подходе). В Unity ECS для этого существует класс Entity.

Компонент


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

Система


Системы же отвечают за обработку компонентов. На вход они получают от Unity список обрабатываемых компонентов по заданным типам, а в перегруженных методах (аналогах Update, Start, OnDestroy) происходит магия игровых механик. Наследуются от ComponentSystem или JobComponentSystem.

Job System


Механика систем, позволяющая распараллелить обработку компонентов. В OnUpdate системы создается структура-Job и добавляется в обработку. В момент скуки и свободных ресурсов Unity обработает и применит результаты к компонентам.

Многопоточность и Unity 2018


Вся работа Job System происходит в других потоках, а стандартные компоненты (Transform, Rigidbody и прочее) невозможно менять в любом потоке, кроме основного. Поэтому в стандартной поставке есть совместимые компоненты-«замены» — Position Component, Rotation Component, Mesh Instance Renderer Component.

Это же относится и к стандартным структурам, как Vector3 или Quaternion. В компонентах для распараллеливания используются лишь простейшие типы данных (float3, float4, вот это всё, программисты графики будут довольны), добавленные в пространстве имен Unity.Mathematics, там же есть и класс math для их обработки. Никаких строк, никаких ссылочных типов, только хардкор.

«Покажите мне код»


Итак, время что-нибудь подвигать!

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

SpeedComponent
[Serializable]
public struct SpeedData : IComponentData
{
    public int Value;
}

public class SpeedComponent : ComponentDataWrapper<SpeedData> {}


Система с помощью аттрибута Inject получает структуру, содержащую компоненты только тех сущностей, на которых есть все три компонента. Так, если у какой-то сущности будут компоненты PositionComponent и SpeedComponent, но не RotationComponent, то эта сущность не будет добавлена в структуру, поступающую в систему. Таким образом, можно осуществлять фильтрацию сущностей по наличию компонента.

MovementSystem
public class MovementSystem : ComponentSystem
{
    public struct ShipsPositions
    {
        public int Length;
        public ComponentDataArray<Position> Positions;
        public ComponentDataArray<Rotation> Rotations;
        public ComponentDataArray<SpeedData> Speeds;
    }

    [Inject] ShipsPositions _shipsMovementData;

    protected override void OnUpdate()
    {
        for(int i = 0; i < _shipsMovementData.Length; i++)
        {
            _shipsMovementData.Positions[i] = new Position(_shipsMovementData.Positions[i].Value + math.forward(_shipsMovementData.Rotations[i].Value) * Time.deltaTime * _shipsMovementData.Speeds[i].Value);
        }
    }
}


Теперь все объекты, содержащие эти три компонента, будут двигаться вперед с заданной скоростью.

Уиииии


Это было просто. Хоть и заняло один день осмысления ECS.

Но стоп. Где здесь Job System?

Дело в том, что еще ничего не сломано настолько, чтобы использовать многопоточность. Время ломать!

Я стянул из семплов систему, рождающую префабы. Из интересного — вот такой кусок кода:

Spawner
EntityManager.Instantiate(prefab, entities);
for (int i = 0; i < count; i++)
{
    var position = new Position
    {
        Value = spawnPositions[i]
    };
    EntityManager.SetComponentData(entities[i], position);
    EntityManager.SetComponentData(entities[i], new SpeedData { Value = Random.Range(15, 25) });
}


Итак, поставим 1000 объектов. Всё ещё слишком хорошо из-за инстанциирования мешей на GPU. 5000 — тоже ок. Покажу, что происходит при 50000 объектов.

В Unity появился Entity Debugger, показывающий, сколько мс занимает работа каждой системы. Системы можно включать/выключать прямо в рантайме, смотреть, какие объекты они обрабатывают, в общем, незаменимая вещь.

Получится такой космолетный шар


Инструмент записывает со скоростью 15 фпс, так что вся суть в числах в списке систем. Наша, MovementSystem, пытается подвигать все 50000 объектов в каждом кадре, и делает это в среднем за 60 мс. Значит, теперь игра сломана вполне достаточно для оптимизации.
Прикрутим JobSystem к системе движения.

Измененная MovementSystem
public class MovementSystem : JobComponentSystem
{
  [ComputeJobOptimization]
    struct MoveShipJob : IJobProcessComponentData<Position, Rotation, SpeedData>
    {
        public float dt;

        public void Execute(ref Position position, ref Rotation rotation, ref SpeedData speed)
        {
            position.Value += math.forward(rotation.Value) * dt * speed.Value;
        }
    }

    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {               
        var job = new MoveShipJob
        {            
            dt = Time.deltaTime
        };        
        return job.Schedule(this, 1, inputDeps);
    }
}


Теперь система наследуется от JobComponentSystem и в каждом кадре создает специальный обработчик, в который Unity передает те же 3 компонента и deltaTime от системы.

Снова запустим космолетный шар


0.15 мс (0.4 в пике, да) против 50-70! 50 тысяч объектов! Я ввел эти цифры в калькулятор, в ответ он показал счастливую рожицу.

Управление


Можно бесконечно смотреть на пролетающий шар, а можно полетать среди кораблей.
Нужна система руления.

Компонент Rotation уже есть на префабе, создадим компонент для хранения контролов.

ControlComponent
[Serializable]
public struct RotationControlData : IComponentData 
{
    public float roll;
    public float pitch;
    public float yaw;
}

public class ControlComponent : ComponentDataWrapper<RotationControlData>{}


Также нам понадобится компонент игрока (хотя не проблема рулить всеми 50к кораблями сразу)

PlayerComponent
public struct PlayerData : IComponentData { }

public class PlayerComponent : ComponentDataWrapper<PlayerData> { }


И сразу же — систему считывания пользовательского ввода.

UserControlSystem
public class UserControlSystem : ComponentSystem
{
    public struct InputPlayerData
    {
        public int Length;
        [ReadOnly] public ComponentDataArray<PlayerData> Data;
        public ComponentDataArray<RotationControlData> Controls;
    }

    [Inject] InputPlayerData _playerData;

    protected override void OnUpdate()
    {
        for (int i = 0; i < _playerData.Length; i++)
        {
            _playerData.Controls[i] = new RotationControlData
            {
                roll = Input.GetAxis("Horizontal"),
                pitch = Input.GetAxis("Vertical"),
                yaw = Input.GetKey(KeyCode.Q) ? -1 : 
                      Input.GetKey(KeyCode.E) ? 1 : 0
            };
        }
    }  
}


Вместо стандартного Input может быть любой любимый самописный велосипед или AI.

И, наконец, обработка контролов и сам поворот. Я столкнулся с тем, что math.euler еще не реализован, поэтому быстрый набег на википедию спас меня с пересчетом из углов Эйлера в кватернион.

ProcessRotationInputSystem
public class ProcessRotationInputSystem : JobComponentSystem
{
    struct LocalRotationSpeedGroup
    {
        public ComponentDataArray<Rotation> rotations;
        [ReadOnly] public ComponentDataArray<RotationSpeedData> rotationSpeeds;
        [ReadOnly] public ComponentDataArray<RotationControlData> controlData;
        public int Length;
    }

    [Inject] private LocalRotationSpeedGroup _rotationGroup;
    [ComputeJobOptimization]
    struct RotateJob : IJobParallelFor
    {
        public ComponentDataArray<Rotation> rotations;
        [ReadOnly] public ComponentDataArray<RotationSpeedData> rotationSpeeds;
        [ReadOnly] public ComponentDataArray<RotationControlData> controlData;
        public float dt;

        public void Execute(int i)
        {
            var speed = rotationSpeeds[i].Value;
            if (speed > 0.0f)
            {
                quaternion nRotation = math.normalize(rotations[i].Value);
                float yaw = controlData[i].yaw * speed * dt; 
                float pitch = controlData[i].pitch * speed * dt;
                float roll = -controlData[i].roll * speed * dt;
                quaternion result = math.mul(nRotation, Euler(pitch, roll, yaw));
                rotations[i] = new Rotation
                {
                    Value = result
                };
            }            
        }

        quaternion Euler(float roll, float yaw, float pitch)
        {
            float cy = math.cos(yaw * 0.5f);
            float sy = math.sin(yaw * 0.5f);
            float cr = math.cos(roll * 0.5f);
            float sr = math.sin(roll * 0.5f);
            float cp = math.cos(pitch * 0.5f);
            float sp = math.sin(pitch * 0.5f);

            float qw = cy * cr * cp + sy * sr * sp;
            float qx = cy * sr * cp - sy * cr * sp;
            float qy = cy * cr * sp + sy * sr * cp;
            float qz = sy * cr * cp - cy * sr * sp;
            return new quaternion(qx, qy, qz, qw);
        }
    }        
        
    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {       
        var job = new RotateJob
        {
            rotations = _rotationGroup.rotations,
            rotationSpeeds = _rotationGroup.rotationSpeeds,
            controlData = _rotationGroup.controlData,
            dt = Time.deltaTime
        };
		
        return job.Schedule(_rotationGroup.Length, 64, inputDeps);
    }
}


Наверно, вы спросите, почему нельзя просто передать 3 компоненты сразу в Job, как в MovementSystem? Потому что. Я долго с этим бился, но не знаю, почему оно так не работает. В семплах повороты реализованы через ComponentDataArray, не будем же отступать от канонов.

Выкидываем префаб на сцену, вешаем компоненты, привязываем камеру, ставим нескучные обои, и вперед!



Заключение


Ребята из Unity Technologies двинулись в верном направлении мультипоточности. Сама Job System еще сыровата (альфа-версия как-никак), но вполне пригодна к использованию и дает ускорение уже сейчас. К сожалению, стандартные компоненты несовместимы с Job System (но не с ECS в отдельности!), поэтому придется лепить костыли, чтобы это обойти. Например, один человек с форума Unity реализует свою физическую систему для GPU, и, вроде как, делает успехи.
ECS же с Unity использовалась и до этого, есть несколько процветающих аналогов, например, статья с обзором самых известных. В ней же описаны плюсы и минусы данного подхода к архитектуре.

От себя же могу добавить такой плюс как чистоту кода. Я начал с того, что попытался реализовать движение в одной системе. Количество компонентов-зависимостей быстро росло, и мне пришлось разделить код на небольшие и удобные системы. А их можно легко переиспользовать в другом проекте.

Код проекта находится здесь: GitHub

Комментарии (10)


  1. fischer
    13.06.2018 15:44
    +2

    Спасибо большое за статью, особенно за примеры и то, что не забыли про фильтрацию сущностей и JobSystem. Я как раз хотел написать свой обзор Unity ECS, но вы меня опередили) Уже несколько лет занимаюсь вопросом разработки на ECS, тема для меня горячая, можно почитать в моих последних статьях на Хабре.
    Что хотелось бы добавить: текущая реализация пока работает только редакторе. Она пишется, можно сказать, всем миром, т.к. Unity стараются собрать по максимуму опыт специалистов в области и их реализация еще сто раз может измениться. На ближайшей Unite Berlin скорее всего расскажут о дальнейшем развитии.
    Также добавил вас в свой небольшой обзор статей про ECS на Хабре (см. в конце статьи). Примечательно, что наши статьи вышли практически одновременно, это очень горячая тема на сегодня.


    1. fstleo Автор
      13.06.2018 16:00

      Да, уже посмотрел ваш вариант, интересно.
      Достаточно почитать ветку форума про ECS, где народ предлагает и реактивное UI, и физику на GPU, а работники Unity отвечают в стиле: «Да, мы думаем над этим», «Мы хотим сделать что-то похожее» и т.д. В общем, пытаются везде успеть.
      P.S. Забавно, но в субботу меня пригласили пройти собеседование в вашу компанию :)


      1. fischer
        13.06.2018 18:02

        Так приходите, будем рады)


  1. metamorphling
    13.06.2018 15:48

    Шикарная статья, огромное вам спасибо за проделанную работу!
    Звучит новая система интересно: и лапшу из кода может вылечить и перфоманс повысить. Интересно, перегонят ли всех насильно на новый подход?(страшно представить сколько плагинов отклеятся)


    1. fstleo Автор
      13.06.2018 15:52

      Очень хотелось бы интеграции в текущую КОП систему, или полную замену текущей системы «под капотом». Не думаю, что станут терять совместимость (может, через пару-другую лет). Насколько знаю, у них не настолько большая команда, чтобы делать форк и поддерживать две версии движка.


  1. indiega
    13.06.2018 15:48

    ECS удобна лишь для проектов, где большое стадо сущностей должно выполнять одно и то же, причем в апдейтах. С джобсами та же хрень, ибо рожать потоки для сложения двух значений, для одной сущности, выйдет накладней ИМХО. Перепробовал кучу велосипедов, включая упомянутые в статье, по приведенной ссылке. В проектах без упомянутого выше стада, вся прелесть сводится на нет некислым оверхедом в писанине. То что обычно умещается в три строчки, с джобсами разрастается до 20-30, и компоненты приходится плодить тоннами. Единственно, что есть в этом стоящего, это отделение данных и действительно 100% переиспользование систем, но для этого есть другие велосипеды. )


    1. fstleo Автор
      13.06.2018 15:54

      Да, системы с джобами довольно объемно смотрятся, поэтому я выносил в них только то, что требует многопоточности


  1. aesee_npc
    13.06.2018 15:48

    Спасибо за полезную статью!


  1. TheShock
    15.06.2018 05:23

    Не знаете, а в этой версии возможность распараллелить EncodeToPNG дали? Или всё еще только в основном потоке?


    1. fstleo Автор
      15.06.2018 09:02

      Всё, что относится к namespace UnityEngine, всё ещё должно выполняться в основном потоке