Независимые компоненты, минимально знающие друг от друге, умеющие выполнять только поставленную цель — это невероятно круто! О начальных зарисовках и идеях я писал в предыдущем посте.

С тех пор довелось столкнуться с новыми проблемами, которые вынудили расширить и модифицировать систему.

Все достоинства и недостатки, а также небольшой накопленный опыт я рассмотрю на примере очень простой 2D физики с гравитацией, силами и столкновением. Добро пожаловать под кат, друзья!

Введение


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

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

Текущее состояние


С момента последней истории прошло не так много времени, но за несколько вечеров понял, что есть ряд задач, которые система пока что решать не умеет.

Итак, давайте вкратце пройдёмся по тому, что уже умеем:
  • создавать независимые компоненты;
  • хранить компоненты в контейнерах и получать к ним доступ;
  • использовать interaction слой, обеспечивающий «общение» между контейнерами;
  • накладывать предусловия на наличие тех или иных компонентов в контейнерах, участвующих в методах присоединения (Attach) и взаимодействия (Interaction).

Новые проблемы


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

И всё-таки, каких же шестерёнок не хватает для работы на полной мощности?

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

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

Помимо этого были мелкие недочёты, которые, скорее всего, остались как есть.

Предусловия


Очень быстро расскажу про предусловия, т.к. решение весьма простое. Новое перечисление:
public enum OnValidationFail
{
  Throw,
  Skip
}

И всё! Теперь его можно использовать так:
[AttachHandler(OnValidationFail.Skip)]

Пересылка сообщений


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

Отправлять можно сообщения, которые в общем виде описываются очень просто:
public class Message<T> : Message
{
  public T Sender { get; private set; }

  public Message(T sender)
  {
    Sender = sender;
  }
}

Для получения я использовал вновь тот же подход (уже в третий раз) с использованием рефлексии, который даёт мне очень навязчивую лёгкость и гибкость.

Всякий компонент может в теле класса описать методы, принимающие в качестве параметра любой Message. Сделать его действительным получателем помогает атрибут [MessageReceiver]. Например:
public class SimpleComponent : Component
{
  [MessageReceiver]
  public void MessageFromFriend(DetachMessage<FriendComponent> message)
  {
    // Эй, ты, дружеский компонент хотят удалить из контейнера.
    // Кто ты без него?
    _container.RemoveComponent(this);
  }

  [MessageReceiver]
  public void MessageFromSomeComponent(DetachMessage<SomeComponent> message)
  {
    // Обычный компонент, тебя не касается, что его удаляют.
    // Когда-то касалось, но уже нет.
    //_container.RemoveComponent(this);
  }
}

Т.к. очевидно наличие таких стадий жизненного цикла компонента как добавление и удаление, шустренько написал protected методы-утилиты в классе Component, которые позволяют наследникам при желании очень кратко оповестить всех «соседей» о текущем состоянии.
protected void SendAttachMessage<TSender, TContainer>
                       (TSender sender, TContainer container) 
                        where TSender : Component
{
   SendMessage(
     new ComponentAttachMessage<TSender, TContainer>(sender, container));
}

protected void SendDetachMessage<T>(T sender) where T : Component
{
   SendMessage(new ComponentDetachMessage<T>(sender));
}

На этом с основными «крупными» доработками (всего-то одна, по факту) всё. Теперь расскажу про непосредственно область применения не только нового функционала, но и, вообще, системы.

Задача с гравитацией


На начальном этапе разработки возникла следующая тривиальная задача: сделать хотя бы так, чтобы на сцене можно было поставить два вида «коробок»:
  • те, что падают, но умеют приземляться на другие;
  • те, что не умеют падать; на них и приземляются первые.

Звучит она очень просто, но не понятно, под силу ли свежеспроектированной системе?

