Android XR

Migliora le esperienze immersive con i motori 3D in Jetpack Compose per XR

Altoparlanti

Informazioni

Informazioni sulla presentazione

Scopri come integrare i motori grafici 3D (Godot e Unity) con Jetpack Compose in un unico codebase per dispositivi mobili e Android XR. Esploreremo l'incorporamento di scene 3D in container spaziali e la configurazione della comunicazione da Kotlin al motore, con uno sguardo esclusivo a come l'NBA ha creato la sua esperienza di basket dal vivo su Android XR.

Riepilogo

  • L'estensione OpenXR Spatial Containers consente ai motori 3D di condividere lo spazio con la UI Android nativa. Anziché occupare lo skybox, i motori in tempo reale vengono eseguiti all'interno di un volume 3D gestito insieme ai pannelli spaziali di Jetpack XR.
  • Gli sviluppatori possono incorporare i motori in tempo reale direttamente nelle applicazioni Jetpack Compose come librerie AAR. Questo approccio consente ad Android nativo di gestire la logica di business e l'UI, mentre il motore esegue il rendering dei componenti 3D.
  • L'incorporamento di Godot richiede una dipendenza AAR e una regola di build AAPT per mantenere gli asset nascosti. Una sottoclasse GodotPlugin gestisce quindi la comunicazione bidirezionale con Kotlin.
  • I loop della UI di Compose devono rimanere disaccoppiati dai loop di tick continui del motore. L'inoltro automatico degli aggiornamenti del motore attiva una ricomposizione costante e causa un ritardo della UI.
  • L'app NBA integra Unity come libreria incorporata in un processo separato utilizzando AIDL per la comunicazione tra processi, proteggendo l'app host da arresti anomali.

Risorse

Gemini Notebook

Argomenti

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

Trascrizione

Scarica la trascrizione

Introduzione ad Android XR

  • Ciao,

  • Mi chiamo Luke Hopkins.

  • Sono un Developer Relations Engineer del team di Android XR.

  • Oggi parleremo di come incorporare motori grafici, come Godot e Unity, nel tuo progetto Android.

  • Più avanti, mi raggiungeranno Josh e Rodrigo di NBA e Beyond Sports, che parleranno di come hanno implementato Unity come libreria nella loro applicazione NBA.

  • In questa sessione, approfondiremo l'integrazione di Godot anche nella tua applicazione Jetpack Compose, poi Josh e Rodrigo illustreranno la loro applicazione NBA.

  • Android XR è un'estensione dell'ecosistema Android che si espande su un'ampia gamma di fattori di forma per portare le esperienze che conosci e ami in una nuova dimensione.

  • Abbiamo reso intuitivo il trasferimento delle tue app XR e app per Android esistenti in questo nuovo spazio e stiamo sfruttando le tecnologie Android esistenti con cui stai già creando app.

  • Android XR è una piattaforma unificata progettata per un'ampia gamma di dispositivi.

  • I dispositivi Android XR supportano diversi tipi di esperienze.

  • Abbiamo esperienze di realtà aumentata per l'audio e gli occhiali con display, nonché esperienze immersive per i visori XR e gli occhiali XR con cavo.

  • Oggi parleremo della creazione di esperienze immersive per occhiali XR con cavo e visori XR.

  • La creazione di esperienze immersive può essere eseguita tramite il nostro SDK Jetpack XR o utilizzando OpenXR con un motore in tempo reale.

  • Supportiamo Unity, Godot e Unreal, così puoi creare le tue esperienze immersive per Android indipendentemente dallo strumento che utilizzi.

  • In questo intervento, spiegherò come sfruttare l'API SceneCore di Jetpack, insieme a Godot e a una nuova estensione OpenXR, per rendere possibile tutto questo.

  • Qui puoi vedere Godot in esecuzione insieme ai pannelli spaziali di Jetpack, che vengono eseguiti contemporaneamente.

  • Nota come Godot viene visualizzato come contenuto spaziale, fornendo un volume 3D insieme alla UI di Compose 2D all'interno di un container spaziale.

  • Questo approccio consente agli sviluppatori di eseguire simulazioni fisiche e shader personalizzati utilizzando il motore in tempo reale che preferiscono, applicando al contempo pratiche di sviluppo Android native al resto dell'applicazione.

  • Con Android XR e XREAL AURA, anche tu puoi creare esperienze come questa.

  • In questo intervento, ti insegneremo come creare questa esperienza utilizzando Godot e Android Studio, quindi ascolterai i nostri partner di NBA e Beyond Sports su come l'hanno creata utilizzando Unity come libreria.

