Собственная ECS-библиотека

Привет разработчики! В этом топике будут выходить devlog’и моего самого сложного проекта. Статья будет вводной, а процесс разработки вы сможете увидеть в комментариях.

:white_question_mark: Почему категория “Полезное”? Думаю, кому-то могут в будущем пригодиться некоторые подходы.

Библиотека создаётся под экосистему Roblox Studio на языке luau. Автор не является профессионалом, поэтому может допускать ошибки в теоретической базе.

:incoming_envelope: Оставляйте комментарии в топике. Буду рад любым исправлениям и идеям по улучшению проекта.

Введение

ECS — Entity-Component System стал альтернативой классическому Объектно-Ориентированному подходу. В процессе разработки программисты поняли, что наследование не такой уж и идеальный инструмент. К сожалению, возможность создавать множество похожих сущностей по поведению чревато серьёзной связанностью кодовой базы.

:automobile: Предположим, вы создаёте игру в стиле Car Crushers 2. Есть базовые классы колёсного и водного транспорта. От них наследуются непосредственно классы конкретных видов, например, машин. И так далее.

В какой-то момент геймдизайнер просит создать колёсный транспорт с возможностью плавать. Программисту приходится реализовывать новый базовый класс колёсно-воздушного транспорта. Пока всё хорошо и ECS вам не нужен!

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

Поэтому в конце 90-х годов была предложена новая концепция. Теперь классы не занимаются хранением и обработкой информации. Данные разделяются в отдельные группы. Так появилась Entity-Component парадигма. Со временем она развилась до привычного современного представления. Обсудим базовые понятия.

:package: Entityсущность. В простом понимании — коробка, которая хранит в себе какие-то состояния. В нашем случае ссылки на компоненты.

И пусть название вас не вводит в заблуждение: сущностью может быть что угодно. От NPC до абстрактной части кода. Даже component в каких-то случаях — entity. Например, такой подход использует мощная библиотека flecs на C++.

:receipt: Componentкомпонент. Место хранения состояний entity. Каждый компонент должен хранить строго определённый тип данных.

:cross_mark: Не рекомендуется в компонент под int-числа класть boolean-значения.

:hammer_and_wrench: Systemсистемы. Функции или классы, которые работают с компонентами: добавляют, читают, изменяют и удаляют.

Вернёмся к нашему многострадальному примеру. Теперь мы можем представить весь транспорт как отдельные entity. Внутри положим component’ы, которые будут определять тип транспорта, скорость, физические показатели и прочее. Напишем функции обработчики.

Поздравляю, мы на простом и абстрактном уровне решили проблему. Если не ощутили кайфа — возможно, ECS вашей игре не нужен.

Все entity хранятся в world. Название говорит за себя. В мире хранится вся информация. Обращения к сущностям производится по их ID. В графическом представлении мир выглядит как таблица. Строки — entity. Столбцы — component. Теперь мы можем пройтись по строке и посмотреть, какими состояниями владеет.

В целом, вся работа с ECS-библиотеками и фреймворками сводится к множественным итерациям. Со временем программисты наткнулись на подводный камень…

Data-Oriented Design

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

Советую, кстати, посмотреть :wink:

Разработчики поняли, что их кодовая база работает слишком медленно с большими массивами данных. Причина кроется в основах computer science. Авторам огромных ECS-библиотек пришлось подстраиваться под новые правила игры.

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

В архитектуре Intel выделяют три уровня: L1 — самый быстрый и маленький, L2 – медленнее и больше, L3 — самый медленный и большой. К слову, самыми быстрыми хранилищами данных у процессора выступают регистры. Данных в них вмещается немного.

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

Программисты стали думать: как расположить данные плотнее друг к другу в памяти? Что эффективнее: Array of Structs или Struct of Arrays?

На эти вопросы пришлось отвечать и мне при разработке библиотеки! Об этом — в следующем devlog.

2 лайка

Devlog #1 | Архитектура компонентов

В вводной статье я рассказывал про Data-Oriented Design (DOD). При разработке нельзя было игнорировать этот момент. Мой главный конкурент — jecs, абсолютно правильно выбрал именно такую стратегию.

