Привет, хабровчанин! Я рад, что спустя полтора года после своей первой статьи у меня наконец закончилась разработка и оптимизация всего, что только можно, и я могу с уверенностью поделиться о лучших практиках при разработке огромных 2д рпг с открытым миром для любой платформы на фреймворке Flame.
Начну с того, что Flame имеет очень уютное коммьюнити, которое всегда поможет, подскажет, примет вашу критику и охотно аппрувнет ваши исправления в главную ветку если они действительно здравые. Так что такое отношение к своему детищу от разработчиков не может не радовать. Да, движок молодой, но очень быстро набирает все необходимые функции, чтобы уже на данном этапе позволяет делать очень крутые 2d игры.
P. S. Все мои объяснения я выложу в конце статьи как ссылку на github с полными примерами и рабочим кодом.
Узкие горлышки производительности
Физические взаимодействия и broadphase
В прошлой статье я начинал писать свой физический движок — не надо так делать. Это было очень наивно и под спойлером написано почему.
Скрытый текст
Потому, что есть готовые очень крутые решения, которые учитывают инерцию, трение, угловое ускорение и прочее. Я наивно полагал, что можно будет просто вычислять нормаль от столкновения, но поверьте — это совсем не то, что даёт тебе физический движок. Flame, как и Unity, использует Box2D. Это очень мощный опенсорсный физический движок написанный на C, и создаётся он на протяжении 11! лет. Так что если ещё возникли идеи быстро для себя состряпать хороший физический движок — у вас не получится. Используйте уже готовые открытые решения, ведь Box2D переведён почти на все возможные ЯП, в том числе и на dart в виде Forge2d.
И помните — разработка физического движка занимает СТОЛЬКО же времени, сколько вся игра в целом. Так что вам стоит определиться что вам больше нравится делать.
Для игры на Flame используйте библиотеку Forge2D. Это форк Box2D, написанный на dart. Если не слышали о таком, то это AngryBirds, Limbo и т. д. Как и большинство физических движков Forge2D использует квадратичное дерево для составления карты возможных столкновений. Это имеет очевидный минус - большие статичные объекты будут попадать в дерево абсолютно везде и проверяться на столкновение для каждого движущегося объекта. Для больших карт c 4223 статичных рельефных линий это становится большой проблемой.