Container spaziali

  • In passato, creare un'esperienza XR con OpenXR significava creare un'esperienza che eseguisse il rendering di ogni singolo pixel.

  • Ciò significa che se uno sviluppatore deve visualizzare una seconda attività, non può averle entrambe contemporaneamente.

  • Per risolvere questo problema, Khronos, il gruppo di lavoro di OpenXR, in collaborazione con diversi leader del settore, ha rilasciato l'estensione OpenXR Spatial Containers.

  • Pensa ai container spaziali come a OpenXR in una scatola.

  • Anziché essere proprietario dell'intero skybox, il tuo motore riceve un volume 3D gestito dal sistema che può coesistere con i pannelli spaziali XR di Jetpack, altri contenitori spaziali e le attività Android standard.

  • Supponiamo che tu voglia utilizzare Android XR visualizzando due pannelli spaziali contemporaneamente, mentre esegui un gioco immersivo.

  • Esegui il rendering di tutto nel motore e, in questi casi d'uso, è possibile avere l'editor Godot e un container spaziale in esecuzione contemporaneamente.

  • A partire dal livello API spaziale 4, Google ha implementato i container spaziali in Android XR.

  • I contenitori spaziali supportano due modalità di visualizzazione fondamentali.

  • La prima è la modalità limitata.

  • In questo modo, viene fornito un volume 3D definito con diverse zone di esclusione integrate.

  • Un utente può afferrare il tuo container spaziale, ruotarlo, posizionarlo, afferrarlo, ridurlo a icona e supporta la proiezione della profondità stereo, offrendoti quella finestra magica o quell'effetto pop-out 3D che hai anche quando ritagli i bordi del container.

  • La seconda è la modalità non limitata.

  • Se l'utente decide di voler un'immersione completa, la tua app può richiedere una transizione fluida dalla modalità delimitata alla modalità spazio intero e poi tornare alla modalità delimitata.

  • Con l'aggiunta dei container spaziali, Android XR apre le porte a nuove e ampie idee.

  • Supponiamo che tu voglia inserire la tua UI Android nel tuo gioco o che tu voglia creare uno shader personalizzato per visualizzare contenuti 3D nella tua applicazione.

  • Prima dell'introduzione dei container spaziali, non era possibile eseguire contemporaneamente Compose e un motore grafico.

  • Anche per quanto riguarda lo sviluppo di applicazioni nei motori in tempo reale, abbiamo vissuto in due paradigmi diversi.

  • Per gli sviluppatori Android nativi, il runtime dell'applicazione è basato su Jetpack Compose.

  • È progettato per un'interfaccia utente rapida e reattiva, l'efficienza della batteria, i layout dinamici, l'accessibilità del sistema e l'inserimento di testo senza interruzioni.

  • È inattivo quando non succede nulla ed è prevedibile.

  • Per gli sviluppatori di motori grafici, il dominio è il rendering in tempo reale, dove strumenti come Godot e Unity eccellono.

  • Non si limitano ad attendere l'interazione dell'utente.

  • Eseguono un ciclo continuo di tick e rendering.

  • Quindi, può essere di 60, 90, 100 frequenze fotogrammi al secondo.

  • Eseguono calcoli fisici, shader complessi, animazioni scheletriche, audio spaziale e tutto questo con prestazioni incredibili.