Кстати, в будущем я буду пользоваться аббревиатурами для удобства.

Выбор структуры данных

Первым делом, я подумал: какая структура данных будет более удобной для хранения данных? Нужен был непрерывный кусок памяти для быстрой итерацией. Решением стала классическая таблица.

Состояния будут лежать в формате array-структуры data_t (data table) для экономия памяти. К слову, именно состояния. Хранить ссылки на entity я буду в другом месте памяти. Почему? Так проще читать и перезаписывать данные. Поэтому создаём вторую array-таблицу ptr_t (pointer table). Здесь будут сохраняться ID сущностей.

В конце сделаю битовую маску bitmask, чтобы хранить какую-то информацию про компонент. Теперь наш компонент весит ~240 байт в пустом состоянии. Benchmark показал, что чтение bitmask’и по отдельному index’у будет эффективнее, чем из таблицы entity.

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

Первую проблему я решил через инверсию. Что если место сущности в ptr_t будет ссылкой на место её данных в data_t? Тогда мы сможем быстро обратиться к данным и прочитать их. Возникает другая проблема: накладных расходов на поиск entity в ptr_t.

Однако элегантное решение я представлю в нескольких devlog’ах позже.

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

Подход не реализовал. Выходом стала лекция по ECS на C++. Автор предлагал концепцию перемещения последнего элемента в пустое пространство. Мы затираем старую информацию и на её место перемещаем старую.

Подход идеальный. Дырки решены, хоть и приходится тратить лишние инструкции на встроенную функцию table.find(). Это не так страшно, ведь удаление в ECS происходит на порядок реже нежели итерирование. Таблица всегда плотно хранит указатели и процессору очень комфортно считывать их.

Buffer и bit32

Пока я разрабатывал архитектуру меня посетила идея…Мне известно, что под флагом !native@native@native (можете найти об этом в сети) некоторые встроенные библиотеки способны выдавать невероятные значения. Начались бенчмарки, которые дали свет идее.

Buffer — непрерывный кусок статической памяти. Идеально для DOD. К тому же экономит байтики. А именно фрагментация памяти становится главной болевой точкой таких решений как jecs.

bit32 — идеальное решение для создание битовых масок. Быстрое и маловесное решение.

Поэтому я решил создать новый тип компонентов: нативный. Создать его можно будет методом recs.native(). Всего он сможет хранить 12 типов. Пользователи библиотеки смогут выбрать их из списка.

Финальным штрихом стало добавление пользовательской настройки “size”. Вы сможете сами определить размер компонента. Если что, моё стандартное решение подразумевает 2^5 сущностей (32) на компонент.

Нативные компоненты рекомендуется использовать в самых горячих местах кода. Преимущественно для чтения данных. А…И важно отметить: нативная генерация кода работает только на серверах. Поэтому клиентская часть получает только 1 бонус: меньший расход памяти.

Почему я не перевёл ptr_t на buffer? Дырки. Всё те же дырки. Количество entity по id может превысить объём памяти по 1 offset. Так что таблицы удобнее.

2 лайка

Devlog #2 | Сложности хранения компонентов

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

Сразу скажу: материал исключительно теоретический без собственных практических реализаций.

Два кита и один слон

Согласно подходу DOD мы должны размещать данные в плотных массивах. Поэтому индустрия геймдева придумала два подхода хранения компонентов в мире (world). Это архетипы (Archetype) и спарс-сеты (Sparse-set). Каждый решает проблему друг друга. Объясню структуры по отдельности.

Archetype | Архетип

:houses: Архетипы напоминают съемное жильё со строгим владельцем. Чтобы получить новые удобства вам приходится переезжать в новое помещение. При этом старые потребности никуда не исчезают. Хотели дом с духовкой, а теперь с бассейном? Переезжайте в коттедж, где есть духовка и бассейн. Ничего не напоминает? Да это же сущности и компоненты. Сущность — человек. Удобства — компоненты.

Главная идея архетипов — определить сущность в определённую область памяти в соответствии с её компонентами. Когда компонент удаляется или добавляется — сущность переезжает.

