Привет, хабровчане! Хочу рассказать о своей системе UI, которую я написал для своего игрового движка, на которой делаю редактор для него же. Вот такой:
![](https://habrastorage.org/getpro/habr/upload_files/6e0/c7e/af2/6e0c7eaf25a6a54cdb49debd3eed9f89.png)
Итак, вот уже в который раз я начал писать движок, и твердо решил что в этот раз сделаю все хорошо и правильно. Одним из этих «хорошо и правильно» является WYSIWYG редактор а-ля Unity3D. К слову сказать, до этого у меня уже был опыт разработки подобных редакторов, на Qt. И к тому моменту я уже понимал, что задача стоит не простая, если я хочу сделать по-настоящему хороший редактор. И для этого нужна очень хорошая и гибкая система UI, в которой я буду очень хорошо разбираться и знать всякие тонкости. Ведь в таком редакторе будет очень много кастомных виджетов, контролов и т.п. Поэтому не должно быть компромисса между качеством редактора и возможностями UI системы.
При этом в самом движке стояла задача сделать хорошую систему UI. Т.к. движок для 2D игр, и в таких играх бывает очень много интерфейсов (бизнес-логика игр, чаты, кланы, инвентари и т.д.), то и система UI в нем должна быть гибкой и удобной.
«Что ж, почему бы не убить двух зайцев одновременно?» — подумал я.
У меня был простой рендер спрайтов и текста, простая система обработки ввода, из которой можно было получать информацию о мышке и нажатых клавишах, и всякие утилиты-обвязки вспомогательных систем движка. В общем какую-то простую картинку вывести я мог. В последствии это все очень сильно изменилось и поросло архитектурными хитростями. Думаю не стоит описывать всю их эволюцию, а сосредоточиться лишь на финальном виде.
Сейчас система UI непосредственно держится на следующих вещах:
-
Рендер
-
Система обработчиков кликов
-
Иерархия сцены
-
Система UI-виджетов
Остановимся на каждом по отдельности.
Рендер
Все UI-элементы рисуются с помощью двух примитивов: спрайт и текст. Они в свою очередь рисуются мешами из треугольников. То есть каждый спрайт или текст — это набор треугольников на плоскости с наложенной на них текстурой.
![](https://habrastorage.org/getpro/habr/upload_files/677/549/7f6/6775497f6c646fe1c157af2c8a4beb59.gif)
Так же для отрисовки UI необходимо отсечение по прямоугольнику — внутри всяких списков, полей ввода и т.д.
Рендеринг треугольников происходит с применением батчинга (группировки): меши, попадающие на отрисовку с одинаковой текстурой и отсечением, группируются в большие меши, с целью оптимизации отправки данных на видеокарту. Если каждый маленький меш рисовать отдельно (draw call), то видеокарта будет простаивать, пока процессор готовит команды для нее, что и как рисовать. Поэтому на старых графических API меши группируются, дабы уменьшить кол-во запросов к видеокарте.
Спрайт
В простом виде спрайт — это картинка, имеющее какое-то положение на экране. По сути два треугольника, объединенные в квадрат с наложенной текстурой. Однако такой простой спрайт имеет недостаток: если его начинать растягивать, то он начинает плыть, очертания форм нарушаются и скругленные края кнопки уже становятся овальными.
![](https://habrastorage.org/getpro/habr/upload_files/4db/ac3/7b9/4dbac37b9e27e0f59403ddb7679eae58.gif)
Чтобы избежать этого используются 9-slice спрайты. Это те же спрайты, но разделенные на 9 частей:
![](https://habrastorage.org/getpro/habr/upload_files/100/640/782/100640782204cfc3c06b092766a16589.png)
В таком спрайте, при изменении размера, углы всегда остаются оригинального размера, зоны по краям растягиваются в одном направлении, а зона посередине тянется во всех направлениях. Это позволяет использовать компактные спрайты для кнопок и других прямоугольных примитивов, а так же растягивать как угодно без потерь качества и формы объектов.
![](https://habrastorage.org/getpro/habr/upload_files/435/c42/48f/435c4248fb7c11b5c0c18aa3f8a58e57.gif)
Уже здесь есть нюанс — как должен вести себя спрайт, если его размер стал меньше размера углов? Размеры углов (и сторон) должны пропорционально уменьшаться. Это позволяет вписывать любой спрайт в любые размеры, а так же более-менее адекватно соединять скругленные части. Последнее оказывается очень полезно при отрисовке прогресс-баров с закругленными краями.
![](https://habrastorage.org/getpro/habr/upload_files/096/df9/502/096df9502528a395d53720729e5ca002.gif)
Кроме этих двух режимов есть и другие, которые применяются реже, но иногда нужны:
Много гифок с режимами спрайта
![Простой спрайт, просто растягивается Простой спрайт, просто растягивается](https://habrastorage.org/getpro/habr/upload_files/c80/278/9ce/c802789ceac308d068ee46924ba7eb5b.gif)
![9-slice спрайт, растягивается пропорционально 9-slice спрайт, растягивается пропорционально](https://habrastorage.org/getpro/habr/upload_files/df8/0ac/7f4/df80ac7f4d21931c37bfc50f7a49b969.gif)
![Показывает прогресс круговым заполнением Показывает прогресс круговым заполнением](https://habrastorage.org/getpro/habr/upload_files/ba7/cf6/4fc/ba7cf64fc1bfd326274b3a96ee1f463f.gif)
![Вертикальное заполнение Вертикальное заполнение](https://habrastorage.org/getpro/habr/upload_files/33d/2fe/935/33d2fe935d9556d25dc04f932dc9b1a2.gif)
![Горизонтальное заполнение Горизонтальное заполнение](https://habrastorage.org/getpro/habr/upload_files/8d7/ea7/fd7/8d7ea7fd779a927204b0c7ca0e77c7df.gif)
![Повторение текстуры Повторение текстуры](https://habrastorage.org/getpro/habr/upload_files/e88/3dc/85f/e883dc85f5a0d287789f2c2fec583340.gif)
![Сохранение соотношения сторон и вписывание Сохранение соотношения сторон и вписывание](https://habrastorage.org/getpro/habr/upload_files/899/fee/860/899fee86025cd598a11a5c418a37cdfa.gif)
Генерация меша для одного спрайта — довольно недорогая операция. Однако, если спрайтов тысячи, это становится дорого. Поэтому меши спрайтов кешируются и обновляются только при изменении трансформации спрайта или других его параметров.
Код спрайта можно посмотреть здесь.
Текст
Как уже описано выше, текст — это набор треугольников.
![](https://habrastorage.org/getpro/habr/upload_files/c86/8c9/fa7/c868c9fa7cd7445c1dccf0bf3644756f.png)
Здесь два основных вопроса:
-
Получить текстуру с глифами символов
-
Сформировать меш
Глифы можно получить двумя путями:
-
Нарисовать самому в графическом редакторе или утилите, и сгруппировать в одной текстуре
-
Рендерить глифы через FreeType из векторного шрифта
У меня в основном используется второй подход, т.к. он гибче и удобнее. Во-первых не нужно заботиться о размере шрифта, можно рендерить глифы любого размера. Во-вторых на глифы можно накладывать эффекты, такие как тень, обводка, градиент или что-то свое. В-третьих глифы рендерятся по мере необходимости, и динамично размещаются в текстуре-атласе. Если в подходе с заранее отрисованными глифами появляется проблема с китайскими языками, где тысячи иероглифов, то в подходе динамичной отрисовки глифов мы можем рендерить только нужные глифы.
Итак, мы так или иначе получили текстуру, в которой лежат глифы для нашего текста. Теперь необходимо сформировать его меш. Эта задача делится на два этапа:
-
Формирование посимвольного описания расположения символов
-
Формирование меша по сгенерированному ранее описанию расположения
Самое интересное конечно же первая часть. Здесь на входе поступает прямоугольник, в который необходимо вписать текст. А так же его форматирование: высота символов, цвет, выравнивание по горизонтали и вертикали, тип обрезания. Ну и конечно же сам текст. Алгоритм построчно размещает символы, начиная с точки соответствующей выравниванию. Учитываются межсимвольные расстояния, кернинг, межстроковые расстояния. Так же, если включен режим обрезания через окончание на «…», проверяется влезет ли следующий символ и производится замена на три точки.
Вторая часть по сути формирует пары из двух треугольников под каждый символ, назначая текстурные координаты соответствующие символу из текстуры.
Здесь важный нюанс — строить новый текст очень дорого и затратно. Поэтому если текст передвинулся на Х пикселей в сторону, текст не перегенерируется заново, достаточно сместить вершины меша на Х пикселей.
Код текста можно посмотреть здесь.
IRectDrawable
Чтобы унифицировать отрисовку спрайта и текста, у них есть общий интерфейс IRectDrawable. Он отображает некую сущность, которая описывается прямоугольником (а точнее матрицей трансформации 2х3), которая может быть нарисована, может быть включена или выключена, и имеет цвет. Сам IRectDrawable наследуется от IDrawable (сущности которая может быть нарисована) и Transform (описывает трансформацию объекта матрицей 2х3, или Basis).
Отсечение
На первый взгляд довольно простая задача, отдал в Graphic API прямоугольник для отсечения, затем сбросил, и все готово. Все и правда так просто, однако в случае вложенных UI виджетов расчет итогового прямоугольника может быть нетривиален. Например, у нас есть прокручиваемая область, у которой есть видимая зона, вне которой все отсекается. Внутри нее может быть еще одна прокручиваемся зона или поле ввода текста, внутри которого тоже есть отсечение. И иногда эти вложенные зоны отсечения выходят за пределы более верхнеуровневого отсечения, и приходиться рассчитывать пересечение этих зон. Самому делать это вручную довольно сложно, нужно постоянно обращаться вверх по иерархии объектов и пытаться узнать у них их зону отсечения, если такая вообще есть.
Но все это можно решать с помощью стека прямоугольников отсечения на уровне системы рендера. Алгоритм прост: мы добавляем прямоугольник в стек, обновляя текущий прямоугольник отсечения. Текущий получается как пересечение предыдущего и нового. И добавлять можем сколько угодно. Затем извлекаем прямоугольники по одному, восстанавливая промежуточные посчитанные прямоугольники.
То есть сначала родительский элемент в начале отрисовки добавляет прямоугольник отсечения, без каких-либо расчётов. Затем рисуются виджеты-дети, которые добавляют в начале отрисовки свои отсечения. На каждом уровне текущий прямоугольник пересчитывается с учетом предыдущего. Затем, в конце отрисовки каждый виджет извлекает из стека свой прямоугольник отсечения.
Получается все просто — каждый виджет заботиться только о своем отсечении, но в итоге получается что они взаимосвязаны.
Забегая вперед скажу еще что интерфейс IDrawable в момент отрисовки запоминает текущий прямоугольник отсечения, чтобы затем корректно отметать клики по этим объектам вне зоны отсечения.
Обработка ввода
Итак, у нас есть возможность что-то нарисовать. Теперь хочется сделать это интерактивным — дать возможность потыкать курсором в эти рисуемые сущности.
На входе у нас есть информация о курсоре (нескольких точнее, для touch-screen), событиях нажатия курсора, движения, отпускания. А так же рисуемые в определенном порядке сущности. Наша задача понять в кого нажимает пользователь.
Здесь есть пара нюансов. Во-первых, как описано выше, отсечение. Если спрайт отсекается, то нажатие по его отсеченной части не должно срабатывать. Во-вторых — это перекрытие спрайтов друг другом. Очевидно нажиматься должно то, что нарисовано выше всех остальных под курсором, собственно что и видит конечный пользователь.
Для обработки кликов от пользователя есть специальный интерфейс CursorAreaEventsListener. В нем есть солидная пачка виртуальных функций, которые вызываются на реакцию пользователя. Здесь клики, вход и выход курсора, для разных курсоров и клавиш мышки. А так же есть система, которая занимается правильной рассылкой этих сообщений EventsSystem, основной частью которой является CursorAreaEventListenersLayer.
Как же правильно понять в кого именно попал курсор? Не отсечен ли этот объект под курсором? Не перекрыт ли другим? Обычно это решается на уровне иерархии. Допустим, пришел ивент о том что пользователь нажал кнопку мыши. Начинаем с верхнеуровневых объектов, спрашиваем не попал ли курсор в них, не попал ли в отсеченную часть. Если попали — идем ниже по иерархии, спрашиваем то же самое у детей, и ниже и ниже, пока не доберемся до последней, которая скажет что в нее попали, и уже у нее вызовем соответствующий ивент на нажатие курсора.
У этого подхода есть ряд минусов. Элементов могут быть тысячи, и на каждый кадр перебирать иерархию довольно дорого. Плюс дети должны быть всегда внутри родителей, иначе ребенок не получит сообщение, т.к. не попали в родителя. Или же придется отказаться от этой оптимизации и просчитывать вообще все дерево, что еще дороже. Здесь еще ряд минусов, такие как сложная реализация «сквозных» ивентов, например когда клик должна принять и прокручиваемая область, и кнопка внутри нее.
На самом деле для обработки ввода у нас есть много информации по ходу отрисовки. Ведь по сути они очень связаны, порядок отрисовки определяет перекрытие, и прямоугольник отсечения так же рассчитывается в стеке.
Поэтому я применил другой подход, нежели обход иерархии. При отрисовке сущности, которая обрабатывает сообщения курсора, она сообщает системе ввода что сущность отрисовалась в данный момент. Система ввода хранит линейный список таких отрисованных сущностей, в порядке их отрисовки. Плюс, как уже писал выше, запоминаем актуальный прямоугольник отсечения для каждой сущности. Так же у каждой такой сущности есть виртуальный метод проверки попадания точки в эту сущность. Этот метод очень простой и работает без учета всяких отсечений и т.п. По сути просто проверка попадания в прямоугольник для IRectDrawable.
![Поочередная отрисовка с перекрытием Поочередная отрисовка с перекрытием](https://habrastorage.org/getpro/habr/upload_files/d21/7cd/819/d217cd819a6cd629f90c991f0d5eb0b8.gif)
Далее, после отрисовки сцены, происходит «трассирование» каждого курсора. Мы идем с конца списка отрисованных сущностей, от последнего отрисованного к первому, проверяем что курсор попадает в прямоугольник отсечения, проверяем попадание в геометрию. Если все возвращает истину — курсор попадает в эту сущность, алгоритм останавливается. Если нет, берем следующий с конца и снова проверяем.
Далее, имея данные о состоянии нажатия курсора, о том какая сущность была трассирована на предыдущем кадре, мы можем формировать сообщения о кликах, входе или выходе курсора.
Эта система работает быстро и стабильно при большом количестве объектов. При этом пользователю достаточно перегрузить функцию попадания точки в геометрию (что в большинстве случаев даже не нужно, если это IRectDrawable), и в момент отрисовки вызвать функцию IDrawable::OnDrawn(). Далее система автоматически рассылает нужные сообщения.
Иерархия сцены
В своем движке я выбрал типичный подход с иерархичной сценой с компонентами. Базовый элемент — Actor, примитивный объект, у которого есть имя, трансформация, набор компонент, определяющих его поведение, и список дочерних Actor’ов. Они строятся в древовидную структуру на сцене. Сцена — это просто список Actor’ов.
Так же Actor’ы могут быть разных типов. То есть это просто базовых интерфейс, от него можно унаследоваться и реализовать свою логику. Это можно сделать и с помощью компонент, но в отличие в том, что компонента не является главной сущностью, она вспомогательная, и их может быть несколько на Actor’е.
![](https://habrastorage.org/getpro/habr/upload_files/f10/1e8/710/f101e8710c7e2342245b94c08ca83443.png)
Система UI строиться на базе этой иерархии сцены. То есть все виджеты, все элементы UI — это наследники Actor’а. Но у них есть один общий интерфейс Widget.
Widget
Это самый элементарный «кирпичик» системы интерфейсов. Если Actor описывается простой трансформацией, включающей в себя позицию, поворот, размер и скейл, то положение Widget’а описывается более сложной структурой и уже зависит от родителя.
Положение Widgеt’а описывается структурой WidgetLayout, которая является наследников от ActorTransform. То есть это надстройка над обычными позицией, поворотом, размером и скейлом Actor’а.
Эта надстройка включает в себя относительные якоря и смещения от них. Якоря задаются в процентах и располагаются относительно родителя. Затем к этим якорям прибавляются смещения в пикселях. Это позволяет делать адаптивную верстку. Задавая якоря правильным образом можно добиваться растягивания дочерних элементов вслед за родителем, или наоборот «приклеивать» детей к определенным точкам, углам или центру. Аналогичный принцип применяется в Unity GUI.
![](https://habrastorage.org/getpro/habr/upload_files/508/4d6/9f6/5084d69f64012b7cee3972b98c300686.gif)
WidgetLayer
Помимо обычной структуры дочерних Actor’ов, внутри Widget’а хранится список слоев WidgetLayer. Слой — это очень упрощенный Widget, который имеет на борту IRectDrawable и аналогичную WidgetLayout’у структуру с описанием адаптивного положения слоя WidgetLayerLayout.
Слоев может быть очень много, у слоев могут быть дочерние слои. За счет упрощения достигается оптимизация, т.к. этими более легковесными сущностями гораздо быстрее и проще оперировать.
Их вполне можно заменить дочерними Widget’ами, но это нарушит целостность понятия Widget’а. Например, кнопка воспринимается как кнопка, а не набор дочерних картинок. Окно воспринимается как контейнер других виджетов, и не хочется чтобы фон, заголовок и другие вспомогательные вещи были перемешаны с содержимым окна.
Поэтому слои — это графическая часть Widget’а, отделенная от дочерних Widget’ов.
WidgetState
Интерфейс не должен быть статичен и как-то реагировать на действия пользователя. При наведении курсора кнопка должна подсвечиваться, а при нажатии становиться темной. Списки должны разворачиваться, курсор ввода текста мигать и т.д. Желательно чтобы это все происходило плавно и красиво.
Для этого в Widget’е есть анимированные состояния — WidgetState‘ы. Они работают по принципу простой стейт-машины. Каждое состояние — это некая анимация, которая имеет два положения вкл. и выкл. При переводе во вкл. анимация проигрывается вперед, при переключении в выкл. анимация проигрывается назад. Причем, анимация может пойти в другую сторону не дождавшись окончания. За счет этого достигается быстрый отклик на действия пользователя.
Сами анимации вещь довольно сложная и для них можно написать отдельную статью. Но суть у них простая — они могут изменять любой параметр любого объекта внутри иерархии Actor’ов, включая Widget’ы, Layout’ы, WidgetLayer’ы и т.д. Здесь помогает собственная рефлексия, о которой когда-то уже писал. Она позволяет искать нужные параметры по стоковым путям, например children/0/transform/anchor
.
Каждый виджет может иметь набор таких состояний, и они могут включаться и выключаться отдельно друг от друга. Например, для кнопки можно задать такой список состояний
-
курсор наведен — подсвечиваем кнопку
-
кнопка нажата — затемняем кнопку
-
кнопку в фокусе — показываем рамку вокруг
-
скрытие и показ кнопки — плавное исчезновение и появление
Так как эти состояния работают через анимации, их можно делать сложными и составными, добавлять различные кривые плавности и т.д.
Эти состояния переключаются как изнутри Widget’ов, так и доступны для переключения извне.
Внутренние Widget’ы
Иногда бывает ситуация, когда внутри одного Widget’а может находиться другой Widget, но при этом не хочется чтобы он был виден как дочерний. Например кнопка закрыть в виде крестика на окне. Окно — это отдельный цельный Widget, и кнопка закрыть ощущается как его часть. А дети окна — это уже содержимое собственно окна.
Реализовывать поведение кнопки закрытия можно и через слои, но это сложнее, чем просто добавить кнопку. Поэтому Widget’ы, кроме списка дочерних Widget’ов, имеют список «внутренних» Widget’ов. Которые ведут себя точно так же как и дочерние, разве что не отображаются вместе с реальными дочерними Widget’ами в иерарзии.
Layout-Widget’ы
Это специальные Widget’ы, отвечающие за определенные алгоритмы расположения дочерних Widget’ов. Например, HorizontalLayout раскладывает своих детей в линию по горизонтали. Аналогично работает VerticalLayout, только раскладывает по вертикали. А так же есть GridLayout, который раскладывает равномерной сеткой.
![Комбинация Horizontal/VerticalLayout Комбинация Horizontal/VerticalLayout](https://habrastorage.org/getpro/habr/upload_files/c1c/fff/9c9/c1cfff9c9c71beb246d187d9d7783cdf.gif)
У этих Layout’ов можно настраивать точку отсчета (от угла, середины), расстояние между элементами, растягивать ли элементы по горизонтали или вертикали. Эти настройки позволяют покрыть практически все возможные вариации с адаптивной версткой.
![Пример адаптивной верстки в редакторе параметров Пример адаптивной верстки в редакторе параметров](https://habrastorage.org/getpro/habr/upload_files/dbb/b8c/8d5/dbbb8c8d5a1798a89bd42893990d97fd.gif)
Алгоритм у этих Layout’ов такой:
-
Рекурсивно рассчитываем размеры дочерних Widget’ов. То есть как бы заглядываем в будущее, какого размера они будут. За основу берем минимальный размер элементов
-
Рассчитываем пространство, которое дочерние элементы могут занять. Берем текущий размер Layout’а, вычитаем все минимальные размеры дочерних элементов, вычитаем промежутки между ними. Получается «пространство, которое можно распределить»
-
Пытаемся распределить это свободное место между элементами. Но если для какого-то элемента этого пространства оказывается слишком много, то есть у него ограничен максимальный размер, он исключается из алгоритма и пространство распределяется уже без него
-
Получив конечные размеры дочерних элементов, им проставляются соответствующие параметры WidgetLayout
Типы Widget’ов
На базе этого простого элемента системы UI строится масса других типов элементов, каждый из них реализует определенную логику. Стоит отметить, что базовый Widget не умеет обрабатывать сообщения ввода. Если Widget должен реагировать на ввод пользователя, он дополнительно наследуется от соответствующих интерфейсов KeyboardEventsListener, CursorAreaEventsListener.
На данный момент поддерживается следующий список типов:
-
Кнопка
-
Checkbox
-
Поле ввода текста, однострочное и многострочное
-
Выпадающий список
-
Список
-
Изображение
-
Надпись
-
Зона прокрутки
-
Горизонтальный/вертикальный progress-bar
-
Горизонтальный/вертикальный scroll bar
-
Спойлер
-
Окно
Попапы
Попап — это часть интерфейса, которая рисуется поверх всего остального. Для них существует отдельный алгоритм отложенной отрисовки.
Все попапы наследуются от общего класса PopupWidget. К попапам так же относятся контекстные меню. Внутри в виде статичной переменной хранится текущий видимый попап. Именно он рисуется в отложенном рендеринге сцены. Так же сам попап может содержать дочерний попап, который будет рисоваться вместе с этим отложенным попапом. Это применяется, например, в контекстных меню: некоторые пункты могут открывать подпункты меню, которые являются дочерними попапами.
![Иерархичное контекстное меню Иерархичное контекстное меню](https://habrastorage.org/getpro/habr/upload_files/b03/f2a/03d/b03f2a03d97875e2adc87eebb31ca0aa.gif)
Так же попапы умеют автоматически закрываться при клике снаружи, закрывая все свои дочерние попапы.
Бонус: anti-aliased линии
Иногда в редакторе нужно рисовать линии и кривые. Те, кто пользовался OpenGL или D3D, знают, что по умолчанию линии рисуются с «лесенкой». Но я хотел себе гладкие линии.
Есть разные подходы к отрисовке графики а anti-aliasing’ом. Я решил попробовать один простой трюк: рисовать по краям линии полигоны, уходящие в нулевую альфу, то есть в плавно исчезающие. И так как эти края очень маленькие, но все же есть, при растеризации видеокарта выбирает некоторые полупрозрачные пиксели с краев основного тела линии, и получается эффект сглаживания.
Дешево в плане рендеринга, но тяжеловато в расчете таких мешей. Ведь есть еще проблема с острыми углами, и там получается 8 проверок пересечения лучей. Но это уже другая история. В целом результат после «подгона» параметров вполне хорош
![](https://habrastorage.org/getpro/habr/upload_files/1f6/38d/30c/1f638d30ce150c2e80db01674dcb4a26.gif)
Оптимизации
Итак, у нас есть система иерархии элементов, мы можем нарисовать все что угодно, оно умеет обрабатывать ввод и умеет адаптироваться к изменению размера. Графика уже вполне оптимизирована, меши кешируются, и не нужно постоянно перестраивать геометрию. Однако, элементов бывает очень много. Например, в моем редакторе их пара тысяч, плюс по несколько слоев на каждом из них. Да и вообще требования к мобильным играм довольно серьезные, кроме отрисовки и обработки интерфейса еще нужно нарисовать и обработать целую игру, 60 раз в секунду.
При этом и на десктопе редактор должен работать шустро, даже в отладочном режиме. Учитывая что С++ в режиме отладки обычно работает на порядок медленнее.
У меня есть большой опыт работы с Unity3D, и с интерфейсами в нем в частности. Если в Unity3D делать интерфейс «в лоб», то уже довольно скоро все начнет лагать, управление станет не отзывчивым, а процессор перегреется и начнет «троттлить«, ухудшая итак плохую производительность. И мне очень хотелось избежать такого же. Моей целью было сделать такую систему, которая при обычных задачах не требовала специальных оптимизаций или разделения на Canvas’ы, чтобы уменьшить перегенерацию мешей, как в Unity3D.
Собственно практически сразу, как я начал делать редактор, я столкнулся с проблемой производительности. И это произошло при разработке окна дерева сцены. Дело в том, что на сцене может быть несколько тысяч Actor’ов, и нужно уметь рисовать иерархию для каждого из них. Первый вариант был простой — для всех развернутых нод дерева создается Widget. Конечно при достижении пары сотен развернутых нод оперирование ими занимало приличное время. Поэтому пришлось отсекать.
![](https://habrastorage.org/getpro/habr/upload_files/3db/bc1/2aa/3dbbc12aa435349d95af452485b9a731.gif)
Отсечение невидимых Widget’ов
Обычно, если в каком-то месте много Widget’ов, то большинство их них не видны. Это списки, деревья и зоны прокрутки. Очевидно то, что скрыто, можно не рисовать. И даже более — не обсчитывать в некоторых ситуациях.
Для начала отсечение невидимых. Как ни странно, отсечение на уровне графического API решает проблему частично, видеокарта не растеризует отсеченные прямоугольники и пиксели. Однако, мы все еще формируем батчи для них и отсылаем в видеокарту. На этом можно сэкономить.
Чтобы понять, что какой-то Widget скрыт, нужно знать зону отсечения его родителей, а так же свою полную площадь с учетом всех слоев и детей. При изменении трансформации или каких-либо параметров, эта площадь пересчитывается, начиная от самых конечных детей.
Далее, в Widget’е есть специальный метод CheckClipping, который определяет отсечен ли он. Здесь же еще одна оптимизация: проверка на отсечение в основном нужна при прокрутке, то есть при линейных сдвигах. Например, есть зона прокрутки с кучей дочерних Widget’ов. Обычно прокрутка происходит вверх-вниз или влево-вправо, и размеры элементов не меняются. Поэтому достаточно взять площадь элемента и просто сдвинуть ее на величину прокрутки.
Так же в длинных списках или деревьях не создаются Widget’ы под каждый элемент. Создается некий буффер элементов, и при прокрутке показываются только те, что попадают в поле видимости. Это позволяет делать списки с тысячами элементов, без сильных затрат памяти и процессора.
Будущие оптимизации
Сейчас редактор в режиме отладки работает приемлемо, а в релизной сборке практически без лагов. Однако, весь интерфейс перерисовывается каждый кадр, что занимает процессор и видеокарту.
Здесь напрашивается оптимизация на статичные части интерфейса. Их можно отрендерить один раз, и затем кусочно перерисовывать измененные участки. Для этого я планирую применить подход с «грязными» областями. Если Widget как-то изменился, что требует его перерисовки, то он сообщает общей системе что его область загрязнена. Если таких Widget’ов несколько, их площадь суммируется, одним прямоугольников покрывающим все. Далее, если этот итоговый «грязный» прямоугольник нулевой, то просто ничего не рисуем, оставляем на экране предыдущий кадр. Если он не нулевой, то изначально включаем отсечение по этому прямоугольнику, а затем рисуем только те элементы, который с ним пересекаются.
Это будет работать хорошо, когда изменения будут небольшие и не частые, как в редакторе. Но в играх такая оптимизация будет только потреблять накладные ресурсы, т.к. в игровых интерфейсах много эффектов и они более чем вероятно постоянно будут обвновляться.
Редактор
Что было первое, курица или яйцо? Система интерфейсов или редактор этих интерфейсов? Да, редактор написанный на системе интерфейсов позволяет их редактировать. Даже есть отладочный режим, в котором видна иерархия редактора и там можно поисследовать редактор изнутри.
Сейчас редактор умеет практически все что нужно:
-
Отображение иерархии сцены и Widget’ов в том числе. Отображение слоев и внутренних Widget’ов
-
Визуальное окно редактирование верстки
-
Окно настроек параметров Actor’ов
-
Редактор анимаций
Заключение
Сейчас система UI в довольно зрелом состоянии, она хорошо показывает себя в разработке редактора. Но еще много работы по улучшению и правке багов редактора.
В общей сумме на эту систему интерфейсов потрачено примерно 6 человеко-месяцев. Я вполне доволен тем что решил сделать свой велосипед. У меня есть опыт разработки подобного редактора на ImGUI, и могу сказать что чужое решение ограничивает, приходится тратить на его «допиливание» время, сопоставимо с написанием своего велосипеда.
PS: Если кому-то интересно позаниматься разработкой движка для 2D игры, буду рад посотрудничать. Одному получается очень долго 🙂 Репозиторий движка: https://github.com/zenkovich/o2, и тестового проекта на нем: https://github.com/zenkovich/PetStory
ссылка на оригинал статьи https://habr.com/ru/post/521306/
Добавить комментарий