Разработка
Игра на Kotlin, Jetpack Compose и Filament: что сработало, а что нет
Начиналось всё как небольшая игра в стиле приложения — почти без графики, скорее как текстовая игра. Потом мне захотелось видеть, как герои перемещаются по миру.
Всем привет! Я делаю Adventurers Guild: RPG Sim — RPG-симулятор управления гильдией, где вы играете за главу гильдии: нанимаете героев, назначаете им задания, отправляете охотиться на монстров, собираете добычу, улучшаете экипировку, открываете навыки для каждого героя и так далее.
Начиналось всё как небольшая игра в стиле приложения — почти без графики, скорее как текстовая игра. Потом мне захотелось видеть, как герои перемещаются по миру. Затем появились деревья, здания, изометрическая карта, погода и цикл дня и ночи. В какой-то момент я понял, что по сути построил небольшой игровой движок.
Я уже публиковал здесь разные этапы разработки, но теперь захотел собрать всю историю проекта целиком, включая то, что мне пришлось переделывать, и обратную связь, которую я получил в этом сабреддите.
Небольшая предыстория: я 7,5 лет работал iOS-разработчиком, потом ушёл с работы и начал заниматься разработкой в одиночку. Я пробовал разные игровые движки, а затем захотел разобраться в Android-разработке. Лучший способ учиться — делать что-то самому, и этот проект я выбрал в том числе для того, чтобы глубже разобраться с памятью и оптимизацией производительности. Меня часто спрашивали, почему я выбрал нативную разработку вместо готового игрового движка. Когда я начинал, проект был гораздо меньше, а Jetpack Compose отлично подходил для меню и управленческой части. По мере роста игры я просто продолжал расширять уже существующую архитектуру.
Что мне понравилось в нативной разработке — это контроль над тем, как именно всё работает. Но обратная сторона в том, что мне пришлось самостоятельно писать системы, которые игровой движок предоставил бы бесплатно. Карта, поиск пути, анимации, эффекты и работа с ассетами — всё это нужно было сначала реализовать, прежде чем использовать в самой игре.
Моя первая версия использовала обычные layout-компоненты Compose для отображения мира. Когда я добавил слишком много деревьев, всё начало тормозить, поэтому я перенёс отрисовку мира в Compose Canvas.
Canvas позволил продвинуться довольно далеко. Там рисовались карта, персонажи, тени, дождь и даже маленькие облачка сообщений над героями. Compose при этом отвечал за меню и остальной интерфейс.
Карта выросла до изометрической сетки 50×50, то есть 2 500 тайлов земли ещё до добавления каких-либо объектов поверх них. Рисовать всё каждый кадр стало слишком дорого, поэтому я разделил карту на 16 чанков и отрисовывал только видимые части. Также я кэшировал вычисления теней, которые менялись в зависимости от времени суток.
Эти изменения помогли достаточно, чтобы продолжать разработку. Но по мере роста требований мне снова и снова приходилось менять реализацию карты. В одном из старых комментариев здесь я упоминал, что к тому моменту уже переделывал её четыре раза.
Ассеты тоже доставляли проблемы. Я использовал смесь доступной графики, поэтому у одного героя была анимация ходьбы, у другого — бега, а некоторые враги вообще не соответствовали изометрической перспективе. В результате движение могло выглядеть неправильно, даже если код движения работал именно так, как я хотел. Я отложил часть работы над полировкой анимаций, пока ассеты и системы продолжали меняться.
Урок, который я вынес: ассеты могут полностью изменить восприятие игры. Они способны превратить проект из ощущения демо в действительно отполированный продукт. Я также понял, что смешивание ассетов легко создаёт проблемы. Лучше по возможности придерживаться одной перспективы — например, только изометрической или только вида сверху, одной цветовой темы, одного общего настроения — уютного или, наоборот, тёмного и мрачного.
Логика игры использовала собственную entity component system на Kotlin. К первому релизу в ней было 28 систем, отвечавших за движение, бой, усталость, задания, экономику и другие части симуляции. Сама симуляция была однопоточной.
Изначально игровой цикл работал через корутины с использованием withFrameMillis. Данные игры я преобразовывал в модели для Compose UI, а Room использовал для сохранения героев, инвентаря и состояния заданий.
Позже я разделил системы по частоте обновления. Движение обновлялось 60 раз в секунду, бой — 30, а более тяжёлые задачи вроде поиска пути и выбора целей — 10 раз в секунду. Это заметно уменьшило объём работы, которую игра должна была выполнять каждый кадр.
Также я переиспользовал sprite sheet для вариантов монстров, меняя оттенок и цвет вместо загрузки отдельной графики для каждого цветового варианта. Вместе с выгрузкой неиспользуемых ассетов это помогло сократить использование памяти и размер приложения.
Ещё один урок: в 2D-играх количество sprite sheet и их разрешение довольно быстро упираются в ограничения, если вы пытаетесь одновременно показывать на экране много разных персонажей. Я стараюсь держать спрайты в разрешении 320×320, потому что в игре можно приближать камеру, и мне не хотелось, чтобы персонажи выглядели размытыми.
Затем в декабре прошлого года я выпустил игру и получил огромное количество сообщений о багах, которые сам не заметил.
Я потратил столько времени на то, чтобы заставить игровой мир нормально работать, что упустил вещи, которые могли вообще не позволить человеку нормально начать играть.
Урок: нужно очень много играть в собственную игру. Даже если багов вроде бы нет, дайте её человеку, который ничего о ней не знает, и просто наблюдайте, где он застревает, где ему нужен туториал и понимает ли он вообще, что делать дальше. Это крайне важно.
По мере того как я добавлял в мир всё больше контента, рендеринг снова стал проблемой. Я тратил всё больше времени на чанкинг, кеширование и кастомный код отрисовки. В итоге я перенёс рендеринг мира на Google Filament.

