Бенчмаркинг Flutter для игр. Типа того.

Бенчмаркинг Flutter для игр. Типа того.

FlutterPulse

Эта статья переведена специально для канала FlutterPulse. В этом канале вы найдёте много интересных вещей, связанных с Flutter. Не забывайте подписываться! 🚀

Привет, меня зовут Дмитрий, и я хотел бы поделиться с вами своей попыткой измерить возможности Flutter для рендеринга с фокусом на игровую…

Привет, меня зовут Дмитрий, и я хотел бы поделиться с вами своей попыткой измерить возможности Flutter для рендеринга с фокусом на разработку игр. И как обойти дерево виджетов Flutter для этого.

В экосистеме пакетов Flutter у нас уже есть солидный фреймворк под названием Flame. Он легко интегрируется с деревом виджетов Flutter, поэтому с ним легко начать. Это отличный инструмент, который, вероятно, может справиться с большинством типов 2D игр.

Однако, моей целью было протестировать сам Flutter, поэтому первое решение, которое пришло мне в голову, — рисование с помощью виджета CustomPainter. Но что мне измерить? Как сравнить результаты? Конечно же, Bunnymark! Это простой, широко используемый бенчмарк во многих графических библиотеках и игровых движках. Хотя многие игровые разработчики с ним знакомы, я решил реализовать его и для Flutter.

Идея Bunnymark проста: мы берем небольшую текстуру кролика и пытаемся нарисовать как можно больше кроликов, сохраняя приемлемое время кадра. Что мы можем из этого узнать? Это дает вам представление о том, сколько изображений или спрайтов вы можете нарисовать, прежде чем ваша игра начнет лагать. Но это только в теории. На практике в реальной игре каждый кадр происходит гораздо больше всего: физика, ИИ, игровая логика и многое другое. Вот почему мы не должны слишком серьезно относиться к результатам.

В качестве эталона мы будем использовать raylib. И вот как выглядит Bunnymark:

Raylib. 200k прыгающих кроликов при ~50 FPS

Все тесты я запускаю на Apple M2 Max с 32GB RAM, macOS 15.6.1. Версия Bunnymark для Raylib собрана в release режиме для ARM. На этой машине я получаю стабильные 60 FPS до примерно 150000 кроликов на экране. После этой точки FPS начинает падать. Смотрите скриншот.

Версию Raylib вы можете найти здесь: https://gist.github.com/posxposy/cd8436d3b07724b22f3e68f58ab4a501

Код очень прост. Raylib уже имеет батчинг под капотом, и это то, чего нам также нужно достичь.

Часть 1. Написать бенчмарк.

Итак, начнем с наших кроликов! Сначала мы хотим описать нашего кролика как сущность. Ничего особенного:

final class Bunny {
double x;
double y;
double speedX = 0;
double speedY = 0;

Bunny({required this.x, required this.y, this.speedX = 0, this.speedY = 0});
}

Следующий шаг — загрузить наше изображение из ассетов:

final byteData = await rootBundle.load(pathToBunnyImage);
ui.decodeImageFromList(byteData.buffer.asUint8List(), (ui.Image image) {
// Мы можем получить загруженное изображение оттуда и передать его painter
});

Но как нам на самом деле нарисовать изображение с Canvas? Наивное и простое решение будет таким:


