Android XR

Améliorez les expériences immersives avec les moteurs 3D dans Jetpack Compose pour la XR

Haut-parleurs

À propos

À propos de l'intervention

Découvrez comment intégrer des moteurs de jeu 3D (Godot et Unity) à Jetpack Compose dans un codebase unique pour mobile et Android XR. Nous verrons comment intégrer des scènes 3D dans des conteneurs spatiaux et configurer la communication entre Kotlin et le moteur, avec un aperçu exclusif de la façon dont la NBA a créé son expérience de basket-ball en direct sur Android XR.

Résumé

  • L'extension OpenXR Spatial Containers permet aux moteurs 3D de partager de l'espace avec l'UI Android native. Au lieu de prendre le contrôle de la skybox, les moteurs en temps réel s'exécutent dans un volume 3D géré, aux côtés des panneaux spatiaux Jetpack XR.
  • Les développeurs peuvent intégrer des moteurs en temps réel directement dans les applications Jetpack Compose sous forme de bibliothèques AAR. Cette approche permet à Android natif de gérer la logique métier et l'UI, tandis que le moteur affiche les composants 3D.
  • L'intégration de Godot nécessite une dépendance AAR et une règle de compilation AAPT pour conserver les éléments masqués. Une sous-classe GodotPlugin gère ensuite la communication bidirectionnelle avec Kotlin.
  • Les boucles d'interface utilisateur Compose doivent rester dissociées des boucles de tick de moteur continu. Transférer aveuglément les mises à jour du moteur déclenche une recomposition constante et entraîne un décalage de l'UI.
  • L'application NBA intègre Unity en tant que bibliothèque intégrée dans un processus distinct à l'aide d'AIDL pour la communication entre processus, ce qui protège l'application hôte des plantages.

Ressources

Gemini Notebook

Thèmes

  • android-xr
  • jetpack-compose
  • spatial-containers
  • openxr
  • godot
  • unité

Transcription

Présentation d'Android XR

  • Bonjour !

  • Je m'appelle Luke Hopkins.

  • Je suis ingénieur DevRel dans l'équipe Android XR.

  • Dans cette présentation, nous allons voir comment intégrer des moteurs de jeu, comme Godot et Unity, dans votre projet Android.

  • Plus tard, je serai rejoint par Josh et Rodrigo de la NBA et de Beyond Sports, qui expliqueront comment ils ont implémenté Unity en tant que bibliothèque dans leur application NBA.

  • Dans cette session, nous allons voir comment intégrer Godot à votre application Jetpack Compose. Ensuite, Josh et Rodrigo vous présenteront leur application NBA.

  • Android XR est une extension de l'écosystème Android qui s'appuie sur un large éventail de facteurs de forme pour donner une nouvelle dimension aux expériences que vous connaissez et appréciez.

  • Nous avons fait en sorte qu'il soit intuitif d'intégrer vos applications XR et Android existantes dans ce nouvel espace, et nous exploitons les technologies Android existantes que vous utilisez déjà aujourd'hui.

  • Android XR est une plate-forme unifiée conçue pour un large éventail d'appareils.

  • Les appareils Android XR proposent différents types d'expériences.

  • Nous proposons des expériences augmentées pour l'audio et les lunettes avec affichage, ainsi que des expériences immersives pour les casques XR et les lunettes XR filaires.

  • Aujourd'hui, nous allons voir comment créer des expériences immersives pour les lunettes XR filaires et les casques XR.

  • Vous pouvez créer des expériences immersives à l'aide de notre SDK Jetpack XR ou de OpenXR avec un moteur en temps réel.

  • Nous prenons en charge Unity, Godot et Unreal pour que vous puissiez créer des expériences immersives pour Android, quel que soit l'outil que vous utilisez.

  • Dans cette présentation, je vais vous expliquer comment utiliser l'API SceneCore de Jetpack, ainsi que Godot et une nouvelle extension OpenXR, pour y parvenir.

  • Ici, vous pouvez voir Godot s'exécuter en même temps que les panneaux spatiaux de Jetpack.

  • Notez que Godot est affiché en tant que contenu spatial, fournissant un volume 3D à côté de l'UI Compose 2D dans un conteneur spatial.

  • Cette approche permet aux développeurs d'exécuter des simulations physiques et des nuanceurs personnalisés à l'aide du moteur en temps réel de leur choix, tout en appliquant des pratiques de développement Android natives au reste de l'application.

  • Avec Android XR et XREAL AURA, vous pouvez vous aussi créer des expériences de ce type.

  • Dans cette présentation, nous allons vous expliquer comment créer cette expérience à l'aide de Godot et d'Android Studio. Vous entendrez ensuite nos partenaires de la NBA et de Beyond Sports expliquer comment ils l'ont créée en utilisant Unity comme bibliothèque.

