Обновить
0
0

Пользователь

Отправить сообщение

Напомнило про недавнее обсуждение ООП virtual inheritance vs DOD (ECS) из другого поста, может кому—то будет интересно взглянуть https://m.habr.com/en/company/piter/blog/524882/comments/#comment_22270190


Заметьте, что ECS решение позволяет оптимально (не смотря в цикле все объекты типа ITransaction) фильтровать по отдельным компонентам, что в теории важно для задачи фильтрации из статьи по ссылке.

Как решаете проблему иерархий в ECS UI?


Используете ли что то вроде компонента relationship как в https://skypjack.github.io/2019-06-25-ecs-baf-part-4/ ?

«Вы одну и ту же простую мысль не можете освоить» «игровой персонаж — очень сложная сущность»
— Я считаю проблему разделения интерфейсов проблемой, Вы нет. Вот и все.

«говно» «приводит в таблицу»
— В ней скорость доступа к кешу и RAM вне зависимости от выбранного подхода. Я не про сравнение якобы «ООП» с его ECS. Если не ошибаюсь, Вы уже сами однажды говорили, что runtime полиморфизм, доступ к RAM не бесплатен. Началось с того, что Вы сказали «достаточно что то оптимизировать, пройтись профилировщиком», но runtime полиморфизм можно оставить. Так runtime полиморфизм не бесплатен, его можно также убрать…
Также Вы говорили, что lookup не избежать. Тогда ecs бы в Unity, Dava Engine, и т.д. и не применяли. И таких презентаций не было бы никогда…

«А если там какая-нибудь механика а-ля «обратный урон», то будет 6-8 проходов, да?»
— Во первых, где, в событии нанесения урона или в игровом цикле? Если в событии нанесения урона, то скорость будет примерно одинакова и сравнивать нечего.
По поводу «механика а-ля» — всегда можно придумать крайний случай (мы начали с конкретного примера), например, есть случаи когда ecs плохо работает ajmmertens.medium.com/why-storing-state-machines-in-ecs-is-a-bad-idea-742de7a18e59

«проитерироваться сразу по всем аспектам всех транзакций» — это конечно очень странный крайний случай (т.е. Вы хотите создать фильтр сразу по тысячам возможных полей всех возможных типов транзакций?). В кеш поместятся только некоторые компоненты. Прирост скорости будет не значителен.

«N^2 ассимптотика.» — где и почему? Мы отложили обработку данных до след. прохода игрового цикла, а не обработали их во вложенном цикле лишний раз. Т.е. данные в любом случае нужно обработать и какая разница сейчас обработать или на N ms позже?

«интерфейс на тысячу строк кода» — конечно такой код найти сложно. Но давайте на более конкретном практическом примере минусы приведем
github.com/TrinityCore/TrinityCore/blob/d934824421c83598853487c5cc9e4cbb3c5d0006/src/server/game/Server/WorldSession.h#L1763
Код содержит «std::unique_ptr _battlePetMgr;», «std::unique_ptr _collectionMgr;» — сколько таких «Mgr» может быть? В теории немало, и все в одном месте. Это вполне может стать проблемой.
Аналогично с «bool m_playerSave;», «bool m_playerLogout;», и т.д. — приходится тщательно следить за полями класса чтобы его не раздуть. Это вполне может стать проблемой.
С ecs не задумываешься о содержимом интерфейса и разделении интерфейсов т.к. интерфейса вообще нет, разве не так? Вот, что я пытался донести.
«В нормальном ООП класс изначально никогда не будет огромным.» — игровой персонаж — очень сложная сущность, состоящая из многих частей. Его нужно или «разделять» на много частей, или делать огромным (если, цитирую, «интерфейс не придется делить»).

«В одном случае вы делаете лукапы всех нужных компонентов» — нам важен только случай с циклом, иначе сравнение не корректно делать. В случае с циклом — предложено использовать view. Там будет только один lookup, перед циклом. Все, больше lookup будут не нужны.
Если же Вы про опциональные компоненты, которые можно передать в параметрах entt::view, то нужно или менять выборку данных, или там уже нечего оптимизировать.

«view итерируется по данным, которые могут не лежать подряд.» — разные компоненты не лежат подряд. Но это не мешает им кешироваться. Так работает кеш. Два компонента кладутся в кеш И их соседи в массивах. Ведь одинаковые компоненты лежат подряд. Мы не говорим явно соседним элементам массивов попасть в кеш, но они будут там. Да, размер кеша ограничен, но для двух небольших компонентов с их соседями места хватит.

К слову, если нужно чтобы где-то еще данные шли подряд в цикле — меняем код, например, делаем обработку отложенной.

«т.е. таки шило на мыло?» — напоминаю про случай, когда нам нужно произвести фильтрацию по отдельным частям транзакции и мы хотим это сделать оптимально. Это уже достаточная причина перехода на ecs (или DOD) в данном примере (фильтрации транзакций).

«я уже объяснял почему на слайдах» — Какая разница какое там соломенное чучело вместо ООП, ведь на слайдах таблица с скоростью доступа к кешу интересна и т.д. Цитирую «В целом этот вопрос мы уже обсудили, т.е. что lookup не проблема в sparse set ecs т.к. есть view на гарантию наличия компонентов. И что полиморфизм не бесплатен.»

«У вас view идет по компонентам одной сущности… взаимодействуют две разные сущности.»
— я уже привел пример как избавиться от проблемы «две разные сущности» и как изменить код на цикл (с обработкой каждого набора сущностей отдельно) вместо события (обработать целый набор сущностей отложенно в след. итерации игрового цикла). Или Вы в событии «нанес урон» вне циклов пытаетесь сравнивать скорость доступа к кешу, но зачем? Вы ведь понимаете, что столь схожая производительность для одиночного события не сравнима вообще?

«замерьте стоимость поиска компонента у сущности.» — зачем? У нас не будет проблемы lookup. Делаем view по нужному набору компонент и нет проблемы lookup в местах в которых важна производительность (hot loop).

