Android XR

Mejora las experiencias envolventes con motores 3D en Jetpack Compose para RE

Bocinas

Acerca de

Acerca de la charla

Aprende a integrar motores de juegos en 3D (Godot y Unity) con Jetpack Compose en una sola base de código para dispositivos móviles y Android XR. Exploraremos cómo incorporar escenas en 3D en contenedores espaciales y configurar la comunicación de Kotlin con el motor, con un vistazo exclusivo de cómo la NBA creó su experiencia de básquetbol en vivo en Android XR.

Resumen

  • La extensión OpenXR Spatial Containers permite que los motores 3D compartan espacio con la IU nativa de Android. En lugar de tomar el control del skybox, los motores en tiempo real se ejecutan dentro de un volumen 3D administrado junto con los paneles espaciales de Jetpack XR.
  • Los desarrolladores pueden incorporar motores en tiempo real directamente en las aplicaciones de Jetpack Compose como bibliotecas de AAR. Este enfoque permite que Android nativo controle la lógica empresarial y la IU mientras el motor renderiza los componentes 3D.
  • Para incorporar Godot, se requiere una dependencia de AAR y una regla de compilación de AAPT para mantener ocultos los recursos. Luego, una subclase GodotPlugin controla la comunicación bidireccional con Kotlin.
  • Los bucles de la IU de Compose deben permanecer separados de los bucles de tics continuos del motor. Reenviar las actualizaciones del motor de forma indiscriminada activa la recomposición constante y provoca un retraso en la IU.
  • La app de la NBA integra Unity como una biblioteca incorporada en un proceso independiente que usa AIDL para la comunicación entre procesos, lo que protege a la app host de fallas.

Recursos

Gemini Notebook

Temas

  • android-xr
  • jetpack-compose
  • spatial-containers
  • openxr
  • godot
  • unidad

Transcripción

Descargar transcripción

Introducción a Android XR

  • Hola a todos.

  • Me llamo Luke Hopkins.

  • Soy ingeniero de relaciones con desarrolladores en el equipo de Android XR.

  • En la charla de hoy, hablaremos sobre cómo puedes incorporar motores de juegos, como Godot y Unity, en tu proyecto de Android.

  • Más adelante, se unirán Josh y Rodrigo de la NBA y Beyond Sports, quienes hablarán sobre cómo han estado implementando Unity como biblioteca en su aplicación de la NBA.

  • En esta sesión, profundizaremos en cómo integrar Godot en tu aplicación de Jetpack Compose y, luego, Josh y Rodrigo analizarán su aplicación de la NBA.

  • Android XR es una extensión del ecosistema Android que amplía una gran variedad de factores de forma para llevar las experiencias que conoces y te encantan a una nueva dimensión.

  • Hicimos que sea intuitivo llevar tus apps para Android y XR existentes a este nuevo espacio, y estamos aprovechando las tecnologías de Android existentes con las que ya estás creando contenido.

  • Android XR es una plataforma unificada diseñada para una amplia variedad de dispositivos.

  • Los dispositivos Android XR potencian diferentes tipos de experiencias.

  • Tenemos experiencias aumentadas para audio y lentes con pantalla, y experiencias inmersivas para visores de RE y lentes de RE con cable.

  • Hoy, hablaremos sobre cómo crear experiencias inmersivas para lentes de RE con cable y visores de RE.

  • Para crear experiencias envolventes, puedes usar nuestro SDK de Jetpack XR o OpenXR con un motor en tiempo real.

  • Ofrecemos compatibilidad con Unity, Godot y Unreal para que puedas crear experiencias envolventes para Android, sin importar la herramienta que uses.

  • En esta charla, explicaré cómo puedes aprovechar la API de SceneCore de Jetpack, junto con Godot y una nueva extensión de OpenXR, para que esto sea posible.

  • Aquí puedes ver a Godot ejecutándose junto con los paneles espaciales de Jetpack al mismo tiempo.

  • Observa cómo Godot se renderiza como contenido espacial, lo que proporciona un volumen 3D junto con la IU de Compose en 2D dentro de un contenedor espacial.

  • Este enfoque permite a los desarrolladores de Android ejecutar simulaciones de física y sombreadores personalizados con el motor en tiempo real que elijan, a la vez que aplican prácticas de desarrollo nativo de Android al resto de la aplicación.

  • Con Android XR y XREAL AURA, tú también puedes crear experiencias como esta.

  • En esta charla, te enseñaremos a crear esta experiencia con Godot y Android Studio, y luego escucharás a nuestros socios de la NBA y Beyond Sports sobre cómo la crearon usando Unity como biblioteca.

