Punch2027: Ogun PDP chieftain disowns lawyer, governorship bidThe Jerusalem PostMamdani, Paris already knows where 'globalize the intifada' ends up - commentInquirer2 alleged fixers nabbed over disorderly behavior in LTO central officeESPN DeportesMou: "Si yo fuese Vini hubiese forzado una salida en verano"ESPNJon Sumrall's cowboy hat, Auburn's caption tops trolls from Week 7 in college footballDaily MaverickWORLD FOOD DAY: Hundreds of protesters take on Parliament: ‘Wake up, the climate emergency is here!’Bollywood HungamaTiger Shroff safe after firing incident reported under Juhu Police StationColliderNetflix’s Next WWII Blockbuster Officially Arrives This MonthZDF heuteAktuelle Pressemitteilungen des ZDFWirtualna PolskaBronił swojego sklepu przed złodziejami. Wideo z Francji hitem sieciسكاي نيوز عربيةفيديو.. سيدة تتوسل لمنع نهب متجرها في فرنساObservador DesportoO "xadrez" que vai reconfigurar o BCE com a saída de Lagarde
The Daily Newsstand · Free, Always
Sunday, October 18, 2026

Методология оптимизации игр, или как 3 дня работы над тестовым стендом дало больше FPS, чем месяц написания кода

Translate

 Я разработчик в сфере мобильных игр, занимаюсь в основном программированием геймплея и инструментарием для команд. Люблю Lua, C++, Godot и делать интересные хобби проекты, связанные с геймдевом. Эта статья как раз про один такой проект

Глоссарий

  • Godot - популярный игровой движок на C++ с собственным скриптовым ЯП Физический сервер - программный компонент движка, отвечающий за физические вычисления

  • Коллизия – в физической симуляции процесс пересечения пары объектов

  • Широкая фаза – первый этап обнаружения коллизий, сокращающий число пар кандидатов, с которыми предстоит работать следующим фазам

  • BVH, пространственное хеширование (хеш-сетка) – распространенные техники, применяемые в широкой фазе обнаружения коллизий в 2D

  • Запекание – процесс переноса вычислений вне рантайма программы

  • Batching (с англ. объединение) – в компьютерной графике процесс объединения геометрии нескольких объектов в одну, экономящий количество запросов к GPU на отрисовку

  • P95, P99 (95-й и 99-й процентиль) – значение, относительно которого 95/99% других значений в выборке будут ниже

Введение

 Началось всё с моей любви к инди игре Brotato. Для тех кто не играл краткое описание: “Ты картошка с оружием, которая прокачивается, и косит бесконечные толпы врагов”. Из этого описания вытекает 2 основные проблемы этой игры в вопросе производительности

  1. Врагов много; у врагов много выстрелов; у тебя много пуль, которые пытаются нанести урон этим врагам

  2. Игра бесконечная и с каждой волной количество врагов растёт

    Пример высокой волны в Brotato

    Пример высокой волны в Brotato

Хардкорные игроки могут доходить до волн 1000+, для более казуальных игроков лимит это волна 150-200 (~6тыс. врагов за одну волну)

 На десктопных ПК эти показатели не составят больших проблем, игра имеет довольно непритязательную графику, а расчёты игровой логики вполне под силу современным процессорам и памяти. Но игра также крайне популярна на карманных устройствах, засчёт своей казуальности и простоты управления. С того времени, как я начал играть в Brotato на карманной ARM Linux консоле Anbernic RG34XXSP и началась эта история

Этап 1. Первая попытка

 На слабеньком железе RG34XXSP игра выдавала 5-10 FPS на волне 20 и выше, что подтолкнуло меня взять исходники игры, достать из них файлы проекта (благо для игр на движке Godot сделать это просто) и попробовать оптимизировать игру. При этом цель была добиться этого без значительных изменений скриптов игры, чтобы будущие обновления не перечёркивали всю работу

Характеристики RG34XXSP для справки