«так name и money принадлежат базовому виду транзакции, или какому-то из подвидов?» — Если базовому, то проблема очевидна (когда все в одном классе). Если подвидам — то нам понадобится два подвида — один с name, другой с money (иначе как сделать оптимальную выборку по ним отдельно?) + их комбинация name + money одновременно (иначе как сделать оптимальную выборку по ним вместе?). В итоге приходим к DOD и уходим от полиморфизма.

«у ITransaction не будет тысячи частей» — значит будут сотни интерфейсов, не так ли? Напоминаю, что нам нужно делать фильтрацию по отдельным частям и проблема разбиения на отдельные части (+ их комбинации для выборки очередного фильтра) осталась (чтобы не смотреть все типы транзакций в каждом фильтре).
«вы не смогли донести ни преимуществ ECS» — например, алгоритмическая оптимальность (сравнивая озвученные примеры) точно является преимуществом. Разве это не достаточная причина?

Пример кода с ECS, который алгоритмически оптимален:

#include <entt/entt.hpp>
#include <entt/entity/registry.hpp>
#include <entt/entity/helper.hpp>
#include <entt/entity/entity.hpp>
#include <entt/core/type_info.hpp>
#include <entt/core/type_traits.hpp>
#include <entt/entity/group.hpp>

#include <boost/algorithm/string.hpp>
#include <boost/current_function.hpp>

#include <cstddef>
#include <cstdint>
#include <sstream>
#include <string>
#include <type_traits>

// на практике проверят валидность данных иначе
const std::string kInvalidStr{"INVALID..."};

struct DebugName
{
  std::string debugName{kInvalidStr};
};

// ECS компонент - данные без логики
struct AccountNumber
{
  int accountId{-1};
};

// ECS компонент - данные без логики
struct AccountName
{
  std::string accountName{kInvalidStr};
};

// ECS компонент - данные без логики
struct MoneyAmount
{
  int moneyAmount{-1};
};

// Метка для любого перевода
using TransactionTag
  = entt::tag<"TransactionTag"_hs>;

// Метка для перевода, который успешно прошел через все фильтры
using FilteredTransactionTag
  = entt::tag<"FilteredTransactionTag"_hs>;

// Метка для перевода, который не прошел через некоторый фильтр
using WrongTransactionTag
  = entt::tag<"WrongTransactionTag"_hs>;

// Метка для входящего перевода
using DepositTag
  = entt::tag<"DepositTag"_hs>;

// Метка для исходящего перевода
using TransferTag
  = entt::tag<"TransferTag"_hs>;

// пометит `FilteredTransactionTag` переводы,
// которые успешно прошли через все фильтры
// пометит `WrongTransactionTag` переводы,
// которые не прошли через некоторые фильтр
using FilterAndMarkFn = std::function<void(entt::registry&)>;

// входные транзакции для примера,
// на практике могут поступать в любой момент
void createTestTransactions(entt::registry& registry)
{
  {
    entt::entity entity
      = registry.create();
    registry.emplace<DebugName>(entity, "Transaction1");
    registry.emplace<TransactionTag>(entity);
    registry.emplace<DepositTag>(entity);
    registry.emplace<MoneyAmount>(entity, MoneyAmount{130});
    registry.emplace<AccountNumber>(entity, AccountNumber{1});
    registry.emplace<AccountName>(entity, AccountName{"Peter"});
  }

  {
    entt::entity entity
      = registry.create();
    registry.emplace<DebugName>(entity, "Transaction2");
    registry.emplace<TransactionTag>(entity);
    registry.emplace<TransferTag>(entity);
    registry.emplace<MoneyAmount>(entity, MoneyAmount{101});
    registry.emplace<AccountNumber>(entity, AccountNumber{2});
    registry.emplace<AccountName>(entity, AccountName{"habrahabr"});
  }
}

void markGoodTransaction(
  entt::registry& registry
  , entt::entity entity
  , const char* debugMsg)
{
  DCHECK(!registry.has<WrongTransactionTag>(entity));

  // Метка для перевода, который успешно прошел через все фильтры
  registry.emplace_or_replace<FilteredTransactionTag>(entity);

  // debug log
  {
    DCHECK(registry.has<DebugName>(entity));
    DLOG(INFO)
      << "entity: "
      << registry.get<DebugName>(entity).debugName
      << " passed filter: "
      << debugMsg;
  }
}

void markBadTransaction(
  entt::registry& registry
  , entt::entity entity
  , const char* debugMsg)
{
  registry.remove_if_exists<FilteredTransactionTag>(entity);
  registry.emplace_or_replace<WrongTransactionTag>(entity);

  // debug log
  {
    DCHECK(registry.has<DebugName>(entity));
    DLOG(INFO)
      << "entity: "
      << registry.get<DebugName>(entity).debugName
      << " failed filter: "
      << debugMsg;
  }
}

void runMoneyAmountLessFilter(
  entt::registry& registry)
{
  auto view
    = registry.view<
        TransactionTag
        , MoneyAmount
        , DebugName
        , DepositTag // отфильтруются только Deposit транзакции
      >(entt::exclude<
          WrongTransactionTag
        >);

  for(const entt::entity& transactionEntity: view)
  {
    // debug проверки входных данных...
    {
      DCHECK(registry.valid(transactionEntity));
      DCHECK(view.get<DebugName>(transactionEntity).debugName != kInvalidStr);
    }

    MoneyAmount& moneyAmount
      = view.get<MoneyAmount>(transactionEntity);

    // тут могут быть доп. проверки входных данных...
    if(moneyAmount.moneyAmount < 0)
    {
      markBadTransaction(
        std::ref(registry)
        , transactionEntity
        , BOOST_CURRENT_FUNCTION);
      return;
    }

    const int kTestAmount = 42;
    if(moneyAmount.moneyAmount >= kTestAmount)
    {
      markBadTransaction(
        std::ref(registry)
        , transactionEntity
        , BOOST_CURRENT_FUNCTION);
      return;
    }

    markGoodTransaction(
      std::ref(registry)
      , transactionEntity
      , BOOST_CURRENT_FUNCTION);
  }
}