Conteneurs spatiaux

  • Par le passé, créer une expérience de XR avec OpenXR signifiait créer une expérience qui affiche chaque pixel.

  • Cela signifie que si un développeur doit afficher une deuxième activité, il ne peut pas afficher les deux en même temps.

  • Pour résoudre ce problème, Khronos, le groupe de travail OpenXR, en collaboration avec plusieurs leaders du secteur, a publié l'extension OpenXR Spatial Containers.

  • Considérez les conteneurs spatiaux comme des OpenXR dans une boîte.

  • Au lieu que votre moteur soit propriétaire de l'ensemble de la skybox, il se voit attribuer un volume 3D géré par le système, qui peut coexister avec les panneaux spatiaux XR de Jetpack, d'autres conteneurs spatiaux et les activités Android standards.

  • Imaginons que vous souhaitiez utiliser Android XR en affichant deux panneaux spatiaux en même temps, tout en exécutant un jeu immersif.

  • Vous effectuez le rendu de tout dans le moteur, ce qui permet, dans ces cas d'utilisation, d'exécuter l'éditeur Godot et un conteneur spatial en même temps.

  • Depuis le niveau 4 de l'API Spatial, Google a implémenté les conteneurs spatiaux dans Android XR.

  • Les conteneurs spatiaux sont compatibles avec deux modes d'affichage essentiels.

  • Le premier est le mode limité.

  • Cela fournit un volume 3D défini avec plusieurs évitements intégrés.

  • Un utilisateur peut saisir votre conteneur spatial, le faire pivoter, le positionner, le réduire, et il est compatible avec la projection de profondeur stéréo, ce qui vous donne cette fenêtre magique ou cet effet d'affichage 3D que vous avez tout en découpant les bords du conteneur.

  • Le second est le mode non limité.

  • Si l'utilisateur décide de passer en immersion totale, votre application peut demander une transition fluide de son mode délimité vers le mode Full Space, puis revenir au mode délimité.

  • Avec l'ajout des conteneurs spatiaux, Android XR ouvre la voie à de nouvelles idées de grande envergure.

  • Imaginons que vous souhaitiez intégrer votre UI Android à votre jeu ou créer un nuanceur personnalisé pour afficher du contenu 3D dans votre application.

  • Avant l'introduction des conteneurs spatiaux, il était impossible d'exécuter Compose et un moteur de jeu en même temps.

  • En ce qui concerne le développement d'applications dans les moteurs en temps réel, nous avons également vécu dans deux paradigmes différents.

  • Pour les développeurs Android natifs, l'environnement d'exécution de votre application est basé sur Jetpack Compose.

  • Il est conçu pour offrir une interface utilisateur rapide et réactive, une efficacité de la batterie, des mises en page dynamiques, une accessibilité du système et une saisie de texte fluide.

  • Il est inactif quand rien ne se passe, et il est prévisible.

  • Pour les développeurs de moteurs de jeu, le domaine est le rendu en temps réel, où des outils comme Godot et Unity excellent.

  • Ils n'attendent pas simplement l'interaction de l'utilisateur.

  • Ils exécutent une boucle continue de tick et de rendu.

  • La fréquence d'images peut donc être de 60, 90 ou 100 images par seconde.

  • Ils effectuent des calculs physiques, des nuanceurs complexes, des animations squelettiques, du son spatial, et tout cela avec des performances incroyables.

