Unity3D ECS и Job System

в 13:52, , рубрики: ecs, job system, unity3d, unity3d ecs, Программирование, разработка игр

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

Unity3D ECS и Job System - 1


Смысл 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);
        }
    }
}

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

Уиииии
Unity3D ECS и Job System - 2

Это было просто. Хоть и заняло один день осмысления 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, показывающий, сколько мс занимает работа каждой системы. Системы можно включать/выключать прямо в рантайме, смотреть, какие объекты они обрабатывают, в общем, незаменимая вещь.

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

Unity3D ECS и Job System - 3

Инструмент записывает со скоростью 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 от системы.

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

Unity3D ECS и Job System - 4

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, не будем же отступать от канонов.

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

Unity3D ECS и Job System - 5

Заключение

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

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

Автор: fstleo

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js