void runMoneyAmountGreaterFilter(
  entt::registry& registry)
{
  auto view
    = registry.view<
        TransactionTag
        , MoneyAmount
        , DebugName
      >(entt::exclude<
          WrongTransactionTag
        , TransferTag // отфильтруются только не Transfer транзакции
        >);

  for(const entt::entity& transactionEntity: view)
  {
    // debug проверки входных данных...
    {
      DCHECK(registry.valid(transactionEntity));
      DCHECK(view.get<DebugName>(transactionEntity).debugName != kInvalidStr);
    }

    MoneyAmount& moneyAmount
      = view.get<MoneyAmount>(transactionEntity);

    // тут могут быть доп. проверки входных данных...
    if(moneyAmount.moneyAmount < 0)
    {
      markBadTransaction(
        std::ref(registry)
        , transactionEntity
        , BOOST_CURRENT_FUNCTION);
      return;
    }

    const int kTestAmount = 42;

    if(moneyAmount.moneyAmount >= kTestAmount)
    {
      markBadTransaction(
        std::ref(registry)
        , transactionEntity
        , BOOST_CURRENT_FUNCTION);
      return;
    }

    markGoodTransaction(
      std::ref(registry)
      , transactionEntity
      , BOOST_CURRENT_FUNCTION);
  }
}

void runNameIgnoreCaseFilter(
  entt::registry& registry)
{
  auto view
    = registry.view<
        TransactionTag
        , AccountName
        , DebugName
        , TransferTag // отфильтруются только Transfer транзакции
      >(entt::exclude<
          WrongTransactionTag
        >);

  for(const entt::entity& transactionEntity: view)
  {
    // debug проверки входных данных...
    {
      DCHECK(registry.valid(transactionEntity));
      DCHECK(view.get<DebugName>(transactionEntity).debugName != kInvalidStr);
    }

    AccountName& accountName
      = view.get<AccountName>(transactionEntity);

    // тут могут быть доп. проверки входных данных...
    if(accountName.accountName == kInvalidStr)
    {
      markBadTransaction(
        std::ref(registry)
        , transactionEntity
        , BOOST_CURRENT_FUNCTION);
      return;
    }

    const std::string kTestName = "habrahabr";

    if(!boost::iequals(accountName.accountName, kTestName))
    {
      markBadTransaction(
        std::ref(registry)
        , transactionEntity
        , BOOST_CURRENT_FUNCTION);
      return;
    }

    markGoodTransaction(
      std::ref(registry)
      , transactionEntity
      , BOOST_CURRENT_FUNCTION);
  }
}

void runNameCaseSensitiveFilter(
  entt::registry& registry)
{
  auto view
    = registry.view<
        TransactionTag
        , AccountName
        , DebugName
      >(entt::exclude<
          WrongTransactionTag
        >);

  for(const entt::entity& transactionEntity: view)
  {
    // debug проверки входных данных...
    {
      DCHECK(registry.valid(transactionEntity));
      DCHECK(view.get<DebugName>(transactionEntity).debugName != kInvalidStr);
    }

    AccountName& accountName
      = view.get<AccountName>(transactionEntity);

    // тут могут быть доп. проверки входных данных...
    if(accountName.accountName == kInvalidStr)
    {
      markBadTransaction(
        std::ref(registry)
        , transactionEntity
        , BOOST_CURRENT_FUNCTION);
      return;
    }

    const std::string kTestName = "habrahabr";

    if(!boost::equals(accountName.accountName, kTestName))
    {
      markBadTransaction(
        std::ref(registry)
        , transactionEntity
        , BOOST_CURRENT_FUNCTION);
      return;
    }

    markGoodTransaction(
      std::ref(registry)
      , transactionEntity
      , BOOST_CURRENT_FUNCTION);
  }
}

// обработка входящих транзакций
void runFiltersOnTransactions(
  entt::registry& registry
  , std::vector<FilterAndMarkFn> enabledFilters)
{
  for(const FilterAndMarkFn& func: enabledFilters)
  {
    func(std::ref(registry));
  }
}

// обработка входящих транзакций
void processFilteredTransactions(
  entt::registry& registry)
{
  // получаем все транзакции, которые прошли через фильтр
  auto viewFiltered
    = registry.view<FilteredTransactionTag>();

  for(const entt::entity& entity: viewFiltered)
  {
    LOG(INFO)
        << "\n"
      << "==================="
      << "\n"
      << "entity passed through filters: "
      << registry.get<DebugName>(entity).debugName
      << "\n"
      << "===================";

    // транзакции, которые прошли через фильтр
    // можно сохранить в БД и т.д.
  }

  // получаем все транзакции, которые не прошли через фильтры
  auto viewWrong
    = registry.view<WrongTransactionTag>();

  for(const entt::entity& entity: viewWrong)
  {
    LOG(INFO)
        << "\n"
      << "==================="
      << "\n"
      << "entity failed filtering: "
      << registry.get<DebugName>(entity).debugName
      << "\n"
      << "===================";

    // транзакции, которые не прошли через фильтр
    // можно отправить тех. поддержке и т.д.
  }
}

void destroyAllTransactions(
  entt::registry& registry)
{
  // получаем все транзакции,
  // которые и прошли через фильтр и не прошли
  auto view
    = registry.view<TransactionTag>();

  // удалим все entity после обработки
  registry.destroy(view.begin(), view.end());
}

int main(int argc, char* argv[])
{
  // предметная область — "банковские транзакции."
  // необходимо фильтровать подозрительные транзакции

  // хранилище данных (ECS)
  entt::registry registry;

  createTestTransactions(std::ref(registry));

  // Каждый фильтр - отдельная часть логики, "система".
  // Оптимальное алгоритмически решение:
  // каждый фильтр имеет "свой отдельный цикл"
  // т.е. получает входные данные
  // сразу с нужным набором компонентов,
  // вместо просмотра всех возможных типов транзакций
  std::vector<FilterAndMarkFn> enabledFilters;

  enabledFilters.push_back(
    &runMoneyAmountLessFilter);

  enabledFilters.push_back(
    &runMoneyAmountGreaterFilter);

  enabledFilters.push_back(
    &runNameIgnoreCaseFilter);

  enabledFilters.push_back(
    &runNameCaseSensitiveFilter);

  // на практике обработчики
  // можно вызывать периодически
  // по мере поступления новых данных
  {
    runFiltersOnTransactions(
      std::ref(registry)
      , std::ref(enabledFilters));

    processFilteredTransactions(
        std::ref(registry));

    destroyAllTransactions(
      std::ref(registry));
  }

  return
    EXIT_SUCCESS;
}