Общий эскиз решения разверну в логическую последовательность, без привязки к реализации:
1. Пусть есть игровые объекты, которые можно расположить на сцене по координатам X, Y и отрисовать.
2. Введём концепцию твёрдого тела — свойства игрового объекта, которое позволяет задавать ему скорости изменения соответствующих координат.
3. В принципе, для движения у игрового объекта есть всё: координаты, механизм, который даёт возможность их менять. Не хватает толчка, иными словами, силы.
4. Что-то ещё? Уметь двигать объекты по сцене — это прекрасно, но нам ещё нужно как-никак научить их сталкиваться. Для этого определим понятие оболочки объекта, ответственной за столкновения

Игровые объекты


Следуя вышеописанному эскизу, выделим первородную сущность — игровой объект, который будет, по совместительству, контейнером компонентов.
public class GameObject : ComponentContainer
{
}

Не всякий игровой объект можно разместить на сцене, поэтому введём наследника — объект сцены.
public class SceneObject : GameObject
{
   public float X { get; set; }
   public float Y { get; set; }

   public event Action<TimeSpan> Updating;
   public event Action<TimeSpan> Drawing;

   public void Draw(TimeSpan deltaTime)
   {
     Updating(deltaTime);
   }

   public void Updated(TimeSpan deltaTime)
   {
     Drawing(deltaTime);
   }
}

Примечание: чтобы не писать много ненужного кода, некоторые детали реализации я буду опускать, т.к. самое важное — это идея, а то, что не проверил Updated и Drawn на null или не подписал пустышки — второстепенный вопрос.

Твёрдые тела


Так как твёрдые тела — это свойства игровых объектов (могут у них быть или не могут), их можно инкапсулировать в компоненты. Давайте посмотрим код:
public class RigidBody : Component
{
   private SceneObject _sceneObject;

   private float _newX;
   private float _newY;

   public float VelocityX { get; set; }
   public float VelocityY { get; set; }

   [AttachHandler]
   public void OnSceneObjectAttach(SceneObject sceneObject)
   {
     _sceneObject = sceneObject;
     
     _sceneObject.Updating += OnUpdate;
     _sceneObject.Drawing += OnDraw;
   }

   private void OnUpdate(TimeSpan deltaTime)
   {
      // Здесь мы будем вычислять новые координаты объекта в зависимости 
      // от скорости.
      if (VelocityX != 0.0f)
         _newX = (float) (_sceneObject.X + 
                         (VelocityX * deltaTime.TotalSeconds));

      if (VelocityY != 0.0f)
        _newY = (float) (_sceneObject.Y + 
                        (VelocityY * deltaTime.TotalSeconds));
   }

   private void OnDraw(TimeSpan deltaTime)
   {
     // На этапе отрисовки двигаем объект.
     _sceneObject.X = _newX;
     _sceneObject.Y = _newY;
   }
}

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

Силы


Вот мы и добрались до последнего ключика к «вечному» движению. Сила — это ведь, по существу, просто что-то, что меняет скорость твёрдого тела. Осталось понять, какую скорость менять и как.

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

Что удалось придумать?

Давайте посмотрим на так называемую «единичную окружность», где значения на осях oX и oY представляются на диапазоне от [-1 до 1].

Допустим, синяя стрелка — это вектор действия силы на тело, а центр окружности — центр тяжести тела. И, скажем, условная величина самой силы — 100.
Если сила изменяет скорость объекта, то она должна менять его скорость как по вертикали, так и по горизонтали, а насколько — ответят нам зелёная и оранжевая линии. Они равны, приблизительно, по 0.9 и 0.5 соответственно.
Следовательно, скорость объекта по вертикали изменится на 100 * 0.9, а по горизонтали — на 100 * 0.5.
Закреплю эти очень простые умозаключения кодом:
public class Force
{
   public int Angle { get; set; }
   public float Power { get; set; }

   public void Add(RigidBody rigidBody)
   {
     // Переведём градусы в радианы (так надо).
     var radians = GeometryUtil.DegreesToRadians(Angle);

     var horizontalCoefficient = GetHorizontalCoefficient(radians);
     var verticalCoefficient = GetVerticalCoefficient(radians);

     rigidBody.VelocityX += Power * horizontalCoefficient;
     rigidBody.VelocityY += Power * verticalCoefficient;
   }