Характеристики RG34XXSP для справки

 Уже имея опыт с оптимизацией мобильных игр я создал простенький тестовый стенд: картошка появляется на 100-й волне с фиксированным сидом, игра засекает время кадра, количество запросов на отрисовку и пар коллизий для каждого отрисованного кадра, а в конце пишет средние показатели в файл на диске. Стенд и подход к измерению имел множество проблем (о которых подробнее в этапе 2), но не имея желания заниматься “скучной подготовкой”, я решил что и так сойдёт

  В результате нескольких прогонов на железе консоли и прогонов с дебаггером на ПК я получил следующую картину

  • Основной ботлнек – обработка коллизий на стороне физического сервера Godot. это было видно по огромному числу пар коллизий за тик, подтвердить это получилось прогоном с физическим TPS в 2 раза меньше оригинального

  • Второстепенный – количество запросов на отрисовку и смена состояния GPU ресурсов. Текстурки хранились в отдельных файлах, что заставляло менять их для каждого вызова отрисовки и ломало батчинг

  • Скрипты создавали нагрузку, но из-за погрешности, создаваемой профилировщиком, указать на конкретный горячий путь не получалось

Проблему физического сервера я попробовал решить заменив Godot реализацию на облегчённую с пересобираемой хеш-сеткой, SoA позиций+размеров сущностей и облегчённым солвером. Пошёл на это по двум причинам: Brotato использует исключительно окружности как формы коллизий, что значительно уменьшает объём реализации, и хеш-сетка проще в реализации чем BVH (который Godot физический сервер использует по дефолту). В качестве теста, я решил реализовать эту систему на скриптовом языке gdscript, а если увижу положительные результаты, то заменить на C++ плагин

 Для проблемы с количеством запросов на отрисовку решение было проще – объединить все текстурки в один атлас и запечь шрифты в bitmap формате. Всё это позволит менять ресурсы видеокарты только при изменении слоя (враги→пули→текст) и позволит Godot использовать батчинг запросов на отрисовку

 Спустя пару недель работы, когда результат был почти готов, консоль неожиданно сломалась (спасибо Anbernic за дешёвые контроллеры питания). Оставшись без возможности тестов на реальном железе энтузиазм поутих, я дописал свой физический сервер и скрипты для активации объединения, прогнал тесты, увидел желаемое увеличение показателей и перешёл к другим проектам

Иллюстрация с результатами, собранная впопыхах после смерти консоли

Иллюстрация с результатами, собранная впопыхах после смерти консоли

Этап 2. Осознание ошибки

 Спустя время я снова вспоминаю про Brotato и решаю опробовать свои оптимизации на версии игры для Android. После прогона оптимизированной и чистой версии на Samsung A51 (слабый телефон, даже на момент своего выхода в 2019-м) я обнаружил, что в сравнении с изначальной версией игры мои оптимизации не дали никакого прироста производительности за пределами погрешности. После чего я решил углубиться в теорию и применить знания, полученные на работе, чтобы понять что я сделал не так. Вот основные ошибки, которые я выявил

  • Отсутствие метаданных прогонов. Они позволяют подтвердить, что 2 записи были сделаны в одинаковых условиях, и их результаты можно сравнивать

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

  • Использование времени для контроля длительности теста. При просадках FPS Godot может выполнить меньше физических тиков на кадр, искажая симуляцию, но облегчая себе работу. Остановка по физическим тикам гарантирует, что два прогона исполнили одинаковое количество работы

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

  • Отсутствие сохранения сырых метрик прогона. Любое изменение способа обработки данных при таком подходе инвалидирует все предыдущие результаты

  • Использование среднего арифметического в качестве представления данных. Среднее арифмитечиское легко искажается из-за аномальных значений, какие представления значений лучше использовать я расскажу в этапе 3

С желанием исправить эти проблемы, я решил попробовать снова, но уделить основное внимание не реализациям оптимизаций, а проработанному тестовому стенду, который позволит объективно наблюдать за эффектом от изменений в проекте