Для того, чтобы мне внятно удалось рассказать о ECS, прошу прочитать (в частности, часть про entt::view) github.com/skypjack/entt/wiki/Crash-Course:-entity-component-system

«странного представления об ООП» — я конкретизировал «примеры кода с runtime полимормизмом» (уже даже слово ООП не использую для них)
Чем лучше?
Например, тем, что только предложенный ecs способен оптимально (оптимально алгоритмически, даже речь не про кеш процессора) решить задачу из статьи (если выбирать из предложенных ранее примеров кода с runtime полимормизмом). Достаточно сделать для каждого фильтра свой отдельный entt::view.

Все остальные примеры кода с runtime полимормизмом или избегали демонстрации «цикла фильтрации» или бежали в одном цикле по всем видам транзакций (со временем, возможно, тысячам типов транзакций), даже если фильтру нужна всего одна транзакция из миллионов (например, тип транзакции только с полями money и senderId).

Также вспомните обсуждение легкости модификации ecs в сравнении с традиционным подходом. Как сильно нужно изменить архитектуру чтобы начать оптимально решать задачу из статьи? (на практике, скорее всего, проблему производительности увидят когда код с традиционным подходом существует долго и оброс связанным функционалом). Скорее всего в кач-ве решения откажутся от runtime полиморфизма (перейдут на DOD, data-oriented design, подход — DOD и ECS уже не сравниваем) сделав для каждого фильтра свой цикл. Но тогда какой смысл в runtime полиморфизме останется?

habr.com/en/company/piter/blog/524882/#comment_22276078

Цитирую:
Также как в предложенном правильном ООП подходе оптимально фильтровать объекты (заметьте, статья о фильтрации)? Допустим, у нас миллионы объектов ITransaction. У ITransaction могут быть тысячи частей (name, money и т.д.). Проблема: мы хотим эффективно отфильтровать транзакции по name и money одновременно. При текущем подходе мы должны просмотреть миллионы транзакций и выбрать только те, у которых есть нужные части. Т.е. в идеале наш фильтр должен обработать только те транзакции у которых задан optional (тривиальный случай) или указатель (второй случай). Т.е. нам не нужны миллионы объектов ITransaction, а лишь несколько. Как эту проблему решить? Хранить массив указателей для каждой части отдельно (если optional задан), а также для комбинаций? А если нужно изменить значение в массиве и оптимизировать расход памяти, то использовать sparse set и т.д.? Или придется уйти от предложенного правильного ООП подхода?

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


«у вас и в ECS-аналоге std::string будет.» — разница в том, что интерфейс подключается не только логикой обработки. В случае ecs логика обработки — отдельный cpp файл (даже несколько файлов с изволированными частями логики — системами)


«сначала нанесли урон, а потом его посчитали?» — да, иначе производительность на 99.99% одинакова и сравнивать нет смысла. Мы же производительность обсуждали (кеш)?


«убедиться что они все корректно учтены и в правильной последовательности посчитаны…» — это одна из проблем ecs, я о ней упоминал. Одни проблемы решает, но другие создает.


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


«а кто говорит про отсутствие runtime полиморфизма» — тогда от использования custom allocator нет пользы. Что описано ранее в https://m.habr.com/en/company/piter/blog/524882/comments/#comment_22275970


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


  • Read from CPU L1 cache: 0.5ns
    ● Branch mispredict: 5ns
    ● Read from CPU L2 cache: 7ns
    ● Read from RAM: 100ns
    ● Read from SSD: 150’000ns
    ● Read 1MB from RAM: 250’000ns
    ● Send network packet CA->NL->CA: 150’000’000ns
    В целом этот вопрос мы уже обсудили, т.е. что lookup не проблема в sparse set ecs т.к. есть view на гарантию наличия компонентов. И что полиморфизм не бесплатен.

Также как в предложенном правильном ООП подходе оптимально фильтровать объекты (заметьте, статья о фильтрации)? Допустим, у нас миллионы объектов ITransaction. У ITransaction могут быть тысячи частей (name, money и т.д.). Проблема: мы хотим эффективно отфильтровать транзакции по name и money одновременно. При текущем подходе мы должны просмотреть миллионы транзакций и выбрать только те, у которых есть нужные части. Т.е. в идеале наш фильтр должен обработать только те транзакции у которых задан optional (тривиальный случай) или указатель (второй случай). Т.е. нам не нужны миллионы объектов ITransaction, а лишь несколько. Как эту проблему решить? Хранить массив указателей для каждой части отдельно (если optional задан), а также для комбинаций? А если нужно изменить значение в массиве и оптимизировать расход памяти, то использовать sparse set и т.д.? Или придется уйти от предложенного правильного ООП подхода?

в прод. пред. комментария
«потребует «заглянуть» в 6 обособленных локаций в памяти» против «В этом примере это будут 3-4 локации в памяти.»
— странно (вообще не сравнимо) сравнивать подходы по cache miss, когда они оба не cache friendly (не в hot loop). Вся идея избавления от cache miss была в представлении данных последовательно и обязательно в проходе по ним в цикле.
Как эту проблему решить — привел пример с ecs, где вводится компонент IncomingDamage.
Считать же обращения к разным участкам памяти в функции события «нанесен урон» (вне циклов) вообще есть ли смысл?

