unity_ecs_madkoala

Кидаем триггеры коллайдеров из Unity в модель на ECS

Вступление

Хочу рассказать о, на мой взгляд, довольно изящном подходе, к которому я пришёл, чтобы решить проблему, обозначенную в заголовке статьи. Несмотря на то, что статья заявлена скорее как инструкция, труднее всего мне сформулировать не КАК, а ЗАЧЕМ вообще была поставлена такая задача. Особенно для тех посетителей, кто достаточно технически подкован.

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

Статья не претендует на академическую точность, я просто хотел поделиться ходом своих мыслей для обмена опытом, готов и буду рад любой критике, но буду рад втройне, если кто-то столкнувшийся с аналогичной проблемой найдёт моё решение полезным. Так как статья написана от юнитиста, в основном, для юнитистов, от читающего требуется как минимум знание C#, понимание, что так Collider в Unity — и как он примерно работает, а также понимание базовых паттернов проектирования.

Немного о ECS

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

Сам по себе ECS в отрыве от приложенной его реализации гениальным Leopotam, это не суть более чем концепция, в которой есть три основных составляющих:

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

ecs_pattern

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

ecs_pattern_example

В данном примере позиции, физика, рендер, АИ и пользовательский ввод — это компоненты. Зомби, деревья и игрок — сущности. Над всем этим где-то существуют системы обработки каждого отдельного списка компонентов.

В идеальном мире сначала система позиций обновит позиции объектов, посмотрев в текущее состояние целей в AI и Player Input.
Затем служба физики пройдётся по компонентам физики и внесёт в позиции свои коррективы. Затем всё это отрисует система рендера.
Ну и под конец система AI обновит у зомби цели (убежал игрок, споткнулся, спрятался или нет), а затем система пользовательского ввода изменит состояние игрока.
В следующем кадре произойдёт всё тоже самое, в строгом порядке одно за одним, что самое важное, начиная с предыдущего состояния.

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

Гладко было на бумаге, но… или описание проблемы

Естественно, лучшим способом чему-то научиться это попробовать написать что-то самому — а затем использовать это на реальной задаче. Поэтому я быстро накидал в голове небольшой проектик, где персонаж может взаимодействовать с множеством предметов на сцене двумя разными способами. Когда активная область персонажа пересекается с активной областью объекта, срабатывает триггер, который меняет состояние персонажа на «готов выполнить действие».  Звучит как самое очевидное применение Collider из UnityEngine, на объект игрока вешается область BoxCollider и RigidBody с флагом Is Kinematic (чтобы на него не действовала физика), на активную игровую область или игровой предмет вешается BoxCollider с флагом IsTrigger.

Как это примерно выглядит, можно посмотреть на главной иллюстрации, без громоздких окон инспектора.

unity_ecs_madkoala

Однако получить информацию об их взаимодействии я могу только из скрипта, повешенного на MonoBehavior, то есть объекта на сцене. А  вся игровая логика у меня считается в модели на ECS.
Что это значит? Что существуют разделённые независимые системы, которые выполняют последовательно логическую операции над компонентами, в которых хранятся данные, однако эти компоненты не должны никак изменяться никакими сущностями вне этих систем. А это значит, что скрипт с коллайдером не имеет права напрямую менять состояние объекта.

Отступление. Или ответ на "А почему бы тебе просто не..."

Здесь стоит оговориться, что я стараюсь всегда придерживаться разделение механики игры на модель(логику игрового процесса) и её отображение (которое может быть плоским, трёхмерным — вообще каким угодно). И практика вешать на объекты на сцене скрипты, которые отвечают как за обработку логики объекта, так и за его отображение — или, что ещё хуже — ставящие знак тождества между этими двумя сущностями — это очень плохая практика. Не делайте так, детишки.

Подобный подход, конечно, позволяет что-то НАЧАТЬ писать очень быстро и получить базовое понимание механизмов работы, но это очень усложняет разработку, когда нужно что-то изменить в коде, особенно, когда на проекте работает несколько десятков человек. И делает код практически нечитаемой паутиной, когда данные из одного объекта нужно перекинуть в другую сущность в другой момент игры.