   private float GetHorizontalCoefficient(double radians)
   {
     var scaleX = Math.Cos(radians);
     if (Math.Abs(scaleX) <= 0) return 0;
     return (float) scaleX;
   }

   private float GetVerticalCoefficient(double radians)
   {
     // Здесь мы умножаем на -1, т.к. в системе координат игры ось oY инвертирована.
     var scaleY = Math.Sin(radians) * -1;
     if (Math.Abs(scaleY) <= 0) return 0;
     return (float) scaleY;
   }
}

Хочу отметить, что класс Force и не компонент, и не контейнер. Это примитивный тип, который в последствии будут переиспользоваться другими.

Чувствуется, что не хватает кого-то, кто мог бы накладывать силу на твёрдые тела, верно?

Гравитация


То, что больше всего вызвало трудностей. Хотелось сделать что-то вроде гравитационного поля, но неясно было, откуда оно берётся и как применяется на объекты. Компонент это или контейнер?

В конце концов, собравшись с мыслями, решил, что компонент. Компонент, который добавляется всякому объекту, присутствующему в гравитационном поле.
Задача компонента — каждый цикл обновления накладывать силу под углом 270 градусов (низ) с величиной 9.83.

[RequiredComponent(typeof(RigidBody))]
public class Gravitation : Component
{
  private SceneObject  _sceneObject;
  private RigidBody      _rigidBody;

  private Force _gravitationForce = new Force(270, 9.83);

  [AttachHandler(OnValidationFail.Skip)]
  public void OnSceneObjectAttach(SceneObject sceneObject)
  {
    _sceneObject = sceneObject;
    _rigidBody = GetComponent<RigidBody>();

    _sceneObject.Updating += OnUpdate;
  }

  private void OnUpdate(TimeSpan deltaTime)
  {
    _gravitationForce.Add(_rigidBody);
  }

  // На случай, если гравитация есть, а твёрдого тела - нет.
  [MessageReceiver]
  public void OnRigidBodyAttach(
                    ComponentAttachMessage<RigidBody, SceneObject> message)
  {
     _rigidBody = message.Sender;
     _sceneObject = message.Container;

     _sceneObject.Updating += OnUpdate;
  }

  [MessageReceiver]
  public void OnRigidBodyDetach(ComponentDetachMessage<RigidBody> message)
  {
    _sceneObject.Updating -= OnUpdate;
  }
}

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

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

Столкновения


Осталась самая интересная часть любого физического движка, на которой зиждется мироздание.

Из эскиза решения задачи следует, что оболочка, ответственная за столкновения — это тоже свойство объекта, как и твёрдое тело. Поэтому не задумываясь определяем её как компонент.

Далее я прикинул, что оболочки бывают разные: самые простые — прямоугольные, но есть ещё круглые, которые тоже несложно считать, и, наконец, оболочки произвольной формы с N вершинами.

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

Опишем абстрактный класс оболочки:
public abstract class Collider : Component
{
  protected SceneObject SceneObject;

  [AttachHandler]
  public OnSceneObjectAttach(SceneObject sceneObject)
  {
    SceneObject = sceneObject;
  }

  // Набор методов для столкновения.
  public abstract bool ResolveCollision(Collider collider);
  public abstract bool ResolveCollision(BoxCollider collider);
}

Основная идея заключается в том, что для просчёта столкновений будет использован метод ResolveCollision, принимающий параметр базового типа Collider, однако внутри этого метода каждая конкретная версия оболочки будет перенаправлять вызов на другой метод, умеющий работать с конкретным типом. Балом заправляет ad-hoc полиморфизм.