final class ImagePainter extends CustomPainter {
final List<Bunny> bunnies;
final ui.Image image;

ImagePainter({required this.bunnies, required this.image});

@override
void paint(Canvas canvas, Size size) {
final paint = Paint();
final w = image.width;
final h = image.height;
for (final bunny in bunnies) {
canvas.drawImage(image, Offset(bunny.x - w / 2.0, bunny.y - h / 2.0), paint);
}
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => ...;
}

Используя этот подход, моя машина может нарисовать около 10,000 кроликов как с Skia, так и с Impeller (в profile/release режиме) до того момента, как поток растеризации начнет умирать! Ура! На этом этапе мы могли бы начать интерпретировать результаты, и это могло бы стать концом статьи, но…

Используя значение bunniesPerBatch, мы можем разделить позиции, текстурные координаты и индексы на отдельные пакеты и вызывать drawVertices для каждого пакета, где каждый содержит 16383 кролика. И знаете что? Это уже больше, чем я могу нарисовать на своей машине, используя только drawImage. Цикл пакетизации может выглядеть так:

final batches = (_bunnies.length / bunniesPerBatch).ceil();
for (int i = 0; i < batches; i++) {
// здесь мы заполняем позиции,
// текстурные координаты и индексы для каждого пакета

// затем создаем объект Vertices
final vertices = Vertices.raw(
VertexMode.triangles,
positions,
indices: indices,
textureCoordinates: textureCoordinates,
);

// и рисуем весь пакет кроликов сразу
canvas.drawVertices(vertices, BlendMode.srcOver, paint);
}

Имейте в виду, что мы здесь работаем на пределе возможностей. В реальной игре я бы рекомендовал использовать более мелкие пакеты — например, 2048 квадратов на пакет. Или где-то между 1024 и 4096, в зависимости от ваших потребностей. Это будет менее нагружать CPU, более эффективно использовать память и в целом безопаснее. И снова, в реальных играх обычно не нужно рисовать столько квадратов одновременно на экране. Создание слишком больших буферов может просто тратить CPU время и память без реальной пользы.

Часть 2. Тестирование

Самое интересное — запустить тест. И результаты впечатляют. На моей машине я могу достичь примерно 250000 кроликов со стабильным временем кадра на бэкенде Skia. Это больше, чем я могу сделать с дефолтным пакетированием raylib.

Skia. Даже выглядит неплохо до 300k, но частота кадров уже нестабильна на этих значениях, с заметными зависаниями и скачками около 150-300 ms на UI потоке.

После 250k я начал видеть случайные скачки UI потока, которые вероятно вызваны GC. Также стоит упомянуть, что я пытался кэшировать как можно больше вычислений, чтобы снизить нагрузку на CPU и GC. Dart DevTools и CPU профайлер были очень полезны в этом.

С другой стороны, когда я запускал тест с Impeller, он начал глючить после примерно 150-160k кроликов. Это можно заметить, посмотрев на график raster потока:

Impeller

На скриншоте это не видно, но некоторые кадры случайно ломались, и сложно сказать почему. Не так просто было поймать эти артефакты, но вот пример:

Impeller. Посмотрите на шрифты на линии UI потока

Так что да, Skia здесь превосходит Impeller: 250k vs 150k. Но давайте будем честны, мы не должны судить их только по этому. Impeller был создан для решения проблем с компиляцией шейдеров, джанками и улучшения отрисовки векторной графики в целом. Вот почему мы не можем осмысленно сравнивать их на основе одного такого бенчмарка.

Я искренне верю, что переход от Skia к Impeller был правильным стратегическим решением. Хотя Impeller еще не до конца готов и определенно нуждается в улучшениях, он уже дает вам доступ к шейдерам вершин, в то время как Skia ограничен только шейдерами фрагментов. И это огромная разница при создании более продвинутой графики.

Это, наверное, уже конец, но…

Часть 3. Можем ли мы сделать лучше?

Пиша этот bunnymark, я постоянно задавал себе один вопрос: действительно ли нам нужен виджетное дерево для проектов типа игр? Большинство игр просто запускают игровой цикл под капотом, и это все, что вам действительно нужно для этой задачи:

while(appIsRunning) {
processEvents();
update(deltaTime);
draw();
}

Часть 4. Подведение итогов?

Что ж, вполне нормально использовать CustomPainter, если вы хотите создавать игры. Вероятно, он достаточно производительный. Если вы хотите пойти немного ниже уровня и делать вещи более традиционным способом, Flutter тоже позволяет это. И мне это нравится больше. Flutter не был создан для игр, но с правильными приемами он все еще может вас удивить.

Skia превосходит Impeller в моем конкретном тесте. Но оба более чем достаточны для создания 2D игр. Просто имейте в виду, что Skia ограничена 2D, в то время как Impeller потенциально может дать вам 3D возможности в будущем. С другой стороны, Impeller еще не полностью завершен, и нам еще предстоит дождаться полностью стабильного релиза на всех платформах.

И также… будьте осторожны с GC. Для игр используйте пулы, кэширование и все другие изящные оптимизационные трюки.

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

Вот и все! Спасибо за чтение ;)

Исходный код: https://github.com/posxposy/flutter_bunnymark

Характеристики моей машины: Apple M2 Max, 32GB RAM, macOS 15.6.1.

Тесты проводились с использованием Flutter 3.38.1, Dart 3.10.0

Делитесь своими результатами с #flutter_bunnymark в соцсетях.

Report Page