Запомните: данные отдельно, отображение отдельно, логика отдельно. Какой бы паттерн вы не использовали: хоть MVC, хоть ECS, пиши вы с ООП или с применением функционального программирования — триада этого принципа должна быть нерушима.

Итак. Есть простой и готовый механизм в Unity, который кажется изначально в строгую систему ECS не очень то и ложится. В идеальном мире с идеальным ECS я должен был бы полностью сам написать собственную сущность активных областей, потом я должен был бы написать собственную систему определения их взаимодействия, а ещё потом мне нужно было бы самому писать ещё и возможность их менять в редакторе… Но это в идеальном мире, а в реальном мире пошло оно в пизду — я слишком для этого ленив, я хочу использовать встроенные и удобные инструменты Unity. Погнали.

Первые шаги к реализации

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

public abstract class EntityCollision
{
private bool onEntityCollisionEnter = false;
private bool onEntityCollisionExit = false;
private bool onEntityCollisionStay = false;
private bool onEntityTriggerEnter = false;
private bool onEntityTriggerExit = false;
private bool onEntityTriggerStay = false;

public bool OnEntityCollisionEnter { get => onEntityCollisionEnter; }
public bool OnEntityCollisionExit { get => onEntityCollisionExit; }
public bool OnEntityCollisionStay { get => onEntityCollisionStay; }
public bool OnEntityTriggerEnter { get => onEntityTriggerEnter; }
public bool OnEntityTriggerExit { get => onEntityTriggerExit; }
public bool OnEntityTriggerStay { get => onEntityTriggerStay; }

public void ClearCollisionEnterFlag() { onEntityCollisionEnter = false; }
public void ClearCollisionExitFlag() { onEntityCollisionExit = false; }
public void ClearCollisionStayFlag() { onEntityCollisionStay = false; }
public void ClearTriggerEnterFlag() { onEntityTriggerEnter = false; }
public void ClearTriggerExitFlag(){ onEntityTriggerExit = false; }
public void ClearTriggerStayFlag() { onEntityTriggerStay = false; }

public void CollisionEnter() { onEntityCollisionEnter = true; }
public void CollisionExit() { onEntityCollisionExit = true; }
public void CollisionStay() { onEntityCollisionStay = true; }
public void TriggerEnter() { onEntityTriggerEnter = true; }
public void TriggerExit() { onEntityTriggerExit = true; }
public void TriggerStay() { onEntityTriggerStay = true; }
}
Я знаю, что вы скажете.Что за херня?! Это уже не ECS!

Внимательный и прошаренный читатель, глядя на приложенный код, сразу же воскликнет: «Позвольте, сударь?! ECS подразумевает, что компонент — это всего лишь структура данных, а все вычисления делают системы, а у тебя здесь полноценный класс с методами, то есть со своей собственной логикой!»

Да, должен признаться. В ходе работы внедрения ECS в свой проект я взял на себя смелость слегка… сломать всю концепцию. Сколько бы я не рассказывал о чистых шаблонах и пользе того, как их придерживаться, иногда нужно самому для себя выбирать ситуации, где нужно немного уйти в сторону. Для меня гибкость в решениях и простота ЧТЕНИЯ кода так же важны, как его гибкость и масштабируемость.

Поэтому, скачав Leopotam для своего проекта, я слегка покопался в исходниках, чтобы в качестве компонента можно было задавать не только Struct, но и Class. Однако при реализации я старался уходить от концепции ECS по минимуму. В моём идеальном мире для меня и моего проекта, компонент не только хранилище данных, но ещё и примитивный набор действий. Условно компонент определяет не только ЧТО он такое, но ещё и ЧТО он может с собой сделать. Однако системы по-прежнему определяют то КОГДА, КАК и ПОЧЕМУ компонент должен менять свои данные.

Надеюсь, вас это не слишком сильно смутит.

От этого чудесного абстрактного класса наследуется класс PlayerActionCollision. Который не имеет ни одного собственного метода.

public class PlayerActionCollision : EntityCollision
{
}

Вопрос, зачем тогда было делать EntityCollision абстрактным, а не сразу писать всё для игрока? Потому что я сразу закладываюсь на будущее. Мне нужны будут разные типы коллизий, одних объектов с другими, логика взаимодействия которых будет сколь угодно различна. А как мы помним, логика определяется системами. И поэтому нужно под каждую систему заложить разный тип коллизий, однако они все должны иметь одинаковый механизм получения информации о коллизиях.