Approcci per l'integrazione di motori 3D

  • Cosa succede se vuoi il meglio di entrambi i mondi?

  • In precedenza, gli sviluppatori dovevano implementare uno di questi due approcci.

  • L'opzione A consiste nel riscrivere l'intera applicazione all'interno di un motore reale come Godot o Unity.

  • Eliminare il codebase esistente significa perdere gran parte della UI di Compose nativa, reimplementare il testo e la navigazione di base e scrivere wrapper per le API Android, come la sintesi vocale.

  • L'opzione B consiste nel creare da zero una pipeline di rendering di grafica 3D di basso livello all'interno della tua app nativa utilizzando le API Vulkan e OpenXR non elaborate.

  • Invece di fornire funzionalità del prodotto, stai essenzialmente creando il tuo motore proprietario.

  • Oggi posso mostrarvi come sfruttare al meglio Godot come libreria e Unity come libreria.

  • Con l'aggiunta dei container spaziali, ora puoi avere il meglio di entrambi i mondi.

  • Possiamo farlo incorporando un motore in tempo reale direttamente in una libreria all'interno della tua applicazione Jetpack.

  • Mantieni la struttura dell'app per Android di produzione, la logica di business e il rendering fluido dei contenuti spaziali 3D all'interno della tua applicazione.

  • Ciò significa anche che puoi avere un APK per tutti i fattori di forma.

  • Se non vuoi mostrare solo contenuti 3D, puoi scegliere di non caricare la libreria nell'attività o nel frammento e mantenere pulito il codebase, oltre a supportare molti dispositivi.

  • La filosofia è semplice: disaccoppiare l'applicazione e trasformare il motore in tempo reale in una libreria.

  • Anziché scegliere tra un'app per Android o un motore, quest'ultimo viene trattato come una libreria software incorporata, nota anche come dipendenza AAR.

  • Puoi inserirlo direttamente nella build Gradle, come qualsiasi altra libreria.

  • Il codice Android nativo rimane il cervello assoluto dell'applicazione che ospita i componenti 3D.

  • Jetpack Compose XR si occupa di ciò che sa fare meglio: eseguire il rendering di un'UI fluida e accessibile nativa e gestire lo stato dell'app.

  • Quando è necessario un elemento 3D, l'app ospita una visualizzazione di rendering incorporata che viene visualizzata dal motore grafico stesso.

  • Oggi approfondiremo esattamente come funziona dietro le quinte per Godot.

  • Inizierò con i meccanismi di incorporamento e stabilendo una comunicazione bidirezionale tra Kotlin e GDScript.

  • Poi esamineremo la nuova estensione di OpenXR, Spatial Container, che sbloccherà davvero la nuova generazione di multitasking e layout spaziale.

  • Diamo una rapida occhiata alla demo che ho mostrato prima.

  • L'ho creato utilizzando Godot e Android Studio.

  • Questo clip mostra Jetpack Compose per XR che esegue il rendering di pannelli spaziali UI 2D nativi per il nostro tabellone segnapunti, le notifiche e i momenti salienti.

  • Inoltre, Godot esegue il rendering di una simulazione di una partita di calcio.

Comunicazione tra Kotlin e Godot

  • Qui puoi vedere un'analisi dettagliata di come comunichiamo da Kotlin a GDScript.

  • Per farlo, Godot fornisce un'architettura di plug-in.

  • Scrivi una classe Kotlin leggera che estende un GodotPlugin.

  • Questa classe funge da interfaccia tra la JVM e il runtime C++ di Godot.

  • Per questo motivo, le interazioni tra la tua app e Godot sono molto leggere.

  • L'architettura di Godot supporta la comunicazione bidirezionale tra Godot e Kotlin.

  • Quando in Godot si verifica un evento di gioco, ad esempio un gol o un fallo, Godot può richiamare direttamente le funzioni Kotlin e aggiornare la UI di Compose in tempo reale.

  • Quando l'utente interagisce con il media player, ad esempio per mettere in pausa o selezionare diversi momenti salienti, Kotlin invia un segnale a Godot e vengono eseguite le azioni corrispondenti del motore grafico.

  • Una volta superato il plug-in Godot, le prestazioni e la gestione rientrano semplicemente nelle linee del dominio.

  • Applica le best practice standard per le app per Android e per i progetti Godot.

  • Ora esamineremo l'architettura.

  • Vediamo come collegare questo elemento a Godot in un'app per Android.

  • Il runtime del motore Godot viene distribuito tramite Maven come AAR.

  • L'integrazione nella build segue le pratiche standard di Android.

  • C'è un passaggio importante di cui devi essere consapevole fin da subito, ovvero quella che io chiamo la trappola degli asset.

    androidResources {
         ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
     }
  • In un progetto Godot, le scene e i file di configurazione del progetto si trovano insieme.

  • Quando incorpori Godot, il progetto Godot viene aggiunto direttamente nella cartella degli asset.

  • Ciò significa che tutte le mesh 3D importate, le texture compresse e le texture compilate vengono archiviate come artefatto di runtime all'interno di un file nascosto chiamato .godot.

  • Per impostazione predefinita, Android rimuove questa cartella durante la creazione della build, quindi ignora qualsiasi directory con un punto.

  • Quindi, se sincronizzi e crei la tua app normalmente, l'APK verrà creato, ma si arresterà in modo anomalo e verrà caricato perché questi elementi non sono stati compilati nell'APK.

  • Per risolvere il problema, puoi semplicemente eseguire l'override esplicito delle opzioni AAPT all'interno del file build.gradle e consentire l'inserimento dei punti iniziali da Godot.

  • A questo punto, puoi eseguire la scena Godot una volta nell'editor Godot e, da lì, puoi creare tutto il resto da Android Studio.

  • Per supportare i container spaziali e l'ultima versione di OpenXR, ti consiglio di utilizzare Godot 4.8 e versioni successive, le estensioni del fornitore Godot 5.2 e il caricatore 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")

