Flutter Flame: подходы к оптимизации
В предыдущей статье я разбирал проблемы Bonfire и причины, по которым он не очень подходит для сложных игр. Несмотря на то, что чистый Flame намного легче, там тоже не всё так гладко. В этой статье я поделюсь своими рецептами увеличения производительности игры и распишу причины, почему это работает. Хочу сразу заметить, что среди этих рецептов нет серебряных пуль, и маловероятно получить существенный буст, применив только что-то одно. Однако в комплексе они достаточно эффективно работают – проверено часами медитации над CPU-профайлером.
Общие подходы к оптимизации
При оптимизации мы в первую очередь ориентируемся на FPS. И только при более глубоком погружении меряем микросекунды для отдельного блока кода, лезем в CPU-профайлер и т.д., т.к. это гораздо утомительнее, чем просто запустить и увидеть, что «тормозит».
Поэтому важно ещё раз обозначить, что такое FPS в случае с Flame и с Flutter в целом. FPS, как правило, не отражает скорость работы графического движка – в большинстве случаев он отлично справляется с любым количеством графики. В нашем случае FPS - это скорость работы игровой логики, а именно то, сколько раз в секунду CPU успевает её пересчитать. Т.к. если логика будет считаться медленно, то и команды на обновление параметров картинки будут поступать «рывками». Немного получается абсурдно, но, выходит, FPS иллюстрирует скорость работы CPU. А значит, если он проседает, то и смотреть нам надо не в сторону упрощения графики, а в сторону оптимизации игровой логики. Вот такой вот парадокс.
Раз мы оптимизируем игровую логику, наше внимание должны привлекать все объекты, у которых есть не пустые методы updateTree
и renderTree
. Один из самых тяжелых таких объектов – это сам класс FlameGame
и его примеси, поэтому стоит и на его работу тоже обратить внимание. Я тут не призываю править исходный код, я призываю в случае возникновения «необъяснимых тормозов» не стесняться заглядывать «под капот», т.к. в итоге это поможет выйти на проблемный код, написанный вами.
Таким образом, основная наша стратегия оптимизации звучит максимально просто: «сделать все вызовы updateTree
и renderTree
максимально лёгкими, а по возможности вообще их исключить».
Звучит довольно общо. Далее мы пройдёмся по более конкретному списку методов, разберем каждый из них, с примерами реализации в коде, где возможно.
Методы оптимизации
Один из самых простых способов повысить производительность – это избавить приложение от лишних повторяющихся вычислений. Как ни странно, один из источников такой нагрузки – это рендер карты, загруженной через flame_tiled.
Оптимизация рендера карты
1. Ускоряем рендер статических слоёв карты
Пакет flame_tiled
рендерит тайлы, используя SpriteBatch и Canvas.drawAtlas()
. Этот механизм быстрее, чем рендеринг индивидуально каждого тайла. Однако, он всё ещё выполняет многочисленные преобразования над каждым тайлом карты: определяет нужный фрагмент картинки, позицию и поворот, которые нужно к нему применить, и записывает эти преобразования «в блокнотик». И при рендере собирает итоговую картинку из полученной «мозаики».
Между тем, для статических слоёв карты можно выполнить эту операцию единожды – при загрузке карты – и, получив финальную статическую картинку, не повторять эти вычисления до окончания игры. Сделать это довольно просто:
final tiledComponent = await TiledComponent.load(mapFile, Vector2.all(gameTileSize));
final recorder = PictureRecorder();
final canvas = Canvas(recorder);
tiledComponent.tileMap.render(canvas);
final picture = recorder.endRecording();
final image = await picture.toImage(tileMap.map.width * tileMap.map.tileWidth, tileMap.map.height * tileMap.map.tileHeight);
...
canvas.drawImage(image, const Offset(0, 0), Paint());
Просто и эффективно. На маленькой карте вряд ли разница будет заметна, но чем она больше, тем больше ресурсов процессора вы сэкономите. Далее мы будем часто использовать этот же подход в различных вариациях, модификациях.
2. Ускоряем рендер анимированных тайлов карты
Некоторые тайлы могут содержать анимацию. И, скорее всего, она будет обладать следующими чертами:
На карте много анимированных однотипных объектов, разбросанных по разным участкам карты или объединённых в группы (например, реки, озёра, зыбучие пески… лава, факелы на стенах и т.п.)
У каждого класса объектов своя анимация, однотипная для всего класса
Кадры анимации проигрываются синхронно для группы объектов, т.е. время старта и длительность одинаковы, переключение кадра происходит одновременно для всех.
Если ваш случай совпадает с этими условиями, тогда имеет смысл (при большом количестве анамированных тайлов) также склеить однотипные тайлы в большой анимированный спрайт. Во-первых, это даст нам оптимизацию из предыдущего пункта: вместо отрисовки кучи мелких картинок мы один раз рисуем большую. Во-вторых, это позволит нам не держать на каждый тайл по одному персональному объекту SpriteAnimation
. Ведь каждый из этих объектов будет вызван в updateTree
и потратит наши ресурсы на вычисление уже ранее посчитанных значений.
Вместо этого мы получим один компонент SpriteAnimation
, в котором в качестве кадров будут склеенные картинки анимированных слоёв. Вариация предыдущего решения, как я и говорил.
3. Полезные инструменты
Чтобы жить было проще, я объединил свои наработки в небольшую библиотеку. Пока не уверен в том, что это финальный вариант, так что на данный момент это часть более крупного проекта, на котором я всё обкатываю: https://github.com/ASGAlex/battletank_game_v2/tree/main/lib/packages/tiled_utils
Как этим пользоваться. Сперва загружаем карту, как обычно:
final tiledComponent = await TiledComponent.load(mapFile, Vector2.all(gameTileSize));
Далее, чтобы склеить статичные слои карты в картинку:
final imageCompiler = ImageBatchCompiler();
final ground = await imageCompiler.compileMapLayer(tileMap: tiledComponent.tileMap, layerNames: ['ground']);
ground.priority = RenderPriority.ground.priority;
add(ground);
В этом примере тайловый слой карты “ground” будет отрендерен в отдельный компонент со статичной картинкой.
Теперь соберем в отдельный компонент анимированные тайлы:
final animationCompiler = AnimationBatchCompiler();
TileProcessor.processTileType(
tileMap: tiledComponent.tileMap,
processorByType: <String, TileProcessorFunc>{
'water': ((tile, position, size) {
animationCompiler.addTile(position, tile);
}),
},
layersToLoad: [
'water',
]);
final animatedWater = await animationCompiler.compile();
animatedWater.priority = RenderPriority.water.priority;
add(animatedWater);
TileProcessor – класс, позволяющий преобразовать каждый тайл в ваш кастомный объект, ориентируясь на наименование типа:
Используя объект TileProcessor
, мы можем получить для тайла спрайт, анимацию и область столкновений. То, что я ожидал бы увидеть в Flame из коробки, но чего там не оказалось. И то, что в зачаточном виде присутствовало в Bonfire. С помощью этого метода мы собираем в AnimationBatchCompiler
анимированные тайлы, которые нужно склеить в один компонент. Ну и методом compile()
мы собираем итоговый SpriteAnimationComponent
. Результат – вместо сотни другой компонентов получаем один. Экономия!
Ускорение добавления новых объектов в игру
В Flame у каждого компонента есть свойство priority
, которое позволяет определить «z-index» объекта: какой отобразить сверху, какой снизу.
Допустим, у вас на карте есть земля, вода, игрок и деревья. Земля находится в самом низу, поверх неё отрисовывается вода, далее идёт игрок, а выше всех рисуются деревья – чтобы игрок оказался скрыт кроной, когда окажется под ними.
По задумке, для каждого объекта внутри каждого слоя мы прописываем priority
, чтобы достичь вышеописанного поведения. Пока число объектов не часто меняется, всё будет работать прекрасно. Но, допустим, у нас на карте… 20 игроков, и каждый из них стреляет пулями. Каждая пуля – это дополнительный объект, имеющий priority
как у игрока. Пуля добавляется в игру, живет несколько секунд до попадания по цели, после чего удаляется.
Что в этот момент происходит внутри движка?
При добавлении каждой новой пули ей назначается
priority
Flame понимает, что нужно отсортировать компоненты заново, исходя из их приоритетов, и помечает родительский компонент добавленного компонента для обработки
При следующей
update()
игры, Flame переберёт все дочерние компоненты помеченного родителя, пересортирует их исходя из приоритета.
Чуете, в чем подвох? Добавляя новые компоненты непосредственно в FlameGame
, мы делаем так, что у всех у них один и тот же родитель. И при любом изменении priority
пересчёт пойдёт по всем компонентам в игре. Хотя нам вовсе не обязательно это делать! Вот более оптимальная стратегия:
Создать компоненты-слои с разными приоритетами (земля, игрок, вода, деревья, пули…). Добавить их непосредственно в
FlameGame
Новые компоненты каждого типа добавлять уже не к игре, а к компоненту-слою. Новый компонент унаследует приоритет от родителя, так что его можно даже не указывать.
Что это даст? При добавлении / удалении компонентов priority
будет пересчитываться только для дочерних объектов внутри слоя. То есть при добавлении новой пули мы сверяем её приоритет только с другими пулями, т.к. они находятся на одном слое. Объекты в других слоях не принимают участия в вычислениях.
Важно, чтобы компоненты-слои наследовались от обычного Component
, т.к. нам не нужны лишние преобразования координат на каждом обновлении экрана. Об этом ниже.
Избегайте избыточных компонентов
Если вы не планируете рисовать что-то на экране, но добавили в игру, например, PositionComponent
– значит, вы потратили ценные ресурсы системы зря. Потому что в renderTree()
у этого класса и всех его потомков вызываются canvas.save()
, canvas.transform()
и canvas.restore()
– довольно дорогие функции. Значит, даже если render()
у вас пустой, вы всё равно потратите ресурсы, тем больше, чем больше таких объектов-невидимок было добавлено.
Поэтому, кстати, если хотите временно скрыть объект со сцены – лучше переопределяйте для этого renderTree
, а не render
– (не)много сэкономите.
Вообще стоит обратить внимание на весь код, который работает у вас в системе. Даже на сторонние библиотеки. Используете где-то getter? А насколько тяжелые внутри него вычисления? А как часто он вызывается? А есть ли смысл все-таки посчитать значение один раз и сохранить его в переменную?
Ускорение отрисовки интерактивных объектов карты
Если мы выполнили все предыдущие оптимизации, то те компоненты, которые остались нетронутыми – это всё интерактивные объекты. Часть из них очень активны – это игрок, NPC, какие-то ещё быстродвижущиеся объекты… С ними мы мало что можем сделать, для таких важных игровых элементов трата ресурсов зачастую оправдана.
Но, кроме этого, в игре могут быть также объекты, которые не изменяются или изменяются, но крайне редко. Например, разрушаемые стены. Но они же не рушатся каждую секунду, так? Большая часть этих объектов до конца игрового раунда может так и остаться нетронутой.
В этом случае мы уже не можем склеить все объекты в Image
– слишком долго и слишком дорого. Но можем зато собрать объект Picture
.
Picture и Image – в чем разница? Документация довольно скупо освещает этот вопрос: Image
это картинка после растеризации, а Picture
– это класс с заранее сформированным набором команд для рисования. На практике это значит, что:
Image
долго формируется, т.к. нужно предварительно выполнить все команды отрисовки, потом растеризовать изображение, применив к нему фильтры сглаживания / интерполяции.Picture
же можно быстрее обновлять, т.к. мы просто записываем новые команды рисования, не преобразуя их в растр.При этом, чем сложнее рисунок мы пытаемся запихнуть в
Picture
, тем медленнее его отрисовка будет идти. Всё же это не готовая картинка, а всего лишь список команд, и если он становится слишком длинным…А вот
Image
как раз всё равно, насколько сложная там отображается графика, лишь бы её не часто обновляли.
Итого, мы можем не рисовать индивидуально каждый интерактивный объект, а, по аналогии с тайлами на карте, объединить их в один слой, но вместо Image
генерировать Picture
. И рендерить закешированную картинку, пока конфигурация объектов не изменится – в этом случае мы относительно быстро успеем сгенерить отрендерить новую Picture
.
Следы от объектов
Вспомните классические шутеры, какой-нибудь незабвенный CS 1.6. Если мы стреляем в стену, на стене остаётся след от выстрела. Следы могут накладываться друг на друга. Во многих играх игроки оставляют за собой след от перемещения.
В Flame, чтобы реализовать такую механику, мы можем помещать на карту спрайт. Но чем больше и чаще мы будем это делать, тем сильнее будет тормозить наша игра. А теперь представьте, что мы хотим, например, сделать затухание следа со временем. Это для каждого объекта придётся отслеживать время его существования. Таким образом, нагрузка может расти катастрофически быстро с увеличением активных NPC в игре – ну или что там в вашем конкретном случае оставляет следы после своего пребывания.
Здесь должен сразу сказать, что идеального решения нет. Совсем. Остаётся выбор между «дорого» и «очень дорого». Очень дорого будет решить задачу «влоб» - накидать на карту спрайтов.
Второй вариант: совместить использование Image
и Picture
. Зная об особенностях работы каждого из этих классов, делаем следующее:
Добавляем в игру служебный компонент
Собираем в него объекты, которые должны быть добавлены в буфер
На вызове
update()
формируемPicture
c добавленными объектами. Добавляем туда и предыдущее содержимоеPicture
с прошлых тактовЧтобы в
Picture
не скапливалось слишком много команд на отрисовку, добавляем счётчик, по достижении которого всё накопленное вPicture
будет слито вImage
. В этот момент у нас возможен лаг в приложении, зато добавление новых объектов вPicture
будет и дальше происходить быстро. Я пробовал не делать этот шаг - тормозит.Полученную
Image
пишем… обратно вPicture
! Но это будет уже быстрее, чем если бы мы оставили всё как есть, т.к. все сложные математические преобразования превратились в растр, с которым работать гораздо быстрее.Если нужен fade-out, то можно при отрисовке указать opacity сразу на весь слой, и это будет дешевле, чем управлять прозрачностью каждого объекта по-отдельности.
Полезные инструменты
Наработки я снова собрал в отдельную библиотеку: https://github.com/ASGAlex/battletank_game_v2/tree/main/lib/packages/back_buffer
BatchComponentRenderer – специальный компонент, в который можно добавлять другие компоненты:
batchRenderer?.batchedComponents.add(brick);
Он отрендерит их в Picture
, в исходных же компонентах функцию render (а лучше renderTree) можно отключить.
Если вдруг положение или размер или вообще спрайт компонента изменились – можно инициализировать обновление картинки выставлением флага:
batchRenderer?.imageChanged = true;
При следующем рендере Picture
будет перерисована.
Я добавил в конструктор компонента также параметры, чтобы рисовать тени или создать эффект изометрии путём отрисовки дополнительных слоёв. Это чистой воды эксперимент, скажу, что такие вещи очень дорого обходятся, т.к. мы несколько раз рисуем один и тот же слой. Хотя работает всё равно быстрее, чем в Bonfire, вот и смешно, и грустно.
BackBuffer – конечно, это не тот низкоуровневый back buffer на стороне графического движка. На самом деле, я даже не уверен, находятся ли создаваемые нами картинки в памяти видеокарты, или же система каждый раз копирует их из оперативки. Увы, склоняюсь ко второму варианту, но приходится работать с тем, что есть.
Этот класс предоставляет нам «холст», на котором мы рисуем все «следы» от объектов.
Создаём класс:
final backBuffer = BackBuffer(mapWidth.toInt(), mapHeight.toInt(), 2, 10, 0.98);
Числовые параметры:
Ширина и высота карты в пикселях, потому что эти параметры нужны для растеризации картинки.
Частота перерисовки
Picture
с применёнными параметрами прозрачности. Чем чаще мы перерисовываем, тем более сглаженный получится Fade-out. Но тем больше мы нагружаем систему.Частота «сброса» данных из
Picture
вImage
. По-хорошему, стоит ориентироваться не на время, а на количество добавленных объектов, ведь появляться они могут с очень разной скоростью. Но я пока решил не усложнять. Общее правило: чем интенсивнее у вас добавляются «следы», тем чаще их надо «сбрасывать» в растровую картинку.Уровень opacity, с которым будет будет перерисовываться слой. Также влияет на скорость Fade-out.
Далее в момент, когда нам надо «картинизировать» объект – просто добавляем его в этот компонент. Вот пример кода, который сохраняет картинку «осколков» убитого игрока, в то время как сам объект из игры удаляется:
current = TargetState.dead;
game.backBuffer?.add(this);
removeFromParent();
Что ещё можно сделать?
Что хочется сказать вместо заключения? Меня в ходе разбора всех этих нюансов не покидало ощущение, что как-то всё больно низкоуровнево, что ли, для того чтобы уверенно называться «игровым движком». Чтобы выжать из Flame максимум возможностей, которые есть у Skia, приходится фактически до уровня работы напрямую с Canvas и опускаться. В целом этот процесс крайне увлекателен, за исключением случаев, когда у вас коммерческий проект и по нему горят сроки, а по ходу дела выясняется, что часть фичей на этом движке будут предъявлять слишком высокие требования к железу...
Но самое «прекрасное» в этой ситуации то, что все перечисленные мной оптимизации могут не оказать абсолютно никакого эффекта на производительность, если вы не доработаете напильником систему определения столкновений! Обработка столкновений – вообще одна из самых дорогих частей движка, и Flame она сделана довольно… беспечно. В моём случае до «обработки напильником» я имел 0 (ноль!) FPS на тестовой карте. После «напильника» время, уходящее на вычисление столкновений, уменьшилось не менее чем в 250 (двести пятьдесят!) раз. Это не очень скурпулёзный подсчёт, скорее всего разрыв ещё больше.
О том, что делать с Collision Detection System, чтобы она работала, а не тормозила – я расскажу в следующей статье, это отдельная большая тема. Но нетерпеливый читатель уже сейчас может ознакомиться с готовым рабочим решением в моём репозитории с тестовой игрой. Ну и, возможно, вас и сама идея игры затянет, предложите что-нибудь по геймплею, сюжету и т.п. :-)