Approches pour intégrer des moteurs 3D

  • Comment faire si vous voulez profiter du meilleur des deux mondes ?

  • Auparavant, les développeurs devaient implémenter l'une de ces deux approches.

  • L'option A consiste à réécrire l'intégralité de votre application dans un véritable moteur comme Godot ou Unity.

  • Supprimer votre codebase existant signifie perdre une grande partie de votre UI Compose native, réimplémenter le texte et la navigation de base, et écrire des wrappers autour des API Android telles que la synthèse vocale.

  • L'option B consiste à créer un pipeline de rendu graphique 3D de bas niveau à partir de zéro dans votre application native à l'aide des API Vulkan et OpenXR brutes.

  • Au lieu de proposer des fonctionnalités de produit, vous créez en quelque sorte votre propre moteur propriétaire.

  • Aujourd'hui, je vais vous montrer comment exploiter au mieux Godot et Unity en tant que bibliothèques.

  • Grâce à l'ajout de conteneurs spatiaux, vous pouvez désormais profiter des avantages de ces deux approches.

  • Pour ce faire, nous pouvons intégrer un moteur en temps réel directement dans une bibliothèque de votre application Jetpack.

  • Vous conservez la structure de l'application Android de production, la logique métier et l'affichage fluide du contenu spatial 3D dans votre application.

  • Cela signifie également que vous pouvez avoir un seul APK pour tous les facteurs de forme.

  • Si vous ne souhaitez pas afficher de contenu 3D, vous pouvez choisir de ne pas charger la bibliothèque dans l'activité ou le fragment, et de conserver votre codebase propre. Cela vous permet de prendre en charge de nombreux appareils.

  • La philosophie est simple : dissocier l'application et faire du moteur en temps réel une bibliothèque.

  • Au lieu de choisir entre une application Android ou un moteur, vous traitez le moteur comme une bibliothèque logicielle intégrée, également appelée dépendance AAR.

  • Vous l'intégrez directement dans votre build Gradle, comme n'importe quelle autre bibliothèque.

  • Votre code Android natif reste le cerveau absolu de l'application hébergeant les composants 3D.

  • Jetpack Compose XR gère ce qu'il fait de mieux : afficher une UI fluide, native et accessible, et gérer l'état de l'application.

  • Lorsqu'un élément 3D est nécessaire, l'application héberge une vue de rendu intégrée qui est affichée par le moteur de jeu lui-même.

  • Aujourd'hui, nous allons examiner en détail le fonctionnement de cette fonctionnalité pour Godot.

  • Je vais commencer par les mécanismes d'intégration et d'établissement d'une communication bidirectionnelle entre Kotlin et GDScript.

  • Nous examinerons ensuite la nouvelle extension OpenXR, Spatial Container, qui permettra d'accéder à une nouvelle génération de multitâche et de mise en page spatiale.

  • Jetons un coup d'œil à la démonstration que j'ai présentée précédemment.

  • Je l'ai créé avec Godot et Android Studio.

  • Cet extrait montre Jetpack Compose pour XR, qui affiche des panneaux spatiaux d'UI 2D natifs pour notre tableau d'affichage, nos notifications et nos temps forts.

  • En plus de cela, Godot affiche une simulation d'un match de football.

Communication entre Kotlin et Godot

  • Vous pouvez voir ici comment nous communiquons de Kotlin à GDScript.

  • Pour ce faire, Godot fournit une architecture de plug-in.

  • Vous écrivez une classe Kotlin légère qui étend un GodotPlugin.

  • Cette classe sert d'interface entre la JVM et l'environnement d'exécution C++ de Godot.

  • Par conséquent, les interactions entre votre application et Godot sont très légères.

  • L'architecture de Godot est compatible avec la communication bidirectionnelle entre Godot et Kotlin.

  • Lorsqu'un événement de jeu se produit dans Godot, comme un but ou une faute, Godot peut appeler directement des fonctions Kotlin et mettre à jour l'UI Compose en temps réel.

  • Ensuite, lorsque l'utilisateur interagit avec le lecteur multimédia, par exemple pour mettre en pause ou sélectionner différents temps forts, Kotlin renvoie un signal à Godot, et les actions correspondantes du moteur de jeu se produisent.

  • Une fois le plug-in Godot installé, les performances et la gestion relèvent simplement du domaine.

  • Appliquez les bonnes pratiques standards pour les applications Android et les projets Godot.

  • Nous allons maintenant examiner l'architecture.

  • Voyons comment intégrer cela dans Godot dans une application Android.

  • L'environnement d'exécution du moteur Godot est distribué via Maven en tant qu'AAR.

  • L'intégration à votre build suit simplement les pratiques Android standards.

  • Il y a une étape importante à connaître d'emblée, et c'est ce que j'appelle le piège des composants.

    androidResources {
         ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
     }
  • Dans un projet Godot, vos scènes et vos fichiers de configuration du projet sont stockés ensemble.

  • Lorsque vous intégrez Godot, le projet Godot est ajouté directement dans votre dossier d'assets.

  • Cela signifie que tous les maillages 3D, textures compressées et textures compilées importés sont stockés en tant qu'artefact d'exécution dans un fichier caché appelé .godot.

  • Par défaut, Android supprime ce dossier lors de la compilation, de sorte qu'il ignore tout répertoire dont le nom commence par un point.

  • Si vous synchronisez et compilez votre application normalement, l'APK sera compilé, mais il plantera et se chargera, car ces éléments n'ont pas été compilés dans l'APK.

  • Pour résoudre ce problème, vous pouvez simplement remplacer explicitement les options AAPT dans votre fichier build.gradle et autoriser les points de début à partir de Godot.

  • Ensuite, vous pouvez exécuter votre scène Godot une fois dans l'éditeur Godot, puis tout compiler depuis Android Studio.

  • Pour prendre en charge les conteneurs spatiaux et la dernière version de OpenXR, je vous recommande d'utiliser Godot 4.8 ou version ultérieure, les extensions de fournisseur Godot 5.2 et le chargeur 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")

