ECS - Entity-Component-System - довольно удобный способ проектирования архитектуры игр. Вынесение данных в компоненты, помещённые в сущности и управляемые системами, позволяет получить необходимое, даже самое сложное, поведение объектов "жонглированием" компонентов внутри них. Именно на него пал наш взор при разработке небольшой пошаговой стратегии на Unity.

Для тех, кто не касался данной темы, вкратце объясню как вообще устроен ECS: вы делите весь ваш проект на три аспекта. Первый - сущности (Entities) - просто набор "пустышек" (контейнеров компонентов), которые не обладают никакой логикой. Они нужны исключительно для хранения компонентов (Components) - второй аспект. Это объекты, содержащие данные, однако, тоже не имеющие какой-либо логики (разве что кроме простейшей, например компонент "HealthComponent" может уметь возвращать текущее здоровье в процентах, не более). А управляются компоненты системами (Systems), которые реализуют всю логику на основе данных, полученных из компонентов. Например, система "HealthSystem" может проверять текущее здоровье у всех "HealthComponent", и инициировать проигрывание анимации смерти у сущности, на которой висит "HealthComponent", здоровье которого опустилось до нуля.

После небольшого расследования нашей отважной командой выяснилось, что в первую очередь, большинство предлагаемых ECS могут быть использованы в Unity, однако они к ней не привязаны, что подразумевает необходимость их как-либо настраивать для нормальной работы. Но зачем разбираться с настройкой, если можно не разбираться с настройкой? На том и решили мы написать свой ленивый ECS конкретно для Unity.

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

Итак, реализовали мы в первую очередь явно не чистый ECS, но постарались приблизиться к нему настолько, насколько это позволяет Unity и наше личное видение данного паттерна.

Небольшой спойлер. На выходе получилась примерно такая архитектура:

Часть архитектуры игры
Часть архитектуры игры

Перейдём же к этапам реализации! Берём в руки клавиатуру и начинаем писать. Начнём с того, что системы и компоненты существуют где-то в эфемерном пространстве, а к ним должен быть лёгкий и достаточно быстрый доступ, так что хотелось бы где-то вести массивы ссылок на них, и этим "где-то" у нас будет ECSInstance - статический класс с двумя полями: список компонентов и список систем. Код его может выглядеть так:

public class ECSInstance
{
    private static ECSInstance instance;
  
    public List<ECSComponent> Components;
    public List<ECSSystem> Systems;
  
    private ECSInstance()
    {
        Components = new List<ECSComponent>();
        Systems = new List<ECSSystem>();
    }
    public static ECSInstance Instance()
    {
        if(instance == null)
            instance = new ECSInstance();
        return instance;
    }
}

Теперь к нему можно получить доступ отовсюду в коде, и его единственный экземпляр будет сохраняться и существовать на протяжении всей игры. Синглтон прекрасно подошёл на роль паттерна для данного класса. Однако, как вы можете заметить, эти списки пустые, и нигде в рамках класса не заполняются. С этим мы разберёмся позже, когда дойдём до рассмотрения компонентов. И, пока что, не совсем удобно доставать отсюда конкретные компоненты или системы, так что мы написали два класса, которые с этим помогают справиться. Знакомьтесь с ними, первый - это класс ECSFilter. Он нужен для удобного доступа к компонентам:

public class ECSFilter
{
    public List<ECSComponent> Components;
  
    public ECSFilter(ECSComponent[] components) { Components = new List<ECSComponent>(components); }); }
    public ECSFilter(List<ECSComponent> components) { Components = components; }
    public ECSFilter() { Components = ECSInstance.Instance().Components; }
    
    public ECSFilter OfType<T>()
    {
        List<ECSComponent> new_list = new List<ECSComponent>();
        foreach(var c in Components)
            if(c.GetType() == typeof(T))
                new_list.Add(c);
        return new ECSFilter(new_list);
    }
  
    public ECSFilter WithoutType<T>()
    {
        List<ECSComponent> new_list = new List<ECSComponent>();
        foreach (var c in Components)
            if (c.GetType() != typeof(T))
                new_list.Add(c);
        return new ECSFilter(new_list);
    }

    public List<T> GetComponents<T>() where T: ECSComponent
    {
        List<T> new_list = new List<T>();
        foreach (var c in Components)
            if (c.GetType() == typeof(T))
                new_list.Add((T)c);
        return new_list;
    }
  
    public List<T> GetComponents<T>(Func<T, bool> predicate) where T: ECSComponent
    {
        List<T> new_list = new List<T>();
        foreach (var c in Components)
            if (c.GetType() == typeof(T))
                if(predicate.Invoke((T)c))
                    new_list.Add((T)c);
        return new_list;
    }
}

За счёт собственной коллекции, фильтр может быть применим к набору компонентов, не связанных с ECSInstance, и занимается выделением из компонентов тех, которые удовлетворяют условиям. Методы OfType и WithoutType возвращают сам фильтр, так что могут быть вызваны в цепочке:

...
ECSFilter filter = new ECSFilter();
List<ECSComponent> = filter.OfType<Moveable>().WithoutType<Selectable>().Components;
...

Второй - класс ECSService. Всё же, основная его задача - инициализация систем и вызов их главных функций, однако я принял решение также дать этому классу возможность возвращать необходимую пользователю систему (был ещё вариант поместить логику получения конкретной системы в ECSInstance, описанный выше). Такое решение я принял на случай, если в будущем захочется дать ECSService больше логики, связанной с взаимодействием систем. Код:

