От системной заставки до первого кадра: запуск игры на Android
Андрей СумскойДелаю игру и движок на Bevy. Часть 2.
При подготовке Android-релиза BoomRivals возникла проблема с запуском. Сначала появлялся системный фон с иконкой, затем мелькал бирюзовый экран, за ним чёрный — и только потом игра. При сворачивании и возврате бирюзовые кадры появлялись снова.
Мне нужно было показать картинку, пока игра загружается, а затем плавно перейти к игровому интерфейсу. Для игрока это один экран загрузки. Но показывать его приходится сначала средствами Android, а потом уже движком: Bevy ещё должен запуститься, подготовить ресурсы и нарисовать первый кадр. Картинку нужно не только вовремя показать, но и вовремя убрать.
Почему нельзя просто поставить картинку в тему
Первый фон с иконкой показывает сама система. Начиная с Android 12 у приложений есть стандартная заставка — splash screen. Она появляется до того, как приложение сможет что-либо нарисовать. Поэтому начать сразу с картинки Bevy не получится: в этот момент рендер ещё не работает.
У системной заставки можно менять оформление, но полноэкранную иллюстрацию в неё не положить. Фон должен быть однотонным, а для иконки выделена область с ограничениями по размеру и маской. Рисунок двора, который используется в BoomRivals, в такой иконке потерял бы смысл. Размеры и доступные настройки описаны в документации SplashScreen.
В теме игры для Android 12+ остаётся чёрный фон, а вместо иконки используется прозрачный vector drawable:
<style name="GameTheme" parent="android:style/Theme.Material.NoActionBar.Fullscreen">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:colorBackground">@android:color/black</item>
<item name="android:windowSplashScreenBackground">@android:color/black</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_transparent</item>
<item name="android:windowSplashScreenIconBackgroundColor">@android:color/black</item>
</style>splash_transparent убирает видимую иконку, но не сам системный этап запуска. Android всё ещё показывает своё окно. Чёрный цвет нужен и в обычной теме, чтобы после системной заставки приложение не начиналось с другого фона. Для старых версий Android он задан отдельно, без атрибутов windowSplashScreen.
Теперь картинку можно показать, как только будет готово окно приложения. Ждать ради этого весь движок не нужно: Android уже умеет выводить обычный ImageView.
Картинка поверх NativeActivity
BoomRivals работает через наследника NativeActivity. У этой Activity есть нативная поверхность, в которую Bevy рисует через Vulkan. Она нужна игре и после загрузки, поэтому подменять её Android-интерфейсом неудобно. Вместо этого картинка открывается поверх неё, в отдельном окне Dialog.
От обычного диалога здесь нужно только окно. Затемнение, фокус, обработка касаний и стандартная анимация появления мешали бы запуску игры. В настройке окна они отключены — вот соответствующий фрагмент:
window.setBackgroundDrawable(new ColorDrawable(Color.BLACK));
window.setFormat(PixelFormat.TRANSLUCENT);
window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND);
window.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE);
window.setWindowAnimations(0);Под картинкой движок может готовить свою поверхность и начинать отрисовку. Само изображение к этому времени уже доступно Android: сборщик берёт его из настроек проекта и подготавливает обычный ресурс приложения. Для его показа не требуется загрузить игровой каталог ресурсов.
В игровом интерфейсе используется тот же рисунок двора. Это помогает связать два экрана, но одинаковый файл ещё не гарантирует незаметного перехода. Если Android и Bevy по-разному масштабируют картинку, при смене экрана двор сдвинется или изменит размер.
У раннего изображения стоит ImageView.ScaleType.CENTER_CROP: оно заполняет экран, лишнее обрезается по краям. Следующий экран должен совпадать с ним по кадрированию. По той же причине полноэкранный режим нужен обоим окнам — и Activity, и диалогу. Иначе между ними могут появиться системные панели и поменять доступную область.
Для этого используется immersive mode. При возврате в приложение он применяется снова; вызвать системные панели свайпом при этом по-прежнему можно.
Сцена загрузилась, а кадра ещё нет
Картинка Android закрывает только начало запуска. Дальше её сменяет загрузочная сцена Bevy: движок сначала готовит ресурсы этой небольшой сцены, показывает её и уже под ней загружает остальную игру.
Здесь легко убрать картинку слишком рано. Событие «сцена загружена» означает, что данные сцены готовы, но ещё ничего не говорит о том, что получилось на экране. Интерфейсу нужно рассчитать размеры, текстурам — попасть на GPU, графическим конвейерам — подготовиться к отрисовке. Если открыть поверхность до этого, игрок снова увидит промежуточный кадр.
Поэтому загрузка ждёт не только данные. Сначала должны быть готовы раскладка интерфейса и графические ресурсы загрузочной сцены. Затем рендер проверяет отправку кадра в окно и ждёт завершения соответствующей работы GPU. Этот фрагмент сохраняет подтверждение:
let value = (surface.0, surface.1);
let completed = completed.0.clone();
queue.on_submitted_work_done(move || {
if let Ok(mut sample) = completed.lock() {
*sample = Some(value);
}
});on_submitted_work_done вызывается, когда ранее отправленная работа очереди завершена. Вместе с подтверждением сохраняются два числа: поколение поверхности (surface.0) и номер кадра (surface.1). Номер кадра позволяет отсечь слишком старую отрисовку. Поколение понадобится при сворачивании игры — к этому ещё вернусь.
После проверки этих значений Rust-код через JNI сообщает Java-коду Activity, что картинку можно убирать. Сам callback не означает, что дисплей уже показал пиксели: он сообщает о завершении работы GPU. Но теперь есть чего ждать перед открытием поверхности, вместо того чтобы ориентироваться только на загрузку сцены.
Анимация добавляется поверх этого условия. Когда системная заставка ушла, рисунок проявляется из чёрного за 300 мс. Когда закончено проявление и готов нативный кадр, окно с рисунком исчезает за 150 мс. Если движок загружается дольше, картинка остаётся — длительность анимации не задаёт срок загрузки.
Ошибка запуска тоже должна завершать ожидание. В этом случае Android-код закрывает картинку и показывает сообщение с кнопкой выхода. Иначе неподвижная иллюстрация скрывала бы не только подготовку игры, но и то, что загрузка уже не продолжится.
После сворачивания старый кадр не подходит
Бирюзовые вспышки при возврате в игру — отдельная часть исходной проблемы. Здесь системной заставки может уже не быть: при горячем возврате к существующей Activity Android её не показывает. Но наличие работающего процесса ещё не означает, что сохранилась поверхность, в которую рисовала игра.
Представим, что рендер отправил кадр, после чего приложение свернули. Android уничтожил поверхность, а при возврате создал новую. Подтверждение старого кадра может прийти уже после этого. Если просто хранить флаг «кадр готов», он разрешит убрать картинку над новой поверхностью, хотя для неё ничего ещё не нарисовано.
Для этого у поверхности есть epoch — номер поколения. Он меняется при её создании, изменении и уничтожении. Возврат остановленной Activity через onRestart тоже сбрасывает прежнее подтверждение, меняет поколение и снова закрывает окно картинкой. Чтобы её убрать, нужен свежий кадр именно для этого поколения.
Одной проверки на стороне Rust мало. Обращение к Activity ставит работу в очередь UI-потока, и до её выполнения поверхность может поменяться ещё раз. Поэтому Java проверяет номер уже внутри runOnUiThread:
public void kedraFrameSubmitted(long expectedEpoch) {
runOnUiThread(() -> {
if (!destroyed && surfaceAvailable && epoch == expectedEpoch && cover != null) {
submittedEpoch = expectedEpoch;
dismissReadyCover();
}
});
}Сравнение epoch == expectedEpoch не даёт старому подтверждению открыть новую поверхность. Проверять его до постановки в очередь было бы рано: между проверкой и исполнением как раз остаётся время, за которое состояние может измениться.
Но и после вызова dismissReadyCover окно закрывается не сразу — оно ещё 150 мс исчезает. За это время приложение тоже могут свернуть. Поэтому в конце анимации поколение проверяется повторно. Если поверхность изменилась, незавершённая анимация отменяется, а окно снова становится непрозрачным.
При возврате необязательно снова показывать начальный рисунок двора. В onPause Android-код пытается сохранить текущий кадр через PixelCopy. Если копирование удалось, поверх новой поверхности будет снимок игры. Если подходящего снимка нет, используется исходная картинка. При возврате к той же Activity начальное проявление не повторяется.
К этому моменту загрузочная сцена уже может быть убрана. Поэтому после загрузки подтверждения приходят от кадров самой игры: ждать загрузочную камеру, которой больше нет, было бы бессмысленно.
Так у первого запуска и возврата остаётся общий способ открыть игру. Android держит изображение поверх нативного окна, пока рендер не подтвердит свежий кадр для нужной поверхности. При запуске это рисунок двора, при возврате — по возможности последний сохранённый кадр. Движок получает время на подготовку, а момент исчезновения картинки больше не зависит от того, сколько миллисекунд ей отвели на анимацию.