Contenedores espaciales

  • Históricamente, crear una experiencia de RE con OpenXR significaba crear una experiencia que renderizara cada píxel.

  • Esto significa que, si un desarrollador necesita mostrar una segunda actividad, no podría tener ambas a la vez.

  • Para resolver este problema, Khronos, el grupo de trabajo de OpenXR, en colaboración con varios líderes de la industria, lanzó la extensión OpenXR Spatial Containers.

  • Piensa en los contenedores espaciales como OpenXR en una caja.

  • En lugar de que tu motor sea el propietario de todo el skybox, se le otorga un volumen en 3D administrado por el sistema que puede coexistir con los paneles espaciales de XR de Jetpack, otros contenedores espaciales y las actividades estándar de Android.

  • Supongamos que quieres usar Android XR mostrando dos paneles espaciales a la vez mientras ejecutas un juego envolvente.

  • Renderizas todo en el motor, lo que permite, en estos casos de uso, tener el editor de Godot y un contenedor espacial ejecutándose al mismo tiempo.

  • A partir del nivel de API espacial 4, Google implementó los contenedores espaciales en Android XR.

  • Los contenedores espaciales admiten dos modos de visualización cruciales.

  • El primero es el modo limitado.

  • Esto proporciona un volumen 3D definido con varias zonas de evitación integradas.

  • Un usuario puede tomar tu contenedor espacial, rotarlo, posicionarlo, tomarlo, minimizarlo y admite la proyección de profundidad estéreo, lo que te brinda esa ventana mágica o ese efecto emergente en 3D que tienes mientras recortas los bordes del contenedor.

  • La segunda es el modo sin límites.

  • Si el usuario decide que quiere una inmersión total, tu app puede solicitar una transición fluida de su modo limitado al modo de espacio completo, y luego también puedes volver al modo limitado.

  • Con la incorporación de los contenedores espaciales, Android XR abre la puerta a ideas nuevas y amplias.

  • Supongamos que quieres incorporar la IU de Android a tu juego o que quieres crear un sombreador personalizado para mostrar contenido en 3D en tu aplicación.

  • Antes de la introducción de los contenedores espaciales, era imposible tener Compose y un motor de juego ejecutándose al mismo tiempo.

  • Y cuando analizamos el desarrollo de aplicaciones en motores en tiempo real, también vivimos en dos paradigmas diferentes.

  • Para los desarrolladores de Android nativo, el entorno de ejecución de la aplicación se basa en Jetpack Compose.

  • Está diseñado para una IU rápida y reactiva, eficiencia de la batería, diseños dinámicos, accesibilidad del sistema y entrada de texto fluida.

  • Se mantiene inactivo cuando no sucede nada y es predecible.

  • Para los desarrolladores de motores de juegos, el dominio es la renderización en tiempo real, donde se destacan herramientas como Godot y Unity.

  • No solo esperan la interacción del usuario.

  • Ejecutan un bucle continuo de tics y renderización.

  • Por lo tanto, puede ser de 60, 90 o 100 fotogramas por segundo.

  • Realizan cálculos de física, sombreadores complejos, animaciones de esqueletos, audio espacial y todo esto con un rendimiento increíble.