Это обеспечивает плотную компоновку данных, потому что архетипы представлены непрерывными массивами без дырок. И, пожалуй, важнее — быстрые запросы (Query). Прошлые части не освещали этот аспект. В общем, прелесть ECS заключается в возможности получить сущности с определёнными наборами данных. Архетипы справляются блестяще.

В памяти Archetype ищется через ссылку. Из опыта изучения библиотек: это либо hash-ключи, либо число в результате логического сложения [оператор “|” или bit32.bor(type: number) для luau]. Так что при запросе <Velocity, Position> система циклически начнёт искать архетипы, которые хранят в себе сущности с Velocity и Position. Необязательно строгий набор. Это может быть сущность {Velocity, Position, Health}. Query-запрос вернёт подобную сущность. Да, мы сталкиваемся со сложностью O(k), где k — число нужных архетипов из количества всех архетипов — N. Авторы библиотек придумывали и на это решение. Библиотека flecs (jecs) формирует кэшированные запросы. Для этого неспеша строится кэш, а потом запрос выполняется быстрее.

:cross_mark: Но серебряной пули не существует. Вот минусы Архетипов:

:baggage_claim: Дорогая миграция. Когда сущность меняет собственные компоненты мы вынуждены тащить в другой архетип ВСЕ данные. Добавили к {Health, Position} компонент Strength? Доо, перетаскиваем все значения Health и Position в новый архетип <Health, Position, Strength>. При постоянных изменениях сущность скачет по памяти и дёргает значения.

:collision: Взрыв архетипов. Каждый новый компонент порождает новый архетип. А что, если их станет 30, 100? На каждую вариацию придётся создавать уникальный архетип. Библиотеки, конечно, фиксят эту проблему. Освобождают из памяти пустые архетипы, группируют по секциям. Но общая боль не пропадает.

:chart_increasing: Фрагментация памяти из-за взрыва архетипов. Со временем данные начнут размазываться по памяти. И очень неэффективно распределяться, когда 1 сущность занимает слот в архетипе, а остальная часть пустует. Таких «необычных» сущностей возникает множество. Фиксы: из прошлого. Остального не существует по фундаментальным причинам.

Sparse-Set | Спарс-Сет

:hotel: Это похоже на список номеров домов в жилом секторе. Мы владеем списком жильцов и номера дома. При этом все дома заполнены.

Подход наследует идеи DOD. Основная идея — хранить данные в формате: разряженный список (Sparse) x плотный список (Dense). В первом лежат ID сущностей и ссылка на индекс массива второго. Во втором данные упакованы плотно для лучшей итерации.

Идеальная система для RPG-проектов. Мы никуда ничего не перемещаем. Достаточно просто знать набор компонентов. Добавить, удалить — проще простого. Процесс предельно понятный. С памятью не возимся. Больше не нужны вариации. Никакого раздувания и фрагментации.

:cross_mark: Выглядит как шикарное решение…Нет. Минусы:

:up_down_arrow: Двойное обращение к памяти. Мы вынуждены сначала заглядывать в Sparse, а затем в Dense части. Кстати, на этом принципе я строил компонентную систему.

:package: Естественно, нам теперь надо владеть дополнительными словарями, куда поместятся ссылки на объекты. Это линейное раздувание памяти.

:part_alternation_mark: Кэш-промахи. Частая история при обходе запросов. Кстати…Они стали медленнее. Найти 1 компонент просто. А объединение? Уже нет. Мы ищем в сразу двух Dense-массивах информацию. Спрашиваем у каждой сущности: есть ли такой компонент? Это чистый O(N).

Итоги

В этой жизни нет универсального метода…Каждая задача решает собственную проблему и вызывает другие ограничения. Выбирайте удобную стратегию под задачи. Хотите высокую производительность большого количества одинаковых сущностей? Пользуйтесь архетипами. Это flecs (jecs for luau), например. Делаете RPG-проект с постоянными изменениями? EnTT всегда пожалуйста.

Внимательные заметили…Я писал про какого-то слона. Да. Есть один подход, который затерялся в индустрии. Он решает часть уязвимостей двух приведённых решений. А работает на инвертированной системе битовых карт индексов

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

1 лайк