Rendering di Godot in Jetpack Compose

  • Una volta pacchettizzati gli asset, come avviene il rendering di Godot e Compose?

  • La libreria Godot fornisce un metodo factory chiamato 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 }
        )
    }
  • Viene restituito un SurfaceView su cui Godot esegue il rendering.

  • Quindi, inserisci questo in una funzione componibile e Godot ora può essere dimensionato, riempito e posizionato ovunque all'interno di un riquadro di entità.

    Subspace {
            SpatialPanel(
                modifier = SubspaceModifier
                    .width(960.dp)
                    .height(540.dp)
                    .offset(z = (-200).dp)
                    .movable()
    ,
        ) {
            GodotView(
                godotHost = godotHost,
                modifier = Modifier.fillMaxSize(),
            )
        }
    }
  • Il motore stesso non può gestire il ciclo di vita.

    @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)
           }
       }
    
    }
  • Pertanto, la tua attività deve implementare l'interfaccia GodotHost.

  • In questo modo, tutti i cicli di vita che si verificano all'interno della tua attività, in pausa, in ripresa, verranno inviati al motore Godot stesso, che ora è consapevole del ciclo di vita.

  • Successivamente, dobbiamo aggiungere la nostra app nativa e il nostro motore per comunicare senza problemi.

  • Per farlo, possiamo utilizzare l'interfaccia GodotHost.

    class MainActivity : ComponentActivity(), GodotHost {
        override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
            setOf(SoccerGodotPlugin(engine))
    }
  • Possiamo definire il plug-in Godot e qui possiamo assicurarci che, all'avvio di Godot, il plug-in sia registrato dal lato di Compose.

  • Ora che il plug-in è registrato, configuriamo Android per comunicare inviando un segnale a Godot.

  • Nel nostro codice Kotlin, creeremo un'istanza di GodotPlugin.

  • Faremo in modo che la nostra attività registri il plug-in con la libreria Godot.

  • Poi, in Kotlin, creiamo GodotPlugin, registriamo il suo nome e indichiamo a Godot tutti gli indicatori che vogliamo utilizzare all'interno di questo pacchetto.

    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),
        )
    
    }
  • Poi abbiamo uno script Godot, in cui puoi ottenere l'istanza di questo 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)
  • Possiamo vedere cosa è registrato e poi collegare le funzioni agli indicatori che abbiamo registrato.

  • Quindi, una volta chiamato un elemento come emitSignal, puoi semplicemente passarlo dal tuo codice Kotlin e questo va direttamente a Godot.

    class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {
    
        fun sendCommandToGodot(command: String) {
            emitSignal("match_command", command)
        }
    }
  • Per consentire a Godot di comunicare con Kotlin, ad esempio quando viene segnato un gol, possiamo chiamare una funzione Kotlin direttamente dallo script Godot, semplicemente utilizzando l'istanza del plug-in.

    func _on_goal_scored(minute: int, home: int, away: int) -> void:
    
        if soccer_plugin:
            soccer_plugin.onMatchEvent("GOAL", minute, home, away)
  • Per prima cosa, annota la funzione Kotlin con 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)
            }
        }
  • Il motore gestisce il marshalling JNI in background.

  • Quindi possiamo chiamare la funzione direttamente da Godot Script.

  • Un suggerimento rapido da ricordare quando utilizzi questa funzione è che UsedByGodot richiede una corrispondenza esatta delle stringhe.

  • Quindi, se lo script Godot chiama onMatchEvent, il nome Kotlin deve essere esattamente lo stesso, quindi non esiste una conversione automatica in notazione a cammello o snake_case.

  • Quindi, devi solo assicurarti che entrambi i metodi siano esattamente gli stessi.

  • Una volta collegato il plug-in, la parte più importante di questo intervento è ciò che chiamo il modello mentale.

  • È qui che i motori grafici e Jetpack Compose operano su due architetture fondamentalmente opposte.

  • Un motore grafico funziona su un tick di frame continuo.

  • Indipendentemente dal fatto che tu abbia apportato modifiche o meno, il processo di tick del motore grafico avviene 60, 90, 100 volte al secondo, offrendo un rendering in tempo reale ricco e fluido.

  • Inoltre, motori come Godot utilizzano in modo efficace i cicli della GPU e della CPU.

  • Jetpack Compose, invece, gestisce le cose in modo reattivo ed è basato sullo stato.

  • E quando un'app non cambia, Compose continua letteralmente a consumare 0 CPU.

  • Se vuoi collegarli per eseguire due runtime senza una strategia chiara, queste filosofie tendono a scontrarsi.

  • Quindi, vediamo come- vediamo come aggirare questo problema con quello che chiamo "tick leak".

  • Qui abbiamo un'esperienza 3D, ad esempio il timer del tabellone segnapunti.

  • Nello script del motore, c'è una variabile timer che cambia continuamente.

  • Se inoltri questa variabile a Compose, ad esempio 90 volte al secondo, entrerai in questo loop di ricomposizione 90 volte al secondo.

  • Ciò significa che Compose rivaluterà i tuoi Composable, rimisurerà i layout e ridisegnerà gli alberi della UI 90 volte al secondo.

  • Inoltre, si verificheranno cali di frame, ritardi nell'interfaccia utente e limitazioni termiche piuttosto rapidamente.

  • Quindi, la regola d'oro è non lasciare mai che il loop continuo del motore detti il loop reattivo della UI.

  • In questo esempio, invece di fare in modo che Godot aggiorni continuamente il timer del tabellone, mi assicuro che Android estragga questi dati una volta al secondo quando ne ha bisogno.

    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, ...) }
    }
  • Infine, ricorda sempre il limite del thread.

  • Un loop del motore grafico Godot viene eseguito continuamente sul proprio thread di rendering.

  • Le UI Android devono essere eseguite sul thread principale di Android.

  • Pertanto, assicurati sempre che il tuo plug-in Kotlin invii eventi utilizzando Dispatchers.Main o primitive di stato sicuro prima di aggiornare gli stati del modello di visualizzazione.

    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
           )
       }
  • Mantenendo il lavoro ad alta frequenza nel motore e la semantica discreta in Compose, puoi ottenere viewport 3D estremamente fluidi senza compromettere la durata della batteria, l'accessibilità o le prestazioni della tua app nativa.

  • Ora vediamo come aggiungere Spatial Containers al tuo progetto Godot.

  • Innanzitutto, iniziamo configurando l'app in modo che sia a spazio intero, attiviamo i contenitori spaziali con i relativi suggerimenti di delimitazione e poi impostiamo la libreria del loader XR nel file manifest di 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 ha le proprie impostazioni di progetto XR, in cui posso attivare OpenXR, gli shader XR e i contenitori spaziali, semplicemente tramite il suo editor.

  • Questo è praticamente tutto ciò che devi fare per attivare i container spaziali in Godot.

  • Ora che hai fatto funzionare Godot con Android XR, i container spaziali e tutto ciò che è stato sviluppato, le best practice per l'utilizzo dei container spaziali, insieme ai pannelli spaziali e a JXR, ti consigliamo vivamente di consultare ulteriori informazioni sul sito web per sviluppatori.

  • Abbiamo appena visto un esempio di come utilizzare i container spaziali all'interno di Godot Engine e Android XR e sono sicuro che molti di voi vorrebbero sentire uno sviluppatore che sta lavorando attivamente ai container spaziali per la sua esperienza con Android XR.

  • Quindi, ora vorrei invitare Josh e Rodrigo dell'NBA, che sono tra i primi partner a utilizzare i container spaziali nella loro applicazione.