Enfoques para integrar motores 3D

  • ¿Qué sucede si quieres lo mejor de ambos mundos?

  • Antes, los desarrolladores tenían que implementar uno de estos dos enfoques.

  • La opción A es reescribir toda la aplicación en un motor real, como Godot o Unity.

  • Desechar tu base de código existente significa perder gran parte de tu IU nativa de Compose, volver a implementar el texto y la navegación básicos, y escribir wrappers alrededor de las APIs de Android, como la de texto a voz.

  • La opción B consiste en compilar una canalización de renderización de gráficos 3D de bajo nivel desde cero dentro de tu aplicación nativa con las APIs de Vulkan y OpenXR sin procesar.

  • En lugar de ofrecer funciones de productos, básicamente estás creando tu propio motor patentado.

  • Por eso, hoy les mostraré cómo pueden aprovechar al máximo Godot y Unity como bibliotecas.

  • Y con esto, la incorporación de contenedores espaciales, ahora puedes tener lo mejor de ambos mundos.

  • Para lograrlo, podemos incorporar un motor en tiempo real directamente en una biblioteca dentro de tu aplicación de Jetpack.

  • Conservas la estructura de la app para Android de producción, la lógica empresarial y la renderización fluida del contenido espacial en 3D dentro de tu aplicación.

  • Esto también significa que puedes tener un APK para todos los factores de forma.

  • Si no solo quieres mostrar contenido en 3D, puedes optar por no cargar la biblioteca en la actividad o el fragmento, mantener tu base de código clara y admitir muchos dispositivos.

  • La filosofía es sencilla: desacoplar la aplicación y convertir el motor en tiempo real en una biblioteca.

  • En lugar de elegir entre una app para Android o un motor, tratas al motor como una biblioteca de software incorporada, también conocida como dependencia de AAR.

  • Puedes extraerla directamente en tu compilación de Gradle, como cualquier otra biblioteca.

  • Tu código nativo de Android sigue siendo el cerebro absoluto de la aplicación que aloja los componentes 3D.

  • Jetpack Compose XR se encarga de lo que mejor sabe hacer: renderizar una IU fluida, accesible y nativa, y administrar el estado de la app.

  • Cuando se necesita un elemento 3D, la app aloja una vista de renderización incorporada que renderiza el propio motor de juego.

  • Hoy, analizaremos en profundidad cómo funciona esto de forma interna en Godot.

  • Comenzaré por la mecánica de incorporar y establecer una comunicación bidireccional entre Kotlin y GDScript.

  • Luego, veremos la nueva extensión de OpenXR, Spatial Container, que realmente desbloqueará la nueva generación de multitarea y diseño espacial.

  • Echemos un vistazo rápido a la demostración que mostré antes.

  • La creé con Godot y Android Studio.

  • En este clip, se muestra Jetpack Compose para XR renderizando paneles espaciales de IU nativa en 2D para nuestro marcador, las notificaciones y los momentos destacados.

  • Además, Godot renderiza una simulación de un partido de fútbol.

Comunicación entre Kotlin y Godot

  • Aquí puedes ver un desglose de cómo nos comunicamos de Kotlin a GDScript.

  • Para ello, Godot proporciona una arquitectura de complementos.

  • Escribes una clase de Kotlin ligera que extiende un GodotPlugin.

  • Esta clase sirve como interfaz entre la JVM y el entorno de ejecución de C++ de Godot.

  • Por este motivo, las interacciones entre tu app y Godot son muy ligeras.

  • La arquitectura de Godot admite la comunicación bidireccional entre Godot y Kotlin.

  • Cuando se produce un evento de juego en Godot, como un gol o una falta, Godot puede invocar directamente funciones de Kotlin y actualizar la IU de Compose en tiempo real.

  • Luego, cuando el usuario interactúa con el reproductor multimedia, por ejemplo, para pausar o seleccionar diferentes momentos destacados, Kotlin emite una señal de vuelta a Godot y se producen las acciones correspondientes del motor de juego.

  • Una vez que se cruza el complemento de Godot, el rendimiento y la administración simplemente se encuentran en las líneas de dominio.

  • Aplica las prácticas recomendadas estándar para las apps para Android y los proyectos de Godot.

  • Ahora, revisaremos la arquitectura.

  • Veamos cómo conectamos esto a Godot en una app para Android.

  • El entorno de ejecución del motor de Godot se distribuye a través de Maven como un AAR.

  • La integración en tu compilación solo sigue las prácticas estándar de Android.

  • Hay un paso importante que debes tener en cuenta de inmediato, y es lo que llamo la trampa de los recursos.

    androidResources {
         ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
     }
  • En un proyecto de Godot, las escenas y los archivos de configuración del proyecto se encuentran juntos.

  • Cuando incorporas Godot, el proyecto de Godot se agrega directamente dentro de tu carpeta de recursos.

  • Esto significa que todas las mallas 3D importadas, las texturas comprimidas y las texturas compiladas se almacenan como un artefacto de tiempo de ejecución dentro de un archivo oculto llamado .godot.

  • De forma predeterminada, Android quitará esta carpeta cuando realice la compilación, por lo que ignorará cualquier directorio con un punto.

  • Por lo tanto, si sincronizas y compilas tu app normalmente, el APK se compilará, pero fallará y se cargará porque esos elementos no se compilaron en el APK.

  • Para solucionar este problema, puedes anular de forma explícita las opciones de AAPT dentro de tu build.gradle y permitir que los puntos iniciales se produzcan desde Godot.

  • Una vez que lo hagas, podrás ejecutar tu escena de Godot una vez en el editor de Godot y, desde allí, podrás compilar todo lo demás desde Android Studio.

  • Para admitir contenedores espaciales y la versión más reciente de OpenXR, te recomiendo que uses Godot 4.8 y versiones posteriores, las extensiones de proveedor de Godot 5.2 y el cargador de OpenXR de Khronos 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")