«мы можем написать простой аллокатор, и они сразу будут выделяться в нужных локациях» — как я понимаю, вы предлагаете ITransaction оставить с runtime полиморфизмом, но custom аллокатор расположит объекты типа «игровой юнит» в, например, std::vector последовательно. Если говорить о cache miss, то в представлении данных последовательно и обязательно в проходе по ним в цикле.
Но у нас без декомпозиции сущности останется проблема — мы не можем отфильтровать «игровой юнит» по, например, наличию оружия — нам нужно пройти всегда каждый «игровой юнит» и явно сделать проверку в цикле. Даже если некоторая логика работает с «игровым юнитом» у которого обязательно наличие оружия — мы все равно делаем выборку по всем «игровым юнитам» и фильтруем их все (возможно делаем цикл по миллионам сущностей вместо нескольких!).
Т.е. custom аллокатор не решил проблем с cache miss.

Схожий момент описан в habr.com/en/post/490500 «вы никогда не знаете, какие игровые объекты обладают нужными компонентами, и, следовательно, вы должны проходиться по всем ним в каждой системе и проверять наличие нужных компонентов у объекта»

«полиморфные объекты — например, оружие» — Также оружие в ecs представимо набором компонент. Скорее всего будет набор тегов «AxeWeaponType», «BowWeaponType»,… (набор тегов вообще без данных, просто для удобства — их можно и не использовать) и набор компонентов «WeaponDamage», «WeaponDurability»,… Т.е. действительно не уместно использование полиморфизма для оружия в ecs, как я и сказал ранее.
«сущность, определяющаяся компонентом Foo» — Как я понял проблема пересечения компонента Foo. Если Вы хотите «добавить другую, с компонентами Foo и Bar» и обрабатывать Foo + Bar разными системами без пересечения логики обработки, то можно добавить компонент notBar (одно из многих вариантов решения проблемы). Foo + Bar — одним способом. Foo без Bar — другим способом. Если Вам не по душе вариант с notBar, то привожу еще пример: можно поменять сущ-ую логику чтобы проверяла наличие Bar в простом условии.

«интерфейсы практически не влияют на время компиляции.» — например, интерфейс возвращает тип std::string, следовательно, нужно подключить . Forward declare для std::string тут не подойдет, не так ли? Если интерфейс на десятки тысяч строк (или подключает много других интерфейсов), то у него будет много зависимостей аналогично .

«юнит X наносит урон юниту Y.» — здесь проблема для кеша в том, что в одной «функции» Вы хотите обратиться к компонентам разных ecs entity (юнит X и юнит Y). При этом предположим, что Axe, и Bow, и Sword вполне могут быть отдельными компонентами т.к. полиморфные объекты не нужны в этом примере. «Что лучше с точки зрения кеша?» — сначала отдельно обработать компоненты юнита X, затем компоненты юнита Y. Вот как можно это сделать — в момент нанесения урона помечаем юнит X компонентом IncomingDamage{amount}, а юнит Y компонентом DealtDamage{amount}. У всех entity с IncomingDamage обновляем компонент health и компонент AnimationState. У всех entity с DealtDamage обновляем компонент score (таблица рейтинга или trustScore для проверки на читы и т.д.) и компонент AnimationState. Да, в момент нанесения урона единожды обратимся к юниту Y чтобы вычислить amount урона на основании наличия у него Axe, или Bow, или Sword. Но при очередной итерации игрового цикла обработаем entity с IncomingDamage последовательно, что даст прирост произв-ти. А нам нужно избежать cache miss обычно именно в hot loop т.е. при итерации игрового цикла.

Возможно еще сильнее можно оптимизировать, например, момент нанесения урона также сделать отложенным во времени и создать для него отдельный компонент (хоть это и не hot loop и не требует оптимизаций). Но считывание разных entity здесь уже нетривиально к оптимизации кеша, но решаемо через entt::group или registry.sort с multi_instance_storage github.com/skypjack/entt/commit/eb8e96f413a3e523bcd45a9e2dcf6fa04d07e7fc

«Или размещать их вручную в тех контейнерах, в которых они потом будут молотиться, только лучше, чем под капотом в ECS» — конечно создать DOD оптимально т.е. хранить сущности в отдельных контейнерах и без runtime полиморфизма. Только такой вариант с ECS нет смысла сравнивать. Я же предполагал, что предполагается наличие runtime полиморфизма для ITransaction (Иначе как их в контейнерах оптимально для кеша разместить? Никак, динамическая аллокация ведь). DOD вообще не сравним с ECS в плане произв-ти (фактически мы пришли к варианту ECS где нет разделения на компоненты).

«Как много компонентов нам нужно для описания игрового объекта?» — сколько угодно, добавление новых компонентов тривиально.

«Насколько сложной получится архитектура?» (ecs) — о ней и не придется задумываться при добавлении новых компонентов к entity. Даже о «разделении интерфейсов» не стоит вопрос.

«на наличие у сущности компонента надо делать lookup в sparse set» — делаем view гарантируя нужный набор компонент, избегаем проблемы lookup внутри цикла.

Также не все виды ECS используют sparse set как entt. Есть еще «Archetype-based ECS» (Unity DOTS, Flecs, Legion) и другие ajmmertens.medium.com/why-storing-state-machines-in-ecs-is-a-bad-idea-742de7a18e59

«Там в первых же двух слайдах нарушение примерно всех принципов ООП.» — речь была не про ООП, а про производительность и про cache (про «мм. Виртуальные вызовы говорите дорогие, да?»).
eao197
«Вы не поняли. Мне не нужно создавать кучу view с разными типами. У меня есть понятие «транзакция», за которым могут скрываться разные типы. У меня есть некий список транзакций, в котором будут транзакции разных типов.»

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

Antervis
«мм. Виртуальные вызовы говорите дорогие, да?»
— сарказм не понятен
Если мне не верите, то может разрабу Unity поверите
aras-p.info/texts/files/2018Academy%20-%20ECS-DoD.pdf
mobile.twitter.com/aras_p/status/1044656885100675072?lang=en

«показывает беглый обзор из которого вообще не понятно зачем всё это»
— это только один урок из серии уроков на том же канале
А на вопрос зачем отвечает
youtu.be/p65Yt20pw0g
Antervis