Panoramica di NBA Court View

  • Grazie, Luke.

  • Ciao a tutti.

  • Mi chiamo Josh e sono uno sviluppatore Android presso l'NBA.

  • Oggi sono qui con il mio collega Rodrigo Sanchez, uno sviluppatore di software Unity presso Beyond Sports.

  • Oggi vogliamo mostrarvi come abbiamo utilizzato Unity as a Library per creare Court View per l'app NBA.

  • Noi dell'NBA siamo sempre alla ricerca di nuove esperienze da offrire ai nostri fan.

  • Per questo, insieme a Google e Beyond Sports, siamo entusiasti di presentare Court View.

  • Court View offre ricreazioni in 3D delle partite NBA, consentendo agli abbonati a League Pass di guardarle da nuove prospettive non possibili con i normali video trasmessi.

  • Scala e ruota di nuovo il campo, posizionandolo dove preferisci nello spazio.

  • Spostati intorno alla scena e guarda la partita da angolazioni uniche.

  • Nel frattempo, puoi utilizzare l'app NBA normalmente, dalla visualizzazione delle statistiche di sincronizzazione alla visione della trasmissione live.

  • Come si configura tutto questo?

  • La visualizzazione del campo viene eseguita in Unity e utilizzata dalla nostra app per Android utilizzando Unity come libreria.

  • Poi produciamo un singolo Android Archive, ospitato su Maven Central, che l'app NBA può inserire come singola dipendenza.

  • In questo modo abbiamo potuto suddividere il lavoro tra il lato Android e il lato Unity.

  • In questo modo, ognuno di noi può concentrarsi su ciò che conosce meglio e lavorare in parallelo per accelerare lo sviluppo complessivo.

  • Ad esempio, mentre il nostro team lavorava all'esperienza a spazio intero in Compose, il team di Unity poteva concentrarsi sulla scalabilità e sul layout del campo 3D.

  • Ora lascio la parola a Rodrigo, che vi parlerà del progetto Unity e di come hanno lavorato con Unity as a Library.