Afficher Godot dans Jetpack Compose

  • Une fois les éléments empaquetés, comment Godot et Compose effectuent-ils le rendu ?

  • La bibliothèque Godot fournit une méthode d'usine appelée 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 }
        )
    }
  • Cela renvoie un SurfaceView sur lequel Godot effectue le rendu.

  • Vous l'encapsulez donc dans une fonction composable, et Godot peut désormais être dimensionné, espacé et placé n'importe où dans un panneau d'entité.

    Subspace {
            SpatialPanel(
                modifier = SubspaceModifier
                    .width(960.dp)
                    .height(540.dp)
                    .offset(z = (-200).dp)
                    .movable()
    ,
        ) {
            GodotView(
                godotHost = godotHost,
                modifier = Modifier.fillMaxSize(),
            )
        }
    }
  • Le moteur lui-même ne peut pas gérer le cycle de vie.

    @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)
           }
       }
    
    }
  • Votre activité doit donc implémenter l'interface GodotHost.

  • Cela permet de s'assurer que tous les cycles de vie qui se produisent dans votre activité (en pause, en reprise) sont envoyés au moteur Godot lui-même, qui est désormais compatible avec les cycles de vie.

  • Nous devons ensuite ajouter notre application native et notre moteur pour qu'ils communiquent de manière fluide.

  • Pour ce faire, nous pouvons utiliser l'interface GodotHost.

    class MainActivity : ComponentActivity(), GodotHost {
        override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
            setOf(SoccerGodotPlugin(engine))
    }
  • Nous pouvons définir le plug-in Godot, et nous assurer que lorsque Godot démarre, le plug-in est enregistré du côté de Compose.

  • Maintenant que le plug-in est enregistré, configurons Android pour qu'il communique en émettant un signal vers Godot.

  • Dans notre code Kotlin, nous allons créer une instance de GodotPlugin.

  • Nous allons faire en sorte que notre activité enregistre le plug-in auprès de la bibliothèque Godot.

  • Ensuite, dans Kotlin, nous créons le GodotPlugin, nous enregistrons son nom et nous indiquons à Godot tous les signaux que nous voulons utiliser dans ce package.

    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),
        )
    
    }
  • Ensuite, nous avons un script Godot, dans lequel vous pouvez obtenir l'instance de ce plug-in.

    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)
  • Nous pouvons voir ce qui est enregistré, puis associer les fonctions aux signaux que nous avons enregistrés.

  • Une fois que vous avez appelé un élément tel que emitSignal, vous pouvez le transmettre à partir de votre code Kotlin, et il est directement envoyé à Godot.

    class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {
    
        fun sendCommandToGodot(command: String) {
            emitSignal("match_command", command)
        }
    }
  • Pour que Godot communique avec Kotlin, par exemple lorsqu'un but est marqué, nous pouvons appeler une fonction Kotlin directement depuis le script Godot, simplement en utilisant l'instance du plug-in.

    func _on_goal_scored(minute: int, home: int, away: int) -> void:
    
        if soccer_plugin:
            soccer_plugin.onMatchEvent("GOAL", minute, home, away)
  • Vous devez d'abord annoter la fonction Kotlin avec 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)
            }
        }
  • Le moteur gère le marshaling JNI en arrière-plan.

  • Nous pouvons ensuite appeler la fonction directement depuis Godot Script.

  • Il y a une astuce rapide à retenir lorsque vous utilisez cette option : UsedByGodot nécessite une correspondance exacte de la chaîne.

  • Ainsi, si votre script Godot appelle onMatchEvent, votre nom Kotlin doit être exactement le même. Il n'y a donc pas de conversion automatique en camelCase ou snake_case.

  • Il vous suffit donc de vous assurer que les deux méthodes sont exactement les mêmes.

  • Une fois le plug-in connecté, la partie la plus importante de cette présentation est ce que j'appelle le modèle mental.

  • C'est là que les moteurs de jeu et Jetpack Compose fonctionnent sur deux architectures fondamentalement opposées.

  • Un moteur de jeu fonctionne sur un tick d'image continu.

  • Que vous ayez modifié quelque chose ou non, le processus de tick du moteur de jeu se produit 60, 90 ou 100 fois par seconde, offrant un rendu en temps réel riche et fluide.

  • Et les moteurs comme Godot utilisent efficacement les cycles du GPU et du CPU.

  • Jetpack Compose, en revanche, gère les choses de manière réactive et est basé sur l'état.

  • Et lorsqu'une application ne change pas, Compose continue littéralement, en consommant 0 CPU.

  • Si vous souhaitez les connecter pour exécuter deux environnements d'exécution sans stratégie claire, ces philosophies ont tendance à entrer en conflit.

  • Voyons comment contourner ce problème grâce à ce que j'appelle une "fuite de tick".

  • C'est là que nous avons une expérience 3D, par exemple le minuteur du tableau d'affichage.

  • Dans le script de votre moteur, une variable de minuteur "ti-" change continuellement.

  • Si vous transmettez cette variable à Compose sans réfléchir, par exemple 90 fois par seconde, vous allez entrer dans cette boucle de recomposition 90 fois par seconde.

  • Cela signifie que Compose réévaluera vos composables, mesurera à nouveau les mises en page et redessinera les arborescences d'UI 90 fois par seconde.

  • Vous allez constater des pertes d'images, des ralentissements de l'interface utilisateur et vous allez rapidement atteindre la limitation thermique.

  • La règle d'or est donc de ne jamais laisser la boucle du moteur continu dicter la boucle de l'UI réactive.

  • Dans cet exemple, au lieu que Godot mette à jour le chronomètre du tableau d'affichage en continu, je m'assure simplement qu'Android extrait ces données une fois par seconde lorsqu'il en a besoin.

    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, ...) }
    }
  • Enfin, n'oubliez pas la limite de thread.

  • Une boucle du moteur de jeu Godot s'exécute en continu sur son propre thread de rendu.

  • Les interfaces utilisateur Android doivent s'exécuter sur le thread principal Android.

  • Par conséquent, assurez-vous toujours que votre plug-in Kotlin distribue les événements à l'aide de Dispatchers.Main ou de primitives d'état sécurisé avant de mettre à jour les états de votre modèle de vue.

    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
           )
       }
  • En conservant les tâches à haute fréquence dans le moteur et la sémantique discrète dans Compose, vous pouvez obtenir des fenêtres d'affichage 3D d'une fluidité exceptionnelle sans compromettre l'autonomie de la batterie, l'accessibilité ni les performances de votre application native.

  • Voyons maintenant comment ajouter des conteneurs spatiaux à votre projet Godot.

  • Commençons par configurer l'application pour qu'elle soit en mode Full Space, activer les conteneurs spatiaux avec leurs indices de délimitation, puis définir la bibliothèque de chargeurs XR dans le fichier manifeste 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 possède ses propres paramètres de projet XR, où je peux activer OpenXR, les nuanceurs XR et les conteneurs spatiaux, directement via son éditeur.

  • C'est à peu près tout ce que vous devez faire pour activer les conteneurs spatiaux dans Godot.

  • Maintenant que vous avez réussi à faire fonctionner Godot avec Android XR, les conteneurs spatiaux et tout ce que nous avons développé, ainsi que les bonnes pratiques d'utilisation des conteneurs spatiaux, des panneaux spatiaux et de JXR, nous vous recommandons vivement de consulter le site Web pour les développeurs pour en savoir plus.

  • Nous venons de voir un exemple d'utilisation des conteneurs spatiaux dans Godot Engine et Android XR. Je suis sûr que beaucoup d'entre vous aimeraient entendre le témoignage d'un développeur qui travaille activement sur les conteneurs spatiaux pour son expérience Android XR.

  • Je vais maintenant inviter Josh et Rodrigo de la NBA, l'un des premiers partenaires à utiliser les conteneurs spatiaux dans leur application.