Этап 3. Успешная попытка

 Как уже было сказано в TLDR, во 2-й попытке я добился значительных успехов всего за 5 дней работы. Из них 3 я уделил подготовке тестового стенда. Самой большой его частью стала система тестирования внутри игры, в которую вошли

  • Система тестовых сценариев, позволяющая быстро повторить любой игровой сценарий

  • Система прогона тестовых сценариев, в которой можно было задать число повторений прогона и число сухих прогонов

  • Система сбора метрик, собирающая выбранные показатели каждый кадр

  • Тестовый HUD, в реальном времени отображающий все измеряемые показатели

  • Билд плагин, записывающий коммит в билд игры

  • Система записи данных, сохраняющая результаты прогонов, конфиги сценариев и метаданные билда на диске после завершения всех тестов

Интерфейс конфигурации тестовых сценариев

Интерфейс конфигурации тестовых сценариев

 Вторая по важности часть – обработка данных. В этот раз я решил вынести её во внешний скрипт, запускаемый на данных, записанных игрой. В качестве представления данных внутри одного прогона я решил выбрать: медианное значение, P95 и P99 и худшее значение. Почему именно эти показатели? Медиана показывает типичное значение, почти не отклоняющейся из-за аномалий. P95, P99 показывает аномальные значения разной степени редкости. После вычисления этих показателей для каждой метрики скрипт считал погрешность и строил итоговый график, цель которого показать аномалии, которые не увидеть. Скрипт также имел режим compare, который смотрел метаданные двух записей, и если они валидны для сравнения, то считал изменения каждого показателя в сравнении с другой записью

 По графику ниже уже можно заметить главное отличие от старого способа имзерения – мы видим, что FPS проседает по пикам, а не в среднем. Вызваны эти пики чаще всего долгом физического сервера, который не успевает посчитать нужное количество тиков за кадр отрисовки. Моей главной целью было избавиться от этих пиков, а только во вторую очередь повысить медианный FPS

Визуалиазация анализа прогонов

Визуалиазация анализа прогонов

 В стенд входила не только программная часть, но и подключенный к ПК смартфон Samsung A51, на котором скриптом через ADB автоматизированно прогонялись все тесты, после чего результат переносился на ПК для дальнейшей обработки (для RG34XXSP такой сетап тоже был возможен, но об ADB режиме в MuOS я узнал слишком поздно). Это позволило не только прогонять тесты на реальном железе легче, но и дало возможность делегировать процесс тестирования ИИ агенту

 Остановился я на следующих тестовых сценариях

  1. 6 рогаток с уроном умноженным на 1 000 000 на волне 200. Этот тест проверял производительность на босс волне (волны кратные 20-и) с реалистичным количеством урона игрока

  2. те же 6 рогаток, но на волне 249. этот тест нужен был по большей части для избежания регрессий на волнах без боссов

  3. AFK тест на волне 150. симулирует сборку персонажа через выживаемость, но с малым количеством урона

Тестовый сетап

Тестовый сетап

 Далее я проверил свои изменения из попытки 1 на этих тестах, и увидел, что оптимизация с заменой физического сервера не только не улучшила производительность, а и ухудшило её для тестов 1 и 2. Оптимизации по отрисовке просто не показала никакого эффекта, что я списал на забитый физикой кадр, и отложил для дальнейших тестов.

 Это был первый успешный результат нового стенда, он не дал мне +FPS, но зато показал реальный эффект старых решений. После этого я прогнал стенд ещё пару раз с подключенным дебаггером для поиска аномальных путей

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

  1. Количество пар коллизий расло быстрее, чем количество врагов. Как оказалась проблема была в том, что у врагов не разделялся хитбокс и область коллизии, из-за чего широкая фаза создавала кучу невалидных пар между врагами, когда нужны были между игроком и врагом. Решение было тривиальное – вынести хитбокс врага в отдельный слой

  2. Мелкие недочёты скриптов, которые я увидел через дебаггер: статичный размер карты вычислялся каждый кадр, пары коллизий для распространения горения вычислялись для не горящих персонажей, покадровые отключения уже отключённых зон коллизий. Всё это было исправлено добавлением парой строк проверок

  3. BVH и пространственное хеширование. Как я уже писал ранее, по дефолту у Godot физического сервера широкая фаза стоит BVH, но что я не знал при первой попытке оптимизации, так это что разработчики brotato включили настройку использования хеш-сетки. Ради эксперимента, я поменял её обратно на BVH, что дало прирост производительности для самому тяжёлому по физике AFK сценарию, но ухудшило показатели для сценариев с рогатками. Я решил оставить это изменение, т.к. улучшение производительности для сценария 3 значительно больше, чем регресс для сценариев 1 и 2

 Всего за пару строчек кода оптимизаций и несколько часов тестов я получил следующие результаты относительно ванильной игры