«если нам надо обрабатывать одни и те же компоненты по-разному в зависимости от того, каким сущностям они принадлежат» — если надо обработать по разному, то нужно как то различать типы сущностей (точнее не типы объектов различать, а наборы компонентов). В случае ecs будем различать по наличию разных компонентов. Например, если у сущности есть компонент owner, то компонент деньги обработает система 1. А если у сущности есть компоненты sender и fee, то компоненты деньги и fee обработает система 2.

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

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

«и как-то убираем эти компоненты из старых view, да» — зачем их убирать?

«не будет ли проще заменить кусок одной архитектуры на другую» — я не говорил про замену частей архитектуры. Представим, что у нас есть две реализации на ecs и без (где был пример с интерфейсом транзакции). Где проще менять код, вот что я спросил.

Про разделение интерфейсов — проблема осталась. Как много интерфейсов нам нужно для описания игрового объекта? Насколько сложной получится архитектура?

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

gameprogrammingpatterns.com/data-locality.html «In C++, using interfaces implies accessing objects through pointers or references. But going through a pointer means hopping across memory, which leads to the cache misses this pattern works to avoid.»

«в игровых движках решение использовать ECS принимается» — обычно на GDC говорят, что на ecs перешли или ради скорости, или ради лучшей архитектуры ПО. В GDC разраб Overwatch, например, говорил про архитектуру ПО www.youtube.com/watch?v=W3aieHjyNvw

«проверять, соответствует ли она нужному типу»


— entt::view даст на вход processor-y т.е. системе уже проверенный на соответствие тип (точнее массив со всеми типами, которые должны попасть в entt::view). Выбор нужных компонентов произойдет быстро, entt использует sparse set, на сайте автора entt есть подробности про реализацию entt.


У нас нет отдельного типа транзакции т.е. обьекта "транзакцияОбмена" или "транзакцияПополнения", вместо них есть компоненты или теги вида «ЛюбаяТранзакция», «Обмен», «Пополнение», «IdТранзакции», «Имя», «Сумма» и т.д.


каждому из processor-ов т.е. каждой системе не нужно скармливать коллекцию сущностей-транзакций (по одной за раз). Каждый processor это отдельный цикл.


Я ранее приводил пример кода с entt. Там вся фильтрация смешана в один цикл. Пример был простейшим чтобы сравнить с do_casewise (демонстрировалось, что логика сводится к набору условий без создания «сложной» архитектуры вроде do_casewise). Если этот цикл разделить на несколько используя разные view, то код станет еще лучше и быстрее. В частности эти части логики станут полностью отдельными, станут даже в разных файлах и только с подключением нужных компонентов в отдельном файле.


Добавление новой логики сведется к созданию cpp файла, подключению в нем только нужных компонентов (отдельных составных частей воображаемой транзакции), обработки этих компонентов (возможно с добавлением новых компонентов или меток). От созданной системы «наружу» понадобится только функция вида processMyTitle(entt::registry&), которую нужно добавить в очередь обработчиков


Для примера есть серия видео уроков с использованием entt https://youtu.be/D4hz0wEB978
Intro to EnTT (ECS) | Game Engine series

Это еще что за «система»?


В случае entt — это entt::view или entt::group.

Система (возможно не одна) и выполнит какую-то операцию для вычисления status-а.

В ООП есть сущность «транзакция». В ECS нет сущности «транзакция», есть id сущности с набором связанных компонент (композиция), некоторые из которых (обычно в сочетании) мы условно считаем «транзакцией» и можем как-то обработать.

Но обработать при этом можно любой набор компонент изолированно.

Система (возможно не одна) должна выполнить какую-то операцию для вычисления status-а. Но ей не нужно «знать про все возможные транзакции», тем более понятия транзакции нет.

Одна система обновит компонент status только если есть TransactionTag (любая транзакция)

Другая система после обновит компонент status только если есть одновременно и TransactionTag и DepositTag и NameComponent (Deposit транзакция у которой есть Name)

Другая система после обновит компонент status только если есть одновременно SenderComponent и MoneyComponent (заметьте, про TransactionTag или DepositTag вообще не известно этой системе)
«у вас в любом случае появился объект, который не вписывается в старую логику обработки, и вам в любом случае придется её дорабатывать.»
— в случае ECS не нужно при каждой правке (добавлении нового компонента) изменять ITransaction (God Object). Даже логику обработки транзакций не обязательно менять (например, компонент NetworkId не повлияет никак на логику обработки транзакций, его даже в hpp подключать не придется).

При этом логика обработки транзакций отлично разделяется на части используя разные entt::view, каждая из которых работает с своим (изолированным) набором компонент.

Давайте сравним добавление нового компонента с

// Фильтр конечно же тривиален:
class NamedFilter final : public IFilter {
public:
    bool matches(const ITransaction& txn) const noexcept override {
        auto named = txn.named_extension();
        return named && (named->name_of_customer() == txn.name_on_account());
    }
};


Допустим был только ecs компонент Name и для него был view с логикой фильтрации.

Добавляем новый ecs компонент Money для `сущности транзакции`. Создаем для него свой view с отдельной логикой фильтрации (заголовочный файл компонента Name даже не подключаем в этот фильтр).

«При этом вы еще и хотите по максимуму переиспользовать уже существующую логику.» — логика обработки компонента Name не меняется при добавлении Money.

Если же нужно обработать совокупность Name и Money — создаем новый view с отдельной логикой фильтрации. Нам даже не нужно создавать уникальный тип фильтра `NameMoney(тут еще куча слов)...Filter`

Сравниваем с `NamedFilter`. Он должен обработать транзакцию даже если в ней нет `Name`, это раз (не делает выборку по `Name`). Он знает о том, что есть `txn.money()`, это два (в выборке не только `Name`). Также все типы данных, что есть в `ITransaction` подключатся `NamedFilter` даже если не используются, что плохо для времени компиляции.

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

Даже если один и тот же компонент должен обработаться по разному в зависимости от условия, то и эта проблема легко решается (или условием прямо в логике, или добавлением entt::tag — метки, здесь означающей истинность условия).

