Инженер по связям с разработчиками Android XR
Штатный инженер Android
Узнайте, как интегрировать 3D-игровые движки (Godot и Unity) с Jetpack Compose в единую базу кода для мобильных устройств и Android XR. Мы расскажем, как встраивать 3D-сцены в пространственные контейнеры и настраивать связь между Kotlin и движком, а также покажем, как NBA создала трансляции баскетбольных матчей в реальном времени на Android XR.
GodotPlugin обрабатывает двунаправленную связь с Kotlin.Расшифровка
Здравствуйте!
Меня зовут Люк Хопкинс.
Я инженер по связям с разработчиками в команде Android XR.
Сегодня мы поговорим о том, как встроить игровые движки, такие как Godot и Unity, в проект для Android.
Позже к нам присоединятся Джош и Родриго из NBA и Beyond Sports. Они расскажут, как внедряли Unity в качестве библиотеки в приложение NBA.
На этом занятии мы рассмотрим, как интегрировать Godot в приложение Jetpack Compose, а затем Джош и Родриго расскажут о своем приложении NBA.
Android XR – это расширение экосистемы Android, которое позволяет использовать привычные и любимые функции на устройствах разных типов.
Мы сделали так, чтобы вы могли легко перенести существующие приложения XR и приложения для Android в новую среду, и используем технологии Android, с которыми вы уже работаете.
Android XR – это единая платформа, разработанная для широкого спектра устройств.
Устройства Android XR поддерживают разные типы контента.
Дополненная реальность доступна для аудиоустройств и видеоочков, а иммерсивная – для XR-шлемов и проводных XR-очков.
Сегодня мы поговорим о том, как создавать иммерсивные приложения для проводных XR-очков и XR-шлемов.
Создавать иммерсивные решения можно с помощью Jetpack XR SDK или OpenXR с движком реального времени.
Мы поддерживаем Unity, Godot и Unreal, чтобы вы могли создавать иммерсивные продукты для Android, используя любой из этих инструментов.
В этом докладе я расскажу, как использовать API SceneCore из Jetpack, а также Godot и новое расширение OpenXR, чтобы реализовать эту возможность.
Здесь вы видите Godot, работающий параллельно с пространственными панелями Jetpack.
Обратите внимание, что Godot отображается как пространственный контент, предоставляя трехмерный объем наряду с двухмерным пользовательским интерфейсом Compose в пространственном контейнере.
Этот подход позволяет разработчикам запускать симуляции физики и пользовательские шейдеры с помощью выбранного ими движка реального времени, применяя при этом нативные методы разработки Android к остальной части приложения.
С Android XR и XREAL AURA вы тоже можете создавать такие решения.
В этом докладе мы расскажем, как создать такой интерфейс с помощью Godot и Android Studio, а затем наши партнеры из NBA и Beyond Sports поделятся опытом использования Unity в качестве библиотеки.
Раньше при создании XR-приложений с помощью OpenXR нужно было отрисовывать каждый пиксель.
Это означает, что если разработчику нужно показать второе действие, он не сможет запустить оба одновременно.
Чтобы решить эту проблему, рабочая группа OpenXR из Khronos Group в сотрудничестве с несколькими лидерами отрасли выпустила расширение OpenXR Spatial Containers.
Пространственные контейнеры можно представить как OpenXR в коробке.
Вместо того чтобы ваш движок владел всем скайбоксом, ему предоставляется управляемый системой трехмерный объем, который может сосуществовать с пространственными панелями XR Jetpack, другими пространственными контейнерами и стандартными действиями Android.
Предположим, вы хотите использовать Android XR, чтобы одновременно показывать две пространственные панели во время игры с эффектом присутствия.
Все отрисовывается в движке, поэтому в таких случаях можно одновременно запустить редактор Godot и пространственный контейнер.
Начиная с уровня API 4 Spatial, в Android XR реализованы пространственные контейнеры.
Пространственные контейнеры поддерживают два важных режима отображения.
Первый – ограниченный режим.
Это позволяет определить трехмерный объем с несколькими встроенными ограничениями.
Пользователь может взять пространственный контейнер, повернуть его, расположить, свернуть. Контейнер поддерживает стереоскопическую проекцию глубины, что дает эффект волшебного окна или трехмерного всплывающего окна, а также обрезает края контейнера.
Второй – неограниченный режим.
Если пользователь захочет полностью погрузиться в виртуальную среду, приложение может запросить плавный переход из ограниченного режима в режим полного пространства, а затем вернуться обратно.
Благодаря пространственным контейнерам Android XR открывает двери для новых идей.
Допустим, вы хотите добавить в игру интерфейс Android или создать собственный шейдер для показа 3D-контента в приложении.
До появления пространственных контейнеров было невозможно одновременно запустить Compose и игровой движок.
При разработке приложений на движках реального времени мы также фактически жили в двух разных парадигмах.
Для разработчиков Android среда выполнения вашего приложения построена на основе Jetpack Compose.
Он разработан для быстрого и адаптивного пользовательского интерфейса, эффективного использования батареи, динамических макетов, специальных возможностей системы и удобного ввода текста.
Она бездействует, когда ничего не происходит, и это предсказуемо.
Для разработчиков игровых движков, таких как Godot и Unity, важен рендеринг в реальном времени.
Они не просто ждут взаимодействия с пользователем.
Они постоянно выполняют цикл отрисовки и обновления.
Это может быть 60, 90, 100 кадров в секунду.
Они выполняют физические расчеты, сложные шейдеры, скелетную анимацию, пространственный звук и все это с невероятной производительностью.
Но что, если вы хотите получить лучшее из обоих миров?
Раньше разработчикам приходилось выбирать один из этих двух подходов.
Вариант А – переписать все приложение в настоящем движке, например Godot или Unity.
Отказ от существующей базы кода означает потерю большей части нативного интерфейса Compose, повторную реализацию базового текста и навигации, а также написание оболочек для API Android, таких как преобразование текста в речь.
Вариант Б – создать конвейер рендеринга низкоуровневой 3D-графики с нуля в нативном приложении, используя необработанные API Vulkan и OpenXR.
Вместо того чтобы разрабатывать функции продукта, вы, по сути, создаете собственный движок.
Сегодня я покажу, как использовать Godot и Unity в качестве библиотек.
А теперь, с добавлением пространственных контейнеров, вы можете получить лучшее из обоих миров.
Для этого мы можем встроить движок реального времени непосредственно в библиотеку приложения Jetpack.
Вы сохраняете структуру рабочего приложения Android, бизнес-логику и плавный рендеринг пространственного 3D-контента в приложении.
Это также означает, что вы можете использовать один APK-файл для всех типов устройств.
Если вы не хотите показывать 3D-контент, вы можете не загружать библиотеку в действие или фрагмент, чтобы не загромождать базу кода и обеспечить поддержку многих устройств.
Философия проста: отделить приложение и сделать движок реального времени библиотекой.
Вместо того чтобы выбирать между приложением Android и движком, вы рассматриваете движок как встроенную библиотеку программного обеспечения, также известную как зависимость AAR.
Вы можете добавить его прямо в сборку Gradle, как и любую другую библиотеку.
Нативный код Android остается абсолютным мозгом приложения, в котором размещаются 3D-компоненты.
Jetpack Compose XR делает то, что у него получается лучше всего: отрисовывает нативный доступный и плавный интерфейс и управляет состоянием приложения.
Когда требуется 3D-элемент, приложение размещает встроенное представление рендеринга, которое визуализируется самим игровым движком.
Сегодня мы подробно рассмотрим, как это работает в Godot.
И я начну с механики встраивания и установления двунаправленной связи между Kotlin и GDScript.
Затем мы рассмотрим новое расширение OpenXR – пространственный контейнер, который откроет новые возможности многозадачности и пространственного макета.
Давайте вернемся к демонстрации, которую я показывал ранее.
Я создал ее с помощью Godot и Android Studio.
А в этом видео показано, как Jetpack Compose для XR отрисовывает нативные 2D-панели для табло, уведомлений и основных моментов.
Кроме того, Godot визуализирует симуляцию футбольного матча.
Ниже показано, как осуществляется взаимодействие между Kotlin и GDScript.
Для этого в Godot предусмотрена архитектура плагинов.
Вы пишете простой класс Kotlin, который расширяет GodotPlugin.
Этот класс служит интерфейсом между JVM и средой выполнения C++ Godot.
Поэтому взаимодействие между вашим приложением и Godot очень простое.
Архитектура Godot поддерживает двустороннюю связь между Godot и Kotlin.
Когда в Godot происходит игровое событие, например гол или фол, Godot может напрямую вызывать функции Kotlin и обновлять интерфейс Compose в реальном времени.
Когда пользователь взаимодействует с медиапроигрывателем, например ставит видео на паузу или выбирает другой фрагмент, Kotlin отправляет сигнал обратно в Godot, и в игровом движке выполняются соответствующие действия.
После того как плагин Godot будет запущен, эффективность и управление будут зависеть от доменов.
Применяйте рекомендации для приложений Android и проектов Godot.
Теперь рассмотрим архитектуру.
Давайте посмотрим, как подключить это к Godot в приложении для Android.
Среда выполнения движка Godot распространяется через Maven в виде AAR.
Интеграция в сборку выполняется в соответствии со стандартными практиками Android.
Есть один важный шаг, о котором нужно знать сразу, и это то, что я называю ловушкой активов.
androidResources {
ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
}В проекте Godot сцены и файлы конфигурации проекта хранятся вместе.
При встраивании Godot проект добавляется непосредственно в папку объектов.
Это означает, что все импортированные 3D-сетки, сжатые текстуры и скомпилированные текстуры хранятся как артефакт времени выполнения в скрытом файле с именем .godot.
По умолчанию Android удаляет эту папку при сборке, поэтому он игнорирует любой каталог с точкой.
Если вы синхронизируете и соберете приложение обычным образом, APK будет создан, но при загрузке произойдет сбой, поскольку эти элементы не были скомпилированы в APK.
Чтобы исправить это, вы можете явно переопределить параметры AAPT в файле build.gradle и разрешить Godot использовать точки в начале.
После этого вы сможете запустить сцену Godot в редакторе Godot, а затем собрать все остальное в Android Studio.
Чтобы поддерживать пространственные контейнеры и последнюю версию OpenXR, рекомендую использовать Godot 4.8 и более поздние версии, расширения поставщика Godot 5.2 и загрузчик Khronos OpenXR 1.1.63.
dependencies {
// Godot 4.8 Android Library + Godot OpenXR Vendors plugin + Khronos OpenXR Loader
implementation("org.godotengine:godot:4.8.0.dev-SNAPSHOT")
implementation("org.godotengine:godot-openxr-vendors-androidxr:5.1.0-stable")
implementation("org.khronos.openxr:openxr_loader_for_android:1.1.63")
// Jetpack XR SDK (Compose for XR, SceneCore, Runtime)
implementation("androidx.xr.compose:compose:1.0.0-beta02")
implementation("androidx.xr.scenecore:scenecore:1.0.0")
implementation("androidx.xr.runtime:runtime:1.0.0")Как Godot и Compose выполняют рендеринг после упаковки объектов?
Библиотека Godot предоставляет фабричный метод onInitRenderView.
@Composable
fun GodotView(godotHost: GodotHost, modifier: Modifier = Modifier) {
// 1. Initialize Godot's native render surface
val renderView = remember(godotHost) {
godotHost.godot!!.onInitRenderView(godotHost)
}
// 2. Host the SurfaceView inside Jetpack Compose
AndroidView(
modifier = modifier,
factory = { renderView }
)
}Возвращается объект SurfaceView, который Godot использует для рендеринга.
Поэтому вы оборачиваете его в Composable-функцию, и теперь Godot можно масштабировать, дополнять и размещать в любом месте панели сущностей.
Subspace {
SpatialPanel(
modifier = SubspaceModifier
.width(960.dp)
.height(540.dp)
.offset(z = (-200).dp)
.movable()
,
) {
GodotView(
godotHost = godotHost,
modifier = Modifier.fillMaxSize(),
)
}
}Сам движок не может управлять жизненным циклом.
@Composable
fun GodotView(
godotHost: GodotHost,
modifier: Modifier = Modifier,
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
) {
DisposableEffect(lifecycleOwner, godotHost) {
val observer = LifecycleEventObserver { _, event ->
val engine = godotHost.godot ?: return@LifecycleEventObserver
when (event) {
Lifecycle.Event.ON_START -> engine.onStart(godotHost)
….
Lifecycle.Event.ON_DESTROY -> engine.onDestroy(godotHost)
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}Поэтому в вашем действии должен быть реализован интерфейс GodotHost.
Это гарантирует, что все жизненные циклы, происходящие в рамках вашей активности, при паузе, при возобновлении, будут отправлены в сам движок Godot, и теперь он будет знать о жизненном цикле.
Теперь нужно добавить нативное приложение и движок, чтобы они могли взаимодействовать.
Для этого можно использовать интерфейс GodotHost.
class MainActivity : ComponentActivity(), GodotHost {
override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
setOf(SoccerGodotPlugin(engine))
}Мы можем определить плагин Godot и убедиться, что при запуске Godot плагин зарегистрирован со стороны Compose.
Теперь, когда плагин зарегистрирован, давайте настроим Android для связи, отправив сигнал в Godot.
В коде Kotlin мы создадим экземпляр GodotPlugin.
Мы собираемся зарегистрировать плагин в библиотеке Godot.
Затем в Kotlin мы создаем GodotPlugin, регистрируем его название и сообщаем Godot обо всех сигналах, которые хотим использовать в этом пакете.
class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {
override fun getPluginName() = "SoccerPlugin"
// 1. Signals sent from Android Compose UI -> Godot
override fun getPluginSignals() = setOf(
SignalInfo("camera_mode_changed", String::class.java),
SignalInfo("match_command", String::class.java),
)
}Далее у нас есть скрипт Godot, в котором можно получить экземпляр этого плагина.
var soccer_plugin: Object = null
func _ready() -> void:
if Engine.has_singleton("SoccerPlugin"):
soccer_plugin = Engine.get_singleton("SoccerPlugin")
# Listen for signals from Jetpack Compose UI
soccer_plugin.connect("camera_mode_changed", _on_camera_mode_changed)
soccer_plugin.connect("match_command", _on_match_command)Мы можем посмотреть, что зарегистрировано, а затем просто подключить функции к зарегистрированным сигналам.
После вызова emitSignal вы можете передать его из кода Kotlin, и он попадет прямо в Godot.
class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {
fun sendCommandToGodot(command: String) {
emitSignal("match_command", command)
}
}Чтобы Godot мог взаимодействовать с Kotlin, например, когда забит гол, мы можем вызвать функцию Kotlin непосредственно из скрипта Godot, просто используя экземпляр плагина.
func _on_goal_scored(minute: int, home: int, away: int) -> void:
if soccer_plugin:
soccer_plugin.onMatchEvent("GOAL", minute, home, away)Сначала вы добавляете к функции Kotlin аннотацию UsedByGodot.
@UsedByGodot
fun onMatchEvent(type: String, minute: Int, home: Int, away: Int, title: String) {
_uiState.update { state ->
state.copy(homeScore = home, awayScore = away, matchSeconds = minute)
}
}Движок обрабатывает маршалинг JNI в фоновом режиме.
Затем мы можем вызвать функцию непосредственно из Godot Script.
При использовании этого оператора следует помнить, что UsedByGodot требует точного совпадения строк.
Если в скрипте Godot вызывается onMatchEvent, то в Kotlin должно быть указано точно такое же название. Автоматического преобразования в верблюжий или змеиный регистр не происходит.
Поэтому вам просто нужно убедиться, что оба метода абсолютно одинаковы.
После подключения плагина наиболее важной частью этого разговора является то, что я называю ментальной моделью.
Игровые движки и Jetpack Compose работают на двух принципиально противоположных архитектурах.
Игровой движок работает на основе непрерывного тика кадров.
Независимо от того, изменилось ли что-то, игровой движок выполняет тики 60, 90 или 100 раз в секунду, обеспечивая плавный рендеринг в реальном времени.
А движки вроде Godot эффективно используют циклы графического и центрального процессоров.
Jetpack Compose, напротив, обрабатывает данные реактивно и управляется состоянием.
А когда приложение не меняется, Compose буквально продолжает работать, потребляя 0% ресурсов процессора.
Если вы хотите подключить их для запуска двух сред выполнения без четкой стратегии, эти философии, как правило, сталкиваются.
Давайте посмотрим, как мы можем обойти эту проблему с помощью того, что я называю утечкой тиков.
Это 3D-объект, например таймер на табло.
В скрипте двигателя есть переменная таймера ti-, которая постоянно меняется.
И если вы слепо пересылаете эту переменную в Compose, скажем, 90 раз в секунду, вы будете входить в этот цикл рекомпозиции 90 раз в секунду.
Это означает, что Compose будет повторно оценивать ваши Composable-функции, повторно измерять макеты и перерисовывать деревья пользовательского интерфейса 90 раз в секунду.
Вы увидите пропущенные кадры, задержки в интерфейсе и быстро столкнетесь с троттлингом.
Поэтому золотое правило заключается в том, чтобы никогда не позволять непрерывному циклу движка диктовать реактивный цикл пользовательского интерфейса.
В этом примере вместо того, чтобы Godot постоянно обновлял таймер на табло, я просто делаю так, чтобы Android извлекал эти данные раз в секунду, когда это необходимо.
LaunchedEffect(uiState.phase) {
while (uiState.phase == MatchPhase.PLAYING) {
delay(1000L) // Update scoreboard timer at 1 Hz, not 90 Hz
SoccerGodotPlugin.tickLocalClock()
}
}
...
@UsedByGodot
fun onMatchEvent(eventTypeStr: String, matchMinute: Int, ...) {
// Recompose once when threshold is crossed to display the popup banner
_uiState.update { it.copy(activeBannerEvent = event, ...) }
}И наконец, всегда помните о границах цепочки.
Цикл игрового движка Godot постоянно выполняется в собственном потоке рендеринга.
Пользовательские интерфейсы Android должны выполняться в основном потоке Android.
Поэтому всегда следите за тем, чтобы плагин Kotlin отправлял события с помощью Dispatchers.Main или примитивов безопасного состояния, прежде чем обновлять состояния модели представления.
private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
mainScope.launch {
_uiState.update { current ->
current.copy(
homeScore = homeScore,
awayScore = awayScore,
matchSeconds = matchMinute.coerceIn(0, 90),
phase = updatedPhase,
events = newEventsList,
activeBannerEvent = event
)
}Если вы будете выполнять высокочастотные операции в движке, а дискретную семантику – в Compose, то сможете добиться плавной работы 3D-окон просмотра, не жертвуя временем работы от батареи, специальными возможностями и производительностью нативного приложения.
Теперь давайте посмотрим, как добавить пространственные контейнеры в проект Godot.
Сначала настройте приложение для работы в полном пространстве, включите пространственные контейнеры с подсказками по границам, а затем укажите библиотеку загрузчика XR в файле манифеста Android.
<uses-native-library
android:name="libopenxr.google.so"
android:required="false" />
<!-- 1. Launch Activity in Full-Space Managed XR mode -->
<property
android:name="android.window.PROPERTY_XR_ACTIVITY_START_MODE"
android:value="XR_ACTIVITY_START_MODE_FULL_SPACE_MANAGED" />
<!-- 2. OpenXR Spatial Container bounds mode hints -->
<property
android:name="org.khronos.openxr.PROPERTY_ACTIVITY_HINT_XR_SPATIAL_CONTAINER_USED_BOUNDS_MODES"
android:value="" />В Godot есть собственные настройки XR-проекта, где можно включить OpenXR, XR-шейдеры и пространственные контейнеры прямо в редакторе.
Вот и все, что нужно сделать, чтобы включить пространственные контейнеры в Godot.
Теперь, когда вы знаете, как использовать Godot с Android XR, пространственные контейнеры и панели, а также JXR, рекомендуем ознакомиться с дополнительной информацией на сайте для разработчиков.
Мы только что рассмотрели пример использования пространственных контейнеров в Godot Engine и Android XR. Уверен, что многим из вас хотелось бы услышать разработчика, который активно работает с пространственными контейнерами в Android XR.
А теперь я хочу пригласить Джоша и Родриго из NBA. Это один из первых партнеров, которые используют пространственные контейнеры в своем приложении.
Спасибо, Люк.
Всем привет!
Меня зовут Джош, и я разработчик Android в НБА.
Сегодня со мной мой коллега Родриго Санчес, разработчик ПО Unity в Beyond Sports.
Сегодня мы расскажем, как использовали Unity as a Library для создания Court View в приложении NBA.
Мы в НБА всегда стремимся предлагать фанатам новые возможности.
Поэтому вместе с Google и Beyond Sports мы рады представить Court View.
Court View – это 3D-реконструкция игр НБА, которая позволяет подписчикам League Pass смотреть матчи с новых ракурсов, недоступных при обычной трансляции.
Измените размер и поверните корт, чтобы разместить его в удобном месте.
Перемещайтесь вокруг него и наблюдайте за игрой с уникальных ракурсов.
При этом вы можете пользоваться приложением NBA как обычно, например смотреть статистику или прямую трансляцию.
Как все это настроить?
Court View отрисовывается в Unity и используется в нашем приложении для Android с помощью Unity as a Library.
Затем мы создаем единый архив Android, размещенный в Maven Central, который приложение NBA может использовать как единую зависимость.
Это позволило нам разделить работу между Android и Unity.
Таким образом, каждый из нас может сосредоточиться на том, что знает лучше всего, и работать параллельно, чтобы ускорить разработку в целом.
Например, пока наша команда работала над полноэкранным режимом в Compose, команда Unity могла сосредоточиться на масштабировании и макете 3D-корта.
А теперь я передаю слово Родриго, который расскажет о проекте Unity и о том, как они работали с Unity как библиотекой.
Спасибо, Денис.
Мы в Beyond Sports уже несколько лет сотрудничаем с НБА, создавая 3D-визуализации на основе реальных данных матчей.
В рамках этого проекта мы адаптировали нашу 3D-симуляцию для совместимости с Android XR.
Симуляция построена на движке Unity с использованием C#.
Мы экспортировали симуляцию в Android с помощью Unity в качестве библиотеки и создали рядом с ней уровень связи, используя Kotlin.
Прежде чем объединить эти два модуля в одну зависимость Maven, которую НБА включит в свое приложение.
На экране вы можете увидеть короткое видео той же симуляции, которую Джош показывал ранее, только запущенной в редакторе.
Для этого мы используем пакет Android XR Extensions для Unity.
Давайте я помогу вам это сделать.
Поэтому перед сборкой в Unity убедитесь, что вы выбрали правильные настройки и плагины.
Скачайте пакет XR Plug-in Management от Unity и пакет Android XR Extensions для Unity от Google.
Настройте их в разделе XR Plug-in Management (Управление плагинами XR), чтобы использовать поставщика расширений Android XR OpenXR.
В нашем случае мы хотели использовать пространственные контейнеры, поэтому включили эту функцию и отключили иммерсивную XR в настройках управления сеансами.
Мы не использовали пакет ввода XR, так как весь ввод обрабатывается нативно, за пределами Unity.
Затем в настройках проигрывателя укажите, что в проекте должен использоваться уровень API не ниже 24, а в качестве точки входа в приложение задайте Game Activity.
Флажок "Действия" должен быть снят.
Нас интересует только ARM64, поэтому мы выбираем эту целевую архитектуру. В разделе разрешения мы включаем параметр "Выполнять в фоновом режиме", чтобы можно было работать с другими приложениями, пока 3D-симуляция выполняется в фоновом режиме.
Мы также включаем Resizeable Activity, так как это необходимо для отрисовки диалогов, например запросов разрешений.
Наконец, для рендеринга приложений Android XR рекомендуется использовать Universal Render Pipeline и Vulkan Graphics API.
В объекте URP должны быть отключены HDR и постобработка.
После выполнения всех этих шагов вы будете готовы к созданию сборки.
Перейдите в окно сборки. Если вы используете Unity 6.1 или более поздней версии, выберите профиль сборки Android XR. Это позволит вам разделить настройки, если вы также создаете сборку для Android Mobile.
Перед созданием убедитесь, что включен экспорт проекта.
После завершения сборки Unity вы получите рабочий проект для Android, который можно собрать в Android Studio и запустить на устройстве, как и любой другой APK-файл Unity.
В Android Studio вам нужно будет преобразовать экспортированную сборку в библиотеку, которую сможет использовать другое приложение.
// The Unity build
include(":unityLibrary")
project(":unityLibrary").projectDir = File("..\\Libraries\\unityBuild\\unityLibrary")
// Custom communication IPC module
include(":nativeCommunications")
project(":nativeCommunications").projectDir = File("..\\Libraries\\nativeCommunications")
// If using XR features
include(":unityLibrary:xrmanifest.androidlib")
// Used for merging libraries, exporting and publishing.
include(":packageExporter")
dependencyResolutionManagement {
repositories {
flatDir { // Required to load unity correctly
dirs("${project(":unityLibrary").projectDir}/libs")
}
}В нашем проекте четыре модуля: основной модуль APK для тестирования в качестве макета приложения NBA, модуль Unity Library с экспортированной сборкой, модуль nativeCommunications со слоем связи, который мы создали между действиями, и модуль packageExporter, который мы используем для объединения AAR в один пакет для Maven.
Объединить несколько AAR-файлов можно с помощью Android Fused Library или более старых методов, таких как Fat AAR.
Чтобы упростить структуру и обмен данными, можно запустить Unity и основное действие в одном процессе.
Мы отказались от этого и решили выделить действия Unity в отдельный процесс, чтобы в случае сбоя в Unity приложение NBA не пострадало и могло безопасно закрыть Unity, не завершая работу.
Это решение заставило нас внедрить более надежную систему межпроцессного взаимодействия.
Поскольку действия выполняются в разных процессах, у них нет общей памяти, и многие распространенные методы IPC для Android не работают или не соответствуют требованиям.
Для связи между основным действием NBA и встроенным действием Unity мы используем язык описания интерфейсов Android (AIDL).
Обмен данными между Android и Unity также основан на интерфейсах. При этом используется встроенный в Unity класс AndroidJavaProxy, который позволяет зарегистрировать интерфейс Kotlin в реализации интерфейса C#.
Наш модуль коммуникаций в основном сосредоточен на том, что мы называем мостом активности.
// Main Activity
val intent = Intent(context, AIDLBridgeService::class.java)
context.startService(intent)
context.bindService(intent, connection, 0)
fun onStartMatchClicked() {
bridge.runWithService { service ->
service.startMatch("match_id")
}
}
// AIDL Bridge Service
private val remoteBinder = object : IBridgeService.Stub() {
override fun startMatch(matchID: String?) {
addToCallbackQueue { nativeInterface?.startMatch(matchID) }
}
}Оба действия имеют доступ к мосту действий и его служебным методам.
Мост содержит ссылку на сервис, который реализует определенные нами интерфейсы AIDL, а также предоставляет простой способ привязки действий к процессу, извините, к сервису и внутренне управляет его жизненным циклом.
Запустить симуляцию можно с помощью обратного вызова, в котором мы передаем идентификатор матча в качестве параметра.
Затем мост передает его через AIDL в приложение Unity, где C# получает команду и начинает настраивать сцену.
public class NativeInterfaceListener : AndroidJavaProxy {
[UsedImplicitly]
public void startMatch(string matchID) {
UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
}
}Нам нужно дождаться, пока Unity запустится и будет готов к нажатию команд, которые мы ему передаем.
Действие Unity, которое содержит приложение, будет активно до самого Unity.
Если команды отправлены слишком рано, мы сохраняем их в очереди обратных вызовов.
fun onStartMatchClickedInternal() {
if (isServiceBound) {
AIDLBridgeService.startMatch("match_id")
} else {
delayedCallbacksQueue.add(action)
}
}После того как Unity завершит загрузку и инициализацию и мы окажемся в состоянии, которое считаем безопасным, мы сообщим из C#, что Unity готов.
// After bootload and initialization
using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
instance.Call("onUnityReady");После этого мы перебираем нашу очередь и пересылаем все сохраненные команды.
@Suppress("unused") // Unity implicit call
fun onUnityReady() {
bridge.activityBridgeServiceInternal?.onUnityReady()
}
delayedCallbacksQueue.forEach { it(activityBridgeService) }На этой диаграмме показано, как работает межпроцессное взаимодействие.
Как видите, с помощью моста между действиями мы можем обрабатывать упомянутые обратные вызовы.
Мы настроили StateFlows, которые обновляются из Unity, чтобы отслеживать такие значения, как статус аутентификации или временная метка текущего состояния матча.
Кроме того, мы управляем шифрованием учетных данных и пересылаем любые ошибки из Unity, чтобы НБА было проще выполнять отладку и решать, как лучше всего обрабатывать их на уровне приложения.
У меня все.
А теперь я передаю слово Джошу, который расскажет, как пакет Court View Unity интегрирован в приложение NBA.
Мы используем эти зависимости Gradle, чтобы интегрировать Unity как библиотеку с нашим приложением Jetpack Compose: XR Runtime для доступа к 3D-сеансу, Jetpack SceneCore для размещения активности Unity и переключения пространственных сред, а также Jetpack Compose для XR, чтобы получить доступ к пространственным панелям в коде Compose.
dependencies {
implementation("androidx.xr.runtime:runtime:1.0.0")
implementation("androidx.xr.scenecore:scenecore:1.0.0")
implementation("androidx.xr.compose:compose:1.0.0-beta02")
}Теперь давайте посмотрим, как мы отображаем Court View в Jetpack Compose.
// Step 1: Request Full Space
LocalSession.current?.scene?.requestFullSpace()
// Step 2: A shared Subspace for NBA app content and embedded Unity Activity.
override fun onCreate(savedInstanceState: Bundle?){
setContent {
Subspace {
SpatialPanel { AppContent() }
// Court View
val intent = courtViewFeature.intent
val controller = rememberSpatialActivityPanelController(intent)
SpatialActivityPanel(controller)
}
}
}Перед запуском Court View мы перенесем вас в полное пространство.
Это необходимо для отображения пространственной панели с Unity.
Затем мы отправляем информацию об игре через AIDL, чтобы запустить Court View для определенной игры.
Затем запускается Unity.
Наш архив Android отправляет это как намерение в Unity Activity, и мы упаковываем его в SpatialActivityPanelController.
Затем мы передаем этот контроллер в SpatialActivityPanel.
На этой панели находится Court View, и она находится в нашем подпространстве вместе с нашей основной пространственной панелью.
Это позволяет нам управлять обоими устройствами одновременно без каких-либо проблем.
нужно будет
Я отправлю его обратно Люку.
Большое спасибо.
Как видите, NBA удалось ускорить разработку Court View, разделив работу над Unity и Jetpack Compose.
Чтобы узнать больше о том, как начать работу с пространственными контейнерами и использовать их в своих приложениях Android XR, ознакомьтесь с документацией для разработчиков на сайте developer.android.com/develop/xr.
Спасибо, что присоединились к нам сегодня. С нетерпением ждем ваших новых проектов.
androidResources {
ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
}dependencies {
// Godot 4.8 Android Library + Godot OpenXR Vendors plugin + Khronos OpenXR Loader
implementation("org.godotengine:godot:4.8.0.dev-SNAPSHOT")
implementation("org.godotengine:godot-openxr-vendors-androidxr:5.1.0-stable")
implementation("org.khronos.openxr:openxr_loader_for_android:1.1.63")
// Jetpack XR SDK (Compose for XR, SceneCore, Runtime)
implementation("androidx.xr.compose:compose:1.0.0-beta02")
implementation("androidx.xr.scenecore:scenecore:1.0.0")
implementation("androidx.xr.runtime:runtime:1.0.0")@Composable
fun GodotView(godotHost: GodotHost, modifier: Modifier = Modifier) {
// 1. Initialize Godot's native render surface
val renderView = remember(godotHost) {
godotHost.godot!!.onInitRenderView(godotHost)
}
// 2. Host the SurfaceView inside Jetpack Compose
AndroidView(
modifier = modifier,
factory = { renderView }
)
}Subspace {
SpatialPanel(
modifier = SubspaceModifier
.width(960.dp)
.height(540.dp)
.offset(z = (-200).dp)
.movable()
,
) {
GodotView(
godotHost = godotHost,
modifier = Modifier.fillMaxSize(),
)
}
}@Composable
fun GodotView(
godotHost: GodotHost,
modifier: Modifier = Modifier,
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
) {
DisposableEffect(lifecycleOwner, godotHost) {
val observer = LifecycleEventObserver { _, event ->
val engine = godotHost.godot ?: return@LifecycleEventObserver
when (event) {
Lifecycle.Event.ON_START -> engine.onStart(godotHost)
….
Lifecycle.Event.ON_DESTROY -> engine.onDestroy(godotHost)
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}class MainActivity : ComponentActivity(), GodotHost {
override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
setOf(SoccerGodotPlugin(engine))
}class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {
override fun getPluginName() = "SoccerPlugin"
// 1. Signals sent from Android Compose UI -> Godot
override fun getPluginSignals() = setOf(
SignalInfo("camera_mode_changed", String::class.java),
SignalInfo("match_command", String::class.java),
)
}var soccer_plugin: Object = null
func _ready() -> void:
if Engine.has_singleton("SoccerPlugin"):
soccer_plugin = Engine.get_singleton("SoccerPlugin")
# Listen for signals from Jetpack Compose UI
soccer_plugin.connect("camera_mode_changed", _on_camera_mode_changed)
soccer_plugin.connect("match_command", _on_match_command)class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {
fun sendCommandToGodot(command: String) {
emitSignal("match_command", command)
}
}func _on_goal_scored(minute: int, home: int, away: int) -> void:
if soccer_plugin:
soccer_plugin.onMatchEvent("GOAL", minute, home, away)@UsedByGodot
fun onMatchEvent(type: String, minute: Int, home: Int, away: Int, title: String) {
_uiState.update { state ->
state.copy(homeScore = home, awayScore = away, matchSeconds = minute)
}
}LaunchedEffect(uiState.phase) {
while (uiState.phase == MatchPhase.PLAYING) {
delay(1000L) // Update scoreboard timer at 1 Hz, not 90 Hz
SoccerGodotPlugin.tickLocalClock()
}
}
...
@UsedByGodot
fun onMatchEvent(eventTypeStr: String, matchMinute: Int, ...) {
// Recompose once when threshold is crossed to display the popup banner
_uiState.update { it.copy(activeBannerEvent = event, ...) }
}private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
mainScope.launch {
_uiState.update { current ->
current.copy(
homeScore = homeScore,
awayScore = awayScore,
matchSeconds = matchMinute.coerceIn(0, 90),
phase = updatedPhase,
events = newEventsList,
activeBannerEvent = event
)
}<uses-native-library
android:name="libopenxr.google.so"
android:required="false" />
<!-- 1. Launch Activity in Full-Space Managed XR mode -->
<property
android:name="android.window.PROPERTY_XR_ACTIVITY_START_MODE"
android:value="XR_ACTIVITY_START_MODE_FULL_SPACE_MANAGED" />
<!-- 2. OpenXR Spatial Container bounds mode hints -->
<property
android:name="org.khronos.openxr.PROPERTY_ACTIVITY_HINT_XR_SPATIAL_CONTAINER_USED_BOUNDS_MODES"
android:value="" />// The Unity build
include(":unityLibrary")
project(":unityLibrary").projectDir = File("..\\Libraries\\unityBuild\\unityLibrary")
// Custom communication IPC module
include(":nativeCommunications")
project(":nativeCommunications").projectDir = File("..\\Libraries\\nativeCommunications")
// If using XR features
include(":unityLibrary:xrmanifest.androidlib")
// Used for merging libraries, exporting and publishing.
include(":packageExporter")
dependencyResolutionManagement {
repositories {
flatDir { // Required to load unity correctly
dirs("${project(":unityLibrary").projectDir}/libs")
}
}// Main Activity
val intent = Intent(context, AIDLBridgeService::class.java)
context.startService(intent)
context.bindService(intent, connection, 0)
fun onStartMatchClicked() {
bridge.runWithService { service ->
service.startMatch("match_id")
}
}
// AIDL Bridge Service
private val remoteBinder = object : IBridgeService.Stub() {
override fun startMatch(matchID: String?) {
addToCallbackQueue { nativeInterface?.startMatch(matchID) }
}
}public class NativeInterfaceListener : AndroidJavaProxy {
[UsedImplicitly]
public void startMatch(string matchID) {
UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
}
}fun onStartMatchClickedInternal() {
if (isServiceBound) {
AIDLBridgeService.startMatch("match_id")
} else {
delayedCallbacksQueue.add(action)
}
}// After bootload and initialization
using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
instance.Call("onUnityReady");@Suppress("unused") // Unity implicit call
fun onUnityReady() {
bridge.activityBridgeServiceInternal?.onUnityReady()
}
delayedCallbacksQueue.forEach { it(activityBridgeService) }dependencies {
implementation("androidx.xr.runtime:runtime:1.0.0")
implementation("androidx.xr.scenecore:scenecore:1.0.0")
implementation("androidx.xr.compose:compose:1.0.0-beta02")
}// Step 1: Request Full Space
LocalSession.current?.scene?.requestFullSpace()
// Step 2: A shared Subspace for NBA app content and embedded Unity Activity.
override fun onCreate(savedInstanceState: Bundle?){
setContent {
Subspace {
SpatialPanel { AppContent() }
// Court View
val intent = courtViewFeature.intent
val controller = rememberSpatialActivityPanelController(intent)
SpatialActivityPanel(controller)
}
}
}