Очевидный метод для решения — использовать аналогию Multi‑box pruning для 2d сетки (ссылка). В мире 3д этот механизм включается по одной кнопке и движок сам бьёт всю карту на регулярную сетку. Но ни в unity, ни во Flame автоматического механизма для 2д нет. Выход — написать такой механизм самому. Ещё важно понимать, что сетка рельефа не будет меняться в ходе игры. Поэтому мы делим все карты на эту сетку один раз ДО игрового процесса, чтобы потом использовать. Для деления всей карты нам понадобиться одна формула пересечения двух прямых и немного логики:‑) Как и в Multi‑box pruning нам надо явно указать какого размера будет ячейка и сколько ячеек максимально в ширину и в высоту содержит текущая карта. Главное, чтобы вся арта помещалась в указанное количество ячеек, ячеек в ширину может быть больше, чем ширина карты, но не меньше. (В примере на github есть карта и при запуске проекта она как раз бьётся на указанную сетку.)
Ещё одно важное замечание — ни один объект ни должен занимать места больше, чем указанная вами ячейка.
Теперь мы сделали колоссальную вещь для оптимизации — мы имеем полностью равномерную карту со всеми объектами и физическими объектами. А это значит, что нам теперь не нужны квадратичные деревья. Мы можем сразу определять грубую возможность столкновения двигающегося объекта просто исходя из ячейки, в которой он сейчас находится.
Мы создаём свои классы class MyBroadPhase implements BroadPhase,TreeCallback
и class WorldPhy implements World
из библиотеки Forge2D
World в своём обычном исполнении имеет final List<Body> bodies = <Body>[]; Туда попадают все физические объекты (Body) при создании. Мы меняем это на такое:
Map<LoadedColumnRow, List<Body>> allEls = {}; //Это статичные объекты
List<Body> activeBody = []; //Это все двигающиеся
LoadedColumnRow - Это LoadedColumnRow(int COLUMN,int ROW);
И добавляем функцию типа setCurrentActiveColumnRow(LoadedColumnRow cell)
, чтобы наш WorldPhy
всегда знал какая где сейчас находится наш игрок или камера. Теперь мы можем обновлять только те ячейки, которые находятся рядом с активной ячейкой, а так же проверять рейкасты тоже по ячейкам.
Класс MyBroadPhase
интереснее, он отвечает за определение потенциальных пар, за проверку на соприкосновение и за движение объектов. Здесь мы делаем что‑то похожее. Делаем отдельно двигающиеся и статичные объекты (на github есть полный код).
Теперь чтобы понять с какими объектами я могу потенциально столкнуться — мне надо узнать в какой ячейке минимально и максимально я нахожусь — это если я на границе двух или четырёх ячеек и просто пробежаться по списку из статичных объектов и грубо сравнить по AABB (axis alignment boundind box). Если не соприкасаются — то идём дальше. Если соприкасаются — то просто добавляем эту пару и дальше точным обсчётом займётся сам Forge2D.
Таким образом получается, что какая бы не была огромная карта — мы всегда имеем по факту несколько маленьких ячеек размером 256*192 пикселей, которые очень быстро отбрасываются по AABB. С моей картой размером 9504*9504 пикселей и количеством статичных линий в 4223 это работает прекрасно. А это — только линии рельефа, без статичных сундуков, деревьев и прочего. т. е. реальная цифра статичных объектов где‑то раза в 2 или 3 больше.
Если статичных объектов очень много и их можно вот так легко оптимизировать, то активные объекты должны сами делать свои Body.setActive(false) при выходе из видимости. Об этом я поговорю дальше.
Не физические спрайтовые взаимодействия
Сюда относится всё то, что не требует физически верной обработки. В моём случае это триггеры, объекты телепортов, какие‑то звуковые триггеры, ловушки и удары противников, твои удары и прочее. Все объекты с которыми можно взаимодействовать тоже относятся сюда.
Почему я не использую стандартные хитбоксы от Flame? Во первых — там нет типа Edge, т. е. нельзя создать линию (ПОЧЕМУ?????), во вторых — нет оптимизации по MBP (multi‑box pruning), в третьих — у меня спрайтовая игра и все удары они несут в себе изменения всей геометрии рисунка (т. е. мне надо каждый спрайт создавать новый хитбокс, удалять старый и так далее, во Flame такого механизма нет).
Я сделал довольно удобный и гибкий класс DCollisionEntity и свой CollisionDetector, который поддерживает разные типы пересечений, вхождение и не вхождение во что‑нибудь, поддерживают динамическую смену точек и представляет собой расширение для Flame.Component. т. е. он добавляется к любому другому Component в игре и относительно него меняет свои точки и так далее.
Тут я когда программировал не удержался, и назвал свой класс столкновений с первой буквы моего имени. Когда делаешь соло проект два года иногда хочется пошутить :‑)
CollisionDetector, как и DCollisionEntity изначально поддерживает ячеечный формат игры. т. е. он имеет отдельно списки статичных и двигающихся объектов и оптимизированно по ячейкам их проверяет на соприкосновение. Если слишком далеко от игрока двигающийся объект забыл сделать collisionType = inactive, то CollisionDetector не рассматривает его. Так же делает и моя broadphase.
Когда игрок бегает по карте, или камера бегает за игроком — конечно активная ячейка игрока будет меняться — это надо передавать и в физический движок, и в CollisionDetector.
На github есть полный код и DCollisionEntity, и CollisionDetector и вся их связь.
Обычный LifeCycle всех компонентов
Если отнять из Flame.Component всё вышеперечисленное, то остаётся ещё кое-что, что будет сильно кушать FPS. Это банальные render(Canvas canvas)
и update(double dt)
. Даже так: renderTree(Canvas canvas)
и updateTree(double dt)
- потому что они запускают как раз и своё update(), и отрисовку всех своих дочерних элементов. О render(Canvas canvas)
и update(double dt)
думать не надо. Только над renderTree и updateTree. Даже если дерево будет находиться на другом конце карты, оно будет вызывать внутри себя эти функции и будет сначала рисовать себя, а потом уже Flame будет отрезать эту часть картинки. Но самое главное - дерево уже себя отрисовало и уже потратила ресурсы, которые так нужны нам для FPS.
Немного размышлений:
Если каждый объект будет проверять допустим каждую секунду а надо ли себя рисовать — FPS всё равно будет проседать сильно.
Если оповещать все объекты каждый раз при смене активной ячейки — то тоже список из 2000 объектов будет обрабатываться довольно долго.
Тогда я сделал так:
Создаём точно такую же сетку, только с ValueNotifier Map<LoadedColumnRow, ValueNotifier<bool>> staticGridNotifier = {}; Теперь если элемент статичный — он будет спать вечно, пока игрок не пробежит рядом и игра не обновит значение на true для этого ValueNotifier. А когда игрок выбежит — значение снова станет false и объект опять канет в лету.
Создаём ещё один ValueNotifier activeCellNotifier для движущихся объектов. Но сделаем его не просто так без всякой мысли. Мы при загрузке даже двигающихся объектов будем подписывать их на staticGridNotifier. Потому что они могут быть далеко от игрока. И только когда игрок пробежит рядом — объект активируется и выпишет себя из staticGridNotifier и подпишет на activeCellNotifier. А вот когда он не догонит игрока и сильно отстанет — он отпишет себя от activeCellNotifier и подпишет себя опять в staticGridNotifier на ту ячейку, где сейчас он находится.
Вот теперь точно всё. Получается что каждый Component имеет у себя bool isRefresh, такие переопределения:
@override
void updateTree(double dt){
if(isRefresh){
super.updateTree(dt);
}
}
@override
void renderTree(Canvas canvas){
if(isRefresh){
super.renderTree(Canvas canvas);
}
}
Ну и вот под спойлером пример такого умного класса для двигающихся и статичных вариантов. Полный код тоже есть на github.
Код smartAnimationComponent
class SmartAnimationSprite extends SpriteAnimationComponent with HasGameRef<KyrgyzGame>
{
SmartAnimationSprite({
super.animation,
super.autoResize,
super.removeOnFinish = false,
super.playing = true,
super.resetOnRemove = false,
super.paint,
super.position,
super.size,
super.scale,
super.angle,
super.nativeAngle,
super.anchor,
super.children,
super.priority,
super.key,
});
LoadedColumnRow? _loadCol;
Ground? ground;
DCollisionEntity? collEntyty;
DCollisionEntity? collEntyty2;
bool isUpdate = false;
@mustCallSuper
@override
void onLoad()
{
_loadCol = LoadedColumnRow(position.x ~/ GameConsts.lengthOfTileSquareObjects.x,
position.y ~/ GameConsts.lengthOfTileSquareObjects.y);
gameRef.staticGridNotifier[_loadCol!]?.addListener(checks);
checks();
}
@mustCallSuper
@override
void onRemove()
{
ground?.destroy();
if(_loadCol == null){
return;
}
gameRef.staticGridNotifier[_loadCol!]?.removeListener(checks);
}
void checks()
{
isUpdate = gameRef.staticGridNotifier[_loadCol!]?.value ?? false;
if(isUpdate){
collEntyty?.collisionType = DCollisionType.passive;
collEntyty2?.collisionType = DCollisionType.passive;
}else{
collEntyty?.collisionType = DCollisionType.inactive;
collEntyty2?.collisionType = DCollisionType.inactive;
}
ground?.setActive(isUpdate);
}
@override
void render(Canvas canvas)
{
if(isUpdate){
super.render(canvas);
}
}
@override
void update(double dt)
{
if(isUpdate){
super.update(dt);
}
}
@override
void renderTree(Canvas canvas)
{
if(isUpdate){
super.renderTree(canvas);
}
}
@override
void updateTree(double dt)
{
if(isUpdate){
super.updateTree(dt);
}
}
}
class SmartMovableObject extends PositionComponent with HasGameRef<KyrgyzGame>
{
LoadedColumnRow? _currCR;
bool isRefresh = false;
Ground? groundBody;
int maximumVisible = 3; //how many cells can be between this object and player
@override
void onLoad()
{
_currCR = LoadedColumnRow(position.x ~/ GameConsts.lengthOfTileSquareObjects.x, position.y ~/ GameConsts.lengthOfTileSquareObjects.y);
gameRef.staticGridNotifier[_currCR!]?.addListener(checkFirst);
checkFirst();
}
void checkFirst()
{
if(gameRef.staticGridNotifier[_currCR!]?.value ?? false){
groundBody?.setActive(true);
isRefresh = true;
gameRef.staticGridNotifier[_currCR!]?.removeListener(checkFirst);
gameRef.checkRemoveItself.addListener(checkIsNeedSelfRemove);
}
}
bool checkIsNeedSelfRemove()
{
if(isRefresh){
int col = position.x ~/ GameConsts.lengthOfTileSquareObjects.x;
int row = position.y ~/GameConsts.lengthOfTileSquareObjects.y;
_currCR = LoadedColumnRow(col, row);
}
int diff = (_currCR!.column - gameRef.columnObj()).abs();
int diff2 = (_currCR!.row - gameRef.rowObj()).abs();
if(diff > maximumVisible || diff2 > maximumVisible){
if(isRefresh) {
isRefresh = false;
groundBody?.setActive(false);
gameRef.checkRemoveItself.removeListener(checkIsNeedSelfRemove);
gameRef.staticGridNotifier[_currCR!]?.addListener(checkFirst);
}
return false;
}
if(diff > maximumVisible - 1 || diff2 > maximumVisible - 1){
groundBody?.linearVelocity.x = 0;
groundBody?.linearVelocity.y = 0;
return false;
}else{
if(!isRefresh) {
groundBody?.setActive(true);
isRefresh = true;
}
return true;
}
}
}
Общие советы
Поздравляю — все бутылочные горлышки кончились. Здесь я бы отел сказать пару слов об общих идеях, которые я получил на своём опыте.
Лучше загружать карту практически целиком, потому что много созданий и удалений объектов вызывают статтеры. Если вы будете делить на ячейки, и все активные объекты грузить и удалять за пределами видимости — статтеры скажут вам спасибо. Я долго так делал — пока на опыте не увидел, что умное управление updateTree()
справляется действительно как надо.
Box2d, как и Forge2d требуют ФИКСИРОВАННОГО обновления, т.е. надо писать вот так:
double tickLimit = 1/60; //60 - it's future FPS
void update(double dt)
{
currentDt += dt;
int cycles = currentDt ~/ tickLimit;
for(int i=0;i < cycles && i < 4;i++){ //Чтобы если вдруг что-то заглючит - то не сильно будет замедлять
_updateCollisions(columnObjects, rowObject, maxVisWibe);
}
currentDt = currentDt - cycles * tickLimit;
}
Иначе у вас на каждом новом устройстве будет разная скорость перемещения абсолютно всего физического.
Кстати, я написал свою программу для удобного создания хитбоксов. Подробный мануал есть на github. Выгружает точки прямо в формате List<Vector2>.
git@github.com:drakkonne007/flame_generator_vertices.git

Ссылка на рабочий проект, который сделает прекомпил любой карты на Tiled. Так же в проекте все файлы, которые уже объединены в систему, и остаётся только добавить их в свою игру.
git@github.com:drakkonne007/flame_optimazions.git
Видео по игре можно найти тут. https://youtu.be/g-lXs03vDfE?si=BxcsPyBykJUrfe1k
Ну и пару скриншотов для особо ленивых) :