Integrazione di Unity come libreria

  • Grazie, Josh.

  • Noi di Beyond Sports collaboriamo con l'NBA da alcuni anni per rappresentare visualizzazioni 3D con dati reali delle partite.

  • Con questo progetto, abbiamo adattato la nostra simulazione 3D per renderla compatibile con Android XR.

  • La nostra simulazione è basata sul motore Unity con C#.

  • Esportiamo la simulazione in Android con Unity as a Library e creiamo un livello di comunicazione accanto utilizzando Kotlin.

  • Prima di raggruppare questi due moduli in un'unica dipendenza Maven che l'NBA può includere nella sua applicazione.

  • Sullo schermo puoi vedere un breve video della stessa simulazione mostrata da Josh in precedenza, ma in esecuzione nell'editor.

  • Per farlo, utilizziamo il pacchetto Android XR Extensions per Unity.

  • Ti illustro la procedura.

  • Quindi, prima di creare in Unity, assicurati di aver selezionato le impostazioni e i plug-in corretti.

  • Scarica il pacchetto XR Plug-in Management di Unity e il pacchetto Android XR Extensions for Unity di Google.

  • Configurale nelle impostazioni di XR Plug-in Management per utilizzare il provider di estensioni Android XR OpenXR.

  • Per il nostro caso d'uso, volevamo utilizzare i container spaziali, quindi abbiamo attivato questa funzionalità e disattivato l'XR immersiva dalle impostazioni di gestione della sessione.

  • Non abbiamo utilizzato alcun pacchetto di input XR, poiché tutto l'input viene gestito in modo nativo, al di fuori di Unity.

  • Successivamente, nelle impostazioni del player, dovrai impostare il progetto in modo che utilizzi un livello API minimo pari o superiore a 24 e l'entry point dell'applicazione deve essere impostato sulla nuova Game Activity.

  • Tieni presente che l'opzione Attività deve essere deselezionata.

  • Ci interessa solo la creazione per ARM64, quindi selezioniamo l'architettura di destinazione e, nella sezione della risoluzione, attiviamo l'esecuzione in background, in modo da poter eseguire il multitasking con altre app e mantenere la simulazione 3D in esecuzione in parallelo.

  • Attiviamo anche l'attività ridimensionabile, in quanto è necessaria per eseguire il rendering delle finestre di dialogo, ad esempio le richieste di autorizzazione di sistema.

  • Infine, ti consigliamo di eseguire il rendering delle app Android XR utilizzando la pipeline di rendering universale e l'API Vulkan Graphics.

  • Nell'asset URP, l'HDR e il post-processing devono essere disattivati.

  • Una volta completati tutti questi passaggi, puoi creare una build.

  • Passa alla finestra di build e, se utilizzi Unity 6.1 o versioni successive, puoi selezionare il profilo di build di Android XR, che ti consente di separare le impostazioni se esegui la build anche per Android Mobile.

  • Prima di creare, assicurati di abilitare il progetto di esportazione.

  • Al termine della build di Unity, avrai un progetto Android funzionante che può essere assemblato in Android Studio ed eseguito su un dispositivo come qualsiasi altro APK Unity.

  • In Android Studio, devi convertire la build esportata in una libreria da utilizzare con un'altra applicazione.

    // 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")
    }
    }
  • Nel nostro progetto qui, abbiamo quattro moduli diversi: il modulo APK principale da testare come segnaposto fittizio per l'applicazione di wrapping di NBA, il modulo della libreria Unity che contiene la nostra build esportata, un modulo nativeCommunications che contiene il nostro livello di comunicazione che abbiamo creato tra le attività e un modulo packageExporter, che utilizziamo per unire i nostri AAR in un unico pacchetto da consegnare a Maven.

  • La fusione di più AAR può essere ottenuta utilizzando la libreria Android Fused o metodi della community meno recenti come Fat AAR.

  • Puoi mantenere la struttura e la comunicazione semplici mantenendo Unity e l'attività principale in esecuzione nello stesso processo.

  • Abbiamo deciso di non farlo e di separare l'attività Unity in un processo autonomo come misura di sicurezza nel caso in cui la parte Unity si arresti in modo anomalo, in modo che l'app NBA non venga interessata e possa gestire in sicurezza la chiusura di Unity senza arrestarsi in modo anomalo.

  • La decisione ci costringe ad adottare un sistema di comunicazione tra processi più solido.

  • Poiché le attività si trovano in processi diversi, non esiste memoria condivisa tra loro e molti metodi IPC Android comuni non funzionano o non soddisfano i requisiti.

  • Utilizziamo l'Android Interface Definition Language (AIDL) per comunicare tra l'attività principale dell'NBA e la nostra attività Unity incorporata.

  • Inoltre, la comunicazione tra Android e Unity si basa anche sulle interfacce, utilizzando l'interfaccia AndroidJavaProxy integrata in Unity per registrare un'interfaccia Kotlin in un'implementazione dell'interfaccia C#.

  • Il nostro modulo di comunicazione si concentra principalmente su quello che chiamiamo il ponte delle attività.

    // 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) }
    }
    }
  • Entrambe le attività hanno accesso al bridge delle attività e ai metodi di utilità al suo interno.

  • Il bridge contiene un riferimento a un servizio che implementa le interfacce AIDL che abbiamo definito e fornisce anche un modo semplice per associare le attività al processo, scusa, al servizio e gestirne internamente il ciclo di vita.

  • L'avvio di una simulazione può essere eseguito con un callback in cui passiamo l'ID corrispondenza come parametro.

  • Poi, il bridge lo passa tramite AIDL e, successivamente, all'app Unity, dove C# riceve un comando e inizia a configurare la scena.

    public class NativeInterfaceListener : AndroidJavaProxy {
    [UsedImplicitly]
    public void startMatch(string matchID) {
       UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
    }
    }
  • Dobbiamo attendere che Unity sia attivo e pronto per premere i comandi che gli passiamo.

  • L'attività Unity che racchiude l'applicazione sarà attiva prima di Unity stesso.

  • Nel caso di comandi inviati troppo presto, li memorizziamo in una coda di callback.

    fun onStartMatchClickedInternal() {
       if (isServiceBound) {
           AIDLBridgeService.startMatch("match_id")
       } else {
           delayedCallbacksQueue.add(action)
       }
    }
  • Una volta che Unity ha terminato il caricamento di avvio e l'inizializzazione e ci troviamo in uno stato che consideriamo sicuro, comunichiamo da C# che Unity è pronto.

    // After bootload and initialization
    
    using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
    using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
    instance.Call("onUnityReady");
  • Dopodiché, eseguiamo un loop sulla coda e inoltriamo tutti i comandi memorizzati.

    @Suppress("unused") //  Unity implicit call
    fun onUnityReady() {
       bridge.activityBridgeServiceInternal?.onUnityReady()
    }
    
    delayedCallbacksQueue.forEach { it(activityBridgeService) }
  • Questo diagramma illustra meglio il funzionamento della nostra comunicazione tra processi.

  • Come puoi vedere, utilizzando il bridge tra le attività, possiamo gestire i callback di cui abbiamo parlato.

  • Abbiamo configurato StateFlow che vengono aggiornati da Unity per monitorare valori come lo stato di autenticazione o il timestamp dello stato corrente della partita.

  • Inoltre, gestiamo anche la crittografia delle credenziali e inoltriamo eventuali errori da Unity in modo che l'NBA possa eseguire il debug più facilmente e decidere come gestirli al meglio a livello di applicazione.

  • Da qui è tutto.

  • Ora lascio la parola a Josh, che vi parlerà di come integrano il pacchetto Unity Court View nell'app NBA.

  • Utilizziamo queste dipendenze Gradle per integrare Unity come libreria con la nostra app Jetpack Compose: XR Runtime per accedere alla sessione 3D, Jetpack SceneCore per ospitare la nostra attività Unity e attivare/disattivare gli ambienti spaziali e Jetpack Compose per XR per accedere ai pannelli spaziali nel nostro codice 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")
    }
  • Ora vediamo come eseguire il rendering di Court View in 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)
            }
        }
    }
  • Prima di lanciare la visualizzazione del campo, ti portiamo nello spazio intero.

  • Questo è necessario per visualizzare il pannello spaziale che contiene Unity.

  • Successivamente, inviamo le informazioni sul gioco tramite AIDL per avviare Court View per un gioco specifico.

  • Unity viene quindi avviato.

  • Il nostro archivio Android lo invia come intent a un'attività Unity e noi lo inseriamo in un SpatialActivityPanelController.

  • Da lì, passiamo il controller a un SpatialActivityPanel.

  • Questo pannello contiene la visualizzazione del campo ed è condiviso nel nostro sottospazio con il pannello spaziale principale.

  • In questo modo, possiamo controllare entrambi i dispositivi contemporaneamente senza problemi.

  • Questo è tutto.

  • Lo rimanderò a Luke.