Итак. Вот первая система, которая только и делает, что если игрок НАХОДИТСЯ в триггере, выводит в консоль: «Player trigger»

using Ecs;

public class PlayerActionCollisionSystem : IPreInitSystem, IInitSystem, IRunSystem
{
    World world = null;
    Filter collisions = null;

    public void Run(float frameTime, float dt)
    {
        foreach (var filter in collisions)
        {
            ref var playerCollision = ref collisions.Get1(filter);

            if (playerCollision.OnEntityCollisionEnter) {
                playerCollision.ClearCollisionEnterFlag();
            }

            if (playerCollision.OnEntityCollisionExit) {
                playerCollision.ClearCollisionExitFlag();
            }

            if (playerCollision.OnEntityCollisionStay) {
                playerCollision.ClearCollisionStayFlag();
            }

            if (playerCollision.OnEntityTriggerEnter) {
                playerCollision.ClearTriggerEnterFlag();
            }

            if (playerCollision.OnEntityTriggerExit) {
                playerCollision.ClearTriggerExitFlag();
            }

            if (playerCollision.OnEntityTriggerStay) {
                Debug.Log("Player trigger");
                playerCollision.ClearTriggerStayFlag();
            }
        }
    }
}

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

Нууу… И да. И нет. Признаюсь, здесь я немного сжульничаю. Давайте воспринимать физический движок Unity просто как ещё одну ECS систему, которая каждый кадр меняет состояние компонента, как и остальные системы. Что я под этим понимаю, смотрите дальше в коде и  небольшое пояснение под спойлером.

Давайте всё-таки заведём скрипт, который будет пробрасывать из MonoBehaviour объекта в Unity событие в нашу модель.

public abstract class AbstractView : MonoBehaviour
{
    public abstract void SetEntity(Entity gameEntity);
}

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

А пока что вот тот самый класс, который считывает информацию о коллизиях в Unity и передаёт её в модель.

using UnityEngine;
public abstract class AbstractEntityCollider <TCollision> : AbstractView where TCollision : EntityCollision
{
    protected TCollision entityCollision
    {
        get
        {
            return GetCollision();
        }
    }

    protected abstract TCollision GetCollision();

    private void OnCollisionEnter(Collision collision){
        entityCollision.CollisionEnter();
    }

    private void OnCollisionExit(Collision collision){
        entityCollision.CollisionExit();
    }

    private void OnCollisionStay(Collision collision){
        entityCollision.CollisionStay();
    }

    private void OnTriggerEnter(Collider other){
        entityCollision.TriggerEnter();
    }

    private void OnTriggerExit(Collider other){
        entityCollision.TriggerExit();
    }

    private void OnTriggerStay(Collider other){
        entityCollision.TriggerStay();
    }

}

Естественно, для каждого случая взаимодействия нужно писать реализацию как и в случае с системами. Вот, например, как это выглядит для активной области игрока.

public class PlayerActionCollider : AbstractEntityCollider<PlayerActionCollision>
{
    private PlayerActionCollision collision = null;

    public override void SetEntity(Entity gameEntity)
    {
        if (gameEntity.Has<PlayerActionCollision>())
        {
            collision = gameEntity.Get<PlayerActionCollision>();
        }
    }

    protected override PlayerActionCollision GetCollision()
    {
        return collision;
    }
}

Выглядит очень просто, правда? Так в этом то и прелесть! Теперь я могу просто объявлять ТИПЫ коллизий и вешать вот такие ТРАНСЛЯТОРЫ одновременно с коллайдерами на трёхмерный объект в Unity. А системы уже сами разберутся, какой им тип нужен и что с ним делать.

Сколько ещё принципов ECS мы сейчас сломаем?