Покажу на простом примере прямоугольника:
public class BoxCollider : Collider
  {
    public float Width { get; set; }
    public float Height { get; set; }

    public RectangleF GetBounds()
    {
      return new RectangleF(SceneObject.X, SceneObject.Y, Width, Height);
    }

    public override bool ResolveCollision(Collider collider)
    {
      return collider.ResolveCollision(this);
    }

    public override bool ResolveCollision(BoxCollider boxCollider)
    {
      var bounds = GetBounds();
      var colliderBounds = boxCollider.GetBounds();

      if (!bounds.IntersectsWith(colliderBounds)) return false;

      // Примечание: мы на самом деле меняем значения в переменной bounds.
      // Т.к. объект двигается слишком быстро, он может не просто "столкнуться"
      // с другим, но и пересечь границу последнего на какую-то величину.
      // В этом куске кода мы двигаем его обратно (пока что только по вертикали).
      bounds.Intersect(colliderBounds);
      if (bounds.Height <= 1f) return false;

      SceneObject.Y -= bounds.Height;

      return true;
    }
  }

С основными проблемами разобрались: теперь можно создавать игровые объекты, наделять их различными свойствами, двигать, сталкивать. Не хватает последней маленькой детали: где просчитывать столкновения?

Сцена


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

В этом поможет тот самый слой Interaction, который до сих пор не находил применения в решении проблемы гравитации. Опишем класс Interactor, который отвечает за просчёт столкновения между двумя объектами сцены:
public class CollisionDetector : Interactor
{
  [InteractionMethod]
  [RequiredComponent("first", typeof(Collider))]
  [RequiredComponent("second", typeof(Collider))]
  public void SceneObjectsInteraction(SceneObject first, SceneObject second)
  {
    var firstCollider = first.GetComponent<Collider>();     
    var secondCollider = second.GetComponent<Collider>();

    if (!firstCollider.ResolveCollision(secondCollider)) return;

    // Здесь видно, что иногда необходимо действовать в зависимости от наличия компонента в контейнере, как ни крути.
    first.IfContains<RigidBody>(TryAddInvertedForce);
    second.IfContains<RigidBody>(TryAddInvertedForce);
  }

  private void TryAddInvertedForce(RigidBody rigidBody)
  {
    var lastAddedForce = rigidBody.ForceHistory.Last();

    var invertedForce = new Force
    {
      Angle = GeometryUtil.GetOppositeAngle(lastAddedForce.Angle),
      Power = rigidBody.GetKineticEnergyY()
    };

    invertedForce.Add(rigidBody);
  }
}

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

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

Сам Interactor вызывается со сцены в методе обновления, который выполняется каждый фрейм.
В механизме просчёта столкновений пока что использую грубый O(n2) метод.
// Код класса сцены.
  public void Update(TimeSpan deltaTime)
  {
    foreach (var sceneObject in _sceneObjects)
    {
      sceneObject.Update(deltaTime);
      foreach (var anotherSceneObject in _sceneObjects)
      {
        if (ReferenceEquals(sceneObject, anotherSceneObject)) continue;
        sceneObject.Interact(anotherSceneObject).Using<CollisionDetector>();
      }
    }
  }

Заключение


Честно признаться, небольшая структура физического движка, на самом деле, казалась сперва не такой очевидной. Особенно столкновения и гравитация. Я точно уверен, что вряд ли физические движки пишутся именно так, но было интересно попробовать и «прощупать» самому.

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

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

