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

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

Godot — популярный игровой движок на C++ с собственным скриптовым языком. Физический сервер — программный компонент движка, отвечающий за физические вычисления. Коллизия — в физической симуляции процесс пересечения пары объектов. Широкая фаза — первый этап обнаружения коллизий, сокращающий число пар кандидатов, с которыми предстоит работать следующим фазам. BVH, пространственное хеширование (хеш‑сетка) — распространенные техники, применяемые в широкой фазе обнаружения коллизий в 2D. Запекание — процесс переноса вычислений вне рантайма программы. Batching (объединение) — в компьютерной графике процесс объединения геометрии нескольких объектов в одну, экономящий количество запросов к GPU на отрисовку. P95, P99 (95‑й и 99‑й процентиль) — значение, относительно которого 95/99% других значений в выборке будут ниже.

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

  • врагов много; у врагов много выстрелов; у тебя много пуль, которые пытаются нанести урон этим врагам;
  • игра бесконечная и с каждой волной количество врагов растёт.

Хардкорные игроки могут доходить до волн 1000+, для более казуальных игроков предел — волна 150–200 (~6 тыс. врагов за одну волну). На десктопных ПК эти показатели не составят больших проблем: игра имеет довольно непритязательную графику, а расчёты игровой логики вполне под силу современным процессорам и памяти. Но игра также крайне популярна на карманных устройствах за счёт своей казуальности и простоты управления. С того момента, как я начал играть в Brotato на карманной ARM Linux консоли Anbernic RG34XXSP, и началась эта история.

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

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

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

  • Основной бутылнек — обработка коллизий на стороне физического сервера Godot. Это было видно по огромному числу пар коллизий за тик; подтвердить это получилось прогоном с физическим TPS в два раза меньше оригинального.
  • Второстепенный — количество запросов на отрисовку и смена состояния GPU‑ресурсов. Текстуры хранились в отдельных файлах, что заставляло менять их для каждого вызова отрисовки и ломало батчинг.
  • Скрипты создавали нагрузку, но из‑за погрешности профилировщика указать на конкретный горячий путь не получалось.

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

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

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

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

  • Отсутствие метаданных прогонов. Они позволяют подтвердить, что две записи были сделаны в одинаковых условиях и их результаты можно сравнивать.
  • Отсутствие «сухих» прогонов. Для мобильных устройств это важно не только для попадания в кэш и ранней компиляции шейдеров, но и для создания сопоставимых температурных условий.
  • Использование времени для контроля длительности теста. При просадках FPS Godot может выполнить меньше физических тиков за кадр, искажая симуляцию, но облегчая себе работу. Остановка по физическим тикам гарантирует, что два прогона выполнили одинаковое количество работы.
  • Использование одного тестового сценария. Это приводит к тому, что ты оптимизируешь не игру в целом, а прохождение этого конкретного сценария.
  • Отсутствие сохранения сырых метрик прогона. Любое изменение способа обработки данных при таком подходе инвалидирует все предыдущие результаты.
  • Использование среднего арифметического в качестве представления данных. Среднее легко искажается из‑за аномалий; какие представления лучше использовать — я расскажу далее.

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

Как уже было сказано в TL;DR, во второй попытке я добился значительных успехов всего за пять дней работы. Три дня я уделил подготовке тестового стенда. Самой большой его частью стала система тестирования внутри игры, в которую вошли:

  • система тестовых сценариев, позволяющая быстро повторить любой игровой сценарий;
  • система прогона тестовых сценариев, где можно задать число повторений прогона и число сухих прогонов;
  • система сбора метрик, собирающая выбранные показатели каждый кадр;
  • тестовый HUD, в реальном времени отображающий все измеряемые показатели;
  • билд‑плагин, записывающий коммит в билд игры;
  • система записи данных, сохраняющая результаты прогона, конфиги сценариев и метаданные билда на диске после завершения всех тестов.

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

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

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

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

  • шесть рогаток с уроном, умноженным на 1 000 000, на волне 200 — тест проверял производительность на босс‑волне (волны кратные 20) с реалистичным количеством урона игрока;
  • те же шесть рогаток, но на волне 249 — тест для избежания регрессий на волнах без боссов;
  • AFK‑тест на волне 150 — симулирует сборку персонажа через выживаемость, но с малым количеством урона.

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

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

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

Найденные проблемы и решения:

  • Количество пар коллизий росло быстрее, чем количество врагов. Проблема оказалась в том, что у врагов не разделялся хитбокс и область коллизии, из‑за чего широкая фаза создавала кучу невалидных пар между врагами, тогда как нужны были пары между игроком и врагом. Решение тривиально — вынести хитбокс врага в отдельный слой.
  • Мелкие недочёты скриптов, которые я увидел через дебаггер: статичный размер карты вычислялся каждый кадр; пары коллизий для распространения горения вычислялись для не горящих персонажей; покадровые отключения уже отключённых зон коллизий. Всё это было исправлено добавлением пары строк проверок.
  • BVH и пространственное хеширование. Как я уже писал ранее, по умолчанию у Godot физического сервера в широкой фазе стоит BVH, но при первой попытке оптимизации я не знал, что разработчики Brotato включили настройку использования хеш‑сетки. Ради эксперимента я вернул BVH, что дало прирост производительности для самого тяжёлого по физике AFK‑сценария, но ухудшило показатели для сценариев с рогатками. Я решил оставить это изменение, поскольку улучшение для сценария 3 значительно больше, чем регресс для сценариев 1 и 2.

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

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

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

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

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

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

Эксперимент я считаю успешным: цели «сделать Brotato играбельной на ранее страдающем устройстве, без значительных изменений кода» я добился (итоговое количество строк кода в исходниках игры чуть меньше 200). Самой впечатляющей я считаю оптимизацию для сценария на волне 200: хоть итоговый прирост и меньше, чем для AFK‑сценария, этот сценарий намного более достижим в реальной игре, и для него удалось найти и исправить два существенных узких горлышка.

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

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

Claude за полчаса разгадал загадку АНБ, которой больше 25 лет.

Четверть века вокруг одного из главных стандартов современной криптографии держалась почти детективная интрига. В 2000 году американский институт стандартов NIST утвердил набор эллиптических кривых от P‑192 до P‑521 — сегодня на P‑256 держатся подписи сертификатов сайтов, токены входа и защищённые чипы телефонов. Параметры этих кривых выведены из начальных значений (seed), которые Агентство национальной безопасности США передало в стандарт без объяснений. 16 октября 2026 года команда Anthropic сообщила, что её внутренняя исследовательская модель Claude меньше чем за полчаса нашла происхождение этих чисел, а криптограф Филиппо Вальсорда, объявивший награду за разгадку, подтвердил правильность решения.

Как я выгружал 1,8 TB с Яндекс.Диска после аварии в дата‑центрах

Anthropic забанила мой Claude и отдала в экспорте только телефон. Теперь мне отвечает Grok в парике.