В 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