Всем спасибо!

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


  1. xGromMx
    20.11.2015 13:55

    Может я ошибаюсь, но акторная модель вроде так же работает?


  1. ATOMOHOD
    21.11.2015 03:06

    Завязанность везде и всюду на рефлексии не даст такому фреймверку права на жизнь в реальных условиях


    1. JoshuaLight
      22.11.2015 12:46

      В свою защиту могу сказать лишь то, что эта рефлексия состоит из двух частей, каждая из которых поддаётся оптимизации:
      1. Construction time рефлексия: в конструкторе компонента выгребаются методы, помеченные атрибутами.
      2. Runtime рефлексия: после присоединения компонента к контейнеру выполняется MethodInfo.Invoke, что также замедляет приложение.

      Способы решения:
      1. Object Pool и статика.
      2. Как вариант.

      Мне не понятно, почему «везде и всюду», ведь использование рефлексии очень и очень прозрачно: известны абсолютно все места и способы её использования.


      1. Bonart
        23.11.2015 02:40

        Специальные атрибуты жестко привязывают ваши собственные классы к новому фреймворку и мешают использовать в качестве компонентов классы чужие.
        Посмотрите на любой современный DI-контейнер — там это делается проще и изящнее.


        1. JoshuaLight
          23.11.2015 11:31

          Спасибо за ответ. Мне, вот, интересно узнать: почему именно атрибуты привязывают классы, а не наследование? Эти атрибуты бессмысленны, если класс не наследуется от Component. Они не существуют отдельно сами по себе. Поэтому, если уж и говорить насчёт «привязанности», то за это ответственны абстрактные типы Component, Interactor и ComponentContainer. Но я не вижу в этом проблемы. Для решения задачи совместимости с иными семействами компонентов существует шаблон проектирования «Адаптер».

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

          P.S. Насчёт Dependency Injection: можно поконкретнее узнать, о каком фреймворке речь, и что там реализовано проще и изящнее?

          P.S.S. Технически, это не фреймворк, а всего лишь попытка реализовать компонентно-ориентированный подход «в домашних условиях».


          1. Bonart
            23.11.2015 12:06

            Мне, вот, интересно узнать: почему именно атрибуты привязывают классы, а не наследование?

            О, слона-то я и не приметил.
            По факту атрибутов уже достаточно для жесткой привязки как минимум к сборке.
            А наследование — это не просто жесткая привязка, а заливка ног цементом.
            Но я не вижу в этом проблемы. Для решения задачи совместимости с иными семействами компонентов существует шаблон проектирования «Адаптер».

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

            И это был отнюдь не комплимент.
            Насчёт Dependency Injection: можно поконкретнее узнать, о каком фреймворке речь, и что там реализовано проще и изящнее?

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

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


            1. JoshuaLight
              23.11.2015 15:31

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

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

              Сам пост выполнен, скорее, в повествовательном стиле, где описывается личный опыт и идеи.

              Всё же, по поводу привязок выскажусь:

              О, слона-то я и не приметил.
              По факту атрибутов уже достаточно для жесткой привязки как минимум к сборке.
              А наследование — это не просто жесткая привязка, а заливка ног цементом.

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

              Что насчёт ASP.NET MVC (как пример)? Так или иначе, используя его, вы "подвязываетесь" под определённую архитектуру (естественно, супер-расширяемую, фабрики контроллеров, модули и т.д.).

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

              К слову, вы писали в первом сообщении:
              Посмотрите на любой современный DI-контейнер — там это делается проще и изящнее.

              Прошу прощения, но вы случайно не посчитали, что я тоже пытался решить те же проблемы, что и DI-контейнер? Просто, это многое бы объяснило.

              Оффтоп: Если атрибуты и наследование устарели, то меня интересует, что сейчас "в тренде"?


              1. Bonart
                23.11.2015 17:06

                Прошу прощения, но вы случайно не посчитали, что я тоже пытался решить те же проблемы, что и DI-контейнер? Просто, это многое бы объяснило.

                Не совсем. Просто DI-контейнер как универсальный инструмент композиции прекрасно подходит и для решения ваших задач.
                Эдакий архитектурный фундамент, который предлагается использовать для более эффективного решения задач. Из разряда: «сел и пиши».

                Так для «сел и пиши» чем меньше ограничений на классы клиента — тем лучше. В идеале — код бизнес-классов клиента можно вообще не менять, достаточно правильно сконфигурировать движок для их эксплуатации.
                Если атрибуты и наследование устарели, то меня интересует, что сейчас «в тренде»?

                Конфигурации и конвенции. Т.е. не классы пользователя подлаживаются под фреймворк, а фреймворк использует классы в соответствии с настройками и соглашениями.
                Посмотрите как устроен Caliburn.Micro


                1. JoshuaLight
                  23.11.2015 18:25

                  Хорошо, я вас понял. Подумаю в эту сторону и обязательно учту все замечания, очень полезно получилось.