Renderiza Godot en Jetpack Compose

  • Una vez que se empaquetan los recursos, ¿cómo se renderizan Godot y Compose?

  • La biblioteca de Godot proporciona un método de fábrica llamado 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 }
        )
    }
  • Esto devuelve un SurfaceView en el que Godot solo renderiza.

  • Por lo tanto, si lo unes en una función de componibilidad, Godot ahora se puede dimensionar, rellenar y colocar en cualquier lugar dentro de un panel de entidad.

    Subspace {
            SpatialPanel(
                modifier = SubspaceModifier
                    .width(960.dp)
                    .height(540.dp)
                    .offset(z = (-200).dp)
                    .movable()
    ,
        ) {
            GodotView(
                godotHost = godotHost,
                modifier = Modifier.fillMaxSize(),
            )
        }
    }
  • El motor en sí no puede administrar el ciclo de vida.

    @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)
           }
       }
    
    }
  • Por lo tanto, tu actividad debe implementar la interfaz GodotHost.

  • Esto garantizará que todos los ciclos de vida que se produzcan en tu actividad, como onPause y onResume, se envíen al motor de Godot, que ahora reconoce el ciclo de vida.

  • A continuación, debemos agregar nuestra aplicación nativa y nuestro motor para que se comuniquen sin problemas.

  • Para ello, podemos usar la interfaz GodotHost.

    class MainActivity : ComponentActivity(), GodotHost {
        override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
            setOf(SoccerGodotPlugin(engine))
    }
  • Podemos definir el complemento de Godot y, aquí, podemos asegurarnos de que, cuando se inicie Godot, el complemento se registre desde el lado de Compose.

  • Ahora que el complemento está registrado, configuremos Android para que se comunique emitiendo una señal a Godot.

  • En nuestro código de Kotlin, crearemos una instancia de GodotPlugin.

  • Haremos que nuestra actividad registre el complemento con la biblioteca de Godot.

  • Luego, en Kotlin, creamos el GodotPlugin, registramos su nombre y le indicamos a Godot todas las señales que queremos usar en este paquete.

    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),
        )
    
    }
  • A continuación, tenemos una secuencia de comandos de Godot, en la que puedes obtener la instancia de este complemento.

    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)
  • Podemos ver lo que está registrado y, luego, conectar las funciones a los indicadores que registramos.

  • Por lo tanto, una vez que llames a algo como emitSignal, puedes pasarlo desde tu código de Kotlin y se enviará directamente a Godot.

    class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {
    
        fun sendCommandToGodot(command: String) {
            emitSignal("match_command", command)
        }
    }
  • Para que Godot se comunique con Kotlin, supongamos que se produce un gol, podemos llamar a una función de Kotlin directamente desde la secuencia de comandos de Godot, solo con la instancia del complemento.

    func _on_goal_scored(minute: int, home: int, away: int) -> void:
    
        if soccer_plugin:
            soccer_plugin.onMatchEvent("GOAL", minute, home, away)
  • Primero, anota la función de 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)
            }
        }
  • El motor controla la serialización de JNI de forma interna.

  • Luego, podemos llamar a la función directamente desde Godot Script.

  • Hay un consejo rápido que debes recordar cuando uses esto, y es que UsedByGodot requiere una coincidencia exacta de la cadena.

  • Por lo tanto, si tu secuencia de comandos de Godot llama a onMatchEvent, tu nombre de Kotlin debe ser exactamente el mismo, de modo que no haya una conversión automática de mayúsculas mediales o minúsculas con guiones bajos.

  • Por lo tanto, solo debes asegurarte de que ambos métodos sean exactamente iguales.

  • Con el complemento conectado, la parte más importante de esta charla es lo que llamo el modelo mental.

  • Aquí es donde los motores de juegos y Jetpack Compose operan en dos arquitecturas fundamentalmente opuestas.

  • Un motor de juego funciona con un tick de fotograma continuo.

  • Ya sea que hayas cambiado algo o no, el proceso de tick del motor de juego se produce 60, 90 o 100 veces por segundo, lo que ofrece una renderización en tiempo real fluida y enriquecida.

  • Además, los motores como Godot hacen un uso eficaz de los ciclos de GPU y CPU.

  • Por otro lado, Jetpack Compose controla las cosas de forma reactiva y se basa en el estado.

  • Y cuando una app no cambia, Compose literalmente continúa y consume 0 de CPU.

  • Si quieres conectarlos para ejecutar dos entornos de ejecución sin una estrategia clara, estas filosofías tienden a chocar.

  • Veamos cómo podemos solucionar este problema con lo que llamo una fuga de tics.

  • Aquí tenemos una experiencia en 3D, por ejemplo, el cronómetro del marcador.

  • En la secuencia de comandos del motor, hay una variable de temporizador que cambia continuamente.

  • Y si reenvías esta variable a Compose sin pensarlo, digamos 90 veces por segundo, ingresarás a este bucle de recomposición 90 veces por segundo.

  • Esto significa que Compose volverá a evaluar tus elementos componibles, volverá a medir los diseños y volverá a dibujar los árboles de IU 90 veces por segundo.

  • Verás pérdidas de fotogramas, retrasos en la IU y, muy pronto, se activará la limitación térmica.

  • Por lo tanto, la regla de oro es nunca dejar que el bucle continuo del motor dicte el bucle reactivo de la IU.

  • En este ejemplo, en lugar de que Godot actualice el cronómetro del marcador de forma continua, solo me aseguro de que Android extraiga estos datos una vez por segundo cuando los necesite.

    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, ...) }
    }
  • Por último, recuerda siempre el límite de subprocesos.

  • Un bucle del motor de juegos de Godot se ejecuta de forma continua en su propio subproceso de renderización.

  • La IU, las IU de Android, deben ejecutarse en el subproceso principal de Android.

  • Por lo tanto, asegúrate siempre de que tu complemento de Kotlin envíe eventos con Dispatchers.Main o primitivas de estado seguro antes de actualizar los estados de tu modelo de vista.

    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
           )
       }
  • Si mantienes el trabajo de alta frecuencia en el motor y la semántica discreta en Compose, puedes obtener viewports en 3D fluidos sin comprometer la duración de la batería, la accesibilidad ni el rendimiento de tu aplicación nativa.

  • Ahora, veamos cómo podemos agregar contenedores espaciales a tu proyecto de Godot.

  • Primero, configuremos la app para que sea de espacio completo, habilitemos los contenedores espaciales con sus sugerencias de límites y, luego, configuremos la biblioteca del cargador de XR en el archivo de manifiesto de 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 tiene su propia configuración de proyectos de RE, donde puedo habilitar OpenXR, sombreadores de RE y Spatial Containers, solo a través de su editor.

  • Y eso es prácticamente todo lo que necesitas hacer para habilitar los contenedores espaciales en Godot.

  • Ahora que lograste que Godot funcione con Android XR, contenedores espaciales y todo lo que desarrollamos, te recomendamos que consultes más información en el sitio web para desarrolladores sobre las prácticas recomendadas para usar contenedores espaciales, junto con paneles espaciales y JXR.

  • Acabamos de ver un ejemplo de cómo usar Spatial Containers en Godot Engine y Android XR, y estoy seguro de que a muchos de ustedes les gustaría escuchar a un desarrollador que esté trabajando activamente en Spatial Containers para su experiencia de Android XR.

  • A continuación, me gustaría invitar a Josh y Rodrigo de la NBA, que son uno de los primeros socios que usan Spatial Containers en su aplicación.