Présentation de NBA Court View

  • Merci, Luke.

  • Bonjour à tous

  • Je m'appelle Josh et je suis développeur Android à la NBA.

  • Je suis accompagné aujourd'hui de mon collègue Rodrigo Sanchez, développeur de logiciels Unity chez Beyond Sports.

  • Aujourd'hui, nous aimerions vous montrer comment nous avons utilisé Unity as a Library pour créer Court View pour l'application NBA.

  • À la NBA, nous cherchons toujours à proposer de nouvelles expériences à nos fans.

  • En collaboration avec Google et Beyond Sports, nous sommes donc ravis de vous présenter Court View.

  • Court View propose des reconstitutions en 3D des matchs de la NBA, ce qui permet aux abonnés League Pass de les regarder sous de nouveaux angles, impossibles à obtenir avec une diffusion vidéo normale.

  • Redimensionnez et faites pivoter le terrain, puis placez-le où vous le souhaitez dans votre espace.

  • Déplacez-vous autour de l'écran et regardez le match sous des angles uniques.

  • Pendant ce temps, vous pouvez utiliser votre application NBA comme d'habitude, qu'il s'agisse de consulter les statistiques de synchronisation ou de regarder la diffusion en direct.

  • Comment tout cela est-il configuré ?

  • Court View est affiché dans Unity et utilisé par notre application Android à l'aide d'Unity as a Library.

  • Nous produisons ensuite une seule archive Android, hébergée sur Maven Central, que l'application NBA peut extraire en tant que dépendance unique.

  • Cela nous a permis de répartir le travail entre les équipes Android et Unity.

  • Ainsi, chacun d'entre nous peut se concentrer sur ce qu'il connaît le mieux et travailler en parallèle pour accélérer le développement global.

  • Par exemple, pendant que notre équipe travaillait sur l'expérience Full Space dans Compose, l'équipe Unity a pu se concentrer sur la mise à l'échelle et la mise en page du terrain en 3D.

  • Je vais maintenant laisser la parole à Rodrigo, qui va vous en dire plus sur le projet Unity et sur la façon dont ils ont utilisé Unity as a Library.