public class ECSService : MonoBehaviour
{
    void InitSystems()
    {
        var Systems = ECSInstance.Instance().Systems;
        
        Systems.Add(new InputSystem(this));
        Systems.Add(new SelectionSystem(this));
        Systems.Add(new MoveSystem(this));
        Systems.Add(new AttackSystem(this));
        //Systems.Add(...
    }

    void Awake()
    {
        InitSystems();
    }

    void Start()
    {
        foreach (var s in ECSInstance.Instance().Systems)
            s.Init();
    }

    void Update()
    {
        foreach (var s in ECSInstance.Instance().Systems)
            s.Run();
    }

    public T GetSystem<T>() where T: IECSSystem
    {
        foreach(var s in ECSInstance.Instance().Systems)
            if (s.GetType() == typeof(T))
                return (T)s;
        return null;
    }
}

Как видно, именно в этом классе "берёт начало" выполнение ежекадровой логики каждой системы. Я уверен, что цикл foreach по всем системам с выполнением функции не является очень оптимальным решением, однако, не стоит забывать, что ECS у нас ленивый :). При создании систем сервис оставляет внутри них ссылку на себя же, чтобы система имела доступ к своим "собратьям". Насколько я помню, в ООП этот приём называют "инъекция зависимости".

Вот и всё на чём держится выполнение ECS. Теперь мы с чистой душой можем перейти к реализации непосредственно компонентов и систем.

Все компоненты у нас наследуются от ECSComponent, который реализует добавление себя в список при создании, а также правильное добавление нового компонента:

public class ECSComponent : MonoBehaviour
{
    private void Awake()
    {
        ECSInstance.Instance().Components.Add(this);
    }

    public void AddComponent<T>(T component) where T: ECSComponent
    {
        gameObject.AddComponent<T>();
        ECSInstance.Instance().Components.Add(component);
    }

    public void RemoveComponent<T>(T component) where T: ECSComponent
    {
        ECSInstance.Instance().Components.Remove(component);
        Destroy(component);
    }
}

Это, конечно, добавляет небольшое неудобство, так как такие важные компоненты как transform, renderer, и другие стандартные компоненты Unity не входят в список и никак не смогут быть оттуда получены. Однако, обращением непосредственно к gameObject наших компонентов мы вполне справляемся.

Наконец, ECSSystem - суперкласс систем:

public class IECSSystem
{
    public ECSService Service;
  
    public IECSSystem(ECSService service) { Service = service; }
  
    public virtual void Run() { }
    public virtual void Init() { }
}

Ничего сверхъестественного, просто пара методов - один из которых будет вызываться на старте (Unity сигнал Start Monobehaviour объектам), а другой - каждый фрейм. Тут же виден и сервис, о котором было сказано выше.

Ну вот и всё, вам осталось лишь запустить на карту вашего будущего воина/дерево/полторашку лимонада в виде пустышки, написать свой первый компонент (например, перемещения) и в первой системе написать что-то вроде...

public override void Run()
{
    ECSFilter f = new ECSFilter();
    List<Movable> components = f.GetComponents<Movable>();
    foreach (var c in components)
        UpdateComponent(c);
}

Если вы оцените данный материал, в дальнейшем хотелось бы выпустить отдельную статью, в которой рассмотреть взаимодействие ECS и UI в рамках Unity проекта. Непременно ждём ваш фидбек. Удачи вам в ваших начинаниях!

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


  1. Leopotam
    12.05.2022 01:13
    +8

    Осталось посмотреть количество аллокаций на каждый чих и больше никогда не пытаться пилить геймдев-код в enterprise стиле. Про статику во всех-всех вызовах апи вообще молчу - это за гранью добра и зла (TDD отдельно отпинают за углом за невозможность нормально писать тесты ко всему этому добру).


    1. Tutanhomon
      13.05.2022 21:57

      в апдейте каждой системы аллоцируются и удаляются Листы, страшно в профайлер вообще заглядывать))

      ЗЫ. Был уверен что увижу твой комент первым :)


      1. loltrol
        14.05.2022 18:51

        А если заменить алокацию на пул листов или других контейнеров?

        пс: у нас сейчас backend java девелоперы(в том числе и я) пишут ui на unity3d. Сделали мы подходящий для себя фреймворк, но в в профайлер еще не заглядывали. Там будет тихий ужас, 100%, потому что на jvm обычно выделяют 100500 мб памяти, тюнят gc и только в самых критичных случаях начинают думать за другие оптимизации. Сейчас потихоньку собираем инфу как и что будем фиксить :)


        1. Leopotam
          14.05.2022 20:32

          Пулинг нужен, да. Основной посыл был - в выборках энтитей по условиям вообще не должно быть аллокаций в принципе. Как это сделано - достаточно посмотреть любой популярный фрейм, ссылки можно погуглить в соседнем посте https://habr.com/ru/post/665276/


  1. Tutanhomon
    13.05.2022 22:01
    +1

    а все что нужно было - взять любой хороший ЕЦС и дописать к нему небольшую прослойку для конвертации (или взять готовую, для ЛеоЕЦС такой конвертер есть, сторонний). Или подождать Entities 0.51, он в целом неплох.


    1. MiYas Автор
      13.05.2022 23:34
      -1

      Листы действительно вещь нехорошая, но вроде поправимая. На самом деле, в основном реализовывал ради интереса, было интересно попробовать, столкнуться с проблемами, которые решаются ECSом и возникают при использовании собственно него же :)