Descripción general de Court View de la NBA

  • Gracias, Luke.

  • Hola.

  • Me llamo Josh y soy desarrollador de Android en la NBA.

  • Hoy me acompaña mi colega Rodrigo Sánchez, desarrollador de software de Unity en Beyond Sports.

  • Hoy, queremos mostrarte cómo usamos Unity como biblioteca para crear Court View para la app de la NBA.

  • En la NBA, siempre buscamos brindar nuevas experiencias a nuestros fans.

  • Por eso, junto con Google y Beyond Sports, nos complace presentar Court View.

  • Court View ofrece recreaciones en 3D de los partidos de la NBA, lo que permite que los suscriptores de League Pass los miren desde nuevas perspectivas que no son posibles con los videos de transmisiones normales.

  • Cambia la escala y vuelve a rotar la cancha para colocarla donde quieras en tu espacio.

  • Muévete por el espacio y mira el partido desde ángulos únicos.

  • Mientras tanto, puedes usar la app de la NBA como de costumbre, desde ver las estadísticas de sincronización hasta mirar la transmisión en vivo.

  • ¿Cómo se configura todo esto?

  • Court View se renderiza en Unity y nuestra app para Android lo consume usando Unity como biblioteca.

  • Luego, producimos un solo Android Archive, alojado en Maven Central, que la app de la NBA puede extraer como una sola dependencia.

  • Esto nos permitió dividir el trabajo entre el lado de Android y el de Unity.

  • De esta manera, cada uno puede enfocarse en lo que mejor sabe hacer y trabajar en paralelo para acelerar el desarrollo general.

  • Por ejemplo, mientras nuestro equipo trabajaba en la experiencia de espacio completo en Compose, el equipo de Unity podía enfocarse en el escalamiento y el diseño de la cancha en 3D.

  • Ahora, le daré la palabra a Rodrigo para que hable más sobre el proyecto de Unity y cómo trabajaron con Unity como biblioteca.