«принципу разделения интерфейсов» — т.е. начать разделять ITransaction (God Object)? Как много разделений интерфейсов придется сделать? Не вернемся ли к проблеме AxedDwarf спустя много разделений? А если в полученной иерархии с ITransaction придется вдруг сделать серьезные изменения, то не будет ли проще сделать правку с ecs архитектурой?

«нет, мы проверяем поддерживает ли сущность интерфейс.» — в случае ecs это был бы компонент name, а не интерфейс.

«И да, можете уточнить, дорого ли проверить наличие у сущности компонента?» — не дорого, ниже код

«мне кажется вы преувеличиваете стоимость виртуальных вызовов и недооцениваете накладные расходы от ECS» — разница есть, ниже код

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

int transactionEntity1 = 0;
int transactionEntity2 = 1;

const int NULL_ENTITY = 0;

std::vector<NameComponent> names;
// Конечно нормальная реализация ecs без `*.reserve(MAX_TRANSACTIONS);`.
names.reserve(MAX_TRANSACTIONS);
names[transactionEntity1] = NameComponent{"John"}; 
names[transactionEntity2] = NULL_ENTITY; 

std::vector<GuidComponent> ids;
// Конечно нормальная реализация ecs без `*.reserve(MAX_TRANSACTIONS);`.
ids.reserve(MAX_TRANSACTIONS);
ids[transactionEntity1] = NULL_ENTITY; 
ids[transactionEntity2] = GuidComponent{"GUID-123-456"}; 

std::vector<int> transactions;
// Конечно нормальная реализация ecs без `*.reserve(MAX_TRANSACTIONS);`.
transactions.reserve(MAX_TRANSACTIONS);
transactions[0] = transactionEntity1;
transactions[1] = transactionEntity1;

for(const int& transactionEntity: transactions)
{
  if(names[transactionEntity] != NULL_ENTITY) std::cout << names[transactionEntity].name;

  if(ids[transactionEntity] != NULL_ENTITY) std::cout << ids[transactionEntity].id;
}


В кеш попадают компоненты из names и ids по которым идут последовательно. В идеале компоненты из names и ids содержат только нужные данные без логики, чтобы поместиться в кеш. См. `entt::group` для примера реализации.

В обычном подходе без ecs в кеш попадают последовательно transactions, а для них уже ищут names и ids которые идут не последовательно + добавляют виртуальные вызовы.

«иметь универсальный инструмент» — кому как нравится. ecs тоже можно назвать универсальным инструментом (он позволяет делать все то же, нет ограничений по возможностям). А иметь ограничения по code style (данные и логика отдельно) — вполне нормально. И наличие ecs обычно не означает, что в проекте нельзя применять никакие другие техники (зависит от решаемых задач).

«подавляющее большинство из них будет иметь намного больший опыт работы именно с ООП подходом» — смотря в какой индустрии. Например, в разработке игр на Unity ECS распространен. К тому же ECS вполне реально изучить (например, за дней 6 выходных), это не месяцы за книгами.

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

«не согласен, и я уже объяснил причины» — т.е. у традиционного подхода нет вообще никаких проблем решаемых ecs, т.е. ecs вообще не нужен?

«status — это зафиксированное значение. Тогда как оно может быть вычисляемым. И на этом, как я понимаю, на ECS ставится крест.»
Как раз нет, создается отдельная система, которая обновляет компонент status.


Да, порядок выполнения систем (вычисления логики) в ecs нужно строго контролировать. Для кого то это минус такого подхода.


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


«Если выполнилась, то попадает в список завершенных, если оказалась в состоянии "выполняется", то идет во второй, если результат неудачный, то идет в третий. В ECS подходе чтобы сделать это вам придется знать про все существующие типы транзакций.» — логику обработки транзакций разделяем на отдельные системы, в строгом порядке следования. Каждая система по завершении добавляет определенные метки или компоненты к сущностям. Следующая система работает только с теми, сущностями, которые специально помечены. При этом каждая из систем не знает про все виды сущностей т.е. берет только интересный ей компонент, игнорируя другие компоненты сущности (даже не подключая hpp этих компонентов).


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


В случае игры системы обычно выполняются в цикле, периодически. Но можно их вызывать и не в цикле. Можно и по условию «обработана ли транзакция» и т.д. В нашем случае функция должна вернуть три списка, значит в функции вызываются обрабатывающие транзакции системы (можно и не вызывать их, тогда результат пред. обработки вернет), затем результат обработки анализируется. Если код асинхронный для созранения транзакций, то можно вернуть результат в promise. В общем, проблемы не вижу, способов решения много.


Как я понял «знать про все существующие типы транзакций» подразумевает знать про все возможные компоненты которые можно добавить к сущности транзакции… Это в частности и позволяет избегать ecs, не так ли?

Прежде всего, спасибо за пример кода.