Сценарий

FPS* до

FPS после

Разница в FPS

Разница в %

Рогатки, волна 200

16,08

32,30

+16,22

+100,8%

Рогатки, волна 249

45,33

51,95

+6,62

+14,6%

AFK, волна 150

14,02

44,02

+30,00

+214,0%

*медианное значение медианного FPS среди всех прогонов сценария

 Далее я решил снова проверить гипотезы, связанные с оптимизацией физического сервера, но по итогу ни одна из них не показала положительного результата, из-за чего я решил переключится на поиск проблемных мест в сценариях 1 и 2, т.к. сценарий 3 упирался на 80% в физику

 Моим главным предположением всё ещё оставалась проблема в пропускной способностью CPU/RAM/GPU и постоянной сменой состояния. Для быстрого подтверждения решил сделать 2 прогона, один с разрешением в 25% (для исключения влияния GPU) и один с выключенной отрисовкой врагов. Результат ожидаемый, уменьшение разрешения прирост не показало, скрытие врагов дало +50% FPS, а заметить на более ранних прогонах я это не смог из-за забитости кадра физикой. Я решил применить мои оптимизации по батчингу и посмотреть на результат в renderdoc, где я увидел одну упущенную мной группу объектов – эффекты крови. Пропустил я её т.к. в прошлой попытке даже не проверял сценарий со стрельбой по врагам, а составляли они 30% от всех вызовов на отрисовку.

 Почитав документацию, я решил перенсти партиклы на MultiMeshInstance2D (в версиях Godot 4+ сделать это можно было бы и с помощью GPUParticles2D, но имеем что имеем), т.е. вместо кучи разных источников крови сделать отдельный слой с этим эффектом, который с помощью одной команды отрисует все капли крови

Кровь рисуется одной glDrawArraysInstanced

Кровь рисуется одной glDrawArraysInstanced

 Итого в сумме старые оптимизации по батчингу и оптимизацию крови дали следующие показатели. AFK тест из списка исключил, т.к. у него прирост в рамках погрешности

Сценарий

FPS до

FPS после

Разница в FPS

Разница в %

Рогатки, волна 200

32,30

40,11

+7,81

+22,8%

Рогатки, волна 249

51,95

55,7

+3,75

+7,2%

Разный прирост объясняется тем, что на волне 249 много мелких быстро умирающих врагов, поэтому прирост в 7.2% приходится преимущественно на оптимизацию крови и цифр урона, а боссов с волны 200 покрывают все изменения

 На этом я решил остановиться, не потому что больше нечего оптимизировать, а потому что дальше начинались уже гораздо более затраные по времени изменения

Финал

Средний FPS указан за отрезок записи, а не за весь прогон

Средний FPS указан за отрезок записи, а не за весь прогон

 Эксперимент я считаю успешным, цели “сделать brotato играбельной на ранее страдающем устройстве, без значительных изменений кода” я добился (итоговое количество строк кода в исходниках игры чуть меньше 200). Cамой впечетляющей я считаю оптимизацию для сценария на волне 200, хоть итоговый прирост и меньше чем для АФК сценария, этот сценарий намного более достижим в реальной игре и для него получилось найти и исправить 2 существенных узких горлышка

 Главный вывод для меня оказался простым: в оптимизации важнее не скорость написания кода, а качество эксперимента. Чем дешевле становится проверка гипотез, тем быстрее можно отбрасывать неудачные идеи и сосредотачиваться на тех, которые действительно дают результат. И с учётом постоянного развития ИИ технологий этот подход становится ещё актуальнее, ведь ИИ может проитерировать 100 гипотез, но если измерения искажены или неверно обработаны, то дадут они больше вреда, чем пользы

 P.S.
Если нашли какие-то ошибки или есть вопросы – дайте знать в комментариях, буду рад услышать любой фидбек

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.