Android XR

Как улучшить эффект присутствия с помощью 3D-движков в Jetpack Compose для XR

Колонки

  • Люк Хопкинс (Luke Hopkins)

    Инженер по связям с разработчиками Android XR

  • Джошуа Лоретт (Joshua Lorett)

    Штатный инженер Android

  • Родриго Санчес (Rodrigo Sanchez)

    Разработчик ПО – Beyond Sports

О приложении

О выступлении

Узнайте, как интегрировать 3D-игровые движки (Godot и Unity) с Jetpack Compose в единую базу кода для мобильных устройств и Android XR. Мы расскажем, как встраивать 3D-сцены в пространственные контейнеры и настраивать связь между Kotlin и движком, а также покажем, как NBA создала трансляции баскетбольных матчей в реальном времени на Android XR.

Сводка

  • Расширение OpenXR Spatial Containers позволяет 3D-движкам делить пространство с нативным интерфейсом Android. Вместо того чтобы занимать весь скайбокс, движки реального времени работают внутри управляемого 3D-объема вместе с пространственными панелями Jetpack XR.
  • Разработчики могут встраивать движки реального времени непосредственно в приложения Jetpack Compose в виде библиотек AAR. При таком подходе бизнес-логика и интерфейс обрабатываются нативными средствами Android, а движок отвечает за рендеринг 3D-компонентов.
  • Для встраивания Godot требуется зависимость AAR и правило сборки AAPT, чтобы сохранить скрытые объекты. Затем подкласс GodotPlugin обрабатывает двунаправленную связь с Kotlin.
  • Циклы интерфейса Compose должны быть отделены от непрерывных циклов тиков движка. Слепая пересылка обновлений движка вызывает постоянную рекомпозицию и задержку пользовательского интерфейса.
  • Unity интегрирован в приложение NBA как встроенная библиотека в отдельном процессе, который использует AIDL для межпроцессного взаимодействия. Это защищает основное приложение от сбоев.

Ресурсы

Gemini Notebook

Темы

  • android-xr
  • jetpack-compose
  • spatial-containers
  • openxr
  • godot
  • единство

Расшифровка

Знакомство с Android XR

  • Здравствуйте!

  • Меня зовут Люк Хопкинс.

  • Я инженер по связям с разработчиками в команде 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 кадров в секунду.

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

Подходы к интеграции 3D-движков

  • Но что, если вы хотите получить лучшее из обоих миров?

  • Раньше разработчикам приходилось выбирать один из этих двух подходов.

  • Вариант А – переписать все приложение в настоящем движке, например 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 и 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 в Jetpack Compose

  • Как 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. Это один из первых партнеров, которые используют пространственные контейнеры в своем приложении.

Обзор Court View в НБА

  • Спасибо, Люк.

  • Всем привет!

  • Меня зовут Джош, и я разработчик 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 как библиотекой.

Интеграция 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.

  • Спасибо, что присоединились к нам сегодня. С нетерпением ждем ваших новых проектов.

Код

Фрагменты кода

Фрагмент скрипта Kotlin на 09:30 в разделе "Обмен данными между Kotlin и Godot"
androidResources {
     ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
 }
Фрагмент кода Kotlin на 10:27 в разделе "Обмен данными между Kotlin и Godot"
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")
Фрагмент кода на Kotlin в видео "Рендеринг Godot в Jetpack Compose"
@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 }
    )
}
Фрагмент кода на Kotlin в видео "Рендеринг Godot в Jetpack Compose"
Subspace {
        SpatialPanel(
            modifier = SubspaceModifier
                .width(960.dp)
                .height(540.dp)
                .offset(z = (-200).dp)
                .movable()
,
    ) {
        GodotView(
            godotHost = godotHost,
            modifier = Modifier.fillMaxSize(),
        )
    }
}
Фрагмент кода Kotlin на 11:05 в видео "Рендеринг Godot в Jetpack Compose"
@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)
       }
   }

}
Фрагмент кода на Kotlin в видео "Рендеринг Godot в Jetpack Compose"
class MainActivity : ComponentActivity(), GodotHost {
    override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
        setOf(SoccerGodotPlugin(engine))
}
Фрагмент кода на Kotlin в видео "Рендеринг Godot в Jetpack Compose"
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),
    )

}
Фрагмент текста на отметке 12:01 в видео "Рендеринг Godot в Jetpack Compose"
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)
Фрагмент кода Kotlin на 12:10 в видео "Рендеринг Godot в Jetpack Compose"
class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {

    fun sendCommandToGodot(command: String) {
        emitSignal("match_command", command)
    }
}
Текстовый фрагмент на отметке 12:20 в видео "Рендеринг Godot в Jetpack Compose"
func _on_goal_scored(minute: int, home: int, away: int) -> void:

    if soccer_plugin:
        soccer_plugin.onMatchEvent("GOAL", minute, home, away)
Фрагмент кода Kotlin на 12:30 в видео "Рендеринг Godot в Jetpack Compose"
@UsedByGodot
fun onMatchEvent(type: String, minute: Int, home: Int, away: Int, title: String) {

        _uiState.update { state ->
            state.copy(homeScore = home, awayScore = away, matchSeconds = minute)
        }
    }
Фрагмент кода на Kotlin в видео "Рендеринг Godot в Jetpack Compose"
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, ...) }
}
Фрагмент кода на Kotlin в видео "Рендеринг Godot в Jetpack Compose"
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
       )
   }
Фрагмент XML-кода на 15:22 в видео "Отрисовка Godot в Jetpack Compose"
<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="" />
Фрагмент скрипта Kotlin на 20:41 в видео "Интеграция Unity в качестве библиотеки"
// 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")
}
}
Фрагмент кода Kotlin на 22:11 в видео "Интеграция Unity в качестве библиотеки"
// 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) }
}
}
Фрагмент кода на C# в видео "Интеграция Unity в качестве библиотеки"
public class NativeInterfaceListener : AndroidJavaProxy {
[UsedImplicitly]
public void startMatch(string matchID) {
   UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
}
}
Фрагмент кода Kotlin на 22:54 в видео "Интеграция Unity в качестве библиотеки"
fun onStartMatchClickedInternal() {
   if (isServiceBound) {
       AIDLBridgeService.startMatch("match_id")
   } else {
       delayedCallbacksQueue.add(action)
   }
}
Фрагмент кода на C# в видео "Интеграция Unity в качестве библиотеки"
// After bootload and initialization

using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
instance.Call("onUnityReady");
Фрагмент кода Kotlin на 23:06 в видео "Интеграция Unity в качестве библиотеки"
@Suppress("unused") //  Unity implicit call
fun onUnityReady() {
   bridge.activityBridgeServiceInternal?.onUnityReady()
}

delayedCallbacksQueue.forEach { it(activityBridgeService) }
Фрагмент кода Kotlin Script на 23:50 в видео "Интеграция Unity в качестве библиотеки"
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")
}
Фрагмент кода Kotlin на 24:10 в видео "Интеграция Unity в качестве библиотеки"
// 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)
        }
    }
}