Cómo integrar Unity como biblioteca

  • Gracias, Josh.

  • En Beyond Sports, trabajamos con la NBA desde hace algunos años para representar visualizaciones en 3D con datos reales de los partidos.

  • Con este proyecto, adaptamos nuestra simulación en 3D para que sea compatible con Android XR.

  • Nuestra simulación se basa en el motor de Unity con C#.

  • Exportamos la simulación a Android con Unity como biblioteca y creamos una capa de comunicación junto a ella con Kotlin.

  • Antes de empaquetar estos dos módulos como una sola dependencia de Maven para que la NBA la incluya en su aplicación,

  • En la pantalla, puedes ver un video breve de la misma simulación que Josh mostró antes, pero que se ejecuta en el editor.

  • Para lograrlo, usamos el paquete de extensiones de Android XR para Unity.

  • Te guiaré por el proceso.

  • Por lo tanto, antes de compilar en Unity, asegúrate de haber seleccionado la configuración y los complementos correctos.

  • Obtén el paquete XR Plug-in Management de Unity y el paquete Android XR Extensions para Unity de Google.

  • Configúralos en los parámetros de configuración de XR Plug-in Management para usar el proveedor de extensiones de Android XR de OpenXR.

  • Para nuestro caso de uso, queríamos usar contenedores espaciales, por lo que habilitamos esa función y, luego, inhabilitamos la RE inmersiva en la configuración de administración de sesiones.

  • No usamos ningún paquete de entrada de RE, ya que toda la entrada se controla de forma nativa, fuera de Unity.

  • Luego, en la configuración del reproductor, deberás establecer que tu proyecto use un nivel de API mínimo de 24 o superior, y el punto de entrada de la aplicación debe establecerse en la nueva Game Activity.

  • Ten en cuenta que la opción Activity debe quedar desmarcada.

  • Solo nos interesa compilar para ARM64, por lo que seleccionamos esa arquitectura de destino y, en la sección de resolución, habilitamos Run in Background para que puedas realizar varias tareas con otras apps y mantener la simulación en 3D en segundo plano.

  • También habilitamos la actividad de cambio de tamaño, ya que es necesaria para renderizar diálogos, como las solicitudes de permisos del sistema.

  • Por último, se recomienda que las apps para Android XR se rendericen con la canalización de renderizado universal y la API de gráficos de Vulkan.

  • En tu recurso de URP, el HDR y el procesamiento posterior deben estar inhabilitados.

  • Una vez que hayas completado todos esos pasos, podrás crear una compilación.

  • Ve a la ventana de compilación y, si usas Unity 6.1 o una versión posterior, puedes seleccionar el perfil de compilación de Android XR, que te permite separar los parámetros de configuración si también compilas para Android Mobile.

  • Antes de compilar, asegúrate de habilitar la exportación del proyecto.

  • Una vez que Unity termine de compilar, tendrás un proyecto de Android en funcionamiento que se puede ensamblar en Android Studio y ejecutar en un dispositivo como cualquier otro APK de Unity.

  • Una vez que estés en Android Studio, deberás convertir tu compilación exportada en una biblioteca para que la use otra aplicación.

    // 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")
    }
    }
  • En nuestro proyecto, tenemos cuatro módulos diferentes: el módulo de APK principal para probar como marcador de posición de simulación para la aplicación de unión de la NBA, el módulo de biblioteca de Unity que contiene nuestra compilación exportada, un módulo nativeCommunications que contiene nuestra capa de comunicación que compilamos entre las actividades y un módulo packageExporter, que usamos para combinar nuestros AAR en un solo paquete para entregarlo a Maven.

  • La combinación de varios AAR se puede lograr con la biblioteca fusionada de Android o con métodos más antiguos de la comunidad, como Fat AAR.

  • Puedes mantener la estructura y la comunicación simples si Unity y la actividad principal se ejecutan en el mismo proceso.

  • No lo hicimos y decidimos separar la actividad de Unity en su propio proceso como medida de seguridad en caso de que la parte de Unity falle, de modo que la app de la NBA no se vea afectada y pueda controlar de forma segura el cierre de Unity sin fallar.

  • La decisión nos obliga a adoptar un sistema de comunicación entre procesos más sólido.

  • Como las actividades se encuentran en procesos diferentes, no hay memoria compartida entre ellas, y muchos métodos de IPC comunes de Android no funcionan o no cumplen con los requisitos.

  • Usamos el lenguaje de definición de la interfaz de Android, o AIDL, para comunicarnos entre la actividad principal de la NBA y nuestra actividad de Unity incorporada.

  • Luego, la comunicación entre Android y Unity también se basa en interfaces, lo que hace uso de la AndroidJavaProxy integrada en Unity para registrar una interfaz de Kotlin en una implementación de interfaz de C#.

  • Nuestro módulo de comunicaciones se enfoca principalmente en lo que llamamos el puente de actividades.

    // 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) }
    }
    }
  • Ambas actividades tienen acceso al puente de actividades y a los métodos de utilidad que contiene.

  • El puente contiene una referencia a un servicio que implementa las interfaces de AIDL que definimos y también proporciona una forma sencilla para que las actividades se vinculen al proceso, perdón, al servicio, y administra internamente su ciclo de vida.

  • Para iniciar una simulación, se puede usar una devolución de llamada en la que pasamos el ID de la partida como parámetro.

  • Luego, el puente lo pasa a través de AIDL y, luego, a la app de Unity, donde C# recibe un comando y comienza a configurar la escena.

    public class NativeInterfaceListener : AndroidJavaProxy {
    [UsedImplicitly]
    public void startMatch(string matchID) {
       UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
    }
    }
  • Debemos esperar a que Unity esté activo y listo para presionar los comandos que le pasamos.

  • La actividad de Unity que une la aplicación estará activa antes que Unity.

  • En el caso de los comandos que se envían demasiado pronto, los almacenamos en una cola de devolución de llamada.

    fun onStartMatchClickedInternal() {
       if (isServiceBound) {
           AIDLBridgeService.startMatch("match_id")
       } else {
           delayedCallbacksQueue.add(action)
       }
    }
  • Una vez que Unity termina de cargar el inicio y de inicializarse, y estamos en un estado que consideramos seguro, nos comunicamos desde C# para indicar que Unity está listo.

    // After bootload and initialization
    
    using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
    using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
    instance.Call("onUnityReady");
  • Después, recorremos nuestra cola y reenviamos todos los comandos almacenados.

    @Suppress("unused") //  Unity implicit call
    fun onUnityReady() {
       bridge.activityBridgeServiceInternal?.onUnityReady()
    }
    
    delayedCallbacksQueue.forEach { it(activityBridgeService) }
  • Este diagrama ilustra mejor cómo funciona nuestra comunicación entre procesos.

  • Como puedes ver, con el puente entre actividades, podemos controlar las devoluciones de llamada que mencionamos.

  • Configuramos StateFlows que se actualizan desde Unity para supervisar valores como el estado de autenticación o la marca de tiempo del estado actual de la partida.

  • Además, también administramos la encriptación de las credenciales y reenviamos los errores de Unity para que el NBA pueda depurar con mayor facilidad y decidir cómo manejarlos mejor a nivel de la aplicación.

  • Esto es todo de mi parte.

  • Ahora, le daré la palabra a Josh para que hable más sobre cómo integran el paquete de Unity de Court View en la app de la NBA.

  • Usamos estas dependencias de Gradle para integrar Unity como biblioteca con nuestra app de Jetpack Compose: XR Runtime para acceder a la sesión en 3D, Jetpack SceneCore para alojar nuestra actividad de Unity y activar o desactivar entornos espaciales, y Jetpack Compose para XR para acceder a paneles espaciales en nuestro código de 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")
    }
  • A continuación, veamos cómo renderizar Court View en 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)
            }
        }
    }
  • Antes de lanzar la vista de la cancha, te llevamos al espacio completo.

  • Esto es necesario para mostrar el panel espacial que contiene Unity.

  • Luego, enviamos la información del juego a través de AIDL para iniciar la vista de la cancha de un juego específico.

  • Luego, se inicia Unity.

  • Nuestro archivo de Android envía esto como un intent a una actividad de Unity, y lo unimos en un SpatialActivityPanelController.

  • Desde allí, solo pasamos ese controlador a un SpatialActivityPanel.

  • Este panel es el que contiene la vista de la cancha y se comparte en nuestro subespacio con nuestro panel espacial principal.

  • Esto nos permite controlar ambos al mismo tiempo sin problemas.

  • Eso es todo.

  • Le devolveré la palabra a Luke.