Мы, конечно, теряем один из козырей ECS: если мы сами пишем системы, мы чётко контролируем порядок их выполнения, а триггер из Unity может прийти условно в случайный момент времени, но в данном случае это простительно, потому что нам неважно, КОГДА придёт коллизия. Для одиночной игры это более чем простительно, для сетевой игры, возможно, стоило бы усложнить всё, заведя кэш пришедших триггеров за фиксированную единицу времени и синхронизировать эти триггеры за эту единицу времени. Думаю, есть множество решений, которые позволили бы нам вновь сделать всю модель строго детерминированной, поэтому я не считаю дальнейшее небольшое отсупление от ECS большим преступлением, так как оно поможет нам добиться невероятной гибкости, написав ГОРАЗДО меньше кода, чем если бы писали систему взаимодействия активных областей самостоятельно.

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

[свернуть]

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

Но и это ещё не всё…

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

Итак. В какой-то момент времени в логике моей игры создаётся вот такая упрощённая сущность игрока с активной областью.

        var player = world.NewEntity();
        var collision = new PlayerActionCollision();
        player.Replace(collision);

После чего, если опустить детали об интерфейсах , просто-напросто бросается событие

listener.CallOnEntityCreated(player);

/*В другой части кода*/

public class GameController {
    public static event Action<Entity> OnEntityCreated = delegate { };

    public void CallOnEntityCreated(Entity entity) {
        OnEntityCreated?.Invoke(entity);
    }
}

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

А дальше происходит небольшая магия. Я здесь с вашего позволения напишу сильно упрощённый код из обрывков и функционалов разных классов, который только даёт представление о процессе в целом. Это упростит вам понимание и сократит мне текст.

Итак, некий внешний контроллер ловит событие о создании сущности

public class GameViewController : MonoBehaviour
{
    void Subscribe()
    {
            GameController.OnEntityCreated += CreateView;
    }

    void CreateView(Entity gameEntity)
    {
            var viewId = gameEntity.Get<View>();
            var template = GetTemplate(viewId.Key);
            var view = Instantiate(template, gameWorld);
            view.SetEntity(gameEntity);
        }
    }

    public ViewContainer GetTemplate(string key)
    {
        /*Находим трёхмерный объект с навешенными на него трансляторами коллизий и вообще чем угодно*/
    }
}

Что здесь происходит поэтапно, после того как было поймано событие создание сущности. Если кратко, я делаю проверку, есть ли у сущности id отображения, которое надо создать. Я нахожу трёхмерный шаблон на сцене с таким id, создаю его и задаю этому шаблону сущность.

Шаблон как можно увидеть из кода имеет тип ViewContainter. Так что же там происходит…

public class ViewContainer : MonoBehaviour
{
    public void SetEntity(Entity entity)
    {
        List<AbstractView> views = new List<AbstractView>();

        views.AddRange(GetComponentsInChildren<AbstractView>());

        foreach (var view in views)
        {
            view.SetEntity(entity);
        }
    }
}

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

Более того, мне не нужно реализовывать задание отображения для каждого отдельной сущности или компонента из ECS. Достаточно объявить и повесить его на нужный мне шаблон. Более того, мне даже не нужно хранить отдельную ссылку на каждый из них. Всё сделает такой контейнер и всё по одному событию.

Итоги

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

Согласен, выглядит совсем не впечатляюще. Однако, подумайте, что за этим стоит? В дальнейшем я могу с минимальным количеством нового кода писать десятки таких триггеров, просто объявляя, что существует такой-то датчик и повесив его на цилиндрик! При этом все они будут работать независимо друг от друга, могут находиться вообще на десятках таких цилиндриков!

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

Не знаю, что вы думаете о моём предложенном решении проблемы (Однако очень-очень хотелось бы узнать, пожалуйста, пишите в комментарии), но мне оно кажется очень гибким, простым и изящным, потому что одновременно помогает получить всем шаблонам на сцене информацию о нужных им сущностях из одного места и с другой стороны оставляет логику, отображение и данные всё-таки сильно отвязанными друг от друга. Логикой продолжают заниматься системы ECS, данные всё так же хранятся в компонентах  ECS и единственный недостаток, что отображение знает о компонентах из ECS и ему приходится иногда передавать из Unity в компоненты данные из событий. Небольшая плата за то, чтобы объединить функциональную мощь инструментов Unity и невероятную эффективность и гибкость разработки на ECS. Как считаете?

Спасибо за внимание. Буду рад критике и любой обратной связи. Надеюсь, эта статья была вам полезна.

Это охуительно!