Intégrer Unity en tant que bibliothèque

  • Merci, Josh.

  • Chez Beyond Sports, nous travaillons avec la NBA depuis quelques années pour représenter des visualisations 3D avec des données réelles issues des matchs.

  • Avec ce projet, nous avons adapté notre simulation 3D pour qu'elle soit compatible avec Android XR.

  • Notre simulation est basée sur le moteur Unity avec C#.

  • Nous exportons la simulation vers Android avec Unity en tant que bibliothèque et avons créé une couche de communication à côté à l'aide de Kotlin.

  • avant de regrouper ces deux modules dans une seule dépendance Maven que la NBA pourra inclure dans son application.

  • À l'écran, vous pouvez voir une courte vidéo de la même simulation que celle que Josh a montrée plus tôt, mais exécutée dans l'éditeur.

  • Pour ce faire, nous utilisons le package d'extensions Android XR pour Unity.

  • Je vais vous guider tout au long du processus.

  • Avant de créer votre projet dans Unity, assurez-vous d'avoir sélectionné les bons paramètres et plug-ins.

  • Obtenez le package XR Plug-in Management d'Unity et le package Android XR Extensions de Google pour Unity.

  • Configurez-les dans les paramètres de gestion des plug-ins XR pour utiliser le fournisseur d'extensions Android XR OpenXR.

  • Pour notre cas d'utilisation, nous voulions utiliser des conteneurs spatiaux. Nous avons donc activé cette fonctionnalité et désactivé la XR immersive dans les paramètres de gestion des sessions.

  • Nous n'avons pas utilisé de package d'entrée XR, car toutes les entrées sont gérées de manière native, en dehors d'Unity.

  • Ensuite, dans les paramètres du lecteur, vous devez configurer votre projet pour qu'il utilise un niveau d'API minimal de 24 ou plus, et le point d'entrée de l'application doit être défini sur la nouvelle GameActivity.

  • Notez que l'option "Activité" doit rester décochée.

  • Nous ne souhaitons créer que pour ARM64. Nous sélectionnons donc cette architecture cible. Dans la section "Resolution" (Résolution), nous activons "Run in Background" (Exécuter en arrière-plan) pour que vous puissiez effectuer plusieurs tâches à la fois avec d'autres applications et que la simulation 3D continue de s'exécuter en parallèle.

  • Nous activons également l'activité redimensionnable, car elle est nécessaire pour afficher les boîtes de dialogue, telles que les demandes d'autorisation système.

  • Enfin, nous vous recommandons d'effectuer le rendu des applications Android XR à l'aide du pipeline de rendu universel et de l'API graphique Vulkan.

  • Dans votre asset URP, le HDR et le post-traitement doivent être désactivés.

  • Une fois toutes ces étapes terminées, vous êtes prêt à créer un build.

  • Accédez à la fenêtre de compilation. Si vous utilisez Unity 6.1 ou une version ultérieure, vous pouvez sélectionner le profil de compilation Android XR, qui vous permet de séparer les paramètres si vous compilez également pour Android Mobile.

  • Avant de créer, assurez-vous d'activer l'exportation du projet.

  • Une fois la compilation terminée, vous obtiendrez un projet Android fonctionnel qui pourra être assemblé dans Android Studio et exécuté sur un appareil comme n'importe quel autre APK Unity.

  • Une fois dans Android Studio, vous devez transformer votre build exporté en bibliothèque pour qu'il puisse être utilisé par une autre application.

    // 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")
    }
    }
  • Dans notre projet, nous avons quatre modules différents : le module APK principal à tester en tant qu'espace réservé pour l'application d'encapsulation de la NBA, le module de bibliothèque Unity contenant notre build exporté, un module nativeCommunications contenant notre couche de communication que nous avons créée entre les activités et un module packageExporter, que nous utilisons pour fusionner nos AAR dans un seul package à livrer à Maven.

  • Vous pouvez fusionner plusieurs AAR à l'aide d'Android Fused Library ou d'anciennes méthodes de la communauté, comme Fat AAR.

  • Vous pouvez simplifier votre structure et votre communication en exécutant Unity et l'activité principale dans le même processus.

  • Nous avons décidé de ne pas le faire et de séparer l'activité Unity dans son propre processus, afin de garantir que si la partie Unity plante, l'application NBA ne soit pas affectée et puisse gérer la fermeture d'Unity sans planter elle-même.

  • Cette décision nous a obligés à adopter un système de communication interprocessus plus robuste.

  • Comme les activités se trouvent dans des processus différents, aucune mémoire n'est partagée entre elles, et de nombreuses méthodes IPC Android courantes ne fonctionnent pas ou ne répondent pas aux exigences.

  • Nous utilisons le langage de définition d'interface Android (AIDL) pour communiquer entre l'activité principale de la NBA et notre activité Unity intégrée.

  • La communication entre Android et Unity est également basée sur des interfaces, en utilisant le AndroidJavaProxy intégré dans Unity pour enregistrer une interface Kotlin dans une implémentation d'interface C#.

  • Notre module de communication est principalement axé sur ce que nous appelons la passerelle d'activité.

    // 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) }
    }
    }
  • Les deux activités ont accès au pont d'activité et aux méthodes utilitaires qu'il contient.

  • Le pont contient une référence à un service qui implémente les interfaces AIDL que nous avons définies. Il permet également aux activités de se lier facilement au processus, pardon, au service, et gère en interne son cycle de vie.

  • Pour lancer une simulation, vous pouvez utiliser un rappel dans lequel vous transmettez l'ID du match en tant que paramètre.

  • Ensuite, le pont le transmet via AIDL, puis à l'application Unity, où C# reçoit une commande et commence à configurer la scène.

    public class NativeInterfaceListener : AndroidJavaProxy {
    [UsedImplicitly]
    public void startMatch(string matchID) {
       UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
    }
    }
  • Nous devons attendre qu'Unity soit actif et prêt à exécuter les commandes que nous lui transmettons.

  • L'activité Unity qui encapsule l'application sera active avant Unity lui-même.

  • Si des commandes sont envoyées trop tôt, nous les stockons dans une file d'attente de rappel.

    fun onStartMatchClickedInternal() {
       if (isServiceBound) {
           AIDLBridgeService.startMatch("match_id")
       } else {
           delayedCallbacksQueue.add(action)
       }
    }
  • Une fois que Unity a terminé le démarrage et l'initialisation, et que nous sommes dans un état que nous considérons comme sûr, nous communiquons depuis C# que Unity est prêt.

    // After bootload and initialization
    
    using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
    using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
    instance.Call("onUnityReady");
  • Ensuite, nous parcourons notre file d'attente et transférons toutes nos commandes stockées.

    @Suppress("unused") //  Unity implicit call
    fun onUnityReady() {
       bridge.activityBridgeServiceInternal?.onUnityReady()
    }
    
    delayedCallbacksQueue.forEach { it(activityBridgeService) }
  • Ce diagramme illustre mieux le fonctionnement de notre communication interprocessus.

  • Comme vous pouvez le voir, en utilisant le pont entre les activités, nous pouvons gérer les rappels que nous avons mentionnés.

  • Nous configurons des StateFlows qui sont mis à jour depuis Unity pour surveiller des valeurs telles que l'état d'authentification ou le code temporel de l'état actuel du match.

  • De plus, nous gérons également le chiffrement des identifiants et nous transmettons toutes les erreurs de Unity afin que la NBA puisse les déboguer plus facilement et décider de la meilleure façon de les gérer au niveau de l'application.

  • C'est tout pour aujourd'hui.

  • Je vais maintenant laisser la parole à Josh pour qu'il vous explique comment ils intègrent le package Unity Court View dans l'application NBA.

  • Nous utilisons ces dépendances Gradle pour intégrer Unity en tant que bibliothèque à notre application Jetpack Compose : XR Runtime pour accéder à la session 3D, Jetpack SceneCore pour héberger notre activité Unity et activer/désactiver les environnements spatiaux, et Jetpack Compose pour XR pour accéder aux panneaux spatiaux dans notre code 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")
    }
  • Voyons maintenant comment afficher Court View dans 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)
            }
        }
    }
  • Avant de lancer Court View, nous vous faisons passer en mode Full Space.

  • Cela est nécessaire pour afficher le panneau spatial contenant Unity.

  • Ensuite, nous envoyons les informations sur le jeu via AIDL pour lancer Court View pour un jeu spécifique.

  • Unity est ensuite démarré.

  • Notre archive Android l'envoie en tant qu'intent à une activité Unity, et nous l'encapsulons dans un SpatialActivityPanelController.

  • Ensuite, nous transmettons simplement ce contrôleur à un SpatialActivityPanel.

  • Ce panneau contient Court View et est partagé dans notre sous-espace avec notre panneau spatial principal.

  • Cela nous permet de contrôler les deux en même temps sans aucun problème.

  • C'est aussi simple que ça.

  • Je vais le renvoyer à Luke.