Conclusión y recursos

  • Muchas gracias.

  • Como puedes ver, la NBA pudo acelerar Court View dividiendo el desarrollo de Unity y Jetpack Compose.

  • Para obtener más información sobre cómo comenzar a usar los contenedores espaciales y usarlos en tus propias apps para Android XR, consulta la documentación para desarrolladores en developer.android.com/develop/xr.

  • Muchas gracias por acompañarnos hoy. Ya queremos ver tus ideas en acción.

Código

Fragmentos de código

Fragmento de secuencia de comandos de Kotlin en el minuto 9:30 de "Comunicación entre Kotlin y Godot"
androidResources {
     ignoreAssetsPattern = "!.svn:!.git:!.gitignore:!.ds_store:!*.scc:<dir>_*:!CVS:!*~:!android:!export_presets.cfg"
 }
Fragmento de secuencia de comandos de Kotlin en el minuto 10:27 de "Comunicación entre Kotlin y 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")
Fragmento de Kotlin en el minuto 10:48 de "Renderiza Godot en 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 }
    )
}
Fragmento de Kotlin en el minuto 10:55 de "Renderiza Godot en Jetpack Compose"
Subspace {
        SpatialPanel(
            modifier = SubspaceModifier
                .width(960.dp)
                .height(540.dp)
                .offset(z = (-200).dp)
                .movable()
,
    ) {
        GodotView(
            godotHost = godotHost,
            modifier = Modifier.fillMaxSize(),
        )
    }
}
Fragmento de Kotlin en el minuto 11:05 de "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)
       }
   }

}
Fragmento de Kotlin en el minuto 11:29 de "Renderiza Godot en Jetpack Compose"
class MainActivity : ComponentActivity(), GodotHost {
    override fun getHostPlugins(engine: Godot): Set<GodotPlugin> =
        setOf(SoccerGodotPlugin(engine))
}
Fragmento de Kotlin en el minuto 11:52 de "Renderiza Godot en 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),
    )

}
Fragmento de texto en el minuto 12:01 de "Renderiza Godot en 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)
Fragmento de Kotlin en el minuto 12:10 de "Renderiza Godot en Jetpack Compose"
class SoccerGodotPlugin(godot: Godot) : GodotPlugin(godot) {