Conclusioni e risorse

  • Grazie mille.

  • Come puoi vedere, l'NBA è riuscita ad accelerare Court View suddividendo lo sviluppo di Unity e Jetpack Compose.

  • Per scoprire di più su come iniziare a utilizzare i container spaziali e su come utilizzarli nelle tue app Android XR, consulta la documentazione per lo sviluppo all'indirizzo developer.android.com/develop/xr.

  • Grazie mille per aver partecipato oggi e non vediamo l'ora di vedere cosa creerai.

Codice

Snippet di codice

Snippet di script Kotlin al minuto 09:30 in "Comunicazione tra Kotlin e Godot"
androidResources {
     ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
 }
Snippet di script Kotlin al minuto 10:27 in "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")
Snippet di Kotlin al minuto 10:48 in "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 }
    )
}
Snippet di Kotlin al minuto 10:55 in "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(),
        )
    }
}
Snippet di Kotlin al minuto 11:05 in "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)
       }
   }

}
Snippet di Kotlin al minuto 11:29 in "Rendering Godot in Jetpack Compose"
class MainActivity : ComponentActivity(), GodotHost {
    override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
        setOf(SoccerGodotPlugin(engine))
}
Snippet di Kotlin alle 11:52 in "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),
    )

}
Snippet di testo alle 12:01 in "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)
Snippet di Kotlin al minuto 12:10 in "Rendering Godot in Jetpack Compose"
class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {

    fun sendCommandToGodot(command: String) {
        emitSignal("match_command", command)
    }
}
Snippet di testo alle ore 12:20 in "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)
Snippet Kotlin alle 12:30 in "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)
        }
    }
Snippet di Kotlin al minuto 14:35 in "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, ...) }
}
Snippet di Kotlin al minuto 14:57 in "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
       )
   }
Frammento XML alle 15:22 in "Rendering di 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="" />
Snippet di script Kotlin alle 20:41 in "Integrazione di Unity come libreria"
// 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")
}
}
Snippet Kotlin al minuto 22:11 in "Integrazione di Unity come libreria"
// 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) }
}
}
Snippet C# al minuto 22:36 in "Integrazione di Unity come libreria"
public class NativeInterfaceListener : AndroidJavaProxy {
[UsedImplicitly]
public void startMatch(string matchID) {
   UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
}
}
Snippet Kotlin al minuto 22:54 in "Integrazione di Unity come libreria"
fun onStartMatchClickedInternal() {
   if (isServiceBound) {
       AIDLBridgeService.startMatch("match_id")
   } else {
       delayedCallbacksQueue.add(action)
   }
}
Snippet C# al minuto 22:57 in "Integrazione di Unity come libreria"
// After bootload and initialization

using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
instance.Call("onUnityReady");
Snippet Kotlin al minuto 23:06 in "Integrazione di Unity come libreria"
@Suppress("unused") //  Unity implicit call
fun onUnityReady() {
   bridge.activityBridgeServiceInternal?.onUnityReady()
}

delayedCallbacksQueue.forEach { it(activityBridgeService) }
Snippet di script Kotlin al minuto 23:50 in "Integrazione di Unity come libreria"
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")
}
Snippet Kotlin al minuto 24:10 in "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)
        }
    }
}