Разработка пользовательского интерфейса с использованием архитектуры ECS (Entity-Component-System) вместо традиционного наследования
Данная статья является продолжением выступления Евгения Захарова на летней конференции С++ Russia, где была описана разработка пользовательского интерфейса с использованием архитектуры ECS (Entity-Component-System) вместо традиционного наследования и часть устройства UI в World of Tanks Blitz.
В своем докладе Евгений подробно останавливается на том, какие принципы создания фреймворков для UI используются сегодня в мире, а также рассказывает, как можно подружить ECS и UI, и какие плюсы и минусы от этого можно получить в итоге.
В этой статье на небольшом примере UI в World of Tanks Blitz Евгений показывает, в чем большой плюс архитектуры ECS в UI.
Перед изучением статьи советуем посмотреть видео доклада.
Реализация радиального отображения прогресса
Иногда бывают ситуации, когда нужно показать радиальный прогресс, т.е. круг, который заполняется на 360 градусов, когда прогресс какой-либо операции завершился на 100%. В нашей игре World of Tanks Blitz такое используется, например, в отображении прогресса закачки DLC на андроидах.

Чтобы создать такую кнопку у нас уже есть часть готового функционала, а именно:
отображение картинки в контроле при помощи texture component;
раскрашивание картинки конкретным цветом тоже при помощи texture component.
Чего не хватает, так это радиального раскрашивания определенного сектора с указанной дугой по принципу часовой стрелки. Для этого мы создали еще одну компоненту, которая хранит процент радиальной заливки как раз для таких случаев: RadialProgressComponent.
Теперь пойдем по шагам того, как такое можно реализовать.
Во-первых, мы берем картинку для прогресса:

Она белая и имеет прозрачный фон. Нам понадобится два контрола с этой же картинкой: один будет служить фоном, а второй мы зальем желтым цветом (саму картинку) и будем "резать" пропорционально нашему прогрессу.
Мы создаем контрол и добавляем на него texture-компоненту с указанием данной картинки и белого цвета заливки.

Получилось так:

Далее мы добавляем еще один контрол сверху, ему добавляем такую же texture-компоненту с тем же путем к картинке, но желтым цветом.

Получилось вот так:

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

Получилось вот так:

А теперь, чтобы не томить, мы покажем, как это устроено в коде (система рисования радиального прогресса – RadialProgressSystem), а в конце покажем видео с тем, что у нас получилось.
Чтобы систематизировать кастомный способ отрисовки текстур в контролах, ранее была создана компонента ClipPolygonComponent – она хранит в себе двухмерный полигон, по которому рендер-система отрисовывает обрезанную текстуру. Потому задача RadialProgress-системы сводится к правильной подготовке двухмерного полигона в зависимости от значения прогресса.
void RadialProgressSystem::RegisterControl(Control &control)
{
    auto *pieClippable = control.GetComponent<RadialProgressComponent>();
    if (pieClippable && control.GetComponent<TextureComponent>())
    {
        pieClippable->MarkAsDirty();
        this->registeredControls.insert(&control);
    }
}Это колбэк, который вызывается, когда в сцене появляется контрол с TextureComponent и RadialProgressComponent. Тут запоминаются все контролы в контейнер внутри системы, чтобы обработать их в главной функции системы, в которой происходит работа – Process.
void RadialProgressSystem::UnregisterControl(Control &control)
{
    this->registeredControls.erase(&control);
}Это колбэк, который вызывается при обратной операции, когда контрол с указанными компонентами исчезает из сцены.
Далее функция Progress обрабатывает все контролы, которые не были обработаны. Для хранения статуса "обработан" есть поле dirty в RadialProgressComponent. Обработке будут "подвергаться" только те контролы, которые имеют RadialProgressComponent с флагом dirty равным true, который после обработки будет выставляться в false.
void RadialProgressSystem::Process(float elapsedTime)
{
    for (Control *control : this->registeredControls)
    {
        auto *pieClippable = control->GetComponent<UIRadialProgressComponent>();
        if (!pieClippable->IsDirty())
        {
            continue;
        }
        auto *polygon = control->GetComponent<ClipPolygonComponent>();
        if (!polygon)
        {
            ReportError(control, "You need UIClipPolygonComponent for UIRadialProgressComponent");
            continue;
        }
        auto *textureComponent = control->GetComponent<TextureComponent>();
        if (textureComponent != nullptr && textureComponent->GetSprite() != nullptr)
        {
            Polygon2 &polygonData = polygon->GetPolygon();
            polygonData.Clear();
            const Vector2 imageSize = textureComponent->GetSprite()->GetSize();
            const Vector2 pivot = CalcPivot(pieClippable);
            const Vector2 center = imageSize * pivot;
            const float progress = pieClippable->GetProgress();
            float startAngle = pieClippable->GetNormalizedStartAngle();
            float endAngle = pieClippable->GetNormalizedEndAngle();
            const float currentAngle = Interpolation::Linear(startAngle, endAngle, 0, progress, 1);
            const float width = pivot.x > 0 ? center.x : imageSize.x;
            const float height = pivot.y > 0 ? center.y : imageSize.y;
            const float initAngle = std::atan(width / height);
            polygonData.AddPoint(center);
            polygonData.AddPoint(CalcPointOnRectangle(startAngle, center, imageSize));
            int direction = startAngle < endAngle ? 1 : -1;
            float startOffset = direction > 0 ? 0 : PI_2 + pieClippable->GetAngleBias();
            float squareAngle = startOffset + direction * initAngle;
            const float directedStartAngle = direction * startAngle;
            const float directedEndAngle = direction * endAngle;
            const float directedCurrentAngle = direction * currentAngle;
            float directedSqureAngle = direction * squareAngle;
            const float doubledInitAngle = initAngle * 2.f;
            Vector<Vector2> squares {
                Vector2(imageSize.x, 0),
                Vector2(imageSize.x, imageSize.y),
                Vector2(0.f, imageSize.y),
                Vector2(0.f, 0.f)
            };
            int i = 0;
            while (directedSqureAngle < directedEndAngle)
            {
                if (directedSqureAngle < directedCurrentAngle && directedSqureAngle > directedStartAngle)
                {
                    int squareIndex = direction > 0 ? i % 4 : 3 - i % 4;
                    polygonData.AddPoint(squares[squareIndex]);
                }
                i++;
                int switcher = i % 2;
                squareAngle += direction * (PI * switcher - Sign(switcher - 0.5f) * doubledInitAngle);
                directedSqureAngle = direction * squareAngle;
            }
            polygonData.AddPoint(CalcPointOnRectangle(currentAngle, center, imageSize));
            pieClippable->ResetDirty();
        }
    }
}Теперь, когда мы указываем прогресс в RadialProgress-компоненте, то получаем именно то, что мы хотели – радиальный прогресс с тем значением, которое мы указали. Визуально это выглядит вот так:

Заключение
В данном примере мы показали, как устроен UI в World of Tanks Blitz – крайне нетипично и при этом очень гибко. И это только маленькая часть всех наших систем. В чем очевидный плюс архитектуры ECS в UI – это четкое разделение логики и данных и, как следствие, возможность создания композиции (любого набора и логики) и компонент в совершенно любом контроле.
          
 
blockspacer
Как решаете проблему иерархий в ECS UI?
Используете ли что то вроде компонента relationship как в https://skypjack.github.io/2019-06-25-ecs-baf-part-4/ ?
fnc12
нет, мы не используем компоненту для этого. У нас у сущности есть поле
parentвсегда. Это указатель на родительскую сущность. У корня он равенnullptrbelator
А как у вас вообще реализована ECS? Это просто набор компонентов или как сделано у Unity — с архетипами? Насколько ваша реализация cache-friendly? Если ходить в parent по указателю — велик шанс нарваться на cache miss. Родитель должен где-то рядом располагаться.
fnc12
у нас было что-то похожее на архетипы ранее. Благодаря этому у нас была очень долгая вставка компонент, но константное получение их при этом. Мы заменили на среднюю сложность вставки и среднюю сложность получения и суммарно выиграли от этого. Насчет cache-friendly вопрос слишком абстрактен. Поле
parentвсегда указывает на валидную сущность, либо равно нулю.