    fun sendCommandToGodot(command: String) {
        emitSignal("match_command", command)
    }
}
Fragmento de texto en el minuto 12:20 de "Renderiza Godot en Jetpack Compose"
func _on_goal_scored(minute: int, home: int, away: int) -> void:

    if soccer_plugin:
        soccer_plugin.onMatchEvent("GOAL", minute, home, away)
Fragmento de Kotlin en el minuto 12:30 de "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)
        }
    }
Fragmento de Kotlin en el minuto 14:35 de "Renderiza Godot en 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, ...) }
}
Fragmento de Kotlin en el minuto 14:57 de "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
       )
   }
Fragmento de XML en el minuto 15:22 de "Renderiza Godot en 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="" />
Fragmento de secuencia de comandos de Kotlin en el minuto 20:41 de "Integración de Unity como biblioteca"
// 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")
}
}
Fragmento de Kotlin en el minuto 22:11 de "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) }
}
}
Fragmento de C# en el minuto 22:36 de "Cómo integrar Unity como biblioteca"
public class NativeInterfaceListener : AndroidJavaProxy {
[UsedImplicitly]
public void startMatch(string matchID) {
   UnityThreadHelper.InvokeOnMain(() => _internalAPI.StartMatch(matchID));
}
}
Fragmento de Kotlin en el minuto 22:54 de "Integrating Unity as a library"
fun onStartMatchClickedInternal() {
   if (isServiceBound) {
       AIDLBridgeService.startMatch("match_id")
   } else {
       delayedCallbacksQueue.add(action)
   }
}
Fragmento de C# en el minuto 22:57 de "Integración de Unity como biblioteca"
// After bootload and initialization

using AndroidJavaClass nativeBridge = new("com.company.UnityActivity");
using AndroidJavaObject instance = nativeBridge.GetStatic<AndroidJavaObject>("instance");
instance.Call("onUnityReady");
Fragmento de Kotlin en el minuto 23:06 de "Cómo integrar Unity como biblioteca"
@Suppress("unused") //  Unity implicit call
fun onUnityReady() {
   bridge.activityBridgeServiceInternal?.onUnityReady()
}

delayedCallbacksQueue.forEach { it(activityBridgeService) }
Fragmento de secuencia de comandos de Kotlin en el minuto 23:50 de "Cómo integrar Unity como biblioteca"
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")
}
Fragmento de Kotlin en el minuto 24:10 de "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)
        }
    }
}