Conclusion et ressources

  • Merci beaucoup.

  • Comme vous pouvez le voir, la NBA a pu accélérer le développement de Court View en séparant le développement Unity et Jetpack Compose.

  • Pour en savoir plus sur la prise en main des conteneurs spatiaux et leur utilisation dans vos propres applications Android XR, consultez la documentation pour les développeurs sur developer.android.com/develop/xr.

  • Merci beaucoup de votre participation. Nous avons hâte de découvrir vos créations.

Code

Extraits de code

Extrait de script Kotlin à 09:30 dans "Communication between Kotlin and Godot"
androidResources {
     ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
 }
Extrait de script Kotlin à 10:27 dans "Communication between Kotlin and 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")
Extrait de code Kotlin à 10:48 dans "Rendering Godot in 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 }
    )
}
Extrait Kotlin à 10:55 dans "Rendering Godot in Jetpack Compose"
Subspace {
        SpatialPanel(
            modifier = SubspaceModifier
                .width(960.dp)
                .height(540.dp)
                .offset(z = (-200).dp)
                .movable()
,
    ) {
        GodotView(
            godotHost = godotHost,
            modifier = Modifier.fillMaxSize(),
        )
    }
}
Extrait de code Kotlin à 11:05 dans "Rendering Godot in 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)
       }
   }

}
Extrait de code Kotlin à 11:29 dans "Rendering Godot in Jetpack Compose"
class MainActivity : ComponentActivity(), GodotHost {
    override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
        setOf(SoccerGodotPlugin(engine))
}
Extrait de code Kotlin à 11:52 dans "Rendering Godot in 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),
    )

}
Extrait de texte à 12:01 dans "Rendering Godot in 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)
Extrait Kotlin à 12:10 dans "Rendering Godot in Jetpack Compose"
class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {

    fun sendCommandToGodot(command: String) {
        emitSignal("match_command", command)
    }
}
Extrait de texte à 12:20 dans "Rendering Godot in Jetpack Compose"
func _on_goal_scored(minute: int, home: int, away: int) -> void:

    if soccer_plugin:
        soccer_plugin.onMatchEvent("GOAL", minute, home, away)
Extrait Kotlin à 12:30 dans "Rendering Godot in 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)
        }
    }
Extrait de code Kotlin à 14:35 dans "Rendering Godot in 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, ...) }
}
Extrait Kotlin à 14:57 dans "Rendering Godot in 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
       )
   }
Extrait XML à 15:22 dans "Rendering Godot in 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="" />
Extrait de script Kotlin à 20:41 dans "Intégrer Unity en tant que bibliothèque"
// 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")
}
}
Extrait de code Kotlin à 22:11 dans "Integrating Unity as a library"
// 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) }
}
}
Extrait de code C# à 22:36 dans "Integrating Unity as a library"
public class NativeInterfaceListener : AndroidJavaProxy {
[UsedImplicitly]
public void startMatch(string matchID) {
   UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
}
}
Extrait de code Kotlin à 22:54 dans "Intégrer Unity en tant que bibliothèque"
fun onStartMatchClickedInternal() {
   if (isServiceBound) {
       AIDLBridgeService.startMatch("match_id")
   } else {
       delayedCallbacksQueue.add(action)
   }
}
Extrait de code C# à 22:57 dans "Integrating Unity as a library"
// After bootload and initialization

using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
instance.Call("onUnityReady");
Extrait Kotlin à 23:06 dans "Intégrer Unity en tant que bibliothèque"
@Suppress("unused") //  Unity implicit call
fun onUnityReady() {
   bridge.activityBridgeServiceInternal?.onUnityReady()
}

delayedCallbacksQueue.forEach { it(activityBridgeService) }
Extrait de script Kotlin à 23:50 dans "Intégrer Unity en tant que bibliothèque"
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")
}
Extrait de code Kotlin à 24:10 dans "Integrating Unity as a library"
// 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)
        }
    }
}