Игра уже была выпущена, поэтому миграцию пришлось делать постепенно, сохраняя рабочими игровую логику и существующие сохранения. Kotlin по-прежнему отвечает за симуляцию, Compose — за интерфейс, а небольшие превью персонажей в меню всё ещё рисуются через Canvas. Сам игровой мир теперь рендерит Filament.
При этом мир всё ещё состоит из 2D-спрайтов. Они отображаются как quads (прямоугольник из двух треугольников в 3D-сцене), поэтому переход на Filament не потребовал заменять всё на 3D-модели.
Большую часть оптимизационного кода, написанного специально под Canvas, пришлось выбросить. Но затем пришлось создавать уже новые оптимизации под Filament. Земля теперь использует общий вертексный буфер, а спрайты группируются по sprite sheet. Основные cutout-спрайты записывают глубину в зависимости от их положения в изометрическом мире. Ветер работает в шейдерах, а загрузку вершин я пропускаю, если отслеживаемое состояние спрайта не изменилось. В какой-то момент я стал настоящим фанатом шейдеров.
Это помогло с рендерингом, но память текстур потребовала отдельной работы.
До этого я использовал WebP, потому что сами файлы занимали мало места. Но после загрузки в память эти текстуры всё равно занимали место в несжатом виде. Например, RGBA-текстура 2048×2048 занимает 16 MiB ещё до mipmap, независимо от того, насколько маленьким был файл WebP.
После изучения темы я узнал о форматах сжатия для GPU. Переход на ASTC и ETC2 в моих тестах сократил общее использование RAM игрой примерно вдвое. Но первая попытка собрать пакет была катастрофой: я включил несколько форматов текстур вместе с fallback-вариантом, и размер приложения вырос примерно со 120 МБ до 400 МБ. Если бы вся игра загружалась в память целиком, она занимала бы около 2,5 ГБ, а после перехода — примерно 600 МБ.
После изменения схемы сжатия и использования texture targeting через Play Asset Delivery текущий размер загрузки составляет около 160 МБ, а после установки игра занимает примерно 195 МБ. Оказалось, что уменьшение размера загрузки и снижение runtime-памяти — это две совершенно разные задачи.
Filament также принёс новые баги, с которыми я раньше не сталкивался, в основном потому, что сам был новичком в Filament.
Некоторые тайлы на один кадр становились чёрными. Оказалось, что я переиспользовал direct buffer до того, как Filament успевал закончить его чтение. Я заменил это пулом из трёх буферов, каждый из которых освобождается через колбек завершения. Затем пришлось чинить ещё и способ выполнения этих колбеков, потому что задержки в main looper могли привести к тому, что пул зависал в состоянии ожидания.
Ещё один визуальный баг показывал кусочки неправильного sprite sheet при появлении новой текстуры. В моей конфигурации проблему решило выполнение обновлений material до вызова beginFrame().
Также пришлось отдельно заниматься очисткой нативных ресурсов в связке с жизненным циклом Compose. Просто сохранить объект оказалось недостаточно — ресурсы Filament и surface нужно явно освобождать.
В одном из предыдущих обсуждений мне предложили добавить карту нормалей, чтобы здания реагировали на движение солнца. В итоге я их добавил, хотя работа ещё не закончена. Проблема была в том, что освещение уже было нарисовано непосредственно в исходных ассетах, поэтому шейдер должен был учитывать это. Кроме того, карта нормалей добавила дополнительное потребление текстурной памяти и нагрузку на шейдеры.
Если оглянуться назад, Compose отлично сработал для управленческого интерфейса, а Canvas позволил довести игру до первого релиза. Переход рендеринга мира на Filament дал мне гораздо больше пространства для дальнейшего развития графической части. Каждый этап решал реальную проблему, с которой я сталкивался в тот момент, хотя многие вещи, на оптимизацию которых я потратил время, позже всё равно были полностью заменены.
Больше всего я недооценил, сколько времени займёт поддержка всех систем вокруг самой игры. Я хотел добавлять новых героев, задания и здания, но это легко превращалось в очередную полную переделку рендера или изменение способа загрузки ассетов.
Надеюсь, эта история окажется полезной кому-нибудь, кто пытается сделать что-то похожее. Спасибо всем здесь, кто по ходу разработки указывал на проблемы и делился предложениями.
-
Разработка1 неделя назадДаем ИИ видеть SwiftUI: Xcode Preview MCP на практике — проблемы и надежды
-
Новости2 недели назадВидео и подкасты о мобильной разработке 2026.39
-
Новости3 недели назадВидео и подкасты о мобильной разработке 2026.38
-
Новости7 дней назадВидео и подкасты о мобильной разработке 2026.40