«Humanoid содержит все нужные данные.» заменим на ITransaction. Тогда в статье (где было про AxedDwarf) говорят о проблемах:


  1. "мы расширяем интерфейс для всех вариантов, хотя пользоваться им будут не все виды транзакций"
    Менять интерфейс нужно и в тривиальном, и в остальных случаях.
    Да, в статье другой «ужасный ООП», но проблема остается (изменение слишком многих сущностей перетекает в изменение слишком многих частей в одной сущности)


  2. ITransaction может иметь очень много частей. Т.е. код вида «virtual class INamedTransaction* named_interface() const noexcept = 0;» в ITransaction будет занимать десятки тысяч строк кода. (Игровой персонаж из серии статей может быть очень сложным по структуре, хоть всю структуру и не перечислили).
    Кому то это может и не понравиться, а кто то только так и пишет код с файлами в десятки тысяч строк (индивидуальный стиль кода у всех разный).


  3. Заметьте, что в не тривиальном случае «named && (named->name_of_customer() == txn.name_on_account()» имеет проблему схожую с ecs, которую назвали проблемой динамической типизации в runtime. Часть compile time проверок перешла в runtime, как и с ecs. Т.е. мы фактически проверяем есть ли у сущности компонент.


  4. Хоть статья не про производительность, но DOD часто выбирают еще из за избежания проблемы «еще виртуальный вызов хоть и не бесплатен, но всё еще дешевле dynamic_cast'а».
    Также для обработки сущностей у которых есть часть name нужно проверить все сущности, что не оптимально (хотя можно решить эту проблему храня указатели на такие сущности отдельно, но ecs это из коробки решает).


  5. Хоть статья и не про легкость архитектуры, но ecs позволяет не задумываться о «Так делать не советую — вы не сэкономите столько на бойлерплейте». Т.е. следовать очень простым рекомендациям и лишний раз не переусложнять код — само сабой разумеющееся для ecs. Но многие другие архитектуры страдают от проблемы «нож слишком острый и им легко перезаться» т.е. проблем вида «mixin'ов через CRTP». Например, для команд из тысяч людей с разным уровнем опыта это может быть очень важно.


  6. Хоть статья и не про разделение данных и логики, но для меня DOD стиль кода больше подходит (и возможно для многих других). Подробнее тут https://m.habr.com/en/company/piter/blog/524882/comments/#comment_22274100



Т.е. в ITransaction десятки тысяч частей на много строк кода, а используем, например, только пару частей во многих объектах (зависит от постоянно меняющегося дизайна игры)


Т.е. в статье про AxedDwarf говорят, что традиционный подход имеет некоторые проблемы. И ecs (и не только) позволяет решить некоторые проблемы. Вы с этим не согласны?


Многие проблемы конечно индивидуальны, зависят от требований к конкретному проекту. Пример: Если для текущего проекта в наследовании (а именно runtime полиморфизме) нет проблем, это не означает, что в другом проекте от этого проблем не будет.


Конечно новый подход не значит, что новые проблемы не появятся (отлично решает одни проблемы, но не идеален). https://stackoverflow.com/questions/58596897/what-are-the-disadvantages-of-the-ecs-entity-component-system-architectural-pa

Также про то, что у транзакции теперь есть виртуальный метод «virtual retry() = 0;»
Проблема еще в личном стиле разработки ПО, кому как нравится.
Мне, например, нравится разделять данные и логику т.е. DOD подход (в частности, ECS) я предпочитаю ООП иерархиям.
Заменим транзакцию на пирожок для наглядности.
Вы добавли в сущность пирожок метод «купить», преполагая, что все пирожки на продажу.
Это не плохо и не хорошо, это зависит от решаемой задачи и стиля кода.
Я бы лично не стал добавлять в каждую сущность метод «virtual describe(logger &) = 0;» просто потому, что в моей программе все объекты должны логироваться.

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


"разработчик никак не защищен от попытки получить компонент, которого нет" — можно создать view который гарантирует наличие компонента. Да, это будет с проверками в runtime. Да, можно опечататься (как и без ecs) и вернуть пустой view. Вам не нравится именно вероятность опечатки при создании сущности из отдельных компонентов. Я не вижу в этом большой проблемы (достаточно избегать опечаток и писать тесты).


Одно это уже ставит жирный крест на "ecs лучше ООП". — говорилось «лучше» именно про легкость изменения логики. Лучше во всем конечно не может быть. Также уточню, что сравнение идет с ООП подходом, в котором есть иерархия (runtime полиморфизм).


Представим, что фильтр — комбинация из тысяч настроек, которые задает пользователь в runtime. Под ООП походом я подразумеваю код с объектами вида FilterNameAgeRaceDepositGender (отдельный объект под настройки). Разве ecs хуже в таком случае? Динамическая типизация здесь сама напрашивается.


ECS лучше если нужно изменять код… «Это ваше личное и бездоказательное оценочное суждение, разбивающееся о суровую реальность»
Это не только мое личное мнение. Немало статей говорят про гибкость ECS к изменениям.
Advantage of ECS
Here are advantages of using ECS in programming:


You can create shorter and less complicated code.
Very flexible Emergent behaviour.
It provides an architecture for 3D and VR development.
Enables scripting by behavior by non-techies.
It helps you to divide your monolithic classes.
Easy refuse and compossibility.
An easy option for unit testing and mocking.
It allows you to build a VR application in terms of complexity.
Substitute components with mocked components at runtime.
It helps you to extend new features.
ECS is a friendly method for parallel-processing and multi-threading,
Helps you to separate data from functions that act on it
Provide better flexibility when defining objects by mixing with reusable parts.
It offers a clean design using the decoupling, encapsulation, modularization, reusability methods.
https://www.guru99.com/entity-component-system.html


Вы просили пример сравнения с хорошим ООП кодом:
https://adventures.michaelfbryan.com/posts/ecs-outside-of-games/


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


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


«Как я понимаю, аналогичная процедура для ECS должна будет модифицирироваться каждый раз при добавлении нового типа транзакции» — нет, не должна. Например, если новый тип транзакции (т.е. добавился опциональный компонент IsBlockchain) фильтруется по уже имеющейся логике, например, по компоненту SenderName (иначе мы получаем совершенно новый тип фильтра и разницы с ООП нет — везде нужна правка кода).
ecs код легко разбивается на отдельные системы, которые могут взаимодействовать, например, через добавление меток к данным после обработки (вида TransactionCompleted)
Даже метод прохода по списку транзакий (где вызов фильтрации) легко разбивается на отдельные случаи (или несколько общих случаев, как захотите)

По-моему это решение превосходит решение из статьи.


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

Приведу пример для
«добавлять новые реализации уже определенных ранее интерфейсов не меняя ничего в уже существующем коде»
Но как это можно сделать в ecs (там понятие интерфейса фильтрации заменяется на набор систем с логикой фильтрации).
У нас есть логика фильтрации т.е. это интерфейс фильтрации, не так ли?
Мы хотим добавить новый фильтр.
В примере кода (выше) с entt фильтр — все, что имеет тег filter.
Мы можем добавить новый фильтр не меняя существующий код других фильтров, не так ли?


Если код примера разделить на отдельные системы с отдельными view для каждого фильтра, то, станет очевиднее изолированность частей логики (систем) в ecs. Т.е. разные фильтры (интерфейсы фильтрации) не связаны, легко изменяются и т.д.

1

Информация

В рейтинге
Не участвует
Зарегистрирован
Активность